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(&self) -> Result<(), AgentError> {
191 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 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 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 self
226 .confirmer
227 .check_confirmation_response(&chat_id, &text)
228 .is_some()
229 {
230 return;
231 }
232
233 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 let attachments: Vec<robit_agent::event::MediaAttachment> =
249 msg.attachments.into_iter().map(|a| a.into()).collect();
250
251 match self.get_or_create_session(&chat_id, &msg.text).await {
253 Ok((tx, frontend)) => {
254 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 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 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 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 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 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 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 pub async fn active_session_count(&self) -> usize {
401 self.agents.lock().await.len()
402 }
403}
404
405#[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
416fn 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
432async fn cleanup_loop(_db: Arc<Mutex<Connection>>, _timeout: Duration) {
438 loop {
444 tokio::time::sleep(CLEANUP_INTERVAL).await;
445 tracing::debug!("cleanup tick (no-op in MVP)");
446 }
447}
448
449#[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 #[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 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 }