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