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/// 聊天机器人回复工具
59pub struct ChatbotReplier {
60    http_client: HttpClient,
61    token_manager: Arc<TokenManager>,
62    client_id: String,
63}
64
65impl ChatbotReplier {
66    /// 创建新的 `ChatbotReplier`
67    pub fn new(
68        http_client: HttpClient,
69        token_manager: Arc<TokenManager>,
70        client_id: String,
71    ) -> Self {
72        Self {
73            http_client,
74            token_manager,
75            client_id,
76        }
77    }
78
79    /// 通过 session webhook 回复文本消息
80    pub async fn reply_text(
81        &self,
82        text: &str,
83        incoming_message: &ChatbotMessage,
84    ) -> crate::Result<serde_json::Value> {
85        let webhook = incoming_message
86            .session_webhook
87            .as_deref()
88            .ok_or_else(|| crate::Error::Handler("session_webhook is empty".to_owned()))?;
89        let body = serde_json::json!({
90            "msgtype": "text",
91            "text": {"content": text},
92            "at": {"atUserIds": [incoming_message.sender_staff_id.as_deref().unwrap_or("")]}
93        });
94        self.http_client.post_json(webhook, &body, None).await
95    }
96
97    /// 通过 session webhook 回复 markdown 消息
98    pub async fn reply_markdown(
99        &self,
100        title: &str,
101        text: &str,
102        incoming_message: &ChatbotMessage,
103    ) -> crate::Result<serde_json::Value> {
104        let webhook = incoming_message
105            .session_webhook
106            .as_deref()
107            .ok_or_else(|| crate::Error::Handler("session_webhook is empty".to_owned()))?;
108        let body = serde_json::json!({
109            "msgtype": "markdown",
110            "markdown": {"title": title, "text": text},
111            "at": {"atUserIds": [incoming_message.sender_staff_id.as_deref().unwrap_or("")]}
112        });
113        self.http_client.post_json(webhook, &body, None).await
114    }
115
116    /// 发送互动卡片 (OpenAPI)
117    pub async fn reply_card(
118        &self,
119        card_data: &serde_json::Value,
120        incoming_message: &ChatbotMessage,
121        at_sender: bool,
122        at_all: bool,
123    ) -> crate::Result<String> {
124        let access_token = self.token_manager.get_access_token().await?;
125        let card_biz_id = CardReplier::gen_card_id(incoming_message);
126        let mut body = serde_json::json!({
127            "cardTemplateId": "StandardCard",
128            "robotCode": self.client_id,
129            "cardData": serde_json::to_string(card_data).unwrap_or_default(),
130            "sendOptions": {},
131            "cardBizId": card_biz_id,
132        });
133        let Some(body_obj) = body.as_object_mut() else {
134            return Ok(card_biz_id);
135        };
136        if incoming_message.conversation_type.as_deref() == Some("2") {
137            body_obj.insert(
138                "openConversationId".to_owned(),
139                serde_json::json!(incoming_message.conversation_id),
140            );
141        } else if incoming_message.conversation_type.as_deref() == Some("1") {
142            let receiver = serde_json::json!({"userId": incoming_message.sender_staff_id});
143            body_obj.insert(
144                "singleChatReceiver".to_owned(),
145                serde_json::Value::String(serde_json::to_string(&receiver).unwrap_or_default()),
146            );
147        }
148        if let Some(send_options) = body_obj
149            .get_mut("sendOptions")
150            .and_then(|v| v.as_object_mut())
151        {
152            send_options.insert("atAll".to_owned(), serde_json::json!(at_all));
153            if at_sender {
154                let user_list = serde_json::json!([{"nickName": incoming_message.sender_nick, "userId": incoming_message.sender_staff_id}]);
155                send_options.insert(
156                    "atUserListJson".to_owned(),
157                    serde_json::Value::String(
158                        serde_json::to_string(&user_list).unwrap_or_default(),
159                    ),
160                );
161            }
162        }
163        let url = format!(
164            "{}/v1.0/im/v1.0/robot/interactiveCards/send",
165            self.http_client.openapi_endpoint()
166        );
167        self.http_client
168            .post_json(&url, &body, Some(&access_token))
169            .await?;
170        Ok(card_biz_id)
171    }
172
173    /// 更新互动卡片
174    pub async fn update_card(
175        &self,
176        card_biz_id: &str,
177        card_data: &serde_json::Value,
178    ) -> crate::Result<serde_json::Value> {
179        let access_token = self.token_manager.get_access_token().await?;
180        let body = serde_json::json!({"cardBizId": card_biz_id, "cardData": serde_json::to_string(card_data).unwrap_or_default()});
181        let url = format!(
182            "{}/v1.0/im/robots/interactiveCards",
183            self.http_client.openapi_endpoint()
184        );
185        self.http_client
186            .put_json(&url, &body, Some(&access_token))
187            .await
188    }
189
190    fn make_card_replier(&self, incoming_message: &ChatbotMessage) -> CardReplier {
191        CardReplier::new(
192            self.http_client.clone(),
193            Arc::clone(&self.token_manager),
194            self.client_id.clone(),
195            incoming_message.clone(),
196        )
197    }
198
199    fn make_ai_card_replier(&self, incoming_message: &ChatbotMessage) -> AICardReplier {
200        AICardReplier::new(self.make_card_replier(incoming_message))
201    }
202
203    /// 创建 AI 卡片回复器,用于自定义模板场景
204    pub fn create_ai_card_replier(&self, incoming_message: &ChatbotMessage) -> AICardReplier {
205        self.make_ai_card_replier(incoming_message)
206    }
207
208    /// 回复 Markdown 卡片
209    pub async fn reply_markdown_card(
210        &self,
211        markdown: &str,
212        incoming_message: &ChatbotMessage,
213        title: &str,
214        logo: &str,
215        at_sender: bool,
216        at_all: bool,
217    ) -> crate::Result<MarkdownCardInstance> {
218        let mut instance = MarkdownCardInstance::new(self.make_card_replier(incoming_message));
219        instance.set_title_and_logo(title, logo);
220        instance
221            .reply(markdown, at_sender, at_all, None, true)
222            .await?;
223        Ok(instance)
224    }
225
226    /// 回复 RPA 插件卡片
227    #[allow(clippy::too_many_arguments)]
228    pub async fn reply_rpa_plugin_card(
229        &self,
230        incoming_message: &ChatbotMessage,
231        plugin_id: &str,
232        plugin_version: &str,
233        plugin_name: &str,
234        ability_name: &str,
235        plugin_args: &serde_json::Value,
236        goal: &str,
237        corp_id: &str,
238        recipients: Option<&[String]>,
239    ) -> crate::Result<RPAPluginCardInstance> {
240        let mut instance = RPAPluginCardInstance::new(self.make_ai_card_replier(incoming_message));
241        instance.set_goal(goal);
242        instance.set_corp_id(corp_id);
243        instance
244            .reply(
245                plugin_id,
246                plugin_version,
247                plugin_name,
248                ability_name,
249                plugin_args,
250                recipients,
251                true,
252            )
253            .await?;
254        Ok(instance)
255    }
256
257    /// 回复带按钮的 Markdown 卡片
258    pub async fn reply_markdown_button(
259        &self,
260        incoming_message: &ChatbotMessage,
261        markdown: &str,
262        button_list: Vec<serde_json::Value>,
263        tips: &str,
264        title: &str,
265        logo: &str,
266    ) -> crate::Result<MarkdownButtonCardInstance> {
267        let mut instance =
268            MarkdownButtonCardInstance::new(self.make_card_replier(incoming_message));
269        instance.set_title_and_logo(title, logo);
270        instance
271            .reply(markdown, button_list, tips, None, true)
272            .await?;
273        Ok(instance)
274    }
275
276    /// 回复带按钮的 AI Markdown 卡片
277    #[allow(clippy::too_many_arguments)]
278    pub async fn reply_ai_markdown_button(
279        &self,
280        incoming_message: &ChatbotMessage,
281        markdown: &str,
282        button_list: Vec<serde_json::Value>,
283        tips: &str,
284        title: &str,
285        logo: &str,
286        recipients: Option<&[String]>,
287        support_forward: bool,
288    ) -> crate::Result<AIMarkdownCardInstance> {
289        let mut instance = AIMarkdownCardInstance::new(self.make_ai_card_replier(incoming_message));
290        instance.set_title_and_logo(title, logo);
291        instance.ai_start(recipients, support_forward).await?;
292        instance.ai_streaming(markdown, true).await?;
293        instance
294            .ai_finish(Some(markdown), Some(button_list), tips)
295            .await?;
296        Ok(instance)
297    }
298
299    /// 回复轮播图卡片
300    pub async fn reply_carousel_card(
301        &self,
302        incoming_message: &ChatbotMessage,
303        markdown: &str,
304        image_slider: &[(String, String)],
305        button_text: &str,
306        title: &str,
307        logo: &str,
308    ) -> crate::Result<CarouselCardInstance> {
309        let mut instance = CarouselCardInstance::new(self.make_ai_card_replier(incoming_message));
310        instance.set_title_and_logo(title, logo);
311        instance
312            .reply(markdown, image_slider, button_text, None, true)
313            .await?;
314        Ok(instance)
315    }
316
317    /// 发起 AI 卡片
318    pub async fn ai_markdown_card_start(
319        &self,
320        incoming_message: &ChatbotMessage,
321        title: &str,
322        logo: &str,
323        recipients: Option<&[String]>,
324    ) -> crate::Result<AIMarkdownCardInstance> {
325        let mut instance = AIMarkdownCardInstance::new(self.make_ai_card_replier(incoming_message));
326        instance.set_title_and_logo(title, logo);
327        instance.ai_start(recipients, true).await?;
328        Ok(instance)
329    }
330
331    /// 从消息中提取文本列表
332    pub fn extract_text(incoming_message: &ChatbotMessage) -> Option<Vec<String>> {
333        incoming_message.get_text_list()
334    }
335
336    /// 从消息中提取图片,下载后重新上传到钉钉
337    pub async fn extract_and_reupload_images(
338        &self,
339        incoming_message: &ChatbotMessage,
340    ) -> crate::Result<Vec<String>> {
341        let image_list = match incoming_message.get_image_list() {
342            Some(list) if !list.is_empty() => list,
343            _ => return Ok(Vec::new()),
344        };
345        let mut media_ids = Vec::new();
346        for download_code in &image_list {
347            let download_url = self.get_image_download_url(download_code).await?;
348            let image_bytes = self.http_client.get_bytes(&download_url).await?;
349            let media_id = self
350                .upload_to_dingtalk(&image_bytes, "image", "image.png", "image/png")
351                .await?;
352            media_ids.push(media_id);
353        }
354        Ok(media_ids)
355    }
356
357    /// 根据 download_code 获取图片下载 URL
358    pub async fn get_image_download_url(&self, download_code: &str) -> crate::Result<String> {
359        let access_token = self.token_manager.get_access_token().await?;
360        let body = serde_json::json!({"robotCode": self.client_id, "downloadCode": download_code});
361        let url = format!(
362            "{}/v1.0/robot/messageFiles/download",
363            self.http_client.openapi_endpoint()
364        );
365        let resp: serde_json::Value = self
366            .http_client
367            .post_json(&url, &body, Some(&access_token))
368            .await?;
369        resp.get("downloadUrl")
370            .and_then(|v| v.as_str())
371            .map(String::from)
372            .ok_or_else(|| crate::Error::Handler("downloadUrl not found".to_owned()))
373    }
374
375    /// 上传文件到钉钉
376    pub async fn upload_to_dingtalk(
377        &self,
378        content: &[u8],
379        filetype: &str,
380        filename: &str,
381        mimetype: &str,
382    ) -> crate::Result<String> {
383        let access_token = self.token_manager.get_access_token().await?;
384        self.http_client
385            .upload_file(&access_token, content, filetype, filename, mimetype)
386            .await
387    }
388
389    /// 设置离线提示词
390    pub async fn set_off_duty_prompt(
391        &self,
392        text: &str,
393        title: &str,
394        logo: &str,
395    ) -> crate::Result<serde_json::Value> {
396        let access_token = self.token_manager.get_access_token().await?;
397        let title = if title.is_empty() {
398            "钉钉Stream机器人"
399        } else {
400            title
401        };
402        let logo = if logo.is_empty() {
403            "@lALPDfJ6V_FPDmvNAfTNAfQ"
404        } else {
405            logo
406        };
407        let prompt_card_data = generate_multi_text_line_card_data(title, logo, &[text]);
408        let body = serde_json::json!({
409            "robotCode": self.client_id,
410            "cardData": serde_json::to_string(&prompt_card_data).unwrap_or_default(),
411            "cardTemplateId": "StandardCard",
412        });
413        let url = format!(
414            "{}/v1.0/innerApi/robot/stream/away/template/update",
415            self.http_client.openapi_endpoint()
416        );
417        self.http_client
418            .post_json(&url, &body, Some(&access_token))
419            .await
420    }
421}