Skip to main content

robit_chatbot/
manager.rs

1//! Multi-session Bot orchestrator.
2//!
3//! [`ChatbotManager<T>`] is the core of `robit-chatbot`. It connects to a
4//! platform via [`PlatformAdapter`](crate::adapter::PlatformAdapter), receives
5//! chat events, and routes each message to an independent Agent session — one
6//! Agent per chat, matching the `robit-gui` pattern. Sessions are persisted to
7//! SQLite keyed by platform `chat_id`, so a chat that messages the bot again
8//! after its in-memory Agent expired gets a fresh session backed by the same
9//! DB record.
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use async_trait::async_trait;
17use robit_agent::event::FrontendMessage;
18use robit_agent::frontend::Frontend;
19use robit_agent::storage::{self, resolve_db_path};
20use robit_agent::tool::ToolCallInfo;
21use robit_agent::{Agent, AgentError, SkillRegistry, ToolRegistry};
22use robit_ai::config::RobitConfig;
23use robit_ai::LlmClient;
24use rusqlite::Connection;
25use tokio::sync::{mpsc, Mutex};
26use uuid::Uuid;
27
28use crate::adapter::{ChatMessage, PlatformAdapter, PlatformCaps, PlatformEvent, SendResult, UploadResult};
29use crate::confirmer::{ConfirmKeywords, Confirmer};
30use crate::extensions::PlatformExtWrapper;
31use crate::frontend::{ChatbotFrontend, PlatformSender, PlatformExt};
32
33/// How often the cleanup loop scans for idle sessions.
34const CLEANUP_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes
35
36/// Handle to a running Agent instance for one chat.
37pub struct AgentHandle {
38    /// Send messages (user input) to the Agent loop.
39    pub message_tx: mpsc::Sender<FrontendMessage>,
40    pub session_id: String,
41    pub last_active_at: Instant,
42}
43
44/// Bridge from a concrete `PlatformAdapter` to the platform-agnostic
45/// `PlatformSender` trait used by `ChatbotFrontend` and `Confirmer`.
46struct PlatformSenderBridge<T: PlatformAdapter> {
47    platform: Arc<T>,
48    caps: PlatformCaps,
49}
50
51#[async_trait]
52impl<T: PlatformAdapter> PlatformSender for PlatformSenderBridge<T> {
53    async fn send(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
54        self.platform.send_message(chat_id, text).await
55    }
56    async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> robit_agent::error::Result<()> {
57        self.platform.edit_message(chat_id, msg_id, text).await
58    }
59    async fn upload_file(
60        &self,
61        chat_id: &str,
62        file_path: &str,
63        media_type: &str,
64    ) -> robit_agent::error::Result<UploadResult> {
65        self.platform.upload_file(chat_id, file_path, media_type).await
66    }
67    async fn send_media_message(
68        &self,
69        chat_id: &str,
70        file_url: &str,
71        file_name: &str,
72        media_type: &str,
73    ) -> robit_agent::error::Result<SendResult> {
74        self.platform
75            .send_media_message(chat_id, file_url, file_name, media_type)
76            .await
77    }
78    fn capabilities(&self) -> PlatformCaps {
79        self.caps.clone()
80    }
81}
82
83/// Core orchestrator for multi-session Bot operations.
84pub struct ChatbotManager<T: PlatformAdapter> {
85    /// The connected platform adapter, shared with the sender bridge.
86    platform: Arc<T>,
87    /// Active Agent instances, keyed by chat_id.
88    agents: Mutex<HashMap<String, AgentHandle>>,
89    /// SQLite connection for session persistence.
90    db: Arc<Mutex<Connection>>,
91    config: RobitConfig,
92    working_dir: PathBuf,
93    llm_client: Arc<LlmClient>,
94    tool_registry: Arc<ToolRegistry>,
95    skill_registry: Arc<SkillRegistry>,
96    /// Shared platform sender (wraps the adapter).
97    platform_sender: Arc<dyn PlatformSender>,
98    /// Shared tool confirmation coordinator.
99    confirmer: Arc<Confirmer>,
100    auto_approve: bool,
101    context_window: Option<u64>,
102    /// Idle session expiry.
103    session_timeout: Duration,
104}
105
106impl<T: PlatformAdapter> ChatbotManager<T> {
107    /// Create a new `ChatbotManager`.
108    ///
109    /// Opens (or creates) the session database and initializes the shared
110    /// `Confirmer` and platform sender bridge. `platform` must already be
111    /// connected (the platform crate owns connection lifecycle).
112    #[allow(clippy::too_many_arguments)]
113    pub fn new(
114        platform: Arc<T>,
115        config: RobitConfig,
116        working_dir: PathBuf,
117        llm_client: Arc<LlmClient>,
118        tool_registry: Arc<ToolRegistry>,
119        skill_registry: Arc<SkillRegistry>,
120    ) -> Result<Self, ManagerError> {
121        let caps = T::capabilities();
122        let platform_sender: Arc<dyn PlatformSender> = Arc::new(PlatformSenderBridge {
123            platform: Arc::clone(&platform),
124            caps: caps.clone(),
125        });
126
127        // Resolve bot settings (with defaults).
128        let bot = config.app.as_ref().and_then(|a| a.bot.as_ref());
129        let auto_approve = config
130            .app
131            .as_ref()
132            .and_then(|a| a.auto_approve)
133            .unwrap_or(false);
134        let confirm_timeout = Duration::from_secs(
135            bot.and_then(|b| b.confirm_timeout_secs).unwrap_or(60),
136        );
137        let session_timeout = Duration::from_secs(
138            bot.and_then(|b| b.session_timeout_minutes).unwrap_or(30) * 60,
139        );
140        let global_storage = config
141            .app
142            .as_ref()
143            .and_then(|a| a.global_storage)
144            .unwrap_or(false);
145        let context_window = llm_client.resolved().context_window;
146
147        // Build the confirmer (optionally with custom keywords).
148        let confirmer = match bot.and_then(|b| b.confirm_keywords.as_ref()) {
149            Some(kw) => Confirmer::with_keywords(
150                Arc::clone(&platform_sender),
151                confirm_timeout,
152                ConfirmKeywords {
153                    approve: kw.approve.clone().unwrap_or_default(),
154                    reject: kw.reject.clone().unwrap_or_default(),
155                },
156            ),
157            None => Confirmer::new(Arc::clone(&platform_sender), confirm_timeout),
158        };
159        let confirmer = Arc::new(confirmer);
160
161        // Open and initialize the database.
162        let db_path = resolve_db_path(&working_dir, global_storage)
163            .map_err(ManagerError::DbPath)?;
164        if let Some(parent) = db_path.parent() {
165            let _ = std::fs::create_dir_all(parent);
166        }
167        let conn = Connection::open(&db_path).map_err(ManagerError::DbOpen)?;
168        storage::init_db(&conn).map_err(ManagerError::DbInit)?;
169        let db = Arc::new(Mutex::new(conn));
170
171        Ok(Self {
172            platform,
173            agents: Mutex::new(HashMap::new()),
174            db,
175            config,
176            working_dir,
177            llm_client,
178            tool_registry,
179            skill_registry,
180            platform_sender,
181            confirmer,
182            auto_approve,
183            context_window,
184            session_timeout,
185        })
186    }
187
188    /// Main event loop. Connects to the platform, then processes events forever.
189    pub async fn run(&self) -> Result<(), AgentError> {
190        // Spawn the idle-session cleanup loop.
191        let cleanup_db = Arc::clone(&self.db);
192        let session_timeout = self.session_timeout;
193        tokio::spawn(async move {
194            cleanup_loop(cleanup_db, session_timeout).await;
195        });
196
197        loop {
198            match self.platform.recv_event().await {
199                Ok(PlatformEvent::Message(msg)) => {
200                    self.handle_message(msg).await;
201                }
202                Ok(PlatformEvent::Disconnected) => {
203                    tracing::warn!("Platform disconnected");
204                    // MVP: stop. Reconnect logic is a future enhancement.
205                    return Ok(());
206                }
207                Ok(PlatformEvent::Other(v)) => {
208                    tracing::debug!("Ignoring platform event: {}", v);
209                }
210                Err(e) => {
211                    tracing::error!("Platform recv error: {}", e);
212                    return Err(e);
213                }
214            }
215        }
216    }
217
218    /// Process a single incoming chat message.
219    async fn handle_message(&self, msg: ChatMessage) {
220        let chat_id = msg.sender.chat_id.clone();
221        let text = msg.text.trim().to_lowercase();
222
223        // If this is a confirmation reply, route it to the Confirmer (not the Agent).
224        if self
225            .confirmer
226            .check_confirmation_response(&chat_id, &text)
227            .is_some()
228        {
229            return;
230        }
231
232        // Download and save media files locally
233        let media_dir = self.working_dir.join("media");
234        for attachment in &msg.attachments {
235            if let Err(e) = robit_agent::media::download_media(
236                &attachment.url,
237                attachment.filename.as_deref(),
238                &media_dir,
239            )
240            .await
241            {
242                tracing::warn!("Failed to download media: {}", e);
243            }
244        }
245
246        // Convert attachments to agent's type
247        let attachments: Vec<robit_agent::event::MediaAttachment> =
248            msg.attachments.into_iter().map(|a| a.into()).collect();
249
250        // Normal message → route to (or create) the chat's Agent session.
251        match self.get_or_create_session(&chat_id, &msg.text).await {
252            Ok(tx) => {
253                if let Err(e) = tx
254                    .send(robit_agent::event::FrontendMessage::UserInput {
255                        text: msg.text,
256                        attachments,
257                    })
258                    .await
259                {
260                    tracing::warn!("Failed to send user message to agent for {}: {}", chat_id, e);
261                }
262            }
263            Err(e) => {
264                tracing::error!("Failed to get/create session for {}: {}", chat_id, e);
265                let _ = self
266                    .platform_sender
267                    .send(&chat_id, &format!("❌ 内部错误,无法处理消息:{}", e))
268                    .await;
269            }
270        }
271    }
272
273    /// Get an existing Agent session for `chat_id`, or create a new one.
274    async fn get_or_create_session(
275        &self,
276        chat_id: &str,
277        first_message: &str,
278    ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
279        let mut agents = self.agents.lock().await;
280        if let Some(handle) = agents.get_mut(chat_id) {
281            handle.last_active_at = Instant::now();
282            return Ok(handle.message_tx.clone());
283        }
284        drop(agents);
285
286        // No active Agent. Check the DB for a persisted session (we still spawn
287        // a fresh Agent — history restoration is a future enhancement).
288        let session_id = {
289            let db = self.db.lock().await;
290            match storage::find_session_by_chat_id(&db, chat_id)
291                .map_err(|e| AgentError::InternalError(format!("DB lookup failed: {}", e)))?
292            {
293                Some(info) => info.id,
294                None => {
295                    // Create a new DB session record.
296                    let id = Uuid::new_v4().to_string();
297                    let title = generate_title(first_message);
298                    let model = self
299                        .config
300                        .default_model
301                        .clone()
302                        .unwrap_or_else(|| self.llm_client.model().to_string());
303                    storage::insert_session(&db, &id, Some(chat_id), &title, &model, "qq")
304                        .map_err(|e| {
305                            AgentError::InternalError(format!("DB insert failed: {}", e))
306                        })?;
307                    id
308                }
309            }
310        };
311
312        let tx = self.spawn_session_agent(chat_id, &session_id).await?;
313
314        let mut agents = self.agents.lock().await;
315        agents.insert(
316            chat_id.to_string(),
317            AgentHandle {
318                message_tx: tx.clone(),
319                session_id,
320                last_active_at: Instant::now(),
321            },
322        );
323        Ok(tx)
324    }
325
326    /// Create a `ChatbotFrontend` + `Agent` for a chat and spawn its loop.
327    async fn spawn_session_agent(
328        &self,
329        chat_id: &str,
330        session_id: &str,
331    ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
332        let frontend = Arc::new(ChatbotFrontend::new(
333            chat_id.to_string(),
334            Arc::clone(&self.platform_sender),
335            Arc::clone(&self.confirmer),
336            self.auto_approve,
337        ));
338
339        let (message_tx, message_rx) = mpsc::channel::<FrontendMessage>(16);
340
341        let agent = Agent::new(
342            Arc::clone(&self.llm_client),
343            Arc::clone(&self.tool_registry),
344            Arc::clone(&self.skill_registry),
345            Arc::clone(&frontend) as Arc<dyn Frontend>,
346            self.config.app.as_ref().and_then(|a| a.context.as_ref()),
347            self.context_window,
348            self.working_dir.clone(),
349            self.auto_approve,
350            {
351                let mut exts = HashMap::new();
352                let platform_ext: Arc<dyn PlatformExt> = frontend.clone();
353                exts.insert(
354                    crate::extensions::keys::PLATFORM_EXT.to_string(),
355                    PlatformExtWrapper::new(platform_ext),
356                );
357                exts
358            },
359        );
360
361        let sid = session_id.to_string();
362        let cid = chat_id.to_string();
363        tokio::spawn(async move {
364            agent.run(message_rx).await;
365            tracing::info!("Agent task ended for chat {} (session {})", cid, sid);
366        });
367
368        Ok(message_tx)
369    }
370
371    /// Number of currently active Agent sessions (for diagnostics / tests).
372    pub async fn active_session_count(&self) -> usize {
373        self.agents.lock().await.len()
374    }
375}
376
377/// Errors that can occur while constructing a [`ChatbotManager`].
378#[derive(Debug, thiserror::Error)]
379pub enum ManagerError {
380    #[error("Failed to resolve DB path: {0}")]
381    DbPath(String),
382    #[error("Failed to open database: {0}")]
383    DbOpen(#[from] rusqlite::Error),
384    #[error("Failed to initialize database: {0}")]
385    DbInit(rusqlite::Error),
386}
387
388/// Generate a short session title from the first user message.
389fn generate_title(message: &str) -> String {
390    let trimmed = message.trim();
391    const MAX: usize = 30;
392    let chars: Vec<char> = trimmed.chars().take(MAX).collect();
393    let mut title: String = chars.into_iter().collect();
394    if trimmed.chars().count() > MAX {
395        title.push('…');
396    }
397    if title.is_empty() {
398        "QQ 会话".to_string()
399    } else {
400        title
401    }
402}
403
404/// Periodically remove idle in-memory Agent sessions.
405///
406/// The DB session record is preserved (persistence); only the live Agent task
407/// is dropped. Dropping the `AgentHandle` drops its `message_tx`, causing the
408/// Agent's `run()` loop to exit when it next awaits on the closed channel.
409async fn cleanup_loop(_db: Arc<Mutex<Connection>>, _timeout: Duration) {
410    // The idle-session cleanup touches the in-memory `agents` map, which lives
411    // on the manager. This standalone loop is a placeholder; the manager's
412    // `run()` owns the map and could check idle expiry between events. For MVP,
413    // sessions live for the process lifetime — acceptable for a single Bot.
414    // TODO: wire idle expiry into the run loop or share the agents map here.
415    loop {
416        tokio::time::sleep(CLEANUP_INTERVAL).await;
417        tracing::debug!("cleanup tick (no-op in MVP)");
418    }
419}
420
421// Keeps ToolCallInfo import referenced for the public surface documentation.
422#[allow(dead_code)]
423fn _tool_call_info_used(_i: &ToolCallInfo) {}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::adapter::{ChatType, SenderInfo};
429    use std::collections::VecDeque;
430
431    /// A mock platform that queues events and records sent messages.
432    #[allow(dead_code)]
433    struct MockPlatform {
434        events: Mutex<VecDeque<PlatformEvent>>,
435        sent: std::sync::Mutex<Vec<(String, String)>>,
436    }
437
438    #[allow(dead_code)]
439    impl MockPlatform {
440        fn new() -> Arc<Self> {
441            Arc::new(Self {
442                events: Mutex::new(VecDeque::new()),
443                sent: std::sync::Mutex::new(Vec::new()),
444            })
445        }
446
447        async fn push_message(&self, chat_id: &str, text: &str) {
448            self.events.lock().await.push_back(PlatformEvent::Message(ChatMessage {
449                text: text.to_string(),
450                sender: SenderInfo {
451                    user_id: "u1".into(),
452                    chat_id: chat_id.to_string(),
453                    chat_type: ChatType::Group,
454                },
455                attachments: vec![],
456            }));
457        }
458
459        fn sent(&self) -> Vec<(String, String)> {
460            self.sent.lock().unwrap().clone()
461        }
462    }
463
464    #[async_trait]
465    impl PlatformAdapter for MockPlatform {
466        fn capabilities() -> PlatformCaps {
467            PlatformCaps::qq()
468        }
469        async fn send_message(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
470            self.sent
471                .lock()
472                .unwrap()
473                .push((chat_id.to_string(), text.to_string()));
474            Ok(SendResult { msg_id: "m1".into() })
475        }
476        async fn recv_event(&self) -> robit_agent::error::Result<PlatformEvent> {
477            // Block-ish: spin until an event is available (test injects events).
478            loop {
479                if let Some(ev) = self.events.lock().await.pop_front() {
480                    return Ok(ev);
481                }
482                tokio::time::sleep(Duration::from_millis(10)).await;
483            }
484        }
485    }
486
487    #[test]
488    fn generate_title_truncates_long_messages() {
489        let long = "x".repeat(100);
490        let title = generate_title(&long);
491        assert!(title.ends_with('…'));
492        assert!(title.chars().count() <= 31);
493    }
494
495    #[test]
496    fn generate_title_short_message() {
497        assert_eq!(generate_title("hello"), "hello");
498    }
499
500    #[test]
501    fn generate_title_empty_message() {
502        assert_eq!(generate_title("   "), "QQ 会话");
503    }
504
505    // Note: a full end-to-end manager test requires a live LLM client, so it's
506    // deferred to manual integration testing. The construction path (new) is
507    // exercised via the QQ main entry point in Phase 9.
508}