1use 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
33const CLEANUP_INTERVAL: Duration = Duration::from_secs(300); pub struct AgentHandle {
38 pub message_tx: mpsc::Sender<FrontendMessage>,
40 pub session_id: String,
41 pub last_active_at: Instant,
42 pub frontend: Arc<ChatbotFrontend>,
44}
45
46struct 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
85pub struct ChatbotManager<T: PlatformAdapter> {
87 platform: Arc<T>,
89 agents: Mutex<HashMap<String, AgentHandle>>,
91 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 platform_sender: Arc<dyn PlatformSender>,
100 confirmer: Arc<Confirmer>,
102 auto_approve: bool,
103 context_window: Option<u64>,
104 session_timeout: Duration,
106}
107
108impl<T: PlatformAdapter> ChatbotManager<T> {
109 #[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 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 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 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 pub async fn run(
193 &self,
194 shutdown: Arc<tokio::sync::Notify>,
195 ) -> Result<(), AgentError> {
196 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 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 self
239 .confirmer
240 .check_confirmation_response(&chat_id, &text)
241 .is_some()
242 {
243 return;
244 }
245
246 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 let attachments: Vec<robit_agent::event::MediaAttachment> =
262 msg.attachments.into_iter().map(|a| a.into()).collect();
263
264 match self.get_or_create_session(&chat_id, &msg.text).await {
266 Ok((tx, frontend)) => {
267 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 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 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 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 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 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 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 pub async fn active_session_count(&self) -> usize {
414 self.agents.lock().await.len()
415 }
416}
417
418#[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
429fn 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
445async 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#[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 #[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 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 }