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 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 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}