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 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 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 let attachments: Vec<robit_agent::event::MediaAttachment> =
299 msg.attachments.into_iter().map(|a| a.into()).collect();
300
301 match self.get_or_create_session(&chat_id, &msg.text).await {
303 Ok((tx, frontend)) => {
304 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 async fn handle_clear_command(&self, chat_id: &str) {
329 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 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 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 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 async fn handle_new_command(&self, chat_id: &str) {
392 let mut agents = self.agents.lock().await;
393
394 if let Some(old_handle) = agents.remove(chat_id) {
396 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 drop(old_handle);
405 }
406 drop(agents);
407
408 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 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 async fn handle_switch_command(&self, chat_id: &str, arg: &str) {
461 let trimmed_arg = arg.trim();
462
463 let session_index = match trimmed_arg.parse::<usize>() {
465 Ok(n) if n > 0 => n - 1, _ => {
467 let _ = self.platform_sender.send(chat_id, "❌ 请输入有效的会话序号,如 /switch 1").await;
468 return;
469 }
470 };
471
472 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 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 {
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 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 let mut agents = self.agents.lock().await;
518 agents.remove(chat_id);
520 drop(agents);
521
522 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 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 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 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 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 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 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 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 pub async fn active_session_count(&self) -> usize {
678 self.agents.lock().await.len()
679 }
680}
681
682#[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
693fn 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
709async 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#[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 #[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 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 }