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        // Check for command messages
247        let trimmed_text = msg.text.trim();
248        if trimmed_text.eq_ignore_ascii_case("/clear") {
249            self.handle_clear_command(&chat_id).await;
250            return;
251        }
252        if trimmed_text.eq_ignore_ascii_case("/stop") {
253            self.handle_stop_command(&chat_id).await;
254            return;
255        }
256        if trimmed_text.eq_ignore_ascii_case("/cancel")
257            || trimmed_text.to_lowercase().starts_with("/cancel ")
258        {
259            let arg = trimmed_text
260                .strip_prefix("/cancel")
261                .unwrap_or("")
262                .trim();
263            self.handle_cancel_command(&chat_id, arg).await;
264            return;
265        }
266        if trimmed_text.eq_ignore_ascii_case("/new") {
267            self.handle_new_command(&chat_id).await;
268            return;
269        }
270        if trimmed_text.eq_ignore_ascii_case("/list") {
271            self.handle_list_command(&chat_id).await;
272            return;
273        }
274        if trimmed_text.to_lowercase().starts_with("/switch ") {
275            self.handle_switch_command(&chat_id, &trimmed_text["/switch ".len()..]).await;
276            return;
277        }
278        if trimmed_text.eq_ignore_ascii_case("/help") {
279            self.handle_help_command(&chat_id).await;
280            return;
281        }
282
283        // Download and save media files locally
284        let media_dir = self.working_dir.join("media");
285        for attachment in &msg.attachments {
286            if let Err(e) = robit_agent::media::download_media(
287                &attachment.url,
288                attachment.filename.as_deref(),
289                &media_dir,
290            )
291            .await
292            {
293                tracing::warn!("Failed to download media: {}", e);
294            }
295        }
296
297        // Convert attachments to agent's type
298        let attachments: Vec<robit_agent::event::MediaAttachment> =
299            msg.attachments.into_iter().map(|a| a.into()).collect();
300
301        // Normal message → route to (or create) the chat's Agent session.
302        match self.get_or_create_session(&chat_id, &msg.text).await {
303            Ok((tx, frontend)) => {
304                // Save user message to database
305                frontend.save_user_message(&msg.text).await;
306
307                if let Err(e) = tx
308                    .send(robit_agent::event::FrontendMessage::UserInput {
309                        text: msg.text,
310                        attachments,
311                    })
312                    .await
313                {
314                    tracing::warn!("Failed to send user message to agent for {}: {}", chat_id, e);
315                }
316            }
317            Err(e) => {
318                tracing::error!("Failed to get/create session for {}: {}", chat_id, e);
319                let _ = self
320                    .platform_sender
321                    .send(&chat_id, &format!("❌ 内部错误,无法处理消息:{}", e))
322                    .await;
323            }
324        }
325    }
326
327    /// Handle /clear command: clear the current conversation context (in-memory only).
328    async fn handle_clear_command(&self, chat_id: &str) {
329        // Send "/clear" as a user message to Agent - Agent already has built-in handling for this
330        match self.get_or_create_session(chat_id, "clear command").await {
331            Ok((tx, _)) => {
332                if let Err(e) = tx.send("/clear".into()).await {
333                    tracing::warn!("Failed to send /clear to agent: {}", e);
334                    let _ = self.platform_sender.send(chat_id, "❌ 清空失败").await;
335                }
336            }
337            Err(e) => {
338                tracing::error!("Failed to get session for /clear: {}", e);
339                let _ = self.platform_sender.send(chat_id, &format!("❌ 无法执行清空:{}", e)).await;
340            }
341        }
342    }
343
344    /// Handle /stop command: stop the current operation.
345    async fn handle_stop_command(&self, chat_id: &str) {
346        let agents = self.agents.lock().await;
347        if let Some(handle) = agents.get(chat_id) {
348            // Send Cancel message to Agent
349            if let Err(e) = handle.message_tx.send(robit_agent::event::FrontendMessage::Cancel).await {
350                tracing::warn!("Failed to send Cancel to agent: {}", e);
351                let _ = self.platform_sender.send(chat_id, "❌ 停止失败").await;
352                return;
353            }
354            let _ = self.platform_sender.send(chat_id, "⏹️ 已发送停止信号").await;
355        } else {
356            let _ = self.platform_sender.send(chat_id, "ℹ️ 当前没有活动的会话").await;
357        }
358    }
359
360    /// Handle /cancel command: cancel async background task(s).
361    ///
362    /// `/cancel` with no argument cancels all pending tasks; `/cancel <task_id>`
363    /// cancels a specific one.
364    async fn handle_cancel_command(&self, chat_id: &str, arg: &str) {
365        let agents = self.agents.lock().await;
366        if let Some(handle) = agents.get(chat_id) {
367            let msg = if arg.is_empty() {
368                let _ = handle
369                    .message_tx
370                    .send(robit_agent::event::FrontendMessage::Cancel)
371                    .await;
372                "⏹️ 已发送取消全部后台任务的信号".to_string()
373            } else {
374                let _ = handle
375                    .message_tx
376                    .send(robit_agent::event::FrontendMessage::CancelTask {
377                        task_id: arg.to_string(),
378                    })
379                    .await;
380                format!("⏹️ 已发送取消任务 {} 的信号", arg)
381            };
382            let _ = self.platform_sender.send(chat_id, &msg).await;
383        } else {
384            let _ = self.platform_sender
385                .send(chat_id, "ℹ️ 当前没有活动的会话")
386                .await;
387        }
388    }
389
390    /// Handle /new command: create a fresh conversation session.
391    async fn handle_new_command(&self, chat_id: &str) {
392        let mut agents = self.agents.lock().await;
393
394        // 1. 如果有当前会话,先关闭它
395        if let Some(old_handle) = agents.remove(chat_id) {
396            // 把旧会话标记为不活跃
397            let db = self.db.lock().await;
398            if let Err(e) = robit_agent::storage::delete_session(&db, &old_handle.session_id) {
399                tracing::warn!("Failed to deactivate old session: {}", e);
400            }
401            drop(db);
402
403            // 丢弃旧的 Agent 通道,让任务自然结束
404            drop(old_handle);
405        }
406        drop(agents);
407
408        // 2. 创建新会话(会自动触发 get_or_create_session)
409        match self.get_or_create_session(chat_id, "新会话").await {
410            Ok((_, frontend)) => {
411                let msg = format!(
412                    "✨ 已创建新会话\n会话ID: {}\n旧会话已归档,使用 /list 查看历史",
413                    frontend.session_id
414                );
415                let _ = self.platform_sender.send(chat_id, &msg).await;
416            }
417            Err(e) => {
418                tracing::error!("Failed to create new session: {}", e);
419                let _ = self.platform_sender.send(chat_id, &format!("❌ 创建新会话失败:{}", e)).await;
420            }
421        }
422    }
423
424    /// Handle /list command: list all sessions for this chat.
425    async fn handle_list_command(&self, chat_id: &str) {
426        let db = self.db.lock().await;
427        match robit_agent::storage::list_all_sessions_by_chat_id(&db, chat_id) {
428            Ok(sessions) if sessions.is_empty() => {
429                let _ = self.platform_sender.send(chat_id, "ℹ️ 暂无历史会话").await;
430            }
431            Ok(sessions) => {
432                let mut list_text = String::from("📜 历史会话列表\n\n");
433                for (i, session) in sessions.iter().enumerate() {
434                    let indicator = if i == 0 { "👉" } else { "  " };
435                    let current_mark = if i == 0 { " [当前]" } else { "" };
436                    list_text.push_str(&format!(
437                        "{} {}. {}{}\n",
438                        indicator,
439                        i + 1,
440                        session.title,
441                        current_mark
442                    ));
443                    list_text.push_str(&format!(
444                        "   ID: {} | 创建: {}\n\n",
445                        session.id,
446                        session.created_at
447                    ));
448                }
449                list_text.push_str("💡 使用 /switch <序号> 切换到对应会话");
450                let _ = self.platform_sender.send(chat_id, &list_text).await;
451            }
452            Err(e) => {
453                tracing::error!("Failed to list sessions: {}", e);
454                let _ = self.platform_sender.send(chat_id, &format!("❌ 获取会话列表失败:{}", e)).await;
455            }
456        }
457    }
458
459    /// Handle /switch command: switch to a specific session.
460    async fn handle_switch_command(&self, chat_id: &str, arg: &str) {
461        let trimmed_arg = arg.trim();
462
463        // 解析序号(支持数字)
464        let session_index = match trimmed_arg.parse::<usize>() {
465            Ok(n) if n > 0 => n - 1, // 转换为0-based索引
466            _ => {
467                let _ = self.platform_sender.send(chat_id, "❌ 请输入有效的会话序号,如 /switch 1").await;
468                return;
469            }
470        };
471
472        // 获取会话列表
473        let db = self.db.lock().await;
474        let sessions = match robit_agent::storage::list_all_sessions_by_chat_id(&db, chat_id) {
475            Ok(s) => s,
476            Err(e) => {
477                tracing::error!("Failed to list sessions: {}", e);
478                let _ = self.platform_sender.send(chat_id, &format!("❌ 获取会话列表失败:{}", e)).await;
479                return;
480            }
481        };
482        drop(db);
483
484        // 检查序号是否有效
485        if session_index >= sessions.len() {
486            let _ = self.platform_sender.send(
487                chat_id,
488                &format!("❌ 会话序号无效,共有 {} 个会话", sessions.len())
489            ).await;
490            return;
491        }
492
493        let target_session = &sessions[session_index];
494        let target_id = target_session.id.clone();
495
496        // 如果已经是当前会话,不需要切换
497        {
498            let agents = self.agents.lock().await;
499            if let Some(current) = agents.get(chat_id) {
500                if current.session_id == target_id {
501                    let _ = self.platform_sender.send(chat_id, "ℹ️ 已经是当前会话").await;
502                    return;
503                }
504            }
505        }
506
507        // 在数据库中激活目标会话,停用其他会话
508        let db = self.db.lock().await;
509        if let Err(e) = robit_agent::storage::activate_session(&db, &target_id, chat_id) {
510            tracing::error!("Failed to activate session: {}", e);
511            let _ = self.platform_sender.send(chat_id, &format!("❌ 切换失败:{}", e)).await;
512            return;
513        }
514        drop(db);
515
516        // 替换内存中的 Agent
517        let mut agents = self.agents.lock().await;
518        // 先移除旧的
519        agents.remove(chat_id);
520        drop(agents);
521
522        // 创建新的 Agent
523        match self.get_or_create_session(chat_id, "切换会话").await {
524            Ok((_, _frontend)) => {
525                let _ = self.platform_sender.send(
526                    chat_id,
527                    &format!("✅ 已切换到会话:{}", target_session.title)
528                ).await;
529            }
530            Err(e) => {
531                tracing::error!("Failed to create agent after switch: {}", e);
532                let _ = self.platform_sender.send(chat_id, &format!("❌ 会话加载失败:{}", e)).await;
533            }
534        }
535    }
536
537    /// Handle /help command: show available commands.
538    async fn handle_help_command(&self, chat_id: &str) {
539        let help_text = r#"🤖 Robit 帮助
540
541可用指令:
542- /clear - 清空当前对话上下文(仅内存中)
543- /stop - 停止当前执行
544- /cancel [task_id] - 取消后台任务(无参数取消全部)
545- /new - 创建新会话(旧会话归档)
546- /list - 列出所有历史会话
547- /switch <序号> - 切换到指定会话
548- /help - 显示此帮助
549
550提示:直接发送消息与机器人对话即可。"#;
551        let _ = self.platform_sender.send(chat_id, help_text).await;
552    }
553
554    /// Get an existing Agent session for `chat_id`, or create a new one.
555    async fn get_or_create_session(
556        &self,
557        chat_id: &str,
558        first_message: &str,
559    ) -> Result<(mpsc::Sender<FrontendMessage>, Arc<ChatbotFrontend>), AgentError> {
560        let mut agents = self.agents.lock().await;
561        if let Some(handle) = agents.get_mut(chat_id) {
562            handle.last_active_at = Instant::now();
563            tracing::debug!("get_or_create_session: found active agent in memory for chat_id={}, session_id={}", chat_id, handle.session_id);
564            return Ok((handle.message_tx.clone(), handle.frontend.clone()));
565        }
566        drop(agents);
567
568        // No active Agent. Check the DB for a persisted session.
569        let session_id = {
570            let db = self.db.lock().await;
571            match storage::find_session_by_chat_id(&db, chat_id)
572                .map_err(|e| AgentError::InternalError(format!("DB lookup failed: {}", e)))?
573            {
574                Some(info) => {
575                    tracing::info!("get_or_create_session: found existing session in DB for chat_id={}, session_id={}, title={}", chat_id, info.id, info.title);
576                    info.id
577                }
578                None => {
579                    // Create a new DB session record.
580                    let id = Uuid::new_v4().to_string();
581                    let title = generate_title(first_message);
582                    let model = self
583                        .config
584                        .default_model
585                        .clone()
586                        .unwrap_or_else(|| self.llm_client.model().to_string());
587                    tracing::info!("get_or_create_session: creating new session in DB for chat_id={}, session_id={}, title={}", chat_id, id, title);
588                    storage::insert_session(&db, &id, Some(chat_id), &title, &model, "qq")
589                        .map_err(|e| {
590                            AgentError::InternalError(format!("DB insert failed: {}", e))
591                        })?;
592                    id
593                }
594            }
595        };
596
597        let (tx, frontend) = self.spawn_session_agent(chat_id, &session_id).await?;
598
599        let mut agents = self.agents.lock().await;
600        agents.insert(
601            chat_id.to_string(),
602            AgentHandle {
603                message_tx: tx.clone(),
604                session_id,
605                last_active_at: Instant::now(),
606                frontend: frontend.clone(),
607            },
608        );
609        Ok((tx, frontend))
610    }
611
612    /// Create a `ChatbotFrontend` + `Agent` for a chat and spawn its loop.
613    async fn spawn_session_agent(
614        &self,
615        chat_id: &str,
616        session_id: &str,
617    ) -> Result<(mpsc::Sender<FrontendMessage>, Arc<ChatbotFrontend>), AgentError> {
618        tracing::info!("spawn_session_agent: chat_id={}, session_id={}", chat_id, session_id);
619
620        let frontend = Arc::new(ChatbotFrontend::new(
621            chat_id.to_string(),
622            session_id.to_string(),
623            Arc::clone(&self.platform_sender),
624            Arc::clone(&self.confirmer),
625            Arc::clone(&self.db),
626            self.auto_approve,
627        ));
628
629        let (message_tx, message_rx) = mpsc::channel::<FrontendMessage>(16);
630
631        // Load historical messages from database
632        tracing::debug!("spawn_session_agent: loading history messages from DB...");
633        let db = self.db.lock().await;
634        let history_messages = robit_agent::storage::load_chat_messages(&db, session_id)
635            .unwrap_or_default();
636        drop(db);
637
638        tracing::info!("spawn_session_agent: loaded {} history messages", history_messages.len());
639
640        // Parse session_id string to SessionId
641        let session_id_obj = SessionId::from(session_id.to_string());
642
643        tracing::debug!("spawn_session_agent: creating Agent with history...");
644        let agent = Agent::with_history(
645            Arc::clone(&self.llm_client),
646            Arc::clone(&self.tool_registry),
647            Arc::clone(&self.skill_registry),
648            Arc::clone(&frontend) as Arc<dyn Frontend>,
649            self.config.app.as_ref().and_then(|a| a.context.as_ref()),
650            self.context_window,
651            self.working_dir.clone(),
652            self.auto_approve,
653            {
654                let mut exts = HashMap::new();
655                let platform_ext: Arc<dyn PlatformExt> = frontend.clone();
656                exts.insert(
657                    crate::extensions::keys::PLATFORM_EXT.to_string(),
658                    PlatformExtWrapper::new(platform_ext),
659                );
660                exts
661            },
662            session_id_obj,
663            history_messages,
664        );
665
666        let sid = session_id.to_string();
667        let cid = chat_id.to_string();
668        tokio::spawn(async move {
669            agent.run(message_rx).await;
670            tracing::info!("Agent task ended for chat {} (session {})", cid, sid);
671        });
672
673        Ok((message_tx, frontend))
674    }
675
676    /// Number of currently active Agent sessions (for diagnostics / tests).
677    pub async fn active_session_count(&self) -> usize {
678        self.agents.lock().await.len()
679    }
680}
681
682/// Errors that can occur while constructing a [`ChatbotManager`].
683#[derive(Debug, thiserror::Error)]
684pub enum ManagerError {
685    #[error("Failed to resolve DB path: {0}")]
686    DbPath(#[from] robit_agent::AgentError),
687    #[error("Failed to open database: {0}")]
688    DbOpen(#[from] rusqlite::Error),
689    #[error("Failed to initialize database: {0}")]
690    DbInit(rusqlite::Error),
691}
692
693/// Generate a short session title from the first user message.
694fn generate_title(message: &str) -> String {
695    let trimmed = message.trim();
696    const MAX: usize = 30;
697    let chars: Vec<char> = trimmed.chars().take(MAX).collect();
698    let mut title: String = chars.into_iter().collect();
699    if trimmed.chars().count() > MAX {
700        title.push('…');
701    }
702    if title.is_empty() {
703        "QQ 会话".to_string()
704    } else {
705        title
706    }
707}
708
709/// Periodically remove idle in-memory Agent sessions.
710///
711/// The DB session record is preserved (persistence); only the live Agent task
712/// is dropped. Dropping the `AgentHandle` drops its `message_tx`, causing the
713/// Agent's `run()` loop to exit when it next awaits on the closed channel.
714async fn cleanup_loop(
715    _db: Arc<Mutex<Connection>>,
716    _timeout: Duration,
717    shutdown: Arc<tokio::sync::Notify>,
718) {
719    loop {
720        tokio::select! {
721            _ = tokio::time::sleep(CLEANUP_INTERVAL) => {
722                tracing::debug!("cleanup tick (no-op in MVP)");
723            }
724            _ = shutdown.notified() => {
725                tracing::debug!("cleanup loop received shutdown signal");
726                return;
727            }
728        }
729    }
730}
731
732// Keeps ToolCallInfo import referenced for the public surface documentation.
733#[allow(dead_code)]
734fn _tool_call_info_used(_i: &ToolCallInfo) {}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use crate::adapter::{ChatType, SenderInfo};
740    use std::collections::VecDeque;
741
742    /// A mock platform that queues events and records sent messages.
743    #[allow(dead_code)]
744    struct MockPlatform {
745        events: Mutex<VecDeque<PlatformEvent>>,
746        sent: std::sync::Mutex<Vec<(String, String)>>,
747    }
748
749    #[allow(dead_code)]
750    impl MockPlatform {
751        fn new() -> Arc<Self> {
752            Arc::new(Self {
753                events: Mutex::new(VecDeque::new()),
754                sent: std::sync::Mutex::new(Vec::new()),
755            })
756        }
757
758        async fn push_message(&self, chat_id: &str, text: &str) {
759            self.events.lock().await.push_back(PlatformEvent::Message(ChatMessage {
760                text: text.to_string(),
761                sender: SenderInfo {
762                    user_id: "u1".into(),
763                    chat_id: chat_id.to_string(),
764                    chat_type: ChatType::Group,
765                },
766                attachments: vec![],
767            }));
768        }
769
770        fn sent(&self) -> Vec<(String, String)> {
771            self.sent.lock().unwrap().clone()
772        }
773    }
774
775    #[async_trait]
776    impl PlatformAdapter for MockPlatform {
777        fn capabilities() -> PlatformCaps {
778            PlatformCaps::qq()
779        }
780        async fn send_message(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
781            self.sent
782                .lock()
783                .unwrap()
784                .push((chat_id.to_string(), text.to_string()));
785            Ok(SendResult { msg_id: "m1".into() })
786        }
787        async fn recv_event(&self) -> robit_agent::error::Result<PlatformEvent> {
788            // Block-ish: spin until an event is available (test injects events).
789            loop {
790                if let Some(ev) = self.events.lock().await.pop_front() {
791                    return Ok(ev);
792                }
793                tokio::time::sleep(Duration::from_millis(10)).await;
794            }
795        }
796    }
797
798    #[test]
799    fn generate_title_truncates_long_messages() {
800        let long = "x".repeat(100);
801        let title = generate_title(&long);
802        assert!(title.ends_with('…'));
803        assert!(title.chars().count() <= 31);
804    }
805
806    #[test]
807    fn generate_title_short_message() {
808        assert_eq!(generate_title("hello"), "hello");
809    }
810
811    #[test]
812    fn generate_title_empty_message() {
813        assert_eq!(generate_title("   "), "QQ 会话");
814    }
815
816    // Note: a full end-to-end manager test requires a live LLM client, so it's
817    // deferred to manual integration testing. The construction path (new) is
818    // exercised via the QQ main entry point in Phase 9.
819}