Skip to main content

dingtalk_stream/handlers/
chatbot.rs

1//! 聊天机器人处理器,对齐 Python chatbot.py
2
3use crate::card::instances::{
4    AIMarkdownCardInstance, CarouselCardInstance, MarkdownButtonCardInstance, MarkdownCardInstance,
5    RPAPluginCardInstance,
6};
7use crate::card::replier::{AICardReplier, CardReplier};
8use crate::card::templates::generate_multi_text_line_card_data;
9use crate::handlers::callback::CallbackHandler;
10use crate::messages::chatbot::ChatbotMessage;
11use crate::messages::frames::{AckMessage, Headers, MessageBody};
12use crate::transport::http::HttpClient;
13use crate::transport::token::TokenManager;
14use async_trait::async_trait;
15use std::sync::Arc;
16
17/// 聊天机器人处理器 trait
18#[async_trait]
19pub trait ChatbotHandler: CallbackHandler {}
20
21/// 异步聊天机器人处理器(立即 ACK + 后台处理)
22pub trait AsyncChatbotHandler: Send + Sync + 'static {
23    /// 用户实现此方法处理消息(非 async)
24    fn process(&self, callback_message: &MessageBody);
25    /// 启动前的初始化
26    fn pre_start(&self) {}
27}
28
29/// 为 `AsyncChatbotHandler` 生成立即 ACK + 后台处理
30pub(crate) async fn async_raw_process(
31    handler: Arc<dyn AsyncChatbotHandler>,
32    callback_message: MessageBody,
33) -> AckMessage {
34    let message_id = callback_message.headers.message_id.clone();
35    let data = callback_message.data.clone();
36
37    tokio::spawn(async move {
38        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
39            handler.process(&callback_message);
40        }));
41        if let Err(e) = result {
42            tracing::error!("AsyncChatbotHandler.process panicked: {:?}", e);
43        }
44    });
45
46    AckMessage {
47        code: AckMessage::STATUS_OK,
48        headers: Headers {
49            message_id,
50            content_type: Some(Headers::CONTENT_TYPE_APPLICATION_JSON.to_owned()),
51            ..Default::default()
52        },
53        message: "ok".to_owned(),
54        data,
55    }
56}
57
58/// 聊天机器人回复工具
59#[derive(Clone)]
60pub struct ChatbotReplier {
61    http_client: HttpClient,
62    token_manager: Arc<TokenManager>,
63    client_id: String,
64}
65
66impl ChatbotReplier {
67    /// 创建新的 `ChatbotReplier`
68    pub fn new(
69        http_client: HttpClient,
70        token_manager: Arc<TokenManager>,
71        client_id: String,
72    ) -> Self {
73        Self {
74            http_client,
75            token_manager,
76            client_id,
77        }
78    }
79
80    /// 通过 session webhook 回复文本消息
81    pub async fn reply_text(
82        &self,
83        text: &str,
84        incoming_message: &ChatbotMessage,
85    ) -> crate::Result<serde_json::Value> {
86        let webhook = incoming_message
87            .session_webhook
88            .as_deref()
89            .ok_or_else(|| crate::Error::Handler("session_webhook is empty".to_owned()))?;
90        let body = serde_json::json!({
91            "msgtype": "text",
92            "text": {"content": text},
93            "at": {"atUserIds": [incoming_message.sender_staff_id.as_deref().unwrap_or("")]}
94        });
95        self.http_client.post_json(webhook, &body, None).await
96    }
97
98    /// 通过 session webhook 回复 markdown 消息
99    pub async fn reply_markdown(
100        &self,
101        title: &str,
102        text: &str,
103        incoming_message: &ChatbotMessage,
104    ) -> crate::Result<serde_json::Value> {
105        let webhook = incoming_message
106            .session_webhook
107            .as_deref()
108            .ok_or_else(|| crate::Error::Handler("session_webhook is empty".to_owned()))?;
109        let body = serde_json::json!({
110            "msgtype": "markdown",
111            "markdown": {"title": title, "text": text},
112            "at": {"atUserIds": [incoming_message.sender_staff_id.as_deref().unwrap_or("")]}
113        });
114        self.http_client.post_json(webhook, &body, None).await
115    }
116
117    /// 发送互动卡片 (OpenAPI)
118    pub async fn reply_card(
119        &self,
120        card_data: &serde_json::Value,
121        incoming_message: &ChatbotMessage,
122        at_sender: bool,
123        at_all: bool,
124    ) -> crate::Result<String> {
125        let access_token = self.token_manager.get_access_token().await?;
126        let card_biz_id = CardReplier::gen_card_id(incoming_message);
127        let mut body = serde_json::json!({
128            "cardTemplateId": "StandardCard",
129            "robotCode": self.client_id,
130            "cardData": serde_json::to_string(card_data).unwrap_or_default(),
131            "sendOptions": {},
132            "cardBizId": card_biz_id,
133        });
134        let Some(body_obj) = body.as_object_mut() else {
135            return Ok(card_biz_id);
136        };
137        if incoming_message.conversation_type.as_deref() == Some("2") {
138            body_obj.insert(
139                "openConversationId".to_owned(),
140                serde_json::json!(incoming_message.conversation_id),
141            );
142        } else if incoming_message.conversation_type.as_deref() == Some("1") {
143            let receiver = serde_json::json!({"userId": incoming_message.sender_staff_id});
144            body_obj.insert(
145                "singleChatReceiver".to_owned(),
146                serde_json::Value::String(serde_json::to_string(&receiver).unwrap_or_default()),
147            );
148        }
149        if let Some(send_options) = body_obj
150            .get_mut("sendOptions")
151            .and_then(|v| v.as_object_mut())
152        {
153            send_options.insert("atAll".to_owned(), serde_json::json!(at_all));
154            if at_sender {
155                let user_list = serde_json::json!([{"nickName": incoming_message.sender_nick, "userId": incoming_message.sender_staff_id}]);
156                send_options.insert(
157                    "atUserListJson".to_owned(),
158                    serde_json::Value::String(
159                        serde_json::to_string(&user_list).unwrap_or_default(),
160                    ),
161                );
162            }
163        }
164        let url = format!(
165            "{}/v1.0/im/v1.0/robot/interactiveCards/send",
166            self.http_client.openapi_endpoint()
167        );
168        self.http_client
169            .post_json(&url, &body, Some(&access_token))
170            .await?;
171        Ok(card_biz_id)
172    }
173
174    /// 更新互动卡片
175    pub async fn update_card(
176        &self,
177        card_biz_id: &str,
178        card_data: &serde_json::Value,
179    ) -> crate::Result<serde_json::Value> {
180        let access_token = self.token_manager.get_access_token().await?;
181        let body = serde_json::json!({"cardBizId": card_biz_id, "cardData": serde_json::to_string(card_data).unwrap_or_default()});
182        let url = format!(
183            "{}/v1.0/im/robots/interactiveCards",
184            self.http_client.openapi_endpoint()
185        );
186        self.http_client
187            .put_json(&url, &body, Some(&access_token))
188            .await
189    }
190
191    fn make_card_replier(&self, incoming_message: &ChatbotMessage) -> CardReplier {
192        CardReplier::new(
193            self.http_client.clone(),
194            Arc::clone(&self.token_manager),
195            self.client_id.clone(),
196            incoming_message.clone(),
197        )
198    }
199
200    fn make_ai_card_replier(&self, incoming_message: &ChatbotMessage) -> AICardReplier {
201        AICardReplier::new(self.make_card_replier(incoming_message))
202    }
203
204    /// 创建 AI 卡片回复器,用于自定义模板场景
205    pub fn create_ai_card_replier(&self, incoming_message: &ChatbotMessage) -> AICardReplier {
206        self.make_ai_card_replier(incoming_message)
207    }
208
209    /// 回复 Markdown 卡片
210    pub async fn reply_markdown_card(
211        &self,
212        markdown: &str,
213        incoming_message: &ChatbotMessage,
214        title: &str,
215        logo: &str,
216        at_sender: bool,
217        at_all: bool,
218    ) -> crate::Result<MarkdownCardInstance> {
219        let mut instance = MarkdownCardInstance::new(self.make_card_replier(incoming_message));
220        instance.set_title_and_logo(title, logo);
221        instance
222            .reply(markdown, at_sender, at_all, None, true)
223            .await?;
224        Ok(instance)
225    }
226
227    /// 回复 RPA 插件卡片
228    #[allow(clippy::too_many_arguments)]
229    pub async fn reply_rpa_plugin_card(
230        &self,
231        incoming_message: &ChatbotMessage,
232        plugin_id: &str,
233        plugin_version: &str,
234        plugin_name: &str,
235        ability_name: &str,
236        plugin_args: &serde_json::Value,
237        goal: &str,
238        corp_id: &str,
239        recipients: Option<&[String]>,
240    ) -> crate::Result<RPAPluginCardInstance> {
241        let mut instance = RPAPluginCardInstance::new(self.make_ai_card_replier(incoming_message));
242        instance.set_goal(goal);
243        instance.set_corp_id(corp_id);
244        instance
245            .reply(
246                plugin_id,
247                plugin_version,
248                plugin_name,
249                ability_name,
250                plugin_args,
251                recipients,
252                true,
253            )
254            .await?;
255        Ok(instance)
256    }
257
258    /// 回复带按钮的 Markdown 卡片
259    pub async fn reply_markdown_button(
260        &self,
261        incoming_message: &ChatbotMessage,
262        markdown: &str,
263        button_list: Vec<serde_json::Value>,
264        tips: &str,
265        title: &str,
266        logo: &str,
267    ) -> crate::Result<MarkdownButtonCardInstance> {
268        let mut instance =
269            MarkdownButtonCardInstance::new(self.make_card_replier(incoming_message));
270        instance.set_title_and_logo(title, logo);
271        instance
272            .reply(markdown, button_list, tips, None, true)
273            .await?;
274        Ok(instance)
275    }
276
277    /// 回复带按钮的 AI Markdown 卡片
278    #[allow(clippy::too_many_arguments)]
279    pub async fn reply_ai_markdown_button(
280        &self,
281        incoming_message: &ChatbotMessage,
282        markdown: &str,
283        button_list: Vec<serde_json::Value>,
284        tips: &str,
285        title: &str,
286        logo: &str,
287        recipients: Option<&[String]>,
288        support_forward: bool,
289    ) -> crate::Result<AIMarkdownCardInstance> {
290        let mut instance = AIMarkdownCardInstance::new(self.make_ai_card_replier(incoming_message));
291        instance.set_title_and_logo(title, logo);
292        instance.ai_start(recipients, support_forward).await?;
293        instance.ai_streaming(markdown, true).await?;
294        instance
295            .ai_finish(Some(markdown), Some(button_list), tips)
296            .await?;
297        Ok(instance)
298    }
299
300    /// 回复轮播图卡片
301    pub async fn reply_carousel_card(
302        &self,
303        incoming_message: &ChatbotMessage,
304        markdown: &str,
305        image_slider: &[(String, String)],
306        button_text: &str,
307        title: &str,
308        logo: &str,
309    ) -> crate::Result<CarouselCardInstance> {
310        let mut instance = CarouselCardInstance::new(self.make_ai_card_replier(incoming_message));
311        instance.set_title_and_logo(title, logo);
312        instance
313            .reply(markdown, image_slider, button_text, None, true)
314            .await?;
315        Ok(instance)
316    }
317
318    /// 发起 AI 卡片
319    pub async fn ai_markdown_card_start(
320        &self,
321        incoming_message: &ChatbotMessage,
322        title: &str,
323        logo: &str,
324        recipients: Option<&[String]>,
325    ) -> crate::Result<AIMarkdownCardInstance> {
326        let mut instance = AIMarkdownCardInstance::new(self.make_ai_card_replier(incoming_message));
327        instance.set_title_and_logo(title, logo);
328        instance.ai_start(recipients, true).await?;
329        Ok(instance)
330    }
331
332    /// 从消息中提取文本列表
333    pub fn extract_text(incoming_message: &ChatbotMessage) -> Option<Vec<String>> {
334        incoming_message.get_text_list()
335    }
336
337    /// 从消息中提取图片,下载后重新上传到钉钉
338    pub async fn extract_and_reupload_images(
339        &self,
340        incoming_message: &ChatbotMessage,
341    ) -> crate::Result<Vec<String>> {
342        let image_list = match incoming_message.get_image_list() {
343            Some(list) if !list.is_empty() => list,
344            _ => return Ok(Vec::new()),
345        };
346        let mut media_ids = Vec::new();
347        for download_code in &image_list {
348            let download_url = self.get_image_download_url(download_code).await?;
349            let image_bytes = self.http_client.get_bytes(&download_url).await?;
350            let media_id = self
351                .upload_to_dingtalk(&image_bytes, "image", "image.png", "image/png")
352                .await?;
353            media_ids.push(media_id);
354        }
355        Ok(media_ids)
356    }
357
358    /// 根据 download_code 获取图片下载 URL
359    pub async fn get_image_download_url(&self, download_code: &str) -> crate::Result<String> {
360        let access_token = self.token_manager.get_access_token().await?;
361        let body = serde_json::json!({"robotCode": self.client_id, "downloadCode": download_code});
362        let url = format!(
363            "{}/v1.0/robot/messageFiles/download",
364            self.http_client.openapi_endpoint()
365        );
366        let resp: serde_json::Value = self
367            .http_client
368            .post_json(&url, &body, Some(&access_token))
369            .await?;
370        resp.get("downloadUrl")
371            .and_then(|v| v.as_str())
372            .map(String::from)
373            .ok_or_else(|| crate::Error::Handler("downloadUrl not found".to_owned()))
374    }
375
376    /// 上传文件到钉钉
377    pub async fn upload_to_dingtalk(
378        &self,
379        content: &[u8],
380        filetype: &str,
381        filename: &str,
382        mimetype: &str,
383    ) -> crate::Result<String> {
384        let access_token = self.token_manager.get_access_token().await?;
385        self.http_client
386            .upload_file(&access_token, content, filetype, filename, mimetype)
387            .await
388    }
389
390    // ── Rust SDK exclusive: OTO message API ──────────────────────────
391    // NOTE: This method is Rust-SDK-only and does NOT exist in the
392    // official Python SDK.  When syncing features from the Python SDK,
393    // do NOT remove this section.
394
395    /// 通过 OpenAPI 向指定用户发送单聊消息(OTO = One-To-One)。
396    ///
397    /// 对应钉钉 OpenAPI:`POST /v1.0/robot/oToMessages/batchSend`
398    ///
399    /// # Arguments
400    /// * `user_id`  – 接收者的 userId(即 staffId)
401    /// * `msg_key`  – 消息模板 key,如 `"sampleFile"`, `"sampleText"` 等
402    /// * `msg_param` – 消息模板参数的 JSON 字符串
403    pub async fn send_oto_message(
404        &self,
405        user_id: &str,
406        msg_key: &str,
407        msg_param: &str,
408    ) -> crate::Result<serde_json::Value> {
409        let access_token = self.token_manager.get_access_token().await?;
410        let body = serde_json::json!({
411            "robotCode": self.client_id,
412            "userIds": [user_id],
413            "msgKey": msg_key,
414            "msgParam": msg_param,
415        });
416        let url = format!(
417            "{}/v1.0/robot/oToMessages/batchSend",
418            self.http_client.openapi_endpoint()
419        );
420        self.http_client
421            .post_json(&url, &body, Some(&access_token))
422            .await
423    }
424
425    // ── Rust SDK exclusive: download helpers ─────────────────────────
426    // NOTE: These methods are Rust-SDK-only and do NOT exist in the
427    // official Python SDK.  When syncing features from the Python SDK,
428    // do NOT remove this section.
429
430    /// 下载文件字节内容(无大小限制)
431    pub async fn download_bytes(&self, url: &str) -> crate::Result<Vec<u8>> {
432        self.http_client.get_bytes(url).await
433    }
434
435    /// 下载文件字节内容(带大小限制)
436    ///
437    /// 先检查响应的 `Content-Length` 头,超过 `max_size` 则直接拒绝;
438    /// 下载过程中累计检查已读字节数,超限则中止。
439    pub async fn download_bytes_with_limit(
440        &self,
441        url: &str,
442        max_size: u64,
443    ) -> crate::Result<Vec<u8>> {
444        self.http_client.get_bytes_with_limit(url, max_size).await
445    }
446
447    /// 设置离线提示词
448    pub async fn set_off_duty_prompt(
449        &self,
450        text: &str,
451        title: &str,
452        logo: &str,
453    ) -> crate::Result<serde_json::Value> {
454        let access_token = self.token_manager.get_access_token().await?;
455        let title = if title.is_empty() {
456            "钉钉Stream机器人"
457        } else {
458            title
459        };
460        let logo = if logo.is_empty() {
461            "@lALPDfJ6V_FPDmvNAfTNAfQ"
462        } else {
463            logo
464        };
465        let prompt_card_data = generate_multi_text_line_card_data(title, logo, &[text]);
466        let body = serde_json::json!({
467            "robotCode": self.client_id,
468            "cardData": serde_json::to_string(&prompt_card_data).unwrap_or_default(),
469            "cardTemplateId": "StandardCard",
470        });
471        let url = format!(
472            "{}/v1.0/innerApi/robot/stream/away/template/update",
473            self.http_client.openapi_endpoint()
474        );
475        self.http_client
476            .post_json(&url, &body, Some(&access_token))
477            .await
478    }
479}