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;
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 .map_err(ManagerError::DbPath)?;
164 if let Some(parent) = db_path.parent() {
165 let _ = std::fs::create_dir_all(parent);
166 }
167 let conn = Connection::open(&db_path).map_err(ManagerError::DbOpen)?;
168 storage::init_db(&conn).map_err(ManagerError::DbInit)?;
169 let db = Arc::new(Mutex::new(conn));
170
171 Ok(Self {
172 platform,
173 agents: Mutex::new(HashMap::new()),
174 db,
175 config,
176 working_dir,
177 llm_client,
178 tool_registry,
179 skill_registry,
180 platform_sender,
181 confirmer,
182 auto_approve,
183 context_window,
184 session_timeout,
185 })
186 }
187
188 pub async fn run(&self) -> Result<(), AgentError> {
190 let cleanup_db = Arc::clone(&self.db);
192 let session_timeout = self.session_timeout;
193 tokio::spawn(async move {
194 cleanup_loop(cleanup_db, session_timeout).await;
195 });
196
197 loop {
198 match self.platform.recv_event().await {
199 Ok(PlatformEvent::Message(msg)) => {
200 self.handle_message(msg).await;
201 }
202 Ok(PlatformEvent::Disconnected) => {
203 tracing::warn!("Platform disconnected");
204 return Ok(());
206 }
207 Ok(PlatformEvent::Other(v)) => {
208 tracing::debug!("Ignoring platform event: {}", v);
209 }
210 Err(e) => {
211 tracing::error!("Platform recv error: {}", e);
212 return Err(e);
213 }
214 }
215 }
216 }
217
218 async fn handle_message(&self, msg: ChatMessage) {
220 let chat_id = msg.sender.chat_id.clone();
221 let text = msg.text.trim().to_lowercase();
222
223 if self
225 .confirmer
226 .check_confirmation_response(&chat_id, &text)
227 .is_some()
228 {
229 return;
230 }
231
232 let media_dir = self.working_dir.join("media");
234 for attachment in &msg.attachments {
235 if let Err(e) = robit_agent::media::download_media(
236 &attachment.url,
237 attachment.filename.as_deref(),
238 &media_dir,
239 )
240 .await
241 {
242 tracing::warn!("Failed to download media: {}", e);
243 }
244 }
245
246 let attachments: Vec<robit_agent::event::MediaAttachment> =
248 msg.attachments.into_iter().map(|a| a.into()).collect();
249
250 match self.get_or_create_session(&chat_id, &msg.text).await {
252 Ok(tx) => {
253 if let Err(e) = tx
254 .send(robit_agent::event::FrontendMessage::UserInput {
255 text: msg.text,
256 attachments,
257 })
258 .await
259 {
260 tracing::warn!("Failed to send user message to agent for {}: {}", chat_id, e);
261 }
262 }
263 Err(e) => {
264 tracing::error!("Failed to get/create session for {}: {}", chat_id, e);
265 let _ = self
266 .platform_sender
267 .send(&chat_id, &format!("❌ 内部错误,无法处理消息:{}", e))
268 .await;
269 }
270 }
271 }
272
273 async fn get_or_create_session(
275 &self,
276 chat_id: &str,
277 first_message: &str,
278 ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
279 let mut agents = self.agents.lock().await;
280 if let Some(handle) = agents.get_mut(chat_id) {
281 handle.last_active_at = Instant::now();
282 return Ok(handle.message_tx.clone());
283 }
284 drop(agents);
285
286 let session_id = {
289 let db = self.db.lock().await;
290 match storage::find_session_by_chat_id(&db, chat_id)
291 .map_err(|e| AgentError::InternalError(format!("DB lookup failed: {}", e)))?
292 {
293 Some(info) => info.id,
294 None => {
295 let id = Uuid::new_v4().to_string();
297 let title = generate_title(first_message);
298 let model = self
299 .config
300 .default_model
301 .clone()
302 .unwrap_or_else(|| self.llm_client.model().to_string());
303 storage::insert_session(&db, &id, Some(chat_id), &title, &model, "qq")
304 .map_err(|e| {
305 AgentError::InternalError(format!("DB insert failed: {}", e))
306 })?;
307 id
308 }
309 }
310 };
311
312 let tx = self.spawn_session_agent(chat_id, &session_id).await?;
313
314 let mut agents = self.agents.lock().await;
315 agents.insert(
316 chat_id.to_string(),
317 AgentHandle {
318 message_tx: tx.clone(),
319 session_id,
320 last_active_at: Instant::now(),
321 },
322 );
323 Ok(tx)
324 }
325
326 async fn spawn_session_agent(
328 &self,
329 chat_id: &str,
330 session_id: &str,
331 ) -> Result<mpsc::Sender<FrontendMessage>, AgentError> {
332 let frontend = Arc::new(ChatbotFrontend::new(
333 chat_id.to_string(),
334 Arc::clone(&self.platform_sender),
335 Arc::clone(&self.confirmer),
336 self.auto_approve,
337 ));
338
339 let (message_tx, message_rx) = mpsc::channel::<FrontendMessage>(16);
340
341 let agent = Agent::new(
342 Arc::clone(&self.llm_client),
343 Arc::clone(&self.tool_registry),
344 Arc::clone(&self.skill_registry),
345 Arc::clone(&frontend) as Arc<dyn Frontend>,
346 self.config.app.as_ref().and_then(|a| a.context.as_ref()),
347 self.context_window,
348 self.working_dir.clone(),
349 self.auto_approve,
350 {
351 let mut exts = HashMap::new();
352 let platform_ext: Arc<dyn PlatformExt> = frontend.clone();
353 exts.insert(
354 crate::extensions::keys::PLATFORM_EXT.to_string(),
355 PlatformExtWrapper::new(platform_ext),
356 );
357 exts
358 },
359 );
360
361 let sid = session_id.to_string();
362 let cid = chat_id.to_string();
363 tokio::spawn(async move {
364 agent.run(message_rx).await;
365 tracing::info!("Agent task ended for chat {} (session {})", cid, sid);
366 });
367
368 Ok(message_tx)
369 }
370
371 pub async fn active_session_count(&self) -> usize {
373 self.agents.lock().await.len()
374 }
375}
376
377#[derive(Debug, thiserror::Error)]
379pub enum ManagerError {
380 #[error("Failed to resolve DB path: {0}")]
381 DbPath(String),
382 #[error("Failed to open database: {0}")]
383 DbOpen(#[from] rusqlite::Error),
384 #[error("Failed to initialize database: {0}")]
385 DbInit(rusqlite::Error),
386}
387
388fn generate_title(message: &str) -> String {
390 let trimmed = message.trim();
391 const MAX: usize = 30;
392 let chars: Vec<char> = trimmed.chars().take(MAX).collect();
393 let mut title: String = chars.into_iter().collect();
394 if trimmed.chars().count() > MAX {
395 title.push('…');
396 }
397 if title.is_empty() {
398 "QQ 会话".to_string()
399 } else {
400 title
401 }
402}
403
404async fn cleanup_loop(_db: Arc<Mutex<Connection>>, _timeout: Duration) {
410 loop {
416 tokio::time::sleep(CLEANUP_INTERVAL).await;
417 tracing::debug!("cleanup tick (no-op in MVP)");
418 }
419}
420
421#[allow(dead_code)]
423fn _tool_call_info_used(_i: &ToolCallInfo) {}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::adapter::{ChatType, SenderInfo};
429 use std::collections::VecDeque;
430
431 #[allow(dead_code)]
433 struct MockPlatform {
434 events: Mutex<VecDeque<PlatformEvent>>,
435 sent: std::sync::Mutex<Vec<(String, String)>>,
436 }
437
438 #[allow(dead_code)]
439 impl MockPlatform {
440 fn new() -> Arc<Self> {
441 Arc::new(Self {
442 events: Mutex::new(VecDeque::new()),
443 sent: std::sync::Mutex::new(Vec::new()),
444 })
445 }
446
447 async fn push_message(&self, chat_id: &str, text: &str) {
448 self.events.lock().await.push_back(PlatformEvent::Message(ChatMessage {
449 text: text.to_string(),
450 sender: SenderInfo {
451 user_id: "u1".into(),
452 chat_id: chat_id.to_string(),
453 chat_type: ChatType::Group,
454 },
455 attachments: vec![],
456 }));
457 }
458
459 fn sent(&self) -> Vec<(String, String)> {
460 self.sent.lock().unwrap().clone()
461 }
462 }
463
464 #[async_trait]
465 impl PlatformAdapter for MockPlatform {
466 fn capabilities() -> PlatformCaps {
467 PlatformCaps::qq()
468 }
469 async fn send_message(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
470 self.sent
471 .lock()
472 .unwrap()
473 .push((chat_id.to_string(), text.to_string()));
474 Ok(SendResult { msg_id: "m1".into() })
475 }
476 async fn recv_event(&self) -> robit_agent::error::Result<PlatformEvent> {
477 loop {
479 if let Some(ev) = self.events.lock().await.pop_front() {
480 return Ok(ev);
481 }
482 tokio::time::sleep(Duration::from_millis(10)).await;
483 }
484 }
485 }
486
487 #[test]
488 fn generate_title_truncates_long_messages() {
489 let long = "x".repeat(100);
490 let title = generate_title(&long);
491 assert!(title.ends_with('…'));
492 assert!(title.chars().count() <= 31);
493 }
494
495 #[test]
496 fn generate_title_short_message() {
497 assert_eq!(generate_title("hello"), "hello");
498 }
499
500 #[test]
501 fn generate_title_empty_message() {
502 assert_eq!(generate_title(" "), "QQ 会话");
503 }
504
505 }