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 memory = robit_agent::memory::resolve_memory_settings(&self.config, &self.working_dir)?;
645 let agent = Agent::with_history(
646 Arc::clone(&self.llm_client),
647 Arc::clone(&self.tool_registry),
648 Arc::clone(&self.skill_registry),
649 Arc::clone(&frontend) as Arc<dyn Frontend>,
650 self.config.app.as_ref().and_then(|a| a.context.as_ref()),
651 self.context_window,
652 self.working_dir.clone(),
653 self.auto_approve,
654 {
655 let mut exts = HashMap::new();
656 let platform_ext: Arc<dyn PlatformExt> = frontend.clone();
657 exts.insert(
658 crate::extensions::keys::PLATFORM_EXT.to_string(),
659 PlatformExtWrapper::new(platform_ext),
660 );
661 exts
662 },
663 session_id_obj,
664 history_messages,
665 memory,
666 );
667
668 let sid = session_id.to_string();
669 let cid = chat_id.to_string();
670 tokio::spawn(async move {
671 agent.run(message_rx).await;
672 tracing::info!("Agent task ended for chat {} (session {})", cid, sid);
673 });
674
675 Ok((message_tx, frontend))
676 }
677
678 pub async fn active_session_count(&self) -> usize {
680 self.agents.lock().await.len()
681 }
682}
683
684#[derive(Debug, thiserror::Error)]
686pub enum ManagerError {
687 #[error("Failed to resolve DB path: {0}")]
688 DbPath(#[from] robit_agent::AgentError),
689 #[error("Failed to open database: {0}")]
690 DbOpen(#[from] rusqlite::Error),
691 #[error("Failed to initialize database: {0}")]
692 DbInit(rusqlite::Error),
693}
694
695fn generate_title(message: &str) -> String {
697 let trimmed = message.trim();
698 const MAX: usize = 30;
699 let chars: Vec<char> = trimmed.chars().take(MAX).collect();
700 let mut title: String = chars.into_iter().collect();
701 if trimmed.chars().count() > MAX {
702 title.push('…');
703 }
704 if title.is_empty() {
705 "QQ 会话".to_string()
706 } else {
707 title
708 }
709}
710
711async fn cleanup_loop(
717 _db: Arc<Mutex<Connection>>,
718 _timeout: Duration,
719 shutdown: Arc<tokio::sync::Notify>,
720) {
721 loop {
722 tokio::select! {
723 _ = tokio::time::sleep(CLEANUP_INTERVAL) => {
724 tracing::debug!("cleanup tick (no-op in MVP)");
725 }
726 _ = shutdown.notified() => {
727 tracing::debug!("cleanup loop received shutdown signal");
728 return;
729 }
730 }
731 }
732}
733
734#[allow(dead_code)]
736fn _tool_call_info_used(_i: &ToolCallInfo) {}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::adapter::{ChatType, SenderInfo};
742 use std::collections::VecDeque;
743
744 #[allow(dead_code)]
746 struct MockPlatform {
747 events: Mutex<VecDeque<PlatformEvent>>,
748 sent: std::sync::Mutex<Vec<(String, String)>>,
749 }
750
751 #[allow(dead_code)]
752 impl MockPlatform {
753 fn new() -> Arc<Self> {
754 Arc::new(Self {
755 events: Mutex::new(VecDeque::new()),
756 sent: std::sync::Mutex::new(Vec::new()),
757 })
758 }
759
760 async fn push_message(&self, chat_id: &str, text: &str) {
761 self.events.lock().await.push_back(PlatformEvent::Message(ChatMessage {
762 text: text.to_string(),
763 sender: SenderInfo {
764 user_id: "u1".into(),
765 chat_id: chat_id.to_string(),
766 chat_type: ChatType::Group,
767 },
768 attachments: vec![],
769 }));
770 }
771
772 fn sent(&self) -> Vec<(String, String)> {
773 self.sent.lock().unwrap().clone()
774 }
775 }
776
777 #[async_trait]
778 impl PlatformAdapter for MockPlatform {
779 fn capabilities() -> PlatformCaps {
780 PlatformCaps::qq()
781 }
782 async fn send_message(&self, chat_id: &str, text: &str) -> robit_agent::error::Result<SendResult> {
783 self.sent
784 .lock()
785 .unwrap()
786 .push((chat_id.to_string(), text.to_string()));
787 Ok(SendResult { msg_id: "m1".into() })
788 }
789 async fn recv_event(&self) -> robit_agent::error::Result<PlatformEvent> {
790 loop {
792 if let Some(ev) = self.events.lock().await.pop_front() {
793 return Ok(ev);
794 }
795 tokio::time::sleep(Duration::from_millis(10)).await;
796 }
797 }
798 }
799
800 #[test]
801 fn generate_title_truncates_long_messages() {
802 let long = "x".repeat(100);
803 let title = generate_title(&long);
804 assert!(title.ends_with('…'));
805 assert!(title.chars().count() <= 31);
806 }
807
808 #[test]
809 fn generate_title_short_message() {
810 assert_eq!(generate_title("hello"), "hello");
811 }
812
813 #[test]
814 fn generate_title_empty_message() {
815 assert_eq!(generate_title(" "), "QQ 会话");
816 }
817
818 }