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