Skip to main content

robit_chatbot/
confirmer.rs

1//! Tool confirmation coordinator for Bot platforms.
2//!
3//! Unlike the GUI which uses dialog boxes, Bot confirmation happens via inline
4//! chat messages: the [`Confirmer`] sends a prompt, then waits for the user to
5//! reply with an approve/reject keyword (with a timeout). The
6//! [`ChatbotManager`](crate::manager::ChatbotManager) intercepts user messages
7//! and routes confirmation replies here via [`Confirmer::check_confirmation_response`].
8
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13use robit_agent::error::{AgentError, Result};
14use robit_agent::tool::ToolCallInfo;
15use tokio::sync::oneshot;
16
17use crate::frontend::PlatformSender;
18
19/// Keywords that trigger approval or rejection of a pending tool call.
20#[derive(Debug, Clone)]
21pub struct ConfirmKeywords {
22    pub approve: Vec<String>,
23    pub reject: Vec<String>,
24}
25
26impl Default for ConfirmKeywords {
27    fn default() -> Self {
28        Self {
29            approve: vec![
30                "确认".into(),
31                "同意".into(),
32                "yes".into(),
33                "y".into(),
34                "approve".into(),
35                "ok".into(),
36                "允许".into(),
37            ],
38            reject: vec![
39                "取消".into(),
40                "拒绝".into(),
41                "no".into(),
42                "n".into(),
43                "reject".into(),
44                "cancel".into(),
45                "deny".into(),
46            ],
47        }
48    }
49}
50
51/// A pending confirmation awaiting a user reply.
52struct PendingConfirmation {
53    /// Oneshot sender used to deliver the user's decision to the waiting
54    /// `request()` call.
55    sender: Option<oneshot::Sender<bool>>,
56    created_at: Instant,
57    #[allow(dead_code)]
58    chat_id: String,
59    #[allow(dead_code)]
60    tool_call_id: String,
61    #[allow(dead_code)]
62    tool_name: String,
63    #[allow(dead_code)]
64    arguments: String,
65}
66
67/// Tool confirmation coordinator for Bot platforms.
68///
69/// One `Confirmer` is shared across all chat sessions (wrapped in `Arc`).
70/// Pending confirmations are keyed by `"{chat_id}:{tool_call_id}"`.
71pub struct Confirmer {
72    /// `std::sync::Mutex` — held only briefly for HashMap operations.
73    pending: Mutex<HashMap<String, PendingConfirmation>>,
74    platform_sender: Arc<dyn PlatformSender>,
75    timeout: Duration,
76    keywords: ConfirmKeywords,
77}
78
79impl Confirmer {
80    /// Create a new `Confirmer`.
81    pub fn new(platform_sender: Arc<dyn PlatformSender>, timeout: Duration) -> Self {
82        Self {
83            pending: Mutex::new(HashMap::new()),
84            platform_sender,
85            timeout,
86            keywords: ConfirmKeywords::default(),
87        }
88    }
89
90    /// Create a `Confirmer` with custom keywords.
91    pub fn with_keywords(
92        platform_sender: Arc<dyn PlatformSender>,
93        timeout: Duration,
94        keywords: ConfirmKeywords,
95    ) -> Self {
96        Self {
97            pending: Mutex::new(HashMap::new()),
98            platform_sender,
99            timeout,
100            keywords,
101        }
102    }
103
104    /// Request tool confirmation. Sends a prompt message to the chat and waits
105    /// for the user to reply with an approve/reject keyword.
106    ///
107    /// - If `auto_approve` is true, returns `true` immediately without sending.
108    /// - Returns `true` if approved, `false` if rejected or timed out.
109    pub async fn request(
110        &self,
111        chat_id: &str,
112        info: &ToolCallInfo,
113        auto_approve: bool,
114    ) -> Result<bool> {
115        if auto_approve {
116            return Ok(true);
117        }
118
119        let key = format!("{}:{}", chat_id, info.id);
120
121        // Build and send the confirmation prompt.
122        let prompt = format_confirmation_prompt(
123            &info.name,
124            &info.arguments,
125            self.timeout,
126            &self.keywords,
127        );
128        if let Err(e) = self.platform_sender.send(chat_id, &prompt).await {
129            tracing::warn!("Failed to send confirmation prompt: {}", e);
130        }
131
132        let (tx, rx) = oneshot::channel::<bool>();
133        {
134            let mut pending = self.pending.lock().unwrap();
135            pending.insert(
136                key.clone(),
137                PendingConfirmation {
138                    sender: Some(tx),
139                    created_at: Instant::now(),
140                    chat_id: chat_id.to_string(),
141                    tool_call_id: info.id.clone(),
142                    tool_name: info.name.clone(),
143                    arguments: info.arguments.clone(),
144                },
145            );
146        }
147
148        // Wait for a reply or timeout.
149        let decision = match tokio::time::timeout(self.timeout, rx).await {
150            Ok(Ok(approved)) => approved,
151            Ok(Err(_)) => {
152                // Sender dropped without a reply — treat as rejection.
153                false
154            }
155            Err(_) => {
156                // Timed out.
157                self.remove_pending(&key);
158                let _ = self
159                    .platform_sender
160                    .send(chat_id, "⏰ 已超时,操作已取消")
161                    .await;
162                return Ok(false);
163            }
164        };
165
166        // Cleanup (the responder may have already removed it).
167        self.remove_pending(&key);
168
169        let notice = if decision { "✅ 已确认" } else { "❌ 已取消" };
170        let _ = self.platform_sender.send(chat_id, notice).await;
171        Ok(decision)
172    }
173
174    /// Check whether a user message is a confirmation reply for a pending
175    /// request in `chat_id`. If it matches a keyword, the pending entry is
176    /// consumed and the waiting `request()` is unblocked.
177    ///
178    /// Returns `Some(approved)` if the message was a confirmation reply,
179    /// `None` if there was no pending confirmation or the message didn't
180    /// match a keyword.
181    pub fn check_confirmation_response(&self, chat_id: &str, text: &str) -> Option<bool> {
182        let text_lower = text.trim().to_lowercase();
183        let is_approve = self.keywords.approve.contains(&text_lower);
184        let is_reject = self.keywords.reject.contains(&text_lower);
185        if !is_approve && !is_reject {
186            return None;
187        }
188
189        let mut pending = self.pending.lock().unwrap();
190        // Find the first pending confirmation for this chat.
191        let key = pending
192            .keys()
193            .find(|k| k.starts_with(&format!("{}:", chat_id)))
194            .cloned()?;
195        let entry = pending.remove(&key)?;
196        let sender = entry.sender?;
197        let _ = sender.send(is_approve);
198        Some(is_approve)
199    }
200
201    /// Periodically clean up expired pending confirmations.
202    ///
203    /// Expired entries are removed; their waiting `request()` calls will then
204    /// time out on their own `tokio::time::timeout`.
205    pub fn cleanup_expired(&self) {
206        let now = Instant::now();
207        let mut pending = self.pending.lock().unwrap();
208        pending.retain(|_, entry| now.duration_since(entry.created_at) < self.timeout);
209    }
210
211    fn remove_pending(&self, key: &str) {
212        let mut pending = self.pending.lock().unwrap();
213        pending.remove(key);
214    }
215}
216
217/// Build the inline confirmation prompt shown to the user.
218fn format_confirmation_prompt(
219    tool_name: &str,
220    arguments: &str,
221    timeout: Duration,
222    keywords: &ConfirmKeywords,
223) -> String {
224    let secs = timeout.as_secs();
225    let approve_hint = keywords.approve.first().cloned().unwrap_or_else(|| "确认".into());
226    let reject_hint = keywords.reject.first().cloned().unwrap_or_else(|| "取消".into());
227    // Pretty-print the arguments JSON if possible.
228    let pretty_args = serde_json::from_str::<serde_json::Value>(arguments)
229        .ok()
230        .and_then(|v| serde_json::to_string_pretty(&v).ok())
231        .unwrap_or_else(|| arguments.to_string());
232
233    format!(
234        "⚠️ 需要确认工具调用\n\n工具: {}\n参数: {}\n\n回复 \"{}\" 或 \"{}\"\n({}秒内有效)",
235        tool_name, pretty_args, approve_hint, reject_hint, secs
236    )
237}
238
239#[allow(dead_code)]
240fn _ensure_agent_error_used() -> AgentError {
241    // Keeps the AgentError import live for the public Result type.
242    AgentError::InternalError(String::new())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::adapter::{MarkdownFeatures, PlatformCaps, SendResult, UploadResult};
249    use async_trait::async_trait;
250    use std::sync::Mutex as StdMutex;
251    use tokio::sync::Mutex;
252
253    struct MockSender {
254        sent: StdMutex<Vec<(String, String)>>,
255        caps: PlatformCaps,
256    }
257
258    impl MockSender {
259        fn new() -> Arc<Self> {
260            Arc::new(Self {
261                sent: StdMutex::new(Vec::new()),
262                caps: PlatformCaps {
263                    supports_edit: true,
264                    returns_msg_id: true,
265                    supports_markdown: true,
266                    markdown_features: MarkdownFeatures::qq(),
267                    max_message_length: 2000,
268                    supports_images: true,
269                    supports_files: true,
270                    max_upload_size: 20 * 1024 * 1024,
271                },
272            })
273        }
274
275        fn messages(&self) -> Vec<(String, String)> {
276            self.sent.lock().unwrap().clone()
277        }
278    }
279
280    #[async_trait]
281    impl PlatformSender for MockSender {
282        async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult> {
283            self.sent
284                .lock()
285                .unwrap()
286                .push((chat_id.to_string(), text.to_string()));
287            Ok(SendResult {
288                msg_id: "mock-id".to_string(),
289            })
290        }
291        async fn edit(&self, _chat_id: &str, _msg_id: &str, _text: &str) -> Result<()> {
292            Ok(())
293        }
294        async fn upload_file(&self, _chat_id: &str, _file_path: &str, _media_type: &str) -> Result<UploadResult> {
295            Ok(UploadResult {
296                file_id: "mock-file-id".into(),
297                url: "/mock/upload.png".into(),
298            })
299        }
300        async fn send_media_message(
301            &self,
302            _chat_id: &str,
303            _file_url: &str,
304            _file_name: &str,
305            _media_type: &str,
306        ) -> Result<SendResult> {
307            Ok(SendResult {
308                msg_id: "mock-media-id".to_string(),
309            })
310        }
311        fn capabilities(&self) -> PlatformCaps {
312            self.caps.clone()
313        }
314    }
315
316    fn tool_info(id: &str, name: &str) -> ToolCallInfo {
317        ToolCallInfo {
318            id: id.to_string(),
319            name: name.to_string(),
320            arguments: "{}".to_string(),
321        }
322    }
323
324    #[tokio::test]
325    async fn auto_approve_skips_prompt() {
326        let sender = MockSender::new();
327        let confirmer = Confirmer::new(sender.clone(), Duration::from_secs(60));
328        let info = tool_info("tc1", "bash");
329        let approved = confirmer.request("group:1", &info, true).await.unwrap();
330        assert!(approved);
331        // No prompt message should have been sent.
332        assert!(sender.messages().is_empty());
333    }
334
335    #[tokio::test]
336    async fn approve_keyword_resolves_pending() {
337        let sender = MockSender::new();
338        let confirmer = Arc::new(Confirmer::new(sender.clone(), Duration::from_secs(5)));
339        let info = tool_info("tc2", "write");
340
341        let confirmer_clone = confirmer.clone();
342        let handle = tokio::spawn(async move {
343            confirmer_clone.request("group:1", &info, false).await
344        });
345
346        // Give the request a moment to register.
347        tokio::time::sleep(Duration::from_millis(50)).await;
348
349        // Simulate the user replying with an approve keyword.
350        let result = confirmer.check_confirmation_response("group:1", "确认");
351        assert_eq!(result, Some(true));
352
353        let approved = handle.await.unwrap().unwrap();
354        assert!(approved);
355        // A confirm prompt + an "已确认" notice were sent.
356        let msgs = sender.messages();
357        assert!(msgs.iter().any(|(_, t)| t.contains("需要确认")));
358        assert!(msgs.iter().any(|(_, t)| t.contains("已确认")));
359    }
360
361    #[tokio::test]
362    async fn reject_keyword_resolves_pending() {
363        let sender = MockSender::new();
364        let confirmer = Arc::new(Confirmer::new(sender.clone(), Duration::from_secs(5)));
365        let info = tool_info("tc3", "bash");
366
367        let confirmer_clone = confirmer.clone();
368        let handle = tokio::spawn(async move {
369            confirmer_clone.request("group:2", &info, false).await
370        });
371
372        tokio::time::sleep(Duration::from_millis(50)).await;
373        let result = confirmer.check_confirmation_response("group:2", "取消");
374        assert_eq!(result, Some(false));
375
376        let approved = handle.await.unwrap().unwrap();
377        assert!(!approved);
378    }
379
380    #[tokio::test]
381    async fn non_keyword_message_returns_none() {
382        let sender = MockSender::new();
383        let confirmer = Arc::new(Confirmer::new(sender.clone(), Duration::from_secs(60)));
384        let info = tool_info("tc4", "bash");
385
386        // Register a pending request (don't await it).
387        let confirmer_clone = confirmer.clone();
388        let handle = tokio::spawn(async move {
389            // Use a short timeout so the test doesn't hang if logic is wrong;
390            // we'll resolve it before then.
391            confirmer_clone.request("group:3", &info, false).await
392        });
393        tokio::time::sleep(Duration::from_millis(50)).await;
394
395        // A normal chat message is not a confirmation reply.
396        assert_eq!(confirmer.check_confirmation_response("group:3", "hello world"), None);
397
398        // Resolve so the spawned task exits cleanly.
399        assert_eq!(confirmer.check_confirmation_response("group:3", "yes"), Some(true));
400        let _ = handle.await.unwrap();
401    }
402
403    #[tokio::test]
404    async fn no_pending_returns_none() {
405        let sender = MockSender::new();
406        let confirmer = Confirmer::new(sender, Duration::from_secs(5));
407        // No pending confirmation → keyword is treated as a normal message.
408        assert_eq!(confirmer.check_confirmation_response("group:9", "确认"), None);
409    }
410
411    #[tokio::test]
412    async fn timeout_returns_false() {
413        let sender = MockSender::new();
414        let confirmer = Confirmer::new(sender.clone(), Duration::from_millis(80));
415        let info = tool_info("tc5", "bash");
416
417        let approved = confirmer.request("group:4", &info, false).await.unwrap();
418        assert!(!approved);
419        // A timeout notice was sent.
420        assert!(sender
421            .messages()
422            .iter()
423            .any(|(_, t)| t.contains("超时")));
424    }
425
426    #[tokio::test]
427    async fn only_first_pending_per_chat_resolved() {
428        let sender = MockSender::new();
429        let confirmer = Arc::new(Confirmer::new(sender.clone(), Duration::from_secs(5)));
430
431        // Two pending confirmations for the same chat with different tool_call_ids.
432        let info1 = tool_info("a", "bash");
433        let info2 = tool_info("b", "write");
434        let c1 = confirmer.clone();
435        let c2 = confirmer.clone();
436        let h1 = tokio::spawn(async move { c1.request("group:5", &info1, false).await });
437        let h2 = tokio::spawn(async move { c2.request("group:5", &info2, false).await });
438        tokio::time::sleep(Duration::from_millis(50)).await;
439
440        // First reply resolves one pending entry; second resolves the other.
441        // HashMap iteration order is non-deterministic, so we don't assume
442        // which tool_call_id is resolved first — only that both get resolved
443        // and the decisions match the keywords sent.
444        assert!(confirmer.check_confirmation_response("group:5", "确认").is_some());
445        assert!(confirmer.check_confirmation_response("group:5", "取消").is_some());
446
447        let r1 = h1.await.unwrap().unwrap();
448        let r2 = h2.await.unwrap().unwrap();
449        // One approved, one rejected — order is unspecified.
450        assert_ne!(r1, r2);
451    }
452
453    #[test]
454    fn cleanup_expired_removes_old_entries() {
455        // cleanup_expired only touches the map; we can't easily test timing
456        // without a pending entry registered via request(). This is a smoke
457        // test that it doesn't panic on an empty map.
458        let sender = MockSender::new();
459        let confirmer = Confirmer::new(sender, Duration::from_secs(1));
460        confirmer.cleanup_expired();
461    }
462
463    // Suppress unused-import warning for Mutex (kept for potential future tests).
464    #[allow(dead_code)]
465    fn _use_mutex() -> Mutex<()> {
466        Mutex::new(())
467    }
468}