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}
43
44struct PlatformSenderBridge<T: PlatformAdapter> {
47 platform: Arc<T>,
48 caps: PlatformCaps,
49}
50
51#[async_trait]
52impl<T: PlatformAdapter> PlatformSender for PlatformSenderBridge<T> {
53 async fn send(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
54 self.platform.send_message(chat_id, text).await
55 }
56 async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> robit_agent::error::Result<()> {
57 self.platform.edit_message(chat_id, msg_id, text).await
58 }
59 async fn upload_file(
60 &self,
61 chat_id: &str,
62 file_path: &str,
63 media_type: &str,
64 ) -> robit_agent::error::Result<UploadResult> {
65 self.platform.upload_file(chat_id, file_path, media_type).await
66 }
67 async fn send_media_message(
68 &self,
69 chat_id: &str,
70 file_url: &str,
71 file_name: &str,
72 media_type: &str,
73 ) -> robit_agent::error::Result<SendResult> {
74 self.platform
75 .send_media_message(chat_id, file_url, file_name, media_type)
76 .await
77 }
78 fn capabilities(&self) -> PlatformCaps {
79 self.caps.clone()
80 }
81}
82
83pub struct ChatbotManager<T: PlatformAdapter> {
85 platform: Arc<T>,
87 agents: Mutex<HashMap<String, AgentHandle>>,
89 db: Arc<Mutex<Connection>>,
91 config: RobitConfig,
92 working_dir: PathBuf,
93 llm_client: Arc<LlmClient>,
94 tool_registry: Arc<ToolRegistry>,
95 skill_registry: Arc<SkillRegistry>,
96 platform_sender: Arc<dyn PlatformSender>,
98 confirmer: Arc<Confirmer>,
100 auto_approve: bool,
101 context_window: Option<u64>,
102 session_timeout: Duration,
104}
105
106impl<T: PlatformAdapter> ChatbotManager<T> {
107 #[allow(clippy::too_many_arguments)]
113 pub fn new(
114 platform: Arc<T>,
115 config: RobitConfig,
116 working_dir: PathBuf,
117 llm_client: Arc<LlmClient>,
118 tool_registry: Arc<ToolRegistry>,
119 skill_registry: Arc<SkillRegistry>,
120 ) -> Result<Self, ManagerError> {
121 let caps = T::capabilities();
122 let platform_sender: Arc<dyn PlatformSender> = Arc::new(PlatformSenderBridge {
123 platform: Arc::clone(&platform),
124 caps: caps.clone(),
125 });
126
127 let bot = config.app.as_ref().and_then(|a| a.bot.as_ref());
129 let auto_approve = config
130 .app
131 .as_ref()
132 .and_then(|a| a.auto_approve)
133 .unwrap_or(false);
134 let confirm_timeout = Duration::from_secs(
135 bot.and_then(|b| b.confirm_timeout_secs).unwrap_or(60),
136 );
137 let session_timeout = Duration::from_secs(
138 bot.and_then(|b| b.session_timeout_minutes).unwrap_or(30) * 60,
139 );
140 let global_storage = config
141 .app
142 .as_ref()
143 .and_then(|a| a.global_storage)
144 .unwrap_or(false);
145 let context_window = llm_client.resolved().context_window;
146
147 let confirmer = match bot.and_then(|b| b.confirm_keywords.as_ref()) {
149 Some(kw) => Confirmer::with_keywords(
150 Arc::clone(&platform_sender),
151 confirm_timeout,
152 ConfirmKeywords {
153 approve: kw.approve.clone().unwrap_or_default(),
154 reject: kw.reject.clone().unwrap_or_default(),
155 },
156 ),
157 None => Confirmer::new(Arc::clone(&platform_sender), confirm_timeout),
158 };
159 let confirmer = Arc::new(confirmer);
160
161 let db_path = resolve_db_path(&working_dir, global_storage)?;
163 if let Some(parent) = db_path.parent() {
164 let _ = std::fs::create_dir_all(parent);
165 }
166 let conn = Connection::open(&db_path).map_err(ManagerError::DbOpen)?;
167 storage::init_db(&conn).map_err(ManagerError::DbInit)?;
168 let db = Arc::new(Mutex::new(conn));
169
170 Ok(Self {
171 platform,
172 agents: Mutex::new(HashMap::new()),
173 db,
174 config,
175 working_dir,
176 llm_client,
177 tool_registry,
178 skill_registry,
179 platform_sender,
180 confirmer,
181 auto_approve,
182 context_window,
183 session_timeout,
184 })
185 }
186
187 pub async fn run(&self) -> Result<(), AgentError> {
189 let cleanup_db = Arc::clone(&self.db);
191 let session_timeout = self.session_timeout;
192 tokio::spawn(async move {
193 cleanup_loop(cleanup_db, session_timeout).await;
194 });
195
196 loop {
197 match self.platform.recv_event().await {
198 Ok(PlatformEvent::Message(msg)) => {
199 self.handle_message(msg).await;
200 }
201 Ok(PlatformEvent::Disconnected) => {
202 tracing::warn!("Platform disconnected");
203 return Ok(());
205 }
206 Ok(PlatformEvent::Other(v)) => {
207 tracing::debug!("Ignoring platform event: {}", v);
208 }
209 Err(e) => {
210 tracing::error!("Platform recv error: {}", e);
211 return Err(e);
212 }
213 }
214 }
215 }
216
217 async fn handle_message(&self, msg: ChatMessage) {
219 let chat_id = msg.sender.chat_id.clone();
220 let text = msg.text.trim().to_lowercase();
221
222 if self
224 .confirmer
225 .check_confirmation_response(&chat_id, &text)
226 .is_some()
227 {
228 return;
229 }
230
231 let media_dir = self.working_dir.join("media");
233 for attachment in &msg.attachments {
234 if let Err(e) = robit_agent::media::download_media(
235 &attachment.url,
236 attachment.filename.as_deref(),
237 &media_dir,
238 )
239 .await
240 {
241 tracing::warn!("Failed to download media: {}", e);
242 }
243 }
244
245 let attachments: Vec<robit_agent::event::MediaAttachment> =
247 msg.attachments.into_iter().map(|a| a.into()).collect();
248
249 match self.get_or_create_session(&chat_id, &msg.text).await {
251 Ok(tx) => {
252 if let Err(e) = tx
253 .send(robit_agent::event::FrontendMessage::UserInput {
254 text: msg.text,
255 attachments,
256 })
257 .await
258 {
259 tracing::warn!("Failed to send user message to agent for {}: {}", chat_id, e);
260 }
261 }
262 Err(e) => {
263 tracing::error!("Failed to get/create session for {}: {}", chat_id, e);
264 let _ = self
265 .platform_sender
266 .send(&chat_id, &format!("❌ 内部错误,无法处理消息:{}", e))
267 .await;
268 }
269 }
270 }
271
272 async fn get_or_create_session(
274 &self,
275 chat_id: &str,
276 first_message: &str,
277 ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
278 let mut agents = self.agents.lock().await;
279 if let Some(handle) = agents.get_mut(chat_id) {
280 handle.last_active_at = Instant::now();
281 return Ok(handle.message_tx.clone());
282 }
283 drop(agents);
284
285 let session_id = {
288 let db = self.db.lock().await;
289 match storage::find_session_by_chat_id(&db, chat_id)
290 .map_err(|e| AgentError::InternalError(format!("DB lookup failed: {}", e)))?
291 {
292 Some(info) => info.id,
293 None => {
294 let id = Uuid::new_v4().to_string();
296 let title = generate_title(first_message);
297 let model = self
298 .config
299 .default_model
300 .clone()
301 .unwrap_or_else(|| self.llm_client.model().to_string());
302 storage::insert_session(&db, &id, Some(chat_id), &title, &model, "qq")
303 .map_err(|e| {
304 AgentError::InternalError(format!("DB insert failed: {}", e))
305 })?;
306 id
307 }
308 }
309 };
310
311 let tx = self.spawn_session_agent(chat_id, &session_id).await?;
312
313 let mut agents = self.agents.lock().await;
314 agents.insert(
315 chat_id.to_string(),
316 AgentHandle {
317 message_tx: tx.clone(),
318 session_id,
319 last_active_at: Instant::now(),
320 },
321 );
322 Ok(tx)
323 }
324
325 async fn spawn_session_agent(
327 &self,
328 chat_id: &str,
329 session_id: &str,
330 ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
331 let frontend = Arc::new(ChatbotFrontend::new(
332 chat_id.to_string(),
333 Arc::clone(&self.platform_sender),
334 Arc::clone(&self.confirmer),
335 self.auto_approve,
336 ));
337
338 let (message_tx, message_rx) = mpsc::channel::<FrontendMessage>(16);
339
340 let db = self.db.lock().await;
342 let history_messages = robit_agent::storage::load_chat_messages(&db, session_id)
343 .unwrap_or_default();
344 drop(db);
345
346 let session_id_obj = SessionId::from(session_id.to_string());
348
349 let agent = Agent::with_history(
350 Arc::clone(&self.llm_client),
351 Arc::clone(&self.tool_registry),
352 Arc::clone(&self.skill_registry),
353 Arc::clone(&frontend) as Arc<dyn Frontend>,
354 self.config.app.as_ref().and_then(|a| a.context.as_ref()),
355 self.context_window,
356 self.working_dir.clone(),
357 self.auto_approve,
358 {
359 let mut exts = HashMap::new();
360 let platform_ext: Arc<dyn PlatformExt> = frontend.clone();
361 exts.insert(
362 crate::extensions::keys::PLATFORM_EXT.to_string(),
363 PlatformExtWrapper::new(platform_ext),
364 );
365 exts
366 },
367 session_id_obj,
368 history_messages,
369 );
370
371 let sid = session_id.to_string();
372 let cid = chat_id.to_string();
373 tokio::spawn(async move {
374 agent.run(message_rx).await;
375 tracing::info!("Agent task ended for chat {} (session {})", cid, sid);
376 });
377
378 Ok(message_tx)
379 }
380
381 pub async fn active_session_count(&self) -> usize {
383 self.agents.lock().await.len()
384 }
385}
386
387#[derive(Debug, thiserror::Error)]
389pub enum ManagerError {
390 #[error("Failed to resolve DB path: {0}")]
391 DbPath(#[from] robit_agent::AgentError),
392 #[error("Failed to open database: {0}")]
393 DbOpen(#[from] rusqlite::Error),
394 #[error("Failed to initialize database: {0}")]
395 DbInit(rusqlite::Error),
396}
397
398fn generate_title(message: &str) -> String {
400 let trimmed = message.trim();
401 const MAX: usize = 30;
402 let chars: Vec<char> = trimmed.chars().take(MAX).collect();
403 let mut title: String = chars.into_iter().collect();
404 if trimmed.chars().count() > MAX {
405 title.push('…');
406 }
407 if title.is_empty() {
408 "QQ 会话".to_string()
409 } else {
410 title
411 }
412}
413
414async fn cleanup_loop(_db: Arc<Mutex<Connection>>, _timeout: Duration) {
420 loop {
426 tokio::time::sleep(CLEANUP_INTERVAL).await;
427 tracing::debug!("cleanup tick (no-op in MVP)");
428 }
429}
430
431#[allow(dead_code)]
433fn _tool_call_info_used(_i: &ToolCallInfo) {}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::adapter::{ChatType, SenderInfo};
439 use std::collections::VecDeque;
440
441 #[allow(dead_code)]
443 struct MockPlatform {
444 events: Mutex<VecDeque<PlatformEvent>>,
445 sent: std::sync::Mutex<Vec<(String, String)>>,
446 }
447
448 #[allow(dead_code)]
449 impl MockPlatform {
450 fn new() -> Arc<Self> {
451 Arc::new(Self {
452 events: Mutex::new(VecDeque::new()),
453 sent: std::sync::Mutex::new(Vec::new()),
454 })
455 }
456
457 async fn push_message(&self, chat_id: &str, text: &str) {
458 self.events.lock().await.push_back(PlatformEvent::Message(ChatMessage {
459 text: text.to_string(),
460 sender: SenderInfo {
461 user_id: "u1".into(),
462 chat_id: chat_id.to_string(),
463 chat_type: ChatType::Group,
464 },
465 attachments: vec![],
466 }));
467 }
468
469 fn sent(&self) -> Vec<(String, String)> {
470 self.sent.lock().unwrap().clone()
471 }
472 }
473
474 #[async_trait]
475 impl PlatformAdapter for MockPlatform {
476 fn capabilities() -> PlatformCaps {
477 PlatformCaps::qq()
478 }
479 async fn send_message(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
480 self.sent
481 .lock()
482 .unwrap()
483 .push((chat_id.to_string(), text.to_string()));
484 Ok(SendResult { msg_id: "m1".into() })
485 }
486 async fn recv_event(&self) -> robit_agent::error::Result<PlatformEvent> {
487 loop {
489 if let Some(ev) = self.events.lock().await.pop_front() {
490 return Ok(ev);
491 }
492 tokio::time::sleep(Duration::from_millis(10)).await;
493 }
494 }
495 }
496
497 #[test]
498 fn generate_title_truncates_long_messages() {
499 let long = "x".repeat(100);
500 let title = generate_title(&long);
501 assert!(title.ends_with('…'));
502 assert!(title.chars().count() <= 31);
503 }
504
505 #[test]
506 fn generate_title_short_message() {
507 assert_eq!(generate_title("hello"), "hello");
508 }
509
510 #[test]
511 fn generate_title_empty_message() {
512 assert_eq!(generate_title(" "), "QQ 会话");
513 }
514
515 }