Skip to main content

microclaw_engine/tools/
send_message.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use serde_json::json;
6use tracing::{info, warn};
7
8use super::{authorize_chat_access, schema_object, Tool, ToolResult};
9use crate::internal::channels::channel::{
10    deliver_and_store_bot_message, enforce_channel_policy, get_required_chat_routing,
11};
12use crate::internal::channels::channel_adapter::ChannelRegistry;
13use crate::internal::storage::db::{call_blocking, Database, StoredMessage};
14use crate::internal::tool_runtime::runtime::auth_context_from_input;
15use microclaw_core::llm_types::ToolDefinition;
16
17pub struct SendMessageTool {
18    registry: Arc<ChannelRegistry>,
19    db: Arc<Database>,
20    default_bot_username: String,
21    channel_bot_usernames: std::collections::HashMap<String, String>,
22}
23
24impl SendMessageTool {
25    pub fn new(
26        registry: Arc<ChannelRegistry>,
27        db: Arc<Database>,
28        default_bot_username: String,
29        channel_bot_usernames: std::collections::HashMap<String, String>,
30    ) -> Self {
31        SendMessageTool {
32            registry,
33            db,
34            default_bot_username,
35            channel_bot_usernames,
36        }
37    }
38
39    fn bot_username_for_channel(&self, channel_name: &str) -> String {
40        self.channel_bot_usernames
41            .get(channel_name)
42            .cloned()
43            .unwrap_or_else(|| self.default_bot_username.clone())
44    }
45
46    async fn store_bot_message(
47        &self,
48        chat_id: i64,
49        sender_name: String,
50        content: String,
51    ) -> Result<(), String> {
52        let msg = StoredMessage {
53            id: uuid::Uuid::new_v4().to_string(),
54            chat_id,
55            sender_name,
56            content,
57            is_from_bot: true,
58            timestamp: chrono::Utc::now().to_rfc3339(),
59        };
60        call_blocking(self.db.clone(), move |db| db.store_message(&msg))
61            .await
62            .map_err(|e| format!("Failed to store sent message: {e}"))
63    }
64
65    async fn resolve_external_chat_id(&self, chat_id: i64) -> Result<String, String> {
66        let external = call_blocking(self.db.clone(), move |db| db.get_chat_external_id(chat_id))
67            .await
68            .map_err(|e| format!("Failed to resolve external chat id: {e}"))?;
69        Ok(external.unwrap_or_else(|| chat_id.to_string()))
70    }
71
72    fn is_feishu_reaction_protocol_like_text(text: &str) -> bool {
73        let trimmed = text.trim();
74        if trimmed.is_empty() {
75            return false;
76        }
77
78        let lowered = trimmed.to_ascii_lowercase();
79        if lowered.starts_with("reaction-only:")
80            || lowered.starts_with("react-only:")
81            || lowered.starts_with("reaction:")
82            || lowered.starts_with("react:")
83            || lowered.starts_with("[reaction:")
84            || lowered.starts_with("[react:")
85        {
86            return true;
87        }
88
89        // Common lone reaction tokens/chars that should not be emitted as plain text via send_message.
90        matches!(
91            trimmed,
92            "👍" | "👎"
93                | "👏"
94                | "🙏"
95                | "❤️"
96                | "❤"
97                | "💔"
98                | "🔥"
99                | "🎉"
100                | "😄"
101                | "😀"
102                | "🙂"
103                | "😊"
104                | "😂"
105                | "🤣"
106                | "😭"
107                | "😢"
108                | "😡"
109                | "😠"
110                | "🤝"
111                | "🚀"
112                | "💯"
113                | "🙈"
114                | "👌"
115                | ":+1:"
116                | ":-1:"
117                | ":clap:"
118                | ":pray:"
119                | ":heart:"
120                | ":broken_heart:"
121                | ":fire:"
122                | ":tada:"
123                | ":smile:"
124                | ":joy:"
125                | ":sob:"
126                | ":rage:"
127                | "THUMBSUP"
128                | "THUMBSDOWN"
129                | "CLAP"
130                | "THANKS"
131                | "HEART"
132                | "BROKENHEART"
133                | "Fire"
134                | "PARTY"
135                | "SMILE"
136                | "TearsofJoy"
137                | "SOB"
138                | "RAGE"
139                | "FISTBUMP"
140                | "ROCKET"
141                | "100"
142                | "LetMeSee"
143                | "OK"
144                | "LOVE"
145                | "HAPPY"
146                | "WINK"
147                | "YEAH"
148                | "STRONG"
149                | "TOP"
150                | "NO1"
151        )
152    }
153}
154
155#[async_trait]
156impl Tool for SendMessageTool {
157    fn name(&self) -> &str {
158        "send_message"
159    }
160
161    fn definition(&self) -> ToolDefinition {
162        ToolDefinition {
163            name: "send_message".into(),
164            description: "Send a message mid-conversation. Supports text for all channels, and attachments for Telegram/Discord/Slack/Feishu/Weixin via attachment_path.".into(),
165            input_schema: schema_object(
166                json!({
167                    "chat_id": {
168                        "type": "integer",
169                        "description": "The target chat ID"
170                    },
171                    "text": {
172                        "type": "string",
173                        "description": "The message text to send"
174                    },
175                    "attachment_path": {
176                        "type": "string",
177                        "description": "Optional local file path to send as an attachment"
178                    },
179                    "caption": {
180                        "type": "string",
181                        "description": "Optional caption used when sending attachment"
182                    }
183                }),
184                &["chat_id"],
185            ),
186        }
187    }
188
189    async fn execute(&self, input: serde_json::Value) -> ToolResult {
190        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
191            Some(id) => id,
192            None => return ToolResult::error("Missing required parameter: chat_id".into()),
193        };
194        let text = input
195            .get("text")
196            .and_then(|v| v.as_str())
197            .unwrap_or("")
198            .trim()
199            .to_string();
200        let attachment_path = input
201            .get("attachment_path")
202            .and_then(|v| v.as_str())
203            .map(|v| v.trim().to_string())
204            .filter(|v| !v.is_empty());
205        let caption = input
206            .get("caption")
207            .and_then(|v| v.as_str())
208            .map(|v| v.trim().to_string())
209            .filter(|v| !v.is_empty());
210
211        if text.is_empty() && attachment_path.is_none() {
212            return ToolResult::error("Provide text and/or attachment_path".into());
213        }
214
215        if let Some(auth) = auth_context_from_input(&input) {
216            if (auth.caller_channel.starts_with("feishu")
217                || auth.caller_channel.starts_with("lark"))
218                && attachment_path.is_none()
219                && Self::is_feishu_reaction_protocol_like_text(&text)
220            {
221                return ToolResult::error(
222                    "Feishu guardrail: do not send reaction protocol text via send_message; return final assistant text instead.".into(),
223                )
224                .with_error_type("feishu_reaction_protocol_text");
225            }
226        }
227
228        info!(
229            "send_message start: chat_id={}, has_text={}, has_attachment={}",
230            chat_id,
231            !text.is_empty(),
232            attachment_path.is_some()
233        );
234
235        if let Err(e) = authorize_chat_access(&input, chat_id) {
236            return ToolResult::error(e);
237        }
238
239        if let Err(e) =
240            enforce_channel_policy(&self.registry, self.db.clone(), &input, chat_id).await
241        {
242            return ToolResult::error(e);
243        }
244
245        if let Some(path) = attachment_path {
246            let routing =
247                match get_required_chat_routing(&self.registry, self.db.clone(), chat_id).await {
248                    Ok(v) => v,
249                    Err(e) => return ToolResult::error(e),
250                };
251            info!(
252                "send_message attachment routing: chat_id={}, channel={}, path={}",
253                chat_id, routing.channel_name, path
254            );
255
256            let file_path = PathBuf::from(&path);
257            if !file_path.is_file() {
258                warn!(
259                    "send_message attachment missing: chat_id={}, path={}, current_dir={}",
260                    chat_id,
261                    file_path.display(),
262                    std::env::current_dir()
263                        .map(|p| p.display().to_string())
264                        .unwrap_or_else(|_| "<unknown>".to_string())
265                );
266                return ToolResult::error(format!(
267                    "attachment_path not found or not a file: {path}"
268                ));
269            }
270
271            let used_caption = caption.or_else(|| {
272                if text.is_empty() {
273                    None
274                } else {
275                    Some(text.clone())
276                }
277            });
278
279            let adapter = match self.registry.get(&routing.channel_name) {
280                Some(a) => a,
281                None => {
282                    return ToolResult::error(format!(
283                        "No adapter registered for channel '{}'",
284                        routing.channel_name
285                    ))
286                }
287            };
288
289            let external_chat_id = match self.resolve_external_chat_id(chat_id).await {
290                Ok(v) => v,
291                Err(e) => return ToolResult::error(e),
292            };
293
294            let send_result = adapter
295                .send_attachment(&external_chat_id, &file_path, used_caption.as_deref())
296                .await;
297
298            match send_result {
299                Ok(content) => {
300                    info!(
301                        "send_message attachment sent: chat_id={}, path={}",
302                        chat_id,
303                        file_path.display()
304                    );
305                    let sender_name = self.bot_username_for_channel(&routing.channel_name);
306                    if let Err(e) = self.store_bot_message(chat_id, sender_name, content).await {
307                        warn!(
308                            "send_message store_bot_message failed: chat_id={}, error={}",
309                            chat_id, e
310                        );
311                        return ToolResult::error(e);
312                    }
313                    ToolResult::success("Attachment sent successfully.".into())
314                }
315                Err(e) => {
316                    warn!(
317                        "send_message attachment delivery failed: chat_id={}, path={}, error={}",
318                        chat_id,
319                        file_path.display(),
320                        e
321                    );
322                    ToolResult::error(e)
323                }
324            }
325        } else {
326            let sender_name =
327                match get_required_chat_routing(&self.registry, self.db.clone(), chat_id).await {
328                    Ok(routing) => self.bot_username_for_channel(&routing.channel_name),
329                    Err(_) => self.default_bot_username.clone(),
330                };
331            match deliver_and_store_bot_message(
332                &self.registry,
333                self.db.clone(),
334                &sender_name,
335                chat_id,
336                &text,
337            )
338            .await
339            {
340                Ok(_) => {
341                    info!("send_message text sent: chat_id={}", chat_id);
342                    ToolResult::success("Message sent successfully.".into())
343                }
344                Err(e) => {
345                    warn!(
346                        "send_message text delivery failed: chat_id={}, error={}",
347                        chat_id, e
348                    );
349                    ToolResult::error(e)
350                }
351            }
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::internal::channels::channel::ConversationKind;
360    use crate::internal::channels::channel_adapter::ChannelAdapter;
361    use crate::internal::channels::channel_adapter::ChannelRegistry;
362    use serde_json::json;
363    use std::path::Path;
364
365    fn test_db() -> (Arc<Database>, std::path::PathBuf) {
366        let dir = std::env::temp_dir().join(format!("microclaw_sendmsg_{}", uuid::Uuid::new_v4()));
367        let db = Arc::new(Database::new(dir.to_str().unwrap()).unwrap());
368        (db, dir)
369    }
370
371    fn cleanup(dir: &std::path::Path) {
372        let _ = std::fs::remove_dir_all(dir);
373    }
374
375    fn test_registry() -> Arc<ChannelRegistry> {
376        let mut registry = ChannelRegistry::new();
377        registry.register(Arc::new(LocalOnlyAdapter { name: "web".into() }));
378        Arc::new(registry)
379    }
380
381    struct LocalOnlyAdapter {
382        name: String,
383    }
384
385    #[async_trait::async_trait]
386    impl ChannelAdapter for LocalOnlyAdapter {
387        fn name(&self) -> &str {
388            &self.name
389        }
390
391        fn chat_type_routes(&self) -> Vec<(&str, ConversationKind)> {
392            match self.name.as_str() {
393                "web" => vec![("web", ConversationKind::Private)],
394                "telegram" | "telegram.sales" => vec![("private", ConversationKind::Private)],
395                "feishu" => vec![("feishu_dm", ConversationKind::Private)],
396                _ => Vec::new(),
397            }
398        }
399
400        fn is_local_only(&self) -> bool {
401            self.name == "web"
402        }
403
404        fn allows_cross_chat(&self) -> bool {
405            self.name != "web"
406        }
407
408        async fn send_text(&self, _external_chat_id: &str, _text: &str) -> Result<(), String> {
409            Ok(())
410        }
411
412        async fn send_attachment(
413            &self,
414            _external_chat_id: &str,
415            _file_path: &Path,
416            _caption: Option<&str>,
417        ) -> Result<String, String> {
418            if self.name == "web" {
419                Err("attachments not supported for web".to_string())
420            } else {
421                Ok("attachment".to_string())
422            }
423        }
424    }
425
426    #[tokio::test]
427    async fn test_send_message_permission_denied_before_network() {
428        let (db, dir) = test_db();
429        let tool = SendMessageTool::new(
430            test_registry(),
431            db,
432            "bot".into(),
433            std::collections::HashMap::new(),
434        );
435        let result = tool
436            .execute(json!({
437                "chat_id": 200,
438                "text": "hello",
439                "__microclaw_auth": {
440                    "caller_chat_id": 100,
441                    "control_chat_ids": []
442                }
443            }))
444            .await;
445        assert!(result.is_error);
446        assert!(result.content.contains("Permission denied"));
447        cleanup(&dir);
448    }
449
450    #[tokio::test]
451    async fn test_send_message_web_target_writes_to_db() {
452        let (db, dir) = test_db();
453        db.upsert_chat(999, Some("web-main"), "web").unwrap();
454
455        let tool = SendMessageTool::new(
456            test_registry(),
457            db.clone(),
458            "bot".into(),
459            std::collections::HashMap::new(),
460        );
461        let result = tool
462            .execute(json!({
463                "chat_id": 999,
464                "text": "hello web",
465                "__microclaw_auth": {
466                    "caller_chat_id": 999,
467                    "control_chat_ids": []
468                }
469            }))
470            .await;
471        assert!(!result.is_error, "{}", result.content);
472
473        let all = db.get_all_messages(999).unwrap();
474        assert_eq!(all.len(), 1);
475        assert_eq!(all[0].content, "hello web");
476        assert!(all[0].is_from_bot);
477        cleanup(&dir);
478    }
479
480    #[tokio::test]
481    async fn test_send_message_uses_channel_account_sender_name() {
482        let (db, dir) = test_db();
483        let chat_id = db
484            .resolve_or_create_chat_id("telegram.sales", "9001", Some("sales"), "private")
485            .unwrap();
486
487        let mut registry = ChannelRegistry::new();
488        registry.register(Arc::new(LocalOnlyAdapter {
489            name: "telegram.sales".to_string(),
490        }));
491        let registry = Arc::new(registry);
492
493        let mut channel_usernames = std::collections::HashMap::new();
494        channel_usernames.insert("telegram.sales".to_string(), "sales_bot".to_string());
495        let tool = SendMessageTool::new(
496            registry,
497            db.clone(),
498            "default_bot".into(),
499            channel_usernames,
500        );
501        let result = tool
502            .execute(json!({
503                "chat_id": chat_id,
504                "text": "hello",
505                "__microclaw_auth": {
506                    "caller_chat_id": chat_id,
507                    "control_chat_ids": []
508                }
509            }))
510            .await;
511        assert!(!result.is_error, "{}", result.content);
512        let all = db.get_all_messages(chat_id).unwrap();
513        assert_eq!(all.len(), 1);
514        assert_eq!(all[0].sender_name, "sales_bot");
515        assert_eq!(all[0].content, "hello");
516        cleanup(&dir);
517    }
518
519    #[tokio::test]
520    async fn test_send_message_web_caller_cross_chat_denied() {
521        let (db, dir) = test_db();
522        db.upsert_chat(100, Some("web-main"), "web").unwrap();
523        db.upsert_chat(200, Some("tg"), "private").unwrap();
524
525        // Need telegram adapter registered for "private" chat type
526        let mut registry = ChannelRegistry::new();
527        registry.register(Arc::new(LocalOnlyAdapter { name: "web".into() }));
528        registry.register(Arc::new(LocalOnlyAdapter {
529            name: "telegram".into(),
530        }));
531        let registry = Arc::new(registry);
532
533        let tool =
534            SendMessageTool::new(registry, db, "bot".into(), std::collections::HashMap::new());
535        let result = tool
536            .execute(json!({
537                "chat_id": 200,
538                "text": "hello",
539                "__microclaw_auth": {
540                    "caller_chat_id": 100,
541                    "control_chat_ids": [100]
542                }
543            }))
544            .await;
545        assert!(result.is_error);
546        assert!(result
547            .content
548            .contains("web chats cannot operate on other chats"));
549        cleanup(&dir);
550    }
551
552    #[tokio::test]
553    async fn test_send_message_requires_text_or_attachment() {
554        let (db, dir) = test_db();
555        let tool = SendMessageTool::new(
556            test_registry(),
557            db,
558            "bot".into(),
559            std::collections::HashMap::new(),
560        );
561        let result = tool
562            .execute(json!({
563                "chat_id": 999,
564                "text": "   "
565            }))
566            .await;
567        assert!(result.is_error);
568        assert!(result
569            .content
570            .contains("Provide text and/or attachment_path"));
571        cleanup(&dir);
572    }
573
574    #[tokio::test]
575    async fn test_send_attachment_non_telegram_rejected_without_network() {
576        let (db, dir) = test_db();
577        db.upsert_chat(999, Some("web-main"), "web").unwrap();
578
579        let attachment = dir.join("sample.txt");
580        std::fs::write(&attachment, "hello").unwrap();
581
582        let tool = SendMessageTool::new(
583            test_registry(),
584            db,
585            "bot".into(),
586            std::collections::HashMap::new(),
587        );
588        let result = tool
589            .execute(json!({
590                "chat_id": 999,
591                "attachment_path": attachment.to_string_lossy(),
592                "caption": "test"
593            }))
594            .await;
595        assert!(result.is_error);
596        assert!(result.content.contains("not supported for web"));
597        cleanup(&dir);
598    }
599
600    #[tokio::test]
601    async fn test_feishu_reaction_guard_allows_attachment_send() {
602        let (db, dir) = test_db();
603        let chat_id = db
604            .resolve_or_create_chat_id("feishu", "chat-1", Some("feishu"), "feishu_dm")
605            .unwrap();
606
607        let attachment = dir.join("sample.txt");
608        std::fs::write(&attachment, "hello").unwrap();
609
610        let mut registry = ChannelRegistry::new();
611        registry.register(Arc::new(LocalOnlyAdapter {
612            name: "feishu".to_string(),
613        }));
614        let tool = SendMessageTool::new(
615            Arc::new(registry),
616            db,
617            "bot".into(),
618            std::collections::HashMap::new(),
619        );
620        let result = tool
621            .execute(json!({
622                "chat_id": chat_id,
623                "text": "reaction-only: 👍",
624                "attachment_path": attachment.to_string_lossy(),
625                "__microclaw_auth": {
626                    "caller_chat_id": chat_id,
627                    "caller_channel": "feishu",
628                    "control_chat_ids": []
629                }
630            }))
631            .await;
632        assert!(!result.is_error, "{}", result.content);
633        cleanup(&dir);
634    }
635}