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, SessionId};
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        if let Some(parent) = db_path.parent() {
164            let _ = std::fs::create_dir_all(parent);
165        }
166        let conn = Connection::open(&db_path).map_err(ManagerError::DbOpen)?;
167        storage::init_db(&conn).map_err(ManagerError::DbInit)?;
168        let db = Arc::new(Mutex::new(conn));
169
170        Ok(Self {
171            platform,
172            agents: Mutex::new(HashMap::new()),
173            db,
174            config,
175            working_dir,
176            llm_client,
177            tool_registry,
178            skill_registry,
179            platform_sender,
180            confirmer,
181            auto_approve,
182            context_window,
183            session_timeout,
184        })
185    }
186
187    /// Main event loop. Connects to the platform, then processes events forever.
188    pub async fn run(&self) -> Result<(), AgentError> {
189        // Spawn the idle-session cleanup loop.
190        let cleanup_db = Arc::clone(&self.db);
191        let session_timeout = self.session_timeout;
192        tokio::spawn(async move {
193            cleanup_loop(cleanup_db, session_timeout).await;
194        });
195
196        loop {
197            match self.platform.recv_event().await {
198                Ok(PlatformEvent::Message(msg)) => {
199                    self.handle_message(msg).await;
200                }
201                Ok(PlatformEvent::Disconnected) => {
202                    tracing::warn!("Platform disconnected");
203                    // MVP: stop. Reconnect logic is a future enhancement.
204                    return Ok(());
205                }
206                Ok(PlatformEvent::Other(v)) => {
207                    tracing::debug!("Ignoring platform event: {}", v);
208                }
209                Err(e) => {
210                    tracing::error!("Platform recv error: {}", e);
211                    return Err(e);
212                }
213            }
214        }
215    }
216
217    /// Process a single incoming chat message.
218    async fn handle_message(&self, msg: ChatMessage) {
219        let chat_id = msg.sender.chat_id.clone();
220        let text = msg.text.trim().to_lowercase();
221
222        // If this is a confirmation reply, route it to the Confirmer (not the Agent).
223        if self
224            .confirmer
225            .check_confirmation_response(&chat_id, &text)
226            .is_some()
227        {
228            return;
229        }
230
231        // Download and save media files locally
232        let media_dir = self.working_dir.join("media");
233        for attachment in &msg.attachments {
234            if let Err(e) = robit_agent::media::download_media(
235                &attachment.url,
236                attachment.filename.as_deref(),
237                &media_dir,
238            )
239            .await
240            {
241                tracing::warn!("Failed to download media: {}", e);
242            }
243        }
244
245        // Convert attachments to agent's type
246        let attachments: Vec<robit_agent::event::MediaAttachment> =
247            msg.attachments.into_iter().map(|a| a.into()).collect();
248
249        // Normal message → route to (or create) the chat's Agent session.
250        match self.get_or_create_session(&chat_id, &msg.text).await {
251            Ok(tx) => {
252                if let Err(e) = tx
253                    .send(robit_agent::event::FrontendMessage::UserInput {
254                        text: msg.text,
255                        attachments,
256                    })
257                    .await
258                {
259                    tracing::warn!("Failed to send user message to agent for {}: {}", chat_id, e);
260                }
261            }
262            Err(e) => {
263                tracing::error!("Failed to get/create session for {}: {}", chat_id, e);
264                let _ = self
265                    .platform_sender
266                    .send(&chat_id, &format!("❌ 内部错误,无法处理消息:{}", e))
267                    .await;
268            }
269        }
270    }
271
272    /// Get an existing Agent session for `chat_id`, or create a new one.
273    async fn get_or_create_session(
274        &self,
275        chat_id: &str,
276        first_message: &str,
277    ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
278        let mut agents = self.agents.lock().await;
279        if let Some(handle) = agents.get_mut(chat_id) {
280            handle.last_active_at = Instant::now();
281            return Ok(handle.message_tx.clone());
282        }
283        drop(agents);
284
285        // No active Agent. Check the DB for a persisted session (we still spawn
286        // a fresh Agent — history restoration is a future enhancement).
287        let session_id = {
288            let db = self.db.lock().await;
289            match storage::find_session_by_chat_id(&db, chat_id)
290                .map_err(|e| AgentError::InternalError(format!("DB lookup failed: {}", e)))?
291            {
292                Some(info) => info.id,
293                None => {
294                    // Create a new DB session record.
295                    let id = Uuid::new_v4().to_string();
296                    let title = generate_title(first_message);
297                    let model = self
298                        .config
299                        .default_model
300                        .clone()
301                        .unwrap_or_else(|| self.llm_client.model().to_string());
302                    storage::insert_session(&db, &id, Some(chat_id), &title, &model, "qq")
303                        .map_err(|e| {
304                            AgentError::InternalError(format!("DB insert failed: {}", e))
305                        })?;
306                    id
307                }
308            }
309        };
310
311        let tx = self.spawn_session_agent(chat_id, &session_id).await?;
312
313        let mut agents = self.agents.lock().await;
314        agents.insert(
315            chat_id.to_string(),
316            AgentHandle {
317                message_tx: tx.clone(),
318                session_id,
319                last_active_at: Instant::now(),
320            },
321        );
322        Ok(tx)
323    }
324
325    /// Create a `ChatbotFrontend` + `Agent` for a chat and spawn its loop.
326    async fn spawn_session_agent(
327        &self,
328        chat_id: &str,
329        session_id: &str,
330    ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
331        let frontend = Arc::new(ChatbotFrontend::new(
332            chat_id.to_string(),
333            Arc::clone(&self.platform_sender),
334            Arc::clone(&self.confirmer),
335            self.auto_approve,
336        ));
337
338        let (message_tx, message_rx) = mpsc::channel::<FrontendMessage>(16);
339
340        // Load historical messages from database
341        let db = self.db.lock().await;
342        let history_messages = robit_agent::storage::load_chat_messages(&db, session_id)
343            .unwrap_or_default();
344        drop(db);
345
346        // Parse session_id string to SessionId
347        let session_id_obj = SessionId::from(session_id.to_string());
348
349        let agent = Agent::with_history(
350            Arc::clone(&self.llm_client),
351            Arc::clone(&self.tool_registry),
352            Arc::clone(&self.skill_registry),
353            Arc::clone(&frontend) as Arc<dyn Frontend>,
354            self.config.app.as_ref().and_then(|a| a.context.as_ref()),
355            self.context_window,
356            self.working_dir.clone(),
357            self.auto_approve,
358            {
359                let mut exts = HashMap::new();
360                let platform_ext: Arc<dyn PlatformExt> = frontend.clone();
361                exts.insert(
362                    crate::extensions::keys::PLATFORM_EXT.to_string(),
363                    PlatformExtWrapper::new(platform_ext),
364                );
365                exts
366            },
367            session_id_obj,
368            history_messages,
369        );
370
371        let sid = session_id.to_string();
372        let cid = chat_id.to_string();
373        tokio::spawn(async move {
374            agent.run(message_rx).await;
375            tracing::info!("Agent task ended for chat {} (session {})", cid, sid);
376        });
377
378        Ok(message_tx)
379    }
380
381    /// Number of currently active Agent sessions (for diagnostics / tests).
382    pub async fn active_session_count(&self) -> usize {
383        self.agents.lock().await.len()
384    }
385}
386
387/// Errors that can occur while constructing a [`ChatbotManager`].
388#[derive(Debug, thiserror::Error)]
389pub enum ManagerError {
390    #[error("Failed to resolve DB path: {0}")]
391    DbPath(#[from] robit_agent::AgentError),
392    #[error("Failed to open database: {0}")]
393    DbOpen(#[from] rusqlite::Error),
394    #[error("Failed to initialize database: {0}")]
395    DbInit(rusqlite::Error),
396}
397
398/// Generate a short session title from the first user message.
399fn generate_title(message: &str) -> String {
400    let trimmed = message.trim();
401    const MAX: usize = 30;
402    let chars: Vec<char> = trimmed.chars().take(MAX).collect();
403    let mut title: String = chars.into_iter().collect();
404    if trimmed.chars().count() > MAX {
405        title.push('…');
406    }
407    if title.is_empty() {
408        "QQ 会话".to_string()
409    } else {
410        title
411    }
412}
413
414/// Periodically remove idle in-memory Agent sessions.
415///
416/// The DB session record is preserved (persistence); only the live Agent task
417/// is dropped. Dropping the `AgentHandle` drops its `message_tx`, causing the
418/// Agent's `run()` loop to exit when it next awaits on the closed channel.
419async fn cleanup_loop(_db: Arc<Mutex<Connection>>, _timeout: Duration) {
420    // The idle-session cleanup touches the in-memory `agents` map, which lives
421    // on the manager. This standalone loop is a placeholder; the manager's
422    // `run()` owns the map and could check idle expiry between events. For MVP,
423    // sessions live for the process lifetime — acceptable for a single Bot.
424    // TODO: wire idle expiry into the run loop or share the agents map here.
425    loop {
426        tokio::time::sleep(CLEANUP_INTERVAL).await;
427        tracing::debug!("cleanup tick (no-op in MVP)");
428    }
429}
430
431// Keeps ToolCallInfo import referenced for the public surface documentation.
432#[allow(dead_code)]
433fn _tool_call_info_used(_i: &ToolCallInfo) {}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::adapter::{ChatType, SenderInfo};
439    use std::collections::VecDeque;
440
441    /// A mock platform that queues events and records sent messages.
442    #[allow(dead_code)]
443    struct MockPlatform {
444        events: Mutex<VecDeque<PlatformEvent>>,
445        sent: std::sync::Mutex<Vec<(String, String)>>,
446    }
447
448    #[allow(dead_code)]
449    impl MockPlatform {
450        fn new() -> Arc<Self> {
451            Arc::new(Self {
452                events: Mutex::new(VecDeque::new()),
453                sent: std::sync::Mutex::new(Vec::new()),
454            })
455        }
456
457        async fn push_message(&self, chat_id: &str, text: &str) {
458            self.events.lock().await.push_back(PlatformEvent::Message(ChatMessage {
459                text: text.to_string(),
460                sender: SenderInfo {
461                    user_id: "u1".into(),
462                    chat_id: chat_id.to_string(),
463                    chat_type: ChatType::Group,
464                },
465                attachments: vec![],
466            }));
467        }
468
469        fn sent(&self) -> Vec<(String, String)> {
470            self.sent.lock().unwrap().clone()
471        }
472    }
473
474    #[async_trait]
475    impl PlatformAdapter for MockPlatform {
476        fn capabilities() -> PlatformCaps {
477            PlatformCaps::qq()
478        }
479        async fn send_message(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
480            self.sent
481                .lock()
482                .unwrap()
483                .push((chat_id.to_string(), text.to_string()));
484            Ok(SendResult { msg_id: "m1".into() })
485        }
486        async fn recv_event(&self) -> robit_agent::error::Result<PlatformEvent> {
487            // Block-ish: spin until an event is available (test injects events).
488            loop {
489                if let Some(ev) = self.events.lock().await.pop_front() {
490                    return Ok(ev);
491                }
492                tokio::time::sleep(Duration::from_millis(10)).await;
493            }
494        }
495    }
496
497    #[test]
498    fn generate_title_truncates_long_messages() {
499        let long = "x".repeat(100);
500        let title = generate_title(&long);
501        assert!(title.ends_with('…'));
502        assert!(title.chars().count() <= 31);
503    }
504
505    #[test]
506    fn generate_title_short_message() {
507        assert_eq!(generate_title("hello"), "hello");
508    }
509
510    #[test]
511    fn generate_title_empty_message() {
512        assert_eq!(generate_title("   "), "QQ 会话");
513    }
514
515    // Note: a full end-to-end manager test requires a live LLM client, so it's
516    // deferred to manual integration testing. The construction path (new) is
517    // exercised via the QQ main entry point in Phase 9.
518}