diff --git a/main.py b/main.py index 413591a..41e5ffb 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,8 @@ """ -群日常分析插件 -基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等 +Plugin phân tích hoạt động nhóm hằng ngày. -重构版本 - 使用模块化架构,支持跨平台 +Tạo báo cáo từ lịch sử trò chuyện, gồm tóm tắt chủ đề, hồ sơ thành viên và +thống kê. Phiên bản module hoá hỗ trợ đa nền tảng. """ import asyncio @@ -55,9 +55,9 @@ from .src.utils.resilience import GlobalRateLimiter class GroupDailyAnalysis(Star): - """群分析插件主类""" + """Lớp plugin phân tích nhóm chính.""" - # ── 显式类型声明 (由 __init__ 初始化) ── + # ── Khai báo kiểu tường minh, được khởi tạo trong __init__ ── config: AstrBotConfig config_manager: ConfigManager bot_manager: BotManager @@ -82,7 +82,7 @@ class GroupDailyAnalysis(Star): super().__init__(context) self.config = config - # 1. 基础设施层 + # 1. Tầng infrastructure. self.config_manager = ConfigManager(config) self.bot_manager = BotManager(self.config_manager) self.bot_manager.set_context(context) @@ -93,22 +93,22 @@ class GroupDailyAnalysis(Star): self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir) - # Telegram 注册表 (持久层) + # Registry Telegram ở tầng persistence. self.platform_group_registry = PlatformGroupRegistry(self) - # 2. 领域层 + # 2. Tầng domain. activity_visualizer = ActivityVisualizer() self.statistics_service = StatisticsService(activity_visualizer) self.analysis_domain_service = AnalysisDomainService() - # 3. 分析核心 (LLM Bridge) + # 3. Lõi phân tích, cầu nối LLM. self.llm_analyzer = LLMAnalyzer(context, self.config_manager) - # 4. 增量分析组件 + # 4. Thành phần phân tích gia tăng. self.incremental_store = IncrementalStore(self) self.incremental_merge_service = IncrementalMergeService() - # 5. 应用层 + # 5. Tầng application. self.analysis_service = AnalysisApplicationService( self.config_manager, self.bot_manager, @@ -121,7 +121,7 @@ class GroupDailyAnalysis(Star): incremental_merge_service=self.incremental_merge_service, ) - # 消息处理服务 + # Dịch vụ xử lý tin nhắn. self.message_processing_service = MessageProcessingService( context, self.platform_group_registry ) @@ -136,7 +136,7 @@ class GroupDailyAnalysis(Star): handlers=[self.telegram_template_preview_handler] ) - # 调度与发送 + # Lập lịch và gửi. self.message_sender = MessageSender(self.bot_manager, self.config_manager) self.auto_scheduler = AutoScheduler( self.config_manager, @@ -147,15 +147,15 @@ class GroupDailyAnalysis(Star): plugin_instance=self, ) - # 同步全局限流并进行初始化配置 + # Đồng bộ cấu hình bộ giới hạn toàn cục. GlobalRateLimiter.get_instance(self.config_manager.get_llm_max_concurrent()) self._initialized = False - self._terminating = False # 生命周期标志 + self._terminating = False # Cờ vòng đời. self._init_lock = asyncio.Lock() self._background_tasks: set[asyncio.Task] = set() - # 异步注册任务,处理插件重载情况 + # Đăng ký tác vụ bất đồng bộ để xử lý reload plugin. try: loop = asyncio.get_running_loop() self._init_task = loop.create_task( @@ -166,18 +166,18 @@ class GroupDailyAnalysis(Star): except RuntimeError: self._init_task = None - # orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。 - # 如果需要高性能缓存,后续可由 AnalysisApplicationService 内部维护。 + # Cache orchestrator đã chuyển vào application service hoặc tạm bỏ để đơn giản. + # Nếu cần cache hiệu năng cao, AnalysisApplicationService có thể quản lý nội bộ. @filter.on_platform_loaded() async def on_platform_loaded(self): - """平台加载完成后初始化""" + """Khởi tạo sau khi nền tảng tải xong.""" await self._run_initialization("Platform Loaded") async def _run_initialization(self, source: str): - """统一初始化逻辑""" + """Logic khởi tạo thống nhất.""" async with self._init_lock: - # 如果已经成功发现过平台,且不是来自 Platform Loaded 的强制触发,则跳过 + # Bỏ qua nếu đã phát hiện nền tảng và không phải trigger Platform Loaded. if ( self._initialized and self.bot_manager @@ -186,77 +186,78 @@ class GroupDailyAnalysis(Star): ): return - # 稍微延迟,确保 context 和环境稳定 - # 针对极少数环境,2秒可能不足以让平台管理器就绪,增加到 5秒 + # Chờ để context, môi trường và platform manager ổn định. await asyncio.sleep(5) - # [加固] 如果在等待期间插件已被卸载(terminate),则直接退出 + # Thoát nếu plugin đã bị gỡ trong thời gian chờ. if not self.bot_manager: return try: - # 注册 TraceID 过滤器 + # Đăng ký bộ lọc TraceID. trace_filter = TraceLogFilter() if not any( isinstance(f, TraceLogFilter) for f in astrbot_logger.filters ): astrbot_logger.addFilter(trace_filter) - astrbot_logger.info("[Trace] TraceID 日志追踪已启用") + astrbot_logger.info("[Trace] Đã bật theo dõi log bằng TraceID") - logger.info(f"正在执行插件初始化 (来源: {source})...") + logger.info(f"Đang khởi tạo plugin (nguồn: {source})...") - # 0. 自动升级旧版 prompt 模板(str.format -> string.Template)并回写配置 + # 0. Tự nâng cấp prompt cũ từ str.format sang string.Template. try: self.config_manager.upgrade_prompt_templates() except Exception as e: - logger.warning(f"自动升级 prompt 模板失败: {e}") + logger.warning(f"Tự nâng cấp template prompt thất bại: {e}") - # 1. 尝试发现 bot 实例 + # 1. Thử phát hiện instance bot. await self.bot_manager.initialize_from_config() - # 2. 注册预览路由器 + # 2. Đăng ký router preview. if self.template_preview_router: await self.template_preview_router.ensure_handlers_registered( self.context ) - # 3. 强制注册定时分析任务 + # 3. Đăng ký tác vụ phân tích định kỳ. if self.auto_scheduler: self.auto_scheduler.schedule_jobs(self.context) self._initialized = True self._discovery_run = True - logger.info(f"插件任务注册完成 (来源: {source})") + logger.info(f"Hoàn tất đăng ký tác vụ plugin (nguồn: {source})") except Exception as e: - logger.error(f"插件初始化失败: {e}", exc_info=True) + logger.error(f"Khởi tạo plugin thất bại: {e}", exc_info=True) async def terminate(self): - """插件被卸载/停用时调用,清理资源""" + """Dọn tài nguyên khi plugin bị gỡ hoặc vô hiệu hoá.""" if self._terminating: return self._terminating = True try: - logger.info("开始清理群日常分析插件资源...") + logger.info("Bắt đầu dọn tài nguyên plugin phân tích nhóm...") - # 1. 停止所有后台任务 + # 1. Dừng mọi tác vụ đang chạy. if self._background_tasks: - logger.info(f"正在取消 {len(self._background_tasks)} 个运行中的任务...") + logger.info( + f"Đang huỷ {len(self._background_tasks)} tác vụ đang chạy..." + ) for task in self._background_tasks: if not task.done(): task.cancel() - # 等待任务结束,给予 3 秒宽限期 + # Chờ tác vụ kết thúc với thời gian gia hạn 3 giây. try: await asyncio.wait(list(self._background_tasks), timeout=3.0) except Exception: pass self._background_tasks.clear() - # 2. 停止各个组件 (顺序:先调度器,后底层服务) + # 2. Dừng các thành phần: scheduler trước, dịch vụ tầng dưới sau. if self.auto_scheduler: - logger.debug("正在停止自动调度器...") + logger.debug("Đang dừng bộ lập lịch tự động...") self.auto_scheduler.unschedule_jobs(self.context) if self.template_preview_router: @@ -265,32 +266,29 @@ class GroupDailyAnalysis(Star): if self.report_generator: await self.report_generator.close() - # 3. [关键修复] 只有在任务全部清理后,才清理引用。 - # 实际上,在 terminate 结束后,self 本身就会被 GC 释放, - # 这里的显式 None 更多是为了协助循环引用清理,但由于异步任务存在竞态, - # 我们可以通过 check _terminating 标志位来保护。 - # 为了彻底解决 #125,我们保留引用,让 GC 自然回收。 - logger.info("群日常分析插件资源清理完成") + # 3. Chỉ dọn tham chiếu sau khi mọi tác vụ đã kết thúc. + # Giữ tham chiếu để GC thu hồi tự nhiên, tránh race với tác vụ bất đồng bộ (#125). + logger.info("Hoàn tất dọn tài nguyên plugin phân tích nhóm") except Exception as e: - logger.error(f"插件资源清理失败: {e}") + logger.error(f"Dọn tài nguyên plugin thất bại: {e}") - # ==================== Telegram 消息拦截器 ==================== + # ==================== Bộ chặn tin nhắn Telegram ==================== @filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE) @filter.platform_adapter_type(filter.PlatformAdapterType.TELEGRAM) async def intercept_telegram_messages(self, event: AstrMessageEvent): """ - 拦截 Telegram 群消息并存储到数据库 + Chặn tin nhắn nhóm Telegram và lưu vào cơ sở dữ liệu. - 委托给 MessageProcessingService 处理 + Uỷ quyền xử lý cho MessageProcessingService. """ try: await self.message_processing_service.process_message(event) except (ValueError, RuntimeError) as e: - logger.warning(f"[Telegram] 消息存储失败: {e}") + logger.warning(f"[Telegram] Lưu tin nhắn thất bại: {e}") except Exception as e: - logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True) + logger.error(f"[Telegram] Lỗi lưu tin nhắn: {e}", exc_info=True) @filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE) @filter.platform_adapter_type( @@ -298,7 +296,7 @@ class GroupDailyAnalysis(Star): | filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK ) async def intercept_qq_official_messages(self, event: AstrMessageEvent): - """缓存 QQ 官方机器人群消息;频道消息不在本插件适配范围内。""" + """Cache tin nhắn nhóm QQ Official; không xử lý tin nhắn kênh.""" raw_message = getattr(getattr(event, "message_obj", None), "raw_message", None) if isinstance(raw_message, dict): author = raw_message.get("author") or {} @@ -316,23 +314,23 @@ class GroupDailyAnalysis(Star): try: await self.message_processing_service.process_message(event) except (ValueError, RuntimeError) as e: - logger.warning(f"[QQOfficial] 消息存储失败: {e}") + logger.warning(f"[QQOfficial] Lưu tin nhắn thất bại: {e}") except Exception as e: - logger.error(f"[QQOfficial] 消息存储异常: {e}", exc_info=True) + logger.error(f"[QQOfficial] Lỗi lưu tin nhắn: {e}", exc_info=True) async def get_telegram_seen_group_ids( self, platform_id: str | None = None ) -> list[str]: - """读取 Telegram 已见群/话题列表(给调度器回退使用)。""" + """Đọc nhóm/chủ đề Telegram đã thấy cho scheduler fallback.""" return await self.platform_group_registry.get_all_group_ids(platform_id) async def get_seen_group_ids(self, platform_id: str | None = None) -> list[str]: - """读取任意事件驱动平台已经见过的群组。""" + """Đọc các nhóm đã thấy trên mọi nền tảng hướng sự kiện.""" return await self.platform_group_registry.get_all_group_ids(platform_id) def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None: - """从消息事件中安全获取群组 ID""" - # 保留此辅助方法,因为在其他 command 中仍被频繁使用 + """Lấy an toàn ID nhóm từ sự kiện tin nhắn.""" + # Giữ helper này vì nhiều command khác vẫn dùng. try: group_id = event.get_group_id() return group_id if group_id else None @@ -340,12 +338,12 @@ class GroupDailyAnalysis(Star): return None def _get_platform_id_from_event(self, event: AstrMessageEvent) -> str: - """从消息事件中获取平台唯一 ID""" - # 保留此辅助方法,因为在其他 command 中仍被频繁使用 + """Lấy ID nền tảng duy nhất từ sự kiện tin nhắn.""" + # Giữ helper này vì nhiều command khác vẫn dùng. try: return event.get_platform_id() except Exception: - # 后备方案:从元数据获取 + # Fallback: lấy từ metadata. if ( hasattr(event, "platform_meta") and event.platform_meta @@ -355,12 +353,12 @@ class GroupDailyAnalysis(Star): return "default" # ================================================================ - # 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式) + # Upload báo cáo ảnh vào tệp/album nhóm, chỉ cho định dạng ảnh trên QQ. # ================================================================ async def _try_upload_image(self, group_id: str, image_url: str, platform_id: str): """ - 尝试将图片报告上传到群文件和/或群相册(静默处理,失败仅日志提示)。 + Thử upload báo cáo ảnh vào tệp và/hoặc album nhóm; lỗi chỉ ghi log. """ import base64 import re @@ -376,12 +374,12 @@ class GroupDailyAnalysis(Star): if not adapter or not hasattr(adapter, "upload_group_file_to_folder"): return - # 1. 构造一个更友好的文件名 + # 1. Tạo tên tệp thân thiện hơn. now = datetime.now() timestamp = now.strftime("%H%M") date_str = now.strftime("%Y-%m-%d") - # 默认基础名和后缀 + # Tên cơ sở và phần mở rộng mặc định. ext = ( ".jpg" if (".jpg" in image_url.lower() or ".jpeg" in image_url.lower()) @@ -390,41 +388,41 @@ class GroupDailyAnalysis(Star): nice_filename = f"bao_cao_phan_tich_nhom_{group_id}_{date_str}_{timestamp}{ext}" try: - # 尝试通过适配器获取群名称,使文件名更具辨识度 + # Thử lấy tên nhóm qua adapter để tên tệp dễ nhận diện hơn. group_info = await adapter.get_group_info(group_id) if group_info and group_info.group_name: - # 过滤非法文件名字符:\ / : * ? " < > | + # Lọc ký tự không hợp lệ trong tên tệp: \ / : * ? " < > | safe_name = re.sub(r'[\\/:*?"<>|]', "", group_info.group_name).strip() if safe_name: nice_filename = f"bao_cao_phan_tich_nhom_{safe_name}_{date_str}_{timestamp}{ext}" except Exception: pass - # 2. 将内容准备为文件或数据 + # 2. Chuẩn bị nội dung dưới dạng tệp hoặc dữ liệu. image_file = None created_temp = False - MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # 20MB 限制 + MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # Giới hạn 20 MB. try: data = None if image_url.startswith("base64://"): base64_str = image_url[len("base64://") :] if len(base64_str) * 3 / 4 > MAX_PAYLOAD_SIZE: - logger.warning("图片上传失败:Base64 负载过大") + logger.warning("Upload ảnh thất bại: payload Base64 quá lớn") return data = base64.b64decode(base64_str) elif image_url.startswith("data:"): parts = image_url.split(",", 1) if len(parts) == 2: if len(parts[1]) * 3 / 4 > MAX_PAYLOAD_SIZE: - logger.warning("图片上传失败:Data URI 负载过大") + logger.warning("Upload ảnh thất bại: payload Data URI quá lớn") return data = base64.b64decode(parts[1]) elif os.path.isfile(image_url): image_file = os.path.abspath(image_url) if data and not image_file: - # 使用 tempfile 生成唯一后缀,防止并发冲突 + # Dùng tempfile tạo hậu tố duy nhất để tránh xung đột đồng thời. fd, image_file = tempfile.mkstemp(suffix=ext, prefix="group_report_") try: with os.fdopen(fd, "wb") as f: @@ -437,7 +435,7 @@ class GroupDailyAnalysis(Star): if not image_file: return - # 3. 执行上传:群文件 + # 3. Upload vào tệp nhóm. if enable_file: try: folder_name = self.config_manager.get_group_file_folder() @@ -450,10 +448,10 @@ class GroupDailyAnalysis(Star): group_id=group_id, file_path=image_file, folder_id=folder_id, - filename=nice_filename, # 显式传递漂亮的文件名 + filename=nice_filename, # Truyền tường minh tên tệp thân thiện. ) except Exception as e: - logger.warning(f"群文件上传失败 (群 {group_id}): {e}") + logger.warning(f"Upload tệp nhóm thất bại (nhóm {group_id}): {e}") if enable_album and hasattr(adapter, "upload_group_album"): try: @@ -465,12 +463,12 @@ class GroupDailyAnalysis(Star): album_id = await adapter.find_album_id(group_id, album_name) # type: ignore[attr-defined] if not album_id and strict_mode: logger.info( - f"群相册严格模式开启:在群 {group_id} 中未找到名为 '{album_name}' 的相册,停止上传。" + f"Đã bật chế độ album nghiêm ngặt: không tìm thấy album '{album_name}' trong nhóm {group_id}, dừng upload" ) return elif strict_mode: logger.info( - f"群相册严格模式开启:未设置目标相册名称,停止上传以防止操作群 {group_id} 的默认相册。" + f"Đã bật chế độ album nghiêm ngặt nhưng chưa đặt tên album đích; dừng để tránh thao tác album mặc định của nhóm {group_id}" ) return await adapter.upload_group_album( # type: ignore[attr-defined] @@ -481,9 +479,9 @@ class GroupDailyAnalysis(Star): strict_mode=strict_mode, ) except Exception as e: - logger.warning(f"群相册上传失败 (群 {group_id}): {e}") + logger.warning(f"Upload album nhóm thất bại (nhóm {group_id}): {e}") except Exception as e: - logger.warning(f"图片上传处理异常: {e}") + logger.warning(f"Lỗi xử lý upload ảnh: {e}") finally: if created_temp and image_file and os.path.exists(image_file): try: @@ -497,8 +495,8 @@ class GroupDailyAnalysis(Star): self, event: AstrMessageEvent, days: int | None = None ): """ - 分析群聊日常活动(跨平台支持) - 用法: /群分析 [天数] + Phân tích hoạt động nhóm hằng ngày trên nhiều nền tảng. + Cách dùng: /phantichnhom [số ngày] """ if self._terminating: return @@ -508,7 +506,7 @@ class GroupDailyAnalysis(Star): self._background_tasks.add(current_task) try: - event.should_call_llm(True) # 阻止默认 LLM 解析 + event.should_call_llm(True) # Ngăn LLM mặc định phân tích. group_id = self._get_group_id_from_event(event) platform_id = self._get_platform_id_from_event(event) @@ -516,10 +514,10 @@ class GroupDailyAnalysis(Star): yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat") return - # 更新bot实例 + # Cập nhật instance bot. self.bot_manager.update_from_event(event) - # 优先使用 UMO 进行权限检查 (兼容白名单 UMO 格式) + # Ưu tiên UMO để kiểm tra quyền và tương thích whitelist UMO. check_target = getattr(event, "unified_msg_origin", None) if not check_target: check_target = f"{platform_id}:GroupMessage:{group_id}" @@ -534,7 +532,7 @@ class GroupDailyAnalysis(Star): ) return - # 获取群名以生成语义化的 TraceID + # Lấy tên nhóm để tạo TraceID có ngữ nghĩa. group_name = "" try: adapter = self.bot_manager.get_adapter(platform_id) @@ -545,20 +543,20 @@ class GroupDailyAnalysis(Star): except Exception: pass - # 设置 TraceID (语义化格式: manual_群名_HHmm) + # Thiết lập TraceID theo dạng manual_tên_nhóm_HHmm. trace_id = TraceContext.generate( prefix="manual", group_name=group_name or group_id ) TraceContext.set(trace_id) - # 表情回应 或 文本提示(二选一,由配置开关控制) + # Reaction hoặc thông báo văn bản, chọn theo cấu hình. adapter = self.bot_manager.get_adapter(platform_id) orig_msg_id = getattr(event.message_obj, "message_id", None) adapter_platform_name = ( (adapter.get_platform_name() if adapter else "").strip().lower() ) - # QQ 官方机器人 API v2 不支持本插件使用的表情回应接口, - # 因此始终沿用原有的文字进度提示,避免触发无效的 reaction 请求。 + # API v2 của QQ Official không hỗ trợ reaction plugin đang dùng, + # nên luôn dùng thông báo tiến độ dạng văn bản. use_text_reply = ( adapter_platform_name in {"qq_official", "qq_official_webhook"} or self.config_manager.get_enable_analysis_reply() @@ -573,7 +571,7 @@ class GroupDailyAnalysis(Star): event.get_group_id(), orig_msg_id, "analysis_started" ) - # 调用 DDD 应用级服务 + # Gọi application service theo DDD. result = await self.analysis_service.execute_daily_analysis( group_id=group_id, platform_id=platform_id, manual=True, days=days ) @@ -586,7 +584,7 @@ class GroupDailyAnalysis(Star): ) elif reason == "muted": logger.warning( - f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过回复以防抛出发送异常" + f"Nhóm {group_id} đã tắt chat toàn nhóm hoặc tắt quyền bot; bỏ qua phản hồi để tránh lỗi gửi" ) else: yield event.plain_result( @@ -607,9 +605,9 @@ class GroupDailyAnalysis(Star): "📊 Phân tích cho nhóm này đang chạy, vui lòng thử lại sau nhé~" ) except asyncio.CancelledError: - logger.info("群分析任务被取消 (插件重载或卸载)") + logger.info("Tác vụ phân tích nhóm đã bị huỷ do plugin reload hoặc bị gỡ") except Exception as e: - logger.error(f"群分析失败: {e}", exc_info=True) + logger.error(f"Phân tích nhóm thất bại: {e}", exc_info=True) yield event.plain_result( f"❌ Phân tích thất bại: {str(e)}. Vui lòng kiểm tra kết nối " "mạng, cấu hình LLM hoặc liên hệ quản trị viên" @@ -621,9 +619,9 @@ class GroupDailyAnalysis(Star): async def _send_analysis_report( self, event: AstrMessageEvent, result: dict ) -> AsyncGenerator: - """处理分析结果的渲染和发送""" + """Render và gửi kết quả phân tích.""" if self._terminating or not self.config_manager: - logger.warning("插件正在关闭,停止发送报告") + logger.warning("Plugin đang đóng, dừng gửi báo cáo") return group_id = result["group_id"] @@ -633,7 +631,7 @@ class GroupDailyAnalysis(Star): output_format = self.config_manager.get_output_format()[0] is_qq_official = adapter.get_platform_name() == "qq_official" - # 定义获取回调 + # Định nghĩa callback truy xuất dữ liệu. async def avatar_url_getter(user_id: str) -> str | None: return await adapter.get_user_avatar_url(user_id) @@ -666,10 +664,12 @@ class GroupDailyAnalysis(Star): sent = await adapter.send_image(group_id, image_url, caption=caption) if sent: await self._try_upload_image(group_id, image_url, platform_id) - return # 成功发送 + return # Gửi thành công. - # 如果图片生成或发送失败,直接回退到文本 - logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}") + # Chuyển thẳng sang văn bản nếu tạo hoặc gửi ảnh thất bại. + logger.warning( + f"Gửi báo cáo ảnh thất bại, đang gửi fallback văn bản cho nhóm {group_id}" + ) await self._send_text_reports( group_id, analysis_result, is_qq_official, adapter ) @@ -690,10 +690,10 @@ class GroupDailyAnalysis(Star): if is_only_url: if base_url and base_url.strip(): - # 获取配置中的输出目录 + # Lấy thư mục output trong cấu hình. html_output_dir = self.config_manager.get_html_output_dir() - # 若用户配置为空,使用默认目录 + # Dùng thư mục mặc định nếu cấu hình rỗng. if not html_output_dir: from astrbot.api.star import StarTools @@ -702,7 +702,7 @@ class GroupDailyAnalysis(Star): "self_hosted_html_reports", ) - # 计算相对路径并转换为URL + # Tính đường dẫn tương đối và chuyển thành URL. rel_path = os.path.relpath(html_path, html_output_dir) url_path = rel_path.replace(os.sep, "/") report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}" @@ -710,15 +710,15 @@ class GroupDailyAnalysis(Star): yield event.plain_result( f"📊 Báo cáo phân tích nhóm hôm nay đã sẵn sàng:\n{report_url}" ) - return # 拦截成功,直接退出,不再发文件 + return # Đã gửi liên kết, không gửi tệp nữa. else: logger.warning( - f"手动触发群 {group_id} 开启了仅发送外链,但未配置 html_base_url,回退至发送文件。" + f"Nhóm {group_id} được kích hoạt thủ công và chỉ bật gửi liên kết nhưng chưa cấu hình html_base_url; chuyển sang gửi tệp" ) caption = self.report_generator.build_html_caption(html_path) - # 发送 HTML 文件 + # Gửi tệp HTML. sender = getattr(self, "message_sender", None) if sender: sent = await sender.send_file( @@ -776,10 +776,10 @@ class GroupDailyAnalysis(Star): @filter.permission_type(PermissionType.ADMIN) async def set_output_format(self, event: AstrMessageEvent, format_input: str = ""): """ - 设置分析报告输出格式(跨平台支持) - 用法: /设置格式 [格式名称或序号] 或 image,html 等逗号分隔的组合 + Thiết lập định dạng báo cáo trên nhiều nền tảng. + Cách dùng: /dinhdang [tên hoặc số thứ tự], có thể dùng ``image,html``. """ - # 命令由插件处理,禁用默认 LLM 回退。 + # Plugin xử lý command, tắt fallback LLM mặc định. event.should_call_llm(True) available_formats = ["image", "text", "html"] @@ -806,19 +806,19 @@ Cách dùng: /dinhdang [tên hoặc số thứ tự], ví dụ: /dinhdang image, return target_format = None - # 尝试由序号选择 + # Thử chọn theo số thứ tự. if format_input.isdigit(): idx = int(format_input) - 1 if 0 <= idx < len(available_formats): target_format = available_formats[idx] - # 尝试按名称选择 + # Thử chọn theo tên. if not target_format: input_lower = format_input.lower() if input_lower in available_formats: target_format = input_lower - # 支持逗号分隔的多个格式 + # Hỗ trợ nhiều định dạng phân tách bằng dấu phẩy. if not target_format: parts = [f.strip() for f in format_input.replace(",", ",").split(",")] if all(p in available_formats for p in parts) and len(parts) > 1: @@ -853,10 +853,10 @@ Cách dùng: /dinhdang [tên hoặc số thứ tự], ví dụ: /dinhdang image, self, event: AstrMessageEvent, template_input: str = "" ): """ - 设置分析报告模板(跨平台支持) - 用法: /设置模板 [模板名称或序号] + Thiết lập mẫu báo cáo trên nhiều nền tảng. + Cách dùng: /maubc [tên mẫu hoặc số thứ tự]. """ - # 命令由插件处理,禁用默认 LLM 回退。 + # Plugin xử lý command, tắt fallback LLM mặc định. event.should_call_llm(True) available_templates = ( @@ -901,10 +901,10 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] @filter.permission_type(PermissionType.ADMIN) async def view_templates(self, event: AstrMessageEvent): """ - 查看所有可用的报告模板及预览图(跨平台支持) - 用法: /查看模板 + Xem mọi mẫu báo cáo khả dụng và ảnh preview trên nhiều nền tảng. + Cách dùng: /xemmau. """ - # 命令由插件处理,禁用默认 LLM 回退。 + # Plugin xử lý command, tắt fallback LLM mặc định. event.should_call_llm(True) available_templates = ( @@ -943,15 +943,11 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] @filter.permission_type(PermissionType.ADMIN) async def analysis_settings(self, event: AstrMessageEvent, action: str = "status"): """ - 管理分析设置(跨平台支持) - 用法: /分析设置 [enable|disable|status|reload|test] - - enable: 启用当前群的分析功能 - - disable: 禁用当前群的分析功能 - - status: 查看当前状态 - - reload: 重新加载配置并重启定时任务 - - test: 测试自动分析功能 - - filter_bot: 切换是否在分析中包含机器人自己的消息 - - incremental_debug: 切换增量分析立即报告模式(调试用) + Quản lý cài đặt phân tích trên nhiều nền tảng. + + Cách dùng: /caidat [enable|disable|status|reload|test|filter_bot|incremental_debug]. + ``filter_bot`` chuyển chế độ lọc tin bot; ``incremental_debug`` chuyển + chế độ gửi ngay báo cáo gia tăng để debug. """ group_id = self._get_group_id_from_event(event) @@ -987,7 +983,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] yield event.plain_result("🧪 Đang kiểm tra tính năng phân tích tự động...") - # 更新bot实例(用于测试) + # Cập nhật instance bot để kiểm tra. self.bot_manager.update_from_event(event) try: @@ -1041,7 +1037,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] output_format = self.config_manager.get_output_format()[0] min_threshold = self.config_manager.get_min_messages_threshold() - # 增量分析状态 + # Trạng thái phân tích gia tăng. incremental_enabled = self.config_manager.get_incremental_enabled() incremental_status_text = "Chưa bật" if incremental_enabled: @@ -1075,7 +1071,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] @filter.command("tangcuong", alias={"incremental_status", "增量状态"}) @filter.permission_type(PermissionType.ADMIN) async def incremental_status(self, event: AstrMessageEvent): - """查看当前增量分析状态(滑动窗口)""" + """Xem trạng thái phân tích gia tăng trong cửa sổ trượt.""" group_id = self._get_group_id_from_event(event) if not group_id: yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat") @@ -1089,12 +1085,12 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] import time as time_mod - # 计算滑动窗口范围 + # Tính phạm vi cửa sổ trượt. analysis_days = self.config_manager.get_analysis_days() window_end = time_mod.time() window_start = window_end - (analysis_days * 24 * 3600) - # 查询窗口内的批次 + # Truy vấn các batch trong cửa sổ. batches = await self.incremental_store.query_batches( group_id, window_start, window_end ) @@ -1110,7 +1106,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] ) return - # 合并批次获取聚合视图 + # Gộp batch để tạo chế độ xem tổng hợp. state = self.incremental_merge_service.merge_batches( batches, window_start, window_end ) @@ -1127,7 +1123,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] ) async def _handle_settings_enable(self, event: AstrMessageEvent, group_id: str): - """协助逻辑:处理启用设置的分支逻辑""" + """Helper xử lý nhánh bật cài đặt.""" mode = self.config_manager.get_group_list_mode() target_id = event.unified_msg_origin or group_id @@ -1164,7 +1160,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự] ) async def _handle_settings_disable(self, event: AstrMessageEvent, group_id: str): - """协助逻辑:处理禁用设置的分支逻辑""" + """Helper xử lý nhánh tắt cài đặt.""" mode = self.config_manager.get_group_list_mode() target_id = event.unified_msg_origin or group_id diff --git a/metadata.yaml b/metadata.yaml index 83b3812..914b598 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,8 +1,8 @@ -name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。 -display_name: 群分析总结插件 # 插件的显示名称 -desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述 -version: v4.11.2 # 插件版本号。格式:v1.1.1 或者 v1.1 -author: SXP-Simon # 作者 +name: astrbot_plugin_qq_group_daily_analysis # Mã định danh duy nhất của plugin. +display_name: Plugin tổng hợp và phân tích nhóm # Tên hiển thị của plugin. +desc: "Plugin tổng hợp và phân tích hoạt động nhóm hằng ngày - hỗ trợ OneBot (NapCat, LLOneBot, Snowluma), bot QQ chính thức, Telegram và Discord; tạo báo cáo phân tích trò chuyện nhóm trực quan, hỗ trợ phân tích chủ đề, chân dung thành viên và trích dẫn nổi bật." # Mô tả ngắn của plugin. +version: v4.11.2 # Phiên bản plugin, theo định dạng v1.1.1 hoặc v1.1. +author: SXP-Simon # Tác giả. astrbot_version: ">=4.16.0" support_platforms: - aiocqhttp @@ -10,4 +10,4 @@ support_platforms: - telegram - qq_official - qq_official_webhook -repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址 +repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # Kho mã nguồn của plugin. diff --git a/src/__init__.py b/src/__init__.py index 8361570..ea172bb 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,17 +1,17 @@ """ -群日常分析插件 - 源代码包 +Package mã nguồn của plugin phân tích nhóm hằng ngày. -本包包含插件的核心实现,采用 DDD (领域驱动设计) 架构: -- application: 应用层 - 编排领域服务,处理用例 -- domain: 领域层 - 核心业务逻辑,平台无关 -- infrastructure: 基础设施层 - 外部服务适配 -- shared: 共享组件 - 跨层使用的工具和常量 +Phần triển khai cốt lõi sử dụng kiến trúc DDD: +- application: điều phối domain service và xử lý use case +- domain: logic nghiệp vụ cốt lõi, độc lập với nền tảng +- infrastructure: adapter cho các dịch vụ bên ngoài +- shared: công cụ và hằng số dùng chung giữa các tầng -遗留模块(渐进式迁移中): -- analysis: 分析器实现 -- core: 核心组件 -- reports: 报告生成 -- scheduler: 定时任务 -- utils: 工具函数 -- visualization: 可视化组件 +Các module cũ đang được chuyển đổi dần: +- analysis: triển khai analyzer +- core: thành phần cốt lõi +- reports: tạo báo cáo +- scheduler: tác vụ định kỳ +- utils: hàm tiện ích +- visualization: thành phần trực quan hoá """ diff --git a/src/application/__init__.py b/src/application/__init__.py index e7c6d1e..39be56d 100644 --- a/src/application/__init__.py +++ b/src/application/__init__.py @@ -1 +1 @@ -# 应用层 - 编排和用例 +# Tầng application - điều phối và triển khai use case diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 26c01f8..8a18e2d 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -1,4 +1,4 @@ -"""命令相关应用服务。""" +"""Các dịch vụ application liên quan đến command.""" from .template_command_service import TemplateCommandService diff --git a/src/application/commands/template_command_service.py b/src/application/commands/template_command_service.py index f21acbe..0c73a6d 100644 --- a/src/application/commands/template_command_service.py +++ b/src/application/commands/template_command_service.py @@ -1,4 +1,4 @@ -"""模板管理相关命令服务。""" +"""Dịch vụ command quản lý template.""" from __future__ import annotations @@ -9,7 +9,7 @@ from astrbot.api.message_components import Image, Node, Nodes, Plain class TemplateCommandService: - """封装模板命令的文件系统与消息构建逻辑。""" + """Đóng gói logic filesystem và tạo tin nhắn cho command template.""" _CIRCLE_NUMBERS = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩"] @@ -17,7 +17,7 @@ class TemplateCommandService: self.plugin_root = plugin_root def resolve_template_base_dir(self) -> str: - """解析报告模板目录(兼容新旧目录结构)。""" + """Xác định thư mục template báo cáo, tương thích cấu trúc cũ và mới.""" candidate_dirs = [ os.path.join( self.plugin_root, "src", "infrastructure", "reporting", "templates" @@ -30,7 +30,7 @@ class TemplateCommandService: return candidate_dirs[0] def resolve_template_preview_path(self, template_name: str) -> str | None: - """解析模板预览图路径。""" + """Xác định đường dẫn ảnh xem trước template.""" candidate_paths = [ os.path.join(self.plugin_root, "assets", f"{template_name}-demo.jpg"), ] @@ -40,7 +40,7 @@ class TemplateCommandService: return None async def list_available_templates(self) -> list[str]: - """列出所有可用模板。""" + """Liệt kê mọi template khả dụng.""" template_base_dir = self.resolve_template_base_dir() def _list_templates_sync() -> list[str]: @@ -58,14 +58,14 @@ class TemplateCommandService: return await asyncio.to_thread(_list_templates_sync) async def template_exists(self, template_name: str) -> bool: - """检查模板目录是否存在。""" + """Kiểm tra thư mục template có tồn tại hay không.""" template_dir = os.path.join(self.resolve_template_base_dir(), template_name) return await asyncio.to_thread(os.path.exists, template_dir) def parse_template_input( self, template_input: str, available_templates: list[str] ) -> tuple[str | None, str | None]: - """解析模板输入(支持模板名或序号)。""" + """Phân tích đầu vào theo tên hoặc số thứ tự template.""" if not template_input: return None, "❌ Tham số mẫu không được để trống" @@ -87,7 +87,7 @@ class TemplateCommandService: current_template: str, bot_id: str, ) -> Nodes: - """构建模板预览的合并消息节点。""" + """Tạo các node tin nhắn tổng hợp để xem trước template.""" node_list = [] header_content = [ diff --git a/src/application/services/analysis_application_service.py b/src/application/services/analysis_application_service.py index cbd76c8..4f203fd 100644 --- a/src/application/services/analysis_application_service.py +++ b/src/application/services/analysis_application_service.py @@ -1,7 +1,8 @@ """ -分析应用服务 - 应用层 -实现"每日群聊分析并生成报告"及"增量分析"核心用例。 -负责协调领域服务、基础设施适配器及持久化层。 +Dịch vụ application cho phân tích. + +Triển khai các use case cốt lõi gồm phân tích nhóm hằng ngày, tạo báo cáo +và phân tích gia tăng; điều phối domain service, platform adapter và persistence. """ from __future__ import annotations @@ -31,13 +32,13 @@ from ...utils.logger import logger class DuplicateGroupTaskError(Exception): - """当同一个群组在同一时间尝试启动相同类型的重复分析任务时抛出。""" + """Được phát sinh khi một nhóm khởi chạy trùng loại tác vụ cùng lúc.""" pass class AnalysisApplicationService: - """分析应用服务 - 协调业务流程(每日分析 + 增量分析)""" + """Điều phối quy trình phân tích hằng ngày và phân tích gia tăng.""" def __init__( self, @@ -61,44 +62,51 @@ class AnalysisApplicationService: self.incremental_store = incremental_store self.incremental_merge_service = incremental_merge_service self._locks = weakref.WeakValueDictionary() - # 全局 LLM 分析信号量,控制对外 API 的并发压力 - # 使用专用的 LLM 并发配置项 + # Semaphore LLM toàn cục để kiểm soát tải đồng thời lên API. + # Dùng giá trị cấu hình đồng thời riêng cho LLM. max_concurrent = self.config_manager.get_llm_max_concurrent() self.llm_semaphore = asyncio.Semaphore(max_concurrent) - # 用于追踪当前正在执行的任务,实现原子的“检查并设置”逻辑,避免 locked() 竞态 + # Theo dõi tác vụ đang chạy để kiểm tra và thiết lập nguyên tử, + # tránh điều kiện tranh chấp khi dùng locked(). self._active_tasks = set() @asynccontextmanager async def group_lock(self, group_id: str, task_type: str = "analysis"): """ - 同一时间、同一个群、同一种任务只能有一个在执行 - 锁将在退出上下文时自动释放。 + Chỉ cho phép một tác vụ cùng loại của cùng một nhóm chạy tại một thời điểm. + Khoá tự động được giải phóng khi thoát khỏi context. """ lock_key = f"{task_type}:{group_id}" - # 获取或创建该群组特有的锁(保留锁作为第二道资源限流防线) + # Lấy hoặc tạo khoá riêng cho nhóm, làm lớp giới hạn tài nguyên thứ hai. lock = self._locks.get(lock_key) if lock is None: lock = asyncio.Lock() self._locks[lock_key] = lock - # 使用同步集合实现原子化的“运行中”检查 - # 在 asyncio 的单线程循环中,同步代码段不会被中断,因此这是原子操作 + # Dùng set đồng bộ để kiểm tra trạng thái đang chạy theo cách nguyên tử. + # Trong event loop đơn luồng của asyncio, đoạn đồng bộ không bị ngắt. if lock_key in self._active_tasks: - logger.warning(f"群 {group_id} 的 {task_type} 任务已在运行,跳过本次请求") + logger.warning( + f"Tác vụ {task_type} của nhóm {group_id} đang chạy; bỏ qua yêu cầu này" + ) raise DuplicateGroupTaskError(f"Duplicate task for {lock_key}") - # 占位:标记任务开始 + # Đánh dấu bắt đầu tác vụ. self._active_tasks.add(lock_key) try: async with lock: - logger.debug(f"[Lock] 已获取群 {group_id} 的 {task_type} 排他锁") + logger.debug( + f"[Lock] Đã lấy khoá độc quyền {task_type} của nhóm {group_id}" + ) yield finally: - # 释放:标记任务结束 + # Giải phóng: đánh dấu tác vụ kết thúc. self._active_tasks.discard(lock_key) - logger.debug(f"[Lock] 已释放群 {group_id} 的 {task_type} 排他锁") + logger.debug( + f"[Lock] Đã giải phóng khoá độc quyền {task_type} của nhóm {group_id}" + ) async def execute_daily_analysis( self, @@ -108,45 +116,47 @@ class AnalysisApplicationService: days: int | None = None, ) -> dict[str, Any]: """ - 执行每日分析用例。 + Thực thi use case phân tích hằng ngày. - 流程: - 1. 获取适配器 - 2. 拉取消息 (Infrastructure) - 3. 基础统计 (Domain Service) - 4. 用户分析 (Domain Service) - 5. LLM 语义分析 (Infrastructure/Analysis Bridge) - 6. 生成报告 (Visualization/Infrastructure) - 7. 持久化摘要 (Persistence) - 8. 返回结果 + Quy trình: + 1. Lấy adapter. + 2. Lấy tin nhắn (infrastructure). + 3. Thống kê cơ bản (domain service). + 4. Phân tích thành viên (domain service). + 5. Phân tích ngữ nghĩa bằng LLM. + 6. Tạo báo cáo. + 7. Lưu bản tóm tắt. + 8. Trả kết quả. """ async with self.group_lock(group_id, "daily"): logger.info( - f"开始执行分析用例: 群 {group_id}, platform_id={platform_id or '默认'}, days={days or '默认'}" + f"Bắt đầu use case phân tích: nhóm {group_id}, platform_id={platform_id or 'mặc định'}, days={days or 'mặc định'}" ) - # 1. 获取适配器 + # 1. Lấy adapter adapter = self.bot_manager.get_adapter(platform_id) if not adapter: raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}") - # 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言) + # Kiểm tra nhóm có bị tắt quyền gửi tin hay không. if hasattr(adapter, "is_group_muted"): try: if await adapter.is_group_muted(group_id): logger.info( - f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次群分析" + f"Nhóm {group_id} đang tắt quyền gửi tin toàn nhóm hoặc với bot; bỏ qua phân tích" ) return {"success": False, "reason": "muted"} except Exception as e: - logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}") + logger.warning( + f"Lỗi khi kiểm tra trạng thái tắt quyền gửi của nhóm {group_id}: {e}" + ) - # 飞书平台在分析前进行一次性权限与成员头像预热,避免报告阶段出现大面积默认头像。 + # Feishu kiểm tra quyền và làm nóng cache ảnh thành viên trước khi phân tích. if hasattr(adapter, "prepare_group_member_cache"): try: logger.info( - "执行平台成员预检查: group=%s, platform=%s", + "Kiểm tra trước thành viên nền tảng: group=%s, platform=%s", group_id, platform_id or "default", ) @@ -154,7 +164,7 @@ class AnalysisApplicationService: if not ok and err: raise ValueError(err) logger.info( - "平台成员预检查通过: group=%s, platform=%s", + "Kiểm tra trước thành viên nền tảng thành công: group=%s, platform=%s", group_id, platform_id or "default", ) @@ -164,7 +174,7 @@ class AnalysisApplicationService: f"vui lòng cấp đủ quyền cho ứng dụng: {e}" ) from e - # 2. 拉取消息 + # 2. Lấy tin nhắn if days is None: days = self.config_manager.get_analysis_days() max_count = self.config_manager.get_max_messages() @@ -173,7 +183,7 @@ class AnalysisApplicationService: group_id=group_id, days=days, max_count=max_count ) logger.info( - "消息拉取完成: group=%s, platform=%s, raw_count=%s, days=%s, max_count=%s", + "Đã lấy tin nhắn: group=%s, platform=%s, raw_count=%s, days=%s, max_count=%s", group_id, platform_id or "default", len(raw_messages), @@ -182,10 +192,12 @@ class AnalysisApplicationService: ) if not raw_messages: - logger.warning(f"群 {group_id} 在最近 {days} 天内无消息或无法获取") + logger.warning( + f"Nhóm {group_id} không có tin nhắn hoặc không thể lấy tin trong {days} ngày gần đây" + ) return {"success": False, "reason": "no_messages"} - # 3. 清理消息 (Filter commands, bot messages, noise) + # 3. Làm sạch tin nhắn: lọc command, tin nhắn bot và nhiễu. from ...domain.services.message_cleaner_service import MessageCleanerService cleaner = MessageCleanerService() @@ -198,32 +210,32 @@ class AnalysisApplicationService: bot_self_ids, ) - # 对于自动任务,强制过滤指令;对于手动任务,也建议过滤以保持报告纯净 + # Luôn lọc command để báo cáo không bị nhiễu. unified_messages = cleaner.clean_messages( raw_messages, bot_self_ids=bot_self_ids, filter_commands=True ) logger.info( - "消息清洗完成: group=%s, platform=%s, cleaned_count=%s, dropped=%s", + "Đã làm sạch tin nhắn: group=%s, platform=%s, cleaned_count=%s, dropped=%s", group_id, platform_id or "default", len(unified_messages), max(len(raw_messages) - len(unified_messages), 0), ) - # 4. 检查最小消息阈值 (在清理后进行) + # 4. Kiểm tra ngưỡng tin nhắn tối thiểu sau khi làm sạch. threshold = self.config_manager.get_min_messages_threshold() if len(unified_messages) < threshold and not manual: logger.info( - f"群 {group_id} 有效消息数 ({len(unified_messages)}) 未达到自动分析阈值 ({threshold})" + f"Số tin nhắn hợp lệ của nhóm {group_id} ({len(unified_messages)}) chưa đạt ngưỡng phân tích tự động ({threshold})" ) return {"success": False, "reason": "below_threshold"} - # 5. 基础统计 (Domain Service) + # 5. Thống kê cơ bản (domain service) statistics = await asyncio.to_thread( self.statistics_service.calculate_group_statistics, unified_messages ) - # 4. 用户分析 (Domain Service) + # 4. Phân tích thành viên (domain service) user_activity = await asyncio.to_thread( self.analysis_domain_service.analyze_user_activity, unified_messages, @@ -235,8 +247,8 @@ class AnalysisApplicationService: user_activity, limit=max_user_titles ) - # 5. LLM 语义分析 (为了保持兼容,目前直接传 UnifiedMessage,后续如需传 raw dict 再加转换) - # LLMAnalyzer 内部可能已经处理了转换(见之前代码) + # 5. Phân tích ngữ nghĩa bằng LLM. + # LLMAnalyzer có thể tự xử lý việc chuyển đổi dữ liệu. topic_enabled = self.config_manager.get_topic_analysis_enabled() user_title_enabled = self.config_manager.get_user_title_analysis_enabled() golden_quote_enabled = ( @@ -252,8 +264,8 @@ class AnalysisApplicationService: chat_quality_review = None total_token_usage = TokenUsage() - # Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配 - # 暂时转换回 legacy 格式以确保稳定性,直到 LLMAnalyzer 被重构 + # LLMAnalyzer hiện có thể chỉ nhận format cũ hoặc adapter UnifiedMessage. + # Tạm chuyển về format cũ để ổn định cho đến khi LLMAnalyzer được refactor. legacy_messages = self.statistics_service._convert_to_legacy_dict( unified_messages ) @@ -269,7 +281,7 @@ class AnalysisApplicationService: or chat_quality_enabled ): async with self.llm_semaphore: - logger.debug(f"[LLM] 已进入分析队列 (群: {group_id})") + logger.debug(f"[LLM] Đã vào hàng đợi phân tích (nhóm: {group_id})") ( topics, user_titles, @@ -287,7 +299,7 @@ class AnalysisApplicationService: chat_quality_enabled=chat_quality_enabled, ) - # 回填结果 + # Gắn kết quả trở lại statistics.golden_quotes = golden_quotes statistics.token_usage = total_token_usage @@ -299,11 +311,11 @@ class AnalysisApplicationService: "chat_quality_review": chat_quality_review, } - # 6. 持久化摘要 (Persistence) + # 6. Lưu bản tóm tắt (persistence) await self.history_manager.save_analysis(group_id, analysis_result) - # 7. 生成报告并发送 (应用层编排发送动作) - # 这里由调用方处理发送,本服务只返回分析结果和可能的视觉产物 + # 7. Tạo và gửi báo cáo (application điều phối thao tác gửi). + # Caller xử lý việc gửi; service chỉ trả kết quả và sản phẩm trực quan. return { "success": True, "analysis_result": analysis_result, @@ -314,37 +326,29 @@ class AnalysisApplicationService: } # ---------------------------------------------------------------- - # 增量分析用例 + # Use case phân tích gia tăng # ---------------------------------------------------------------- async def execute_incremental_analysis( self, group_id: str, platform_id: str | None = None ) -> dict[str, Any]: """ - 执行一次增量分析用例(滑动窗口批次架构)。 + Thực thi một use case phân tích gia tăng theo kiến trúc batch cửa sổ trượt. - 与每日分析不同,增量分析每次仅处理最近一段时间的消息, - 提取少量话题和金句,将结果作为独立批次存储到 KV。 - 不生成用户称号(留到最终报告时再做),不生成报告。 + Khác với phân tích hằng ngày, mỗi lần chỉ xử lý tin nhắn gần đây, + trích xuất một số chủ đề và trích dẫn rồi lưu kết quả thành batch độc + lập trong KV. Danh hiệu thành viên và báo cáo được tạo ở bước cuối. - 流程: - 1. 获取适配器 - 2. 拉取消息(使用增量配置的 max_messages) - 3. 清理消息 - 4. 按时间戳去重:过滤已分析过的消息 - 5. 检查最小消息阈值 - 6. 计算基础统计(小时分布、用户活跃、表情) - 7. LLM 增量分析(仅话题 + 金句) - 8. 构建 IncrementalBatch 并保存 - 9. 更新最后分析消息时间戳 - 10. 返回批次结果 + Quy trình: lấy adapter và tin nhắn, làm sạch, loại trùng theo timestamp, + kiểm tra ngưỡng, tính thống kê, phân tích gia tăng bằng LLM, lưu + ``IncrementalBatch``, cập nhật tiến độ và trả kết quả batch. Args: - group_id: 群组 ID - platform_id: 平台标识,缺省为默认 + group_id: ID nhóm. + platform_id: ID nền tảng; mặc định dùng nền tảng mặc định. Returns: - dict: 包含 success、batch_summary 等信息 + Dict chứa success, batch_summary và các thông tin liên quan. """ async with self.group_lock(group_id, "incremental"): if not self.incremental_store: @@ -353,34 +357,36 @@ class AnalysisApplicationService: ) logger.info( - f"开始增量分析用例: 群 {group_id}, 平台 {platform_id or '默认'}" + f"Bắt đầu phân tích gia tăng: nhóm {group_id}, nền tảng {platform_id or 'mặc định'}" ) - # 1. 获取适配器 + # 1. Lấy adapter adapter = self.bot_manager.get_adapter(platform_id) if not adapter: raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}") - # 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言) + # Kiểm tra nhóm có tắt quyền gửi tin hay không. if hasattr(adapter, "is_group_muted"): try: if await adapter.is_group_muted(group_id): logger.info( - f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次增量群分析" + f"Nhóm {group_id} đang tắt quyền gửi tin toàn nhóm hoặc với bot; bỏ qua phân tích gia tăng" ) return {"success": False, "reason": "muted"} except Exception as e: - logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}") + logger.warning( + f"Lỗi khi kiểm tra trạng thái tắt quyền gửi của nhóm {group_id}: {e}" + ) - # 2. 拉取消息,获取进度并确定拉取量 + # 2. Lấy tiến độ và xác định số lượng tin nhắn cần truy xuất. last_analyzed_ts = await self.incremental_store.get_last_analyzed_timestamp( group_id ) days = self.config_manager.get_analysis_days() - # 在增量模式下,拉取上限由安全限制 (Safe Count) 统一控制,确保能追平进度且不溢出 + # Giới hạn an toàn giúp bắt kịp tiến độ mà không gây tràn dữ liệu. max_count = self.config_manager.get_incremental_safe_limit() - # 3. 拉取消息(优先从上次进度点开始回溯,确保不遗漏高活跃期间的 Gap) + # 3. Lấy tin nhắn từ điểm tiến độ gần nhất để không bỏ sót khoảng trống. raw_messages = await adapter.fetch_messages( group_id=group_id, days=days, @@ -389,10 +395,12 @@ class AnalysisApplicationService: ) if not raw_messages: - logger.warning(f"群 {group_id} 在最近 {days} 天内无消息或无法获取") + logger.warning( + f"Nhóm {group_id} không có tin nhắn hoặc không thể lấy tin trong {days} ngày gần đây" + ) return {"success": False, "reason": "no_messages"} - # 3. 清理消息 + # 3. Làm sạch tin nhắn from ...domain.services.message_cleaner_service import MessageCleanerService cleaner = MessageCleanerService() @@ -408,22 +416,22 @@ class AnalysisApplicationService: raw_messages, bot_self_ids=bot_self_ids, filter_commands=True ) - # 5. 二次去重,确保只保留断点之后的真正新消息 + # 5. Loại trùng lần hai để chỉ giữ tin nhắn mới sau điểm tiến độ. if last_analyzed_ts > 0: unified_messages = [ msg for msg in unified_messages if msg.timestamp > last_analyzed_ts ] - # 5. 检查最小消息阈值 + # 5. Kiểm tra ngưỡng tin nhắn tối thiểu. min_messages = self.config_manager.get_incremental_min_messages() if len(unified_messages) < min_messages: logger.info( - f"群 {group_id} 增量分析:新消息数 ({len(unified_messages)}) " - f"未达到阈值 ({min_messages}),跳过本次分析" + f"Phân tích gia tăng nhóm {group_id}: số tin nhắn mới ({len(unified_messages)}) " + f"chưa đạt ngưỡng ({min_messages}); bỏ qua lần phân tích này" ) return {"success": False, "reason": "below_threshold"} - # 6. 计算基础统计 + # 6. Tính thống kê cơ bản statistics = await asyncio.to_thread( self.statistics_service.calculate_group_statistics, unified_messages ) @@ -433,16 +441,16 @@ class AnalysisApplicationService: bot_self_ids, ) - # 计算本批次的小时分布 + # Tính phân bố theo giờ của batch này hourly_msg_counts, hourly_char_counts = self._compute_hourly_counts( unified_messages ) - # 7. LLM 增量分析(仅话题 + 金句) + # 7. Phân tích gia tăng bằng LLM (chủ đề và trích dẫn) topics_per_batch = self.config_manager.get_incremental_topics_per_batch() quotes_per_batch = self.config_manager.get_incremental_quotes_per_batch() - # 获取功能开关状态 + # Lấy trạng thái các công tắc tính năng topic_enabled = self.config_manager.get_topic_analysis_enabled() golden_quote_enabled = ( self.config_manager.get_golden_quote_analysis_enabled() @@ -451,7 +459,7 @@ class AnalysisApplicationService: self.config_manager.get_chat_quality_analysis_enabled() ) - # 需要将 UnifiedMessage 转换为 legacy 格式供 LLM 分析器使用 + # Chuyển UnifiedMessage sang format cũ cho analyzer LLM legacy_messages = self.statistics_service._convert_to_legacy_dict( unified_messages ) @@ -466,7 +474,9 @@ class AnalysisApplicationService: if topic_enabled or golden_quote_enabled or chat_quality_enabled: async with self.llm_semaphore: - logger.debug(f"[LLM] 已进入增量分析队列 (群: {group_id})") + logger.debug( + f"[LLM] Đã vào hàng đợi phân tích gia tăng (nhóm: {group_id})" + ) ( topics, golden_quotes, @@ -482,8 +492,8 @@ class AnalysisApplicationService: chat_quality_enabled=chat_quality_enabled, ) - # 8. 构建 IncrementalBatch - # 8a. 转换话题: SummaryTopic -> dict + # 8. Xây dựng IncrementalBatch + # 8a. Chuyển chủ đề: SummaryTopic -> dict new_topics = [ { "topic": t.topic, @@ -494,7 +504,7 @@ class AnalysisApplicationService: for t in topics ] - # 8b. 转换金句: GoldenQuote -> dict + # 8b. Chuyển trích dẫn: GoldenQuote -> dict new_quotes = [ { "content": q.content, @@ -505,19 +515,19 @@ class AnalysisApplicationService: for q in golden_quotes ] - # 8c. 转换 token 消耗: TokenUsage -> dict + # 8c. Chuyển mức sử dụng token: TokenUsage -> dict token_usage_dict = { "prompt_tokens": token_usage.prompt_tokens, "completion_tokens": token_usage.completion_tokens, "total_tokens": token_usage.total_tokens, } - # 8d. 转换用户统计: AnalysisDomainService 格式 -> IncrementalBatch 格式 + # 8d. Chuyển thống kê thành viên sang format IncrementalBatch user_stats = self._convert_user_activity_for_merge( user_activity, unified_messages ) - # 8e. 转换表情统计: EmojiStatistics -> dict + # 8e. Chuyển thống kê biểu cảm: EmojiStatistics -> dict emoji_stats = { "face_count": statistics.emoji_statistics.face_count, "mface_count": statistics.emoji_statistics.mface_count, @@ -527,7 +537,7 @@ class AnalysisApplicationService: "face_details": statistics.emoji_statistics.face_details, } - # 8f. 转换聊天质量锐评: QualityReview -> dict + # 8f. Chuyển đánh giá chất lượng: QualityReview -> dict chat_quality_dict = None if chat_quality_review: chat_quality_dict = { @@ -545,16 +555,16 @@ class AnalysisApplicationService: "summary": chat_quality_review.summary, } - # 8g. 获取参与者 ID 和最后消息时间戳 + # 8g. Lấy ID người tham gia và timestamp tin nhắn cuối participant_ids = list({msg.sender_id for msg in unified_messages}) last_message_timestamp = max( (msg.timestamp for msg in unified_messages), default=0 ) - # 8g. 计算本批次总字符数 + # 8g. Tính tổng số ký tự của batch characters_count = sum(msg.get_text_length() for msg in unified_messages) - # 构建批次对象 + # Xây dựng đối tượng batch batch = IncrementalBatch( group_id=group_id, timestamp=time_mod.time(), @@ -572,10 +582,10 @@ class AnalysisApplicationService: participant_ids=participant_ids, ) - # 9. 保存批次并更新最后分析时间戳 + # 9. Lưu batch và cập nhật timestamp phân tích cuối. await self.incremental_store.save_batch(batch) - # 安全更新水位线:取消息最大时间戳,但不能超过当前时间+1分钟,防止未来时间戳毒化导致后续分析死锁 + # Cập nhật mốc an toàn, không vượt quá hiện tại + 1 phút để tránh timestamp tương lai. import time safe_now = int(time.time()) + 60 @@ -586,9 +596,9 @@ class AnalysisApplicationService: ) logger.info( - f"群 {group_id} 增量分析完成: " - f"本批次消息={len(unified_messages)}, " - f"新话题={len(new_topics)}, 新金句={len(new_quotes)}" + f"Hoàn tất phân tích gia tăng nhóm {group_id}: " + f"tin nhắn batch={len(unified_messages)}, " + f"chủ đề mới={len(new_topics)}, trích dẫn mới={len(new_quotes)}" ) return { @@ -603,28 +613,21 @@ class AnalysisApplicationService: self, group_id: str, platform_id: str | None = None ) -> dict[str, Any]: """ - 基于滑动窗口内的增量批次生成最终报告。 + Tạo báo cáo cuối từ các batch gia tăng trong cửa sổ trượt. - 按 analysis_days × 24h 的时间窗口查询所有批次, - 合并为 IncrementalState,额外执行用户称号分析, - 然后生成与传统每日分析格式完全一致的 analysis_result。 + Truy vấn batch theo cửa sổ ``analysis_days × 24 giờ``, gộp thành + ``IncrementalState``, phân tích thêm danh hiệu thành viên rồi tạo + ``analysis_result`` cùng định dạng với phân tích hằng ngày. - 流程: - 1. 计算滑动窗口范围 - 2. 查询窗口内的所有批次 - 3. 检查批次有效性 - 4. 合并批次为 IncrementalState - 5. 执行用户称号 LLM 分析(基于合并后的累积数据) - 6. 使用 IncrementalMergeService 构建 analysis_result - 7. 持久化到 history_manager - 8. 返回结果 + Quy trình: tính cửa sổ, truy vấn và kiểm tra batch, gộp trạng thái, + phân tích danh hiệu bằng LLM, dựng kết quả, lưu lịch sử và trả kết quả. Args: - group_id: 群组 ID - platform_id: 平台标识,缺省为默认 + group_id: ID nhóm. + platform_id: ID nền tảng; mặc định dùng nền tảng mặc định. Returns: - dict: 包含 success、analysis_result、adapter 等信息 + Dict chứa success, analysis_result, adapter và thông tin liên quan. """ async with self.group_lock(group_id, "final"): if not self.incremental_store or not self.incremental_merge_service: @@ -634,48 +637,50 @@ class AnalysisApplicationService: ) logger.info( - f"开始增量最终报告: 群 {group_id}, 平台 {platform_id or '默认'}" + f"Bắt đầu báo cáo gia tăng cuối: nhóm {group_id}, nền tảng {platform_id or 'mặc định'}" ) - # 1. 计算滑动窗口范围 + # 1. Tính phạm vi cửa sổ trượt analysis_days = self.config_manager.get_analysis_days() window_end = time_mod.time() window_start = window_end - (analysis_days * 24 * 3600) - # 2. 查询窗口内的所有批次 + # 2. Truy vấn mọi batch trong cửa sổ batches = await self.incremental_store.query_batches( group_id, window_start, window_end ) - # 3. 检查批次有效性 + # 3. Kiểm tra tính hợp lệ của batch if not batches: logger.warning( - f"群 {group_id} 滑动窗口内无增量分析数据,无法生成最终报告" + f"Nhóm {group_id} không có dữ liệu gia tăng trong cửa sổ trượt; không thể tạo báo cáo cuối" ) return {"success": False, "reason": "no_incremental_data"} - # 4. 合并批次为 IncrementalState + # 4. Gộp batch thành IncrementalState state = self.incremental_merge_service.merge_batches( batches, window_start, window_end ) - # 5. 获取适配器(报告发送需要) + # 5. Lấy adapter cần cho việc gửi báo cáo adapter = self.bot_manager.get_adapter(platform_id) if not adapter: raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}") - # 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言) + # Kiểm tra nhóm có tắt quyền gửi tin hay không. if hasattr(adapter, "is_group_muted"): try: if await adapter.is_group_muted(group_id): logger.info( - f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次增量最终报告生成" + f"Nhóm {group_id} đang tắt quyền gửi tin toàn nhóm hoặc với bot; bỏ qua báo cáo cuối" ) return {"success": False, "reason": "muted"} except Exception as e: - logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}") + logger.warning( + f"Lỗi khi kiểm tra trạng thái tắt quyền gửi của nhóm {group_id}: {e}" + ) - # 6. 执行分析相关的变量准备 + # 6. Chuẩn bị biến cho quá trình phân tích user_titles = [] user_title_enabled = self.config_manager.get_user_title_analysis_enabled() unified_msg_origin = ( @@ -684,24 +689,26 @@ class AnalysisApplicationService: if user_title_enabled and state.user_activities: max_user_titles = self.config_manager.get_max_user_titles() - # 从合并后的 user_activities 中取出 top 用户 + # Lấy các thành viên hàng đầu từ user_activities đã gộp top_users = state.get_user_activity_ranking(max_user_titles) try: async with self.llm_semaphore: - logger.debug(f"[LLM] 已进入称号分析队列 (群: {group_id})") + logger.debug( + f"[LLM] Đã vào hàng đợi phân tích danh hiệu (nhóm: {group_id})" + ) ( user_titles_result, title_token_usage, ) = await self.llm_analyzer.analyze_user_titles( - messages=[], # 增量模式下不传原始消息 + messages=[], # Không truyền tin nhắn gốc ở chế độ gia tăng user_activity=state.user_activities, umo=unified_msg_origin, top_users=top_users, ) user_titles = user_titles_result - # 将称号分析的 token 消耗追加到状态中 + # Cộng mức sử dụng token của phân tích danh hiệu vào trạng thái state.total_token_usage["prompt_tokens"] = ( state.total_token_usage.get("prompt_tokens", 0) + title_token_usage.prompt_tokens @@ -715,9 +722,12 @@ class AnalysisApplicationService: + title_token_usage.total_tokens ) except Exception as e: - logger.error(f"增量最终报告用户称号分析失败: {e}", exc_info=True) + logger.error( + f"Phân tích danh hiệu cho báo cáo gia tăng cuối thất bại: {e}", + exc_info=True, + ) - # 6.5 执行聊天质量汇总分析 (如果有多个批次的质量报告) + # 6.5 Tổng hợp chất lượng trò chuyện nếu có đánh giá từ nhiều batch if ( self.config_manager.get_chat_quality_analysis_enabled() and state.all_quality_reviews @@ -725,7 +735,7 @@ class AnalysisApplicationService: try: async with self.llm_semaphore: logger.debug( - f"[LLM] 已进入聊天质量汇总分析队列 (群: {group_id})" + f"[LLM] Đã vào hàng đợi tổng hợp chất lượng trò chuyện (nhóm: {group_id})" ) ( summarized_review, @@ -735,9 +745,8 @@ class AnalysisApplicationService: umo=unified_msg_origin, ) if summarized_review: - # 更新 state 中的 review 为汇总后的结果 - # 这里我们需要将 QualityReview 对象存回 dict 或直接在后续处理中使用 - # build_analysis_result 会使用 state.chat_quality_review + # Cập nhật review trong state bằng kết quả tổng hợp. + # build_analysis_result sử dụng state.chat_quality_review. state.chat_quality_review = { "title": summarized_review.title, "subtitle": summarized_review.subtitle, @@ -753,7 +762,7 @@ class AnalysisApplicationService: "summary": summarized_review.summary, } - # 累加 Token + # Cộng dồn token state.total_token_usage["prompt_tokens"] = ( state.total_token_usage.get("prompt_tokens", 0) + quality_token_usage.prompt_tokens @@ -767,22 +776,25 @@ class AnalysisApplicationService: + quality_token_usage.total_tokens ) except Exception as e: - logger.error(f"增量最终报告聊天质量汇总失败: {e}", exc_info=True) + logger.error( + f"Tổng hợp chất lượng trò chuyện cho báo cáo cuối thất bại: {e}", + exc_info=True, + ) - # 7. 构建 analysis_result + # 7. Xây dựng analysis_result analysis_result = self.incremental_merge_service.build_analysis_result( state, user_titles ) - # 8. 持久化到 history_manager + # 8. Lưu vào history_manager await self.history_manager.save_analysis(group_id, analysis_result) logger.info( - f"群 {group_id} 增量最终报告完成: " - f"窗口={state.get_window_date_str()}, " - f"累计消息={state.total_message_count}, " - f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}, " - f"批次={state.total_analysis_count}" + f"Hoàn tất báo cáo gia tăng cuối của nhóm {group_id}: " + f"cửa sổ={state.get_window_date_str()}, " + f"tin nhắn tích luỹ={state.total_message_count}, " + f"chủ đề={len(state.topics)}, trích dẫn={len(state.golden_quotes)}, " + f"batch={state.total_analysis_count}" ) return { @@ -795,7 +807,7 @@ class AnalysisApplicationService: } # ---------------------------------------------------------------- - # 辅助方法 + # Phương thức hỗ trợ # ---------------------------------------------------------------- @staticmethod @@ -803,13 +815,13 @@ class AnalysisApplicationService: messages: list[UnifiedMessage], ) -> tuple[dict[int, int], dict[int, int]]: """ - 从消息列表计算按小时的消息数和字符数分布。 + Tính phân bố số tin nhắn và ký tự theo giờ từ danh sách tin nhắn. Args: - messages: 统一格式的消息列表 + messages: Danh sách tin nhắn thống nhất. Returns: - tuple: (每小时消息计数, 每小时字符计数) + Tuple gồm số tin nhắn và số ký tự theo giờ. """ hourly_msg: dict[int, int] = defaultdict(int) hourly_char: dict[int, int] = defaultdict(int) @@ -827,22 +839,22 @@ class AnalysisApplicationService: messages: list[UnifiedMessage], ) -> dict[str, dict]: """ - 将 AnalysisDomainService.analyze_user_activity() 的返回格式 - 转换为 IncrementalBatch 所需的 user_stats 格式。 + Chuyển kết quả của ``AnalysisDomainService.analyze_user_activity()`` + sang format ``user_stats`` mà ``IncrementalBatch`` yêu cầu. - 转换映射: + Ánh xạ chuyển đổi: - nickname -> name - hours (defaultdict) -> active_hours (list) - - 新增 last_message_time(从消息时间戳中提取) + - Thêm last_message_time lấy từ timestamp tin nhắn. Args: - user_activity: AnalysisDomainService 返回的用户活跃数据 - messages: 本批次的消息列表(用于提取每个用户的最后发言时间) + user_activity: Dữ liệu hoạt động do AnalysisDomainService trả về. + messages: Tin nhắn batch dùng để lấy thời gian cuối của từng thành viên. Returns: - dict: IncrementalBatch 所需的 user_stats 格式 + Dict ``user_stats`` theo format IncrementalBatch yêu cầu. """ - # 预先计算每个用户的最后消息时间戳 + # Tính trước timestamp tin nhắn cuối của từng thành viên user_last_time: dict[str, int] = {} for msg in messages: current = user_last_time.get(msg.sender_id, 0) @@ -859,7 +871,7 @@ class AnalysisApplicationService: "reply_count": stats.get("reply_count", 0), "hours": dict( stats.get("hours", {}) - ), # 这里的 hours 是 defaultdict(int),转为 dict + ), # hours là defaultdict(int), chuyển thành dict "last_message_time": user_last_time.get(user_id, 0), } diff --git a/src/application/services/message_processing_service.py b/src/application/services/message_processing_service.py index 929388f..9af8c63 100644 --- a/src/application/services/message_processing_service.py +++ b/src/application/services/message_processing_service.py @@ -13,18 +13,18 @@ _QQ_OFFICIAL_MENTION_PATTERN = re.compile(r"<@!?([A-Za-z0-9_-]+)>") class MessageProcessingService: """ - 消息处理服务 + Dịch vụ xử lý tin nhắn. - 解析收到的群消息事件,提取内容与发送者信息,持久化历史记录, - 并维护事件驱动平台(Telegram、QQ 官方等)的群组注册表。 - QQ 官方平台特有的重复消息去重逻辑也在本服务中处理。 + Phân tích sự kiện tin nhắn nhóm, trích xuất nội dung và thông tin người gửi, + lưu lịch sử và duy trì registry nhóm cho các nền tảng hướng sự kiện như + Telegram và QQ Official. Dịch vụ cũng xử lý loại trùng riêng của QQ Official. - 职责: - 1. 解析消息内容(文本、图片、@提及等) - 2. 解析发送者展示名(跨平台兼容) - 3. 存储消息历史 - 4. 维护群组注册表,供调度器做群组发现(Telegram、QQ 官方等事件驱动平台) - 5. QQ 官方事件消息去重(按 message_id 预占 + 确认机制) + Trách nhiệm: + 1. Phân tích nội dung tin nhắn như văn bản, hình ảnh và @mention. + 2. Xác định tên hiển thị của người gửi trên nhiều nền tảng. + 3. Lưu lịch sử tin nhắn. + 4. Duy trì registry để scheduler khám phá nhóm trên nền tảng hướng sự kiện. + 5. Loại trùng sự kiện QQ Official bằng cơ chế giữ chỗ và xác nhận message_id. """ def __init__(self, context: Context, group_registry: PlatformGroupRegistry): @@ -36,45 +36,49 @@ class MessageProcessingService: async def process_message(self, event: AstrMessageEvent) -> None: """ - 处理并在历史记录中存储消息。 - 被 main.py 的 Telegram 和 QQ 官方消息拦截器共同调用。 + Xử lý và lưu tin nhắn vào lịch sử. - Args: - event: AstrBot 消息事件 + Được interceptor Telegram và QQ Official trong ``main.py`` cùng gọi. - Raises: - ValueError: 当必要数据无法获取时 - RuntimeError: 当消息内容为空时 + Args: + event: Sự kiện tin nhắn AstrBot. + + Raises: + ValueError: Khi không lấy được dữ liệu bắt buộc. + RuntimeError: Khi nội dung tin nhắn rỗng. """ - # 1. 获取群组 ID(必需) + # 1. Lấy ID nhóm (bắt buộc) group_id = self._get_group_id_from_event(event) if not group_id: - raise ValueError("无法获取群组 ID,拒绝存储消息") + raise ValueError("Không thể lấy ID nhóm; từ chối lưu tin nhắn") - # 2. 获取发送者 ID(必需) + # 2. Lấy ID người gửi (bắt buộc) sender_id = event.get_sender_id() if not sender_id: - raise ValueError(f"群 {group_id}: 无法获取发送者 ID,拒绝存储消息") + raise ValueError( + f"Nhóm {group_id}: không thể lấy ID người gửi; từ chối lưu tin nhắn" + ) sender_id = str(sender_id) - # 3. 获取发送者名称(昵称优先,必要时回退) + # 3. Lấy tên người gửi, ưu tiên biệt danh và dùng fallback khi cần sender_name = self._resolve_sender_name(event, sender_id) - # 4. 获取平台 ID(必需) + # 4. Lấy ID nền tảng (bắt buộc) platform_id = event.get_platform_id() if not platform_id: - raise ValueError(f"群 {group_id}: 无法获取平台 ID,拒绝存储消息") - - # 5. 提取消息内容 - message_parts = self._extract_message_parts(event) - if not message_parts: - # 尝试记录一条警告但不中断流程(或者视为错误) - # 原逻辑是抛出 RuntimeError - raise RuntimeError( - f"群 {group_id}: 消息内容为空 (sender={sender_name}),拒绝存储" + raise ValueError( + f"Nhóm {group_id}: không thể lấy ID nền tảng; từ chối lưu tin nhắn" ) - # 6. 提取事件消息 ID 和事件时间 + # 5. Trích xuất nội dung tin nhắn + message_parts = self._extract_message_parts(event) + if not message_parts: + # Nội dung rỗng được coi là lỗi như hành vi ban đầu. + raise RuntimeError( + f"Nhóm {group_id}: nội dung tin nhắn rỗng (người gửi={sender_name}); từ chối lưu" + ) + + # 6. Trích xuất ID tin nhắn sự kiện và thời gian sự kiện msg_obj = getattr(event, "message_obj", None) event_message_id = str(getattr(msg_obj, "message_id", "") or "") @@ -83,7 +87,9 @@ class MessageProcessingService: if platform_name in {"qq_official", "qq_official_webhook"} and event_message_id: reserved_event_id = self._reserve_event_id(event_message_id) if not reserved_event_id: - logger.debug("[QQOfficial] 跳过重复消息事件: %s", event_message_id) + logger.debug( + "[QQOfficial] Bỏ qua sự kiện tin nhắn trùng: %s", event_message_id + ) return history_content = { "type": "user", @@ -96,7 +102,7 @@ class MessageProcessingService: "timestamp": event_timestamp, } - # 7. 存储到数据库 + # 7. Lưu vào cơ sở dữ liệu try: await self.context.message_history_manager.insert( platform_id=platform_id, @@ -130,11 +136,11 @@ class MessageProcessingService: ) logger.debug( - f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})" + f"[{platform_id}] Đã lưu tin nhắn của nhóm {group_id} (người gửi: {sender_name})" ) def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None: - """从消息事件中安全获取群组 ID""" + """Lấy ID nhóm an toàn từ sự kiện tin nhắn.""" try: group_id = event.get_group_id() return group_id if group_id else None @@ -142,7 +148,7 @@ class MessageProcessingService: return None def _resolve_sender_name(self, event: AstrMessageEvent, sender_id: str) -> str: - """解析发送者展示名""" + """Xác định tên hiển thị của người gửi.""" platform_name = str(event.get_platform_name() or "").lower() candidates: list[str | None] = [] @@ -187,7 +193,7 @@ class MessageProcessingService: return sender_id def _extract_message_parts(self, event: AstrMessageEvent) -> list[dict]: - """从事件中提取消息内容""" + """Trích xuất nội dung tin nhắn từ sự kiện.""" message_parts = [] message = event.message_obj platform_name = str(event.get_platform_name() or "").strip().lower() @@ -197,7 +203,7 @@ class MessageProcessingService: else None ) - # 收集 @ 标记 + # Thu thập các @mention pending_mentions: Counter[str] = Counter() if message and hasattr(message, "message"): for seg in message.message: @@ -282,7 +288,7 @@ class MessageProcessingService: ) message_parts.append({"type": "plain", "text": fallback_text}) - # 清理空文本段 + # Loại bỏ phân đoạn văn bản rỗng message_parts = [ part for part in message_parts @@ -338,7 +344,7 @@ class MessageProcessingService: ).strip() display_name = display_name.lstrip("@").strip() if cls._is_placeholder_sender_name(display_name, mention_id): - display_name = "群友" + display_name = "Thành viên" replacements[mention_id] = f"@{display_name}" return replacements @@ -348,8 +354,8 @@ class MessageProcessingService: def replace_mention(match: re.Match[str]) -> str: mention_id = match.group(1) if mention_id.lower() in {"all", "everyone"}: - return "@全体成员" - return replacements.get(mention_id, "@群友") + return "@Tất cả thành viên" + return replacements.get(mention_id, "@Thành viên") cleaned = _QQ_OFFICIAL_MENTION_PATTERN.sub(replace_mention, str(text)) return re.sub(r"[^\S\r\n]{2,}", " ", cleaned).strip(" \t") @@ -370,7 +376,7 @@ class MessageProcessingService: @staticmethod def _strip_known_mentions(text: str, pending_mentions: Counter[str]) -> str: - """从文本中移除已识别的 @ 提及""" + """Xoá các @mention đã nhận diện khỏi văn bản.""" cleaned = str(text) if not cleaned or not pending_mentions: return cleaned.strip() @@ -396,7 +402,7 @@ class MessageProcessingService: @staticmethod def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool: - """判断 sender_name 是否为占位值""" + """Kiểm tra sender_name có phải giá trị placeholder hay không.""" if not name: return True normalized = str(name).strip() @@ -408,7 +414,7 @@ class MessageProcessingService: @staticmethod def _extract_event_timestamp(message_obj: object) -> int: - """从消息对象中提取平台事件时间戳。""" + """Trích xuất timestamp sự kiện nền tảng từ đối tượng tin nhắn.""" raw_message = getattr(message_obj, "raw_message", None) if isinstance(raw_message, dict): candidate = raw_message.get("timestamp") @@ -437,7 +443,7 @@ class MessageProcessingService: return 0 def _reserve_event_id(self, event_message_id: str) -> bool: - """预占事件消息 ID:在历史记录持久化期间防止重复入库。""" + """Giữ chỗ ID sự kiện để tránh lưu trùng trong lúc ghi lịch sử.""" if ( event_message_id in self._inflight_event_ids or event_message_id in self._seen_event_ids @@ -449,7 +455,7 @@ class MessageProcessingService: return True def _commit_event_id(self, event_message_id: str) -> None: - """确认事件消息 ID:标记为已持久化,纳入后续去重。""" + """Xác nhận ID sự kiện đã lưu để dùng cho các lần loại trùng sau.""" self._inflight_event_ids.discard(event_message_id) if event_message_id in self._seen_event_ids: self._seen_event_ids.move_to_end(event_message_id) @@ -459,5 +465,5 @@ class MessageProcessingService: self._seen_event_ids.popitem(last=False) def _release_event_id(self, event_message_id: str) -> None: - """释放事件消息 ID:持久化失败或取消时清理预占状态。""" + """Giải phóng ID sự kiện khi việc lưu thất bại hoặc bị huỷ.""" self._inflight_event_ids.discard(event_message_id) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index e7f7a29..8d4c878 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -1 +1 @@ -# 领域层 - 与平台无关的业务逻辑 +# Tầng domain - logic nghiệp vụ độc lập với nền tảng diff --git a/src/domain/entities/__init__.py b/src/domain/entities/__init__.py index 80631e6..296900d 100644 --- a/src/domain/entities/__init__.py +++ b/src/domain/entities/__init__.py @@ -1,10 +1,10 @@ """ -领域实体 +Các entity thuộc tầng domain. -该模块导出所有领域实体类,包括: -- AnalysisTask: 分析任务聚合根 -- IncrementalBatch: 增量分析独立批次实体 -- IncrementalState: 增量分析聚合视图(报告时使用) +Module này export các entity domain, gồm: +- AnalysisTask: aggregate root của tác vụ phân tích +- IncrementalBatch: entity đại diện cho một batch phân tích gia tăng +- IncrementalState: view tổng hợp phân tích gia tăng dùng khi tạo báo cáo """ from .analysis_task import AnalysisTask, TaskStatus diff --git a/src/domain/entities/analysis_task.py b/src/domain/entities/analysis_task.py index ab35b7e..55916f8 100644 --- a/src/domain/entities/analysis_task.py +++ b/src/domain/entities/analysis_task.py @@ -1,6 +1,4 @@ -""" -分析任务实体 - 聚合根 -""" +"""Entity tác vụ phân tích - aggregate root.""" import time import uuid @@ -22,7 +20,7 @@ class TaskStatus(Enum): @dataclass class AnalysisTask: - """分析任务实体 - 聚合根""" + """Entity tác vụ phân tích - aggregate root.""" id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) group_id: str = "" @@ -37,34 +35,34 @@ class AnalysisTask: error_message: str | None = None def start(self, can_analyze: bool) -> bool: - """启动任务,验证平台能力""" + """Khởi động tác vụ và kiểm tra năng lực nền tảng.""" if not can_analyze: self.status = TaskStatus.UNSUPPORTED_PLATFORM - self.error_message = f"平台 {self.platform_name} 不支持分析" + self.error_message = f"Nền tảng {self.platform_name} không hỗ trợ phân tích" return False self.status = TaskStatus.FETCHING_MESSAGES self.started_at = time.time() return True def advance_to(self, status: TaskStatus): - """推进到下一个状态""" + """Chuyển tác vụ sang trạng thái tiếp theo.""" self.status = status def complete(self, result_id: str): - """标记任务为已完成""" + """Đánh dấu tác vụ đã hoàn tất.""" self.status = TaskStatus.COMPLETED self.result_id = result_id self.completed_at = time.time() def fail(self, error: str): - """标记任务为失败""" + """Đánh dấu tác vụ thất bại.""" self.status = TaskStatus.FAILED self.error_message = error self.completed_at = time.time() @property def duration(self) -> float | None: - """获取任务持续时间(秒)""" + """Lấy thời lượng thực thi tác vụ tính bằng giây.""" if self.started_at and self.completed_at: return self.completed_at - self.started_at return None diff --git a/src/domain/entities/incremental_state.py b/src/domain/entities/incremental_state.py index 5d3c424..315b5de 100644 --- a/src/domain/entities/incremental_state.py +++ b/src/domain/entities/incremental_state.py @@ -1,14 +1,14 @@ """ -增量分析实体 — 滑动窗口批次存储架构 +Entity phân tích gia tăng theo kiến trúc lưu batch trong cửa sổ trượt. -核心概念: -- IncrementalBatch: 单次增量分析产生的独立批次数据,按批次独立存储 -- IncrementalState: 报告生成时由多个批次合并而成的聚合视图(不再持久化) +Khái niệm cốt lõi: +- IncrementalBatch: dữ liệu độc lập sinh ra từ một lần phân tích gia tăng +- IncrementalState: view tổng hợp từ nhiều batch khi tạo báo cáo, không lưu bền vững -滑动窗口设计: -- 每次增量分析产生一个 IncrementalBatch,独立存储到 KV -- 最终报告时按 analysis_days × 24h 的时间窗口查询批次并合并 -- 支持同一天多次发送报告,每次都基于当前时间窗口内的所有批次 +Thiết kế cửa sổ trượt: +- Mỗi lần phân tích gia tăng tạo một IncrementalBatch và lưu riêng vào KV +- Khi tạo báo cáo cuối, truy vấn và gộp batch theo cửa sổ analysis_days × 24 giờ +- Có thể gửi nhiều báo cáo trong ngày, mỗi báo cáo dựa trên mọi batch trong cửa sổ hiện tại """ import time @@ -21,50 +21,50 @@ from typing import Any @dataclass class IncrementalBatch: """ - 单次增量分析批次数据 + Dữ liệu của một batch phân tích gia tăng. - 每次增量分析执行完毕后产生一个 IncrementalBatch, - 包含该批次的所有统计数据和 LLM 分析结果,独立存储到 KV。 + Mỗi lần phân tích gia tăng hoàn tất sẽ tạo một ``IncrementalBatch`` chứa + toàn bộ số liệu thống kê và kết quả LLM của batch, được lưu riêng vào KV. Attributes: - group_id: 群组 ID - batch_id: 批次唯一标识(UUID) - timestamp: 批次创建时间戳(epoch) - messages_count: 本批次分析的消息数量 - characters_count: 本批次的总字符数 - hourly_msg_counts: 按小时的消息计数 {hour_str: count} - hourly_char_counts: 按小时的字符计数 {hour_str: count} - user_stats: 用户统计 {user_id: {name, message_count, char_count, ...}} - emoji_stats: 表情统计 {emoji_type: count} - topics: 本批次提取的话题列表 - golden_quotes: 本批次提取的金句列表 - token_usage: 本批次 token 消耗 {prompt_tokens, completion_tokens, total_tokens} - chat_quality_review: 本批次提取的聊天质量锐评 - last_message_timestamp: 本批次最后一条消息的时间戳 - participant_ids: 本批次参与者 ID 列表 + group_id: ID nhóm. + batch_id: UUID duy nhất của batch. + timestamp: Epoch timestamp khi tạo batch. + messages_count: Số tin nhắn được phân tích trong batch. + characters_count: Tổng số ký tự trong batch. + hourly_msg_counts: Số tin nhắn theo giờ. + hourly_char_counts: Số ký tự theo giờ. + user_stats: Thống kê thành viên. + emoji_stats: Thống kê biểu cảm. + topics: Danh sách chủ đề trích xuất từ batch. + golden_quotes: Danh sách trích dẫn nổi bật từ batch. + token_usage: Mức sử dụng token của batch. + chat_quality_review: Đánh giá chất lượng trò chuyện của batch. + last_message_timestamp: Timestamp tin nhắn cuối trong batch. + participant_ids: Danh sách ID người tham gia trong batch. """ group_id: str = "" batch_id: str = field(default_factory=lambda: str(uuid.uuid4())) timestamp: float = field(default_factory=time.time) - # 统计数据 + # Dữ liệu thống kê messages_count: int = 0 characters_count: int = 0 hourly_msg_counts: dict[str, int] = field(default_factory=dict) hourly_char_counts: dict[str, int] = field(default_factory=dict) - # 用户活跃数据 + # Dữ liệu hoạt động của thành viên user_stats: dict[str, dict] = field(default_factory=dict) - # 表情统计 + # Thống kê biểu cảm emoji_stats: dict[str, Any] = field(default_factory=dict) - # LLM 分析结果 + # Kết quả phân tích bằng LLM topics: list[dict] = field(default_factory=list) golden_quotes: list[dict] = field(default_factory=list) - # Token 消耗 + # Mức sử dụng token token_usage: dict = field( default_factory=lambda: { "prompt_tokens": 0, @@ -73,13 +73,13 @@ class IncrementalBatch: } ) - # 增量追踪 + # Theo dõi phân tích gia tăng chat_quality_review: dict[str, Any] | None = None last_message_timestamp: int = 0 participant_ids: list[str] = field(default_factory=list) def to_dict(self) -> dict: - """序列化为字典,用于 KV 存储""" + """Tuần tự hoá thành dict để lưu trong KV.""" return { "group_id": self.group_id, "batch_id": self.batch_id, @@ -100,7 +100,7 @@ class IncrementalBatch: @classmethod def from_dict(cls, data: dict) -> "IncrementalBatch": - """从字典反序列化""" + """Khôi phục đối tượng từ dict.""" return cls( group_id=data.get("group_id", ""), batch_id=data.get("batch_id", ""), @@ -127,7 +127,7 @@ class IncrementalBatch: ) def get_summary(self) -> dict: - """获取批次摘要信息""" + """Lấy thông tin tóm tắt của batch.""" return { "batch_id": self.batch_id[:8], "timestamp": datetime.fromtimestamp(self.timestamp).strftime( @@ -143,53 +143,53 @@ class IncrementalBatch: @dataclass class IncrementalState: """ - 增量分析聚合视图(报告时使用) + View tổng hợp phân tích gia tăng dùng khi tạo báo cáo. - 由多个 IncrementalBatch 合并而成,不直接持久化。 - IncrementalMergeService.merge_batches() 负责从批次列表构建此对象。 + Được gộp từ nhiều ``IncrementalBatch`` và không lưu trực tiếp. + ``IncrementalMergeService.merge_batches()`` xây dựng đối tượng từ danh sách batch. Attributes: - group_id: 群组 ID - window_start: 滑动窗口起始时间戳 - window_end: 滑动窗口结束时间戳 - topics: 合并去重后的话题列表 - golden_quotes: 合并去重后的金句列表 - hourly_message_counts: 合并后的每小时消息计数 {hour_str: count} - hourly_character_counts: 合并后的每小时字符计数 {hour_str: count} - user_activities: 合并后的用户活跃数据 - emoji_counts: 合并后的表情统计 - total_message_count: 窗口内总消息数 - total_character_count: 窗口内总字符数 - total_analysis_count: 窗口内批次数量 - total_token_usage: 累计 token 消耗 - last_analyzed_message_timestamp: 最后分析消息时间戳 - all_participant_ids: 所有参与者 ID 集合 + group_id: ID nhóm. + window_start: Timestamp bắt đầu cửa sổ trượt. + window_end: Timestamp kết thúc cửa sổ trượt. + topics: Danh sách chủ đề đã gộp và loại trùng. + golden_quotes: Danh sách trích dẫn đã gộp và loại trùng. + hourly_message_counts: Số tin nhắn theo giờ sau khi gộp. + hourly_character_counts: Số ký tự theo giờ sau khi gộp. + user_activities: Dữ liệu hoạt động thành viên sau khi gộp. + emoji_counts: Thống kê biểu cảm sau khi gộp. + total_message_count: Tổng số tin nhắn trong cửa sổ. + total_character_count: Tổng số ký tự trong cửa sổ. + total_analysis_count: Số batch trong cửa sổ. + total_token_usage: Tổng mức sử dụng token. + last_analyzed_message_timestamp: Timestamp tin nhắn được phân tích cuối cùng. + all_participant_ids: Tập hợp ID của tất cả người tham gia. """ - # 标识信息 + # Thông tin định danh group_id: str = "" window_start: float = 0.0 window_end: float = 0.0 - # 合并后的 LLM 分析结果 + # Kết quả phân tích LLM sau khi gộp topics: list[dict] = field(default_factory=list) golden_quotes: list[dict] = field(default_factory=list) chat_quality_review: dict[str, Any] | None = None all_quality_reviews: list[dict] = field( default_factory=list - ) # 存储所有批次的质量锐评,用于最终报告时的汇总分析 + ) # Lưu đánh giá của mọi batch để tổng hợp khi tạo báo cáo cuối - # 合并后的统计数据(按小时) + # Dữ liệu thống kê theo giờ sau khi gộp hourly_message_counts: dict[str, int] = field(default_factory=dict) hourly_character_counts: dict[str, int] = field(default_factory=dict) - # 用户活跃数据 + # Dữ liệu hoạt động của thành viên user_activities: dict[str, dict] = field(default_factory=dict) - # 表情统计 + # Thống kê biểu cảm emoji_counts: dict[str, Any] = field(default_factory=dict) - # 汇总统计 + # Thống kê tổng hợp total_message_count: int = 0 total_character_count: int = 0 total_analysis_count: int = 0 @@ -201,23 +201,23 @@ class IncrementalState: } ) - # 增量跟踪 + # Theo dõi phân tích gia tăng last_analyzed_message_timestamp: int = 0 all_participant_ids: set[str] = field(default_factory=set) - # 元数据 + # Metadata created_at: float = field(default_factory=time.time) updated_at: float = field(default_factory=time.time) def get_peak_hours(self, top_n: int = 3) -> list[int]: """ - 获取消息最活跃的时段。 + Lấy các giờ có nhiều tin nhắn nhất. Args: - top_n: 返回前 N 个最活跃的小时 + top_n: Số giờ hoạt động tích cực nhất cần trả về. Returns: - list[int]: 活跃小时列表,按消息量降序 + Danh sách giờ, sắp xếp giảm dần theo số tin nhắn. """ if not self.hourly_message_counts: return [] @@ -230,26 +230,26 @@ class IncrementalState: def get_most_active_period(self) -> str: """ - 获取最活跃时段的描述字符串。 + Lấy chuỗi mô tả khung giờ hoạt động tích cực nhất. Returns: - str: 如 "20:00-21:00" + Chuỗi dạng ``20:00-21:00``. """ peak = self.get_peak_hours(1) if not peak: - return "未知" + return "Không xác định" hour = peak[0] return f"{hour:02d}:00-{hour + 1:02d}:00" def get_user_activity_ranking(self, top_n: int = 10) -> list[dict]: """ - 获取用户活跃度排名。 + Lấy bảng xếp hạng mức độ hoạt động của thành viên. Args: - top_n: 返回前 N 名 + top_n: Số thành viên đứng đầu cần trả về. Returns: - list[dict]: 按消息数降序排列的用户列表 + Danh sách thành viên sắp xếp giảm dần theo số tin nhắn. """ users = [] for user_id, data in self.user_activities.items(): @@ -266,10 +266,10 @@ class IncrementalState: def get_window_date_str(self) -> str: """ - 获取窗口的日期范围字符串,用于报告显示。 + Lấy chuỗi phạm vi ngày của cửa sổ để hiển thị trong báo cáo. Returns: - str: 如 "2024-01-15" 或 "2024-01-14 ~ 2024-01-15" + Chuỗi như ``2024-01-15`` hoặc ``2024-01-14 ~ 2024-01-15``. """ if self.window_start <= 0 or self.window_end <= 0: return datetime.now().strftime("%Y-%m-%d") @@ -283,10 +283,10 @@ class IncrementalState: def get_summary(self) -> dict: """ - 获取当前增量状态的摘要信息,用于状态查询命令。 + Lấy tóm tắt trạng thái gia tăng cho lệnh truy vấn trạng thái. Returns: - dict: 包含关键统计信息的摘要 + Bản tóm tắt chứa các số liệu thống kê chính. """ return { "group_id": self.group_id, @@ -301,7 +301,7 @@ class IncrementalState: "last_analysis_time": ( datetime.fromtimestamp(self.updated_at).strftime("%H:%M:%S") if self.updated_at - else "无" + else "Không có" ), "peak_hours": self.get_peak_hours(3), } @@ -311,18 +311,18 @@ class IncrementalState: new_topic: dict, existing_topics: list[dict], threshold: float = 0.6 ) -> bool: """ - 检测话题是否与已有话题重复。 + Kiểm tra chủ đề có trùng với chủ đề hiện có hay không. - 使用简单的字符重叠相似度判断。 - 当新话题的名称与已有话题名称相似度超过阈值时,认为是重复话题。 + Dùng độ tương đồng giao nhau ký tự đơn giản. Nếu độ tương đồng giữa + tên chủ đề mới và tên hiện có vượt ngưỡng thì coi là trùng lặp. Args: - new_topic: 待检测的新话题 - existing_topics: 已有话题列表 - threshold: 相似度阈值(0-1),默认 0.6 + new_topic: Chủ đề mới cần kiểm tra. + existing_topics: Danh sách chủ đề hiện có. + threshold: Ngưỡng tương đồng từ 0 đến 1, mặc định là 0.6. Returns: - bool: 是否重复 + Có trùng lặp hay không. """ new_name = new_topic.get("topic", "") if not new_name: @@ -344,15 +344,15 @@ class IncrementalState: new_quote: dict, existing_quotes: list[dict], threshold: float = 0.7 ) -> bool: """ - 检测金句是否与已有金句重复。 + Kiểm tra trích dẫn có trùng với trích dẫn hiện có hay không. Args: - new_quote: 待检测的新金句 - existing_quotes: 已有金句列表 - threshold: 相似度阈值(0-1),默认 0.7 + new_quote: Trích dẫn mới cần kiểm tra. + existing_quotes: Danh sách trích dẫn hiện có. + threshold: Ngưỡng tương đồng từ 0 đến 1, mặc định là 0.7. Returns: - bool: 是否重复 + Có trùng lặp hay không. """ new_content = new_quote.get("content", "") if not new_content: @@ -372,14 +372,14 @@ class IncrementalState: @staticmethod def char_overlap_similarity(s1: str, s2: str) -> float: """ - 计算两个字符串的字符重叠相似度(Jaccard 相似系数)。 + Tính độ tương đồng giao nhau ký tự của hai chuỗi theo hệ số Jaccard. Args: - s1: 第一个字符串 - s2: 第二个字符串 + s1: Chuỗi thứ nhất. + s2: Chuỗi thứ hai. Returns: - float: 相似度值(0-1) + Độ tương đồng từ 0 đến 1. """ if not s1 or not s2: return 0.0 diff --git a/src/domain/exceptions.py b/src/domain/exceptions.py index 9467d41..176fc44 100644 --- a/src/domain/exceptions.py +++ b/src/domain/exceptions.py @@ -1,13 +1,13 @@ """ -领域异常 - 领域层自定义异常 +Ngoại lệ domain - các ngoại lệ tuỳ chỉnh của tầng domain. -该模块包含插件中使用的所有领域特定异常。 -这些异常是平台无关的,表示业务逻辑错误。 +Module này chứa các ngoại lệ đặc thù nghiệp vụ được plugin sử dụng. +Các ngoại lệ độc lập với nền tảng và biểu diễn lỗi logic nghiệp vụ. """ class DomainException(Exception): - """所有领域错误的基础异常。""" + """Ngoại lệ cơ sở cho mọi lỗi domain.""" def __init__(self, message: str, code: str = "DOMAIN_ERROR"): self.message = message @@ -16,33 +16,33 @@ class DomainException(Exception): # ============================================================================ -# 分析异常 +# Ngoại lệ phân tích # ============================================================================ class AnalysisException(DomainException): - """分析相关错误的基础异常。""" + """Ngoại lệ cơ sở cho các lỗi liên quan đến phân tích.""" def __init__(self, message: str, code: str = "ANALYSIS_ERROR"): super().__init__(message, code) class InsufficientDataException(AnalysisException): - """当数据不足以进行分析时抛出。""" + """Được phát sinh khi dữ liệu không đủ để phân tích.""" def __init__(self, message: str = "Không đủ dữ liệu để phân tích"): super().__init__(message, "INSUFFICIENT_DATA") class AnalysisTimeoutException(AnalysisException): - """当分析超时时抛出。""" + """Được phát sinh khi phân tích quá thời gian cho phép.""" def __init__(self, message: str = "Phân tích bị timeout"): super().__init__(message, "ANALYSIS_TIMEOUT") class LLMException(AnalysisException): - """当 LLM API 调用失败时抛出。""" + """Được phát sinh khi gọi API LLM thất bại.""" def __init__(self, message: str = "Gọi API LLM thất bại", provider: str = ""): self.provider = provider @@ -53,7 +53,7 @@ class LLMException(AnalysisException): class LLMRateLimitException(LLMException): - """当 LLM API 速率限制超出时抛出。""" + """Được phát sinh khi vượt quá giới hạn tốc độ của API LLM.""" def __init__( self, message: str = "Vượt quá giới hạn tốc độ LLM", provider: str = "" @@ -63,7 +63,7 @@ class LLMRateLimitException(LLMException): class LLMQuotaExceededException(LLMException): - """当 LLM API 配额超出时抛出。""" + """Được phát sinh khi vượt quá hạn ngạch của API LLM.""" def __init__(self, message: str = "Vượt quá hạn ngạch LLM", provider: str = ""): super().__init__(message, provider) @@ -71,12 +71,12 @@ class LLMQuotaExceededException(LLMException): # ============================================================================ -# 平台异常 +# Ngoại lệ nền tảng # ============================================================================ class PlatformException(DomainException): - """平台相关错误的基础异常。""" + """Ngoại lệ cơ sở cho các lỗi liên quan đến nền tảng.""" def __init__(self, message: str, platform: str = "", code: str = "PLATFORM_ERROR"): self.platform = platform @@ -84,7 +84,7 @@ class PlatformException(DomainException): class PlatformNotSupportedException(PlatformException): - """当平台不被支持时抛出。""" + """Được phát sinh khi nền tảng không được hỗ trợ.""" def __init__(self, platform: str): super().__init__( @@ -95,21 +95,21 @@ class PlatformNotSupportedException(PlatformException): class PlatformConnectionException(PlatformException): - """当连接平台失败时抛出。""" + """Được phát sinh khi kết nối đến nền tảng thất bại.""" def __init__(self, message: str = "Kết nối nền tảng thất bại", platform: str = ""): super().__init__(message, platform, "PLATFORM_CONNECTION_ERROR") class PlatformAPIException(PlatformException): - """当平台 API 调用失败时抛出。""" + """Được phát sinh khi gọi API nền tảng thất bại.""" def __init__(self, message: str = "Gọi API nền tảng thất bại", platform: str = ""): super().__init__(message, platform, "PLATFORM_API_ERROR") class MessageFetchException(PlatformException): - """当获取消息失败时抛出。""" + """Được phát sinh khi lấy tin nhắn thất bại.""" def __init__( self, @@ -126,7 +126,7 @@ class MessageFetchException(PlatformException): class MessageSendException(PlatformException): - """当发送消息失败时抛出。""" + """Được phát sinh khi gửi tin nhắn thất bại.""" def __init__( self, @@ -143,19 +143,19 @@ class MessageSendException(PlatformException): # ============================================================================ -# 配置异常 +# Ngoại lệ cấu hình # ============================================================================ class ConfigurationException(DomainException): - """配置相关错误的基础异常。""" + """Ngoại lệ cơ sở cho các lỗi liên quan đến cấu hình.""" def __init__(self, message: str, code: str = "CONFIG_ERROR"): super().__init__(message, code) class InvalidConfigurationException(ConfigurationException): - """当配置无效时抛出。""" + """Được phát sinh khi cấu hình không hợp lệ.""" def __init__(self, message: str = "Cấu hình không hợp lệ", key: str = ""): self.key = key @@ -163,7 +163,7 @@ class InvalidConfigurationException(ConfigurationException): class MissingConfigurationException(ConfigurationException): - """当缺少必需配置时抛出。""" + """Được phát sinh khi thiếu cấu hình bắt buộc.""" def __init__(self, key: str): self.key = key @@ -171,19 +171,19 @@ class MissingConfigurationException(ConfigurationException): # ============================================================================ -# 仓储异常 +# Ngoại lệ repository # ============================================================================ class RepositoryException(DomainException): - """仓储相关错误的基础异常。""" + """Ngoại lệ cơ sở cho các lỗi liên quan đến repository.""" def __init__(self, message: str, code: str = "REPOSITORY_ERROR"): super().__init__(message, code) class DataNotFoundException(RepositoryException): - """当请求的数据未找到时抛出。""" + """Được phát sinh khi không tìm thấy dữ liệu được yêu cầu.""" def __init__( self, @@ -200,26 +200,26 @@ class DataNotFoundException(RepositoryException): class DataPersistenceException(RepositoryException): - """当数据持久化失败时抛出。""" + """Được phát sinh khi lưu trữ dữ liệu thất bại.""" def __init__(self, message: str = "Lưu trữ dữ liệu thất bại"): super().__init__(message, "DATA_PERSISTENCE_ERROR") # ============================================================================ -# 调度异常 +# Ngoại lệ lập lịch # ============================================================================ class SchedulingException(DomainException): - """调度相关错误的基础异常。""" + """Ngoại lệ cơ sở cho các lỗi liên quan đến lập lịch.""" def __init__(self, message: str, code: str = "SCHEDULING_ERROR"): super().__init__(message, code) class TaskAlreadyScheduledException(SchedulingException): - """当尝试调度已调度的任务时抛出。""" + """Được phát sinh khi cố lập lịch cho tác vụ đã được lập lịch.""" def __init__(self, task_id: str): self.task_id = task_id @@ -229,7 +229,7 @@ class TaskAlreadyScheduledException(SchedulingException): class TaskNotFoundException(SchedulingException): - """当找不到已调度的任务时抛出。""" + """Được phát sinh khi không tìm thấy tác vụ đã lập lịch.""" def __init__(self, task_id: str): self.task_id = task_id @@ -239,12 +239,12 @@ class TaskNotFoundException(SchedulingException): # ============================================================================ -# 验证异常 +# Ngoại lệ xác thực # ============================================================================ class ValidationException(DomainException): - """验证错误的基础异常。""" + """Ngoại lệ cơ sở cho các lỗi xác thực.""" def __init__(self, message: str, field: str = "", code: str = "VALIDATION_ERROR"): self.field = field @@ -252,7 +252,7 @@ class ValidationException(DomainException): class InvalidGroupIdException(ValidationException): - """当群组 ID 无效时抛出。""" + """Được phát sinh khi ID nhóm không hợp lệ.""" def __init__(self, group_id: str): super().__init__( @@ -261,7 +261,7 @@ class InvalidGroupIdException(ValidationException): class InvalidUserIdException(ValidationException): - """当用户 ID 无效时抛出。""" + """Được phát sinh khi ID thành viên không hợp lệ.""" def __init__(self, user_id: str): super().__init__( @@ -270,7 +270,7 @@ class InvalidUserIdException(ValidationException): class InvalidMessageException(ValidationException): - """当消息格式无效时抛出。""" + """Được phát sinh khi định dạng tin nhắn không hợp lệ.""" def __init__(self, message: str = "Định dạng tin nhắn không hợp lệ"): super().__init__(message, "message", "INVALID_MESSAGE") diff --git a/src/domain/repositories/__init__.py b/src/domain/repositories/__init__.py index ea243c0..6fbf4aa 100644 --- a/src/domain/repositories/__init__.py +++ b/src/domain/repositories/__init__.py @@ -1,4 +1,4 @@ -# 仓储接口 +# Các giao diện repository from .avatar_repository import IAvatarRepository from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender from .visualization_repository import IActivityVisualizer diff --git a/src/domain/repositories/analysis_repository.py b/src/domain/repositories/analysis_repository.py index d6100ce..9854ee2 100644 --- a/src/domain/repositories/analysis_repository.py +++ b/src/domain/repositories/analysis_repository.py @@ -1,6 +1,7 @@ """ -分析服务接口 - 领域层 -定义语义分析的抽象契约 +Giao diện dịch vụ phân tích thuộc tầng domain. + +Định nghĩa hợp đồng trừu tượng cho chức năng phân tích ngữ nghĩa. """ from abc import ABC, abstractmethod @@ -15,9 +16,7 @@ from ..models.data_models import ( class IAnalysisProvider(ABC): - """ - LLM 分析提供商接口 - """ + """Giao diện nhà cung cấp dịch vụ phân tích bằng LLM.""" @abstractmethod async def analyze_topics( @@ -26,7 +25,7 @@ class IAnalysisProvider(ABC): umo: str | None = None, session_id: str | None = None, ) -> tuple[list[SummaryTopic], TokenUsage]: - """分析话题""" + """Phân tích các chủ đề thảo luận.""" pass @abstractmethod @@ -38,7 +37,7 @@ class IAnalysisProvider(ABC): top_users: list[dict] | None = None, session_id: str | None = None, ) -> tuple[list[UserTitle], TokenUsage]: - """分析用户称号""" + """Phân tích danh hiệu của thành viên.""" pass @abstractmethod @@ -48,7 +47,7 @@ class IAnalysisProvider(ABC): umo: str | None = None, session_id: str | None = None, ) -> tuple[list[GoldenQuote], TokenUsage]: - """分析金句""" + """Phân tích các trích dẫn nổi bật.""" pass @abstractmethod @@ -69,7 +68,7 @@ class IAnalysisProvider(ABC): TokenUsage, QualityReview | None, ]: - """并发分析所有内容""" + """Phân tích đồng thời tất cả nội dung.""" pass @abstractmethod @@ -83,7 +82,7 @@ class IAnalysisProvider(ABC): golden_quote_enabled: bool = True, chat_quality_enabled: bool = False, ) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]: - """增量模式并发分析""" + """Phân tích đồng thời ở chế độ gia tăng.""" pass @abstractmethod @@ -93,5 +92,5 @@ class IAnalysisProvider(ABC): umo: str | None = None, session_id: str | None = None, ) -> tuple[QualityReview | None, TokenUsage]: - """汇总多个聊天质量报告(增量模式使用)""" + """Tổng hợp nhiều báo cáo chất lượng trò chuyện ở chế độ gia tăng.""" pass diff --git a/src/domain/repositories/avatar_repository.py b/src/domain/repositories/avatar_repository.py index b59fcfa..cb9bc1b 100644 --- a/src/domain/repositories/avatar_repository.py +++ b/src/domain/repositories/avatar_repository.py @@ -1,19 +1,17 @@ -""" -头像仓储接口 - 跨平台头像抽象 -""" +"""Giao diện repository ảnh đại diện - lớp trừu tượng đa nền tảng.""" from abc import ABC, abstractmethod class IAvatarRepository(ABC): """ - 头像仓储接口 + Giao diện repository ảnh đại diện. - 不同平台获取头像的方式不同: - - QQ/OneBot: URL 模板 (q1.qlogo.cn) - - Telegram: API 调用 (getUserProfilePhotos + getFile) - - Discord: CDN URL 模板 (cdn.discordapp.com) - - Slack: users.info API profile.image_* 字段 + Cách lấy ảnh đại diện khác nhau giữa các nền tảng: + - QQ/OneBot: mẫu URL (q1.qlogo.cn) + - Telegram: gọi API (getUserProfilePhotos + getFile) + - Discord: mẫu URL CDN (cdn.discordapp.com) + - Slack: trường profile.image_* của API users.info """ @abstractmethod @@ -23,14 +21,14 @@ class IAvatarRepository(ABC): size: int = 100, ) -> str | None: """ - 获取用户头像 URL + Lấy URL ảnh đại diện thành viên. - 参数: - user_id: 用户 ID - size: 期望的头像尺寸(将选择最接近的可用尺寸) + Args: + user_id: ID thành viên. + size: Kích thước mong muốn; chọn kích thước khả dụng gần nhất. - 返回: - 头像 URL,如果不可用则返回 None + Returns: + URL ảnh đại diện hoặc ``None`` nếu không khả dụng. """ pass @@ -41,13 +39,12 @@ class IAvatarRepository(ABC): size: int = 100, ) -> str | None: """ - 获取用户头像的 Base64 数据 + Lấy dữ liệu Base64 của ảnh đại diện thành viên. - 用于需要嵌入图片的场景(如 HTML 模板渲染) + Dùng trong các trường hợp cần nhúng ảnh, chẳng hạn khi render template HTML. - 返回: - Base64 编码的图片数据 (data:image/png;base64,...), - 如果不可用则返回 None + Returns: + Dữ liệu ảnh mã hoá Base64 hoặc ``None`` nếu không khả dụng. """ pass @@ -57,7 +54,7 @@ class IAvatarRepository(ABC): group_id: str, size: int = 100, ) -> str | None: - """获取群组头像 URL""" + """Lấy URL ảnh đại diện nhóm.""" pass @abstractmethod @@ -67,12 +64,12 @@ class IAvatarRepository(ABC): size: int = 100, ) -> dict[str, str | None]: """ - 批量获取用户头像 URL + Lấy URL ảnh đại diện của nhiều thành viên. - 用于报告生成时需要一次获取多个头像 + Dùng khi tạo báo cáo cần lấy nhiều ảnh đại diện trong một lần. """ pass def get_default_avatar_url(self) -> str: - """获取默认头像 URL(当用户头像不可用时)""" + """Lấy URL ảnh đại diện mặc định khi ảnh của thành viên không khả dụng.""" return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyaDE2di0yYzAtMi42Ni01LjMzLTQtOC00eiIvPjwvc3ZnPg==" diff --git a/src/domain/repositories/message_repository.py b/src/domain/repositories/message_repository.py index a430495..8782a53 100644 --- a/src/domain/repositories/message_repository.py +++ b/src/domain/repositories/message_repository.py @@ -1,6 +1,4 @@ -""" -消息仓储接口 - 平台无关的抽象 -""" +"""Giao diện repository tin nhắn độc lập với nền tảng.""" from abc import ABC, abstractmethod @@ -11,10 +9,10 @@ from ..value_objects.unified_message import UnifiedMessage class IMessageRepository(ABC): """ - 消息仓储接口 + Giao diện repository tin nhắn. - 每个平台适配器必须实现此接口。 - 所有方法返回统一格式,隐藏平台差异。 + Mỗi adapter nền tảng phải triển khai giao diện này. Tất cả phương thức + trả về định dạng thống nhất để che giấu khác biệt giữa các nền tảng. """ @abstractmethod @@ -27,33 +25,33 @@ class IMessageRepository(ABC): since_ts: int | None = None, ) -> list[UnifiedMessage]: """ - 获取群组消息历史 + Lấy lịch sử tin nhắn của nhóm. - 参数: - group_id: 群组 ID - days: 获取最近 N 天的消息 - max_count: 最大消息数量 - before_id: 获取此 ID 之前的消息(用于分页) - since_ts: 从指定时间戳开始拉取消息(Unix timestamp),优先级高于 days。 + Args: + group_id: ID của nhóm. + days: Số ngày gần nhất cần lấy tin nhắn. + max_count: Số lượng tin nhắn tối đa. + before_id: Chỉ lấy tin nhắn trước ID này, dùng để phân trang. + since_ts: Lấy tin nhắn từ Unix timestamp này; ưu tiên hơn ``days``. - 返回: - 统一消息列表,按时间升序排列 + Returns: + Danh sách tin nhắn thống nhất, sắp xếp tăng dần theo thời gian. """ pass @abstractmethod def get_capabilities(self) -> PlatformCapabilities: - """获取平台能力""" + """Lấy mô tả năng lực của nền tảng.""" pass @abstractmethod def get_platform_name(self) -> str: - """获取平台名称""" + """Lấy tên nền tảng.""" pass class IMessageSender(ABC): - """消息发送接口""" + """Giao diện gửi tin nhắn.""" @abstractmethod async def send_text( @@ -62,7 +60,7 @@ class IMessageSender(ABC): text: str, reply_to: str | None = None, ) -> bool: - """发送文本消息""" + """Gửi tin nhắn văn bản.""" pass @abstractmethod @@ -72,7 +70,7 @@ class IMessageSender(ABC): image_path: str, caption: str = "", ) -> bool: - """发送图片消息""" + """Gửi tin nhắn hình ảnh.""" pass @abstractmethod @@ -82,12 +80,13 @@ class IMessageSender(ABC): nodes: list[dict], ) -> bool: """ - 发送合并转发消息。 + Gửi tin nhắn chuyển tiếp tổng hợp. Args: - group_id: 目标群组 ID - nodes: 转发节点列表。每个节点通常包含 name, uin (或 user_id), content。 - 目前主要用于 OneBot 兼容性。 + group_id: ID nhóm đích. + nodes: Danh sách nút chuyển tiếp. Mỗi nút thường chứa ``name``, + ``uin`` (hoặc ``user_id``) và ``content``. Hiện chủ yếu dùng + để tương thích với OneBot. """ pass @@ -98,26 +97,26 @@ class IMessageSender(ABC): file_path: str, filename: str | None = None, ) -> bool: - """发送文件""" + """Gửi tệp.""" pass class IGroupInfoRepository(ABC): - """群组信息仓储接口""" + """Giao diện repository thông tin nhóm.""" @abstractmethod async def get_group_info(self, group_id: str) -> UnifiedGroup | None: - """获取群组信息""" + """Lấy thông tin nhóm.""" pass @abstractmethod async def get_group_list(self) -> list[str]: - """获取机器人所在的所有群组 ID""" + """Lấy ID của tất cả nhóm mà bot đang tham gia.""" pass @abstractmethod async def get_member_list(self, group_id: str) -> list[UnifiedMember]: - """获取群组成员列表""" + """Lấy danh sách thành viên nhóm.""" pass @abstractmethod @@ -126,5 +125,5 @@ class IGroupInfoRepository(ABC): group_id: str, user_id: str, ) -> UnifiedMember | None: - """获取指定成员信息""" + """Lấy thông tin của thành viên được chỉ định.""" pass diff --git a/src/domain/repositories/report_repository.py b/src/domain/repositories/report_repository.py index 60930bf..060038e 100644 --- a/src/domain/repositories/report_repository.py +++ b/src/domain/repositories/report_repository.py @@ -1,6 +1,7 @@ """ -报告生成接口 - 领域层 -定义分析报告生成的抽象契约 +Giao diện tạo báo cáo thuộc tầng domain. + +Định nghĩa hợp đồng trừu tượng cho chức năng tạo báo cáo phân tích. """ from abc import ABC, abstractmethod @@ -8,9 +9,7 @@ from typing import Any class IReportGenerator(ABC): - """ - 报告生成器接口 - """ + """Giao diện trình tạo báo cáo.""" @abstractmethod async def generate_image_report( @@ -24,7 +23,7 @@ class IReportGenerator(ABC): hide_user_names: bool = False, allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: - """生成图片报告""" + """Tạo báo cáo hình ảnh.""" pass @abstractmethod @@ -38,15 +37,15 @@ class IReportGenerator(ABC): hide_user_names: bool = False, allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: - """生成 HTML 报告""" + """Tạo báo cáo HTML.""" pass @abstractmethod def generate_text_report(self, analysis_result: dict) -> str: - """生成文本报告""" + """Tạo báo cáo văn bản.""" pass @abstractmethod async def close(self): - """释放资源""" + """Giải phóng tài nguyên.""" pass diff --git a/src/domain/repositories/visualization_repository.py b/src/domain/repositories/visualization_repository.py index bfcb6a5..9005fb3 100644 --- a/src/domain/repositories/visualization_repository.py +++ b/src/domain/repositories/visualization_repository.py @@ -1,6 +1,7 @@ """ -可视化仓储接口 - 领域层 -定义活跃度可视化的抽象契约。 +Giao diện repository trực quan hoá thuộc tầng domain. + +Định nghĩa hợp đồng trừu tượng cho chức năng trực quan hoá hoạt động. """ from abc import ABC, abstractmethod @@ -9,11 +10,11 @@ from ..models.data_models import ActivityVisualization class IActivityVisualizer(ABC): - """活跃度可视化接口 - 领域层抽象""" + """Giao diện trực quan hoá hoạt động của tầng domain.""" @abstractmethod def generate_activity_visualization( self, messages: list[dict] ) -> ActivityVisualization: - """从消息列表生成活跃度可视化数据""" + """Tạo dữ liệu trực quan hoá hoạt động từ danh sách tin nhắn.""" pass diff --git a/src/domain/services/__init__.py b/src/domain/services/__init__.py index 1edbc6b..3115193 100644 --- a/src/domain/services/__init__.py +++ b/src/domain/services/__init__.py @@ -1,8 +1,8 @@ """ -领域服务 - 分析业务逻辑服务 +Dịch vụ domain - các dịch vụ xử lý logic phân tích nghiệp vụ. -该模块导出所有封装核心业务逻辑的领域服务, -用于分析群聊数据。这些服务是平台无关的。 +Module này export các dịch vụ domain đóng gói logic nghiệp vụ cốt lõi +để phân tích dữ liệu trò chuyện nhóm. Các dịch vụ độc lập với nền tảng. """ from .incremental_merge_service import IncrementalMergeService diff --git a/src/domain/services/analysis_domain_service.py b/src/domain/services/analysis_domain_service.py index 89ea826..cd6cbe1 100644 --- a/src/domain/services/analysis_domain_service.py +++ b/src/domain/services/analysis_domain_service.py @@ -1,6 +1,8 @@ """ -分析领域服务 - 领域层 -负责用户维度的活跃度分析、发言习惯及活动模式识别。 +Dịch vụ phân tích thuộc tầng domain. + +Phụ trách phân tích mức độ hoạt động, thói quen trò chuyện và nhận diện +mẫu hoạt động của từng thành viên. """ from datetime import datetime @@ -19,7 +21,7 @@ class UserActivityStats(TypedDict): class AnalysisDomainService: - """分析领域服务 - 处理用户画像及行为分析""" + """Xử lý phân tích chân dung và hành vi thành viên.""" def analyze_user_activity( self, @@ -27,9 +29,10 @@ class AnalysisDomainService: bot_self_ids: list[str] | None = None, ) -> dict[str, UserActivityStats]: """ - 分析用户活跃度。 + Phân tích mức độ hoạt động của thành viên. - 基于 UnifiedMessage 计算每个用户的发言数、字数、表情数等。 + Dựa trên ``UnifiedMessage`` để tính số tin nhắn, số ký tự, + số biểu cảm và các chỉ số khác của từng thành viên. """ user_stats: dict[str, UserActivityStats] = {} @@ -38,7 +41,7 @@ class AnalysisDomainService: for msg in messages: user_id = msg.sender_id - # 跳过机器人自己的消息 + # Bỏ qua tin nhắn của chính bot if user_id in bot_ids: continue @@ -56,12 +59,12 @@ class AnalysisDomainService: stats["message_count"] += 1 stats["nickname"] = msg.sender_card or msg.sender_name - # 统计时间分布 + # Thống kê phân bố theo thời gian msg_time = datetime.fromtimestamp(msg.timestamp) hour = msg_time.hour stats["hours"][hour] = stats["hours"].get(hour, 0) + 1 - # 统计内容 + # Thống kê nội dung for content in msg.contents: if content.type == MessageContentType.TEXT: stats["char_count"] += len(content.text or "") @@ -70,7 +73,7 @@ class AnalysisDomainService: stats["emoji_count"] += 1 elif content.type == MessageContentType.IMAGE: - # 与 GroupStatistics 口径保持一致 + # Giữ cách tính nhất quán với GroupStatistics if self._is_emoji_like_image(content.raw_data): stats["emoji_count"] += 1 @@ -81,7 +84,7 @@ class AnalysisDomainService: @staticmethod def _is_emoji_like_image(raw_data: object) -> bool: - """判断 IMAGE 段是否应按表情计数。""" + """Kiểm tra phân đoạn IMAGE có được tính là biểu cảm hay không.""" if isinstance(raw_data, dict): sub_type = raw_data.get("sub_type") if sub_type is not None: @@ -98,7 +101,7 @@ class AnalysisDomainService: def get_top_users( self, user_activity: dict[str, UserActivityStats], limit: int = 10 ) -> list[dict]: - """获取最活跃的用户列表""" + """Lấy danh sách thành viên hoạt động tích cực nhất.""" users = [] for user_id, stats in user_activity.items(): users.append( @@ -112,24 +115,24 @@ class AnalysisDomainService: } ) - # 按消息数量排序 + # Sắp xếp theo số lượng tin nhắn users.sort(key=lambda x: x["message_count"], reverse=True) return users[:limit] def get_user_activity_pattern( self, user_activity: dict[str, UserActivityStats], user_id: str ) -> dict: - """获取并识别指定用户的活动模式""" + """Lấy và nhận diện mẫu hoạt động của thành viên được chỉ định.""" if user_id not in user_activity: return {} stats = user_activity[user_id] hours = stats["hours"] - # 找出最活跃的时间段 + # Xác định khung giờ hoạt động tích cực nhất most_active_hour = max(hours.items(), key=lambda x: x[1])[0] if hours else 0 - # 计算夜间活跃度 (0-6点) + # Tính mức độ hoạt động ban đêm (0-6 giờ) night_messages = sum(hours[h] for h in range(0, 6)) night_ratio = ( night_messages / stats["message_count"] if stats["message_count"] > 0 else 0 diff --git a/src/domain/services/incremental_merge_service.py b/src/domain/services/incremental_merge_service.py index 1ffbdbf..ad174c5 100644 --- a/src/domain/services/incremental_merge_service.py +++ b/src/domain/services/incremental_merge_service.py @@ -1,15 +1,15 @@ """ -增量合并领域服务 +Dịch vụ domain gộp dữ liệu phân tích gia tăng. -负责将 IncrementalBatch 列表合并为 IncrementalState, -以及将 IncrementalState 累积数据转换为现有实体类型, -以便复用现有的报告生成器和分发器。 +Phụ trách gộp danh sách ``IncrementalBatch`` thành ``IncrementalState`` +và chuyển dữ liệu tích luỹ thành các entity hiện có để tái sử dụng trình +tạo và phân phối báo cáo. -核心职责: -- merge_batches: 将多个 IncrementalBatch 合并为一个 IncrementalState(滑动窗口聚合) -- IncrementalState → GroupStatistics(含 ActivityVisualization、EmojiStatistics) -- IncrementalState → list[SummaryTopic] -- IncrementalState → list[GoldenQuote] +Trách nhiệm chính: +- ``merge_batches``: gộp nhiều batch thành một trạng thái trong cửa sổ trượt +- ``IncrementalState`` → ``GroupStatistics`` +- ``IncrementalState`` → ``list[SummaryTopic]`` +- ``IncrementalState`` → ``list[GoldenQuote]`` """ import time @@ -30,10 +30,11 @@ from ...utils.logger import logger class IncrementalMergeService: """ - 增量合并服务 + Dịch vụ gộp dữ liệu gia tăng. - 将滑动窗口内的多个批次数据合并为报告所需的数据结构, - 确保增量模式下生成的最终报告与传统单次分析报告格式完全一致。 + Gộp dữ liệu của nhiều batch trong cửa sổ trượt thành cấu trúc cần cho + báo cáo, đảm bảo báo cáo cuối ở chế độ gia tăng có cùng định dạng với + báo cáo phân tích một lần truyền thống. """ def merge_batches( @@ -43,18 +44,18 @@ class IncrementalMergeService: window_end: float, ) -> IncrementalState: """ - 从批次列表合并构建 IncrementalState。 + Gộp danh sách batch để xây dựng ``IncrementalState``. - 遍历所有批次,累加统计数据并对话题和金句执行去重, - 生成可用于报告的聚合视图。 + Duyệt toàn bộ batch, cộng dồn số liệu và loại trùng chủ đề cùng + trích dẫn để tạo view tổng hợp dùng cho báo cáo. Args: - batches: 时间窗口内的批次列表(按时间升序) - window_start: 窗口起始时间戳(epoch) - window_end: 窗口结束时间戳(epoch) + batches: Danh sách batch trong cửa sổ, tăng dần theo thời gian. + window_start: Epoch timestamp bắt đầu cửa sổ. + window_end: Epoch timestamp kết thúc cửa sổ. Returns: - IncrementalState: 合并后的聚合视图 + IncrementalState: View tổng hợp sau khi gộp. """ state = IncrementalState( group_id=batches[0].group_id if batches else "", @@ -66,25 +67,25 @@ class IncrementalMergeService: ) for batch in batches: - # 累加消息和字符计数 + # Cộng dồn số tin nhắn và ký tự state.total_message_count += batch.messages_count state.total_character_count += batch.characters_count - # 合并每小时消息分布(按键累加) + # Gộp phân bố tin nhắn theo giờ bằng cách cộng theo key for hour_key, count in batch.hourly_msg_counts.items(): hour_str = str(hour_key) state.hourly_message_counts[hour_str] = ( state.hourly_message_counts.get(hour_str, 0) + count ) - # 合并每小时字符分布 + # Gộp phân bố ký tự theo giờ for hour_key, count in batch.hourly_char_counts.items(): hour_str = str(hour_key) state.hourly_character_counts[hour_str] = ( state.hourly_character_counts.get(hour_str, 0) + count ) - # 合并用户统计(按用户累加消息数、字符数等) + # Gộp thống kê thành viên bằng cách cộng dồn theo người dùng for raw_user_id, stats in batch.user_stats.items(): user_id = str(raw_user_id) if user_id not in state.user_activities: @@ -103,43 +104,43 @@ class IncrementalMergeService: existing["emoji_count"] += stats.get("emoji_count", 0) existing["reply_count"] += stats.get("reply_count", 0) - # 合并每小时统计 - # 兼容旧版本 (active_hours 是 list) 和新版本 (hours 是 dict) + # Gộp thống kê theo giờ. + # Tương thích phiên bản cũ (active_hours là list) và mới (hours là dict). batch_hours = stats.get("hours", {}) if isinstance(batch_hours, dict): - # 现代 schema: hours 是 dict {hour: count} + # Schema mới: hours là dict {hour: count} for h_str, h_count in batch_hours.items(): h_int = int(h_str) existing["hours"][h_int] = ( existing["hours"].get(h_int, 0) + h_count ) else: - # 兼容旧 schema: 只有 active_hours (list) + # Schema cũ: chỉ có active_hours (list) active_hours = stats.get("active_hours", []) for h in active_hours: h_int = int(h) existing["hours"][h_int] = existing["hours"].get(h_int, 0) + 1 - # 取最后消息时间的较大值 + # Lấy thời điểm của tin nhắn cuối lớn hơn batch_last = stats.get("last_message_time", 0) if batch_last > existing.get("last_message_time", 0): existing["last_message_time"] = batch_last - # 更新昵称(使用最新批次的有效昵称) + # Cập nhật biệt danh bằng giá trị hợp lệ mới nhất nickname = stats.get("nickname", stats.get("name", "")) if nickname and str(nickname).strip(): existing["nickname"] = nickname - # 合并表情统计(按键累加) + # Gộp thống kê biểu cảm bằng cách cộng theo key for emoji_key, count in batch.emoji_stats.items(): current_val = state.emoji_counts.get(emoji_key, 0) if isinstance(count, dict): - # 如果是嵌套字典(如 face_details),则合并内部计数 + # Gộp bộ đếm bên trong nếu là dict lồng nhau như face_details if not isinstance(current_val, dict): current_val = {} for sub_key, sub_count in count.items(): - # 确保 current_val 是字典且 sub_count 是数字 + # Đảm bảo current_val là dict và sub_count là số if isinstance(current_val, dict): current_val[sub_key] = ( current_val.get(sub_key, 0) + sub_count @@ -147,80 +148,81 @@ class IncrementalMergeService: state.emoji_counts[emoji_key] = current_val else: - # 如果是数值,直接累加 + # Nếu là số thì cộng trực tiếp if isinstance(current_val, dict): - # 异常情况:现有值是字典但新值是数字,通常不应发生,除非 schema 变更 - # 此时保留字典,忽略数字或记录错误,这里选择保留字典 + # Trường hợp bất thường: giá trị cũ là dict nhưng giá trị mới là số. + # Giữ dict và bỏ qua giá trị số để tương thích khi schema thay đổi. continue state.emoji_counts[emoji_key] = current_val + count - # 合并话题(去重) + # Gộp chủ đề và loại trùng for topic in batch.topics: if not IncrementalState.is_duplicate_topic(topic, state.topics): state.topics.append(topic) - # 合并金句(去重) + # Gộp trích dẫn và loại trùng for quote in batch.golden_quotes: if not IncrementalState.is_duplicate_quote(quote, state.golden_quotes): state.golden_quotes.append(quote) - # 累加 token 消耗 + # Cộng dồn mức sử dụng token for token_key in ("prompt_tokens", "completion_tokens", "total_tokens"): state.total_token_usage[token_key] = state.total_token_usage.get( token_key, 0 ) + batch.token_usage.get(token_key, 0) - # 合并参与者 ID(取并集) + # Gộp ID người tham gia bằng phép hợp state.all_participant_ids.update(batch.participant_ids) - # 收集所有批次的质量锐评(用于最终汇总) + # Thu thập đánh giá chất lượng của mọi batch để tổng hợp cuối if batch.chat_quality_review: state.all_quality_reviews.append(batch.chat_quality_review) - # 记录最后分析消息时间戳(取最大值) + # Lưu timestamp tin nhắn phân tích cuối cùng bằng giá trị lớn nhất if batch.last_message_timestamp > state.last_analyzed_message_timestamp: state.last_analyzed_message_timestamp = batch.last_message_timestamp - # 更新锐评为最新批次的 (如果没有汇总分析,则作为兜底) + # Dùng đánh giá của batch mới nhất làm fallback nếu chưa có tổng hợp if batch.chat_quality_review: state.chat_quality_review = batch.chat_quality_review logger.info( - f"合并批次完成: 群={state.group_id}, " - f"窗口={state.get_window_date_str()}, " - f"批次数={len(batches)}, " - f"总消息={state.total_message_count}, " - f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}" + f"Đã gộp batch: nhóm={state.group_id}, " + f"cửa sổ={state.get_window_date_str()}, " + f"số batch={len(batches)}, " + f"tổng tin nhắn={state.total_message_count}, " + f"chủ đề={len(state.topics)}, trích dẫn={len(state.golden_quotes)}" ) return state def build_final_statistics(self, state: IncrementalState) -> GroupStatistics: """ - 从增量状态构建最终的群组统计数据。 + Xây dựng số liệu thống kê nhóm cuối từ trạng thái gia tăng. - 将 IncrementalState 中的累积数据映射到 GroupStatistics, - 包含完整的 24 小时活跃度分布、表情统计和 token 消耗。 + Ánh xạ dữ liệu tích luỹ trong ``IncrementalState`` sang + ``GroupStatistics``, gồm phân bố hoạt động 24 giờ, thống kê biểu cảm + và mức sử dụng token. Args: - state: 由 merge_batches 合并生成的增量分析状态 + state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra. Returns: - GroupStatistics: 与传统分析格式一致的统计数据 + GroupStatistics: Số liệu có cùng định dạng với phân tích truyền thống. """ - # 构建 24 小时活跃度分布 + # Xây dựng phân bố hoạt động trong 24 giờ hourly_activity = {} for hour in range(24): hour_key = str(hour) hourly_activity[hour] = state.hourly_message_counts.get(hour_key, 0) - # 获取高峰时段 + # Lấy các khung giờ cao điểm peak_hours = state.get_peak_hours(3) - # 构建用户活跃排名 + # Xây dựng bảng xếp hạng hoạt động của thành viên user_ranking = state.get_user_activity_ranking(10) - # 构建活跃度可视化数据 + # Xây dựng dữ liệu trực quan hoá hoạt động activity_visualization = ActivityVisualization( hourly_activity=hourly_activity, daily_activity={state.get_window_date_str(): state.total_message_count}, @@ -229,27 +231,27 @@ class IncrementalMergeService: activity_heatmap_data={}, ) - # 构建表情统计 + # Xây dựng thống kê biểu cảm emoji_statistics = self._build_emoji_statistics(state) - # 构建 token 消耗 + # Xây dựng thống kê sử dụng token token_usage = TokenUsage( prompt_tokens=state.total_token_usage.get("prompt_tokens", 0), completion_tokens=state.total_token_usage.get("completion_tokens", 0), total_tokens=state.total_token_usage.get("total_tokens", 0), ) - # 获取最活跃时段描述 + # Lấy mô tả khung giờ hoạt động tích cực nhất most_active_period = state.get_most_active_period() - # 转换聊天质量锐评 (如果有) + # Chuyển đổi đánh giá chất lượng trò chuyện nếu có chat_quality_review = None if state.chat_quality_review: review_dict = state.chat_quality_review dimensions_dict = review_dict.get("dimensions", []) dimensions = [ QualityDimension( - name=d.get("name", "未知"), + name=d.get("name", "Không xác định"), percentage=float(d.get("percentage", 0)), comment=d.get("comment", ""), color=d.get("color", "#607d8b"), @@ -257,10 +259,12 @@ class IncrementalMergeService: for d in dimensions_dict ] chat_quality_review = QualityReview( - title=review_dict.get("title", "聊天质量锐评"), - subtitle=review_dict.get("subtitle", "今天的群里发生了什么?"), + title=review_dict.get("title", "Đánh giá chất lượng trò chuyện"), + subtitle=review_dict.get("subtitle", "Hôm nay nhóm đã có chuyện gì?"), dimensions=dimensions, - summary=review_dict.get("summary", "今天也是充满活力的一天。"), + summary=review_dict.get( + "summary", "Hôm nay cũng là một ngày đầy năng lượng." + ), ) statistics = GroupStatistics( @@ -268,7 +272,7 @@ class IncrementalMergeService: total_characters=state.total_character_count, participant_count=len(state.all_participant_ids), most_active_period=most_active_period, - golden_quotes=[], # 金句通过 build_quotes_for_report 单独构建 + golden_quotes=[], # Trích dẫn được xây dựng riêng bởi build_quotes_for_report emoji_count=emoji_statistics.total_emoji_count, emoji_statistics=emoji_statistics, activity_visualization=activity_visualization, @@ -277,51 +281,52 @@ class IncrementalMergeService: ) logger.debug( - f"从增量状态构建统计: " - f"消息数={state.total_message_count}, " - f"参与人数={len(state.all_participant_ids)}, " - f"话题数={len(state.topics)}, " - f"金句数={len(state.golden_quotes)}" + f"Đã xây dựng thống kê từ trạng thái gia tăng: " + f"tin nhắn={state.total_message_count}, " + f"người tham gia={len(state.all_participant_ids)}, " + f"chủ đề={len(state.topics)}, " + f"trích dẫn={len(state.golden_quotes)}" ) return statistics def build_topics_for_report(self, state: IncrementalState) -> list[SummaryTopic]: """ - 从增量状态构建报告用的话题列表。 + Xây dựng danh sách chủ đề dùng cho báo cáo từ trạng thái gia tăng. - 将 IncrementalState 中累积的话题字典转换为 SummaryTopic 实例列表。 + Chuyển các dict chủ đề tích luỹ trong ``IncrementalState`` thành + danh sách instance ``SummaryTopic``. Args: - state: 由 merge_batches 合并生成的增量分析状态 + state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra. Returns: - list[SummaryTopic]: 话题列表,格式与传统分析结果一致 + list[SummaryTopic]: Danh sách chủ đề cùng định dạng với kết quả truyền thống. """ topics = [] for topic_dict in state.topics: topic = SummaryTopic( - topic=topic_dict.get("topic", "未知话题"), + topic=topic_dict.get("topic", "Chủ đề không xác định"), contributors=topic_dict.get("contributors", []), detail=topic_dict.get("detail", ""), contributor_ids=topic_dict.get("contributor_ids", []), ) topics.append(topic) - logger.debug(f"从增量状态构建了 {len(topics)} 个话题") + logger.debug(f"Đã xây dựng {len(topics)} chủ đề từ trạng thái gia tăng") return topics def build_quotes_for_report(self, state: IncrementalState) -> list[GoldenQuote]: """ - 从增量状态构建报告用的金句列表。 + Xây dựng danh sách trích dẫn dùng cho báo cáo từ trạng thái gia tăng. - 将 IncrementalState 中累积的金句字典转换为 GoldenQuote 实例列表。 + Chuyển các dict trích dẫn tích luỹ thành danh sách instance ``GoldenQuote``. Args: - state: 由 merge_batches 合并生成的增量分析状态 + state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra. Returns: - list[GoldenQuote]: 金句列表,格式与传统分析结果一致 + list[GoldenQuote]: Danh sách trích dẫn cùng định dạng với kết quả truyền thống. """ quotes = [] for quote_dict in state.golden_quotes: @@ -333,7 +338,7 @@ class IncrementalMergeService: ) quotes.append(quote) - logger.debug(f"从增量状态构建了 {len(quotes)} 条金句") + logger.debug(f"Đã xây dựng {len(quotes)} trích dẫn từ trạng thái gia tăng") return quotes def build_analysis_result( @@ -342,23 +347,24 @@ class IncrementalMergeService: user_titles: list | None = None, ) -> dict: """ - 从增量状态构建完整的 analysis_result 字典。 + Xây dựng dict ``analysis_result`` hoàn chỉnh từ trạng thái gia tăng. - 该字典格式与 AnalysisApplicationService.execute_daily_analysis() - 返回的 analysis_result 完全一致,可直接传入 ReportDispatcher。 + Định dạng dict giống hoàn toàn với ``analysis_result`` do + ``AnalysisApplicationService.execute_daily_analysis()`` trả về và có + thể truyền trực tiếp cho ``ReportDispatcher``. Args: - state: 由 merge_batches 合并生成的增量分析状态 - user_titles: 用户称号列表(由最终报告时 LLM 分析生成) + state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra. + user_titles: Danh sách danh hiệu do LLM tạo khi lập báo cáo cuối. Returns: - dict: 包含 statistics、topics、user_titles、user_analysis 的结果字典 + Dict kết quả gồm statistics, topics, user_titles và user_analysis. """ statistics = self.build_final_statistics(state) topics = self.build_topics_for_report(state) golden_quotes = self.build_quotes_for_report(state) - # 将金句回填到 statistics 中(与传统流程一致) + # Gắn trích dẫn trở lại statistics để khớp quy trình truyền thống statistics.golden_quotes = golden_quotes analysis_result = { @@ -370,31 +376,32 @@ class IncrementalMergeService: } logger.info( - f"从增量状态构建完整分析结果: " - f"群={state.group_id}, 窗口={state.get_window_date_str()}, " - f"消息={state.total_message_count}, " - f"话题={len(topics)}, " - f"金句={len(golden_quotes)}, " - f"批次={state.total_analysis_count}" + f"Đã xây dựng kết quả phân tích hoàn chỉnh từ trạng thái gia tăng: " + f"nhóm={state.group_id}, cửa sổ={state.get_window_date_str()}, " + f"tin nhắn={state.total_message_count}, " + f"chủ đề={len(topics)}, " + f"trích dẫn={len(golden_quotes)}, " + f"batch={state.total_analysis_count}" ) return analysis_result def _build_emoji_statistics(self, state: IncrementalState) -> EmojiStatistics: """ - 从增量状态构建表情统计。 + Xây dựng thống kê biểu cảm từ trạng thái gia tăng. - 将 IncrementalState 中的 emoji_counts 字典映射到 EmojiStatistics 字段。 + Ánh xạ dict ``emoji_counts`` trong ``IncrementalState`` vào các + trường của ``EmojiStatistics``. Args: - state: 增量分析状态 + state: Trạng thái phân tích gia tăng. Returns: - EmojiStatistics: 表情统计实例 + EmojiStatistics: Instance thống kê biểu cảm. """ emoji_counts = state.emoji_counts - # 显式提取并检查类型,辅助 Pylance 类型推断 + # Trích xuất và kiểm tra kiểu rõ ràng để hỗ trợ Pylance suy luận kiểu face_details = emoji_counts.get("face_details") if not isinstance(face_details, dict): face_details = {} diff --git a/src/domain/services/message_cleaner_service.py b/src/domain/services/message_cleaner_service.py index 156bac8..318a59f 100644 --- a/src/domain/services/message_cleaner_service.py +++ b/src/domain/services/message_cleaner_service.py @@ -1,6 +1,8 @@ """ -消息清理服务 - 领域层 -负责过滤掉机器人消息、指令、技术性内容(如原始表情代码)及敏感内容。 +Dịch vụ làm sạch tin nhắn thuộc tầng domain. + +Phụ trách lọc tin nhắn của bot, lệnh, nội dung kỹ thuật như mã biểu cảm gốc +và các nội dung nhạy cảm. """ import re @@ -12,14 +14,14 @@ from ..value_objects.unified_message import ( UnifiedMessage, ) -# Discord 自定义表情正则 <:name:id> 或 +# Regex biểu cảm tuỳ chỉnh Discord: <:name:id> hoặc _DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"") -# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息 +# Regex lệnh: khớp tin nhắn bắt đầu bằng / hoặc @thành viên /. _COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/") class MessageCleanerService: - """消息清理服务""" + """Dịch vụ làm sạch tin nhắn.""" def clean_messages( self, @@ -28,25 +30,25 @@ class MessageCleanerService: filter_commands: bool = True, ) -> list[UnifiedMessage]: """ - 清理并过滤消息列表。 + Làm sạch và lọc danh sách tin nhắn. Args: - messages: 原始统一格式消息列表 - bot_self_ids: 机器人自身的 ID 列表 - filter_commands: 是否过滤指令消息 + messages: Danh sách tin nhắn thống nhất ban đầu. + bot_self_ids: Danh sách ID của bot. + filter_commands: Có lọc tin nhắn lệnh hay không. Returns: - 清理后的消息列表 + Danh sách tin nhắn sau khi làm sạch. """ bot_ids = set(bot_self_ids or []) cleaned_list = [] for msg in messages: - # 1. 过滤机器人发送的消息 + # 1. Lọc tin nhắn do bot gửi if msg.sender_id in bot_ids: continue - # 2. 预检指令消息(首个内容块通常是文本) + # 2. Kiểm tra trước tin nhắn lệnh (khối nội dung đầu thường là văn bản) is_command = False first_text = msg.text_content if filter_commands and first_text and _COMMAND_PATTERN.match(first_text): @@ -55,7 +57,7 @@ class MessageCleanerService: if is_command: continue - # 3. 清理消息内容中的技术性噪音 + # 3. Làm sạch nhiễu kỹ thuật trong nội dung tin nhắn cleaned_contents = [] has_meaningful_content = False @@ -63,13 +65,13 @@ class MessageCleanerService: if content.type == MessageContentType.TEXT: text = content.text or "" - # 移除 Discord 原始表情代码 + # Xoá mã biểu cảm gốc của Discord text = _DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text) - # 移除 @mentions 文本 (e.g. <@123456>) + # Xoá văn bản @mention, ví dụ <@123456> text = re.sub(r"<@\d+>", "", text) - # 清理多余空格 + # Xoá khoảng trắng thừa text = text.strip() if text: @@ -78,14 +80,14 @@ class MessageCleanerService: ) has_meaningful_content = True else: - # 其他类型(图片、回复等)暂时保留,但由后续分析器决定是否使用 + # Tạm giữ loại khác như ảnh và reply; analyzer quyết định việc sử dụng cleaned_contents.append(content) if content.type != MessageContentType.REPLY: has_meaningful_content = True - # 4. 如果清理后仍有内容,则保留消息 + # 4. Chỉ giữ tin nhắn nếu vẫn còn nội dung sau khi làm sạch if has_meaningful_content: - # 重新合成 text_content 用于 LLM 分析 + # Ghép lại text_content để phân tích bằng LLM new_text_content = "".join( [ c.text @@ -94,7 +96,7 @@ class MessageCleanerService: ] ).strip() - # 使用 replace 创建新实例(Frozen dataclass 必须如此) + # Dùng replace để tạo instance mới (dataclass frozen yêu cầu như vậy) new_msg = replace( msg, contents=tuple(cleaned_contents), text_content=new_text_content ) diff --git a/src/domain/services/statistics_service.py b/src/domain/services/statistics_service.py index 2377caf..d092d82 100644 --- a/src/domain/services/statistics_service.py +++ b/src/domain/services/statistics_service.py @@ -1,6 +1,8 @@ """ -统计领域服务 - 领域层 -负责核心统计逻辑的计算,不依赖于具体的平台或基础设施。 +Dịch vụ thống kê thuộc tầng domain. + +Phụ trách tính toán logic thống kê cốt lõi, không phụ thuộc nền tảng hoặc +cơ sở hạ tầng cụ thể. """ from collections import defaultdict @@ -13,7 +15,7 @@ from ..value_objects.unified_message import MessageContentType, UnifiedMessage class StatisticsService: - """统计服务 - 处理群聊数据的聚合统计""" + """Xử lý thống kê tổng hợp dữ liệu trò chuyện nhóm.""" def __init__(self, activity_visualizer: IActivityVisualizer | None = None): if activity_visualizer is None: @@ -26,9 +28,10 @@ class StatisticsService: self, messages: list[UnifiedMessage] ) -> GroupStatistics: """ - 计算群组基础统计数据。 + Tính các số liệu thống kê cơ bản của nhóm. - 基于统一消息格式(UnifiedMessage)进行计算,确保跨平台一致性。 + Tính toán dựa trên định dạng ``UnifiedMessage`` để đảm bảo kết quả + nhất quán giữa các nền tảng. """ total_chars = 0 participants = set() @@ -38,35 +41,35 @@ class StatisticsService: for msg in messages: participants.add(msg.sender_id) - # 统计时间分布 + # Thống kê phân bố theo thời gian msg_time = datetime.fromtimestamp(msg.timestamp) hour_counts[msg_time.hour] += 1 - # 处理消息内容 + # Xử lý nội dung tin nhắn for content in msg.contents: if content.type == MessageContentType.TEXT: total_chars += len(content.text or "") elif content.type == MessageContentType.EMOJI: emoji_statistics.face_count += 1 - # 尝试保留原始表情详情(如果适配器提供了) + # Giữ chi tiết biểu cảm gốc nếu adapter cung cấp face_id = content.emoji_id or "unknown" emoji_statistics.face_details[f"emoji_{face_id}"] = ( emoji_statistics.face_details.get(f"emoji_{face_id}", 0) + 1 ) elif content.type == MessageContentType.IMAGE: - # 兼容识别“图片形态的表情”: - # 1) 优先使用 onebot sub_type=1 信号 - # 2) 若无该字段,再回退到历史 summary 文本匹配 + # Nhận diện tương thích biểu cảm ở dạng hình ảnh: + # 1) Ưu tiên tín hiệu sub_type=1 của OneBot + # 2) Nếu không có, đối chiếu văn bản summary kiểu cũ if self._is_emoji_like_image(content.raw_data): emoji_statistics.mface_count += 1 elif content.type in ( MessageContentType.VOICE, MessageContentType.VIDEO, ): - # 其他非文本类型统计(可选) + # Có thể bổ sung thống kê các loại phi văn bản khác pass - # 找出最活跃时段 + # Xác định khung giờ hoạt động tích cực nhất most_active_hour = ( max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0 ) @@ -74,9 +77,9 @@ class StatisticsService: f"{most_active_hour:02d}:00-{(most_active_hour + 1) % 24:02d}:00" ) - # 生成活跃度可视化数据 - # 注意:ActivityVisualizer 可能需要迁移以支持 UnifiedMessage - # 目前先转换回 dict 以保持兼容性,或者之后重构它 + # Tạo dữ liệu trực quan hoá hoạt động + # ActivityVisualizer có thể cần được chuyển đổi để hỗ trợ UnifiedMessage. + # Hiện tại chuyển ngược về dict để duy trì khả năng tương thích. raw_msgs = self._convert_to_legacy_dict(messages) activity_visualization = ( self.activity_visualizer.generate_activity_visualization(raw_msgs) @@ -96,7 +99,7 @@ class StatisticsService: @staticmethod def _is_emoji_like_image(raw_data: object) -> bool: - """判断 IMAGE 段是否应按表情计数。""" + """Kiểm tra phân đoạn IMAGE có được tính là biểu cảm hay không.""" if isinstance(raw_data, dict): sub_type = raw_data.get("sub_type") if sub_type is not None: @@ -111,7 +114,7 @@ class StatisticsService: return "动画表情" in text or "表情" in text def _convert_to_legacy_dict(self, messages: list[UnifiedMessage]) -> list[dict]: - """内部辅助:将 UnifiedMessage 转换为 Legacy Dict 格式,用于兼容可视化组件""" + """Chuyển ``UnifiedMessage`` sang dict cũ để tương thích trình trực quan.""" legacy_list = [] for msg in messages: legacy_list.append( diff --git a/src/domain/value_objects/__init__.py b/src/domain/value_objects/__init__.py index 30ddc0b..f0ec764 100644 --- a/src/domain/value_objects/__init__.py +++ b/src/domain/value_objects/__init__.py @@ -1,10 +1,10 @@ -# 值对象 +# Các value object from .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities from .unified_group import UnifiedGroup, UnifiedMember from .unified_message import MessageContent, MessageContentType, UnifiedMessage __all__ = [ - # 核心平台抽象 + # Lớp trừu tượng nền tảng cốt lõi "UnifiedMessage", "MessageContent", "MessageContentType", diff --git a/src/domain/value_objects/platform_capabilities.py b/src/domain/value_objects/platform_capabilities.py index 86129f7..3d2fecf 100644 --- a/src/domain/value_objects/platform_capabilities.py +++ b/src/domain/value_objects/platform_capabilities.py @@ -43,23 +43,23 @@ class PlatformCapabilities: avatar_sizes (tuple[int, ...]): Các kích thước ảnh đại diện tính bằng pixel mà nền tảng hỗ trợ. """ - # 平台标识 + # Định danh nền tảng platform_name: str platform_version: str = "unknown" - # 消息获取能力 + # Khả năng truy xuất tin nhắn supports_message_history: bool = False max_message_history_days: int = 0 max_message_count: int = 0 supports_message_search: bool = False - # 群组信息能力 + # Khả năng truy xuất thông tin nhóm supports_group_list: bool = False supports_group_info: bool = False supports_member_list: bool = False supports_member_info: bool = False - # 消息发送能力 + # Khả năng gửi tin nhắn supports_text_message: bool = True supports_image_message: bool = False supports_file_message: bool = False @@ -68,12 +68,12 @@ class PlatformCapabilities: max_text_length: int = 4096 max_image_size_mb: float = 10.0 - # 特殊能力 + # Các khả năng đặc biệt supports_at_all: bool = False supports_recall: bool = False supports_edit: bool = False - # 头像能力 + # Khả năng xử lý ảnh đại diện supports_user_avatar: bool = True supports_group_avatar: bool = False avatar_needs_api_call: bool = False diff --git a/src/domain/value_objects/unified_message.py b/src/domain/value_objects/unified_message.py index e3d93f7..748174b 100644 --- a/src/domain/value_objects/unified_message.py +++ b/src/domain/value_objects/unified_message.py @@ -1,7 +1,7 @@ """ -统一消息值对象 - 跨平台核心抽象 +Value object tin nhắn thống nhất - lớp trừu tượng cốt lõi đa nền tảng. -所有平台消息都转换为此格式进行分析。 +Tin nhắn từ mọi nền tảng đều được chuyển sang định dạng này để phân tích. """ from dataclasses import dataclass, field @@ -12,9 +12,9 @@ from typing import Any class MessageContentType(Enum): """ - 枚举:消息内容类型 + Enum loại nội dung tin nhắn. - 用于标识 MessageContent 的具体类型。 + Dùng để xác định loại cụ thể của ``MessageContent``. """ TEXT = "text" @@ -33,19 +33,19 @@ class MessageContentType(Enum): @dataclass(frozen=True) class MessageContent: """ - 值对象:消息内容段 + Value object biểu diễn một phân đoạn nội dung tin nhắn. - 表示消息链中的一个组成部分(如文本、图片、表情等)。 - 该对象是不可变的,用于保证数据流的纯净。 + Biểu diễn một thành phần trong chuỗi tin nhắn, chẳng hạn văn bản, + hình ảnh hoặc biểu cảm. Đối tượng bất biến để đảm bảo luồng dữ liệu sạch. Attributes: - type (MessageContentType): 内容类型 - text (str): 文本内容(仅当类型为 TEXT 或包含文本描述时) - url (str): 资源链接(图片、视频、文件等) - emoji_id (str): 表情 ID - emoji_name (str): 表情名称 - at_user_id (str): 被 @ 的用户 ID - raw_data (Any): 平台原始数据,用于扩展 + type: Loại nội dung. + text: Nội dung văn bản khi loại là TEXT hoặc có mô tả văn bản. + url: Liên kết tài nguyên như hình ảnh, video hoặc tệp. + emoji_id: ID biểu cảm. + emoji_name: Tên biểu cảm. + at_user_id: ID thành viên được nhắc đến. + raw_data: Dữ liệu nền tảng gốc dùng cho mục đích mở rộng. """ type: MessageContentType @@ -57,17 +57,17 @@ class MessageContent: raw_data: Any = None def is_text(self) -> bool: - """检查是否为文本内容。""" + """Kiểm tra đây có phải nội dung văn bản hay không.""" return self.type == MessageContentType.TEXT def is_emoji(self) -> bool: - """检查是否为表情内容。""" + """Kiểm tra đây có phải nội dung biểu cảm hay không.""" return self.type == MessageContentType.EMOJI @property def target_id(self) -> str: """ - 获取被 @ 的用户 ID(兼容旧代码)。 + Lấy ID thành viên được nhắc đến để tương thích mã cũ. Alias for at_user_id. """ @@ -77,101 +77,102 @@ class MessageContent: @dataclass(frozen=True) class UnifiedMessage: """ - 核心值对象:统一消息格式 + Value object cốt lõi biểu diễn định dạng tin nhắn thống nhất. - 跨平台抽象层,将不同平台的原始消息转换为统一格式进行分析。 - 采用“只读”设计,确保分析逻辑的一致性。 + Lớp trừu tượng đa nền tảng chuyển tin nhắn gốc sang một định dạng chung + để phân tích. Thiết kế chỉ đọc đảm bảo logic phân tích nhất quán. Attributes: - message_id (str): 消息唯一标识符 - sender_id (str): 发送者唯一 ID - sender_name (str): 发送者昵称 - group_id (str): 群组/会话唯一 ID - text_content (str): 经过清洗后的纯文本内容,主要用于 LLM 分析 - contents (tuple[MessageContent, ...]): 结构化消息链 - timestamp (int): Unix 时间戳(秒) - platform (str): 来源平台名称(如 onebot, discord 等) - reply_to_id (str, optional): 被回复的消息 ID - sender_card (str, optional): 平台特定的群名片或特别备注 + message_id: Mã định danh duy nhất của tin nhắn. + sender_id: ID duy nhất của người gửi. + sender_name: Biệt danh người gửi. + group_id: ID duy nhất của nhóm hoặc cuộc trò chuyện. + text_content: Nội dung văn bản thuần đã làm sạch, chủ yếu dùng cho LLM. + contents: Chuỗi nội dung tin nhắn có cấu trúc. + timestamp: Unix timestamp tính bằng giây. + platform: Tên nền tảng nguồn, ví dụ OneBot hoặc Discord. + reply_to_id: ID tin nhắn được trả lời. + sender_card: Tên hiển thị hoặc ghi chú riêng của nền tảng. """ - # 基础标识 + # Thông tin định danh cơ bản message_id: str sender_id: str sender_name: str group_id: str - # 消息内容 + # Nội dung tin nhắn text_content: str contents: tuple[MessageContent, ...] = field(default_factory=tuple) - # 时间信息 + # Thông tin thời gian timestamp: int = 0 - # 平台信息 + # Thông tin nền tảng platform: str = "unknown" - # 可选信息 + # Thông tin tuỳ chọn reply_to_id: str | None = None sender_card: str | None = None - # 分析辅助方法 + # Phương thức hỗ trợ phân tích def has_text(self) -> bool: """ - 判断消息是否包含非空文本。 + Kiểm tra tin nhắn có chứa văn bản không rỗng hay không. Returns: - bool: 包含有效文本则返回 True + ``True`` nếu tin nhắn chứa văn bản hợp lệ. """ return bool(self.text_content.strip()) def get_display_name(self) -> str: """ - 获取用户显示名称。 - 优先级:群名片 > 昵称 > 用户 ID。 + Lấy tên hiển thị của thành viên. + + Thứ tự ưu tiên: tên trong nhóm, biệt danh, ID thành viên. Returns: - str: 格式化后的显示名称 + Tên hiển thị đã định dạng. """ return self.sender_card or self.sender_name or self.sender_id def get_emoji_count(self) -> int: """ - 计算消息链中包含的表情数量。 + Tính số biểu cảm có trong chuỗi tin nhắn. Returns: - int: 表情总数 + Tổng số biểu cảm. """ return sum(1 for c in self.contents if c.is_emoji()) def get_text_length(self) -> int: """ - 获取文本内容的字符长度。 + Lấy độ dài ký tự của nội dung văn bản. Returns: - int: 字符数 + Số ký tự. """ return len(self.text_content) def get_datetime(self) -> datetime: """ - 将 Unix 时间戳转换为 datetime 对象。 + Chuyển Unix timestamp thành đối tượng ``datetime``. Returns: - datetime: 本地化后的时间对象 + Đối tượng thời gian theo múi giờ cục bộ. """ return datetime.fromtimestamp(self.timestamp) def to_analysis_format(self) -> str: """ - 转换为供 LLM 消费的分析格式。 + Chuyển sang định dạng phân tích dành cho LLM. Returns: - str: 格式如 "[用户名]: 消息内容" 的字符串 + Chuỗi có dạng ``[tên thành viên]: nội dung tin nhắn``. """ name = self.get_display_name() return f"[{name}]: {self.text_content}" -# 类型别名 +# Bí danh kiểu dữ liệu MessageList = list[UnifiedMessage] diff --git a/src/infrastructure/__init__.py b/src/infrastructure/__init__.py index 2af8900..b75c5f1 100644 --- a/src/infrastructure/__init__.py +++ b/src/infrastructure/__init__.py @@ -1 +1 @@ -# 基础设施层 +# Tầng infrastructure diff --git a/src/infrastructure/analysis/__init__.py b/src/infrastructure/analysis/__init__.py index 1142ee2..da02d47 100644 --- a/src/infrastructure/analysis/__init__.py +++ b/src/infrastructure/analysis/__init__.py @@ -1,7 +1,4 @@ -""" -分析模块 -包含LLM分析功能 -""" +"""Module phân tích, chứa chức năng phân tích bằng LLM.""" from .llm_analyzer import LLMAnalyzer diff --git a/src/infrastructure/analysis/analyzers/__init__.py b/src/infrastructure/analysis/analyzers/__init__.py index d64cf46..315d4ba 100644 --- a/src/infrastructure/analysis/analyzers/__init__.py +++ b/src/infrastructure/analysis/analyzers/__init__.py @@ -1,7 +1,4 @@ -""" -分析器模块 -包含各种LLM分析功能的实现 -""" +"""Module analyzer chứa các triển khai phân tích bằng LLM.""" from .base_analyzer import BaseAnalyzer from .golden_quote_analyzer import GoldenQuoteAnalyzer diff --git a/src/infrastructure/analysis/analyzers/base_analyzer.py b/src/infrastructure/analysis/analyzers/base_analyzer.py index 0df3ed1..54c13e7 100644 --- a/src/infrastructure/analysis/analyzers/base_analyzer.py +++ b/src/infrastructure/analysis/analyzers/base_analyzer.py @@ -1,7 +1,4 @@ -""" -基础分析器抽象类 -定义通用分析流程和接口 -""" +"""Lớp analyzer cơ sở định nghĩa quy trình và giao diện chung.""" from abc import ABC, abstractmethod from collections.abc import Sized @@ -24,91 +21,89 @@ TInputData = TypeVar("TInputData") class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): - """ - 基础分析器抽象类 - 定义所有分析器的通用接口 and 流程 - """ + """Lớp analyzer trừu tượng với giao diện và quy trình dùng chung.""" def __init__(self, context, config_manager): """ - 初始化基础分析器 + Khởi tạo analyzer cơ sở. Args: - context: AstrBot上下文对象 - config_manager: 配置管理器 + context: Context AstrBot. + config_manager: Trình quản lý cấu hình. """ self.context = context self.config_manager = config_manager - # 增量分析模式下的最大数量覆盖值,为 None 时使用配置默认值 + # Giới hạn ghi đè cho phân tích gia tăng; None dùng cấu hình mặc định. self._incremental_max_count: int | None = None def get_provider_id_key(self) -> str | None: """ - 获取 Provider ID 配置键名 - 子类可重写以指定特定的 provider,默认返回 None(使用主 LLM Provider) + Lấy tên key cấu hình Provider ID. + + Lớp con có thể ghi đè để chỉ định provider riêng; mặc định dùng provider LLM chính. Returns: - Provider ID 配置键名,如 'topic_provider_id' + Tên key cấu hình như ``topic_provider_id``. """ return None @abstractmethod def get_data_type(self) -> str: """ - 获取数据类型标识 + Lấy định danh loại dữ liệu. Returns: - 数据类型字符串 + Chuỗi loại dữ liệu. """ pass @abstractmethod def get_max_count(self) -> int: """ - 获取最大提取数量 + Lấy số lượng trích xuất tối đa. Returns: - 最大数量 + Số lượng tối đa. """ pass @abstractmethod def build_prompt(self, data: TInputData) -> str: """ - 构建LLM提示词 + Xây dựng prompt LLM. Args: - data: 输入数据 + data: Dữ liệu đầu vào. Returns: - 提示词字符串 + Chuỗi prompt. """ pass @abstractmethod def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: """ - 使用正则表达式提取数据 + Trích xuất dữ liệu bằng regex. Args: - result_text: LLM响应文本 - max_count: 最大提取数量 + result_text: Văn bản phản hồi LLM. + max_count: Số lượng trích xuất tối đa. Returns: - 提取到的数据列表 + Danh sách dữ liệu đã trích xuất. """ pass @abstractmethod def create_data_objects(self, data_list: list[dict]) -> list[TDataObject]: """ - 创建数据对象列表 + Tạo danh sách đối tượng dữ liệu. Args: - data_list: 原始数据列表 + data_list: Danh sách dữ liệu gốc. Returns: - 数据对象列表 + Danh sách đối tượng dữ liệu. """ pass @@ -126,7 +121,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): def get_schema_retry_max_attempts(self) -> int: """ - schema 解析失败后的最大重试次数(不含首轮请求)。 + Số lần retry tối đa sau khi parse schema thất bại, không gồm lần đầu. """ return 2 @@ -134,8 +129,9 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): self, base_temperature: float | None ) -> tuple[float, ...]: """ - schema 解析失败后的温度重试序列(不含首轮请求)。 - 采用动态降温,提高结构化稳定性。 + Chuỗi temperature retry sau khi parse schema thất bại. + + Giảm temperature động để tăng tính ổn định của output có cấu trúc. """ attempts = max(0, self.get_schema_retry_max_attempts()) if attempts == 0: @@ -161,7 +157,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): provider_id: str | None = None, ) -> float | None: """ - 尝试从当前将要调用的 Provider 配置中解析基础 temperature。 + Thử lấy temperature cơ sở từ cấu hình provider sắp gọi. """ pid = provider_id if not pid: @@ -209,8 +205,9 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): self, result_text: str ) -> tuple[bool, list[dict] | None, str | None]: """ - 解析结构化响应(默认 JSON 数组解析)。 - 子类可重写此方法定制对象解析逻辑。 + Parse phản hồi có cấu trúc, mặc định là mảng JSON. + + Lớp con có thể ghi đè để tuỳ chỉnh logic parse object. """ return parse_json_response(result_text, self.get_data_type()) @@ -222,7 +219,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): attempt_index: int, ) -> str: """ - 构建结构化失败后的修复重试提示词。 + Xây dựng prompt retry sửa output có cấu trúc. """ err_text = parse_error or "unknown_parse_error" return ( @@ -241,7 +238,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): self, result_text: str ) -> tuple[bool, list[dict] | None, str | None]: """ - 先尝试结构化 JSON 解析(含修复逻辑),失败后立即尝试正则降级。 + Thử parse JSON có cấu trúc trước, sau đó fallback regex nếu thất bại. """ success, parsed_data, error_msg = self.parse_structured_response(result_text) if success and parsed_data: @@ -259,7 +256,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): ) if validated_success and validated_data: logger.info( - f"{self.get_data_type()}结构化解析失败后,正则降级提取成功,获得 {len(validated_data)} 条数据" + f"Parse có cấu trúc {self.get_data_type()} thất bại; fallback regex lấy được {len(validated_data)} mục" ) return True, validated_data, None error_msg = validated_error or error_msg @@ -270,18 +267,19 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): self, data_list: list[dict] ) -> tuple[bool, list[dict] | None, str | None]: """ - 解析结果的本地二次校验(默认直接通过)。 - 子类可重写为 Pydantic 校验。 + Kiểm tra cục bộ lần hai cho kết quả parse, mặc định luôn hợp lệ. + + Lớp con có thể ghi đè bằng kiểm tra Pydantic. """ return True, data_list, None def _save_debug_data(self, prompt: str, session_id: str): """ - 保存调试数据到文件 + Lưu dữ liệu debug vào tệp. Args: - prompt: 提示词内容 - session_id: 会话ID + prompt: Nội dung prompt. + session_id: ID phiên. """ try: from astrbot.api.star import StarTools @@ -293,95 +291,102 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): file_name = f"{session_id}_{self.get_data_type()}.txt" file_path = data_path / file_name - logger.info(f"正在保存调试数据到: {file_path}") + logger.info(f"Đang lưu dữ liệu debug vào: {file_path}") with open(file_path, "w", encoding="utf-8") as f: f.write(prompt) - logger.info(f"已保存 {self.get_data_type()} 分析 Prompt 到 {file_path}") + logger.info( + f"Đã lưu prompt phân tích {self.get_data_type()} vào {file_path}" + ) except Exception as e: - logger.error(f"保存调试数据失败: {e}", exc_info=True) + logger.error(f"Lưu dữ liệu debug thất bại: {e}", exc_info=True) def _apply_persona_reinforcement( self, prompt: str, system_prompt: str | None ) -> str: """ - 核心的人格强化注入逻辑。采用首尾深度注入与指令交织策略。 - 不仅强化输出口吻,更强调使用人格的逻辑视角进行分析过程。 + Inject tăng cường persona ở đầu và cuối prompt. + + Không chỉ củng cố giọng điệu mà còn yêu cầu phân tích theo góc nhìn persona. """ if not system_prompt or not system_prompt.strip(): return prompt - logger.info(f"[{self.get_data_type()}分析] 已启用人格设定(深度强化模式)") + logger.info( + f"[Phân tích {self.get_data_type()}] Đã bật persona ở chế độ tăng cường sâu" + ) - # 构造更具强制性的标识符 + # Xây dựng marker có tính bắt buộc cao hơn. persona_content = system_prompt.strip() return ( "【SYSTEM_CORE_IDENTITY_FIXED】\n" - f"你现在的身份已由系统初始化为:\n{persona_content}\n\n" + f"Danh tính hiện tại của bạn đã được hệ thống khởi tạo là:\n{persona_content}\n\n" "--- MISSION_DIRECTIVE_START ---\n" - "⚠️ 核心任务警告:你接下来的所有分析行为必须基于上述【身份设定】进行。\n" - "这包括但不限于:你的思维切入点、对数据的敏感度、点评的犀利/温情程度、以及你对群聊氛围的感知逻辑。\n" - f"请以该人格的思维方式去处理以下‘{self.get_data_type()}’分析任务:\n\n" + "⚠️ Yêu cầu cốt lõi: mọi phân tích tiếp theo phải dựa trên 【persona】 ở trên.\n" + "Điều này bao gồm góc tiếp cận, độ nhạy với dữ liệu, mức sắc sảo/ấm áp khi nhận xét và cách cảm nhận không khí nhóm.\n" + f"Hãy xử lý tác vụ phân tích ‘{self.get_data_type()}’ sau theo tư duy của persona này:\n\n" f"{prompt}\n" "--- MISSION_DIRECTIVE_END ---\n\n" "【FINAL_IDENTITY_REINFORCEMENT】\n" - f"1. 你不再是通用的 AI 助手,你是上述设定中的角色,我将在此处再次提醒你的身份:\n{persona_content}\n 正在观察并点评这些群聊数据。\n" - f"2. 请务必使用该角色的第一人称视角 or 其独有的观察视角进行‘{self.get_data_type()}’输出。\n" - "3. 你的分析成果必须体现该角色的性格色彩,禁止输出中立、客套、公式化的 AI 话术。\n" - "4. ⚠️ 格式铁律:无论人格多么狂放,最终输出的内容必须严格遵守‘ MISSION_DIRECTIVE ’中所要求的纯 JSON 格式。除了 JSON 数据外,严禁输出任何 Markdown 标记或角色扮演的额外闲聊。" + f"1. Bạn không còn là trợ lý AI chung chung mà là nhân vật đã thiết lập ở trên. Đây là lời nhắc lại persona:\n{persona_content}\n Persona này đang quan sát và nhận xét dữ liệu trò chuyện nhóm.\n" + f"2. Hãy xuất ‘{self.get_data_type()}’ theo ngôi thứ nhất hoặc góc quan sát riêng của nhân vật.\n" + "3. Kết quả phải thể hiện cá tính của nhân vật; không dùng lời lẽ AI trung lập, khách sáo hoặc rập khuôn.\n" + "4. ⚠️ Quy tắc định dạng: dù persona có phóng khoáng đến đâu, output cuối phải tuân thủ nghiêm ngặt JSON thuần được yêu cầu trong MISSION_DIRECTIVE. Ngoài JSON, không xuất Markdown hoặc trò chuyện nhập vai bổ sung." ) async def analyze( self, data: TInputData, umo: str | None = None, session_id: str | None = None ) -> tuple[list[TDataObject], TokenUsage]: """ - 统一的分析流程 + Quy trình phân tích thống nhất. Args: - data: 输入数据 - umo: 模型唯一标识符 - session_id: 会话ID (用于调试模式) + data: Dữ liệu đầu vào. + umo: Định danh model duy nhất. + session_id: ID phiên dùng cho debug mode. Returns: - (分析结果列表, Token使用统计) + Tuple danh sách kết quả và thống kê token. """ try: - # 1. 构建提示词 + # 1. Xây dựng prompt. logger.debug( - f"{self.get_data_type()}分析开始构建prompt,输入数据类型: {type(data)}" + f"Bắt đầu xây dựng prompt {self.get_data_type()}, kiểu dữ liệu đầu vào: {type(data)}" ) data_length = len(data) if isinstance(data, Sized) else "N/A" - logger.debug(f"{self.get_data_type()}分析输入数据长度: {data_length}") + logger.debug( + f"Độ dài dữ liệu đầu vào {self.get_data_type()}: {data_length}" + ) prompt = self.build_prompt(data) - logger.info(f"开始{self.get_data_type()}分析,构建提示词完成") + logger.info(f"Bắt đầu phân tích {self.get_data_type()}, đã xây dựng prompt") logger.debug( - f"{self.get_data_type()}分析prompt长度: {len(prompt) if prompt else 0}" + f"Độ dài prompt {self.get_data_type()}: {len(prompt) if prompt else 0}" ) logger.debug( - f"{self.get_data_type()}分析prompt前100字符: {prompt[:100] if prompt else 'None'}..." + f"100 ký tự đầu prompt {self.get_data_type()}: {prompt[:100] if prompt else 'None'}..." ) - # 保存调试数据 + # Lưu dữ liệu debug. debug_mode = self.config_manager.get_debug_mode() if debug_mode and session_id and prompt: self._save_debug_data(prompt, session_id) elif debug_mode and not session_id: logger.warning("[Debug] Debug mode enabled but no session_id provided") - # 检查 prompt 是否为空 + # Kiểm tra prompt rỗng. if not prompt or not prompt.strip(): logger.warning( - f"{self.get_data_type()}分析: prompt 为空或只包含空白字符,跳过LLM调用" + f"Phân tích {self.get_data_type()}: prompt rỗng hoặc chỉ có khoảng trắng, bỏ qua lời gọi LLM" ) return [], TokenUsage() - # 2. 调用LLM(使用配置的 provider) + # 2. Gọi LLM bằng provider đã cấu hình. provider_id_key = self.get_provider_id_key() - # 只 resolve 一次 provider ID,同时传递给温度解析和 LLM 调用,避免重复日志 + # Chỉ resolve provider ID một lần để tránh log lặp. resolved_provider_id = None if provider_id_key: resolved_provider_id = await get_provider_id_with_fallback( @@ -392,15 +397,17 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): provider_id_key, umo, provider_id=resolved_provider_id ) - # 获取人格设定 + # Lấy cấu hình persona. system_prompt = await self._build_system_prompt(umo) - # 应用人格强化注入 + # Inject tăng cường persona. prompt = self._apply_persona_reinforcement(prompt, system_prompt) - logger.info(f"[{self.get_data_type()}分析] 开始发起 LLM 请求, umo: {umo}") + logger.info( + f"[Phân tích {self.get_data_type()}] Bắt đầu yêu cầu LLM, umo: {umo}" + ) - # [Debug] 记录调试信息 + # Ghi thông tin debug. if debug_mode: logger.debug( f"[Debug] debug_mode={debug_mode}, umo={umo}, session_id={session_id}, prompt_len={len(prompt) if prompt else 0}" @@ -419,11 +426,11 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): if response is None: logger.error( - f"{self.get_data_type()}分析调用LLM失败: provider返回None(重试失败)" + f"Phân tích {self.get_data_type()} gọi LLM thất bại: provider trả về None sau retry" ) return [], TokenUsage() - # 3. 提取token使用统计 + # 3. Trích xuất thống kê token. token_usage_dict = extract_token_usage(response) token_usage = TokenUsage( prompt_tokens=token_usage_dict["prompt_tokens"], @@ -431,14 +438,14 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): total_tokens=token_usage_dict["total_tokens"], ) - # 4. 提取响应文本 + # 4. Trích xuất văn bản phản hồi. result_text = extract_response_text(response) - logger.debug(f"{self.get_data_type()}分析原始响应: {result_text[:500]}...") + logger.debug(f"Phản hồi gốc {self.get_data_type()}: {result_text[:500]}...") - # 5. 尝试结构化解析 + 正则降级解析 + # 5. Thử parse có cấu trúc rồi fallback regex. success, parsed_data, error_msg = self._try_parse_with_fallback(result_text) - # 5.1 仅在两种解析方式都失败时,进入 schema 修复重试(温度递减) + # 5.1 Chỉ retry sửa schema với temperature giảm khi cả hai cách thất bại. if not success and self.get_response_format() is not None: temperatures = self.get_schema_retry_temperatures(base_temperature) for idx, temperature in enumerate(temperatures, start=1): @@ -449,7 +456,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): attempt_index=idx, ) logger.warning( - f"{self.get_data_type()}结构化解析失败,触发 schema 修复重试 " + f"Parse có cấu trúc {self.get_data_type()} thất bại, retry sửa schema " f"(attempt={idx}, temperature={temperature:.1f})" ) retry_response = await call_provider_with_retry( @@ -481,50 +488,49 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): error_msg = retry_error_msg if success and parsed_data: - # JSON解析成功,创建数据对象 + # Parse JSON thành công, tạo object dữ liệu. data_objects = self.create_data_objects(parsed_data) logger.info( - f"{self.get_data_type()}分析成功,解析到 {len(data_objects)} 条数据" + f"Phân tích {self.get_data_type()} thành công, parse được {len(data_objects)} mục" ) return data_objects, token_usage - # 6. 全部尝试失败 + # 6. Mọi lần thử đều thất bại. logger.error( - f"{self.get_data_type()}分析失败: JSON解析与正则降级均未成功: {error_msg}" + f"Phân tích {self.get_data_type()} thất bại: cả parse JSON và fallback regex đều lỗi: {error_msg}" ) return [], token_usage except Exception as e: - logger.error(f"{self.get_data_type()}分析失败: {e}", exc_info=True) + logger.error( + f"Phân tích {self.get_data_type()} thất bại: {e}", exc_info=True + ) return [], TokenUsage() async def _build_system_prompt(self, umo: str | None) -> str | None: """ - 构建带有会话人格的系统提示词,优先级如下: - 1. 插件指定的全局人格 (若核心开关开启) - 2. 会话/对话选定的人格 (若开启了继承开关) - 3. 当前 UMO 的默认人格 (若开启了继承开关) + Xây dựng system prompt kèm persona của phiên theo thứ tự ưu tiên: + persona toàn cục của plugin, persona phiên/hội thoại, rồi persona mặc định UMO. Args: - umo: 用户模型对象标识,用于定位会话上下文 + umo: Định danh dùng để xác định context phiên. Returns: - 最终生成的 System Prompt 字符串,若无则返回 None + System prompt cuối hoặc None nếu không có. """ - # 获取配置 + # Lấy cấu hình. use_specific = self.config_manager.get_use_plugin_specific_persona() specific_id = self.config_manager.get_plugin_specific_persona_id() keep_original = self.config_manager.get_keep_original_persona() - # 获取 AstrBot 核心的人格管理器 + # Lấy persona manager cốt lõi của AstrBot. persona_mgr = getattr(self.context, "persona_manager", None) if persona_mgr is None: return None persona_prompt = None - # --- 优先级 1: 插件指定的全局固定人格 --- - # 适用于希望所有分析报告都呈现同一种风格的情况 + # Ưu tiên 1: persona toàn cục cố định do plugin chỉ định. if use_specific and specific_id: try: persona_obj = await persona_mgr.get_persona(specific_id) @@ -534,15 +540,18 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): else None ) if persona_prompt: - logger.debug(f"已应用插件指定的全局强制人格设定: {specific_id}") + logger.debug( + f"Đã áp dụng persona toàn cục bắt buộc của plugin: {specific_id}" + ) except Exception as e: - logger.warning(f"获取插件指定人格失败 (ID: {specific_id}): {e}") + logger.warning( + f"Lấy persona do plugin chỉ định thất bại (ID: {specific_id}): {e}" + ) - # --- 优先级 2: 继承当前会话/群聊的原始人格 --- - # 只有在未开启“强制人格”且开启了“继承设定”时生效 + # Ưu tiên 2: kế thừa persona gốc của phiên/nhóm hiện tại. if not persona_prompt and keep_original and umo: try: - # 2.1 尝试获取 SharedPreferences 中会话绑定的 Persona ID (通常是 /persona 命令设置的) + # 2.1 Thử lấy Persona ID gắn với phiên trong SharedPreferences. from astrbot.api import sp session_service_config = await sp.get_async( @@ -565,9 +574,11 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): else None ) if persona_prompt: - logger.debug(f"继承到会话选定人格: {persona_id}") + logger.debug( + f"Đã kế thừa persona được chọn cho phiên: {persona_id}" + ) - # 2.2 若无会话绑定,尝试获取当前对话(Dialogue)级别的人格 + # 2.2 Nếu phiên chưa gắn persona, thử persona cấp hội thoại. if not persona_prompt: conv_mgr = getattr(self.context, "conversation_manager", None) if conv_mgr: @@ -591,10 +602,10 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): ) if persona_prompt: logger.debug( - f"继承到对话(Dialogue)设定人格: {conv_obj.persona_id}" + f"Đã kế thừa persona của hội thoại: {conv_obj.persona_id}" ) - # 2.3 若仍无结果,尝试获取 UMO 设定的默认人格 + # 2.3 Nếu vẫn chưa có, thử persona mặc định của UMO. if not persona_prompt: personality = await persona_mgr.get_default_persona_v3(umo) if isinstance(personality, dict): @@ -602,12 +613,14 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): else: persona_prompt = getattr(personality, "prompt", None) if persona_prompt: - logger.debug("继承到 UMO 默认人格设定") + logger.debug("Đã kế thừa persona mặc định của UMO") except Exception as e: - logger.warning(f"分析人格回溯识别失败 (umo: {umo}): {e}") + logger.warning( + f"Truy ngược persona phân tích thất bại (umo: {umo}): {e}" + ) - # 检查生成结果 + # Kiểm tra kết quả. if not isinstance(persona_prompt, str) or not persona_prompt.strip(): return None diff --git a/src/infrastructure/analysis/analyzers/chat_quality_analyzer.py b/src/infrastructure/analysis/analyzers/chat_quality_analyzer.py index 4913dc1..ec04355 100644 --- a/src/infrastructure/analysis/analyzers/chat_quality_analyzer.py +++ b/src/infrastructure/analysis/analyzers/chat_quality_analyzer.py @@ -1,7 +1,4 @@ -""" -聊天质量分析模块 -专门处理群聊质量锐评分析 -""" +"""Module phân tích và đánh giá đa chiều chất lượng trò chuyện nhóm.""" from datetime import datetime @@ -22,24 +19,22 @@ from .base_analyzer import BaseAnalyzer class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]): """ - 聊天质量分析器 - 专门处理群聊质量的锐评和多维度分析 + Analyzer chất lượng trò chuyện và đánh giá đa chiều. - 注意:由于聊天质量分析返回的是 JSON 对象而非数组, - 此分析器重写了 analyze() 方法,使用 parse_json_object_response 解析, - 并以 extract_quality_with_regex 作为正则降级方案。 + Vì kết quả là object JSON thay vì mảng, analyzer ghi đè ``analyze()``, dùng + ``parse_json_object_response`` và fallback bằng ``extract_quality_with_regex``. """ def get_provider_id_key(self) -> str: - """获取 Provider ID 配置键名""" + """Lấy tên key cấu hình Provider ID.""" return "quality_provider_id" def get_data_type(self) -> str: - """获取数据类型标识""" - return "聊天质量" + """Lấy định danh loại dữ liệu.""" + return "Chất lượng trò chuyện" def get_max_count(self) -> int: - """获取最大维度数量""" + """Lấy số chiều tối đa.""" return 8 def get_response_schema_name(self) -> str: @@ -50,12 +45,12 @@ class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]): def build_prompt(self, data: list[dict]) -> str: """ - 构建聊天质量分析提示词 + Xây dựng prompt phân tích chất lượng trò chuyện. """ if not data: return "" - # 提取文本消息 + # Trích xuất tin nhắn văn bản. text_messages = [] for msg in data: if not isinstance(msg, dict): @@ -89,39 +84,39 @@ class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]): if prompt_template: return render_template(prompt_template, messages_text=messages_text) - prompt_template = """请分析以下群聊记录,输出一份"聊天质量锐评"。 + prompt_template = """Hãy phân tích lịch sử trò chuyện nhóm sau và đưa ra một bản "đánh giá chất lượng trò chuyện". -## 任务目标: -1. **维度划分**:将聊天内容划分为 3-6 个【高层级、抽象、泛化】的维度(例如:就业焦虑、生涯规划、技术方案研究、情感树洞、无意义水群等)。 -2. **严禁在维度名称(name)中出现任何具体的群聊人物名、项目名、具体的报错内容或细碎的事件点。标题必须保持高度抽象且字数简练(2-6个字)。** -3. 为每个维度计算一个大致的百分比占位(总和小于等于 100%)。 -4. **点评内容**:为每个维度写一句犀利、幽默、毒舌或温情的点评。具体的吐槽内容、具体的细节事件描述请放在这里。 -5. **全群表现**:给出一句总结性的评价,作为总结标题对应的“金句”。 -6. **主题设定**:设定一个本次报告的主题标题和副标题。 + ## Mục tiêu: + 1. **Phân chia chiều**: chia nội dung thành 3-6 chiều 【cấp cao, trừu tượng, khái quát】, ví dụ: lo âu nghề nghiệp, hoạch định tương lai, nghiên cứu giải pháp kỹ thuật, chia sẻ cảm xúc hoặc trò chuyện lan man. + 2. **Tên chiều (name) tuyệt đối không chứa tên thành viên, dự án, lỗi cụ thể hay sự kiện vụn vặt. Tiêu đề phải trừu tượng và ngắn gọn (2-6 từ).** + 3. Ước tính tỷ lệ phần trăm cho từng chiều, tổng không vượt quá 100%. + 4. **Nội dung nhận xét**: viết một câu sắc sảo, hài hước, châm biếm hoặc ấm áp cho từng chiều; đặt chi tiết cụ thể ở đây. + 5. **Biểu hiện toàn nhóm**: đưa ra một câu tổng kết nổi bật. + 6. **Thiết lập chủ đề**: đặt tiêu đề và phụ đề cho báo cáo. -## 点评风格指南: -- 语言要接地气,多用互联网黑话。吐槽要精准,避重就轻。 -- **只有维度名称(name)需要抽象,点评(comment)和总结(summary)可以非常具体和生动。** + ## Hướng dẫn phong cách: + - Ngôn ngữ gần gũi, tự nhiên, có thể dùng tiếng lóng Internet; nhận xét phải chính xác. + - **Chỉ tên chiều (name) cần trừu tượng; comment và summary có thể cụ thể, sinh động.** -## 返回格式要求: -必须以纯 JSON 格式返回,不得包含任何 Markdown 格式。 + ## Yêu cầu định dạng: + Chỉ trả về JSON thuần, không chứa Markdown. ```json {{ - "title": "今日群聊主题", - "subtitle": "副标题", + "title": "Chủ đề trò chuyện hôm nay", + "subtitle": "Phụ đề", "dimensions": [ {{ - "name": "抽象维度名", - "percentage": 比例, - "comment": "维度的毒舌点评" + "name": "Tên chiều trừu tượng", + "percentage": 25, + "comment": "Nhận xét sắc sảo về chiều này" }} ], - "summary": "一句总结性的金句" + "summary": "Một câu tổng kết nổi bật" }} ``` -群聊记录: +Lịch sử trò chuyện nhóm: ${messages_text} """ @@ -129,32 +124,30 @@ ${messages_text} def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: """ - 使用正则表达式提取质量分析数据(BaseAnalyzer 要求的接口) + Trích xuất dữ liệu chất lượng bằng regex theo giao diện BaseAnalyzer. - 注意: 此方法供 BaseAnalyzer.analyze() 的降级流程使用, - 但由于聊天质量分析重写了 analyze(),实际由 analyze_quality() 中调用 - extract_quality_with_regex 实现。 + Analyzer này ghi đè ``analyze()`` nên thực tế fallback được gọi từ + ``analyze_quality()`` qua ``extract_quality_with_regex``. """ return [] def create_data_objects(self, data_list: list[dict]) -> list[QualityReview]: """ - 满足 BaseAnalyzer 抽象要求。 - 聊天质量分析的数据对象创建在 analyze_quality 中完成。 + Đáp ứng giao diện trừu tượng BaseAnalyzer; object được tạo trong analyze_quality. """ return [] def _build_review_from_dict(self, data: dict) -> QualityReview: """ - 从解析后的字典构建 QualityReview 对象 + Xây dựng object QualityReview từ dict đã parse. Args: - data: 解析后的 JSON 对象字典 + data: Dict object JSON đã parse. Returns: - QualityReview 数据对象 + Object QualityReview. """ - # 控制维度占比总和不超过100% + # Đảm bảo tổng tỷ lệ các chiều không vượt 100%. total_percentage = sum( max(0.0, min(100.0, float(d.get("percentage", 0)))) for d in data.get("dimensions", []) @@ -172,13 +165,13 @@ ${messages_text} dimensions.append( QualityDimension( - name=d.get("name", "未知"), + name=d.get("name", "Không xác định"), percentage=final_p, comment=d.get("comment", ""), ) ) - # 自动分配颜色 + # Tự phân bổ màu. colors = [ "#607d8b", "#2196f3", @@ -193,10 +186,10 @@ ${messages_text} d.color = colors[i % len(colors)] return QualityReview( - title=data.get("title", "聊天质量锐评"), - subtitle=data.get("subtitle", "今天的群里发生了什么?"), + title=data.get("title", "Đánh giá chất lượng trò chuyện"), + subtitle=data.get("subtitle", "Hôm nay nhóm đã có chuyện gì?"), dimensions=dimensions, - summary=data.get("summary", "今天也是充满活力的一天。"), + summary=data.get("summary", "Hôm nay cũng là một ngày đầy năng lượng."), ) def _validate_review_payload( @@ -228,7 +221,7 @@ ${messages_text} attempt_index=idx, ) logger.warning( - f"聊天质量结构化解析失败,触发 schema 修复重试 " + f"Parse có cấu trúc chất lượng trò chuyện thất bại, retry sửa schema " f"(attempt={idx}, temperature={temperature:.1f})" ) retry_response = await call_provider_with_retry( @@ -271,7 +264,7 @@ ${messages_text} session_id: str | None = None, ) -> tuple[QualityReview | None, TokenUsage]: """ - 汇总多个增量批次的质量报告,生成最终的每日全天总评。 + Tổng hợp báo cáo chất lượng từ nhiều batch gia tăng thành đánh giá cả ngày. """ if not batch_reviews: return None, TokenUsage() @@ -280,10 +273,10 @@ ${messages_text} return self._build_review_from_dict(batch_reviews[0]), TokenUsage() try: - # 构建汇总用的提示词 + # Xây dựng prompt tổng hợp. reviews_text = "" for i, rev in enumerate(batch_reviews): - title = rev.get("title", "未命名") + title = rev.get("title", "Chưa đặt tên") summary = rev.get("summary", "") dims = ", ".join( [ @@ -291,53 +284,53 @@ ${messages_text} for d in rev.get("dimensions", []) ] ) - reviews_text += f"\n批次 {i + 1} [{title}]:\n- 维度表现: {dims}\n- 核心摘要: {summary}\n" + reviews_text += f"\nBatch {i + 1} [{title}]:\n- Biểu hiện theo chiều: {dims}\n- Tóm tắt cốt lõi: {summary}\n" - # 获取配置中的汇总提示词模板,如果没有则使用默认模板 + # Dùng prompt tổng hợp trong cấu hình hoặc template mặc định. prompt_template = ( self.config_manager.get_quality_summary_prompt() - or """你现在有一份今天全天分散时间段的多个“增量批次点评笔记”。 -你的任务是将这些分散的笔记汇总成一份最终的“全天聊天质量终极锐评”。 + or """Bạn có nhiều ghi chú đánh giá theo batch gia tăng ở các khoảng thời gian trong ngày. +Nhiệm vụ là tổng hợp chúng thành một bản đánh giá chất lượng trò chuyện cuối cùng cho cả ngày. -## 任务目标: -1. **全局抽象维度**:根据各批次的维度表现,平衡权重,提取出 3-6 个覆盖全天的【核心、上层抽象】课题维度(如:职场/行业风向、技术架构演进、社畜心理博弈等)。 -2. **严禁在维度名称(name)中出现具体的批次细节。标题必须代表全天的某种趋势。** -3. **百分比融合**:根据全天笔记的频率和强度,给出一个代表全天整体分布的比例(总和不超过100%)。 -4. **终极点评**:为每个汇总维度写出一句升华后的全天总结性点评。可以融合具体批次中的有趣槽点。 -5. **终极总结**:拟定全天的大型主题标题、副标题,并给出一句霸气的全天表现总结。 +## Mục tiêu: +1. **Chiều trừu tượng toàn cục**: cân bằng trọng số giữa các batch và rút ra 3-6 chiều cốt lõi cấp cao bao phủ cả ngày, như xu hướng nghề nghiệp/ngành, phát triển kiến trúc kỹ thuật hoặc tâm lý nơi làm việc. +2. **Tên chiều (name) không được chứa chi tiết batch; tiêu đề phải đại diện cho xu hướng cả ngày.** +3. **Hợp nhất phần trăm**: dựa trên tần suất và cường độ để đưa ra phân bố cả ngày, tổng không vượt 100%. +4. **Nhận xét cuối**: viết nhận xét tổng kết nâng cao cho từng chiều, có thể kết hợp chi tiết thú vị từ các batch. +5. **Tổng kết cuối**: đặt tiêu đề, phụ đề và một câu kết mạnh mẽ cho cả ngày. -## 风格要求: -- 只有维度名称(name)需要高度概括抽象。 -- 点评(comment)和总结(summary)请尽量生动、具体,要把一整天的梗串联起来。 +## Yêu cầu phong cách: +- Chỉ tên chiều (name) cần khái quát và trừu tượng cao. +- Comment và summary phải sinh động, cụ thể và kết nối các điểm đáng nhớ trong ngày. -## 返回格式要求: -必须以纯 JSON 格式返回,不得包含任何 Markdown 格式。 +## Yêu cầu định dạng: +Chỉ trả về JSON thuần, không chứa Markdown. ```json {{ - "title": "今日群聊主题", - "subtitle": "副标题", + "title": "Chủ đề trò chuyện hôm nay", + "subtitle": "Phụ đề", "dimensions": [ {{ - "name": "抽象大类标题", - "percentage": 比例, - "comment": "维度的全天锐评" + "name": "Tên chiều khái quát", + "percentage": 25, + "comment": "Nhận xét cả ngày cho chiều này" }} ], - "summary": "全天总结金句" + "summary": "Câu tổng kết nổi bật cho cả ngày" }} ``` """ ) prompt = render_template(prompt_template, reviews_text=reviews_text) - # 调用 LLM 进行汇总 + # Gọi LLM để tổng hợp. system_prompt = await self._build_system_prompt(umo) base_temperature = await self._resolve_provider_temperature( self.get_provider_id_key(), umo ) - # 应用人设强化注入 + # Inject tăng cường persona. prompt = self._apply_persona_reinforcement(prompt, system_prompt) response = await call_provider_with_retry( @@ -365,7 +358,7 @@ ${messages_text} return None, usage success, parsed_data, error_msg = parse_json_object_response( - result_text, "汇总质量分析" + result_text, "tổng hợp chất lượng" ) if success and parsed_data: @@ -375,7 +368,7 @@ ${messages_text} if valid and normalized: review = self._build_review_from_dict(normalized) logger.info( - f"聊天质量汇总分析成功,解析到 {len(review.dimensions)} 个汇总维度" + f"Tổng hợp chất lượng trò chuyện thành công, parse được {len(review.dimensions)} chiều" ) return review, usage error_msg = validation_error or error_msg @@ -391,16 +384,18 @@ ${messages_text} if repaired_data: review = self._build_review_from_dict(repaired_data) logger.info( - f"聊天质量汇总 schema 修复重试成功,解析到 {len(review.dimensions)} 个汇总维度" + f"Retry sửa schema tổng hợp chất lượng thành công, parse được {len(review.dimensions)} chiều" ) return review, usage - # 降级:如果汇总失败,返回最新的一个 - logger.warning(f"聊天质量汇总分析失败,降级使用最新批次: {error_msg}") + # Fallback về batch mới nhất nếu tổng hợp thất bại. + logger.warning( + f"Tổng hợp chất lượng thất bại, dùng batch mới nhất: {error_msg}" + ) return self._build_review_from_dict(batch_reviews[-1]), usage except Exception as e: - logger.error(f"聊天质量汇总分析异常: {e}", exc_info=True) + logger.error(f"Lỗi tổng hợp chất lượng trò chuyện: {e}", exc_info=True) return self._build_review_from_dict(batch_reviews[-1]), TokenUsage() async def analyze_quality( @@ -410,31 +405,26 @@ ${messages_text} session_id: str | None = None, ) -> tuple[QualityReview | None, TokenUsage]: """ - 分析聊天质量 + Phân tích chất lượng trò chuyện. - 流程遵循 BaseAnalyzer 的设计模式: - 1. 构建 prompt - 2. 调用 LLM - 3. 提取 token 使用统计 - 4. JSON 解析(使用 parse_json_object_response) - 5. 正则降级(使用 extract_quality_with_regex) + Theo mẫu BaseAnalyzer: xây prompt, gọi LLM, lấy token, parse JSON và fallback regex. """ try: - # 1. 获取人格设定 + # 1. Lấy persona. system_prompt = await self._build_system_prompt(umo) base_temperature = await self._resolve_provider_temperature( self.get_provider_id_key(), umo ) - # 2. 构建 prompt + # 2. Xây dựng prompt. prompt = self.build_prompt(messages) if not prompt: return None, TokenUsage() - # 应用人设强化注入 + # Inject tăng cường persona. prompt = self._apply_persona_reinforcement(prompt, system_prompt) - # 3. 调用 LLM + # 3. Gọi LLM. response = await call_provider_with_retry( self.context, self.config_manager, @@ -448,7 +438,7 @@ ${messages_text} if response is None: return None, TokenUsage() - # 4. 提取 token 使用统计 + # 4. Trích xuất thống kê token. token_usage_dict = extract_token_usage(response) usage = TokenUsage( prompt_tokens=token_usage_dict["prompt_tokens"], @@ -456,12 +446,12 @@ ${messages_text} total_tokens=token_usage_dict["total_tokens"], ) - # 5. 提取响应文本 + # 5. Trích xuất văn bản phản hồi. result_text = extract_response_text(response) if not result_text: return None, usage - # 6. JSON 解析(使用 parse_json_object_response) + # 6. Parse JSON bằng parse_json_object_response. success, parsed_data, error_msg = parse_json_object_response( result_text, self.get_data_type() ) @@ -473,7 +463,7 @@ ${messages_text} if valid and normalized: review = self._build_review_from_dict(normalized) logger.debug( - f"聊天质量分析成功,解析到 {len(review.dimensions)} 个维度" + f"Phân tích chất lượng thành công, parse được {len(review.dimensions)} chiều" ) return review, usage error_msg = validation_error or error_msg @@ -486,7 +476,7 @@ ${messages_text} if valid and normalized: review = self._build_review_from_dict(normalized) logger.debug( - f"聊天质量首轮结构化失败后,正则提取成功,获得 {len(review.dimensions)} 个维度" + f"Lần parse có cấu trúc đầu thất bại; regex lấy được {len(review.dimensions)} chiều" ) return review, usage error_msg = validation_error or error_msg @@ -502,16 +492,18 @@ ${messages_text} if repaired_data: review = self._build_review_from_dict(repaired_data) logger.debug( - f"聊天质量 schema 修复重试成功,解析到 {len(review.dimensions)} 个维度" + f"Retry sửa schema chất lượng thành công, parse được {len(review.dimensions)} chiều" ) return review, usage - # 7. 全部失败 - logger.error(f"聊天质量分析失败: JSON解析和正则提取均未成功: {error_msg}") + # 7. Mọi cách đều thất bại. + logger.error( + f"Phân tích chất lượng thất bại: cả parse JSON và regex đều lỗi: {error_msg}" + ) return None, usage except Exception as e: - logger.error(f"聊天质量分析失败: {e}", exc_info=True) + logger.error(f"Phân tích chất lượng thất bại: {e}", exc_info=True) return None, TokenUsage() # Override analyze to bridge the base class interface diff --git a/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py b/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py index 2ce27e0..a20cc24 100644 --- a/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py +++ b/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py @@ -1,7 +1,4 @@ -""" -金句分析模块 -专门处理群聊金句提取和分析 -""" +"""Module trích xuất và phân tích trích dẫn nổi bật trong nhóm.""" from datetime import datetime @@ -16,21 +13,18 @@ from .base_analyzer import BaseAnalyzer class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): - """ - 金句分析器 - 专门处理群聊金句的提取和分析 - """ + """Analyzer trích xuất và phân tích trích dẫn nổi bật.""" def get_provider_id_key(self) -> str: - """获取 Provider ID 配置键名""" + """Lấy tên key cấu hình Provider ID.""" return "golden_quote_provider_id" def get_data_type(self) -> str: - """获取数据类型标识""" + """Lấy định danh loại dữ liệu.""" return "Trích dẫn nổi bật" def get_max_count(self) -> int: - """获取最大金句数量,增量模式下使用覆盖值""" + """Lấy số trích dẫn tối đa, dùng giá trị ghi đè trong chế độ gia tăng.""" if self._incremental_max_count is not None: return self._incremental_max_count return self.config_manager.get_max_golden_quotes() @@ -43,25 +37,25 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): def build_prompt(self, data: list[dict]) -> str: """ - 构建金句分析提示词 + Xây dựng prompt phân tích trích dẫn. Args: - messages: 群聊的文本消息列表 + messages: Danh sách tin nhắn văn bản của nhóm. Returns: - 提示词字符串 + Chuỗi prompt. """ if not data: return "" - # 构建消息文本 (用 [user_id] 替代 nickname 以确保回填 100% 准确,避免 Emoji 等干扰) + # Dùng [user_id] thay nickname để khôi phục chính xác và tránh nhiễu emoji. messages_text = "\n".join( [f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}" for msg in data] ) max_golden_quotes = self.get_max_count() - # 从配置读取 prompt 模板(默认使用 "default" 风格) + # Đọc template prompt từ cấu hình, mặc định kiểu ``default``. prompt_template = self.config_manager.get_golden_quote_analysis_prompt() if prompt_template: @@ -71,50 +65,52 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): max_golden_quotes=max_golden_quotes, messages_text=messages_text, ) - logger.info("使用配置中的金句分析提示词") + logger.info("Đang dùng prompt phân tích trích dẫn trong cấu hình") return prompt except Exception as e: - logger.warning(f"应用金句分析提示词失败: {e}") + logger.warning(f"Áp dụng prompt phân tích trích dẫn thất bại: {e}") - logger.warning("未找到有效的金句分析提示词配置,请检查配置文件") + logger.warning("Không tìm thấy cấu hình prompt phân tích trích dẫn hợp lệ") return "" def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: """ - 使用正则表达式提取金句信息 + Trích xuất trích dẫn bằng regex. Args: - result_text: LLM响应文本 - max_count: 最大提取数量 + result_text: Văn bản phản hồi LLM. + max_count: Số lượng tối đa. Returns: - 金句数据列表 + Danh sách dữ liệu trích dẫn. """ return extract_golden_quotes_with_regex(result_text, max_count) def create_data_objects(self, data_list: list[dict]) -> list[GoldenQuote]: """ - 创建金句对象列表 + Tạo danh sách object trích dẫn. Args: - quotes_data: 原始金句数据列表 + quotes_data: Danh sách dữ liệu trích dẫn gốc. Returns: - GoldenQuote对象列表 + Danh sách object GoldenQuote. """ try: quotes = [] max_quotes = self.get_max_count() for quote_data in data_list[:max_quotes]: - # 确保数据格式正确 + # Đảm bảo định dạng dữ liệu đúng. content = quote_data.get("content", "").strip() sender = quote_data.get("sender", "").strip() reason = quote_data.get("reason", "").strip() - # 验证必要字段 + # Xác thực trường bắt buộc. if not content or not sender or not reason: - logger.warning(f"金句数据格式不完整,跳过: {quote_data}") + logger.warning( + f"Dữ liệu trích dẫn không đầy đủ, bỏ qua: {quote_data}" + ) continue quotes.append( @@ -124,7 +120,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): return quotes except Exception as e: - logger.error(f"创建金句对象失败: {e}") + logger.error(f"Tạo object trích dẫn thất bại: {e}") return [] def validate_parsed_data( @@ -139,38 +135,41 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): session_id: str | None = None, ) -> tuple[list[GoldenQuote], TokenUsage]: """ - 分析群聊金句 + Phân tích trích dẫn nổi bật trong nhóm. Args: - messages: 群聊消息列表 - umo: 模型唯一标识符 - session_id: 会话ID (用于调试模式) + messages: Danh sách tin nhắn nhóm. + umo: Định danh model. + session_id: ID phiên dùng cho debug mode. Returns: - (金句列表, Token使用统计) + Tuple danh sách trích dẫn và thống kê token. """ try: - # 提取圣经的文本消息 + # Trích xuất tin nhắn văn bản đáng chú ý. interesting_messages = self.extract_interesting_messages(messages) if not interesting_messages: - logger.info("没有符合条件的圣经消息,返回空结果") + logger.info( + "Không có tin nhắn phù hợp để trích dẫn, trả về kết quả rỗng" + ) return [], TokenUsage() - logger.info(f"开始从 {len(interesting_messages)} 条圣经消息中提取金句") + logger.info( + f"Bắt đầu trích xuất trích dẫn từ {len(interesting_messages)} tin nhắn đáng chú ý" + ) quotes, usage = await self.analyze(interesting_messages, umo, session_id) - # 建立 ID 到昵称的映射表用于恢复显示 + # Lập ánh xạ ID sang biệt danh để khôi phục hiển thị. id_to_nickname = {} for msg in interesting_messages: uid = str(msg.get("user_id", "")) if uid: id_to_nickname[uid] = msg.get("sender", "") - # 回填 User ID 并恢复发送者昵称 + # Điền User ID và khôi phục biệt danh người gửi. for quote in quotes: - # 此时 quote.sender 包含的是 Prompt 中的 [user_id] - # 有些 LLM 可能会带上中括号,尝试清理 + # quote.sender hiện chứa [user_id] trong prompt; loại ngoặc nếu có. potential_id = quote.sender.strip().strip("[]") if potential_id in id_to_nickname: @@ -178,29 +177,29 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): quote.sender = id_to_nickname[potential_id] else: logger.warning( - f"[金句分析] 无法匹配 User ID: {potential_id},金句将无法显示真实头像。" + f"[Phân tích trích dẫn] Không khớp User ID: {potential_id}; không thể hiển thị avatar thật" ) return quotes, usage except Exception as e: - logger.error(f"金句分析失败: {e}") + logger.error(f"Phân tích trích dẫn thất bại: {e}") return [], TokenUsage() def extract_interesting_messages(self, messages: list[dict]) -> list[dict]: """ - 根据清理后的消息提取可能有意义的消息片段用于金句分析。 + Trích xuất đoạn tin nhắn có ý nghĩa từ dữ liệu đã làm sạch. Args: - messages: 已由 MessageCleaner 处理过的 legacy 消息列表 + messages: Danh sách tin nhắn legacy đã qua MessageCleaner. Returns: - 提取的文本消息列表 + Danh sách tin nhắn văn bản đã trích xuất. """ interesting_messages = [] for msg in messages: - # 获取发送者显示名 + # Lấy tên hiển thị của người gửi. sender = msg.get("sender", {}) nickname = InfoUtils.get_user_nickname(self.config_manager, sender) msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") @@ -208,7 +207,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): for content in msg.get("message", []): if content.get("type") == "text": text = content.get("data", {}).get("text", "").strip() - # 过滤掉过短或过长的噪音(已经在 cleaner 处理过一遍基本垃圾) + # Lọc nhiễu quá ngắn hoặc quá dài sau bước cleaner cơ bản. if 2 <= len(text) <= 500: interesting_messages.append( { diff --git a/src/infrastructure/analysis/analyzers/topic_analyzer.py b/src/infrastructure/analysis/analyzers/topic_analyzer.py index 48fd6fa..1686584 100644 --- a/src/infrastructure/analysis/analyzers/topic_analyzer.py +++ b/src/infrastructure/analysis/analyzers/topic_analyzer.py @@ -1,7 +1,4 @@ -""" -话题分析模块 -专门处理群聊话题分析 -""" +"""Module phân tích chủ đề trò chuyện nhóm.""" import re from datetime import datetime @@ -17,21 +14,18 @@ from .base_analyzer import BaseAnalyzer class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): - """ - 话题分析器 - 专门处理群聊话题的提取和分析 - """ + """Trích xuất và phân tích chủ đề trò chuyện nhóm.""" def get_provider_id_key(self) -> str: - """获取 Provider ID 配置键名""" + """Lấy tên key cấu hình Provider ID.""" return "topic_provider_id" def get_data_type(self) -> str: - """获取数据类型标识""" + """Lấy định danh loại dữ liệu.""" return "Chủ đề" def get_max_count(self) -> int: - """获取最大话题数量,增量模式下使用覆盖值""" + """Lấy số chủ đề tối đa, ưu tiên giá trị override ở chế độ gia tăng.""" if self._incremental_max_count is not None: return self._incremental_max_count return self.config_manager.get_max_topics() @@ -44,42 +38,42 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): def build_prompt(self, data: list[dict]) -> str: """ - 构建话题分析提示词 + Xây dựng prompt phân tích chủ đề. Args: - messages: 群聊消息列表 + data: Danh sách tin nhắn nhóm. Returns: - 提示词字符串 + Chuỗi prompt. """ - # 验证输入数据格式 + # Xác thực định dạng input. if not isinstance(data, list): - logger.error(f"build_prompt 期望列表,但收到: {type(data)}") + logger.error(f"build_prompt cần list nhưng nhận được: {type(data)}") return "" - # 检查消息列表是否为空 + # Kiểm tra danh sách tin nhắn rỗng. if not data: - logger.warning("build_prompt 收到空消息列表") + logger.warning("build_prompt nhận danh sách tin nhắn rỗng") return "" - # 提取文本消息 + # Trích xuất tin nhắn văn bản. text_messages = [] for i, msg in enumerate(data): - # 确保msg是字典类型,避免'str' object has no attribute 'get'错误 + # Bỏ qua msg không phải dict để tránh lỗi thuộc tính get. if not isinstance(msg, dict): continue try: sender = msg.get("sender", {}) - # 确保sender是字典类型,避免'str' object has no attribute 'get'错误 + # Bỏ qua sender không phải dict. if not isinstance(sender, dict): continue - # 获取发送者ID并过滤机器人消息 + # Lấy ID người gửi và lọc tin nhắn bot. user_id = str(sender.get("user_id", "")) bot_self_ids = self.config_manager.get_bot_self_ids() - # 跳过机器人自己的消息 + # Bỏ qua tin nhắn của bot. if bot_self_ids and user_id in [str(uid) for uid in bot_self_ids]: continue @@ -88,7 +82,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): message_list = msg.get("message", []) - # 提取文本内容,可能分布在多个 content 中 + # Nội dung văn bản có thể nằm trong nhiều phần content. text_parts = [] for j, content in enumerate(message_list): if not isinstance(content, dict): @@ -101,21 +95,21 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): if text: text_parts.append(text) elif content_type == "at": - # 处理 @ 消息,转换为文本 + # Chuyển mention thành văn bản. at_data = content.get("data", {}) - # 兼容不同平台的 ID 字段 + # Tương thích trường ID giữa các nền tảng. at_id = at_data.get("id") or at_data.get("user_id") if at_id: at_text = f"@{at_id}" text_parts.append(at_text) elif content_type == "reply": - # 处理回复消息,添加标记 + # Thêm nhãn cho tin nhắn trả lời. reply_id = content.get("data", {}).get("id", "") if reply_id: - reply_text = f"[回复:{reply_id}]" + reply_text = f"[Trả lời:{reply_id}]" text_parts.append(reply_text) - # 合并所有文本部分 + # Gộp mọi phần văn bản. combined_text = "".join(text_parts).strip() if ( @@ -123,7 +117,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): and len(combined_text) > 2 and not combined_text.startswith("/") ): - # 清理消息内容 + # Làm sạch nội dung tin nhắn. cleaned_text = combined_text.replace("“", '"').replace("”", '"') cleaned_text = cleaned_text.replace("‘", "'").replace("’", "'") cleaned_text = cleaned_text.replace("\n", " ").replace("\r", " ") @@ -140,16 +134,18 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): ) except Exception as e: logger.error( - f"build_prompt 处理第 {i + 1} 条消息时出错: {e}", exc_info=True + f"build_prompt lỗi khi xử lý tin nhắn thứ {i + 1}: {e}", + exc_info=True, ) continue if not text_messages: - logger.warning("build_prompt 没有提取到有效的文本消息,返回空prompt") + logger.warning( + "build_prompt không trích xuất được tin nhắn hợp lệ; trả về prompt rỗng" + ) return "" - # 构建消息文本 - # 使用用户提供的 ID-Only 格式: [HH:MM] [用户ID]: 消息内容 + # Dựng văn bản theo định dạng chỉ ID: [HH:MM] [ID người dùng]: nội dung. messages_text = "\n".join( [ f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}" @@ -159,7 +155,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): max_topics = self.get_max_count() - # 从配置读取 prompt 模板(默认使用 "default" 风格) + # Đọc template prompt từ cấu hình. prompt_template = self.config_manager.get_topic_analysis_prompt() if prompt_template: @@ -169,98 +165,104 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): max_topics=max_topics, messages_text=messages_text, ) - logger.info("使用配置中的话题分析提示词") + logger.info("Đang dùng prompt phân tích chủ đề trong cấu hình") return prompt except Exception as e: - logger.warning(f"应用话题分析提示词失败: {e}") + logger.warning(f"Áp dụng prompt phân tích chủ đề thất bại: {e}") - logger.warning("未找到有效的话题分析提示词配置,请检查配置文件") + logger.warning( + "Không tìm thấy prompt phân tích chủ đề hợp lệ; hãy kiểm tra cấu hình" + ) return "" def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: """ - 使用正则表达式提取话题信息 + Trích xuất thông tin chủ đề bằng regex. Args: - result_text: LLM响应文本 - max_count: 最大话题数量 + result_text: Văn bản phản hồi LLM. + max_count: Số chủ đề tối đa. Returns: - 话题数据列表 + Danh sách dữ liệu chủ đề. """ return extract_topics_with_regex(result_text, max_count) def create_data_objects(self, data_list: list[dict]) -> list[SummaryTopic]: """ - 创建话题对象列表 + Tạo danh sách object chủ đề. Args: - topics_data: 原始话题数据列表 + data_list: Danh sách dữ liệu chủ đề thô. Returns: - SummaryTopic对象列表 + Danh sách object SummaryTopic. """ logger.debug( - f"create_data_objects 开始处理,输入数据数量: {len(data_list) if data_list else 0}" + f"create_data_objects bắt đầu, số mục input: {len(data_list) if data_list else 0}" ) - logger.debug(f"输入数据类型: {type(data_list)}") + logger.debug(f"Loại dữ liệu input: {type(data_list)}") try: topics = [] max_topics = self.get_max_count() - logger.debug(f"处理前 {max_topics} 条话题数据") + logger.debug(f"Đang xử lý tối đa {max_topics} chủ đề đầu") for i, topic_data in enumerate(data_list[:max_topics]): - logger.debug(f"处理第 {i + 1} 条话题数据,类型: {type(topic_data)}") + logger.debug(f"Đang xử lý chủ đề thứ {i + 1}, loại: {type(topic_data)}") - # 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误 + # Bỏ qua dữ liệu chủ đề không phải dict. if not isinstance(topic_data, dict): logger.warning( - f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}" + f"Bỏ qua dữ liệu chủ đề không phải dict: {type(topic_data)} - {topic_data}" ) continue try: - # 确保数据格式正确 + # Chuẩn hoá định dạng dữ liệu. topic_name = topic_data.get("topic", "").strip() contributors = topic_data.get("contributors", []) detail = topic_data.get("detail", "").strip() logger.debug( - f"话题数据 - 名称: {topic_name}, 参与者: {contributors}, 详情: {detail[:50]}..." + f"Chủ đề - tên: {topic_name}, người tham gia: {contributors}, chi tiết: {detail[:50]}..." ) - # 验证必要字段 + # Xác thực các trường bắt buộc. if not topic_name or not detail: - logger.warning(f"话题数据格式不完整,跳过: {topic_data}") + logger.warning( + f"Dữ liệu chủ đề không đầy đủ, bỏ qua: {topic_data}" + ) continue - # 确保参与者列表有效 + # Đảm bảo danh sách người tham gia hợp lệ. if not contributors or not isinstance(contributors, list): - contributors = ["群友"] + contributors = ["Thành viên nhóm"] else: - # 清理参与者名称 + # Làm sạch tên người tham gia. contributors = [ str(c).strip() for c in contributors if c and str(c).strip() - ] or ["群友"] + ] or ["Thành viên nhóm"] topics.append( SummaryTopic( topic=topic_name, - contributors=contributors[:5], # 最多5个参与者 + contributors=contributors[:5], # Tối đa 5 người tham gia. detail=detail, ) ) except Exception as e: - logger.error(f"处理第 {i + 1} 条话题数据时出错: {e}", exc_info=True) + logger.error( + f"Lỗi khi xử lý chủ đề thứ {i + 1}: {e}", exc_info=True + ) continue - logger.debug(f"create_data_objects 完成,创建了 {len(topics)} 个话题对象") + logger.debug(f"create_data_objects hoàn tất, đã tạo {len(topics)} chủ đề") return topics except Exception as e: - logger.error(f"创建话题对象失败: {e}", exc_info=True) + logger.error(f"Tạo object chủ đề thất bại: {e}", exc_info=True) return [] def validate_parsed_data( @@ -270,18 +272,18 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): def extract_text_messages(self, messages: list[dict]) -> list[dict]: """ - 从已清理的消息中提取文本消息用于话题分析。 + Trích xuất tin nhắn văn bản đã làm sạch để phân tích chủ đề. Args: - messages: 已由 MessageCleaner 处理过的 legacy 消息列表 + messages: Danh sách tin nhắn legacy đã qua MessageCleaner. Returns: - 提取的文本消息列表 + Danh sách tin nhắn văn bản được trích xuất. """ text_messages = [] for msg in messages: - # 获取发送者显示名 + # Lấy tên hiển thị người gửi. sender = msg.get("sender", {}) nickname = InfoUtils.get_user_nickname(self.config_manager, sender) msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") @@ -289,9 +291,9 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): for content in msg.get("message", []): if content.get("type") == "text": text = content.get("data", {}).get("text", "").strip() - # 已经在 MessageCleaner 中处理过基本的垃圾内容 + # Nội dung rác cơ bản đã được MessageCleaner xử lý. if text: - # 简单的额外清理 + # Làm sạch bổ sung đơn giản. cleaned_text = text.replace("\n", " ").replace("\r", " ") cleaned_text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned_text) @@ -312,42 +314,46 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): session_id: str | None = None, ) -> tuple[list[SummaryTopic], TokenUsage]: """ - 分析群聊话题 + Phân tích chủ đề trò chuyện nhóm. Args: - messages: 群聊消息列表 - umo: 模型唯一标识符 - session_id: 会话ID (用于调试模式) + messages: Danh sách tin nhắn nhóm. + umo: Định danh UMO. + session_id: ID phiên dùng trong debug mode. Returns: - (话题列表, Token使用统计) + Tuple danh sách chủ đề và thống kê token. """ try: logger.debug( - f"analyze_topics 开始处理,消息数量: {len(messages) if messages else 0}" + f"analyze_topics bắt đầu, số tin nhắn: {len(messages) if messages else 0}" ) - logger.debug(f"消息类型: {type(messages)}") + logger.debug(f"Loại dữ liệu tin nhắn: {type(messages)}") if messages: logger.debug( - f"第一条消息类型: {type(messages[0]) if messages else '无'}" + f"Loại tin nhắn đầu tiên: {type(messages[0]) if messages else 'không có'}" + ) + logger.debug( + f"Nội dung tin nhắn đầu tiên: {messages[0] if messages else 'không có'}" ) - logger.debug(f"第一条消息内容: {messages[0] if messages else '无'}") - # 检查是否有有效的文本消息 + # Kiểm tra tin nhắn văn bản hợp lệ. text_messages = self.extract_text_messages(messages) - logger.debug(f"提取到 {len(text_messages)} 条文本消息") + logger.debug(f"Đã trích xuất {len(text_messages)} tin nhắn văn bản") if not text_messages: - logger.info("没有有效的文本消息,返回空结果") + logger.info("Không có tin nhắn văn bản hợp lệ; trả về kết quả rỗng") return [], TokenUsage() - logger.info(f"开始分析 {len(text_messages)} 条文本消息中的话题") - logger.debug(f"文本消息类型: {type(text_messages)}") + logger.info(f"Bắt đầu phân tích chủ đề từ {len(text_messages)} tin nhắn") + logger.debug(f"Loại dữ liệu tin nhắn văn bản: {type(text_messages)}") if text_messages: - logger.debug(f"第一条文本消息类型: {type(text_messages[0])}") - logger.debug(f"第一条文本消息内容: {text_messages[0]}") + logger.debug( + f"Loại tin nhắn văn bản đầu tiên: {type(text_messages[0])}" + ) + logger.debug(f"Nội dung tin nhắn văn bản đầu tiên: {text_messages[0]}") - # 建立 ID 到昵称的映射表 + # Lập bảng ánh xạ ID sang nickname. id_to_nickname = {} for msg in text_messages: sender = msg.get("sender") @@ -355,15 +361,15 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): if sender and user_id: id_to_nickname[user_id] = sender - # 直接传入原始消息,让 build_prompt 方法处理 + # Truyền tin nhắn gốc để build_prompt xử lý. topics, usage = await self.analyze(messages, umo, session_id) - # 后处理:contributors 此时包含的是 ID,需要映射回昵称 + # Hậu xử lý: ánh xạ contributor ID về nickname. for topic in topics: - raw_ids = topic.contributors # LLM 返回的是 ID 列表 + raw_ids = topic.contributors # LLM trả về danh sách ID. - # 填充 contributor_ids。QQ 官方 member_openid 并非纯数字, - # 因此仅接受本批次已知用户或已配置机器人 ID,而不是用 isdigit 过滤。 + # member_openid QQ Official không chỉ gồm số; chỉ nhận ID đã biết + # trong batch hoặc ID bot cấu hình thay vì lọc bằng isdigit. bot_ids = {str(uid) for uid in self.config_manager.get_bot_self_ids()} known_ids = set(id_to_nickname) | bot_ids valid_ids = [] @@ -373,13 +379,13 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): valid_ids.append(uid) topic.contributor_ids = valid_ids - # 映射回昵称用于显示 + # Ánh xạ về nickname để hiển thị. resolved_names = [] for uid in valid_ids: - # 尝试从当前批次消息映射 + # Thử ánh xạ từ batch hiện tại. name = id_to_nickname.get(uid) if not name: - # 尝试去全局配置里找 (e.g. 机器人自己) + # Thử tìm trong cấu hình toàn cục, ví dụ bot. if uid in bot_ids: name = "Bot" else: @@ -391,5 +397,5 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): return topics, usage except Exception as e: - logger.error(f"话题分析失败: {e}", exc_info=True) + logger.error(f"Phân tích chủ đề thất bại: {e}", exc_info=True) return [], TokenUsage() diff --git a/src/infrastructure/analysis/analyzers/user_title_analyzer.py b/src/infrastructure/analysis/analyzers/user_title_analyzer.py index b74073b..d74be66 100644 --- a/src/infrastructure/analysis/analyzers/user_title_analyzer.py +++ b/src/infrastructure/analysis/analyzers/user_title_analyzer.py @@ -1,7 +1,4 @@ -""" -用户称号分析模块 -专门处理用户称号和MBTI类型分析 -""" +"""Module phân tích danh hiệu thành viên và kiểu MBTI.""" from ....domain.models.data_models import TokenUsage, UserTitle from ....utils.logger import logger @@ -13,21 +10,18 @@ from .base_analyzer import BaseAnalyzer class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): - """ - 用户称号分析器 - 专门处理用户称号分配和MBTI类型分析 - """ + """Analyzer phân bổ danh hiệu thành viên và phân tích MBTI.""" def get_provider_id_key(self) -> str: - """获取 Provider ID 配置键名""" + """Lấy tên key cấu hình Provider ID.""" return "user_title_provider_id" def get_data_type(self) -> str: - """获取数据类型标识""" + """Lấy định danh loại dữ liệu.""" return "Danh hiệu thành viên" def get_max_count(self) -> int: - """获取最大用户称号数量""" + """Lấy số danh hiệu thành viên tối đa.""" return self.config_manager.get_max_user_titles() def get_response_schema_name(self) -> str: @@ -38,89 +32,91 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): def build_prompt(self, data: dict) -> str: """ - 构建用户称号分析提示词 + Xây dựng prompt phân tích danh hiệu thành viên. Args: - user_data: 用户数据字典,包含用户统计信息 + user_data: Dict dữ liệu và thống kê thành viên. Returns: - 提示词字符串 + Chuỗi prompt. """ user_summaries = data.get("user_summaries", []) if not user_summaries: return "" - # 构建用户数据文本 + # Xây dựng văn bản dữ liệu thành viên. users_text = "\n".join( [ f"- {user['name']} (ID:{user['user_id']}): " - f"发言{user['message_count']}条, 平均{user['avg_chars']}字, " - f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, " - f"回复比例{user['reply_ratio']}" + f"{user['message_count']} tin nhắn, trung bình {user['avg_chars']} ký tự, " + f"tỷ lệ biểu cảm {user['emoji_ratio']}, tỷ lệ chat ban đêm {user['night_ratio']}, " + f"tỷ lệ trả lời {user['reply_ratio']}" for user in user_summaries ] ) - # 从配置读取 prompt 模板(默认使用 "default" 风格) + # Đọc template prompt từ cấu hình, mặc định kiểu ``default``. prompt_template = self.config_manager.get_user_title_analysis_prompt() if prompt_template: try: prompt = render_template(prompt_template, users_text=users_text) - logger.info("使用配置中的用户称号分析提示词") + logger.info("Đang dùng prompt phân tích danh hiệu trong cấu hình") return prompt except Exception as e: - logger.warning(f"应用用户称号分析提示词失败: {e}") + logger.warning(f"Áp dụng prompt phân tích danh hiệu thất bại: {e}") - logger.warning("未找到有效的用户称号分析提示词配置,请检查配置文件") + logger.warning("Không tìm thấy cấu hình prompt phân tích danh hiệu hợp lệ") return "" def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: """ - 使用正则表达式提取用户称号信息 + Trích xuất thông tin danh hiệu bằng regex. Args: - result_text: LLM响应文本 - max_count: 最大提取数量 + result_text: Văn bản phản hồi LLM. + max_count: Số lượng tối đa. Returns: - 用户称号数据列表 + Danh sách dữ liệu danh hiệu. """ return extract_user_titles_with_regex(result_text, max_count) def create_data_objects(self, data_list: list[dict]) -> list[UserTitle]: """ - 创建用户称号对象列表 + Tạo danh sách object danh hiệu. Args: - titles_data: 原始用户称号数据列表 + titles_data: Danh sách dữ liệu danh hiệu gốc. Returns: - UserTitle对象列表 + Danh sách object UserTitle. """ try: titles = [] max_titles = self.get_max_count() for title_data in data_list[:max_titles]: - # 确保数据格式正确 + # Đảm bảo định dạng dữ liệu đúng. name = title_data.get("name", "").strip() user_id = title_data.get("user_id") title = title_data.get("title", "").strip() mbti = title_data.get("mbti", "").strip() reason = title_data.get("reason", "").strip() - # 验证必要字段 + # Xác thực trường bắt buộc. if not name or not title or not mbti or not reason: - logger.warning(f"用户称号数据格式不完整,跳过: {title_data}") + logger.warning( + f"Dữ liệu danh hiệu không đầy đủ, bỏ qua: {title_data}" + ) continue - # 确保 user_id 是字符串 + # Đảm bảo user_id là chuỗi. if user_id is not None: user_id = str(user_id) else: - logger.warning(f"未找到用户ID (user_id),跳过: {title_data}") + logger.warning(f"Không tìm thấy user_id, bỏ qua: {title_data}") continue titles.append( @@ -136,7 +132,7 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): return titles except Exception as e: - logger.error(f"创建用户称号对象失败: {e}") + logger.error(f"Tạo object danh hiệu thất bại: {e}") return [] def validate_parsed_data( @@ -151,31 +147,33 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): top_users: list[dict] | None = None, ) -> dict: """ - 准备用户数据 + Chuẩn bị dữ liệu thành viên. Args: - messages: 群聊消息列表 - user_analysis: 用户分析统计 - top_users: 活跃用户列表(从get_top_users获取) + messages: Danh sách tin nhắn nhóm. + user_analysis: Thống kê phân tích thành viên. + top_users: Danh sách thành viên tích cực từ get_top_users. Returns: - 准备好的用户数据字典 + Dict dữ liệu thành viên đã chuẩn bị. """ try: - # 获取机器人 ID 列表用于过滤 + # Lấy danh sách ID bot để lọc. bot_self_ids = self.config_manager.get_bot_self_ids() user_summaries = [] - # 如果提供了top_users列表,只分析这些活跃用户 + # Chỉ phân tích thành viên tích cực nếu có top_users. if top_users: logger.info( - f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析" + f"Phân tích danh hiệu cho {len(top_users)} thành viên tích cực do get_top_users lọc" ) target_user_ids = {str(user["user_id"]) for user in top_users} else: - # 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户 - logger.info("未提供活跃用户列表,使用消息数>=5的用户") + # Tương thích logic cũ: dùng thành viên có ít nhất 5 tin nhắn. + logger.info( + "Không có danh sách tích cực, dùng thành viên có ít nhất 5 tin nhắn" + ) target_user_ids = { user_id for user_id, stats in user_analysis.items() @@ -184,23 +182,22 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): for user_id, stats in user_analysis.items(): user_id_str = str(user_id) - # 过滤机器人由 MessageCleaner 已处理,此处仅作为二级防御 + # MessageCleaner đã lọc bot; đây là lớp phòng vệ thứ hai. if bot_self_ids and user_id_str in [str(uid) for uid in bot_self_ids]: continue - # 只处理活跃用户 (top_users 或 消息数>=5) + # Chỉ xử lý thành viên tích cực. if user_id_str not in target_user_ids: continue - # 分析用户特征 (此处已基于已清理的 stats) - # 兼容性处理:优先使用 hours (dict),如果没有则尝试从消息推断或使用空 + # Phân tích đặc trưng từ stats đã làm sạch; ưu tiên hours dạng dict. hours_data = stats.get("hours") if hours_data is None: - # 尝试兼容旧 schema 或简化版 + # Tương thích schema cũ hoặc bản đơn giản. active_hours = stats.get("active_hours", []) hours_data = dict.fromkeys(active_hours, 1) - # 安全计算夜间发言数 + # Tính an toàn số tin nhắn ban đêm. night_messages = sum(hours_data.get(h, 0) for h in range(6)) message_count = stats.get("message_count", 0) @@ -209,7 +206,7 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): avg_chars = stats.get("char_count", 0) / message_count - # 称号所需维度 + # Các chiều cần cho danh hiệu. user_summaries.append( { "name": stats.get("nickname", stats.get("name", user_id_str)), @@ -229,13 +226,13 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): if not user_summaries: return {"user_summaries": []} - # 按消息数量排序 + # Sắp xếp theo số tin nhắn. user_summaries.sort(key=lambda x: x["message_count"], reverse=True) return {"user_summaries": user_summaries} except Exception as e: - logger.error(f"准备用户数据失败: {e}") + logger.error(f"Chuẩn bị dữ liệu thành viên thất bại: {e}") return {"user_summaries": []} async def analyze_user_titles( @@ -247,29 +244,31 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): session_id: str | None = None, ) -> tuple[list[UserTitle], TokenUsage]: """ - 分析用户称号 + Phân tích danh hiệu thành viên. Args: - messages: 群聊消息列表 - user_analysis: 用户分析统计 - umo: 模型唯一标识符 - top_users: 活跃用户列表(从get_top_users获取,可选) - session_id: 会话ID (用于调试模式) + messages: Danh sách tin nhắn nhóm. + user_analysis: Thống kê phân tích thành viên. + umo: Định danh model. + top_users: Danh sách thành viên tích cực, tuỳ chọn. + session_id: ID phiên dùng cho debug mode. Returns: - (用户称号列表, Token使用统计) + Tuple danh sách danh hiệu và thống kê token. """ try: - # 准备用户数据,传入活跃用户列表 + # Chuẩn bị dữ liệu và truyền danh sách thành viên tích cực. user_data = self.prepare_user_data(messages, user_activity, top_users) if not user_data["user_summaries"]: - logger.info("没有符合条件的用户,返回空结果") + logger.info("Không có thành viên phù hợp, trả về kết quả rỗng") return [], TokenUsage() - logger.info(f"开始分析 {len(user_data['user_summaries'])} 个活跃用户的称号") + logger.info( + f"Bắt đầu phân tích danh hiệu cho {len(user_data['user_summaries'])} thành viên tích cực" + ) return await self.analyze(user_data, umo, session_id) except Exception as e: - logger.error(f"用户称号分析失败: {e}") + logger.error(f"Phân tích danh hiệu thất bại: {e}") return [], TokenUsage() diff --git a/src/infrastructure/analysis/llm_analyzer.py b/src/infrastructure/analysis/llm_analyzer.py index 278dbbe..6358868 100644 --- a/src/infrastructure/analysis/llm_analyzer.py +++ b/src/infrastructure/analysis/llm_analyzer.py @@ -1,7 +1,4 @@ -""" -LLM分析器模块 -负责协调各个分析器进行话题分析、用户称号分析和金句分析 -""" +"""Điều phối analyzer LLM cho chủ đề, danh hiệu và trích dẫn nổi bật.""" import asyncio @@ -25,9 +22,8 @@ from .utils.llm_utils import call_provider_with_retry class LLMAnalyzer(IAnalysisProvider): """ - LLM分析器 - 作为统一入口,协调各个专门的分析器进行不同类型的分析 - 保持向后兼容性,提供原有的接口 + Analyzer LLM làm điểm vào thống nhất cho các loại phân tích chuyên biệt, + đồng thời duy trì giao diện tương thích ngược. """ topic_analyzer: TopicAnalyzer @@ -36,16 +32,16 @@ class LLMAnalyzer(IAnalysisProvider): def __init__(self, context, config_manager): """ - 初始化LLM分析器 + Khởi tạo analyzer LLM. Args: - context: AstrBot上下文对象 - config_manager: 配置管理器 + context: Context AstrBot. + config_manager: Trình quản lý cấu hình. """ self.context = context self.config_manager = config_manager - # 初始化各个专门的分析器 + # Khởi tạo các analyzer chuyên biệt. self.topic_analyzer = TopicAnalyzer(context, config_manager) self.user_title_analyzer = UserTitleAnalyzer(context, config_manager) self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager) @@ -73,24 +69,23 @@ class LLMAnalyzer(IAnalysisProvider): session_id: str | None = None, ) -> tuple[list[SummaryTopic], TokenUsage]: """ - 使用LLM分析话题 - 保持原有接口,委托给专门的TopicAnalyzer处理 + Phân tích chủ đề bằng LLM qua TopicAnalyzer chuyên biệt. Args: - messages: 群聊消息列表 - umo: 模型唯一标识符 - session_id: 会话ID (用于调试模式) + messages: Danh sách tin nhắn nhóm. + umo: Định danh duy nhất của model. + session_id: ID phiên dùng cho debug mode. Returns: - (话题列表, Token使用统计) + Tuple danh sách chủ đề và thống kê token. """ try: session_id = self._make_session_id(session_id, umo) - logger.info(f"开始话题分析, session_id: {session_id}") + logger.info(f"Bắt đầu phân tích chủ đề, session_id: {session_id}") return await self.topic_analyzer.analyze_topics(messages, umo, session_id) except Exception as e: - logger.error(f"话题分析失败: {e}") + logger.error(f"Phân tích chủ đề thất bại: {e}") return [], TokenUsage() async def analyze_user_titles( @@ -102,28 +97,27 @@ class LLMAnalyzer(IAnalysisProvider): session_id: str | None = None, ) -> tuple[list[UserTitle], TokenUsage]: """ - 使用LLM分析用户称号 - 保持原有接口,委托给专门的UserTitleAnalyzer处理 + Phân tích danh hiệu thành viên bằng LLM qua UserTitleAnalyzer. Args: - messages: 群聊消息列表 - user_activity: 用户分析统计 - umo: 模型唯一标识符 - top_users: 活跃用户列表(可选) - session_id: 会话ID (用于调试模式) + messages: Danh sách tin nhắn nhóm. + user_activity: Thống kê hoạt động thành viên. + umo: Định danh duy nhất của model. + top_users: Danh sách thành viên tích cực, tuỳ chọn. + session_id: ID phiên dùng cho debug mode. Returns: - (用户称号列表, Token使用统计) + Tuple danh sách danh hiệu và thống kê token. """ try: session_id = self._make_session_id(session_id, umo) - logger.info(f"开始用户称号分析, session_id: {session_id}") + logger.info(f"Bắt đầu phân tích danh hiệu, session_id: {session_id}") return await self.user_title_analyzer.analyze_user_titles( messages, user_activity, umo, top_users, session_id ) except Exception as e: - logger.error(f"用户称号分析失败: {e}") + logger.error(f"Phân tích danh hiệu thất bại: {e}") return [], TokenUsage() async def analyze_golden_quotes( @@ -133,26 +127,25 @@ class LLMAnalyzer(IAnalysisProvider): session_id: str | None = None, ) -> tuple[list[GoldenQuote], TokenUsage]: """ - 使用LLM分析群聊金句 - 保持原有接口,委托给专门的GoldenQuoteAnalyzer处理 + Phân tích trích dẫn nổi bật bằng LLM qua GoldenQuoteAnalyzer. Args: - messages: 群聊消息列表 - umo: 模型唯一标识符 - session_id: 会话ID (用于调试模式) + messages: Danh sách tin nhắn nhóm. + umo: Định danh duy nhất của model. + session_id: ID phiên dùng cho debug mode. Returns: - (金句列表, Token使用统计) + Tuple danh sách trích dẫn và thống kê token. """ try: session_id = self._make_session_id(session_id, umo) - logger.info(f"开始金句分析, session_id: {session_id}") + logger.info(f"Bắt đầu phân tích trích dẫn, session_id: {session_id}") return await self.golden_quote_analyzer.analyze_golden_quotes( messages, umo, session_id ) except Exception as e: - logger.error(f"金句分析失败: {e}") + logger.error(f"Phân tích trích dẫn thất bại: {e}") return [], TokenUsage() async def summarize_quality_reviews( @@ -162,7 +155,7 @@ class LLMAnalyzer(IAnalysisProvider): session_id: str | None = None, ) -> tuple[QualityReview | None, TokenUsage]: """ - 汇总多个质量分析报告(增量模式使用) + Tổng hợp nhiều báo cáo chất lượng trong chế độ gia tăng. """ return await self.chat_quality_analyzer.summarize_batch_reviews( batch_reviews, umo, session_id @@ -186,32 +179,32 @@ class LLMAnalyzer(IAnalysisProvider): QualityReview | None, ]: """ - 并发执行所有分析任务(话题、用户称号、金句),支持按需启用。 + Thực thi đồng thời các tác vụ phân tích được bật. Args: - messages: 群聊消息列表 - user_activity: 用户分析统计 - umo: 模型唯一标识符 - top_users: 活跃用户列表(可选) - topic_enabled: 是否启用话题分析 - user_title_enabled: 是否启用用户称号分析 - golden_quote_enabled: 是否启用金句分析 + messages: Danh sách tin nhắn nhóm. + user_activity: Thống kê hoạt động thành viên. + umo: Định danh model. + top_users: Danh sách thành viên tích cực. + topic_enabled: Có bật phân tích chủ đề hay không. + user_title_enabled: Có bật phân tích danh hiệu hay không. + golden_quote_enabled: Có bật phân tích trích dẫn hay không. Returns: - (话题列表, 用户称号列表, 金句列表, 总Token使用统计) + Danh sách chủ đề, danh hiệu, trích dẫn và tổng token. """ try: session_id = self._make_session_id(None, umo) logger.info( - f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}" + f"Bắt đầu phân tích đồng thời (chủ đề:{topic_enabled}, danh hiệu:{user_title_enabled}, trích dẫn:{golden_quote_enabled}), session_id: {session_id}" ) - # 保存原始消息数据 (Debug Mode) + # Lưu dữ liệu tin nhắn gốc trong debug mode. if self.config_manager.get_debug_mode(): self._save_debug_messages(messages, session_id) - # 构建并发任务列表 + # Xây dựng danh sách tác vụ đồng thời. tasks = [] task_names = [] @@ -250,7 +243,7 @@ class LLMAnalyzer(IAnalysisProvider): results = await asyncio.gather(*tasks, return_exceptions=True) - # 处理结果 + # Xử lý kết quả. topics, topic_usage = [], TokenUsage() user_titles, title_usage = [], TokenUsage() golden_quotes, quote_usage = [], TokenUsage() @@ -260,7 +253,7 @@ class LLMAnalyzer(IAnalysisProvider): for i, result in enumerate(results): name = task_names[i] if isinstance(result, Exception): - logger.error(f"分析任务 {name} 失败: {result}") + logger.error(f"Tác vụ phân tích {name} thất bại: {result}") continue if name == "topic" and isinstance(result, tuple): @@ -274,7 +267,7 @@ class LLMAnalyzer(IAnalysisProvider): if not isinstance(quality_usage, TokenUsage): quality_usage = TokenUsage() - # 合并Token使用统计 + # Gộp thống kê sử dụng token. total_usage = TokenUsage( prompt_tokens=topic_usage.prompt_tokens + title_usage.prompt_tokens @@ -291,7 +284,7 @@ class LLMAnalyzer(IAnalysisProvider): ) logger.info( - f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}, 质量锐评: {1 if chat_quality_review else 0}" + f"Hoàn tất phân tích đồng thời - chủ đề: {len(topics)}, danh hiệu: {len(user_titles)}, trích dẫn: {len(golden_quotes)}, đánh giá chất lượng: {1 if chat_quality_review else 0}" ) return ( topics, @@ -302,7 +295,7 @@ class LLMAnalyzer(IAnalysisProvider): ) except Exception as e: - logger.error(f"并发分析失败: {e}") + logger.error(f"Phân tích đồng thời thất bại: {e}") return [], [], [], TokenUsage(), None async def analyze_incremental_concurrent( @@ -316,39 +309,38 @@ class LLMAnalyzer(IAnalysisProvider): chat_quality_enabled: bool = False, ) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]: """ - 增量分析模式的并发执行方法。 - 仅执行话题分析和金句分析(用户称号分析在最终报告时执行), - 使用较小的批次数量以控制单次分析的输出规模。 + Thực thi đồng thời trong chế độ gia tăng. Chỉ phân tích chủ đề, + trích dẫn và chất lượng; danh hiệu được xử lý khi tạo báo cáo cuối. Args: - messages: 本次增量分析的群聊消息列表 - umo: 模型唯一标识符 - topics_per_batch: 本次批次最大话题数量 - quotes_per_batch: 本次批次最大金句数量 - topic_enabled: 是否启用话题分析 - golden_quote_enabled: 是否启用金句分析 + messages: Tin nhắn nhóm của lần phân tích gia tăng. + umo: Định danh model. + topics_per_batch: Số chủ đề tối đa trong batch. + quotes_per_batch: Số trích dẫn tối đa trong batch. + topic_enabled: Có bật phân tích chủ đề hay không. + golden_quote_enabled: Có bật phân tích trích dẫn hay không. Returns: - (话题列表, 金句列表, 总Token使用统计) + Danh sách chủ đề, trích dẫn và tổng token. """ try: session_id = self._make_session_id(None, umo, "incr_") logger.info( - f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled})," - f"消息数量: {len(messages)},会话ID: {session_id}" + f"Bắt đầu phân tích gia tăng đồng thời (chủ đề:{topic_enabled}/{topics_per_batch}, trích dẫn:{golden_quote_enabled}/{quotes_per_batch}, chất lượng:{chat_quality_enabled}), " + f"tin nhắn: {len(messages)}, session_id: {session_id}" ) - # 保存原始消息数据 (Debug Mode) + # Lưu dữ liệu tin nhắn gốc trong debug mode. if self.config_manager.get_debug_mode(): self._save_debug_messages(messages, session_id) - # 设置增量模式的最大数量覆盖值 + # Thiết lập giới hạn ghi đè cho chế độ gia tăng. self.topic_analyzer._incremental_max_count = topics_per_batch self.golden_quote_analyzer._incremental_max_count = quotes_per_batch try: - # 构建并发任务列表(仅话题和金句,不包含用户称号) + # Xây dựng tác vụ đồng thời, không gồm danh hiệu thành viên. tasks = [] task_names = [] @@ -379,7 +371,7 @@ class LLMAnalyzer(IAnalysisProvider): results = await asyncio.gather(*tasks, return_exceptions=True) - # 处理结果 + # Xử lý kết quả. topics, topic_usage = [], TokenUsage() golden_quotes, quote_usage = [], TokenUsage() chat_quality_review = None @@ -388,7 +380,7 @@ class LLMAnalyzer(IAnalysisProvider): for i, result in enumerate(results): name = task_names[i] if isinstance(result, Exception): - logger.error(f"增量{name}分析失败: {result}") + logger.error(f"Phân tích gia tăng {name} thất bại: {result}") continue if name == "topic" and isinstance(result, tuple): @@ -400,7 +392,7 @@ class LLMAnalyzer(IAnalysisProvider): if not isinstance(quality_usage, TokenUsage): quality_usage = TokenUsage() - # 合并Token使用统计 + # Gộp thống kê sử dụng token. total_usage = TokenUsage( prompt_tokens=topic_usage.prompt_tokens + quote_usage.prompt_tokens @@ -414,27 +406,27 @@ class LLMAnalyzer(IAnalysisProvider): ) logger.info( - f"增量并发分析完成 - 话题: {len(topics)}, 金句: {len(golden_quotes)}, 质量锐评: {1 if chat_quality_review else 0}, " - f"Token消耗: {total_usage.total_tokens}" + f"Hoàn tất phân tích gia tăng đồng thời - chủ đề: {len(topics)}, trích dẫn: {len(golden_quotes)}, đánh giá chất lượng: {1 if chat_quality_review else 0}, " + f"token: {total_usage.total_tokens}" ) return topics, golden_quotes, total_usage, chat_quality_review finally: - # 无论成功或失败,都要恢复原始的最大数量设置 + # Luôn khôi phục giới hạn ban đầu dù thành công hay thất bại. self.topic_analyzer._incremental_max_count = None self.golden_quote_analyzer._incremental_max_count = None except Exception as e: - logger.error(f"增量并发分析失败: {e}", exc_info=True) + logger.error(f"Phân tích gia tăng đồng thời thất bại: {e}", exc_info=True) return [], [], TokenUsage(), None def _save_debug_messages(self, messages: list[dict], session_id: str): """ - 保存调试消息数据到文件(Debug Mode 专用) + Lưu dữ liệu tin nhắn debug vào tệp. Args: - messages: 群聊消息列表 - session_id: 会话ID + messages: Danh sách tin nhắn nhóm. + session_id: ID phiên. """ try: import json @@ -450,7 +442,7 @@ class LLMAnalyzer(IAnalysisProvider): except Exception: pass - # 向后兼容的方法,保持原有调用方式 + # Phương thức tương thích ngược, giữ cách gọi cũ. async def _call_provider_with_retry( self, provider, @@ -459,17 +451,16 @@ class LLMAnalyzer(IAnalysisProvider): provider_id_key: str | None = None, ): """ - 向后兼容的LLM调用方法 - 现在委托给llm_utils模块处理 + Phương thức gọi LLM tương thích ngược, uỷ quyền cho llm_utils. Args: - provider: LLM服务商实例或None(已弃用,现在使用 provider_id_key) - prompt: 输入的提示语 - umo: 指定使用的模型唯一标识符 - provider_id_key: 配置中的 provider_id 键名(可选) + provider: Provider LLM hoặc None; đã deprecated. + prompt: Prompt đầu vào. + umo: Định danh model cần dùng. + provider_id_key: Tên key provider_id tuỳ chọn trong cấu hình. Returns: - LLM生成的结果 + Kết quả do LLM tạo. """ return await call_provider_with_retry( self.context, @@ -481,13 +472,12 @@ class LLMAnalyzer(IAnalysisProvider): def _fix_json(self, text: str) -> str: """ - 向后兼容的JSON修复方法 - 现在委托给json_utils模块处理 + Phương thức sửa JSON tương thích ngược, uỷ quyền cho json_utils. Args: - text: 需要修复的JSON文本 + text: Văn bản JSON cần sửa. Returns: - 修复后的JSON文本 + Văn bản JSON sau khi sửa. """ return fix_json(text) diff --git a/src/infrastructure/analysis/utils/__init__.py b/src/infrastructure/analysis/utils/__init__.py index 1fb65f6..c07ecdc 100644 --- a/src/infrastructure/analysis/utils/__init__.py +++ b/src/infrastructure/analysis/utils/__init__.py @@ -1,7 +1,4 @@ -""" -分析工具模块 -包含JSON处理和LLM API请求处理工具 -""" +"""Module tiện ích phân tích, xử lý JSON và yêu cầu API LLM.""" from .info_utils import InfoUtils from .json_utils import ( diff --git a/src/infrastructure/analysis/utils/info_utils.py b/src/infrastructure/analysis/utils/info_utils.py index f62ae81..98055ef 100644 --- a/src/infrastructure/analysis/utils/info_utils.py +++ b/src/infrastructure/analysis/utils/info_utils.py @@ -2,9 +2,9 @@ class InfoUtils: @staticmethod def get_user_nickname(config_manager, sender) -> str: """ - 获取用户昵称 + Lấy biệt danh thành viên. - 优先使用nickname字段,如果为空则使用card(群名片)字段 + Ưu tiên trường nickname; nếu rỗng thì dùng card (tên trong nhóm). """ enable_user_card = config_manager.get_enable_user_card() if enable_user_card: diff --git a/src/infrastructure/analysis/utils/json_utils.py b/src/infrastructure/analysis/utils/json_utils.py index 32875ef..f8b1825 100644 --- a/src/infrastructure/analysis/utils/json_utils.py +++ b/src/infrastructure/analysis/utils/json_utils.py @@ -1,7 +1,4 @@ -""" -JSON处理工具模块 -提供JSON解析、修复和正则提取功能 -""" +"""Công cụ phân tích, sửa JSON và trích xuất bằng regex.""" import json import re @@ -12,130 +9,132 @@ from ....utils.logger import logger def fix_json(text: str) -> str: """ - 修复JSON格式问题,包括中文符号替换 + Sửa lỗi định dạng JSON, bao gồm thay thế dấu câu tiếng Trung. Args: - text: 需要修复的JSON文本 + text: Văn bản JSON cần sửa. Returns: - 修复后的JSON文本 + Văn bản JSON sau khi sửa. """ try: - # 1. 移除markdown代码块标记 + # 1. Xoá marker code block Markdown. text = re.sub(r"```json\s*", "", text) text = re.sub(r"```\s*$", "", text) - # 2. 基础清理 + # 2. Làm sạch cơ bản. text = text.replace("\n", " ").replace("\r", " ") text = re.sub(r"\s+", " ", text) - # 3. 替换中文符号为英文符号(修复) - # 中文引号 -> 英文引号 + # 3. Thay dấu câu tiếng Trung bằng dấu câu ASCII để sửa JSON. + # Dấu ngoặc kép tiếng Trung -> dấu ngoặc kép ASCII. text = text.replace("“", '"').replace("”", '"') text = text.replace("‘", "'").replace("’", "'") - # 中文逗号 -> 英文逗号 + # Dấu phẩy tiếng Trung -> dấu phẩy ASCII. text = text.replace(",", ",") - # 中文冒号 -> 英文冒号 + # Dấu hai chấm tiếng Trung -> dấu hai chấm ASCII. text = text.replace(":", ":") - # 中文括号 -> 英文括号 + # Dấu ngoặc tiếng Trung -> dấu ngoặc ASCII. text = text.replace("(", "(").replace(")", ")") text = text.replace("【", "[").replace("】", "]") - # 4. 处理字符串内容中的特殊字符 - # 转义字符串内的双引号 + # 4. Xử lý ký tự đặc biệt trong chuỗi. + # Escape dấu ngoặc kép bên trong chuỗi. def escape_quotes_in_strings(match): content = match.group(1) - # 转义内部的双引号 + # Escape dấu ngoặc kép bên trong. content = content.replace('"', '\\"') return f'"{content}"' - # 先处理字段值中的引号 + # Xử lý dấu ngoặc kép trong giá trị trường trước. text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text) - # 5. 修复截断的JSON + # 5. Sửa JSON bị cắt ngắn. if not text.endswith("]"): last_complete = text.rfind("}") if last_complete > 0: text = text[: last_complete + 1] + "]" - # 6. 修复常见的JSON格式问题 - # 1. 修复缺失的逗号 + # 6. Sửa các lỗi định dạng JSON phổ biến. + # 1. Bổ sung dấu phẩy bị thiếu. text = re.sub(r"}\s*{", "}, {", text) - # 2. 确保字段名有引号(仅在对象开始或逗号后,避免破坏字符串值) + # 2. Đảm bảo tên trường có dấu ngoặc kép mà không phá giá trị chuỗi. def quote_field_names(match): prefix = match.group(1) key = match.group(2) return f'{prefix}"{key}":' - # 只在 { 或 , 后面匹配字段名,避免在字符串值中误匹配 + # Chỉ khớp tên trường sau { hoặc , để tránh khớp nhầm trong giá trị. text = re.sub(r"([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:", quote_field_names, text) - # 3. 移除多余的逗号 + # 3. Xoá dấu phẩy thừa. text = re.sub(r",\s*}", "}", text) text = re.sub(r",\s*]", "]", text) return text.strip() except Exception as e: - logger.error(f"JSON修复失败: {e}") + logger.error(f"Sửa JSON thất bại: {e}") return text def _parse_json_with_pattern( - result_text: str, pattern: str, data_type: str, expected_type_name: str = "数据" + result_text: str, pattern: str, data_type: str, expected_type_name: str = "dữ liệu" ) -> tuple[bool, Any, str | None]: """ - 通用内部 JSON 解析逻辑,包含提取、直接解析、修复后重试。 + Logic phân tích JSON nội bộ: trích xuất, parse trực tiếp và thử lại sau sửa. """ fixed_json_text = None try: - # 1. 基础清理:去除 markdown 代码块标记 + # 1. Làm sạch cơ bản: xoá marker code block Markdown. clean_text = result_text.strip() clean_text = re.sub(r"```(?:json)?\s*", "", clean_text) clean_text = re.sub(r"```\s*$", "", clean_text) - # 2. 提取 JSON 部分 + # 2. Trích xuất phần JSON. json_match = re.search(pattern, clean_text, re.DOTALL) if not json_match: - error_msg = f"{data_type}响应中未找到JSON{expected_type_name}" + error_msg = ( + f"Không tìm thấy JSON {expected_type_name} trong phản hồi {data_type}" + ) logger.warning(error_msg) return False, None, error_msg json_text = json_match.group() - logger.debug(f"{data_type}分析JSON原文: {json_text[:500]}...") + logger.debug(f"JSON gốc của phân tích {data_type}: {json_text[:500]}...") - # 3. 尝试直接解析 + # 3. Thử parse trực tiếp. try: data = json.loads(json_text) - count_info = f",包含 {len(data)} 条数据" if isinstance(data, list) else "" - logger.info(f"{data_type}直接解析成功{count_info}") + count_info = f", gồm {len(data)} mục" if isinstance(data, list) else "" + logger.info(f"Parse trực tiếp {data_type} thành công{count_info}") return True, data, None except json.JSONDecodeError: - logger.debug(f"{data_type}直接解析失败,尝试修复JSON...") + logger.debug(f"Parse trực tiếp {data_type} thất bại, thử sửa JSON...") - # 4. 修复后重试 + # 4. Thử lại sau khi sửa. fixed_json_text = fix_json(json_text) - # 修复后需要重新提取,因为 fix_json 可能会改变文本结构(例如补齐括号) + # Trích xuất lại vì fix_json có thể thay đổi cấu trúc văn bản. fixed_match = re.search(pattern, fixed_json_text, re.DOTALL) if fixed_match: try: data = json.loads(fixed_match.group()) - count_info = ( - f",包含 {len(data)} 条数据" if isinstance(data, list) else "" - ) - logger.info(f"{data_type}修复后解析成功{count_info}") + count_info = f", gồm {len(data)} mục" if isinstance(data, list) else "" + logger.info(f"Parse {data_type} thành công sau khi sửa{count_info}") return True, data, None except json.JSONDecodeError as e: - error_msg = f"{data_type}JSON修复后解析仍失败: {e}" + error_msg = f"Parse JSON {data_type} vẫn thất bại sau khi sửa: {e}" logger.warning(error_msg) return False, None, error_msg - error_msg = f"{data_type}修复后未找到JSON{expected_type_name}" + error_msg = ( + f"Không tìm thấy JSON {expected_type_name} của {data_type} sau khi sửa" + ) return False, None, error_msg except Exception as e: - error_msg = f"{data_type}解析异常: {e}" + error_msg = f"Lỗi parse {data_type}: {e}" logger.error(error_msg) return False, None, error_msg @@ -144,10 +143,10 @@ def parse_json_response( result_text: str, data_type: str ) -> tuple[bool, list[dict] | None, str | None]: """ - 统一的JSON解析方法(用于JSON数组响应) + Phương thức parse thống nhất cho phản hồi mảng JSON. """ return _parse_json_with_pattern( - result_text, r"\[.*\]", data_type, expected_type_name="数组" + result_text, r"\[.*\]", data_type, expected_type_name="mảng" ) @@ -155,39 +154,38 @@ def parse_json_object_response( result_text: str, data_type: str ) -> tuple[bool, dict | None, str | None]: """ - 统一的JSON解析方法(用于JSON对象响应) + Phương thức parse thống nhất cho phản hồi object JSON. """ return _parse_json_with_pattern( - result_text, r"\{.*\}", data_type, expected_type_name="对象" + result_text, r"\{.*\}", data_type, expected_type_name="đối tượng" ) def _clean_json_string(text: str) -> str: """ - 清理 JSON 字符串中的转义字符,用于正则提取后的数据清洗。 + Làm sạch ký tự escape trong chuỗi JSON sau khi trích xuất bằng regex. """ return text.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ") def extract_topics_with_regex(result_text: str, max_topics: int) -> list[dict]: """ - 使用正则表达式提取话题信息 + Trích xuất thông tin chủ đề bằng regex. Args: - result_text: 需要提取的文本 - max_topics: 最大话题数量 + result_text: Văn bản cần trích xuất. + max_topics: Số chủ đề tối đa. Returns: - 话题数据列表 + Danh sách dữ liệu chủ đề. """ try: - # 更强的正则表达式提取话题信息,处理转义字符 - # 匹配每个完整的话题对象 + # Regex mạnh hơn để xử lý ký tự escape và khớp object chủ đề hoàn chỉnh. topic_pattern = r'\{\s*"topic":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"contributors":\s*\[(.*?)\],?\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}' matches = re.findall(topic_pattern, result_text, re.DOTALL) if not matches: - # 尝试更宽松的匹配 + # Thử pattern linh hoạt hơn. topic_pattern = r'"topic":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"contributors":\s*\[(.*?)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"' matches = re.findall(topic_pattern, result_text, re.DOTALL) @@ -197,48 +195,50 @@ def extract_topics_with_regex(result_text: str, max_topics: int) -> list[dict]: contributors_str = match[1].strip() detail = _clean_json_string(match[2].strip()) - # 解析参与者列表 + # Parse danh sách người tham gia. contributors = [ contrib.strip() for contrib in re.findall(r'"([^"]+)"', contributors_str) - ] or ["群友"] + ] or ["Thành viên"] topics.append( { "topic": topic_name, - "contributors": contributors[:5], # 最多5个参与者 + "contributors": contributors[:5], # Tối đa 5 người tham gia "detail": detail, } ) - logger.info(f"话题正则表达式提取成功,提取到 {len(topics)} 条有效话题内容") + logger.info( + f"Trích xuất chủ đề bằng regex thành công: {len(topics)} chủ đề hợp lệ" + ) return topics except Exception as e: - logger.error(f"话题正则表达式提取失败: {e}") + logger.error(f"Trích xuất chủ đề bằng regex thất bại: {e}") return [] def extract_user_titles_with_regex(result_text: str, max_count: int) -> list[dict]: """ - 使用正则表达式提取用户称号信息 + Trích xuất thông tin danh hiệu bằng regex. Args: - result_text: 需要提取的文本 - max_count: 最大提取数量 + result_text: Văn bản cần trích xuất. + max_count: Số lượng tối đa. Returns: - 用户称号数据列表 + Danh sách dữ liệu danh hiệu. """ try: titles = [] - # 正则模式:匹配完整的用户称号对象 + # Pattern khớp object danh hiệu hoàn chỉnh. pattern = r'\{\s*"name":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"user_id":\s*"([^"]+)"\s*,\s*"title":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"mbti":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}' matches = re.findall(pattern, result_text, re.DOTALL) if not matches: - # 尝试更宽松的匹配(字段顺序可变) + # Thử pattern linh hoạt hơn, cho phép thứ tự trường thay đổi. pattern = r'"name":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"user_id":\s*"([^"]+)"[^}]*"title":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"mbti":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"' matches = re.findall(pattern, result_text, re.DOTALL) @@ -259,34 +259,36 @@ def extract_user_titles_with_regex(result_text: str, max_count: int) -> list[dic } ) - logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号") + logger.info( + f"Trích xuất danh hiệu bằng regex thành công: {len(titles)} danh hiệu hợp lệ" + ) return titles except Exception as e: - logger.error(f"用户称号正则表达式提取失败: {e}") + logger.error(f"Trích xuất danh hiệu bằng regex thất bại: {e}") return [] def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> list[dict]: """ - 使用正则表达式提取金句信息 + Trích xuất trích dẫn nổi bật bằng regex. Args: - result_text: 需要提取的文本 - max_count: 最大提取数量 + result_text: Văn bản cần trích xuất. + max_count: Số lượng tối đa. Returns: - 金句数据列表 + Danh sách dữ liệu trích dẫn. """ try: quotes = [] - # 正则模式:匹配完整的金句对象 + # Pattern khớp object trích dẫn hoàn chỉnh. pattern = r'\{\s*"content":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"sender":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}' matches = re.findall(pattern, result_text, re.DOTALL) if not matches: - # 尝试更宽松的匹配(字段顺序可变) + # Thử pattern linh hoạt hơn, cho phép thứ tự trường thay đổi. pattern = r'"content":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"sender":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"' matches = re.findall(pattern, result_text, re.DOTALL) @@ -297,25 +299,27 @@ def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> list[d quotes.append({"content": content, "sender": sender, "reason": reason}) - logger.info(f"金句正则表达式提取成功,提取到 {len(quotes)} 条有效金句") + logger.info( + f"Trích xuất trích dẫn bằng regex thành công: {len(quotes)} mục hợp lệ" + ) return quotes except Exception as e: - logger.error(f"金句正则表达式提取失败: {e}") + logger.error(f"Trích xuất trích dẫn bằng regex thất bại: {e}") return [] def extract_quality_with_regex(result_text: str) -> dict | None: """ - 使用正则表达式提取聊天质量分析数据 + Trích xuất dữ liệu chất lượng trò chuyện bằng regex. - 当 JSON 解析失败时作为降级方案使用。 + Dùng làm fallback khi parse JSON thất bại. Args: - result_text: LLM 返回的原始文本 + result_text: Văn bản gốc do LLM trả về. Returns: - 解析后的质量分析字典,失败返回 None + Dict chất lượng sau khi parse hoặc None nếu thất bại. """ try: title_m = re.search(r'"title"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', result_text) @@ -342,19 +346,25 @@ def extract_quality_with_regex(result_text: str) -> dict | None: ) if not dims: - logger.warning("聊天质量正则提取未找到有效维度数据") + logger.warning( + "Regex chất lượng trò chuyện không tìm thấy dữ liệu chiều hợp lệ" + ) return None data = { - "title": title_m.group(1) if title_m else "聊天质量锐评", - "subtitle": subtitle_m.group(1) if subtitle_m else "今天的群里发生了什么?", + "title": title_m.group(1) if title_m else "Đánh giá chất lượng trò chuyện", + "subtitle": subtitle_m.group(1) + if subtitle_m + else "Hôm nay nhóm đã có chuyện gì?", "dimensions": dims, - "summary": summary_m.group(1) if summary_m else "今天也是充满活力的一天。", + "summary": summary_m.group(1) + if summary_m + else "Hôm nay cũng là một ngày đầy năng lượng.", } - logger.info(f"聊天质量正则表达式提取成功,提取到 {len(dims)} 个维度") + logger.info(f"Trích xuất chất lượng bằng regex thành công: {len(dims)} chiều") return data except Exception as e: - logger.error(f"聊天质量正则表达式提取失败: {e}") + logger.error(f"Trích xuất chất lượng bằng regex thất bại: {e}") return None diff --git a/src/infrastructure/analysis/utils/llm_utils.py b/src/infrastructure/analysis/utils/llm_utils.py index 5e16d73..1ae229a 100644 --- a/src/infrastructure/analysis/utils/llm_utils.py +++ b/src/infrastructure/analysis/utils/llm_utils.py @@ -1,7 +1,4 @@ -""" -LLM API请求处理工具模块 -提供LLM调用和token统计功能 -""" +"""Công cụ gọi API LLM và thống kê token.""" import asyncio import random @@ -19,7 +16,7 @@ _circuit_breakers = {} def _is_response_format_unsupported_error(error: Exception) -> bool: """ - 判断是否为 Provider/网关不支持 response_format 的兼容性错误。 + Kiểm tra lỗi tương thích khi provider/gateway không hỗ trợ response_format. """ text = str(error).lower() patterns = [ @@ -46,7 +43,7 @@ async def _call_provider_stream( ) -> LLMResponse: provider = context.get_provider_by_id(provider_id=provider_id) if provider is None: - raise RuntimeError(f"Provider 不存在: {provider_id}") + raise RuntimeError(f"Provider không tồn tại: {provider_id}") stream_kwargs = dict(llm_kwargs) stream_kwargs.pop("chat_provider_id", None) @@ -61,7 +58,7 @@ async def _call_provider_stream( content_parts.append(text) if final_resp is None: - raise RuntimeError("流式 LLM 调用未返回任何响应") + raise RuntimeError("Lời gọi LLM streaming không trả về phản hồi") final_text = extract_response_text(final_resp) if final_text and not getattr(final_resp, "is_chunk", False): @@ -79,63 +76,63 @@ async def _try_get_provider_id_by_id( context, provider_id: str, description: str ) -> str | None: """ - 尝试通过 ID 获取 Provider ID 的辅助函数 + Thử xác thực và lấy Provider ID theo ID cấu hình. Args: - context: AstrBot上下文对象 + context: Context AstrBot. provider_id: Provider ID - description: 描述信息,用于日志 + description: Mô tả dùng trong log. Returns: - Provider ID 或 None + Provider ID hoặc None. """ if not provider_id or not isinstance(provider_id, str) or not provider_id.strip(): return None provider_id = provider_id.strip() - logger.info(f"尝试使用{description}: {provider_id}") + logger.info(f"Thử dùng {description}: {provider_id}") try: - # 验证 Provider 是否存在 + # Xác thực provider tồn tại. provider = context.get_provider_by_id(provider_id=provider_id) if provider: - logger.info(f"✓ 使用{description}: {provider_id}") + logger.info(f"✓ Dùng {description}: {provider_id}") return provider_id except Exception as e: - logger.warning(f"无法找到{description} '{provider_id}': {e}") + logger.warning(f"Không tìm thấy {description} '{provider_id}': {e}") return None async def _try_get_session_provider_id(context, umo: str | None) -> str | None: """ - 尝试获取会话 Provider ID 的辅助函数 + Thử lấy Provider ID của phiên hiện tại. Args: - context: AstrBot上下文对象 + context: Context AstrBot. umo: unified_msg_origin Returns: - Provider ID 或 None + Provider ID hoặc None. """ try: - # 使用新 API 获取当前会话的 Provider ID + # Dùng API mới để lấy Provider ID của phiên hiện tại. provider_id = await context.get_current_chat_provider_id(umo=umo) if provider_id: - logger.info(f"✓ 使用当前会话的 Provider: {provider_id}") + logger.info(f"✓ Dùng provider của phiên hiện tại: {provider_id}") return provider_id except Exception as e: - logger.warning(f"无法获取会话 Provider ID: {e}") + logger.warning(f"Không thể lấy Provider ID của phiên: {e}") return None async def _try_get_first_available_provider_id(context) -> str | None: """ - 尝试获取第一个可用 Provider ID 的辅助函数 + Thử lấy Provider ID khả dụng đầu tiên. Args: - context: AstrBot上下文对象 + context: Context AstrBot. Returns: - Provider ID 或 None + Provider ID hoặc None. """ try: all_providers = context.get_all_providers() @@ -144,12 +141,12 @@ async def _try_get_first_available_provider_id(context) -> str | None: try: meta = provider.meta() provider_id = meta.id - logger.info(f"✓ 使用第一个可用 Provider: {provider_id}") + logger.info(f"✓ Dùng provider khả dụng đầu tiên: {provider_id}") return provider_id except Exception: - logger.warning("第一个 Provider 无法获取 ID") + logger.warning("Không thể lấy ID của provider đầu tiên") except Exception as e: - logger.warning(f"无法获取任何 Provider: {e}") + logger.warning(f"Không thể lấy provider nào: {e}") return None @@ -160,33 +157,30 @@ async def get_provider_id_with_fallback( umo: str | None = None, ) -> str | None: """ - 根据配置键获取 Provider ID,支持多级回退 + Lấy Provider ID theo key cấu hình với fallback nhiều cấp. - 回退顺序: - 1. 尝试从配置获取指定的 provider_id(如 topic_provider_id) - 2. 回退到主 LLM provider_id(llm_provider_id) - 3. 回退到当前会话的 Provider(通过 umo) - 4. 回退到第一个可用的 Provider + Thứ tự fallback: provider riêng của tác vụ, provider LLM chính, provider + của phiên hiện tại, rồi provider khả dụng đầu tiên. Args: - context: AstrBot上下文对象 - config_manager: 配置管理器 - provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id') - umo: unified_msg_origin,用于获取会话默认 Provider + context: Context AstrBot. + config_manager: Trình quản lý cấu hình. + provider_id_key: Key provider_id trong cấu hình. + umo: unified_msg_origin để lấy provider mặc định của phiên. Returns: - Provider ID 或 None + Provider ID hoặc None. """ try: - # 输出Provider选择开始日志 - task_desc = provider_id_key if provider_id_key else "默认任务" - logger.info(f"[Provider 选择] 开始为 {task_desc} 选择 Provider...") + # Ghi log bắt đầu chọn provider. + task_desc = provider_id_key if provider_id_key else "tác vụ mặc định" + logger.info(f"[Chọn provider] Bắt đầu chọn provider cho {task_desc}...") - # 定义回退策略列表 + # Định nghĩa danh sách chiến lược fallback. strategies = [] strategy_names = [] - # 1. 特定任务的 provider_id + # 1. provider_id riêng của tác vụ. if provider_id_key: getter_method = f"get_{provider_id_key}" if hasattr(config_manager, getter_method): @@ -194,46 +188,48 @@ async def get_provider_id_with_fallback( if specific_provider_id: strategies.append( lambda pid=specific_provider_id: _try_get_provider_id_by_id( - context, pid, f"配置的 {provider_id_key}" + context, pid, f"{provider_id_key} đã cấu hình" ) ) - strategy_names.append(f"1. 配置的 {provider_id_key}") + strategy_names.append(f"1. {provider_id_key} đã cấu hình") - # 2. 主 LLM provider_id + # 2. provider_id LLM chính. main_provider_id = config_manager.get_llm_provider_id() if main_provider_id: strategies.append( lambda pid=main_provider_id: _try_get_provider_id_by_id( - context, pid, "主 LLM Provider" + context, pid, "provider LLM chính" ) ) - strategy_names.append("2. 主 LLM Provider") + strategy_names.append("2. Provider LLM chính") - # 3. 当前会话的 Provider + # 3. Provider của phiên hiện tại. strategies.append(lambda: _try_get_session_provider_id(context, umo)) - strategy_names.append("3. 当前会话 Provider") + strategy_names.append("3. Provider phiên hiện tại") - # 4. 第一个可用的 Provider + # 4. Provider khả dụng đầu tiên. strategies.append(lambda: _try_get_first_available_provider_id(context)) - strategy_names.append("4. 第一个可用 Provider") + strategy_names.append("4. Provider khả dụng đầu tiên") - # 输出回退策略列表 - logger.info(f"[Provider 选择] 回退策略顺序:{' -> '.join(strategy_names)}") + # Ghi thứ tự chiến lược fallback. + logger.info(f"[Chọn provider] Thứ tự fallback: {' -> '.join(strategy_names)}") - # 依次尝试每个策略 + # Thử lần lượt từng chiến lược. for idx, strategy in enumerate(strategies): provider_id = await strategy() if provider_id: logger.info( - f"[Provider 选择] ✓ 成功!使用策略 #{idx + 1},Provider ID: {provider_id}" + f"[Chọn provider] ✓ Thành công với chiến lược #{idx + 1}, Provider ID: {provider_id}" ) return provider_id - logger.error("[Provider 选择] ✗ 失败:所有回退策略均无法获取可用 Provider") + logger.error( + "[Chọn provider] ✗ Thất bại: không chiến lược nào trả về provider khả dụng" + ) return None except Exception as e: - logger.error(f"[Provider 选择] ✗ 异常:Provider 选择过程出错: {e}") + logger.error(f"[Chọn provider] ✗ Lỗi trong quá trình chọn provider: {e}") return None @@ -249,31 +245,30 @@ async def call_provider_with_retry( extra_generate_kwargs: dict[str, JSONValue] | None = None, ) -> LLMResponse | None: """ - 调用LLM提供者,带超时、重试与退避。支持自定义服务商和配置化 Provider 选择。 + Gọi provider LLM với retry và backoff, hỗ trợ chọn provider theo cấu hình. Args: - context: AstrBot上下文对象 - config_manager: 配置管理器 - prompt: 输入的提示语 - umo: 指定使用的模型唯一标识符 - provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id'),用于选择特定的 Provider - system_prompt: 系统提示词 - response_format: 结构化输出约束(OpenAI 风格) - extra_generate_kwargs: 传递给 context.llm_generate 的附加参数(用于内部高级重试策略) + context: Context AstrBot. + config_manager: Trình quản lý cấu hình. + prompt: Prompt đầu vào. + umo: Định danh model cần dùng. + provider_id_key: Key provider_id để chọn provider riêng. + system_prompt: System prompt. + response_format: Ràng buộc output có cấu trúc kiểu OpenAI. + extra_generate_kwargs: Tham số bổ sung cho context.llm_generate. Returns: - LLM生成的结果,失败时返回None + Kết quả LLM hoặc None nếu thất bại. """ - # 注意: 超时由 AstrBot Provider 内部配置控制,不再使用插件层 asyncio.wait_for - # 用户可在 AstrBot WebUI 中为每个 Provider 配置 timeout 参数 + # Timeout do provider AstrBot kiểm soát và có thể cấu hình trong WebUI. retries = config_manager.get_llm_retries() backoff = config_manager.get_llm_backoff() enable_streaming_llm_call = config_manager.get_enable_streaming_llm_call() - # 1. 确定我们要尝试的 Provider 队列 + # 1. Xác định hàng đợi provider cần thử. attempt_queue = [] - # 尝试获取指定的 Provider + # Thử lấy provider được chỉ định. specific_provider_id = provider_id if not specific_provider_id: specific_provider_id = await get_provider_id_with_fallback( @@ -283,16 +278,18 @@ async def call_provider_with_retry( attempt_queue.extend([(specific_provider_id, False)] * retries) if not attempt_queue: - logger.error("无可用 Provider,无法调用 llm_generate") + logger.error("Không có provider khả dụng để gọi llm_generate") return None - # 2. 核心请求执行闭包 + # 2. Closure thực thi yêu cầu cốt lõi. async def _execute_llm_request( pid: str, r_format: JSONObject | None ) -> LLMResponse: cb = _get_circuit_breaker(pid) if not cb.allow_request(): - logger.warning(f"Provider {pid} 熔断器已打开,跳过本次请求") + logger.warning( + f"Circuit breaker của provider {pid} đang mở, bỏ qua yêu cầu" + ) raise Exception("Circuit breaker open") try: @@ -320,31 +317,31 @@ async def call_provider_with_retry( cb.record_failure() raise err - # 3. 开始执行队列 + # 3. Bắt đầu xử lý hàng đợi. last_exc = None current_response_format = response_format - # 记录上一次尝试的 Provider ID,用于判断是否发生切换 + # Lưu Provider ID trước để phát hiện chuyển provider. previous_pid = None - # 惰性降级标记:仅在 primary provider 重试用尽后才 resolve fallback + # Chỉ resolve fallback sau khi hết lượt retry provider chính. needs_fallback = provider_id_key is not None for i, (current_pid, is_fallback) in enumerate(attempt_queue): attempt_num = i + 1 - # 修复状态污染:如果切换了全新的 Provider,必须重置 response_format 约束 + # Reset response_format khi chuyển sang provider mới. if current_pid != previous_pid: current_response_format = response_format previous_pid = current_pid - prefix = "[降级补偿] " if is_fallback else "[LLM 调用] " + prefix = "[Fallback] " if is_fallback else "[Gọi LLM] " logger.info( - f"{prefix}尝试 #{attempt_num} | Provider ID: {current_pid} | " - f"prompt长度={len(prompt) if prompt else 0}字符" + f"{prefix}Lần thử #{attempt_num} | Provider ID: {current_pid} | " + f"độ dài prompt={len(prompt) if prompt else 0} ký tự" ) if not prompt or not prompt.strip(): - logger.error("LLM provider: prompt 为空,无法调用") + logger.error("LLM provider: prompt rỗng, không thể gọi") return None try: @@ -353,16 +350,16 @@ async def call_provider_with_retry( except Exception as e: last_exc = e - # 处理不支持 response_format 的情况 + # Xử lý provider không hỗ trợ response_format. if ( current_response_format is not None and _is_response_format_unsupported_error(e) ): logger.warning( - f"{prefix}当前 Provider 可能不支持 response_format,已自动降级为无 schema 约束。" + f"{prefix}Provider hiện tại có thể không hỗ trợ response_format; tự chuyển sang không ràng buộc schema." ) current_response_format = None - # 在当前尝试额度内立即再试一次剥离了 schema 的请求 + # Thử lại ngay yêu cầu không có schema trong lượt hiện tại. try: return await _execute_llm_request( current_pid, current_response_format @@ -370,8 +367,8 @@ async def call_provider_with_retry( except Exception as inner_e: last_exc = inner_e - logger.warning(f"{prefix}请求失败: {last_exc}") - # 惰性降级:仅当所有 primary provider 的重试都耗尽后才 resolve 并注入 fallback + logger.warning(f"{prefix}Yêu cầu thất bại: {last_exc}") + # Chỉ resolve và thêm fallback khi hết retry provider chính. if not is_fallback and i == retries - 1 and needs_fallback: fallback_provider_id = await get_provider_id_with_fallback( context, config_manager, None, umo @@ -387,46 +384,46 @@ async def call_provider_with_retry( if not is_last_attempt: # Exponential backoff with jitter: backoff * (2 ^ (attempt_num - 1)) + random jitter sleep_time = backoff * (2 ** (attempt_num - 1)) + random.uniform(0, 1) - logger.debug(f"等待 {sleep_time:.2f} 秒后重试...") + logger.debug(f"Chờ {sleep_time:.2f} giây trước khi thử lại...") await asyncio.sleep(sleep_time) - logger.error(f"LLM请求队列全部耗尽,最终失败: {last_exc}") + logger.error(f"Đã dùng hết hàng đợi yêu cầu LLM, lỗi cuối: {last_exc}") return None def extract_token_usage(response) -> dict: """ - 从LLM响应中提取token使用统计 + Trích xuất thống kê token từ phản hồi LLM. Args: - response: LLM响应对象 + response: Đối tượng phản hồi LLM. Returns: - Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens + Dict gồm prompt_tokens, completion_tokens và total_tokens. """ token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} try: - # 1. 尝试直接获取 response.usage + # 1. Thử lấy trực tiếp response.usage. usage = getattr(response, "usage", None) - # 2. 尝试从 response.raw_completion.usage 获取 (兼容旧版) + # 2. Thử response.raw_completion.usage để tương thích bản cũ. if not usage and hasattr(response, "raw_completion"): usage = getattr(response.raw_completion, "usage", None) - # 3. 如果 response 本身就是 dict (某些特殊情况) + # 3. Xử lý trường hợp response là dict. if not usage and isinstance(response, dict): usage = response.get("usage") if usage: - # 优先检查 AstrBot 的 TokenUsage 对象字段 (input, output, total) + # Ưu tiên các trường của TokenUsage AstrBot. # AstrBot TokenUsage define: input (prop), output (attr), total (prop) if hasattr(usage, "input") and hasattr(usage, "output"): token_usage["prompt_tokens"] = getattr(usage, "input", 0) or 0 token_usage["completion_tokens"] = getattr(usage, "output", 0) or 0 token_usage["total_tokens"] = getattr(usage, "total", 0) or 0 - # 处理 usage 是字典的情况 + # Xử lý usage dạng dict. elif isinstance(usage, dict): token_usage["prompt_tokens"] = usage.get("prompt_tokens", 0) or 0 token_usage["completion_tokens"] = ( @@ -434,7 +431,7 @@ def extract_token_usage(response) -> dict: ) token_usage["total_tokens"] = usage.get("total_tokens", 0) or 0 - # 处理 OpenAI CompletionUsage 等标准对象 + # Xử lý object chuẩn như OpenAI CompletionUsage. else: token_usage["prompt_tokens"] = getattr(usage, "prompt_tokens", 0) or 0 token_usage["completion_tokens"] = ( @@ -445,19 +442,19 @@ def extract_token_usage(response) -> dict: return token_usage except Exception as e: - logger.error(f"提取token使用统计失败: {e}") + logger.error(f"Trích xuất thống kê token thất bại: {e}") return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} def extract_response_text(response) -> str: """ - 从LLM响应中提取文本内容 + Trích xuất nội dung văn bản từ phản hồi LLM. Args: - response: LLM响应对象 + response: Đối tượng phản hồi LLM. Returns: - 响应文本内容 + Nội dung văn bản phản hồi. """ try: if hasattr(response, "completion_text"): @@ -465,5 +462,5 @@ def extract_response_text(response) -> str: else: return str(response) except Exception as e: - logger.error(f"提取响应文本失败: {e}") + logger.error(f"Trích xuất văn bản phản hồi thất bại: {e}") return "" diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 1116197..da52c42 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -1,7 +1,4 @@ -""" -配置管理模块 - 基础设施层 -负责处理插件配置 -""" +"""Trình quản lý cấu hình plugin ở tầng infrastructure.""" from astrbot.api import AstrBotConfig from astrbot.api.star import StarTools @@ -12,43 +9,38 @@ from ..utils.template_utils import upgrade_str_format_template class ConfigManager: - """配置管理器 + """Trình quản lý cấu hình. - 配置结构采用分组嵌套方式,顶层分为以下分组: - - basic: 基础设置 - - qq_official: QQ 官方机器人展示设置 - - auto_analysis: 自动分析设置 - - llm: LLM 设置 - - analysis_features: 分析功能开关 - - incremental: 增量分析设置 - - prompts: 提示词模板 + Cấu hình được nhóm lồng nhau ở cấp cao nhất: ``basic``, ``qq_official``, + ``auto_analysis``, ``llm``, ``analysis_features``, ``incremental`` và ``prompts``. """ def __init__(self, config: AstrBotConfig): self.config = config def _get_group(self, group: str) -> dict: - """获取指定分组的配置字典,不存在时返回空字典""" + """Lấy dict cấu hình của nhóm hoặc dict rỗng nếu không tồn tại.""" return self.config.get(group, {}) def _ensure_group(self, group: str) -> dict: - """确保指定分组存在并返回其字典引用""" + """Đảm bảo nhóm tồn tại và trả về tham chiếu dict.""" if group not in self.config: self.config[group] = {} return self.config[group] def get_group_list_mode(self) -> str: - """获取群组列表模式 (whitelist/blacklist/none)""" + """Lấy chế độ danh sách nhóm: whitelist, blacklist hoặc none.""" return self._get_group("basic").get("group_list_mode", "none") def get_group_list(self) -> list[str]: - """获取群组列表(用于黑白名单)""" + """Lấy danh sách nhóm dùng cho whitelist/blacklist.""" return self._get_group("basic").get("group_list", []) def is_group_allowed(self, group_id_or_umo: str) -> bool: """ - 根据配置的白/黑名单判断是否允许在该群聊中使用 - 支持传入 simple group_id 或 UMO (Unified Message Origin) + Kiểm tra nhóm có được phép theo whitelist/blacklist. + + Hỗ trợ group_id đơn giản hoặc UMO (Unified Message Origin). """ mode = self.get_group_list_mode().lower() if mode not in ("whitelist", "blacklist", "none"): @@ -71,19 +63,19 @@ class ConfigManager: def _is_group_match(self, target: str, item: str) -> bool: """ - 核心匹配逻辑:判断名单中的 item 是否匹配目标的 target (Unified Message Origin, UMO 或 纯 ID)。 - 支持处理 Telegram 话题 (#) 和 独立隔离会话 (_) 的双向穿透匹配。 + So khớp item danh sách với UMO hoặc ID đích, hỗ trợ topic Telegram (#) + và phiên cô lập (_) theo cả hai chiều. """ if item == target: return True - # 分解目标 UMO 的前缀和 ID 部分 (如 default:GroupMessage:ID) + # Tách tiền tố UMO và phần ID, ví dụ default:GroupMessage:ID. if ":" in target: target_prefix, target_id = target.rsplit(":", 1) else: target_prefix, target_id = "", target - # 生成目标 ID 的所有“穿透”候选 (处理隔离模式和话题) + # Tạo mọi ứng viên ID cho chế độ cô lập và topic. candidates = {target_id} if "#" in target_id: candidates.add(target_id.split("#", 1)[0]) @@ -91,16 +83,16 @@ class ConfigManager: for part in target_id.split("_"): candidates.add(part) - # 检查名单项 (item) 的格式 + # Kiểm tra định dạng item trong danh sách. if ":" in item: i_prefix, i_id = item.rsplit(":", 1) - # 名单项带前缀时,前缀必须匹配 (如果 target 本身没前缀,则允许作为跨平台通用 ID 匹配) + # Nếu item có tiền tố thì phải khớp, trừ target không có tiền tố. if target_prefix and i_prefix != target_prefix: return False else: i_id = item - # [修复] 名单项 ID 也可能包含复合形式 (如 UserId_GroupId),需要拆解匹配 + # ID trong danh sách có thể ở dạng ghép như UserId_GroupId. item_variants = {i_id} if "#" in i_id: item_variants.add(i_id.split("#", 1)[0]) @@ -108,120 +100,119 @@ class ConfigManager: for part in i_id.split("_"): item_variants.add(part) - # 只要两边的 ID “核心部分”存在交集,即视为匹配成功 + # Khớp nếu hai tập phần ID cốt lõi có giao nhau. return not item_variants.isdisjoint(candidates) def get_max_messages(self) -> int: - """获取最大消息数量""" + """Lấy số tin nhắn tối đa.""" return self._get_group("basic").get("max_messages", 1000) def get_analysis_days(self) -> int: - """获取分析天数""" + """Lấy số ngày phân tích.""" return self._get_group("basic").get("analysis_days", 1) def get_auto_analysis_time(self) -> list[str]: - """获取自动分析时间列表""" + """Lấy danh sách thời điểm phân tích tự động.""" group = self._get_group("auto_analysis") val = group.get("auto_analysis_time", ["09:00"]) - # 兼容旧版本字符串配置 + # Tương thích cấu hình chuỗi của bản cũ. if isinstance(val, str): val_list = [val] - # 自动修复配置格式 + # Tự sửa định dạng cấu hình. try: auto_group = self._ensure_group("auto_analysis") auto_group["auto_analysis_time"] = val_list self.config.save_config() - logger.info(f"自动修复配置格式 auto_analysis_time: {val} -> {val_list}") + logger.info(f"Tự sửa định dạng auto_analysis_time: {val} -> {val_list}") except Exception as e: - logger.warning(f"修复配置格式失败: {e}") + logger.warning(f"Sửa định dạng cấu hình thất bại: {e}") return val_list return val if isinstance(val, list) else ["09:00"] def get_enable_auto_analysis(self) -> bool: """ - 获取是否启用自动分析(兼容旧接口)。 + Kiểm tra phân tích tự động có bật hay không, tương thích giao diện cũ. - 旧版本使用 auto_analysis.enable_auto_analysis 布尔值; - 新版本改为由 scheduled_group_list_mode + scheduled_group_list 推导。 + Bản cũ dùng boolean ``enable_auto_analysis``; bản mới suy ra từ danh sách lịch. """ return self.is_auto_analysis_enabled() def get_output_format(self) -> list[str]: - """获取输出格式""" + """Lấy định dạng output.""" val = self._get_group("basic").get("output_format", ["image"]) return val if isinstance(val, list) else [val] def get_qq_official_t2i_summary_dashboard_enabled(self) -> bool: - """是否启用 QQ 官方 T2I 概览图。""" + """Kiểm tra dashboard tổng quan T2I QQ Official có bật hay không.""" group = self._get_group("qq_official") if "enable_t2i_summary_dashboard" in group: return bool(group["enable_t2i_summary_dashboard"]) return bool(group.get("enable_t2i_activity_histogram", True)) def get_min_messages_threshold(self) -> int: - """获取最小消息阈值""" + """Lấy ngưỡng tin nhắn tối thiểu.""" return self._get_group("basic").get("min_messages_threshold", 50) def get_topic_analysis_enabled(self) -> bool: - """获取是否启用话题分析""" + """Kiểm tra phân tích chủ đề có bật hay không.""" return self._get_group("analysis_features").get("topic_analysis_enabled", True) def get_user_title_analysis_enabled(self) -> bool: - """获取是否启用用户称号分析""" + """Kiểm tra phân tích danh hiệu có bật hay không.""" return self._get_group("analysis_features").get( "user_title_analysis_enabled", True ) def get_golden_quote_analysis_enabled(self) -> bool: - """获取是否启用金句分析""" + """Kiểm tra phân tích trích dẫn có bật hay không.""" return self._get_group("analysis_features").get( "golden_quote_analysis_enabled", True ) def get_chat_quality_analysis_enabled(self) -> bool: - """获取是否启用聊天质量分析""" + """Kiểm tra phân tích chất lượng có bật hay không.""" return self._get_group("analysis_features").get( "chat_quality_analysis_enabled", False ) def get_max_topics(self) -> int: - """获取最大话题数量""" + """Lấy số chủ đề tối đa.""" return self._get_group("analysis_features").get("max_topics", 5) def get_max_user_titles(self) -> int: - """获取最大用户称号数量""" + """Lấy số danh hiệu tối đa.""" return self._get_group("analysis_features").get("max_user_titles", 8) def get_max_golden_quotes(self) -> int: - """获取最大金句数量""" + """Lấy số trích dẫn tối đa.""" return self._get_group("analysis_features").get("max_golden_quotes", 5) def get_llm_retries(self) -> int: - """获取LLM请求重试次数""" + """Lấy số lần retry yêu cầu LLM.""" return self._get_group("llm").get("llm_retries", 2) def get_llm_backoff(self) -> int: - """获取LLM请求重试退避基值(秒),实际退避会乘以尝试次数""" + """Lấy giá trị backoff cơ sở của retry LLM, tính bằng giây.""" return self._get_group("llm").get("llm_backoff", 2) def get_enable_streaming_llm_call(self) -> bool: - """获取是否启用流式 LLM 调用""" + """Kiểm tra lời gọi LLM streaming có bật hay không.""" return self._get_group("llm").get("enable_streaming_llm_call", False) def get_debug_mode(self) -> bool: - """获取是否启用调试模式""" + """Kiểm tra debug mode có bật hay không.""" return self._get_group("basic").get("debug_mode", False) def get_enable_base64_image(self) -> bool: - """获取是否启用 Base64 图片传输""" + """Kiểm tra truyền ảnh Base64 có bật hay không.""" return self._get_group("basic").get("enable_base64_image", False) def get_t2i_rendering_strategies(self) -> list[dict]: - """获取用户配置的两轮 T2I 渲染策略""" + """Lấy hai chiến lược render T2I do người dùng cấu hình.""" group = self._get_group("t2i_rendering") return [ - # 第一轮:质量优先 + # Lượt đầu: ưu tiên chất lượng. { "full_page": True, "type": group.get("t2i_r1_type", "png"), @@ -229,7 +220,7 @@ class ConfigManager: "device_scale_factor_level": group.get("t2i_r1_device_scale", "ultra"), "timeout": group.get("t2i_r1_timeout", 30000), }, - # 第二轮:稳定性/回退优先 + # Lượt hai: ưu tiên ổn định và fallback. { "full_page": True, "type": group.get("t2i_r2_type", "jpeg"), @@ -240,11 +231,11 @@ class ConfigManager: ] def get_t2i_font_source(self) -> str: - """获取 T2I 字体源 (Mainland/Overseas)""" + """Lấy nguồn font T2I: Mainland hoặc Overseas.""" return self._get_group("t2i_rendering").get("t2i_font_source", "Overseas") def get_t2i_google_fonts_mirror(self) -> str: - """根据环境选择获取 Google Fonts 镜像地址""" + """Lấy URL mirror Google Fonts theo môi trường.""" source = self.get_t2i_font_source() group = self._get_group("t2i_rendering") if source == "Mainland": @@ -252,7 +243,7 @@ class ConfigManager: return group.get("t2i_overseas_google_fonts", "https://fonts.googleapis.com") def get_t2i_gstatic_mirror(self) -> str: - """根据环境选择获取 Gstatic 镜像地址""" + """Lấy URL mirror Gstatic theo môi trường.""" source = self.get_t2i_font_source() group = self._get_group("t2i_rendering") if source == "Mainland": @@ -260,45 +251,45 @@ class ConfigManager: return group.get("t2i_overseas_gstatic", "https://fonts.gstatic.com") def get_t2i_atri_font_mirror(self) -> str: - """获取 ATRI 主题字体镜像地址 (目前保持不变,如有需要可后续添加 Mainland/Overseas 配置)""" + """Lấy URL mirror font theme ATRI.""" return self._get_group("t2i_rendering").get( "t2i_atri_font_mirror", "https://tc.ciallo.ccwu.cc" ) def get_llm_provider_id(self) -> str: - """获取主 LLM Provider ID""" + """Lấy Provider ID LLM chính.""" return self._get_group("llm").get("llm_provider_id", "") def get_topic_provider_id(self) -> str: - """获取话题分析专用 Provider ID""" + """Lấy Provider ID riêng cho phân tích chủ đề.""" return self._get_group("llm").get("topic_provider_id", "") def get_user_title_provider_id(self) -> str: - """获取用户称号分析专用 Provider ID""" + """Lấy Provider ID riêng cho phân tích danh hiệu.""" return self._get_group("llm").get("user_title_provider_id", "") def get_golden_quote_provider_id(self) -> str: - """获取金句分析专用 Provider ID""" + """Lấy Provider ID riêng cho phân tích trích dẫn.""" return self._get_group("llm").get("golden_quote_provider_id", "") def get_keep_original_persona(self) -> bool: - """获取是否继承会话原始人格设定""" + """Kiểm tra có kế thừa persona gốc của phiên hay không.""" return self._get_group("analysis_features").get("keep_original_persona", False) def get_use_plugin_specific_persona(self) -> bool: - """获取是否强制使用插件指定的人格设定""" + """Kiểm tra có bắt buộc dùng persona do plugin chỉ định hay không.""" return self._get_group("analysis_features").get( "use_plugin_specific_persona", False ) def get_plugin_specific_persona_id(self) -> str: - """获取插件指定的全局人格 ID (通过 select_persona 接口选择)""" + """Lấy ID persona toàn cục do plugin chỉ định.""" return self._get_group("analysis_features").get( "plugin_specific_persona_id", "" ) def get_bot_self_ids(self) -> list: - """获取机器人自身的 ID 列表 (兼容 bot_qq_ids)""" + """Lấy danh sách ID của bot, tương thích bot_qq_ids.""" basic = self._get_group("basic") ids = basic.get("bot_self_ids", []) if not ids: @@ -306,42 +297,42 @@ class ConfigManager: return ids def get_filter_bot_messages(self) -> bool: - """获取是否过滤机器人自己的消息。""" + """Kiểm tra có lọc tin nhắn của bot hay không.""" return self._get_group("basic").get("filter_bot_messages", True) def set_filter_bot_messages(self, enabled: bool): - """设置是否过滤机器人自己的消息。""" + """Thiết lập lọc tin nhắn của bot.""" self._ensure_group("basic")["filter_bot_messages"] = enabled self.config.save_config() def get_html_output_dir(self) -> str: - """获取HTML输出目录""" + """Lấy thư mục output HTML.""" default_path = StarTools.get_data_dir(PLUGIN_NAME) / "self_hosted_html_reports" val = self._get_group("html").get("html_output_dir") return val if val else str(default_path) def get_html_base_url(self) -> str: - """获取HTML外链Base URL""" + """Lấy base URL liên kết HTML.""" return self._get_group("html").get("html_base_url", "") def get_html_only_url(self) -> bool: - """获取是否仅输出外链而不发送文件本体""" + """Kiểm tra có chỉ gửi liên kết thay vì tệp HTML hay không.""" return self._get_group("html").get("html_only_url", False) def set_html_only_url(self, enabled: bool): - """设置是否仅输出外链而不发送文件本体""" + """Thiết lập chỉ gửi liên kết thay vì tệp HTML.""" self._ensure_group("html")["html_only_url"] = enabled self.config.save_config() def get_html_filename_format(self) -> str: - """获取HTML文件名格式""" + """Lấy định dạng tên tệp HTML.""" return self._get_group("html").get( - "html_filename_format", "群聊分析报告_{group_id}_{date}.html" + "html_filename_format", "bao_cao_phan_tich_nhom_{group_id}_{date}.html" ) def get_topic_analysis_prompt(self, style: str = "topic_prompt") -> str: - """获取话题分析提示词模板""" + """Lấy template prompt phân tích chủ đề.""" prompts_config = self._get_group("prompts").get("topic_analysis_prompts", {}) prompt = prompts_config.get(style, "") if prompt: @@ -349,7 +340,7 @@ class ConfigManager: return "" def get_user_title_analysis_prompt(self, style: str = "user_title_prompt") -> str: - """获取用户称号分析提示词模板""" + """Lấy template prompt phân tích danh hiệu.""" prompts_config = self._get_group("prompts").get( "user_title_analysis_prompts", {} ) @@ -361,7 +352,7 @@ class ConfigManager: def get_golden_quote_analysis_prompt( self, style: str = "golden_quote_v2_prompt" ) -> str: - """获取金句分析提示词模板""" + """Lấy template prompt phân tích trích dẫn.""" prompts_config = self._get_group("prompts").get( "golden_quote_analysis_prompts", {} ) @@ -371,7 +362,7 @@ class ConfigManager: return "" def get_quality_analysis_prompt(self, style: str = "quality_v2_prompt") -> str: - """获取聊天质量分析提示词模板""" + """Lấy template prompt phân tích chất lượng.""" prompts_config = self._get_group("prompts").get("quality_analysis_prompts", {}) prompt = prompts_config.get(style, "") if prompt: @@ -379,7 +370,7 @@ class ConfigManager: return "" def set_quality_analysis_prompt(self, prompt: str): - """设置聊天质量分析提示词模板""" + """Thiết lập template prompt phân tích chất lượng.""" prompts = self._ensure_group("prompts") if "quality_analysis_prompts" not in prompts: prompts["quality_analysis_prompts"] = {} @@ -387,8 +378,8 @@ class ConfigManager: self.config.save_config() def _upgrade_config_item(self, group: str, key: str, setter_func): - """升级指定配置项的值(从 str.format -> string.Template),并回写。""" - # 如果是 prompts,则先取 prompts 分组,再取子分组 (group) + """Nâng cấp mục cấu hình từ str.format sang string.Template và ghi lại.""" + # Với prompt, lấy nhóm prompts rồi lấy nhóm con. if group in ( "quality_analysis_prompts", "topic_analysis_prompts", @@ -407,15 +398,15 @@ class ConfigManager: if upgraded and upgraded_val != val: setter_func(upgraded_val) logger.info( - f"配置项 {group}.{key} 发现旧版语法并已自动升级为 string.Template 格式。" + f"Phát hiện cú pháp cũ ở {group}.{key}; đã tự nâng cấp sang string.Template" ) return True return False def upgrade_prompt_templates(self): - """启动时调用,扫描并升级所有可配置的模板(含 prompt 和文件名)。""" + """Quét và nâng cấp mọi template cấu hình khi khởi động.""" modified = False - # 1. 提示词模板升级 + # 1. Nâng cấp template prompt. modified |= self._upgrade_config_item( "quality_analysis_prompts", "quality_v2_prompt", @@ -442,7 +433,7 @@ class ConfigManager: self.set_golden_quote_analysis_prompt, ) - # 2. 文件名格式升级 + # 2. Nâng cấp định dạng tên tệp. modified |= self._upgrade_config_item( "html", "html_filename_format", @@ -451,12 +442,12 @@ class ConfigManager: if modified: logger.info( - "已完成所有配置模板从 str.format 到 string.Template 的安全迁移。(已自动回写配置)" + "Đã di chuyển an toàn mọi template cấu hình từ str.format sang string.Template và ghi lại cấu hình" ) return modified def get_quality_summary_prompt(self, style: str = "quality_summary_prompt") -> str: - """获取聊天质量汇总分析提示词模板""" + """Lấy template prompt tổng hợp chất lượng.""" prompts_config = self._get_group("prompts").get("quality_analysis_prompts", {}) prompt = prompts_config.get(style, "") if prompt: @@ -464,7 +455,7 @@ class ConfigManager: return "" def set_topic_analysis_prompt(self, prompt: str): - """设置话题分析提示词模板""" + """Thiết lập template prompt phân tích chủ đề.""" prompts = self._ensure_group("prompts") if "topic_analysis_prompts" not in prompts: prompts["topic_analysis_prompts"] = {} @@ -472,7 +463,7 @@ class ConfigManager: self.config.save_config() def set_quality_summary_prompt(self, prompt: str): - """设置聊天质量汇总分析提示词模板""" + """Thiết lập template prompt tổng hợp chất lượng.""" prompts = self._ensure_group("prompts") if "quality_analysis_prompts" not in prompts: prompts["quality_analysis_prompts"] = {} @@ -480,7 +471,7 @@ class ConfigManager: self.config.save_config() def set_user_title_analysis_prompt(self, prompt: str): - """设置用户称号分析提示词模板""" + """Thiết lập template prompt phân tích danh hiệu.""" prompts = self._ensure_group("prompts") if "user_title_analysis_prompts" not in prompts: prompts["user_title_analysis_prompts"] = {} @@ -488,7 +479,7 @@ class ConfigManager: self.config.save_config() def set_golden_quote_analysis_prompt(self, prompt: str): - """设置金句分析提示词模板""" + """Thiết lập template prompt phân tích trích dẫn.""" prompts = self._ensure_group("prompts") if "golden_quote_analysis_prompts" not in prompts: prompts["golden_quote_analysis_prompts"] = {} @@ -496,90 +487,93 @@ class ConfigManager: self.config.save_config() def set_output_format(self, format_types: str | list[str]): - """设置输出格式""" + """Thiết lập định dạng output.""" if isinstance(format_types, str): format_types = [ f.strip() for f in format_types.replace(",", ",").split(",") ] for f in format_types: if f not in ("image", "text", "html"): - raise ValueError(f"无效格式: {f}。有效: image, text, html") + raise ValueError( + f"Định dạng không hợp lệ: {f}. Hợp lệ: image, text, html" + ) self._ensure_group("basic")["output_format"] = format_types self.config.save_config() def set_group_list_mode(self, mode: str): - """设置群组列表模式""" + """Thiết lập chế độ danh sách nhóm.""" self._ensure_group("basic")["group_list_mode"] = mode self.config.save_config() def set_group_list(self, groups: list[str]): - """设置群组列表""" + """Thiết lập danh sách nhóm.""" self._ensure_group("basic")["group_list"] = groups self.config.save_config() def get_max_concurrent_tasks(self) -> int: - """获取自动分析最大并发群数""" + """Lấy số nhóm phân tích tự động đồng thời tối đa.""" return self._get_group("performance").get("max_concurrent_groups", 3) def get_llm_max_concurrent(self) -> int: - """获取全局 LLM 最大并发请求数""" + """Lấy số yêu cầu LLM đồng thời toàn cục tối đa.""" return self._get_group("performance").get("max_concurrent_llm", 3) def get_t2i_max_concurrent(self) -> int: - """获取全局图片渲染(T2I)最大并发数""" + """Lấy số tác vụ render ảnh T2I đồng thời toàn cục tối đa.""" return self._get_group("performance").get("max_concurrent_t2i", 1) def get_stagger_seconds(self) -> int: - """获取多群分析任务启动时的交错间隔(秒)""" + """Lấy khoảng cách khởi động tác vụ nhiều nhóm, tính bằng giây.""" return self._get_group("performance").get("stagger_seconds", 2) def set_max_concurrent_tasks(self, count: int): - """设置自动分析最大并发数""" + """Thiết lập số tác vụ phân tích tự động đồng thời tối đa.""" self._ensure_group("performance")["max_concurrent_groups"] = count self.config.save_config() def set_max_messages(self, count: int): - """设置最大消息数量""" + """Thiết lập số tin nhắn tối đa.""" self._ensure_group("basic")["max_messages"] = count self.config.save_config() def set_analysis_days(self, days: int): - """设置分析天数""" + """Thiết lập số ngày phân tích.""" self._ensure_group("basic")["analysis_days"] = days self.config.save_config() def set_auto_analysis_time(self, time_val: str | list[str]): - """设置自动分析时间点""" + """Thiết lập thời điểm phân tích tự động.""" self._ensure_group("auto_analysis")["auto_analysis_time"] = time_val self.config.save_config() def is_auto_analysis_enabled(self) -> bool: """ - 判断自动分析功能是否通过名单“按需开启”。 - 逻辑:如果是白名单模式且名单不为空,或者为黑名单模式,则视为开启。 + Kiểm tra phân tích tự động có được bật theo danh sách hay không. + + Bật khi whitelist không rỗng hoặc đang ở chế độ blacklist. """ mode = self.get_scheduled_group_list_mode() lst = self.get_scheduled_group_list() return (mode == "whitelist" and len(lst) > 0) or (mode == "blacklist") def get_scheduled_group_list_mode(self) -> str: - """获取定时分析名单模式 (whitelist/blacklist)""" + """Lấy chế độ danh sách phân tích định kỳ.""" return self._get_group("auto_analysis").get( "scheduled_group_list_mode", "whitelist" ) def set_scheduled_group_list_mode(self, mode: str): - """设置定时分析名单模式""" + """Thiết lập chế độ danh sách phân tích định kỳ.""" self._ensure_group("auto_analysis")["scheduled_group_list_mode"] = mode self.config.save_config() def get_scheduled_group_list(self) -> list[str]: - """获取定时分析目标群列表""" + """Lấy danh sách nhóm đích phân tích định kỳ.""" return self._get_group("auto_analysis").get("scheduled_group_list", []) def set_scheduled_group_list(self, groups: list[str]): - """设置定时分析目标群列表""" + """Thiết lập danh sách nhóm đích phân tích định kỳ.""" self._ensure_group("auto_analysis")["scheduled_group_list"] = groups self.config.save_config() @@ -587,119 +581,114 @@ class ConfigManager: self, group_umo_or_id: str, mode: str, group_list: list ) -> bool: """ - 通用的名单判定逻辑。 + Logic kiểm tra danh sách dùng chung. - 逻辑如下: - - whitelist 模式: - - 如果列表为空,则视为“此级别未开启”。 - - 如果不为空,仅在列表中的通过。 - - blacklist 模式: - - 在列表中的不通过。 - - 如果列表为空,则全部通过。 + Whitelist rỗng nghĩa là cấp này chưa bật; nếu không rỗng chỉ cho item + trong danh sách. Blacklist chặn item trong danh sách; rỗng thì cho tất cả. """ group_list = [str(x).strip() for x in group_list] target = str(group_umo_or_id).strip() if mode == "whitelist": if not group_list: - # 白名单为空:此级别不开启 (按需开启逻辑) + # Whitelist rỗng: cấp này chưa bật. return False return any(self._is_group_match(target, item) for item in group_list) else: # blacklist if not group_list: - # 黑名单为空:全通过 + # Blacklist rỗng: cho tất cả. return True return not any(self._is_group_match(target, item) for item in group_list) def set_min_messages_threshold(self, threshold: int): - """设置最小消息阈值""" + """Thiết lập ngưỡng tin nhắn tối thiểu.""" self._ensure_group("basic")["min_messages_threshold"] = threshold self.config.save_config() def set_topic_analysis_enabled(self, enabled: bool): - """设置是否启用话题分析""" + """Bật hoặc tắt phân tích chủ đề.""" self._ensure_group("analysis_features")["topic_analysis_enabled"] = enabled self.config.save_config() def set_user_title_analysis_enabled(self, enabled: bool): - """设置是否启用用户称号分析""" + """Bật hoặc tắt phân tích danh hiệu.""" self._ensure_group("analysis_features")["user_title_analysis_enabled"] = enabled self.config.save_config() def set_golden_quote_analysis_enabled(self, enabled: bool): - """设置是否启用金句分析""" + """Bật hoặc tắt phân tích trích dẫn.""" self._ensure_group("analysis_features")["golden_quote_analysis_enabled"] = ( enabled ) self.config.save_config() def set_chat_quality_analysis_enabled(self, enabled: bool): - """设置是否启用聊天质量分析""" + """Bật hoặc tắt phân tích chất lượng.""" self._ensure_group("analysis_features")["chat_quality_analysis_enabled"] = ( enabled ) self.config.save_config() def set_max_topics(self, count: int): - """设置最大话题数量""" + """Thiết lập số chủ đề tối đa.""" self._ensure_group("analysis_features")["max_topics"] = count self.config.save_config() def set_max_user_titles(self, count: int): - """设置最大用户称号数量""" + """Thiết lập số danh hiệu tối đa.""" self._ensure_group("analysis_features")["max_user_titles"] = count self.config.save_config() def set_max_golden_quotes(self, count: int): - """设置最大金句数量""" + """Thiết lập số trích dẫn tối đa.""" self._ensure_group("analysis_features")["max_golden_quotes"] = count self.config.save_config() def set_html_filename_format(self, format_str: str): - """设置HTML文件名格式""" + """Thiết lập định dạng tên tệp HTML.""" self._ensure_group("html")["html_filename_format"] = format_str self.config.save_config() def get_report_template(self) -> str: - """获取报告模板名称""" + """Lấy tên template báo cáo.""" return self._get_group("basic").get("report_template", "scrapbook") def set_report_template(self, template_name: str): - """设置报告模板名称""" + """Thiết lập tên template báo cáo.""" self._ensure_group("basic")["report_template"] = template_name self.config.save_config() def get_enable_user_card(self) -> bool: - """获取是否使用用户群名片""" + """Kiểm tra có dùng tên thành viên trong nhóm hay không.""" return self._get_group("basic").get("enable_user_card", False) def get_enable_analysis_reply(self) -> bool: - """获取是否在群分析完成后发送文本回复""" + """Kiểm tra có gửi phản hồi văn bản sau phân tích hay không.""" return self._get_group("basic").get("enable_analysis_reply", False) def set_enable_analysis_reply(self, enabled: bool): - """设置是否在群分析完成后发送文本回复""" + """Thiết lập gửi phản hồi văn bản sau phân tích.""" self._ensure_group("basic")["enable_analysis_reply"] = enabled self.config.save_config() def get_show_report_caption(self) -> bool: - """获取是否发送 \"📊 每日群聊分析报告已生成\" 前缀文字。""" + """Kiểm tra có gửi caption báo cáo hay không.""" return self._get_group("basic").get("show_report_caption", True) def set_show_report_caption(self, enabled: bool): - """设置是否发送 \"📊 每日群聊分析报告已生成\" 前缀文字。""" + """Thiết lập gửi caption báo cáo.""" self._ensure_group("basic")["show_report_caption"] = enabled self.config.save_config() def get_profile_display_mode(self) -> str: - """获取人格标签展示模式。""" + """Lấy chế độ hiển thị nhãn hồ sơ.""" mode = str(self._get_group("basic").get("profile_display_mode", "mbti")).lower() if mode not in {"mbti", "sbti", "acgti"}: return "mbti" return mode def get_profile_image_opacity(self) -> float: - """获取人格背景图透明度。""" + """Lấy độ trong suốt ảnh nền hồ sơ.""" value = self._get_group("basic").get("profile_image_opacity", 0.12) try: return max(0.0, min(1.0, float(value))) @@ -707,7 +696,7 @@ class ConfigManager: return 0.12 def get_profile_image_size_mode(self) -> str: - """获取人格背景图尺寸模式。""" + """Lấy chế độ kích thước ảnh nền hồ sơ.""" mode = str( self._get_group("basic").get("profile_image_size_mode", "contain") ).lower() @@ -716,122 +705,122 @@ class ConfigManager: return mode def get_profile_mapping_config(self) -> str: - """获取人格映射配置(JSON 文本)。""" + """Lấy cấu hình ánh xạ hồ sơ dưới dạng văn bản JSON.""" return str(self._get_group("basic").get("profile_mapping_config", "")).strip() - # ========== 群文件/群相册上传配置 ========== + # ========== Cấu hình upload tệp/album nhóm ========== def get_enable_group_file_upload(self) -> bool: - """获取是否启用群文件上传""" + """Kiểm tra upload tệp nhóm có bật hay không.""" return self._get_group("qq_group_upload").get("enable_group_file_upload", False) def get_group_file_folder(self) -> str: - """获取群文件上传目录名,空字符串表示根目录""" + """Lấy tên thư mục upload tệp nhóm; chuỗi rỗng là thư mục gốc.""" return self._get_group("qq_group_upload").get("group_file_folder", "") def get_enable_group_album_upload(self) -> bool: - """获取是否启用群相册上传(仅 NapCat)""" + """Kiểm tra upload album nhóm có bật hay không, chỉ NapCat.""" return self._get_group("qq_group_upload").get( "enable_group_album_upload", False ) def get_group_album_name(self) -> str: - """获取目标群相册名称,空字符串表示默认相册""" + """Lấy tên album đích; chuỗi rỗng là album mặc định.""" return self._get_group("qq_group_upload").get("group_album_name", "") def get_group_album_strict_mode(self) -> bool: - """获取群相册上传严格模式开关。""" + """Lấy trạng thái chế độ upload album nghiêm ngặt.""" return bool( self._get_group("qq_group_upload").get("group_album_strict_mode", True) ) def set_group_album_strict_mode(self, enabled: bool): - """设置群相册上传严格模式""" + """Thiết lập chế độ upload album nghiêm ngặt.""" self._ensure_group("qq_group_upload")["group_album_strict_mode"] = enabled self.config.save_config() - # ========== 增量分析配置 ========== + # ========== Cấu hình phân tích gia tăng ========== def get_incremental_enabled(self) -> bool: - """获取是否开启了增量分析(由名单状态决定)""" + """Kiểm tra phân tích gia tăng có bật theo trạng thái danh sách hay không.""" mode = self.get_incremental_group_list_mode() lst = self.get_incremental_group_list() - # 如果是白名单且不为空,或者是黑名单模式,则视为功能“开启” + # Bật khi whitelist không rỗng hoặc đang ở chế độ blacklist. return (mode == "whitelist" and len(lst) > 0) or (mode == "blacklist") def get_incremental_group_list_mode(self) -> str: - """获取增量分析名单模式 (whitelist/blacklist)""" + """Lấy chế độ danh sách phân tích gia tăng.""" return self._get_group("incremental").get( "incremental_group_list_mode", "whitelist" ) def get_incremental_group_list(self) -> list[str]: - """获取增量分析群列表""" + """Lấy danh sách nhóm phân tích gia tăng.""" return self._get_group("incremental").get("incremental_group_list", []) def get_incremental_fallback_enabled(self) -> bool: - """获取增量分析失败回退到全量分析的开关(默认启用)""" + """Lấy trạng thái fallback sang phân tích đầy đủ khi gia tăng thất bại.""" return self._get_group("incremental").get("incremental_fallback_enabled", True) def get_incremental_report_immediately(self) -> bool: - """获取是否启用增量分析立即发送报告(调试用)""" + """Kiểm tra có gửi ngay báo cáo gia tăng hay không, dùng để debug.""" return self._get_group("incremental").get( "incremental_report_immediately", False ) def set_incremental_report_immediately(self, enabled: bool): - """设置增量分析是否立即发送报告""" + """Thiết lập gửi ngay báo cáo phân tích gia tăng.""" self._ensure_group("incremental")["incremental_report_immediately"] = enabled self.config.save_config() def get_incremental_interval_minutes(self) -> int: - """获取增量分析间隔(分钟)""" + """Lấy khoảng cách phân tích gia tăng, tính bằng phút.""" return self._get_group("incremental").get("incremental_interval_minutes", 120) def get_incremental_max_daily_analyses(self) -> int: - """获取每天最大增量分析次数""" + """Lấy số lần phân tích gia tăng tối đa mỗi ngày.""" return self._get_group("incremental").get("incremental_max_daily_analyses", 8) def get_incremental_safe_limit(self) -> int: - """获取单次增量分析的安全分析/同步上限 (Safe Count)""" + """Lấy giới hạn phân tích/đồng bộ an toàn mỗi batch gia tăng.""" return self._get_group("incremental").get("incremental_safe_limit", 2000) def get_incremental_min_messages(self) -> int: - """获取触发增量分析的最小消息数阈值""" + """Lấy ngưỡng tin nhắn tối thiểu để kích hoạt phân tích gia tăng.""" return self._get_group("incremental").get("incremental_min_messages", 20) def get_incremental_topics_per_batch(self) -> int: - """获取单次增量分析提取的最大话题数""" + """Lấy số chủ đề tối đa được trích xuất mỗi batch gia tăng.""" return self._get_group("incremental").get("incremental_topics_per_batch", 3) def get_incremental_quotes_per_batch(self) -> int: - """获取单次增量分析提取的最大金句数""" + """Lấy số trích dẫn tối đa được trích xuất mỗi batch gia tăng.""" return self._get_group("incremental").get("incremental_quotes_per_batch", 3) def get_incremental_active_start_hour(self) -> int: - """获取增量分析活跃时段起始小时(24小时制)""" + """Lấy giờ bắt đầu hoạt động gia tăng theo định dạng 24 giờ.""" return self._get_group("incremental").get("incremental_active_start_hour", 8) def get_incremental_active_end_hour(self) -> int: - """获取增量分析活跃时段结束小时(24小时制)""" + """Lấy giờ kết thúc hoạt động gia tăng theo định dạng 24 giờ.""" return self._get_group("incremental").get("incremental_active_end_hour", 23) def get_incremental_stagger_seconds(self) -> int: - """获取多群增量分析的交错间隔(秒),避免 API 压力""" + """Lấy khoảng cách tác vụ gia tăng nhiều nhóm để giảm tải API.""" return self._get_group("incremental").get("incremental_stagger_seconds", 30) def save_config(self): - """保存配置到AstrBot配置系统""" + """Lưu cấu hình vào hệ thống cấu hình AstrBot.""" try: self.config.save_config() - logger.info("配置已保存") + logger.info("Đã lưu cấu hình") except Exception as e: - logger.error(f"保存配置失败: {e}") + logger.error(f"Lưu cấu hình thất bại: {e}") def reload_config(self): - """重新加载配置""" + """Tải lại cấu hình.""" try: - logger.info("重新加载配置...") - logger.info("配置重载完成") + logger.info("Đang tải lại cấu hình...") + logger.info("Đã tải lại cấu hình") except Exception as e: - logger.error(f"重新加载配置失败: {e}") + logger.error(f"Tải lại cấu hình thất bại: {e}") diff --git a/src/infrastructure/messaging/message_sender.py b/src/infrastructure/messaging/message_sender.py index dab4834..af374c6 100644 --- a/src/infrastructure/messaging/message_sender.py +++ b/src/infrastructure/messaging/message_sender.py @@ -1,16 +1,14 @@ """ -消息发送器 - 基础设施层 -提供高层消息发送接口,支持跨平台智能路由。 +Trình gửi tin nhắn thuộc tầng infrastructure. + +Cung cấp giao diện gửi cấp cao với khả năng định tuyến đa nền tảng. """ from ...utils.logger import logger class MessageSender: - """ - 消息发送器 - 封装了 PlatformAdapter 的底层调用,提供更高层的发送接口 - """ + """Đóng gói lệnh gọi PlatformAdapter và cung cấp giao diện gửi cấp cao.""" def __init__(self, bot_manager, config_manager): self.bot_manager = bot_manager @@ -19,10 +17,12 @@ class MessageSender: async def send_text( self, group_id: str, text: str, platform_id: str | None = None ) -> bool: - """发送文本消息""" + """Gửi tin nhắn văn bản.""" adapter = self.bot_manager.get_adapter(platform_id) if not adapter: - logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器") + logger.error( + f"[MessageSender] Không tìm thấy adapter cho nền tảng {platform_id}" + ) return False return await adapter.send_text(group_id, text) @@ -33,10 +33,12 @@ class MessageSender: caption: str = "", platform_id: str | None = None, ) -> bool: - """智能发送图片,支持自动选择适配器""" + """Gửi ảnh và tự động chọn adapter phù hợp.""" adapter = self.bot_manager.get_adapter(platform_id) if not adapter: - logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器") + logger.error( + f"[MessageSender] Không tìm thấy adapter cho nền tảng {platform_id}" + ) return False return await adapter.send_image(group_id, image_url, caption) @@ -47,33 +49,37 @@ class MessageSender: caption: str = "", platform_id: str | None = None, ) -> bool: - """发送文件(HTML/PDF/其它文件)。支持可选 caption。""" + """Gửi tệp HTML, PDF hoặc loại khác với caption tuỳ chọn.""" adapter = self.bot_manager.get_adapter(platform_id) if not adapter: - logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器") + logger.error( + f"[MessageSender] Không tìm thấy adapter cho nền tảng {platform_id}" + ) return False - # 首先发送文件,本方法的返回值只代表文件是否发送成功。 + # Gửi tệp trước; kết quả phương thức chỉ phản ánh việc gửi tệp. file_sent = await adapter.send_file(group_id, file_path) if not file_sent: - # 适配器返回 False,表示文件未成功发送 + # Adapter trả False nghĩa là tệp chưa được gửi thành công. return False - # 文件已成功发送,下面的 caption 发送为尽力而为,不影响整体成功与否。 + # Sau khi gửi tệp, caption được gửi theo nỗ lực tối đa và không đổi kết quả. if caption: try: caption_sent = await adapter.send_text(group_id, f"{caption}") if not caption_sent: logger.warning( - "[MessageSender] 文件已发送,但 caption 发送失败(适配器返回 False)" + "[MessageSender] Đã gửi tệp nhưng gửi caption thất bại (adapter trả False)" ) except Exception as e: - logger.warning(f"[MessageSender] 文件已发送,但 caption 发送异常: {e}") + logger.warning( + f"[MessageSender] Đã gửi tệp nhưng xảy ra lỗi khi gửi caption: {e}" + ) return True def _get_available_platforms(self, group_id: str): - """获取可用的平台列表 (Helper for Dispatcher)""" - # 简单实现:返回所有已加载的平台 + """Lấy danh sách nền tảng khả dụng cho Dispatcher.""" + # Triển khai đơn giản: trả về mọi nền tảng đã nạp. return [(pid, None) for pid in self.bot_manager.get_platform_ids()] diff --git a/src/infrastructure/persistence/__init__.py b/src/infrastructure/persistence/__init__.py index d429bd8..6e3091e 100644 --- a/src/infrastructure/persistence/__init__.py +++ b/src/infrastructure/persistence/__init__.py @@ -1,7 +1,7 @@ """ -持久化模块 - 数据存储实现 +Module persistence triển khai lưu trữ dữ liệu. -包含历史记录仓储和增量分析状态仓储。 +Chứa repository lịch sử và trạng thái phân tích gia tăng. """ from .history_repository import HistoryRepository diff --git a/src/infrastructure/persistence/history_manager.py b/src/infrastructure/persistence/history_manager.py index aba14cc..aa1ae06 100644 --- a/src/infrastructure/persistence/history_manager.py +++ b/src/infrastructure/persistence/history_manager.py @@ -1,7 +1,8 @@ """ -历史记录管理器模块 - 基础设施持久化层 -负责存储和查询群聊分析报告的摘要信息 -使用 AstrBot 的 put_kv_data/get_kv_data 实现 +Module quản lý lịch sử thuộc tầng persistence infrastructure. + +Phụ trách lưu và truy vấn thông tin tóm tắt của báo cáo phân tích nhóm, +sử dụng put_kv_data/get_kv_data của AstrBot. """ import datetime @@ -12,19 +13,18 @@ from ...utils.logger import logger class HistoryManager: """ - 核心组件:历史分析存档管理器 + Thành phần cốt lõi: trình quản lý lưu trữ lịch sử phân tích. - 该类负责将每日生成的群消息分析报告摘要持久化存储,并提供查询接口。 - 底层基于 AstrBot 提供的 KV 存储能力(put_kv_data/get_kv_data), - 确保即使在 Bot 重启后也能回溯历史数据。 + Lưu bản tóm tắt báo cáo phân tích nhóm hằng ngày và cung cấp giao diện truy vấn. + Dựa trên KV của AstrBot để có thể truy xuất dữ liệu sau khi bot khởi động lại. """ def __init__(self, star_instance: Any): """ - 初始化历史记录管理器。 + Khởi tạo trình quản lý lịch sử. Args: - star_instance (Any): Star 插件实例,用于访问底层持久化引擎 + star_instance: Instance Star dùng để truy cập persistence engine. """ self.plugin = star_instance @@ -36,18 +36,19 @@ class HistoryManager: time_str: str | None = None, ) -> bool: """ - 序列化并存储一份分析报告摘要。 + Tuần tự hoá và lưu một bản tóm tắt báo cáo phân tích. - 摘要包含:发言总量、人数、提取的主题摘要及生成时间,不包含完整的原始消息流。 + Bản tóm tắt gồm tổng tin nhắn, số thành viên, chủ đề và thời gian tạo; + không chứa toàn bộ luồng tin nhắn gốc. Args: - group_id (str): 群组 ID - analysis_result (dict[str, Any]): 包含 statistics, topics, user_titles 的完整分析对象 - date_str (str, optional): 归档日期 (YYYY-MM-DD),缺省为当天 - time_str (str, optional): 归档时间点 (HH-MM),缺省为当前时刻 + group_id: ID nhóm. + analysis_result: Đối tượng phân tích gồm statistics, topics và user_titles. + date_str: Ngày lưu trữ (YYYY-MM-DD), mặc định là hôm nay. + time_str: Thời điểm lưu (HH-MM), mặc định là hiện tại. Returns: - bool: 存储是否成功 + True nếu lưu thành công. """ try: now = datetime.datetime.now() @@ -56,10 +57,10 @@ class HistoryManager: if not time_str: time_str = now.strftime("%H-%M") - # 消解非法字符,确保 Key 兼容性 + # Loại ký tự không hợp lệ để key tương thích. time_str = time_str.replace(":", "-") - # 从分析结果中剥离非持久化字段,提取核心统计元数据 + # Loại trường không cần lưu và lấy metadata thống kê cốt lõi. stats = analysis_result.get("statistics") topics = analysis_result.get("topics", []) user_titles = analysis_result.get("user_titles", []) @@ -78,18 +79,18 @@ class HistoryManager: await self.plugin.put_kv_data(key, summary) logger.info( - f"已保存群 {group_id} 在 {date_str} {time_str} 的分析摘要到历史记录 (Key: {key})" + f"Đã lưu tóm tắt phân tích của nhóm {group_id} lúc {date_str} {time_str} (Key: {key})" ) return True except Exception as e: - logger.error(f"保存历史分析记录失败: {e}", exc_info=True) + logger.error(f"Lưu bản ghi phân tích lịch sử thất bại: {e}", exc_info=True) return False async def get_history( self, group_id: str, date_str: str, time_str: str ) -> dict[str, Any] | None: """ - 根据群组、日期和时间点检索一份历史摘要。 + Truy vấn một bản tóm tắt lịch sử theo nhóm, ngày và thời điểm. """ time_str = time_str.replace(":", "-") key = f"analysis_{group_id}_{date_str}_{time_str}" @@ -97,7 +98,7 @@ class HistoryManager: async def has_history(self, group_id: str, date_str: str, time_str: str) -> bool: """ - 快速判定是否存在指定时间点的历史分析记录。 + Kiểm tra nhanh bản ghi lịch sử tại thời điểm chỉ định có tồn tại hay không. """ history = await self.get_history(group_id, date_str, time_str) return history is not None diff --git a/src/infrastructure/persistence/history_repository.py b/src/infrastructure/persistence/history_repository.py index 278ff3d..08d1902 100644 --- a/src/infrastructure/persistence/history_repository.py +++ b/src/infrastructure/persistence/history_repository.py @@ -1,8 +1,8 @@ """ -历史仓库 - 存储分析历史的实现 +Repository lịch sử - triển khai lưu trữ lịch sử phân tích. -该模块提供分析结果和历史记录的持久化存储。 -它封装了现有的 history_manager 功能。 +Module cung cấp persistence cho kết quả phân tích và bản ghi lịch sử, +đóng gói chức năng history_manager hiện có. """ import json @@ -15,33 +15,33 @@ from ...utils.logger import logger class HistoryRepository: """ - 基础设施:历史仓库 + Infrastructure: repository lịch sử. - 负责群聊分析历史记录的持久化存储与检索。目前使用本地 JSON 文件实现, - 保持了与旧版 `history_manager` 的数据格式兼容性。 + Lưu và truy xuất lịch sử phân tích nhóm bằng file JSON cục bộ, + duy trì tương thích định dạng dữ liệu với history_manager cũ. Attributes: - data_dir (Path): 插件数据存储的总根目录 - history_dir (Path): 专门存放历史记录的子目录 + data_dir: Thư mục gốc lưu dữ liệu plugin. + history_dir: Thư mục con lưu lịch sử. """ def __init__(self, data_dir: str): """ - 初始化历史仓库。 + Khởi tạo repository lịch sử. Args: - data_dir (str): 存储历史数据的基础目录路径 + data_dir: Đường dẫn thư mục cơ sở lưu dữ liệu lịch sử. """ self.data_dir = Path(data_dir) self.history_dir = self.data_dir / "history" self._ensure_directories() def _ensure_directories(self) -> None: - """内部方法:确保所需的目录结构已创建。""" + """Đảm bảo cấu trúc thư mục cần thiết đã tồn tại.""" self.history_dir.mkdir(parents=True, exist_ok=True) def _get_group_history_path(self, group_id: str) -> Path: - """内部方法:获取特定群组的历史 JSON 文件路径。""" + """Lấy đường dẫn JSON lịch sử của nhóm.""" return self.history_dir / f"group_{group_id}.json" def save_analysis_result( @@ -51,52 +51,54 @@ class HistoryRepository: date_str: str | None = None, ) -> bool: """ - 将分析结果保存到持久化存储。 + Lưu kết quả phân tích vào persistence. Args: - group_id (str): 群组标识符 - result (dict[str, Any]): 包含统计、金句等信息的分析结果字典 - date_str (str, optional): 关联日期 (YYYY-MM-DD),默认为执行日 + group_id: Định danh nhóm. + result: Dict kết quả gồm thống kê, trích dẫn và thông tin liên quan. + date_str: Ngày liên quan (YYYY-MM-DD), mặc định là ngày thực thi. Returns: - bool: 保存成功返回 True,发生异常返回 False + True nếu lưu thành công, False nếu xảy ra lỗi. """ try: date_str = date_str or datetime.now().strftime("%Y-%m-%d") history = self.load_group_history(group_id) - # 注入执行时间戳 + # Gắn timestamp thực thi. if "timestamp" not in result: result["timestamp"] = datetime.now().isoformat() - # 结构化存储:二级映射 {date -> result} + # Lưu có cấu trúc: ánh xạ hai cấp {date -> result}. if "daily" not in history: history["daily"] = {} history["daily"][date_str] = result history["last_updated"] = datetime.now().isoformat() - # 原子写入(覆盖) + # Ghi nguyên tử (ghi đè). history_path = self._get_group_history_path(group_id) with open(history_path, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) - logger.debug(f"已保存群 {group_id} 在 {date_str} 的历史分析记录") + logger.debug( + f"Đã lưu bản ghi phân tích lịch sử của nhóm {group_id} ngày {date_str}" + ) return True except Exception as e: - logger.error(f"保存群 {group_id} 的历史记录失败: {e}") + logger.error(f"Lưu lịch sử của nhóm {group_id} thất bại: {e}") return False def load_group_history(self, group_id: str) -> dict[str, Any]: """ - 加载特定群组的完整历史记录字典。 + Tải toàn bộ dict lịch sử của nhóm. Args: - group_id (str): 群组标识符 + group_id: Định danh nhóm. Returns: - dict[str, Any]: 历史数据字典,若文件不存在则返回包含空 daily 结构的初始字典 + Dict lịch sử; nếu file chưa tồn tại thì trả cấu trúc daily rỗng. """ try: history_path = self._get_group_history_path(group_id) @@ -105,77 +107,77 @@ class HistoryRepository: return json.load(f) return {"daily": {}, "group_id": group_id} except Exception as e: - logger.error(f"加载群 {group_id} 的历史记录失败: {e}") + logger.error(f"Tải lịch sử của nhóm {group_id} thất bại: {e}") return {"daily": {}, "group_id": group_id} def get_analysis_result( self, group_id: str, date_str: str ) -> dict[str, Any] | None: """ - 获取指定日期已存档的分析结果。 + Lấy kết quả phân tích đã lưu của ngày chỉ định. Args: - group_id (str): 群组 ID - date_str (str): 目标日期 (YYYY-MM-DD) + group_id: ID nhóm. + date_str: Ngày đích (YYYY-MM-DD). Returns: - Optional[dict[str, Any]]: 分析结果字典,未找到则返回 None + Dict kết quả hoặc None nếu không tìm thấy. """ history = self.load_group_history(group_id) return history.get("daily", {}).get(date_str) def get_recent_results(self, group_id: str, limit: int = 7) -> list[dict[str, Any]]: """ - 获取指定群组最近 N 次的分析结果列表。 + Lấy N kết quả phân tích gần nhất của nhóm. Args: - group_id (str): 群组 ID - limit (int): 最大返回条数 + group_id: ID nhóm. + limit: Số kết quả tối đa. Returns: - list[dict[str, Any]]: 按日期降序排列的结果列表 + Danh sách kết quả sắp xếp giảm dần theo ngày. """ history = self.load_group_history(group_id) daily = history.get("daily", {}) - # 按日期字符串字典序降序排列(YYYY-MM-DD 天然有序) + # Sắp xếp giảm dần theo chuỗi ngày (YYYY-MM-DD vốn có thứ tự). sorted_dates = sorted(daily.keys(), reverse=True)[:limit] return [daily[date] for date in sorted_dates] def has_analysis_for_date(self, group_id: str, date_str: str) -> bool: """ - 检查指定日期是否已经生成过分析。 + Kiểm tra ngày chỉ định đã có phân tích hay chưa. Args: - group_id (str): 群组 ID - date_str (str): 日期字符串 + group_id: ID nhóm. + date_str: Chuỗi ngày. Returns: - bool: 存在记录则返回 True + True nếu bản ghi tồn tại. """ return self.get_analysis_result(group_id, date_str) is not None def delete_old_history(self, group_id: str, keep_days: int = 30) -> int: """ - 自动清理超过天数限制的陈旧历史记录。 + Tự động xoá bản ghi lịch sử cũ vượt quá số ngày giữ lại. Args: - group_id (str): 群组 ID - keep_days (int): 保留的天数上限 + group_id: ID nhóm. + keep_days: Số ngày tối đa cần giữ lại. Returns: - int: 实际删除的记录条数 + Số bản ghi thực tế đã xoá. """ try: history = self.load_group_history(group_id) daily = history.get("daily", {}) - # 计算截止日期边界 + # Tính mốc ngày giới hạn. from datetime import timedelta cutoff = (datetime.now() - timedelta(days=keep_days)).strftime("%Y-%m-%d") - # 筛选已过期的日期 + # Lọc các ngày đã hết hạn. dates_to_delete = [date for date in daily.keys() if date < cutoff] for date in dates_to_delete: @@ -190,23 +192,23 @@ class HistoryRepository: return len(dates_to_delete) except Exception as e: - logger.error(f"清理群 {group_id} 的陈旧历史记录失败: {e}") + logger.error(f"Xoá lịch sử cũ của nhóm {group_id} thất bại: {e}") return 0 def list_groups_with_history(self) -> list[str]: """ - 扫描文件系统,列出当前所有具有存档记录的群组 ID。 + Quét filesystem và liệt kê ID của các nhóm có bản ghi lưu trữ. Returns: - list[str]: 群组 ID 字符串列表 + Danh sách chuỗi ID nhóm. """ try: groups = [] for file_path in self.history_dir.glob("group_*.json"): - # 从文件名反推群组 ID (group_123.json -> 123) + # Suy ra ID nhóm từ tên tệp (group_123.json -> 123). group_id = file_path.stem.replace("group_", "") groups.append(group_id) return groups except Exception as e: - logger.error(f"列出历史记录群组失败: {e}") + logger.error(f"Liệt kê nhóm có lịch sử thất bại: {e}") return [] diff --git a/src/infrastructure/persistence/incremental_store.py b/src/infrastructure/persistence/incremental_store.py index e3a979a..0b8cc0c 100644 --- a/src/infrastructure/persistence/incremental_store.py +++ b/src/infrastructure/persistence/incremental_store.py @@ -1,16 +1,13 @@ """ -增量分析批次持久化存储 — 滑动窗口架构 +Persistence cho batch phân tích gia tăng theo kiến trúc cửa sổ trượt. -基于 AstrBot 的 put_kv_data/get_kv_data 实现按批次独立存储, -支持按时间窗口查询批次、批次索引管理和过期批次清理。 +Dùng put_kv_data/get_kv_data của AstrBot để lưu riêng từng batch, hỗ trợ +truy vấn theo cửa sổ thời gian, quản lý index và dọn batch hết hạn. -KV 键设计: -- 批次索引: incr_batch_index_{group_id} - 值: [{"batch_id": "xxx", "timestamp": 1234567890.0}, ...] -- 批次数据: incr_batch_{group_id}_{batch_id} - 值: IncrementalBatch.to_dict() -- 最后分析消息时间戳: incr_last_ts_{group_id} - 值: int (epoch timestamp) +Thiết kế key KV: +- Index batch: incr_batch_index_{group_id} +- Dữ liệu batch: incr_batch_{group_id}_{batch_id} +- Timestamp tin nhắn phân tích cuối: incr_last_ts_{group_id} """ from typing import Any @@ -21,59 +18,55 @@ from ...utils.logger import logger class IncrementalStore: """ - 增量分析批次持久化仓储 + Repository persistence cho batch phân tích gia tăng. - 核心职责: - - save_batch: 保存单个批次数据并更新索引 - - query_batches: 按时间窗口查询批次列表 - - get_last_analyzed_timestamp / update_last_analyzed_timestamp: 跨批次去重 - - cleanup_old_batches: 清理过期批次 - - get_batch_count: 获取当前批次总数(状态查询用) + Trách nhiệm chính: lưu và truy vấn batch, quản lý timestamp loại trùng, + dọn batch hết hạn và cung cấp số lượng batch cho truy vấn trạng thái. """ - # KV 键前缀 + # Tiền tố key KV INDEX_PREFIX = "incr_batch_index" BATCH_PREFIX = "incr_batch" LAST_TS_PREFIX = "incr_last_ts" def __init__(self, star_instance: Any): """ - 初始化批次持久化仓储。 + Khởi tạo repository persistence cho batch. Args: - star_instance: Star 插件实例,用于访问底层 KV 存储引擎 + star_instance: Instance Star dùng để truy cập KV storage. """ self.plugin = star_instance # ================================================================ - # 键构建 + # Xây dựng key # ================================================================ def _index_key(self, group_id: str) -> str: - """构建批次索引键""" + """Tạo key index batch.""" return f"{self.INDEX_PREFIX}_{group_id}" def _batch_key(self, group_id: str, batch_id: str) -> str: - """构建单个批次数据键""" + """Tạo key dữ liệu cho một batch.""" return f"{self.BATCH_PREFIX}_{group_id}_{batch_id}" def _last_ts_key(self, group_id: str) -> str: - """构建最后分析消息时间戳键""" + """Tạo key timestamp của tin nhắn phân tích cuối.""" return f"{self.LAST_TS_PREFIX}_{group_id}" # ================================================================ - # 批次索引操作 + # Thao tác index batch # ================================================================ async def _get_index(self, group_id: str) -> list[dict]: """ - 获取指定群的批次索引列表。 + Lấy danh sách index batch của nhóm. Args: - group_id: 群组 ID + group_id: ID nhóm. Returns: - list[dict]: 索引条目列表,每项包含 batch_id 和 timestamp + Danh sách mục index, mỗi mục chứa batch_id và timestamp. """ key = self._index_key(group_id) try: @@ -82,53 +75,53 @@ class IncrementalStore: return [] if isinstance(data, list): return data - logger.warning(f"批次索引数据格式异常 (Key: {key}): {type(data)}") + logger.warning( + f"Định dạng dữ liệu index batch bất thường (Key: {key}): {type(data)}" + ) return [] except Exception as e: - logger.error(f"读取批次索引失败 (Key: {key}): {e}", exc_info=True) + logger.error(f"Đọc index batch thất bại (Key: {key}): {e}", exc_info=True) return [] async def _save_index(self, group_id: str, index: list[dict]) -> None: """ - 保存批次索引列表。 + Lưu danh sách index batch. Args: - group_id: 群组 ID - index: 索引条目列表 + group_id: ID nhóm. + index: Danh sách mục index. """ key = self._index_key(group_id) try: await self.plugin.put_kv_data(key, index) except Exception as e: - logger.error(f"保存批次索引失败 (Key: {key}): {e}", exc_info=True) + logger.error(f"Lưu index batch thất bại (Key: {key}): {e}", exc_info=True) raise # ================================================================ - # 批次数据操作 + # Thao tác dữ liệu batch # ================================================================ async def save_batch(self, batch: IncrementalBatch) -> bool: """ - 保存单个批次数据并更新索引。 + Lưu dữ liệu của một batch và cập nhật index. - 流程: - 1. 将批次数据写入独立 KV 键 - 2. 将批次元数据(batch_id + timestamp)追加到索引 + Ghi dữ liệu vào key KV riêng rồi thêm metadata batch vào index. Args: - batch: 要保存的增量分析批次 + batch: Batch phân tích gia tăng cần lưu. Returns: - bool: 保存是否成功 + True nếu lưu thành công. """ group_id = batch.group_id batch_key = self._batch_key(group_id, batch.batch_id) try: - # 1. 保存批次数据 + # 1. Lưu dữ liệu batch await self.plugin.put_kv_data(batch_key, batch.to_dict()) - # 2. 更新索引 + # 2. Cập nhật index index = await self._get_index(group_id) index.append( { @@ -139,13 +132,13 @@ class IncrementalStore: await self._save_index(group_id, index) logger.debug( - f"已保存批次 {batch.batch_id[:8]}... " - f"(群 {group_id}, 消息数={batch.messages_count})" + f"Đã lưu batch {batch.batch_id[:8]}... " + f"(nhóm {group_id}, tin nhắn={batch.messages_count})" ) return True except Exception as e: logger.error( - f"保存批次失败 (群 {group_id}, 批次 {batch.batch_id[:8]}...): {e}", + f"Lưu batch thất bại (nhóm {group_id}, batch {batch.batch_id[:8]}...): {e}", exc_info=True, ) return False @@ -157,29 +150,29 @@ class IncrementalStore: window_end: float, ) -> list[IncrementalBatch]: """ - 按时间窗口查询批次列表。 + Truy vấn danh sách batch theo cửa sổ thời gian. - 从索引中筛选时间戳落在 [window_start, window_end] 范围内的批次, - 逐个加载完整批次数据。 + Lọc batch có timestamp trong ``[window_start, window_end]`` từ index + rồi tải toàn bộ dữ liệu từng batch. Args: - group_id: 群组 ID - window_start: 窗口起始时间戳(epoch) - window_end: 窗口结束时间戳(epoch) + group_id: ID nhóm. + window_start: Epoch timestamp bắt đầu cửa sổ. + window_end: Epoch timestamp kết thúc cửa sổ. Returns: - list[IncrementalBatch]: 符合窗口范围的批次列表,按时间戳升序 + Danh sách batch trong cửa sổ, tăng dần theo timestamp. """ index = await self._get_index(group_id) - # 筛选在窗口范围内的批次 + # Lọc batch trong phạm vi cửa sổ. matching_entries = [ entry for entry in index if window_start <= entry.get("timestamp", 0) <= window_end ] - # 按时间戳升序排列 + # Sắp xếp tăng dần theo timestamp. matching_entries.sort(key=lambda x: x.get("timestamp", 0)) batches: list[IncrementalBatch] = [] @@ -196,89 +189,94 @@ class IncrementalStore: batches.append(batch) else: logger.warning( - f"批次数据缺失 (群 {group_id}, 批次 {batch_id[:8]}...)" + f"Thiếu dữ liệu batch (nhóm {group_id}, batch {batch_id[:8]}...)" ) except Exception as e: logger.error( - f"加载批次数据失败 (群 {group_id}, 批次 {batch_id[:8]}...): {e}", + f"Tải dữ liệu batch thất bại (nhóm {group_id}, batch {batch_id[:8]}...): {e}", exc_info=True, ) logger.debug( - f"窗口查询完成: 群 {group_id}, " - f"窗口 [{window_start:.0f}, {window_end:.0f}], " - f"匹配 {len(batches)}/{len(index)} 个批次" + f"Hoàn tất truy vấn cửa sổ: nhóm {group_id}, " + f"cửa sổ [{window_start:.0f}, {window_end:.0f}], " + f"khớp {len(batches)}/{len(index)} batch" ) return batches # ================================================================ - # 最后分析消息时间戳(跨批次去重用) + # Timestamp tin nhắn phân tích cuối để loại trùng giữa các batch # ================================================================ async def get_last_analyzed_timestamp(self, group_id: str) -> int: """ - 获取指定群的最后分析消息时间戳。 + Lấy timestamp tin nhắn phân tích cuối của nhóm. - 用于增量分析时过滤已分析过的消息。 + Dùng để lọc tin nhắn đã phân tích trong chế độ gia tăng. Args: - group_id: 群组 ID + group_id: ID nhóm. Returns: - int: 最后分析消息的 epoch 时间戳,不存在则返回 0 + Epoch timestamp cuối hoặc 0 nếu chưa có. """ key = self._last_ts_key(group_id) try: data = await self.plugin.get_kv_data(key, 0) return int(data) if data else 0 except Exception as e: - logger.error(f"读取最后分析时间戳失败 (Key: {key}): {e}", exc_info=True) + logger.error( + f"Đọc timestamp phân tích cuối thất bại (Key: {key}): {e}", + exc_info=True, + ) return 0 async def update_last_analyzed_timestamp( self, group_id: str, timestamp: int ) -> None: """ - 更新指定群的最后分析消息时间戳。 + Cập nhật timestamp tin nhắn phân tích cuối của nhóm. Args: - group_id: 群组 ID - timestamp: 最后分析消息的 epoch 时间戳 + group_id: ID nhóm. + timestamp: Epoch timestamp của tin nhắn phân tích cuối. """ key = self._last_ts_key(group_id) try: await self.plugin.put_kv_data(key, timestamp) - logger.debug(f"更新最后分析时间戳: 群 {group_id}, ts={timestamp}") + logger.debug( + f"Đã cập nhật timestamp phân tích cuối: nhóm {group_id}, ts={timestamp}" + ) except Exception as e: - logger.error(f"更新最后分析时间戳失败 (Key: {key}): {e}", exc_info=True) + logger.error( + f"Cập nhật timestamp phân tích cuối thất bại (Key: {key}): {e}", + exc_info=True, + ) raise # ================================================================ - # 过期批次清理 + # Dọn batch hết hạn # ================================================================ async def cleanup_old_batches(self, group_id: str, before_timestamp: float) -> int: """ - 清理指定群中早于给定时间戳的所有批次。 + Xoá mọi batch của nhóm cũ hơn timestamp chỉ định. - 流程: - 1. 从索引中分离出过期条目和保留条目 - 2. 逐个删除过期批次的 KV 数据 - 3. 用保留条目覆盖索引 + Tách mục hết hạn và còn giữ, xoá KV của batch hết hạn rồi ghi lại index. Args: - group_id: 群组 ID - before_timestamp: 清理此时间戳之前的所有批次 + group_id: ID nhóm. + before_timestamp: Xoá mọi batch trước timestamp này. Returns: - int: 已清理的批次数量 + Số batch đã xoá. """ index = await self._get_index(group_id) if not index: return 0 - # 分离过期和保留 + # Tách mục hết hạn và mục cần giữ. expired = [] retained = [] for entry in index: @@ -290,7 +288,7 @@ class IncrementalStore: if not expired: return 0 - # 删除过期批次数据 + # Xoá dữ liệu batch hết hạn. deleted_count = 0 for entry in expired: batch_id = entry.get("batch_id", "") @@ -302,50 +300,50 @@ class IncrementalStore: deleted_count += 1 except Exception as e: logger.error( - f"删除过期批次失败 (群 {group_id}, 批次 {batch_id[:8]}...): {e}", + f"Xoá batch hết hạn thất bại (nhóm {group_id}, batch {batch_id[:8]}...): {e}", exc_info=True, ) - # 更新索引(仅保留未过期条目) + # Cập nhật index, chỉ giữ mục chưa hết hạn. await self._save_index(group_id, retained) logger.info( - f"清理过期批次: 群 {group_id}, " - f"删除 {deleted_count} 个, 保留 {len(retained)} 个" + f"Đã dọn batch hết hạn: nhóm {group_id}, " + f"xoá {deleted_count}, giữ {len(retained)}" ) return deleted_count # ================================================================ - # 状态查询 + # Truy vấn trạng thái # ================================================================ async def get_batch_count(self, group_id: str) -> int: """ - 获取指定群的当前批次总数。 + Lấy tổng số batch hiện tại của nhóm. Args: - group_id: 群组 ID + group_id: ID nhóm. Returns: - int: 批次总数 + Tổng số batch. """ index = await self._get_index(group_id) return len(index) async def get_all_batch_summaries(self, group_id: str) -> list[dict]: """ - 获取指定群所有批次的摘要信息(不加载完整数据)。 + Lấy tóm tắt mọi batch của nhóm mà không tải toàn bộ dữ liệu. - 用于状态查询命令展示批次概览。 + Dùng để hiển thị tổng quan batch trong command trạng thái. Args: - group_id: 群组 ID + group_id: ID nhóm. Returns: - list[dict]: 批次摘要列表,按时间升序 + Danh sách tóm tắt batch tăng dần theo thời gian. """ index = await self._get_index(group_id) - # 按时间戳升序排列 + # Sắp xếp tăng dần theo timestamp. index.sort(key=lambda x: x.get("timestamp", 0)) return index diff --git a/src/infrastructure/persistence/telegram_group_registry.py b/src/infrastructure/persistence/telegram_group_registry.py index c64ba96..183c60c 100644 --- a/src/infrastructure/persistence/telegram_group_registry.py +++ b/src/infrastructure/persistence/telegram_group_registry.py @@ -6,10 +6,10 @@ from astrbot.api.star import Star class TelegramGroupRegistry: """ - Telegram 群组/话题注册表 + Registry nhóm và chủ đề Telegram. - 负责管理 Telegram 的已见群组和话题列表,用于在无法通过 API 获取群列表时提供回退支持。 - 数据存储在 AstrBot 的 KV 存储中。 + Quản lý danh sách nhóm và chủ đề Telegram đã thấy, dùng làm fallback khi + API không thể cung cấp danh sách nhóm. Dữ liệu được lưu trong KV của AstrBot. """ _KV_KEY = "telegram_seen_groups_v1" @@ -26,7 +26,7 @@ class TelegramGroupRegistry: sender_name: str, event_message_id: str, ) -> None: - """更新 Telegram 已见群/话题注册表(KV)。""" + """Cập nhật registry nhóm và chủ đề Telegram đã thấy trong KV.""" async with self._lock: registry = await self.plugin.get_kv_data(self._KV_KEY, {}) if not isinstance(registry, dict): @@ -70,7 +70,7 @@ class TelegramGroupRegistry: await self.plugin.put_kv_data(self._KV_KEY, registry) async def get_all_group_ids(self, platform_id: str | None = None) -> list[str]: - """读取 Telegram 已见群/话题列表。""" + """Đọc danh sách nhóm và chủ đề Telegram đã thấy.""" async with self._lock: registry = await self.plugin.get_kv_data(self._KV_KEY, {}) if not isinstance(registry, dict): diff --git a/src/infrastructure/platform/adapters/discord_adapter.py b/src/infrastructure/platform/adapters/discord_adapter.py index 1157e51..7134511 100644 --- a/src/infrastructure/platform/adapters/discord_adapter.py +++ b/src/infrastructure/platform/adapters/discord_adapter.py @@ -1,11 +1,8 @@ """ -Discord 平台适配器 +Adapter nền tảng Discord. -为 Discord 平台提供消息获取、发送和群组管理功能。 -这是一个骨架实现,展示如何为新平台创建适配器。 - -注意:Discord 的消息获取需要使用 Discord API, -具体实现取决于 AstrBot 的 Discord 集成方式。 +Cung cấp khả năng lấy và gửi tin nhắn cùng quản lý nhóm. Việc lấy tin nhắn +phụ thuộc cách AstrBot tích hợp Discord API. """ from datetime import datetime, timedelta @@ -33,47 +30,47 @@ from ..base import PlatformAdapter class DiscordAdapter(PlatformAdapter): """ - 具体实现:Discord 平台适配器 + Triển khai adapter nền tảng Discord. - 利用 Discord API 为群组(频道)提供消息获取、发送及基础元数据查询功能。 - 由于 Discord 的高度异步特性和复杂的权限模型,该适配器集成了懒加载客户端和多级频道查询机制。 + Dùng Discord API để lấy/gửi tin nhắn và truy vấn metadata cơ bản cho kênh. + Adapter tích hợp lazy loading client và cơ chế truy vấn kênh nhiều cấp. Attributes: - bot_user_id (str): 机器人自身的 Discord 用户 ID + bot_user_id: ID Discord của bot. """ def __init__(self, bot_instance: Any, config: dict | None = None): """ - 初始化 Discord 适配器。 + Khởi tạo adapter Discord. Args: - bot_instance (Any): 宿主机器人实例 - config (dict, optional): 配置项,用于提取机器人自身的 Discord ID + bot_instance: Instance bot chủ quản. + config: Cấu hình dùng để lấy ID Discord của bot. """ super().__init__(bot_instance, config) - # 机器人自己的用户 ID,用于消息过滤(避免分析博取回复) + # ID của bot dùng để lọc tin nhắn do bot gửi. self.bot_user_id = str(config.get("bot_user_id", "")) if config else "" - # 缓存 Discord 客户端(Lazy Loading) + # Cache Discord client theo lazy loading. self._cached_client = None @property def _discord_client(self) -> Any: """ - 内部属性:获取实际的 Discord 客户端实例。 + Lấy instance Discord client thực tế. - 具备懒加载和自动身份嗅探功能。 + Hỗ trợ lazy loading và tự nhận diện client. Returns: - Any: Discord Client 对象 + Đối tượng Discord Client. """ if self._cached_client: return self._cached_client - # 执行路径探测逻辑,兼容不同版本的 AstrBot 宿主结构 + # Dò đường dẫn để tương thích nhiều cấu trúc AstrBot. self._cached_client = self._get_discord_client() - # 兜底:尝试从客户端连接状态中补全机器人 ID + # Fallback: lấy ID bot từ trạng thái kết nối client. if not self.bot_user_id and self._cached_client: if hasattr(self._cached_client, "user") and self._cached_client.user: self.bot_user_id = str(self._cached_client.user.id) @@ -81,27 +78,27 @@ class DiscordAdapter(PlatformAdapter): return self._cached_client def _get_discord_client(self) -> Any: - """内部方法:通过多级探测从 bot_instance 中提取 Discord SDK 客户端。""" - # 路径 A:bot 本身就是 Client (如小型集成) + """Dò nhiều cấp để lấy Discord SDK client từ bot_instance.""" + # Đường A: bot chính là Client. if hasattr(self.bot, "get_channel"): return self.bot - # 路径 B:bot 是包装器,client 在标准成员变量中 + # Đường B: bot là wrapper và client nằm trong thuộc tính chuẩn. if hasattr(self.bot, "client"): return self.bot.client - # 路径 C:其他常见私有属性名 + # Đường C: các tên thuộc tính riêng phổ biến khác. for attr in ("_client", "discord_client", "_discord_client"): if hasattr(self.bot, attr): client = getattr(self.bot, attr) if hasattr(client, "get_channel"): return client - logger.warning(f"无法从 {type(self.bot).__name__} 中提取 Discord 客户端实例") + logger.warning(f"Không thể lấy Discord client từ {type(self.bot).__name__}") return None def _init_capabilities(self) -> PlatformCapabilities: - """返回预定义的 Discord 平台能力集。""" + """Trả về bộ năng lực Discord được định nghĩa sẵn.""" return DISCORD_CAPABILITIES - # ==================== IMessageRepository 实现 ==================== + # ==================== Triển khai IMessageRepository ==================== async def fetch_messages( self, @@ -112,36 +109,38 @@ class DiscordAdapter(PlatformAdapter): since_ts: int | None = None, ) -> list[UnifiedMessage]: """ - 从 Discord 频道异步拉取历史消息记录。 + Lấy bất đồng bộ lịch sử tin nhắn từ kênh Discord. Args: - group_id (str): Discord 频道 (Channel) ID - days (int): 查询天数范围 - max_count (int): 最大拉取消息数量上限 - before_id (str, optional): 锚点消息 ID,从此之前开始拉取 + group_id: ID kênh Discord. + days: Phạm vi số ngày truy vấn. + max_count: Số tin nhắn tối đa. + before_id: ID tin nhắn mốc; lấy các tin trước đó. Returns: - list[UnifiedMessage]: 统一格式的消息对象列表 + Danh sách tin nhắn định dạng thống nhất. """ if not discord: - logger.error("未找到 Discord 模块 (py-cord),无法拉取历史消息。") + logger.error( + "Không tìm thấy module Discord (py-cord), không thể lấy lịch sử" + ) return [] try: channel_id = int(group_id) - # 先从缓存尝试获取频道 + # Thử lấy kênh từ cache trước. channel = self._discord_client.get_channel(channel_id) if not channel: - # 缓存未命中则通过网络 fetch + # Nếu cache miss thì fetch qua mạng. try: channel = await self._discord_client.fetch_channel(channel_id) except Exception as e: - logger.debug(f"拉取 Discord 频道 {group_id} 失败: {e}") + logger.debug(f"Lấy kênh Discord {group_id} thất bại: {e}") return [] - # 验证权限:确保支持历史消息流 + # Xác thực hỗ trợ truy cập lịch sử. if not hasattr(channel, "history"): - logger.warning(f"频道 {group_id} 不支持历史消息访问。") + logger.warning(f"Kênh {group_id} không hỗ trợ truy cập lịch sử") return [] if since_ts and since_ts > 0: @@ -152,18 +151,18 @@ class DiscordAdapter(PlatformAdapter): messages = [] - # 构建 Discord SDK 的 history 查询参数 + # Xây dựng tham số truy vấn history cho Discord SDK. history_kwargs = {"limit": max_count, "after": start_time} if before_id: try: - # 使用 Snowflake ID 指向特定消息 + # Dùng Snowflake ID để trỏ tới tin nhắn cụ thể. history_kwargs["before"] = discord.Object(id=int(before_id)) except (ValueError, TypeError): pass - # 消息迭代处理 + # Duyệt và xử lý tin nhắn. async for msg in channel.history(**history_kwargs): - # 排除机器人自身发布的消息 + # Loại tin nhắn do chính bot gửi. if self.bot_user_id and str(msg.author.id) == self.bot_user_id: continue @@ -171,7 +170,7 @@ class DiscordAdapter(PlatformAdapter): if unified: messages.append(unified) - # 排序回升序(SDK 通常返回降序) + # Sắp xếp tăng dần vì SDK thường trả về giảm dần. messages.sort(key=lambda m: m.timestamp) return messages @@ -180,17 +179,17 @@ class DiscordAdapter(PlatformAdapter): return [] def _convert_message(self, raw_msg: Any, group_id: str) -> UnifiedMessage | None: - """内部方法:将 `discord.Message` 对象转换为统一的 `UnifiedMessage`。""" + """Chuyển ``discord.Message`` thành ``UnifiedMessage``.""" try: contents = [] - # 1. 基础文本 + # 1. Văn bản cơ bản. if raw_msg.content: contents.append( MessageContent(type=MessageContentType.TEXT, text=raw_msg.content) ) - # 2. 附件处理 (图片/视频/语音/普通文件) + # 2. Xử lý tệp đính kèm: ảnh, video, âm thanh và tệp thường. for attachment in raw_msg.attachments: content_type = attachment.content_type or "" if content_type.startswith("image/"): @@ -223,7 +222,7 @@ class DiscordAdapter(PlatformAdapter): ) ) - # 3. 嵌入内容处理 (部分 Embed 可能包含富文本描述) + # 3. Xử lý nội dung embed có mô tả rich text. for embed in raw_msg.embeds: if embed.image: contents.append( @@ -239,12 +238,12 @@ class DiscordAdapter(PlatformAdapter): ) ) - # 4. 贴纸处理 (Stickers) + # 4. Xử lý sticker. if raw_msg.stickers: for sticker in raw_msg.stickers: contents.append( MessageContent( - type=MessageContentType.IMAGE, # 贴纸在逻辑上按图片处理 + type=MessageContentType.IMAGE, # Xử lý sticker như ảnh. url=sticker.url, raw_data={ "sticker_id": str(sticker.id), @@ -253,7 +252,7 @@ class DiscordAdapter(PlatformAdapter): ) ) - # 确定发送者的显示名称(服务器昵称 > 全局名称 > 用户名) + # Tên hiển thị: biệt danh server > tên toàn cục > username. sender_card = None if hasattr(raw_msg.author, "nick") and raw_msg.author.nick: sender_card = raw_msg.author.nick @@ -275,11 +274,11 @@ class DiscordAdapter(PlatformAdapter): else None, ) except Exception as e: - logger.debug(f"Discord 消息转换错误: {e}") + logger.debug(f"Lỗi chuyển đổi tin nhắn Discord: {e}") return None def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: - """将统一格式降级转换为 OneBot 风格的字典,以适配下游组件。""" + """Chuyển định dạng thống nhất thành dict kiểu OneBot cho tầng sau.""" raw_messages = [] for msg in messages: raw_msg = { @@ -292,7 +291,7 @@ class DiscordAdapter(PlatformAdapter): "card": msg.sender_card, }, "message": [], - "user_id": msg.sender_id, # 后向兼容 + "user_id": msg.sender_id, # Tương thích ngược. } for content in msg.contents: @@ -323,7 +322,7 @@ class DiscordAdapter(PlatformAdapter): raw_messages.append(raw_msg) return raw_messages - # ==================== IMessageSender 实现 ==================== + # ==================== Triển khai IMessageSender ==================== async def send_text( self, @@ -332,15 +331,15 @@ class DiscordAdapter(PlatformAdapter): reply_to: str | None = None, ) -> bool: """ - 向 Discord 频道发送文本消息。 + Gửi tin nhắn văn bản tới kênh Discord. Args: - group_id (str): 频道 ID - text (str): 文本内容 - reply_to (str, optional): 引用的消息 ID + group_id: ID kênh. + text: Nội dung văn bản. + reply_to: ID tin nhắn được trả lời. Returns: - bool: 是否发送成功 + True nếu gửi thành công. """ if not discord: return False @@ -366,7 +365,7 @@ class DiscordAdapter(PlatformAdapter): await channel.send(content=text, reference=reference) return True except Exception as e: - logger.error(f"Discord 文本发送失败: {e}") + logger.error(f"Gửi văn bản Discord thất bại: {e}") return False async def send_image( @@ -376,17 +375,17 @@ class DiscordAdapter(PlatformAdapter): caption: str = "", ) -> bool: """ - 向 Discord 频道异步发送图片。 + Gửi ảnh bất đồng bộ tới kênh Discord. - 对于远程 URL,会先下载到内存再通过 Discord API 发送。 + URL từ xa được tải vào bộ nhớ trước khi gửi qua Discord API. Args: - group_id (str): 频道 ID - image_path (str): 本地路径或 http URL - caption (str): 可选说明文字 + group_id: ID kênh. + image_path: Đường dẫn cục bộ hoặc URL HTTP. + caption: Chú thích tuỳ chọn. Returns: - bool: 是否发送成功 + True nếu gửi thành công. """ if not discord: return False @@ -402,7 +401,7 @@ class DiscordAdapter(PlatformAdapter): file_to_send = None if image_path.startswith("base64://"): - # Base64 图片:解码 -> 内存 Object -> Discord + # Ảnh Base64: decode -> object trong bộ nhớ -> Discord. import base64 # Fix: Ensure base64 is imported from io import BytesIO @@ -413,11 +412,11 @@ class DiscordAdapter(PlatformAdapter): BytesIO(image_bytes), filename="daily_report_image.png" ) except Exception as e: - logger.error(f"Discord Base64 图片解码失败: {e}") + logger.error(f"Giải mã ảnh Base64 Discord thất bại: {e}") return False elif image_path.startswith(("http://", "https://")): - # 远程图片:下载 -> 内存 Object -> Discord + # Ảnh từ xa: tải -> object trong bộ nhớ -> Discord. from io import BytesIO import aiohttp @@ -429,7 +428,7 @@ class DiscordAdapter(PlatformAdapter): ) as resp: if resp.status == 200: data = await resp.read() - # 尽量保留原始后缀 + # Cố gắng giữ phần mở rộng gốc. filename = image_path.split("/")[-1].split("?")[0] if not filename.lower().endswith( (".png", ".jpg", ".jpeg", ".gif", ".webp") @@ -440,7 +439,7 @@ class DiscordAdapter(PlatformAdapter): BytesIO(data), filename=filename ) else: - # 兜底:如果下载失败,直接发 URL 给 Discord 尝试自动解析 + # Fallback: gửi URL để Discord tự phân tích. content = ( f"{caption}\n{image_path}" if caption @@ -450,13 +449,13 @@ class DiscordAdapter(PlatformAdapter): return True except Exception as de: logger.warning( - f"Discord 远程图片下载失败: {de},将回退为发送 URL。" + f"Tải ảnh Discord từ xa thất bại: {de}; chuyển sang gửi URL." ) content = f"{caption}\n{image_path}" if caption else image_path await channel.send(content=content) return True else: - # 本地图片 + # Ảnh cục bộ. file_to_send = discord.File(image_path) if file_to_send: @@ -464,7 +463,7 @@ class DiscordAdapter(PlatformAdapter): return True except Exception as e: - logger.error(f"Discord 图片发送失败: {e}") + logger.error(f"Gửi ảnh Discord thất bại: {e}") return False async def send_file( @@ -473,7 +472,7 @@ class DiscordAdapter(PlatformAdapter): file_path: str, filename: str | None = None, ) -> bool: - """向 Discord 频道上传任意文件。""" + """Tải tệp bất kỳ lên kênh Discord.""" if not discord: return False @@ -490,7 +489,7 @@ class DiscordAdapter(PlatformAdapter): await channel.send(file=file_to_send) return True except Exception as e: - logger.error(f"Discord 文件发送失败: {e}") + logger.error(f"Gửi tệp Discord thất bại: {e}") return False async def send_forward_msg( @@ -499,9 +498,9 @@ class DiscordAdapter(PlatformAdapter): nodes: list[dict], ) -> bool: """ - 在 Discord 模拟合并转发。 + Mô phỏng chuyển tiếp gộp trên Discord. - 由于 Discord 没有原生节点转发 API,我们将其转换为一组文本消息发送。 + Discord không có API chuyển tiếp node gốc nên chuyển thành nhóm tin văn bản. """ if not discord: return False @@ -515,17 +514,17 @@ class DiscordAdapter(PlatformAdapter): if not hasattr(channel, "send"): return False - # 将节点汇总为美化的文本块 - lines = ["📊 **结构化报告摘要 (Structured Report)**\n"] + # Tổng hợp node thành khối văn bản có định dạng. + lines = ["📊 **Tóm tắt báo cáo có cấu trúc (Structured Report)**\n"] for node in nodes: - data = node.get("data", node) # 兼容不同格式 + data = node.get("data", node) # Tương thích nhiều định dạng. name = data.get("name", "AstrBot") content = data.get("content", "") lines.append(f"**[{name}]**:\n{content}\n") full_text = "\n".join(lines) - # 分段处理大消息 + # Chia nhỏ tin nhắn dài. if len(full_text) > 1900: parts = [ full_text[i : i + 1900] for i in range(0, len(full_text), 1900) @@ -537,13 +536,13 @@ class DiscordAdapter(PlatformAdapter): return True except Exception as e: - logger.error(f"Discord 模拟转发失败: {e}") + logger.error(f"Mô phỏng chuyển tiếp Discord thất bại: {e}") return False - # ==================== IGroupInfoRepository 实现 ==================== + # ==================== Triển khai IGroupInfoRepository ==================== async def get_group_info(self, group_id: str) -> UnifiedGroup | None: - """解析 Discord 频道及所属服务器的基本信息。""" + """Phân tích thông tin cơ bản của kênh và server Discord.""" if not discord: return None @@ -557,11 +556,11 @@ class DiscordAdapter(PlatformAdapter): group_name = getattr(channel, "name", str(channel.id)) if guild: - # 群聊(服务器频道) + # Kênh server. member_count = guild.member_count owner_id = str(guild.owner_id) else: - # 私人对话(DM) + # Tin nhắn riêng. member_count = len(getattr(channel, "recipients", [])) + 1 owner_id = str(getattr(channel, "owner_id", "")) @@ -574,11 +573,11 @@ class DiscordAdapter(PlatformAdapter): platform="discord", ) except Exception as e: - logger.debug(f"Discord 获取群组信息错误: {e}") + logger.debug(f"Lỗi lấy thông tin nhóm Discord: {e}") return None async def get_group_list(self) -> list[str]: - """列出机器人所在服务器中所有可访问的文本频道 ID。""" + """Liệt kê ID kênh văn bản bot có thể truy cập trên các server.""" if not discord: return [] @@ -593,9 +592,9 @@ class DiscordAdapter(PlatformAdapter): async def get_member_list(self, group_id: str) -> list[UnifiedMember]: """ - 获取频道对应的成员列表。 + Lấy danh sách thành viên của kênh. - 注意:对于大型服务器,建议启用 GUILD_MEMBERS 意图以保证列表完整性。 + Nên bật intent GUILD_MEMBERS trên server lớn để đảm bảo đầy đủ. """ if not discord: return [] @@ -608,7 +607,7 @@ class DiscordAdapter(PlatformAdapter): guild = getattr(channel, "guild", None) if not guild: - # 私聊收件人 + # Người nhận tin nhắn riêng. return [ UnifiedMember( user_id=str(u.id), @@ -647,7 +646,7 @@ class DiscordAdapter(PlatformAdapter): group_id: str, user_id: str, ) -> UnifiedMember | None: - """获取并解析特定 Discord 用户的身份信息。""" + """Lấy và phân tích thông tin định danh của thành viên Discord.""" if not discord: return None @@ -660,7 +659,7 @@ class DiscordAdapter(PlatformAdapter): guild = getattr(channel, "guild", None) if not guild: - # 跨频道/私聊探测 + # Dò xuyên kênh hoặc tin nhắn riêng. user = await self.bot.fetch_user(uid) return UnifiedMember( user_id=str(user.id), nickname=user.name, card=user.display_name @@ -688,14 +687,14 @@ class DiscordAdapter(PlatformAdapter): except Exception: return None - # ==================== IAvatarRepository 实现 ==================== + # ==================== Triển khai IAvatarRepository ==================== async def get_user_avatar_url( self, user_id: str, size: int = 100, ) -> str | None: - """根据 Discord 用户 ID 动态解析其头像 CDN 地址。""" + """Phân giải động URL CDN avatar theo ID thành viên Discord.""" if not discord or not self._discord_client: return None @@ -706,14 +705,14 @@ class DiscordAdapter(PlatformAdapter): ) or await self._discord_client.fetch_user(uid) if user: - # 自动对齐 Discord 支持的尺寸 (2的幂) + # Chọn kích thước gần nhất được Discord hỗ trợ. allowed_sizes = (16, 32, 64, 128, 256, 512, 1024, 2048, 4096) target_size = min(allowed_sizes, key=lambda x: abs(x - size)) return user.display_avatar.with_size(target_size).url return None except Exception as e: - logger.debug(f"Discord 获取用户头像 URL 错误: {e}") + logger.debug(f"Lỗi lấy URL avatar Discord: {e}") return None async def get_user_avatar_data( @@ -721,7 +720,7 @@ class DiscordAdapter(PlatformAdapter): user_id: str, size: int = 100, ) -> str | None: - """暂不提供 Base64 转换服务,优先使用 CDN 链接。""" + """Chưa hỗ trợ Base64; ưu tiên URL CDN.""" return None async def get_group_avatar_url( @@ -729,7 +728,7 @@ class DiscordAdapter(PlatformAdapter): group_id: str, size: int = 100, ) -> str | None: - """获取 Discord 服务器(Guild)的图标地址。""" + """Lấy URL biểu tượng server Discord.""" if not discord: return None @@ -751,14 +750,14 @@ class DiscordAdapter(PlatformAdapter): user_ids: list[str], size: int = 100, ) -> dict[str, str | None]: - """批量获取头像的最佳实践。""" + """Lấy hàng loạt URL avatar.""" return {uid: await self.get_user_avatar_url(uid, size) for uid in user_ids} async def set_reaction( self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True ) -> bool: """ - Discord 实现消息回应。 + Triển khai reaction tin nhắn Discord. """ if not discord: return False @@ -779,7 +778,7 @@ class DiscordAdapter(PlatformAdapter): channel = await self._discord_client.fetch_channel(channel_id) if not hasattr(channel, "get_partial_message"): - # 如果较低版本的 SDK 没这个方法,则直接 fetch + # Fetch trực tiếp nếu SDK cũ không có phương thức này. msg = await channel.fetch_message(int(message_id)) else: msg = channel.get_partial_message(int(message_id)) @@ -790,5 +789,5 @@ class DiscordAdapter(PlatformAdapter): await msg.remove_reaction(emoji_to_use, self._discord_client.user) return True except Exception as e: - logger.debug(f"Discord set_reaction 失败: {e}") + logger.debug(f"Discord set_reaction thất bại: {e}") return False diff --git a/src/infrastructure/platform/adapters/lark_adapter.py b/src/infrastructure/platform/adapters/lark_adapter.py index 092c26f..40d2e28 100644 --- a/src/infrastructure/platform/adapters/lark_adapter.py +++ b/src/infrastructure/platform/adapters/lark_adapter.py @@ -1,7 +1,8 @@ """ -Feishu/Lark 平台适配器 +Adapter nền tảng Feishu/Lark. -复用 AstrBot 已有 lark_oapi 生态能力,实现飞书群分析消息读取、成员信息与头像获取。 +Tái sử dụng hệ sinh thái lark_oapi của AstrBot để lấy tin nhắn phân tích nhóm, +thông tin thành viên và ảnh đại diện Feishu. """ from __future__ import annotations @@ -117,18 +118,18 @@ try: ReplyMessageRequestBody = _ReplyMessageRequestBody LARK_AVAILABLE = True -except Exception: # pragma: no cover - 兼容缺依赖环境 +except Exception: # pragma: no cover - tương thích môi trường thiếu dependency LARK_AVAILABLE = False class LarkAdapter(PlatformAdapter): - """飞书平台适配器。""" + """Adapter nền tảng Feishu.""" platform_name = "lark" _DEFAULT_SCOPE_HINT = ( - "Please grant these Feishu app scopes once: " - "`im:message:readonly`, `im:chat:readonly`, and user/contact read scopes " - "for profile avatar fields, then reinstall/re-authorize the app." + "Vui lòng cấp các scope Feishu `im:message:readonly`, " + "`im:chat:readonly` và quyền đọc người dùng/danh bạ để lấy ảnh đại diện, " + "sau đó cài đặt hoặc uỷ quyền lại ứng dụng." ) def __init__( @@ -145,7 +146,7 @@ class LarkAdapter(PlatformAdapter): self._permission_checked_groups: set[str] = set() self._permission_error_by_group: dict[str, str | None] = {} logger.info( - "飞书适配器初始化完成 (SDK可用=%s, 客户端就绪=%s)", + "Đã khởi tạo adapter Feishu (SDK khả dụng=%s, client sẵn sàng=%s)", LARK_AVAILABLE, bool(self._lark_client), ) @@ -169,15 +170,15 @@ class LarkAdapter(PlatformAdapter): def _resolve_lark_client(bot_instance: object) -> _SDKNode | None: if bot_instance is None: return None - # 直接是 lark.Client + # Chính là lark.Client. if hasattr(bot_instance, "im") and hasattr(bot_instance, "contact"): return cast(_SDKNode, bot_instance) - # 平台实例上暴露 lark_api + # Instance nền tảng công khai lark_api. if hasattr(bot_instance, "lark_api"): api = getattr(bot_instance, "lark_api") if hasattr(api, "im"): return cast(_SDKNode, api) - # 常见包装层 + # Các lớp wrapper phổ biến. for attr in ("client", "_client", "bot"): if hasattr(bot_instance, attr): client = getattr(bot_instance, attr) @@ -305,22 +306,29 @@ class LarkAdapter(PlatformAdapter): self, group_id: str ) -> tuple[bool, str | None]: """ - 预热群成员缓存并完成权限探测。 - 该方法用于在分析前一次性确认“成员信息+头像”权限是否齐备。 + Làm nóng cache thành viên nhóm và kiểm tra quyền. + + Xác nhận một lần trước khi phân tích rằng quyền đọc thông tin thành viên + và ảnh đại diện đã đầy đủ. """ if group_id in self._permission_checked_groups: err = self._permission_error_by_group.get(group_id) logger.debug( - "飞书预检查命中缓存: 群=%s, 结果=%s", + "Kiểm tra trước Feishu dùng cache: nhóm=%s, kết quả=%s", group_id, err is None, ) return err is None, err - logger.info("飞书预检查开始: 群=%s", group_id) + logger.info("Bắt đầu kiểm tra trước Feishu: nhóm=%s", group_id) if not LARK_AVAILABLE or not self._lark_client or not self._lark_client.im: - self._permission_error_by_group[group_id] = "飞书 SDK 客户端未初始化。" + self._permission_error_by_group[group_id] = ( + "Client SDK Feishu chưa được khởi tạo." + ) self._permission_checked_groups.add(group_id) - logger.warning("飞书预检查失败: 群=%s, 原因=SDK或客户端不可用", group_id) + logger.warning( + "Kiểm tra trước Feishu thất bại: nhóm=%s, lý do=SDK hoặc client không khả dụng", + group_id, + ) return False, self._permission_error_by_group[group_id] try: @@ -330,10 +338,14 @@ class LarkAdapter(PlatformAdapter): f"Cannot list chat members. {self._DEFAULT_SCOPE_HINT}" ) self._permission_checked_groups.add(group_id) - logger.warning("飞书预检查失败: 群=%s, 原因=未获取到成员列表", group_id) + logger.warning( + "Kiểm tra trước Feishu thất bại: nhóm=%s, lý do=không lấy được danh sách thành viên", + group_id, + ) return False, self._permission_error_by_group[group_id] - # 只预热近期活跃用户常见数量,避免在超大群上引入不必要延迟 + # Chỉ làm nóng số lượng thành viên hoạt động gần đây thường gặp + # để tránh độ trễ không cần thiết ở nhóm rất lớn. target_ids = [m.user_id for m in members[:100]] avatar_map = await self.batch_get_avatar_urls(target_ids, size=240) avatar_ok_count = sum(1 for uid in target_ids if avatar_map.get(uid)) @@ -344,7 +356,7 @@ class LarkAdapter(PlatformAdapter): ) self._permission_checked_groups.add(group_id) logger.warning( - "飞书预检查失败: 群=%s, 原因=头像预热结果为空 (用户数=%s)", + "Kiểm tra trước Feishu thất bại: nhóm=%s, lý do=kết quả làm nóng ảnh rỗng (thành viên=%s)", group_id, len(target_ids), ) @@ -353,7 +365,7 @@ class LarkAdapter(PlatformAdapter): self._permission_error_by_group[group_id] = None self._permission_checked_groups.add(group_id) logger.info( - "飞书预检查通过: 群=%s (成员=%s, 头像成功=%s/%s)", + "Kiểm tra trước Feishu thành công: nhóm=%s (thành viên=%s, ảnh thành công=%s/%s)", group_id, len(members), avatar_ok_count, @@ -362,11 +374,11 @@ class LarkAdapter(PlatformAdapter): return True, None except Exception as e: self._permission_error_by_group[group_id] = ( - f"飞书成员缓存预热失败: {e}. {self._DEFAULT_SCOPE_HINT}" + f"Làm nóng cache thành viên Feishu thất bại: {e}. {self._DEFAULT_SCOPE_HINT}" ) self._permission_checked_groups.add(group_id) logger.error( - "飞书预检查异常: 群=%s, 错误=%s", + "Lỗi kiểm tra trước Feishu: nhóm=%s, lỗi=%s", group_id, e, exc_info=True, @@ -382,7 +394,10 @@ class LarkAdapter(PlatformAdapter): since_ts: int | None = None, ) -> list[UnifiedMessage]: if not LARK_AVAILABLE or not self._lark_client or not self._lark_client.im: - logger.warning("飞书消息拉取跳过: 群=%s, 原因=SDK或客户端不可用", group_id) + logger.warning( + "Bỏ qua lấy tin nhắn Feishu: nhóm=%s, lý do=SDK hoặc client không khả dụng", + group_id, + ) return [] now_seconds = int(__import__("time").time()) start_seconds = ( @@ -395,7 +410,7 @@ class LarkAdapter(PlatformAdapter): seen_ids: set[str] = set() page_index = 0 logger.info( - "飞书消息拉取开始: 群=%s (天数=%s, 最大条数=%s, since_ts=%s)", + "Bắt đầu lấy tin nhắn Feishu: nhóm=%s (ngày=%s, tối đa=%s, since_ts=%s)", group_id, days, max_count, @@ -431,7 +446,7 @@ class LarkAdapter(PlatformAdapter): response = await self._lark_client.im.v1.message.alist(request) if not response.success(): logger.warning( - "飞书消息拉取失败: 群=%s, code=%s, msg=%s", + "Lấy tin nhắn Feishu thất bại: nhóm=%s, code=%s, msg=%s", group_id, response.code, response.msg, @@ -441,7 +456,7 @@ class LarkAdapter(PlatformAdapter): items_raw = (response.data.items if response.data else None) or [] items: list[object] = items_raw if isinstance(items_raw, list) else [] logger.debug( - "飞书消息分页: 页=%s, 群=%s, 条数=%s, has_more=%s", + "Phân trang tin nhắn Feishu: trang=%s, nhóm=%s, số lượng=%s, has_more=%s", page_index, group_id, len(items), @@ -471,7 +486,7 @@ class LarkAdapter(PlatformAdapter): messages.sort(key=lambda m: m.timestamp) logger.info( - "飞书消息拉取完成: 群=%s (消息=%s, 页数=%s, 起始=%s, 结束=%s)", + "Hoàn tất lấy tin nhắn Feishu: nhóm=%s (tin nhắn=%s, trang=%s, bắt đầu=%s, kết thúc=%s)", group_id, len(messages), page_index, @@ -612,7 +627,7 @@ class LarkAdapter(PlatformAdapter): ), ) except Exception as e: - logger.debug(f"飞书消息转换失败: {e}") + logger.debug(f"Chuyển đổi tin nhắn Feishu thất bại: {e}") return None def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: @@ -698,7 +713,7 @@ class LarkAdapter(PlatformAdapter): response = await self._lark_client.im.v1.message.acreate(request) return bool(response.success()) except Exception as e: - logger.error(f"飞书文本发送失败: {e}") + logger.error(f"Gửi văn bản Feishu thất bại: {e}") return False async def send_image( @@ -790,7 +805,7 @@ class LarkAdapter(PlatformAdapter): await self.send_text(group_id, caption) return bool(send_resp.success()) except Exception as e: - logger.error(f"飞书图片发送失败: {e}") + logger.error(f"Gửi ảnh Feishu thất bại: {e}") return False finally: if temp_path and temp_path.exists(): @@ -854,7 +869,7 @@ class LarkAdapter(PlatformAdapter): msg_resp = await self._lark_client.im.v1.message.acreate(msg_req) return bool(msg_resp.success()) except Exception as e: - logger.error(f"飞书文件发送失败: {e}") + logger.error(f"Gửi tệp Feishu thất bại: {e}") return False async def get_group_info(self, group_id: str) -> UnifiedGroup | None: @@ -880,24 +895,24 @@ class LarkAdapter(PlatformAdapter): platform="lark", ) except Exception as e: - logger.debug(f"飞书群信息获取失败: {e}") + logger.debug(f"Lấy thông tin nhóm Feishu thất bại: {e}") return None async def get_group_list(self) -> list[str]: - # 飞书服务端 API 不提供简单“机器人可见群列表”枚举能力 + # API Feishu không cung cấp cách đơn giản để liệt kê nhóm bot nhìn thấy. return [] async def get_member_list(self, group_id: str) -> list[UnifiedMember]: if not self._lark_client or not self._lark_client.im: logger.warning( - "飞书成员列表获取跳过: 群=%s, 原因=客户端不可用", + "Bỏ qua lấy danh sách thành viên Feishu: nhóm=%s, lý do=client không khả dụng", group_id, ) return [] members: list[UnifiedMember] = [] page_token: str | None = None page_index = 0 - logger.debug("飞书成员列表获取开始: 群=%s", group_id) + logger.debug("Bắt đầu lấy danh sách thành viên Feishu: nhóm=%s", group_id) while True: page_index += 1 GetChatMembersRequestClass = self._request_class_or_throw( @@ -919,14 +934,14 @@ class LarkAdapter(PlatformAdapter): str(getattr(response, "msg", "") or ""), ): logger.warning( - "飞书成员列表权限不足: 群=%s, code=%s, msg=%s", + "Không đủ quyền lấy danh sách thành viên Feishu: nhóm=%s, code=%s, msg=%s", group_id, response.code, response.msg, ) else: logger.warning( - "飞书成员列表获取失败: 群=%s, code=%s, msg=%s", + "Lấy danh sách thành viên Feishu thất bại: nhóm=%s, code=%s, msg=%s", group_id, getattr(response, "code", "unknown"), getattr(response, "msg", "unknown"), @@ -935,7 +950,7 @@ class LarkAdapter(PlatformAdapter): items_raw = (response.data.items if response.data else None) or [] items: list[object] = items_raw if isinstance(items_raw, list) else [] logger.debug( - "飞书成员分页: 页=%s, 群=%s, 条数=%s", + "Phân trang thành viên Feishu: trang=%s, nhóm=%s, số lượng=%s", page_index, group_id, len(items), @@ -962,7 +977,7 @@ class LarkAdapter(PlatformAdapter): page_token_raw = getattr(response.data, "page_token", None) page_token = str(page_token_raw) if page_token_raw else None logger.info( - "飞书成员列表获取完成: 群=%s (成员=%s, 页数=%s)", + "Hoàn tất lấy thành viên Feishu: nhóm=%s (thành viên=%s, trang=%s)", group_id, len(members), page_index, @@ -989,14 +1004,14 @@ class LarkAdapter(PlatformAdapter): str(getattr(response, "msg", "") or ""), ): logger.warning( - "飞书用户资料权限不足: 用户=%s, code=%s, msg=%s", + "Không đủ quyền lấy hồ sơ Feishu: thành viên=%s, code=%s, msg=%s", self._short_id(user_id), response.code, response.msg, ) else: logger.warning( - "飞书用户资料获取失败: 用户=%s, code=%s, msg=%s", + "Lấy hồ sơ Feishu thất bại: thành viên=%s, code=%s, msg=%s", self._short_id(user_id), getattr(response, "code", "unknown"), getattr(response, "msg", "unknown"), @@ -1004,7 +1019,7 @@ class LarkAdapter(PlatformAdapter): return None return response.data except Exception as e: - logger.debug(f"飞书用户资料获取失败: {e}") + logger.debug(f"Lấy hồ sơ Feishu thất bại: {e}") return None async def get_member_info( @@ -1057,7 +1072,7 @@ class LarkAdapter(PlatformAdapter): fallback_avatar = self._build_fallback_avatar(user_id, cached_name) self._avatar_url_cache[user_id] = fallback_avatar logger.debug( - "飞书头像使用回退图: 用户=%s, 尺寸=%s", + "Ảnh Feishu dùng hình fallback: thành viên=%s, kích thước=%s", self._short_id(user_id), size, ) @@ -1106,7 +1121,7 @@ class LarkAdapter(PlatformAdapter): unique_ids = list(dict.fromkeys(user_ids)) semaphore = asyncio.Semaphore(8) logger.debug( - "飞书批量头像获取开始 (请求=%s, 去重后=%s, 尺寸=%s)", + "Bắt đầu lấy ảnh Feishu hàng loạt (yêu cầu=%s, sau loại trùng=%s, kích thước=%s)", len(user_ids), len(unique_ids), size, @@ -1119,11 +1134,15 @@ class LarkAdapter(PlatformAdapter): pairs = await asyncio.gather(*(_fetch(uid) for uid in unique_ids)) result = dict(pairs) ok_count = sum(1 for _, url in pairs if url) - logger.debug("飞书批量头像获取完成 (成功=%s/%s)", ok_count, len(unique_ids)) + logger.debug( + "Hoàn tất lấy ảnh Feishu hàng loạt (thành công=%s/%s)", + ok_count, + len(unique_ids), + ) return result async def set_reaction( self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True ) -> bool: - # 当前插件分析流程不依赖飞书 reaction,这里返回 False 以保持兼容。 + # Quy trình hiện tại không phụ thuộc reaction Feishu; trả False để tương thích. return False diff --git a/src/infrastructure/platform/adapters/onebot_adapter.py b/src/infrastructure/platform/adapters/onebot_adapter.py index eb6e851..e7eabee 100644 --- a/src/infrastructure/platform/adapters/onebot_adapter.py +++ b/src/infrastructure/platform/adapters/onebot_adapter.py @@ -1,8 +1,4 @@ -""" -OneBot v11 平台适配器 - -支持 NapCat、go-cqhttp、Lagrange 及其他 OneBot 实现。 -""" +"""Adapter OneBot v11 cho NapCat, go-cqhttp, Lagrange và implementation khác.""" import asyncio import base64 @@ -28,35 +24,26 @@ from ..base import PlatformAdapter class OneBotAdapter(PlatformAdapter): - """ - 具体实现:OneBot v11 平台适配器 - - 支持 NapCat, go-cqhttp, Lagrange 等遵循 OneBot v11 协议的 QQ 机器人框架。 - 实现了消息获取、发送、群组管理及头像解析等全套功能。 - - Attributes: - platform_name (str): 平台硬编码标识 'onebot' - bot_self_ids (list[str]): 机器人自身的 QQ 号列表,用于消息过滤 - """ + """Adapter OneBot v11 hỗ trợ tin nhắn, nhóm và avatar cho bot QQ.""" platform_name = "onebot" - # QQ 头像服务 URL 模板 + # Template URL dịch vụ avatar QQ. USER_AVATAR_TEMPLATE = "https://q1.qlogo.cn/g?b=qq&nk={user_id}&s={size}" USER_AVATAR_HD_TEMPLATE = ( "https://q.qlogo.cn/headimg_dl?dst_uin={user_id}&spec={size}&img_type=jpg" ) GROUP_AVATAR_TEMPLATE = "https://p.qlogo.cn/gh/{group_id}/{group_id}/{size}/" - # OneBot 服务支持的头像尺寸像素 + # Kích thước avatar được dịch vụ OneBot hỗ trợ. AVAILABLE_SIZES = (40, 100, 140, 160, 640) def __init__(self, bot_instance: Any, config: dict | None = None): """ - 初始化 OneBot 适配器。 + Khởi tạo adapter OneBot. """ super().__init__(bot_instance, config) - # 支持从多个潜在的配置键中提取机器人 ID + # Hỗ trợ lấy ID bot từ nhiều key cấu hình tiềm năng. self.bot_self_ids = ( [str(id) for id in config.get("bot_self_ids", [])] if config else [] ) @@ -66,41 +53,41 @@ class OneBotAdapter(PlatformAdapter): config.get("filter_bot_messages", True) if config else True ) - # LLBot 探测标志 + # Cờ phát hiện LLBot. self._is_llbot = False self._llbot_checked = False - # SnowLuma 探测标志 + # Cờ phát hiện SnowLuma. self._is_snowluma = False self._snowluma_checked = False - # 禁言状态缓存 (group_id -> timestamp) + # Cache trạng thái mute: group_id -> timestamp. self._muted_groups_cache = {} - # 群角色缓存 (group_id -> (role, timestamp)),用于 get_group_member_info 超时降级 + # Cache role nhóm để fallback khi get_group_member_info timeout. self._group_role_cache: dict[str, tuple[str, float]] = {} def _init_capabilities(self) -> PlatformCapabilities: - """返回预定义的 OneBot v11 能力集。""" + """Trả về capability OneBot v11 định sẵn.""" return ONEBOT_V11_CAPABILITIES async def _detect_llbot(self): - """探测是否为 LLBot""" + """Phát hiện LLBot.""" if self._llbot_checked: return try: - # 避免在一些不支持 get_version_info 的老版本上卡死 + # Tránh treo ở bản cũ không hỗ trợ get_version_info. result = await self.bot.call_action("get_version_info") if isinstance(result, dict): app_name = result.get("app_name", "") self._is_llbot = app_name == "LLOneBot" if self._is_llbot: - logger.info("[OneBot] 探测到当前协议端为 LLBot") + logger.info("[OneBot] Phát hiện endpoint giao thức LLBot") except Exception: self._is_llbot = False self._llbot_checked = True async def _detect_snowluma(self): - """探测是否为 SnowLuma""" + """Phát hiện SnowLuma.""" if self._snowluma_checked: return try: @@ -109,10 +96,10 @@ class OneBotAdapter(PlatformAdapter): app_name = result.get("app_name", "") self._is_snowluma = app_name.lower() == "snowluma" if self._is_snowluma: - logger.info("[OneBot] 探测到当前协议端为 SnowLuma") + logger.info("[OneBot] Phát hiện endpoint giao thức SnowLuma") except Exception as exc: logger.debug( - "[OneBot] 探测 SnowLuma 失败,将按非 SnowLuma 处理: %s", + "[OneBot] Phát hiện SnowLuma thất bại, xử lý như endpoint khác: %s", exc, exc_info=True, ) @@ -120,10 +107,10 @@ class OneBotAdapter(PlatformAdapter): self._snowluma_checked = True def _get_nearest_size(self, requested_size: int) -> int: - """从支持的尺寸列表中找到最接近请求尺寸的一个。""" + """Tìm kích thước hỗ trợ gần nhất với yêu cầu.""" return min(self.AVAILABLE_SIZES, key=lambda x: abs(x - requested_size)) - # ==================== IMessageRepository 实现 ==================== + # ==================== Triển khai IMessageRepository ==================== async def fetch_messages( self, @@ -134,18 +121,17 @@ class OneBotAdapter(PlatformAdapter): since_ts: int | None = None, ) -> list[UnifiedMessage]: """ - 从 OneBot 后端拉取群组历史消息。 - 采用分页拉取策略(参考 portrayal 插件),减少 NapCat/go-cqhttp 单次请求的 CPU 和内存负担。 + Lấy lịch sử nhóm từ backend OneBot theo trang để giảm tải CPU và bộ nhớ. Args: - group_id (str): 群号 - days (int): 拉取过去几天的消息 - max_count (int): 最大拉取条数 - before_id (str, optional): 锚点消息 ID,用于分页回溯 - since_ts (int, optional): 从指定时间戳开始拉取消息(Unix timestamp),优先级高于 days。 + group_id: ID nhóm. + days: Số ngày lịch sử. + max_count: Số tin nhắn tối đa. + before_id: ID neo để phân trang ngược. + since_ts: Unix timestamp bắt đầu, ưu tiên hơn days. Returns: - list[UnifiedMessage]: 统一格式的消息列表 + Danh sách UnifiedMessage. """ if not hasattr(self.bot, "call_action"): return [] @@ -153,10 +139,10 @@ class OneBotAdapter(PlatformAdapter): await self._detect_snowluma() try: - chunk_size = 100 # 每次拉取 100 条,较为稳健 + chunk_size = 100 # Mỗi lần lấy 100 mục để ổn định. all_raw_messages = [] - # 确定回溯的起始时间点 + # Xác định thời điểm bắt đầu truy ngược. if since_ts and since_ts > 0: start_timestamp = since_ts else: @@ -164,13 +150,13 @@ class OneBotAdapter(PlatformAdapter): start_time_dt = end_time_dt - timedelta(days=days) start_timestamp = int(start_time_dt.timestamp()) - # 使用 message_seq 或 message_id 进行分页回溯拉取 + # Phân trang ngược bằng message_seq hoặc message_id. current_anchor_id = before_id logger.info( - f"OneBot 开始分页回溯消息: 群 {group_id}, " - f"起始时间 {datetime.fromtimestamp(start_timestamp).strftime('%Y-%m-%d %H:%M:%S')}, " - f"上限 {max_count} 条" + f"OneBot bắt đầu lấy lịch sử phân trang: nhóm {group_id}, " + f"từ {datetime.fromtimestamp(start_timestamp).strftime('%Y-%m-%d %H:%M:%S')}, " + f"tối đa {max_count} mục" ) while len(all_raw_messages) < max_count: @@ -192,27 +178,26 @@ class OneBotAdapter(PlatformAdapter): if not result or "messages" not in result: logger.debug( - f"OneBot 分页拉取:API 调用返回空或无效数据,停止回溯。群: {group_id}" + f"OneBot phân trang: API trả dữ liệu rỗng hoặc lỗi, dừng nhóm {group_id}" ) break messages = result.get("messages", []) if not messages: logger.debug( - f"OneBot 分页拉取:获取到 0 条消息,停止回溯。群: {group_id}" + f"OneBot phân trang: lấy được 0 tin nhắn, dừng nhóm {group_id}" ) break - # 确定该批次中最旧的消息作为下一次回溯的起点 - # 不同 OneBot 实现对 reverseOrder 的处理可能导致结果顺序不同(反映在消息时间戳上) - # 我们通过比较首尾消息的时间戳,动态识别出本批次中最旧的消息 + # Chọn tin cũ nhất trong batch làm điểm bắt đầu tiếp theo; + # implementation OneBot có thể xử lý reverseOrder khác nhau. first_msg = messages[0] last_msg = messages[-1] if first_msg.get("time", 0) <= last_msg.get("time", 0): - # 正序:首条消息最旧 + # Thứ tự xuôi: tin đầu cũ nhất. chunk_earliest_msg = first_msg else: - # 逆序:末条消息最旧 + # Thứ tự ngược: tin cuối cũ nhất. chunk_earliest_msg = last_msg chunk_earliest_time = chunk_earliest_msg.get("time", 0) @@ -221,27 +206,24 @@ class OneBotAdapter(PlatformAdapter): msg_time = raw_msg.get("time", 0) msg_id = str(raw_msg.get("message_id", "")) - # 基础过滤:去重 + # Lọc cơ bản: loại trùng. if any( str(m.get("message_id", "")) == msg_id for m in all_raw_messages ): continue - # 身份过滤(排除机器人自己) + # Lọc danh tính: bỏ bot. sender_id = str(raw_msg.get("sender", {}).get("user_id", "")) if self.filter_bot_messages and sender_id in self.bot_self_ids: continue - # 时间范围判定 + # Kiểm tra khoảng thời gian. if start_timestamp <= msg_time <= int(datetime.now().timestamp()): all_raw_messages.append(raw_msg) - # 提取锚点。 - # SnowLuma 仅支持 message_id 作为分页锚点。 - # 其他 OneBot 实现优先级: message_seq > real_id > seq > message_id - # 注意:为了兼容 NapCat (NTQQ) 这种 Message ID 非连续的情况, - # 以及 LLBot 这种 Sequence 模式,我们统一不进行 -1 偏移。 - # 分页产生的重叠消息将由上方的去重逻辑 (all_raw_messages 循环对比) 自动处理。 + # SnowLuma chỉ hỗ trợ message_id làm neo; implementation khác ưu tiên + # message_seq > real_id > seq > message_id. Không trừ 1 để tương thích + # ID không liên tục của NapCat và sequence mode của LLBot. if self._is_snowluma: new_anchor_id = chunk_earliest_msg.get("message_id") else: @@ -253,28 +235,28 @@ class OneBotAdapter(PlatformAdapter): mid_val = chunk_earliest_msg.get("message_id") new_anchor_id = seq_val if seq_val is not None else mid_val - # 如果消息时间已到达起始点,或者锚点无法继续往前位移,则停止 + # Dừng khi tới thời điểm bắt đầu hoặc neo không thể lùi thêm. if chunk_earliest_time <= start_timestamp: logger.debug( - f"OneBot 分页拉取:已到达起始时间 ({start_timestamp}),回溯同步完成。" + f"OneBot phân trang: đã tới thời điểm bắt đầu ({start_timestamp}), hoàn tất" ) break if current_anchor_id and str(new_anchor_id) == str(current_anchor_id): logger.debug( - "OneBot 分页拉取:消息锚点未发生有效位移,可能已到达历史尽头。" + "OneBot phân trang: neo không dịch chuyển, có thể đã hết lịch sử" ) break current_anchor_id = new_anchor_id logger.debug( - f"OneBot 分页拉取进度: 已获取 {len(all_raw_messages)} 条基础/有效消息,下一次锚点: {current_anchor_id}" + f"Tiến độ OneBot: đã lấy {len(all_raw_messages)} tin hợp lệ, neo tiếp: {current_anchor_id}" ) - # 稍微延迟,减缓服务端压力 + # Giãn nhẹ để giảm tải server. await asyncio.sleep(0.05) - # 统一转换为 UnifiedMessage 并在返回前去重排序 + # Chuyển thành UnifiedMessage, loại trùng và sắp xếp trước khi trả. unified_messages = [] seen_ids = set() for raw_msg in all_raw_messages: @@ -287,25 +269,25 @@ class OneBotAdapter(PlatformAdapter): unified_messages.append(unified) seen_ids.add(mid) - # 确保最终结果符合时间顺序 + # Đảm bảo kết quả theo thứ tự thời gian. unified_messages.sort(key=lambda m: m.timestamp) logger.info( - f"OneBot 分页拉取完成: 共处理 {len(all_raw_messages)} 条原始消息, 最终有效 {len(unified_messages)} 条" + f"OneBot phân trang hoàn tất: xử lý {len(all_raw_messages)} tin thô, hợp lệ {len(unified_messages)}" ) return unified_messages except Exception as e: - logger.warning(f"OneBot 分页获取消息失败: {e}") + logger.warning(f"OneBot lấy tin nhắn phân trang thất bại: {e}") return [] def _convert_message(self, raw_msg: dict, group_id: str) -> UnifiedMessage | None: - """内部方法:将 OneBot 原生原始消息字典转换为 UnifiedMessage 值对象。""" + """Chuyển dict tin nhắn native OneBot thành UnifiedMessage.""" try: sender = raw_msg.get("sender", {}) message_chain = raw_msg.get("message", []) - # 兼容性处理:如果是字符串格式的 message,转换为列表格式 + # Chuyển message dạng chuỗi thành list để tương thích. if isinstance(message_chain, str): message_chain = [{"type": "text", "data": {"text": message_chain}}] @@ -324,14 +306,14 @@ class OneBotAdapter(PlatformAdapter): ) elif seg_type == "image": - # QQ 平台: subType=1 表示表情包,通过 raw_data 传递给下游统计 + # QQ: subType=1 là sticker, truyền qua raw_data cho thống kê. sub_type = seg_data.get("subType", seg_data.get("sub_type")) - # 安全地转换为整数,防止非数字值导致异常 + # Chuyển an toàn sang int. try: is_sticker = int(sub_type) == 1 except (TypeError, ValueError): is_sticker = False - # 只在 sub_type 有效时包含在 raw_data 中 + # Chỉ thêm sub_type vào raw_data khi hợp lệ. raw_data: dict[str, Any] = {"summary": seg_data.get("summary", "")} if sub_type is not None: raw_data["sub_type"] = int(sub_type) @@ -398,7 +380,7 @@ class OneBotAdapter(PlatformAdapter): MessageContent(type=MessageContentType.UNKNOWN, raw_data=seg) ) - # 提取回复 ID + # Trích xuất ID trả lời. reply_to = None for c in contents: if c.type == MessageContentType.REPLY and c.raw_data: @@ -419,20 +401,20 @@ class OneBotAdapter(PlatformAdapter): ) except Exception as e: - logger.debug(f"OneBot _convert_message 错误: {e}") + logger.debug(f"Lỗi OneBot _convert_message: {e}") return None def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: """ - 将统一格式转换回 OneBot v11 原生字典格式。 + Chuyển định dạng thống nhất về dict native OneBot v11. - 使现有业务逻辑逻辑无需重构即可使用新流水。 + Cho phép logic hiện tại dùng pipeline mới mà không cần refactor. Args: - messages (list[UnifiedMessage]): 统一消息列表 + messages: Danh sách UnifiedMessage. Returns: - list[dict]: OneBot 格式的消息字典列表 + Danh sách dict tin nhắn OneBot. """ raw_messages = [] for msg in messages: @@ -496,7 +478,7 @@ class OneBotAdapter(PlatformAdapter): return raw_messages - # ==================== IMessageSender 实现 ==================== + # ==================== Triển khai IMessageSender ==================== async def send_text( self, @@ -505,15 +487,15 @@ class OneBotAdapter(PlatformAdapter): reply_to: str | None = None, ) -> bool: """ - 向群组发送文本消息。 + Gửi tin nhắn văn bản tới nhóm. Args: - group_id (str): 目标群号 - text (str): 消息内容 - reply_to (str, optional): 引用回复的消息 ID + group_id: ID nhóm đích. + text: Nội dung tin nhắn. + reply_to: ID tin nhắn được trả lời. Returns: - bool: 是否发送成功 + Có gửi thành công hay không. """ try: message = [{"type": "text", "data": {"text": text}}] @@ -526,12 +508,12 @@ class OneBotAdapter(PlatformAdapter): group_id=int(group_id), message=message, ) - self._record_mute_status(group_id, False) # 成功发送,清除禁言缓存 + self._record_mute_status(group_id, False) # Gửi thành công, xoá cache mute. return True except Exception as e: if self._is_mute_exception(e): self._record_mute_status(group_id, True) - logger.error(f"OneBot 文本发送失败: {e}") + logger.error(f"OneBot gửi văn bản thất bại: {e}") return False async def _execute_transmission_strategy( @@ -542,30 +524,29 @@ class OneBotAdapter(PlatformAdapter): format_path_as_url: bool = False, ) -> bool: """ - 通用传输策略执行器。 - 处理 Base64 优先(开启时)、物理路径尝试、以及 Base64 兜底。 + Thực thi chiến lược truyền chung: ưu tiên Base64 nếu bật, thử path và fallback Base64. Args: - path: 文件路径或 URL - worker: 执行具体 API 调用的异步函数,接收 (file_val, mode_label) -> Awaitable[None] - label: 业务标签,用于日志 - format_path_as_url: 是否将本地路径格式化为 file:/// 形式 + path: Path tệp hoặc URL. + worker: Hàm async gọi API nhận file_val và mode_label. + label: Nhãn nghiệp vụ dùng cho log. + format_path_as_url: Có đổi path local thành file URL hay không. """ try: use_base64 = self._get_use_base64() abs_path, is_remote, exists = self._prepare_path(path) - # 1. 优先尝试 Base64 (如果开启) + # 1. Ưu tiên Base64 nếu bật. if use_base64 and not is_remote: b64 = await self._get_base64_from_file(abs_path) if b64: try: - await worker(b64, "Base64 优先") + await worker(b64, "Ưu tiên Base64") return True except Exception: pass - # 2. 尝试物理路径/远程 URL + # 2. Thử path vật lý hoặc URL từ xa. if exists: try: file_val = abs_path @@ -575,28 +556,32 @@ class OneBotAdapter(PlatformAdapter): if abs_path.startswith("/") else f"file:///{abs_path}" ) - await worker(file_val, "路径模式") + await worker(file_val, "Chế độ path") return True except Exception as e: if not use_base64: - logger.error(f"[{label}] 发送失败: {e}") + logger.error(f"[{label}] Gửi thất bại: {e}") return False - logger.warning(f"[{label}] 路径发送失败 ({e}),准备 Base64 补救...") + logger.warning( + f"[{label}] Gửi bằng path thất bại ({e}), chuẩn bị fallback Base64..." + ) else: if not use_base64: - logger.error(f"[{label}] 文件不存在且未开启 Base64: {abs_path}") + logger.error( + f"[{label}] Tệp không tồn tại và Base64 chưa bật: {abs_path}" + ) return False - # 3. 兜底回退 + # 3. Fallback cuối. if not is_remote: b64 = await self._get_base64_from_file(abs_path) if b64: - await worker(b64, "Base64 补发") + await worker(b64, "Gửi bù Base64") return True return False except Exception as e: - logger.error(f"[{label}] 发送异常: {e}") + logger.error(f"[{label}] Lỗi gửi: {e}") return False async def send_image( @@ -605,7 +590,7 @@ class OneBotAdapter(PlatformAdapter): image_path: str, caption: str = "", ) -> bool: - """向群组发送图片消息。""" + """Gửi ảnh tới nhóm.""" async def do_send(file_val: str, label: str): msg = [] @@ -621,10 +606,10 @@ class OneBotAdapter(PlatformAdapter): if self._is_mute_exception(e): self._record_mute_status(group_id, True) raise - logger.debug(f"[OneBot] 图片发送成功 ({label}): 群 {group_id}") + logger.debug(f"[OneBot] Gửi ảnh thành công ({label}): nhóm {group_id}") return await self._execute_transmission_strategy( - image_path, do_send, "OneBot 图片", format_path_as_url=True + image_path, do_send, "Ảnh OneBot", format_path_as_url=True ) async def send_file( @@ -633,7 +618,7 @@ class OneBotAdapter(PlatformAdapter): file_path: str, filename: str | None = None, ) -> bool: - """通过群文件功能上传并发送文件。""" + """Upload và gửi tệp qua tính năng tệp nhóm.""" async def do_upload(content: str, label: str): try: @@ -648,10 +633,12 @@ class OneBotAdapter(PlatformAdapter): if self._is_mute_exception(e): self._record_mute_status(group_id, True) raise - logger.debug(f"[OneBot] 文件发送成功 ({label}): {filename or file_path}") + logger.debug( + f"[OneBot] Gửi tệp thành công ({label}): {filename or file_path}" + ) return await self._execute_transmission_strategy( - file_path, do_upload, "OneBot 文件" + file_path, do_upload, "Tệp OneBot" ) async def send_forward_msg( @@ -660,13 +647,13 @@ class OneBotAdapter(PlatformAdapter): nodes: list[dict], ) -> bool: """ - 发送群合并转发消息。 + Gửi tin nhắn chuyển tiếp nhóm đã gộp. """ if not hasattr(self.bot, "call_action"): return False try: - # 兼容处理节点中的 uin -> user_id (有些后端偏好 uin) + # Tương thích uin và user_id trong node. for node in nodes: if "data" in node: if "user_id" in node["data"] and "uin" not in node["data"]: @@ -682,13 +669,13 @@ class OneBotAdapter(PlatformAdapter): except Exception as e: if self._is_mute_exception(e): self._record_mute_status(group_id, True) - logger.warning(f"[OneBot] 发送合并转发消息失败: {e}") + logger.warning(f"[OneBot] Gửi tin chuyển tiếp đã gộp thất bại: {e}") return False - # ==================== IGroupInfoRepository 实现 ==================== + # ==================== Triển khai IGroupInfoRepository ==================== async def get_group_info(self, group_id: str) -> UnifiedGroup | None: - """获取指定群组的基础元数据。""" + """Lấy metadata cơ bản của nhóm.""" try: result = await self.bot.call_action( "get_group_info", @@ -710,7 +697,7 @@ class OneBotAdapter(PlatformAdapter): return None async def get_group_list(self) -> list[str]: - """获取当前机器人已加入的所有群组 ID 列表。""" + """Lấy ID mọi nhóm bot hiện đã tham gia.""" try: result = await self.bot.call_action("get_group_list") return [str(g.get("group_id", "")) for g in result or []] @@ -718,7 +705,7 @@ class OneBotAdapter(PlatformAdapter): return [] async def get_member_list(self, group_id: str) -> list[UnifiedMember]: - """拉取整个群组成员列表。""" + """Lấy toàn bộ danh sách thành viên nhóm.""" try: result = await self.bot.call_action( "get_group_member_list", @@ -745,7 +732,7 @@ class OneBotAdapter(PlatformAdapter): group_id: str, user_id: str, ) -> UnifiedMember | None: - """拉取特定群成员的详细名片及角色信息。""" + """Lấy thông tin chi tiết và role của thành viên nhóm.""" try: result = await self.bot.call_action( "get_group_member_info", @@ -768,19 +755,19 @@ class OneBotAdapter(PlatformAdapter): async def _get_base64_from_file(self, file_path: str) -> str | None: """ - 读取本地文件并返回 Base64 编码字符串。 + Đọc tệp local và trả chuỗi mã hoá Base64. Args: - file_path: 本地文件绝对路径 + file_path: Path tuyệt đối của tệp local. Returns: - str | None: base64://... 格式的字符串,读取失败返回 None + Chuỗi dạng base64:// hoặc None nếu đọc thất bại. """ try: import os if not os.path.exists(file_path): - logger.error(f"文件不存在,无法读取 Base64: {file_path}") + logger.error(f"Tệp không tồn tại, không thể đọc Base64: {file_path}") return None with open(file_path, "rb") as f: @@ -788,10 +775,10 @@ class OneBotAdapter(PlatformAdapter): b64 = base64.b64encode(data).decode("utf-8") return f"base64://{b64}" except Exception as e: - logger.error(f"读取文件并转换 Base64 失败: {e}") + logger.error(f"Đọc tệp và chuyển Base64 thất bại: {e}") return None - # ==================== IAvatarRepository 实现 ==================== + # ==================== Triển khai IAvatarRepository ==================== async def get_user_avatar_url( self, @@ -799,17 +786,17 @@ class OneBotAdapter(PlatformAdapter): size: int = 100, ) -> str | None: """ - 拼凑 QQ 官方服务地址获取用户头像。 + Dựng URL dịch vụ QQ Official để lấy avatar người dùng. Args: - user_id (str): QQ 号 - size (int): 期望像素大小 + user_id: ID QQ. + size: Kích thước pixel mong muốn. Returns: - str: 格式化后的 URL + URL đã định dạng. """ actual_size = self._get_nearest_size(size) - # 640 使用 HD 接口更清晰 + # Dùng endpoint HD cho kích thước 640. if actual_size >= 640: return self.USER_AVATAR_HD_TEMPLATE.format(user_id=user_id, size=640) return self.USER_AVATAR_TEMPLATE.format(user_id=user_id, size=actual_size) @@ -820,7 +807,7 @@ class OneBotAdapter(PlatformAdapter): size: int = 100, ) -> str | None: """ - 通过网络下载头像并转换为 Base64 格式,适用于前端模板直接渲染。 + Tải avatar qua mạng và chuyển sang Base64 để render trực tiếp. """ url = await self.get_user_avatar_url(user_id, size) if not url: @@ -837,7 +824,7 @@ class OneBotAdapter(PlatformAdapter): content_type = resp.headers.get("Content-Type", "image/png") return f"data:{content_type};base64,{b64}" except Exception as e: - logger.debug(f"OneBot 头像下载失败: {e}") + logger.debug(f"OneBot tải avatar thất bại: {e}") return None async def get_group_avatar_url( @@ -845,7 +832,7 @@ class OneBotAdapter(PlatformAdapter): group_id: str, size: int = 100, ) -> str | None: - """获取 QQ 群头像地址。""" + """Lấy URL avatar nhóm QQ.""" actual_size = self._get_nearest_size(size) return self.GROUP_AVATAR_TEMPLATE.format(group_id=group_id, size=actual_size) @@ -854,7 +841,7 @@ class OneBotAdapter(PlatformAdapter): user_ids: list[str], size: int = 100, ) -> dict[str, str | None]: - """批量映射 QQ 号到其头像 URL 地址。""" + """Ánh xạ hàng loạt ID QQ sang URL avatar.""" return { user_id: await self.get_user_avatar_url(user_id, size) for user_id in user_ids @@ -862,22 +849,22 @@ class OneBotAdapter(PlatformAdapter): async def is_group_muted(self, group_id: str) -> bool: """ - 检查 OneBot 平台下的群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)。 + Kiểm tra nhóm OneBot có mute toàn nhóm hoặc riêng bot hay không. """ group_id_str = str(group_id) - # 1. 检查最近缓存的禁言状态(5分钟内有效) + # 1. Kiểm tra trạng thái mute cache trong 5 phút. last_mute_time = self._muted_groups_cache.get(group_id_str) if last_mute_time and (time.time() - last_mute_time) < 300: logger.info( - f"[OneBot] 从缓存中检测到群 {group_id_str} 最近处于禁言状态,跳过分析" + f"[OneBot] Cache cho thấy nhóm {group_id_str} vừa bị mute, bỏ qua phân tích" ) return True if not hasattr(self.bot, "call_action"): return False - # 2. 获取 Bot 自身的 QQ 号,并过滤掉非法的字符串(如 functools.partial 或含字母/特殊字符的异常值) + # 2. Lấy ID QQ bot và lọc giá trị lỗi như functools.partial. bot_user_id = None if self.bot_self_ids: valid_ids = [ @@ -894,7 +881,7 @@ class OneBotAdapter(PlatformAdapter): if not bot_user_id: try: - # 设定 5.0 秒超时,防止接口请求无限挂起 + # Timeout 5 giây để tránh API treo vô hạn. login_info = await asyncio.wait_for( self.bot.call_action("get_login_info"), timeout=5.0 ) @@ -905,17 +892,17 @@ class OneBotAdapter(PlatformAdapter): if self._is_mute_exception(e): self._record_mute_status(group_id, True) logger.info( - f"[OneBot] 从 get_login_info 异常中检测到 Bot 在群 {group_id} 中已被禁言" + f"[OneBot] Phát hiện bot bị mute trong nhóm {group_id} từ lỗi get_login_info" ) return True - logger.warning(f"[OneBot] 获取 Bot 自身登录信息失败: {e}") + logger.warning(f"[OneBot] Lấy thông tin đăng nhập bot thất bại: {e}") - # 3. 检查 Bot 是否被个人禁言,并获取 Bot 在群内的角色 + # 3. Kiểm tra bot bị mute riêng và lấy role trong nhóm. is_individually_muted = False - role = "member" # 默认为 member 以防万一 + role = "member" # Mặc định member để an toàn. if bot_user_id: try: - # 设定 5.0 秒超时,且不传递 no_cache=True 以免强制向腾讯服务器同步导致高延时超时 + # Timeout 5 giây và không ép no_cache để tránh độ trễ Tencent. member_info = await asyncio.wait_for( self.bot.call_action( "get_group_member_info", @@ -926,50 +913,49 @@ class OneBotAdapter(PlatformAdapter): ) if member_info: role = member_info.get("role", "member") - # 缓存角色信息,用于 get_group_member_info 超时时降级使用 + # Cache role để fallback khi get_group_member_info timeout. self._group_role_cache[group_id_str] = (role, time.time()) shut_up_time = member_info.get("shut_up_time", 0) if shut_up_time > 0: - # 如果 shut_up_time 是 Unix 时间戳 + # shut_up_time có thể là Unix timestamp. if shut_up_time > 1000000000: if shut_up_time > time.time(): is_individually_muted = True else: - # 否则认为是相对禁言剩余时间(秒) + # Nếu không thì là số giây mute còn lại. is_individually_muted = True except asyncio.TimeoutError: - # 超时降级:优先使用缓存的角色(角色几乎不会变) + # Fallback timeout: ưu tiên role đã cache. cached_role = self._group_role_cache.get(group_id_str) if cached_role: role = cached_role[0] logger.warning( - f"[OneBot] 获取群成员信息超时,使用缓存角色: {role} (group_id={group_id}, user_id={bot_user_id})" + f"[OneBot] Lấy thông tin thành viên timeout, dùng role cache: {role} (group_id={group_id}, user_id={bot_user_id})" ) else: logger.warning( - f"[OneBot] 获取群成员信息超时且无角色缓存,将按 member 处理 (group_id={group_id}, user_id={bot_user_id})" + f"[OneBot] Lấy thông tin thành viên timeout và không có cache, dùng member (group_id={group_id}, user_id={bot_user_id})" ) except Exception as e: if self._is_mute_exception(e): self._record_mute_status(group_id, True) logger.info( - f"[OneBot] 从 get_group_member_info 异常中检测到 Bot 在群 {group_id} 中已被禁言" + f"[OneBot] Phát hiện bot bị mute trong nhóm {group_id} từ lỗi get_group_member_info" ) return True logger.warning( - f"[OneBot] 获取群成员信息失败 (group_id={group_id}, user_id={bot_user_id}): {e}" + f"[OneBot] Lấy thông tin thành viên thất bại (group_id={group_id}, user_id={bot_user_id}): {e}" ) if is_individually_muted: self._record_mute_status(group_id, True) - logger.info(f"[OneBot] 检测到 Bot 在群 {group_id} 中已被单独禁言") + logger.info(f"[OneBot] Phát hiện bot bị mute riêng trong nhóm {group_id}") return True - # 4. 如果 Bot 不是管理员或群主,则需要检查群聊是否开启了全群禁言 - # 管理员 (admin) 和群主 (owner) 在全群禁言下依然可以发言 + # 4. Nếu bot không phải admin/owner, kiểm tra mute toàn nhóm. if role not in ("admin", "owner"): try: - # 设定 5.0 秒超时,不传 no_cache=True + # Timeout 5 giây, không truyền no_cache=True. group_info = await asyncio.wait_for( self.bot.call_action( "get_group_info", @@ -978,7 +964,7 @@ class OneBotAdapter(PlatformAdapter): timeout=5.0, ) if group_info: - # 兼容 LLOneBot, Lagrange, NapCat/SnowLuma 以及标准 OneBot 各种全群禁言状态字段 + # Tương thích các trường mute toàn nhóm giữa các backend. is_whole_ban = ( group_info.get("group_all_shut") or group_info.get("shutup_all") @@ -990,21 +976,25 @@ class OneBotAdapter(PlatformAdapter): if is_whole_ban: self._record_mute_status(group_id, True) logger.info( - f"[OneBot] 检测到群 {group_id} 开启了全群禁言,且 Bot 为普通成员" + f"[OneBot] Phát hiện nhóm {group_id} mute toàn nhóm và bot là thành viên thường" ) return True except asyncio.TimeoutError: - logger.warning(f"[OneBot] 获取群信息超时 (group_id={group_id})") + logger.warning( + f"[OneBot] Lấy thông tin nhóm timeout (group_id={group_id})" + ) except Exception as e: if self._is_mute_exception(e): self._record_mute_status(group_id, True) logger.info( - f"[OneBot] 从 get_group_info 异常中检测到 Bot 在群 {group_id} 中已被禁言" + f"[OneBot] Phát hiện bot bị mute trong nhóm {group_id} từ lỗi get_group_info" ) return True - logger.warning(f"[OneBot] 获取群信息失败 (group_id={group_id}): {e}") + logger.warning( + f"[OneBot] Lấy thông tin nhóm thất bại (group_id={group_id}): {e}" + ) - # 如果所有检测均未发现禁言,则暂时视为未禁言 + # Nếu không phát hiện mute thì xem là chưa mute. return False def _is_mute_exception(self, e: Exception) -> bool: @@ -1014,16 +1004,12 @@ class OneBotAdapter(PlatformAdapter): mute_keywords = ("禁言", "操作失败", "下游群鉴权") - # --- 方法一:根据 retcode 检测 --- - # NapCat/LLOneBot 被禁言时返回 retcode=1200(INTERNAL_ERROR) - # SnowLuma/OIDB 操作被拒时返回 retcode=100(ACTION_FAILED)+ wording 含错误描述 - # SnowLuma 发送消息被拒时返回 result=120 + # Cách 1: phát hiện theo retcode của các backend. if any(rc in err_str for rc in ("1200", "retcode=100", "result=120")): if any(kw in err_str for kw in mute_keywords): return True - # --- 方法二:检查 exception 的 message/wording 属性 --- - # 适配不同协议端对错误信息的字段命名差异 + # Cách 2: kiểm tra thuộc tính message/wording của exception. for attr in ("message", "wording"): val = getattr(e, attr, "") or "" if any(kw in val for kw in mute_keywords): @@ -1031,8 +1017,7 @@ class OneBotAdapter(PlatformAdapter): if "shut up" in val.lower(): return True - # --- 方法三:检测 SnowLuma 发消息被拒的特定模式 --- - # SnowLuma 在群内发消息失败时返回: + # Cách 3: phát hiện mẫu SnowLuma từ chối gửi tin nhắn. # retcode=100, wording="send group message rejected: result=120 err=" err_lower = err_str.lower() if "rejected" in err_lower and ( @@ -1040,8 +1025,7 @@ class OneBotAdapter(PlatformAdapter): ): return True - # --- 方法四:兜底 --- 直接从 err_str 匹配禁言关键词 --- - # 即使 getattr 获取不到 wording 属性,str(e) 本身仍包含关键词文本 + # Cách 4: fallback khớp keyword mute trực tiếp trong err_str. if any(kw in err_str for kw in mute_keywords): return True @@ -1072,7 +1056,7 @@ class OneBotAdapter(PlatformAdapter): self._muted_groups_cache.pop(group_id_str, None) # ================================================================ - # 群文件 / 群相册上传 + # Upload tệp nhóm / album nhóm. # ================================================================ async def upload_group_file_to_folder( @@ -1082,7 +1066,7 @@ class OneBotAdapter(PlatformAdapter): filename: str | None = None, folder_id: str | None = None, ) -> bool: - """上传文件到群文件目录的指定子文件夹。""" + """Upload tệp vào thư mục con chỉ định của tệp nhóm.""" async def do_upload(content: str, label: str): params = { @@ -1093,10 +1077,12 @@ class OneBotAdapter(PlatformAdapter): if folder_id: params["folder"] = folder_id await self.bot.call_action("upload_group_file", **params) - logger.debug(f"[OneBot] 群文件发送成功 ({label}): {params['name']}") + logger.debug( + f"[OneBot] Gửi tệp nhóm thành công ({label}): {params['name']}" + ) return await self._execute_transmission_strategy( - file_path, do_upload, "OneBot 群文件" + file_path, do_upload, "Tệp nhóm OneBot" ) async def create_group_file_folder( @@ -1105,14 +1091,14 @@ class OneBotAdapter(PlatformAdapter): folder_name: str, ) -> str | None: """ - 在群文件根目录下创建子文件夹。 + Tạo thư mục con trong thư mục gốc tệp nhóm. Args: - group_id: 目标群号 - folder_name: 文件夹名称 + group_id: ID nhóm đích. + folder_name: Tên thư mục. Returns: - str | None: 创建成功时返回 folder_id,失败返回 None + folder_id nếu thành công, ngược lại None. """ try: result = await self.bot.call_action( @@ -1121,22 +1107,24 @@ class OneBotAdapter(PlatformAdapter): name=folder_name, parent_id="/", ) - # go-cqhttp 等实现可能不返回 folder_id + # Một số implementation như go-cqhttp có thể không trả folder_id. folder_id = None if isinstance(result, dict): folder_id = result.get("folder_id") or result.get("id") logger.info( - f"OneBot 群文件夹创建成功: {folder_name} (群 {group_id})" + f"Tạo thư mục tệp nhóm OneBot thành công: {folder_name} (nhóm {group_id})" + (f" [ID: {folder_id}]" if folder_id else "") ) return folder_id except Exception as e: error_msg = str(e).lower() - # 文件夹已存在的情况不视为错误 + # Thư mục đã tồn tại không được xem là lỗi. if "exist" in error_msg or "已存在" in error_msg: - logger.info(f"OneBot 群文件夹已存在: {folder_name} (群 {group_id})") - return None # 需要通过 get_group_file_root_folders 获取 ID - logger.error(f"OneBot 群文件夹创建失败: {e}") + logger.info( + f"Thư mục tệp nhóm OneBot đã tồn tại: {folder_name} (nhóm {group_id})" + ) + return None # Cần lấy ID qua get_group_file_root_folders. + logger.error(f"Tạo thư mục tệp nhóm OneBot thất bại: {e}") return None async def get_group_file_root_folders( @@ -1144,14 +1132,13 @@ class OneBotAdapter(PlatformAdapter): group_id: str, ) -> list[dict]: """ - 获取群文件根目录下的文件夹列表。 + Lấy danh sách thư mục trong thư mục gốc tệp nhóm. Args: - group_id: 目标群号 + group_id: ID nhóm đích. Returns: - list[dict]: 文件夹列表,每项包含 folder_id/name 等字段。 - API 不可用时返回空列表。 + Danh sách dict thư mục; trả list rỗng nếu API không khả dụng. """ try: result = await self.bot.call_action( @@ -1162,7 +1149,7 @@ class OneBotAdapter(PlatformAdapter): return result.get("folders", []) or [] return [] except Exception as e: - logger.debug(f"OneBot 获取群文件夹列表失败: {e}") + logger.debug(f"OneBot lấy danh sách thư mục nhóm thất bại: {e}") return [] async def find_or_create_folder( @@ -1171,44 +1158,47 @@ class OneBotAdapter(PlatformAdapter): folder_name: str, ) -> str | None: """ - 查找或创建指定名称的群文件子文件夹,返回 folder_id。 + Tìm hoặc tạo thư mục con tệp nhóm theo tên và trả folder_id. - 先尝试在现有根目录文件夹中查找匹配名称的文件夹, - 找不到则创建新文件夹。 + Trước tiên tìm trong thư mục gốc hiện có, nếu không thấy thì tạo mới. Args: - group_id: 目标群号 - folder_name: 文件夹名称 + group_id: ID nhóm đích. + folder_name: Tên thư mục. """ if not folder_name: return None - # 1. 先尝试查找已有文件夹 + # 1. Tìm thư mục hiện có. folders = await self.get_group_file_root_folders(group_id) for folder in folders: name = folder.get("folder_name") or folder.get("name", "") fid = folder.get("folder_id") or folder.get("id", "") if name == folder_name and fid: - logger.debug(f"找到已有群文件夹: {folder_name} [ID: {fid}]") + logger.debug( + f"Tìm thấy thư mục nhóm hiện có: {folder_name} [ID: {fid}]" + ) return fid - # 2. 未找到,尝试创建 + # 2. Nếu chưa thấy, thử tạo. created_id = await self.create_group_file_folder(group_id, folder_name) if created_id: return created_id - # 3. 创建后再次查找(某些实现创建时不返回 ID) + # 3. Tìm lại sau khi tạo vì một số backend không trả ID. folders = await self.get_group_file_root_folders(group_id) for folder in folders: name = folder.get("folder_name") or folder.get("name", "") fid = folder.get("folder_id") or folder.get("id", "") if name == folder_name and fid: - logger.debug(f"创建后找到群文件夹: {folder_name} [ID: {fid}]") + logger.debug( + f"Tìm thấy thư mục nhóm sau khi tạo: {folder_name} [ID: {fid}]" + ) return fid logger.warning( - f"无法获取群文件夹 ID: {folder_name} (群 {group_id}),将上传到根目录" + f"Không thể lấy ID thư mục nhóm: {folder_name} (nhóm {group_id}); sẽ upload vào thư mục gốc" ) return None @@ -1220,27 +1210,27 @@ class OneBotAdapter(PlatformAdapter): album_name: str | None = None, strict_mode: bool = False, ) -> bool: - """上传图片到群相册(NapCat 扩展 API)。""" - # 严格模式:指定了相册名但未解析到 album_id 时,禁止上传 + """Upload ảnh vào album nhóm qua API mở rộng NapCat.""" + # Strict mode: không upload khi có tên album nhưng không phân giải được ID. if strict_mode and album_name and not album_id: logger.info( - f"[群分析相册] 严格模式开启:未找到相册 '{album_name}' (群 {group_id}),停止上传。" + f"[Album phân tích nhóm] Strict mode: không thấy album '{album_name}' (nhóm {group_id}), dừng upload" ) return False - # 兜底查询 + # Truy vấn fallback. if not album_id: albums = await self.get_group_album_list(group_id) album_id = self._find_item_in_list( albums, album_name, ["album_id"], ["name", "album_name"] ) - # 如果仍没指定且没搜到特定相册,取第一个 + # Nếu không chỉ định album, dùng album đầu tiên. if not album_id and not album_name and albums: album_id = albums[0].get("album_id") or albums[0].get("id") if not album_id: logger.info( - f"[群分析相册] 未能确定目标相册 (群 {group_id}),跳过相册上传。" + f"[Album phân tích nhóm] Không xác định được album đích (nhóm {group_id}), bỏ qua upload" ) return False @@ -1248,8 +1238,7 @@ class OneBotAdapter(PlatformAdapter): await self._detect_llbot() if self._is_llbot: - # LLBot 模式:使用 files 参数 (列表) - # LLBot 的 upload_group_album 接收 files 作为数组 + # LLBot dùng tham số files dạng list. llbot_params = { "group_id": int(group_id), "album_id": str(album_id), @@ -1258,12 +1247,12 @@ class OneBotAdapter(PlatformAdapter): try: await self.bot.call_action("upload_group_album", **llbot_params) logger.debug( - f"[群分析相册] 上传成功 (LLBot, {label}): 群 {group_id}" + f"[Album phân tích nhóm] Upload thành công (LLBot, {label}): nhóm {group_id}" ) return except Exception as e: logger.warning( - f"[群分析相册] LLBot 上传接口调用失败: {e},尝试 NapCat 模式..." + f"[Album phân tích nhóm] API upload LLBot thất bại: {e}, thử chế độ NapCat..." ) params = { @@ -1282,15 +1271,15 @@ class OneBotAdapter(PlatformAdapter): try: await self.bot.call_action(action, **params) logger.debug( - f"[群分析相册] 上传成功 ({label}, {action}): 群 {group_id}" + f"[Album phân tích nhóm] Upload thành công ({label}, {action}): nhóm {group_id}" ) return except Exception: continue - raise RuntimeError("所有相册上传 API 均调用失败") + raise RuntimeError("Mọi API upload album đều thất bại") return await self._execute_transmission_strategy( - image_path, do_upload, "OneBot 相册" + image_path, do_upload, "Album OneBot" ) async def get_group_album_list( @@ -1298,16 +1287,16 @@ class OneBotAdapter(PlatformAdapter): group_id: str, ) -> list[dict]: """ - 获取群分析相册列表(兼容多种 OneBot 扩展实现)。 + Lấy danh sách album nhóm, tương thích nhiều extension OneBot. """ def extract_list(payload: Any) -> list[dict]: - """从不同结构的响应中提取相册列表:直接列表、嵌套在 data 中、或直接在根字段中。""" + """Trích xuất album từ list trực tiếp, data lồng hoặc trường gốc.""" if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] if not isinstance(payload, dict): logger.debug( - f"[群分析相册] 提取相册列表失败: payload 非字典/列表类型 ({type(payload)})" + f"[Album phân tích nhóm] Trích xuất thất bại: payload không phải dict/list ({type(payload)})" ) return [] @@ -1317,16 +1306,20 @@ class OneBotAdapter(PlatformAdapter): if isinstance(album_list, list): return [item for item in album_list if isinstance(item, dict)] else: - logger.debug(f"[群分析相册] 在 data 字段中未找到列表: data={data}") + logger.debug( + f"[Album phân tích nhóm] Không tìm thấy list trong trường data: {data}" + ) album_list = payload.get("album_list") or payload.get("list") if isinstance(album_list, list): return [item for item in album_list if isinstance(item, dict)] - logger.debug(f"[群分析相册] 无法从响应中提取相册列表: payload={payload}") + logger.debug( + f"[Album phân tích nhóm] Không thể trích xuất album từ phản hồi: {payload}" + ) return [] - # 候选 API 名称 + # Tên API ứng viên. actions = [ "get_qun_album_list", "get_group_album_list", @@ -1337,22 +1330,24 @@ class OneBotAdapter(PlatformAdapter): for action in actions: try: logger.debug( - f"[群分析相册] 正在通过 {action} 获取列表 (群: {group_id})..." + f"[Album phân tích nhóm] Đang lấy danh sách qua {action} (nhóm: {group_id})..." ) result = await self.bot.call_action( action, group_id=int(group_id), ) - logger.debug(f"[群分析相册] 接口 {action} 原始响应内容: {result}") + logger.debug( + f"[Album phân tích nhóm] Phản hồi thô từ {action}: {result}" + ) if result: albums = extract_list(result) if albums: logger.debug( - f"[群分析相册] {action} 成功获取并提取到 {len(albums)} 个相册对象" + f"[Album phân tích nhóm] {action} lấy được {len(albums)} album" ) return albums except Exception as e: - logger.debug(f"[群分析相册] 接口 {action} 尝试失败: {e}") + logger.debug(f"[Album phân tích nhóm] Thử API {action} thất bại: {e}") return [] @@ -1362,54 +1357,55 @@ class OneBotAdapter(PlatformAdapter): album_name: str, ) -> str | None: """ - 根据相册名称查找 album_id。找不到返回 None(将回退到默认相册)。 + Tìm album_id theo tên, trả None để fallback album mặc định nếu không thấy. Args: - group_id: 目标群号 - album_name: 目标相册名称 + group_id: ID nhóm đích. + album_name: Tên album đích. Returns: - str | None: 匹配的 album_id,未找到返回 None + album_id khớp hoặc None. """ if not album_name: return None logger.debug( - f"[群分析相册] 正在群 {group_id} 中查找名为 '{album_name}' 的相册..." + f"[Album phân tích nhóm] Đang tìm album '{album_name}' trong nhóm {group_id}..." ) albums = await self.get_group_album_list(group_id) for album in albums: name = album.get("name") or album.get("album_name") logger.debug( - f"[群分析相册] 正在匹配相册: 目标='{album_name}', 当前相册名称='{name}', 原始数据={album}" + f"[Album phân tích nhóm] So khớp album: đích='{album_name}', hiện tại='{name}', dữ liệu={album}" ) if name == album_name: aid = album.get("album_id") if aid: logger.info( - f"[群分析相册] 成功定位相册: '{album_name}' -> ID: {aid}" + f"[Album phân tích nhóm] Xác định album thành công: '{album_name}' -> ID: {aid}" ) return str(aid) else: logger.debug( - f"[群分析相册] 相册 '{name}' 名称匹配,但未找到有效的 album_id" + f"[Album phân tích nhóm] Tên album '{name}' khớp nhưng thiếu album_id hợp lệ" ) - logger.info(f"[群分析相册] 未能找到名为 '{album_name}' 的相册 (群 {group_id})") + logger.info( + f"[Album phân tích nhóm] Không tìm thấy album '{album_name}' (nhóm {group_id})" + ) return None async def set_reaction( self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True ) -> bool: """ - OneBot 实现消息回应 (set_msg_emoji_like)。 - 支持 Go-CQHTTP, NapCat, Lagrange 等 OneBot 实现。 + Triển khai reaction OneBot bằng set_msg_emoji_like. """ try: reaction_key = str(emoji) emoji_id = { - "analysis_started": "289", # 🫣 表情 (表示任务已接收) - "analysis_done": "124", # 👌 表情 (表示任务处理完成) + "analysis_started": "289", # Đã nhận tác vụ. + "analysis_done": "124", # Đã xử lý xong tác vụ. "🔍": "289", "📊": "124", }.get(reaction_key, reaction_key) @@ -1418,7 +1414,7 @@ class OneBotAdapter(PlatformAdapter): "set_msg_emoji_like", message_id=int(message_id), emoji_id=emoji_id, - emoji_type="1", # 还原为最稳定的系统表情类型 + emoji_type="1", # Loại emoji hệ thống ổn định nhất. set=is_add, ) self._record_mute_status(group_id, False) @@ -1426,18 +1422,18 @@ class OneBotAdapter(PlatformAdapter): except Exception as e: if self._is_mute_exception(e): self._record_mute_status(group_id, True) - logger.debug(f"OneBot set_reaction 失败 (API 可能不支持): {e}") + logger.debug(f"OneBot set_reaction thất bại, API có thể không hỗ trợ: {e}") return False def _get_use_base64(self) -> bool: - """从插件配置中获取是否启用 Base64""" + """Lấy trạng thái bật Base64 từ cấu hình plugin.""" plugin: Any = self.config.get("plugin_instance") if self.config else None if plugin and hasattr(plugin, "config_manager"): return plugin.config_manager.get_enable_base64_image() return False def _prepare_path(self, path: str) -> tuple[str, bool, bool]: - """统一路径预处理。返回: (标准化后的绝对路径, 是否为远程/编码路径, 本地文件是否存在)""" + """Chuẩn hoá path và trả path, trạng thái remote/encoded, trạng thái tồn tại.""" is_remote = path.startswith(("http://", "https://", "base64://")) if is_remote: return path, True, True @@ -1453,7 +1449,7 @@ class OneBotAdapter(PlatformAdapter): id_keys: list[str], name_keys: list[str], ) -> str | None: - """从对象列表中根据名称查找 ID (通用辅助函数)""" + """Tìm ID theo tên trong danh sách object.""" if not target_name: return None diff --git a/src/infrastructure/platform/adapters/qq_official_adapter.py b/src/infrastructure/platform/adapters/qq_official_adapter.py index 9659f54..67d2d13 100644 --- a/src/infrastructure/platform/adapters/qq_official_adapter.py +++ b/src/infrastructure/platform/adapters/qq_official_adapter.py @@ -80,7 +80,7 @@ class QQOfficialAdapter(PlatformAdapter): if not cls._is_placeholder_sender_name(normalized, sender_id): return normalized digest = hashlib.sha256(f"{group_id}\0{sender_id}".encode()).hexdigest()[:8] - return f"群友-{digest.upper()}" + return f"Thành viên-{digest.upper()}" def set_context(self, context: Context) -> None: self._context = context @@ -97,7 +97,9 @@ class QQOfficialAdapter(PlatformAdapter): since_ts: int | None = None, ) -> list[UnifiedMessage]: if not self._context: - logger.warning("[QQOfficial] 未设置 context,无法读取本地消息历史") + logger.warning( + "[QQOfficial] Chưa thiết lập context, không thể đọc lịch sử cục bộ" + ) return [] history_mgr = self._context.message_history_manager @@ -163,13 +165,17 @@ class QQOfficialAdapter(PlatformAdapter): if len(messages) > target_count: messages = messages[-target_count:] logger.info( - "[QQOfficial] 从本地历史获取群 %s 消息 %s 条", - group_id, + "[QQOfficial] Đã lấy %s tin nhắn nhóm %s từ lịch sử cục bộ", len(messages), + group_id, ) return messages except Exception as exc: - logger.error("[QQOfficial] 读取本地消息历史失败: %s", exc, exc_info=True) + logger.error( + "[QQOfficial] Đọc lịch sử tin nhắn cục bộ thất bại: %s", + exc, + exc_info=True, + ) return [] def _convert_history_record( @@ -258,7 +264,9 @@ class QQOfficialAdapter(PlatformAdapter): platform=self.platform_name, ) except Exception as exc: - logger.debug("[QQOfficial] 转换本地历史记录失败: %s", exc) + logger.debug( + "[QQOfficial] Chuyển đổi bản ghi lịch sử cục bộ thất bại: %s", exc + ) return None def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: @@ -290,7 +298,7 @@ class QQOfficialAdapter(PlatformAdapter): async def _send_chain(self, group_id: str, chain: Any) -> bool: if not self._context: - logger.error("[QQOfficial] 未设置 context,无法发送消息") + logger.error("[QQOfficial] Chưa thiết lập context, không thể gửi tin nhắn") return False try: # AstrBot's QQ Official adapter keeps the group/channel scene only @@ -303,7 +311,7 @@ class QQOfficialAdapter(PlatformAdapter): umo = f"{self.platform_id}:GroupMessage:{group_id}" return bool(await self._context.send_message(umo, chain)) except Exception as exc: - logger.error("[QQOfficial] 发送消息失败: %s", exc, exc_info=True) + logger.error("[QQOfficial] Gửi tin nhắn thất bại: %s", exc, exc_info=True) return False async def send_text( @@ -333,11 +341,11 @@ class QQOfficialAdapter(PlatformAdapter): sent_markdown_chunks += 1 continue logger.warning( - "[QQOfficial] Markdown 接口未返回成功结果,后续改用普通文本" + "[QQOfficial] API Markdown không trả về thành công, chuyển sang văn bản thường" ) except Exception as exc: logger.warning( - "[QQOfficial] Markdown 报告发送失败,后续改用普通文本: %s", + "[QQOfficial] Gửi báo cáo Markdown thất bại, chuyển sang văn bản thường: %s", exc, ) markdown_enabled = False @@ -488,7 +496,9 @@ class QQOfficialAdapter(PlatformAdapter): try: return await self._plugin_instance.get_seen_group_ids(self.platform_id) except Exception as exc: - logger.warning("[QQOfficial] 获取已见群列表失败: %s", exc) + logger.warning( + "[QQOfficial] Lấy danh sách nhóm đã thấy thất bại: %s", exc + ) return [] async def get_member_list(self, group_id: str) -> list[UnifiedMember]: @@ -529,7 +539,7 @@ class QQOfficialAdapter(PlatformAdapter): mime = "image/png" if payload.startswith(b"\x89PNG") else "image/jpeg" return f"data:{mime};base64,{base64.b64encode(payload).decode('utf-8')}" except Exception as exc: - logger.debug("[QQOfficial] 下载头像失败: %s", exc) + logger.debug("[QQOfficial] Tải avatar thất bại: %s", exc) return None async def get_group_avatar_url(self, group_id: str, size: int = 100) -> str | None: diff --git a/src/infrastructure/platform/adapters/telegram_adapter.py b/src/infrastructure/platform/adapters/telegram_adapter.py index c9cdceb..11dadda 100644 --- a/src/infrastructure/platform/adapters/telegram_adapter.py +++ b/src/infrastructure/platform/adapters/telegram_adapter.py @@ -1,9 +1,4 @@ -""" -Telegram 平台适配器 - -支持 Telegram Bot API 的消息发送功能。 -通过 AstrBot 的 message_history_manager 存储和读取消息历史。 -""" +"""Adapter Telegram dùng Bot API và lịch sử tin nhắn AstrBot.""" import asyncio import base64 @@ -30,7 +25,7 @@ from ..base import PlatformAdapter if TYPE_CHECKING: from astrbot.api.star import Context -# Telegram 依赖 +# Dependency Telegram. try: from telegram.ext import ExtBot @@ -45,29 +40,17 @@ TELEGRAM_AVATAR_NEGATIVE_CACHE_MAX_SIZE = 1024 class TelegramAdapter(PlatformAdapter): - """ - Telegram Bot API 适配器 - - 实现 PlatformAdapter 接口,支持: - - 消息发送(文本、图片、文件) - - 头像获取 - - 群组信息获取 - - 消息历史(通过 AstrBot 的 message_history_manager) - - 消息历史机制: - - 消息通过拦截器存储到 AstrBot 数据库 - - fetch_messages 从数据库读取历史消息 - """ + """Adapter Telegram hỗ trợ gửi tin, avatar, nhóm và lịch sử AstrBot.""" def __init__(self, bot_instance: Any, config: dict | None = None): super().__init__(bot_instance, config) self._cached_client: Any = None self._context: Context | None = None - # 机器人自身 ID(用于消息过滤) + # ID của bot để lọc tin nhắn. self.bot_user_id = str(config.get("bot_user_id", "")) if config else "" - # 尝试从配置获取 bot self ids 列表 + # Thử lấy danh sách self ID của bot từ cấu hình. self.bot_self_ids: list[str] = [] if config: ids = config.get("bot_self_ids", []) @@ -81,34 +64,31 @@ class TelegramAdapter(PlatformAdapter): def set_context(self, context: "Context") -> None: """ - 设置 AstrBot 上下文 + Thiết lập context AstrBot. - 用于访问 message_history_manager 等核心服务。 + Dùng để truy cập các dịch vụ lõi như message_history_manager. """ self._context = context def _init_capabilities(self) -> PlatformCapabilities: - """返回 Telegram 平台能力声明""" + """Trả về capability của nền tảng Telegram.""" return TELEGRAM_CAPABILITIES async def get_group_list(self) -> list[str]: """ - 获取群组列表 + Lấy danh sách nhóm. - Telegram Bot API 不支持直接获取群列表。 - 因此这里尝试结合多种策略: - 1. 尝试调用 API (如果未来支持) - 2. 回退:从插件的 KV 存储中获取已知群组 (需注入插件实例) + Telegram Bot API không hỗ trợ lấy trực tiếp danh sách nhóm, do đó + fallback sang nhóm đã biết trong KV của plugin. """ groups = [] - # 1. 尝试 API (目前 python-telegram-bot 不支持直接列出所有 chat) - # 如果 client 有扩展方法或未来支持,可在此实现 + # python-telegram-bot hiện chưa hỗ trợ liệt kê toàn bộ chat. - # 2. 回退:使用 KV 注册表 + # Fallback: dùng registry KV. if not groups and self._plugin_instance: try: - # 检查插件实例是否有 get_telegram_seen_group_ids 方法 + # Kiểm tra plugin có phương thức lấy nhóm Telegram đã thấy. if hasattr(self._plugin_instance, "get_telegram_seen_group_ids"): kv_groups = await self._plugin_instance.get_telegram_seen_group_ids( self._platform_id @@ -116,48 +96,54 @@ class TelegramAdapter(PlatformAdapter): if kv_groups: groups.extend(kv_groups) logger.debug( - f"[Telegram] 通过 KV 回退获取到 {len(kv_groups)} 个群组" + f"[Telegram] Fallback KV lấy được {len(kv_groups)} nhóm" ) except Exception as e: - logger.warning(f"[Telegram] KV 回退获取群列表失败: {e}") + logger.warning( + f"[Telegram] Fallback KV lấy danh sách nhóm thất bại: {e}" + ) if not groups: - logger.debug("[Telegram] 无法获取群列表 (API不支持且无KV记录)") + logger.debug( + "[Telegram] Không thể lấy danh sách nhóm: API không hỗ trợ và KV rỗng" + ) return list(set(groups)) @property def _telegram_client(self) -> Any: """ - 懒加载获取 Telegram 客户端 + Lazy load Telegram client. - 支持多种获取路径,适应 AstrBot 不同版本。 + Hỗ trợ nhiều đường dẫn để tương thích các phiên bản AstrBot. """ if self._cached_client is not None: return self._cached_client if not TELEGRAM_AVAILABLE: - logger.warning("python-telegram-bot 库未安装,Telegram 适配器不可用") + logger.warning( + "Chưa cài python-telegram-bot; adapter Telegram không khả dụng" + ) return None - # 路径 A: bot 本身就是 ExtBot + # Đường dẫn A: bot chính là ExtBot. if ExtBot is not None and isinstance(self.bot, ExtBot): self._cached_client = self.bot return self._cached_client - # 路径 B: bot.client + # Đường dẫn B: bot.client. if hasattr(self.bot, "client"): client = self.bot.client if ExtBot is not None and isinstance(client, ExtBot): self._cached_client = client return self._cached_client - # 路径 C: bot 有 send_message 方法(ExtBot 的特征) + # Đường dẫn C: bot có send_message, đặc trưng ExtBot. if hasattr(self.bot, "send_message") and hasattr(self.bot, "send_photo"): self._cached_client = self.bot return self._cached_client - # 尝试从 bot 的其他属性获取 + # Thử các thuộc tính khác của bot. for attr in ("_client", "telegram_client", "_telegram_client", "bot"): if hasattr(self.bot, attr): client = getattr(self.bot, attr) @@ -165,7 +151,7 @@ class TelegramAdapter(PlatformAdapter): self._cached_client = client return self._cached_client - logger.warning("无法从 bot_instance 获取 Telegram 客户端") + logger.warning("Không thể lấy Telegram client từ bot_instance") return None # ==================== IMessageRepository ==================== @@ -179,12 +165,12 @@ class TelegramAdapter(PlatformAdapter): since_ts: int | None = None, ) -> list[UnifiedMessage]: """ - 获取历史消息。 + Lấy lịch sử tin nhắn. - 从 AstrBot 的 message_history_manager 读取存储的消息。 + Đọc tin nhắn đã lưu từ message_history_manager của AstrBot. """ if not self._context: - logger.warning("[Telegram] 未设置 context,无法获取消息历史") + logger.warning("[Telegram] Chưa thiết lập context, không thể lấy lịch sử") return [] try: @@ -192,7 +178,7 @@ class TelegramAdapter(PlatformAdapter): platform_id = self._get_platform_id() logger.info( - f"[Telegram] 正在获取群 {group_id} 的历史消息,使用 platform_id: {platform_id}" + f"[Telegram] Đang lấy lịch sử nhóm {group_id}, platform_id: {platform_id}" ) before_id_int: int | None = None if before_id: @@ -202,7 +188,7 @@ class TelegramAdapter(PlatformAdapter): logger.warning(f"[Telegram] before_id invalid: {before_id}") if since_ts and since_ts > 0: - # 统一使用 UTC 以兼容数据库记录的时间存储 + # Dùng UTC để tương thích thời gian lưu trong database. cutoff_time = datetime.fromtimestamp(since_ts, timezone.utc) else: cutoff_time = datetime.now(timezone.utc) - timedelta(days=days) @@ -224,14 +210,14 @@ class TelegramAdapter(PlatformAdapter): if not history_records: if current_page == 1: logger.info( - f"[Telegram] 群 {group_id} 没有存储的消息。" - f"提示:消息需要通过拦截器实时存储。" + f"[Telegram] Nhóm {group_id} chưa có tin nhắn đã lưu. " + "Tin nhắn cần được interceptor lưu theo thời gian thực." ) break total_records_loaded += len(history_records) - # 先用当前页已有的有效昵称预热缓存,减少额外 API 请求 + # Làm nóng cache bằng nickname hợp lệ trên trang để giảm API call. for record in history_records: sender_id = str(getattr(record, "sender_id", "") or "").strip() sender_name = str(getattr(record, "sender_name", "") or "").strip() @@ -264,7 +250,7 @@ class TelegramAdapter(PlatformAdapter): if not msg: continue - # 过滤机器人自己的消息 + # Lọc tin nhắn của bot. if self.bot_user_id and msg.sender_id == self.bot_user_id: continue if msg.sender_id in self.bot_self_ids: @@ -275,11 +261,11 @@ class TelegramAdapter(PlatformAdapter): ) messages.append(msg) - # 当前页完整处理后已足够,停止继续翻更旧页面。 + # Dừng nếu đã đủ sau khi xử lý hết trang hiện tại. if len(messages) >= target_count: break - # 下一页一定更旧,若当前页最旧记录已越过时间窗口则可提前停止 + # Dừng sớm nếu bản ghi cũ nhất đã vượt cửa sổ thời gian. if oldest_record_time and oldest_record_time < cutoff_time: break if len(history_records) < page_size: @@ -291,17 +277,17 @@ class TelegramAdapter(PlatformAdapter): messages = messages[-target_count:] logger.info( - f"[Telegram] 从数据库获取群 {group_id} 的消息: " - f"{len(messages)}/{total_records_loaded} 条" + f"[Telegram] Lấy tin nhắn nhóm {group_id} từ database: " + f"{len(messages)}/{total_records_loaded} mục" ) return messages except Exception as e: - logger.error(f"[Telegram] 获取消息历史失败: {e}") + logger.error(f"[Telegram] Lấy lịch sử tin nhắn thất bại: {e}") return [] def _get_platform_id(self) -> str: - """获取平台 ID""" + """Lấy platform ID.""" if self._platform_id: return self._platform_id @@ -310,7 +296,7 @@ class TelegramAdapter(PlatformAdapter): if config_platform_id: return config_platform_id - # 尝试从 bot 实例获取 + # Thử lấy từ bot instance. if hasattr(self.bot, "meta") and callable(self.bot.meta): try: meta = self.bot.meta() # type: ignore @@ -322,7 +308,7 @@ class TelegramAdapter(PlatformAdapter): @staticmethod def _is_placeholder_sender_name(name: str | None, sender_id: str | None) -> bool: - """判断 sender_name 是否属于占位值。""" + """Kiểm tra sender_name có phải giá trị placeholder hay không.""" if not name: return True normalized = str(name).strip() @@ -341,11 +327,9 @@ class TelegramAdapter(PlatformAdapter): sender_name_cache: dict[str, str], ) -> UnifiedMessage: """ - 如果 sender_name 是占位值,尝试通过 get_member_info 修复。 + Nếu sender_name là placeholder, thử sửa qua get_member_info. - 说明: - - 兼容历史脏数据(sender_name 写成 user_id / Unknown) - - 使用 sender_id 级缓存,避免重复请求 Telegram API + Tương thích dữ liệu lịch sử bẩn và cache theo sender_id để tránh gọi API lặp. """ if not self._is_placeholder_sender_name(msg.sender_name, msg.sender_id): return msg @@ -367,7 +351,7 @@ class TelegramAdapter(PlatformAdapter): if not self._is_placeholder_sender_name(candidate, sender_id): resolved_name = candidate except Exception as e: - logger.debug(f"[Telegram] 修复 sender_name 失败 (uid={sender_id}): {e}") + logger.debug(f"[Telegram] Sửa sender_name thất bại (uid={sender_id}): {e}") sender_name_cache[sender_id] = resolved_name if resolved_name == msg.sender_name: @@ -378,14 +362,14 @@ class TelegramAdapter(PlatformAdapter): self, record: Any, group_id: str ) -> UnifiedMessage | None: """ - 将数据库记录转换为 UnifiedMessage + Chuyển bản ghi database thành UnifiedMessage. """ try: content = record.content if not content: return None - # 提取消息内容 + # Trích xuất nội dung tin nhắn. message_parts = content.get("message", []) text_content = "" contents = [] @@ -448,14 +432,14 @@ class TelegramAdapter(PlatformAdapter): ) except Exception as e: - logger.debug(f"[Telegram] 转换历史记录失败: {e}") + logger.debug(f"[Telegram] Chuyển bản ghi lịch sử thất bại: {e}") return None def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: """ - 将统一消息格式转换为 OneBot 兼容格式 + Chuyển định dạng tin nhắn thống nhất sang định dạng tương thích OneBot. - 用于向后兼容现有分析逻辑。 + Dùng để tương thích ngược với logic phân tích hiện tại. """ result = [] for msg in messages: @@ -472,7 +456,7 @@ class TelegramAdapter(PlatformAdapter): "user_id": msg.sender_id, } - # 转换消息内容 + # Chuyển nội dung tin nhắn. for content in msg.contents: if content.type == MessageContentType.TEXT: raw["message"].append( @@ -499,14 +483,14 @@ class TelegramAdapter(PlatformAdapter): text: str, reply_to: str | None = None, ) -> bool: - """发送文本消息""" + """Gửi tin nhắn văn bản.""" client = self._telegram_client if not client: - logger.error("[Telegram] 客户端未初始化,无法发送文本") + logger.error("[Telegram] Client chưa khởi tạo, không thể gửi văn bản") return False try: - # 处理群组话题 ID + # Xử lý ID topic nhóm. chat_id, message_thread_id = self._parse_group_id(group_id) kwargs: dict[str, Any] = {"chat_id": chat_id, "text": text} @@ -518,7 +502,7 @@ class TelegramAdapter(PlatformAdapter): await client.send_message(**kwargs) return True except Exception as e: - logger.error(f"[Telegram] 发送文本失败: {e}") + logger.error(f"[Telegram] Gửi văn bản thất bại: {e}") return False async def send_image( @@ -527,10 +511,10 @@ class TelegramAdapter(PlatformAdapter): image_path: str, caption: str = "", ) -> bool: - """发送图片消息""" + """Gửi tin nhắn ảnh.""" client = self._telegram_client if not client: - logger.error("[Telegram] 客户端未初始化,无法发送图片") + logger.error("[Telegram] Client chưa khởi tạo, không thể gửi ảnh") return False try: @@ -544,7 +528,7 @@ class TelegramAdapter(PlatformAdapter): if caption: kwargs["caption"] = caption - # 1. 统一处理输入源 (Base64 / URL / Local File) + # 1. Xử lý thống nhất nguồn Base64, URL hoặc tệp local. if image_path.startswith("base64://"): data = base64.b64decode(image_path[len("base64://") :]) file_obj = BytesIO(data) @@ -568,19 +552,21 @@ class TelegramAdapter(PlatformAdapter): file_obj = BytesIO(data) is_temp_obj = True else: - file_obj = image_path # 尝试直接发 URL + file_obj = image_path # Thử gửi URL trực tiếp. except Exception as e: - logger.warning(f"[Telegram] 下载图片失败,尝试直接发送: {e}") + logger.warning( + f"[Telegram] Tải ảnh thất bại, thử gửi trực tiếp: {e}" + ) file_obj = image_path else: - # 本地文件 + # Tệp local. if os.path.exists(image_path): file_obj = open(image_path, "rb") is_temp_obj = True else: file_obj = image_path - # 2. 发送图片 + # 2. Gửi ảnh. kwargs["photo"] = file_obj try: await client.send_photo(**kwargs) @@ -592,18 +578,20 @@ class TelegramAdapter(PlatformAdapter): except Exception as e: err_msg = str(e) - # Photo_invalid_dimensions: Telegram 报错提示图片长宽比例或总尺寸不合规 + # Photo_invalid_dimensions: tỷ lệ hoặc tổng kích thước ảnh không hợp lệ. if ( "Photo_invalid_dimensions" in err_msg or "Photo invalid dimensions" in err_msg ): - logger.warning("[Telegram] 图片尺寸超限,正在尝试以文件形式发送...") - # 构造一个更有意义的文件名 + logger.warning( + "[Telegram] Kích thước ảnh vượt giới hạn, thử gửi dạng tệp..." + ) + # Tạo tên tệp dễ hiểu hơn. ts = datetime.now().strftime("%Y%m%d_%H%M%S") fn = f"analysis_report_{group_id}_{ts}.png" return await self.send_file(group_id, image_path, filename=fn) - logger.error(f"[Telegram] 发送图片失败: {e}") + logger.error(f"[Telegram] Gửi ảnh thất bại: {e}") return False async def send_file( @@ -612,10 +600,10 @@ class TelegramAdapter(PlatformAdapter): file_path: str, filename: str | None = None, ) -> bool: - """发送文件消息""" + """Gửi tin nhắn tệp.""" client = self._telegram_client if not client: - logger.error("[Telegram] 客户端未初始化,无法发送文件") + logger.error("[Telegram] Client chưa khởi tạo, không thể gửi tệp") return False try: @@ -627,7 +615,7 @@ class TelegramAdapter(PlatformAdapter): if message_thread_id: kwargs["message_thread_id"] = int(message_thread_id) - # 1. 统一处理输入源 (Base64 / Local File) + # 1. Xử lý thống nhất nguồn Base64 hoặc tệp local. if file_path.startswith("base64://"): data = base64.b64decode(file_path[len("base64://") :]) file_obj = BytesIO(data) @@ -648,7 +636,7 @@ class TelegramAdapter(PlatformAdapter): if not filename: filename = os.path.basename(file_path) else: - # 可能是 URL 或缓存 ID + # Có thể là URL hoặc cache ID. file_obj = file_path if not filename: filename = "file" @@ -664,25 +652,25 @@ class TelegramAdapter(PlatformAdapter): return True except Exception as e: - logger.error(f"[Telegram] 发送文件失败: {e}") + logger.error(f"[Telegram] Gửi tệp thất bại: {e}") return False async def send_forward_msg(self, group_id: str, nodes: list[dict]) -> bool: """ - 发送合并转发消息 + Gửi tin nhắn chuyển tiếp đã gộp. - Telegram 不支持原生转发消息链,转换为格式化文本发送。 + Telegram không hỗ trợ chuỗi chuyển tiếp native nên gửi dạng văn bản định dạng. """ if not nodes: return True - lines = ["📊 **分析报告**\n"] + lines = ["📊 **Báo cáo phân tích**\n"] for node in nodes: data = node.get("data", node) name = data.get("name", "AstrBot") content = data.get("content", "") if isinstance(content, list): - # 消息链 + # Chuỗi tin nhắn. text_parts = [] for seg in content: if isinstance(seg, dict) and seg.get("type") == "text": @@ -692,7 +680,7 @@ class TelegramAdapter(PlatformAdapter): full_text = "\n".join(lines) - # 分段发送(Telegram 限制 4096 字符) + # Chia đoạn vì Telegram giới hạn 4096 ký tự. max_len = 4000 if len(full_text) > max_len: parts = [ @@ -708,7 +696,7 @@ class TelegramAdapter(PlatformAdapter): # ==================== IGroupInfoRepository ==================== async def get_group_info(self, group_id: str) -> UnifiedGroup | None: - """获取群组信息""" + """Lấy thông tin nhóm.""" client = self._telegram_client if not client: return None @@ -725,14 +713,14 @@ class TelegramAdapter(PlatformAdapter): platform="telegram", ) except Exception as e: - logger.debug(f"[Telegram] 获取群信息失败: {e}") + logger.debug(f"[Telegram] Lấy thông tin nhóm thất bại: {e}") return None async def get_member_list(self, group_id: str) -> list[UnifiedMember]: """ - 获取成员列表 + Lấy danh sách thành viên. - Telegram Bot API 对成员列表获取有限制。 + Telegram Bot API giới hạn việc lấy danh sách thành viên. """ client = self._telegram_client if not client: @@ -740,8 +728,7 @@ class TelegramAdapter(PlatformAdapter): try: chat_id, _ = self._parse_group_id(group_id) - # Telegram Bot API 需要使用 getChatAdministrators - # 只能获取管理员列表,无法获取全部成员 + # Telegram Bot API chỉ cho lấy danh sách quản trị viên. admins = await client.get_chat_administrators(chat_id=chat_id) members = [] @@ -760,7 +747,7 @@ class TelegramAdapter(PlatformAdapter): ) return members except Exception as e: - logger.debug(f"[Telegram] 获取成员列表失败: {e}") + logger.debug(f"[Telegram] Lấy danh sách thành viên thất bại: {e}") return [] async def get_member_info( @@ -768,7 +755,7 @@ class TelegramAdapter(PlatformAdapter): group_id: str, user_id: str, ) -> UnifiedMember | None: - """获取成员信息""" + """Lấy thông tin thành viên.""" client = self._telegram_client if not client: return None @@ -794,7 +781,7 @@ class TelegramAdapter(PlatformAdapter): role=role, ) except Exception as e: - logger.debug(f"[Telegram] 获取成员信息失败: {e}") + logger.debug(f"[Telegram] Lấy thông tin thành viên thất bại: {e}") return None # ==================== IAvatarRepository ==================== @@ -805,14 +792,14 @@ class TelegramAdapter(PlatformAdapter): size: int = 100, ) -> str | None: """ - 获取用户头像 URL + Lấy URL avatar người dùng. - Telegram 需要调用 API 获取头像文件。 + Telegram cần gọi API để lấy tệp avatar. """ client = self._telegram_client if not client: logger.warning( - f"[Telegram] 获取用户头像失败 uid={user_id}: Telegram 客户端未初始化" + f"[Telegram] Lấy avatar người dùng thất bại uid={user_id}: client chưa khởi tạo" ) return None @@ -820,67 +807,72 @@ class TelegramAdapter(PlatformAdapter): cached_reason = self._get_avatar_negative_cache_reason(user_id_str) if cached_reason: logger.debug( - f"[Telegram] 跳过用户头像获取 uid={user_id_str}: negative cache 命中," - f"上次失败原因: {cached_reason}" + f"[Telegram] Bỏ qua avatar uid={user_id_str}: trúng negative cache, " + f"lý do thất bại trước: {cached_reason}" ) return None try: tg_user_id = int(user_id_str) except (TypeError, ValueError): - reason = f"用户 ID 不是有效整数: {user_id!r}" + reason = f"ID người dùng không phải số nguyên hợp lệ: {user_id!r}" self._remember_avatar_negative(user_id_str, reason) - logger.warning(f"[Telegram] 获取用户头像失败 uid={user_id}: {reason}") + logger.warning( + f"[Telegram] Lấy avatar người dùng thất bại uid={user_id}: {reason}" + ) return None try: photos = await client.get_user_profile_photos(user_id=tg_user_id, limit=1) if photos.photos: - # 获取最大尺寸的头像 + # Lấy avatar kích thước lớn nhất. photo_sizes = photos.photos[0] if photo_sizes: - # 选择最接近请求尺寸的 - best = photo_sizes[-1] # 通常最后一个是最大的 + # Chọn kích thước gần yêu cầu nhất. + best = photo_sizes[-1] # Phần tử cuối thường lớn nhất. file = await client.get_file(best.file_id) if file.file_path: - # 构建完整 URL - # 格式: https://api.telegram.org/file/bot/ - # python-telegram-bot 的 File.file_path 属性通常只返回路径部分 - # 需要手动拼接或使用 instance.file.file_path (取决于版本) + # Dựng URL đầy đủ; File.file_path thường chỉ trả phần path. file_path = file.file_path if file_path.startswith("http"): return file_path - # 尝试构建完整 URL + # Thử dựng URL đầy đủ. if hasattr(client, "token"): return f"https://api.telegram.org/file/bot{client.token}/{file_path}" - # 如果无法获取 token,返回 None - reason = "get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL" + # Trả None nếu không lấy được token. + reason = "get_file trả file_path tương đối nhưng client không có token để dựng URL tải" self._remember_avatar_negative(user_id_str, reason) logger.warning( - f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}" + f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}" ) return None - reason = "get_file 未返回 file_path" + reason = "get_file không trả file_path" self._remember_avatar_negative(user_id_str, reason) logger.warning( - f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}" + f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}" ) return None - reason = "get_user_profile_photos 返回的首张头像没有可用尺寸" + reason = "Avatar đầu tiên từ get_user_profile_photos không có kích thước khả dụng" self._remember_avatar_negative(user_id_str, reason) - logger.info(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}") + logger.info( + f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}" + ) return None - reason = "get_user_profile_photos 返回空列表,用户可能没有公开头像或隐私设置不可见" + reason = "get_user_profile_photos trả danh sách rỗng; avatar có thể không công khai" self._remember_avatar_negative(user_id_str, reason) - logger.info(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}") + logger.info( + f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}" + ) return None except Exception as e: reason = f"{type(e).__name__}: {e}" self._remember_avatar_negative(user_id_str, reason) - logger.warning(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}") + logger.warning( + f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}" + ) return None async def get_user_avatar_data( @@ -888,10 +880,10 @@ class TelegramAdapter(PlatformAdapter): user_id: str, size: int = 100, ) -> str | None: - """获取头像的 Base64 数据""" - # 暂不实现,返回 None + """Lấy dữ liệu Base64 của avatar.""" + # Chưa triển khai, trả về None. logger.debug( - f"[Telegram] 获取用户头像数据失败 uid={user_id}: get_user_avatar_data 暂未实现" + f"[Telegram] Không thể lấy dữ liệu avatar uid={user_id}: get_user_avatar_data chưa triển khai" ) return None @@ -900,11 +892,11 @@ class TelegramAdapter(PlatformAdapter): group_id: str, size: int = 100, ) -> str | None: - """获取群组头像 URL""" + """Lấy URL avatar nhóm.""" client = self._telegram_client if not client: logger.warning( - f"[Telegram] 获取群头像失败 group_id={group_id}: Telegram 客户端未初始化" + f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: client chưa khởi tạo" ) return None @@ -923,26 +915,26 @@ class TelegramAdapter(PlatformAdapter): return f"https://api.telegram.org/file/bot{client.token}/{file_path}" logger.warning( - f"[Telegram] 获取群头像失败 group_id={group_id}: " - "get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL" + f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: " + "get_file trả file_path tương đối nhưng client không có token để dựng URL tải" ) return None logger.warning( - f"[Telegram] 获取群头像失败 group_id={group_id}: get_file 未返回 file_path" + f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: get_file không trả file_path" ) return None logger.info( - f"[Telegram] 获取群头像失败 group_id={group_id}: 群组未设置头像或 bot 不可见" + f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: nhóm chưa đặt avatar hoặc bot không thấy" ) return None except Exception as e: logger.warning( - f"[Telegram] 获取群头像失败 group_id={group_id}: {type(e).__name__}: {e}" + f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: {type(e).__name__}: {e}" ) return None def _prune_avatar_negative_cache(self) -> None: - """清理过期项并限制 negative cache 大小,避免长期运行时无界增长。""" + """Xoá mục hết hạn và giới hạn negative cache để tránh tăng vô hạn.""" cache = self._avatar_negative_cache if not cache: return @@ -988,11 +980,11 @@ class TelegramAdapter(PlatformAdapter): user_ids: list[str], size: int = 100, ) -> dict[str, str | None]: - """批量获取头像 URL""" + """Lấy hàng loạt URL avatar.""" if not user_ids: return {} - # 适度并发,避免串行等待过久,也避免瞬时过载 Telegram API + # Giới hạn concurrency để tránh chờ tuần tự lâu hoặc quá tải Telegram API. semaphore = asyncio.Semaphore(8) async def _fetch_avatar(uid: str) -> tuple[str, str | None]: @@ -1000,7 +992,9 @@ class TelegramAdapter(PlatformAdapter): try: return uid, await self.get_user_avatar_url(uid, size) except Exception as e: - logger.debug(f"[Telegram] 批量获取头像失败 uid={uid}: {e}") + logger.debug( + f"[Telegram] Lấy avatar hàng loạt thất bại uid={uid}: {e}" + ) return uid, None pairs = await asyncio.gather(*(_fetch_avatar(uid) for uid in user_ids)) @@ -1010,7 +1004,7 @@ class TelegramAdapter(PlatformAdapter): self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True ) -> bool: """ - Telegram 实现消息回应。 + Triển khai reaction tin nhắn Telegram. """ client = self._telegram_client if not client: @@ -1019,7 +1013,7 @@ class TelegramAdapter(PlatformAdapter): try: chat_id, _ = self._parse_group_id(group_id) - # 只有开启了库支持且版本符合时才尝试。set_message_reaction 是 Bot API 7.0 (PTB 20.8+) 特性。 + # set_message_reaction cần Bot API 7.0 và PTB 20.8 trở lên. if not hasattr(client, "set_message_reaction"): return False @@ -1079,20 +1073,20 @@ class TelegramAdapter(PlatformAdapter): continue logger.debug( - f"[Telegram] set_reaction 未匹配到可用表情: emoji={emoji}, candidates={candidates}" + f"[Telegram] set_reaction không khớp emoji khả dụng: emoji={emoji}, candidates={candidates}" ) return False except Exception as e: - logger.debug(f"[Telegram] set_reaction 失败: {e}") + logger.debug(f"[Telegram] set_reaction thất bại: {e}") return False - # ==================== 辅助方法 ==================== + # ==================== Phương thức hỗ trợ ==================== def _parse_group_id(self, group_id: str) -> tuple[str, str | None]: """ - 解析群组 ID + Parse ID nhóm. - Telegram 话题群的 ID 格式为: "chat_id#thread_id" + ID nhóm topic Telegram có dạng ``chat_id#thread_id``. Returns: tuple[str, str | None]: (chat_id, message_thread_id) diff --git a/src/infrastructure/platform/base.py b/src/infrastructure/platform/base.py index 7f11db3..5857b56 100644 --- a/src/infrastructure/platform/base.py +++ b/src/infrastructure/platform/base.py @@ -1,6 +1,4 @@ -""" -平台适配器基类 -""" +"""Lớp cơ sở cho adapter nền tảng.""" from abc import ABC, abstractmethod from collections.abc import Mapping @@ -20,14 +18,14 @@ class PlatformAdapter( IMessageRepository, IMessageSender, IGroupInfoRepository, IAvatarRepository, ABC ): """ - 基础设施:平台适配器基类 + Lớp cơ sở adapter ở tầng infrastructure. - 继承自多个领域接口(仓储、发送器、群组信息、头像), - 充当领域层与具体聊天平台(如 OneBot, Discord)之间的中转站。 + Kế thừa các giao diện domain về repository, gửi tin nhắn, thông tin nhóm + và avatar; làm cầu nối giữa domain với nền tảng như OneBot hoặc Discord. Attributes: - bot (Any): 平台对应的机器人 SDK 实例,显式标注为 Any 以支持动态属性调用 - config (dict): 针对该平台的特定配置 + bot: Instance SDK bot của nền tảng. + config: Cấu hình riêng của nền tảng. """ bot: Any @@ -38,11 +36,11 @@ class PlatformAdapter( config: Mapping[str, Any] | None = None, ): """ - 初始化平台适配器。 + Khởi tạo adapter nền tảng. Args: - bot_instance (Any): 后端机器人实例 - config (dict, optional): 平台特定配置项 + bot_instance: Instance bot backend. + config: Cấu hình riêng của nền tảng. """ self.bot = bot_instance self.config: dict[str, object] = dict(config) if config is not None else {} @@ -51,22 +49,22 @@ class PlatformAdapter( def set_context(self, context: Any): """ - 设置上下文对象(用于部分需要 ctx 的平台如 Telegram)。 + Thiết lập context cho nền tảng cần context như Telegram. Args: - context (Any): 上下文对象 + context: Đối tượng context. """ pass @property def capabilities(self) -> PlatformCapabilities: """ - 获取当前平台的能力描述对象。 + Lấy mô tả năng lực của nền tảng hiện tại. - 采用延迟加载机制,在首次访问时调用 `_init_capabilities`。 + Dùng lazy loading và gọi ``_init_capabilities`` khi truy cập lần đầu. Returns: - PlatformCapabilities: 平台能力对象 + Đối tượng năng lực nền tảng. """ if self._capabilities is None: self._capabilities = self._init_capabilities() @@ -75,35 +73,35 @@ class PlatformAdapter( @abstractmethod def _init_capabilities(self) -> PlatformCapabilities: """ - 初始化并返回当前平台的能力定义。 + Khởi tạo và trả về định nghĩa năng lực nền tảng hiện tại. - 子类必须实现此方法以声明其对历史记录、图片发送等功能的支持情况。 + Lớp con phải khai báo hỗ trợ lịch sử, gửi ảnh và các tính năng khác. Returns: - PlatformCapabilities: 初始化后的能力对象 + Đối tượng năng lực đã khởi tạo. """ raise NotImplementedError def get_capabilities(self) -> PlatformCapabilities: - """获取平台能力的便捷入口。""" + """Điểm vào tiện lợi để lấy năng lực nền tảng.""" return self.capabilities def get_platform_name(self) -> str: - """获取当前适配器的平台标识名称。""" + """Lấy tên định danh nền tảng của adapter hiện tại.""" return self.capabilities.platform_name @abstractmethod def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: """ - 将平台无关的统一消息列表转换回当前平台的原生字典格式。 + Chuyển tin nhắn thống nhất độc lập nền tảng về dict gốc của nền tảng. - 此方法主要用于向后兼容,使新的统一接口能与依赖原生数据结构的旧版分析逻辑协同工作。 + Dùng để tương thích ngược với logic phân tích cũ phụ thuộc cấu trúc gốc. Args: - messages (list[UnifiedMessage]): 待转换的统一消息列表 + messages: Danh sách tin nhắn thống nhất cần chuyển. Returns: - list[dict]: 转换后的平台原生消息字典列表 + Danh sách dict tin nhắn gốc của nền tảng. """ raise NotImplementedError @@ -113,13 +111,14 @@ class PlatformAdapter( nodes: list[dict], ) -> bool: """ - 发送合并转发消息(基类默认实现:转换为格式化文本分段发送)。 - 各适配器可覆盖此方法实现原生合并转发。 + Gửi tin nhắn chuyển tiếp gộp. + + Mặc định chuyển thành văn bản có định dạng và chia đoạn; adapter có thể ghi đè. """ if not nodes: return True - # 万能回退:将节点重新组合成易读的长文本 + # Fallback chung: ghép node thành văn bản dài dễ đọc. lines = [] for node in nodes: data = node.get("data", node) @@ -130,14 +129,14 @@ class PlatformAdapter( full_text = "\n\n".join(lines) - # 处理超长文本分段(取大部分平台的安全阈值 1800 字符) + # Chia văn bản dài ở ngưỡng an toàn 1.800 ký tự. max_chunk_size = 1800 if len(full_text) > max_chunk_size: - # 尝试在换行处拆分 + # Thử tách tại ký tự xuống dòng. chunks = [] curr = full_text while len(curr) > max_chunk_size: - # 寻找最近的换行符 + # Tìm ký tự xuống dòng gần nhất. split_idx = curr.rfind("\n", 0, max_chunk_size) if split_idx == -1: split_idx = max_chunk_size @@ -157,32 +156,33 @@ class PlatformAdapter( self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True ) -> bool: """ - 对消息添加/移除表情回应。 + Thêm hoặc xoá reaction cho tin nhắn. Args: - group_id (str): 群组/频道 ID - message_id (str): 消息 ID - emoji (str | int): 表情代码或字符 - is_add (bool): True 为添加,False 为移除 + group_id: ID nhóm/kênh. + message_id: ID tin nhắn. + emoji: Mã hoặc ký tự emoji. + is_add: True để thêm, False để xoá. Returns: - bool: 平台是否支持并成功执行 + True nếu nền tảng hỗ trợ và thực thi thành công. """ return False async def send_text_report(self, group_id: str, content: str) -> bool: """ - 以最适合当前平台的方式发送长文本报告。 - 默认逻辑:将长文本切分为多个节点,然后调用 send_forward_msg。 - 各平台适配器通过实现 send_forward_msg 来决定最终呈现形式(合并转发、分段发送等)。 + Gửi báo cáo văn bản dài theo cách phù hợp nhất với nền tảng. + + Mặc định chia thành node rồi gọi ``send_forward_msg``; adapter quyết định + hình thức cuối như chuyển tiếp gộp hoặc gửi theo đoạn. """ import re try: - # 1. 准备节点基础信息 + # 1. Chuẩn bị thông tin node cơ bản. self_id = self.bot_self_ids[0] if self.bot_self_ids else "bot" - self_name = "分析报告" - # 2. 切分文本为逻辑段落(按标题、空行切分) + self_name = "Báo cáo phân tích" + # 2. Chia thành đoạn logic theo tiêu đề và dòng trống. raw_content = str(content) sections = re.split(r"\n+(?=[🎯📊💬🏆])|\n{2,}", raw_content.strip()) nodes = [] @@ -204,15 +204,16 @@ class PlatformAdapter( if not nodes: return await self.send_text(group_id, raw_content) - # 3. 尝试发送转发消息/长消息链 + # 3. Thử gửi chuyển tiếp hoặc chuỗi tin nhắn dài. return await self.send_forward_msg(group_id, nodes) except Exception: - # 兜底:直接发送 + # Fallback: gửi trực tiếp. return await self.send_text(group_id, str(content)) async def is_group_muted(self, group_id: str) -> bool: """ - 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)。 - 默认返回 False。各平台适配器可以根据需要重写此方法。 + Kiểm tra nhóm có tắt chat toàn bộ hoặc tắt quyền bot hay không. + + Mặc định trả về False; adapter có thể ghi đè khi cần. """ return False diff --git a/src/infrastructure/platform/bot_manager.py b/src/infrastructure/platform/bot_manager.py index 96d508f..9546fff 100644 --- a/src/infrastructure/platform/bot_manager.py +++ b/src/infrastructure/platform/bot_manager.py @@ -1,7 +1,4 @@ -""" -Bot实例管理模块 - 基础设施层 -统一管理bot实例的获取、设置和使用 -""" +"""Quản lý tập trung các bot instance ở tầng infrastructure.""" from __future__ import annotations @@ -12,50 +9,47 @@ from . import PlatformAdapter, PlatformAdapterFactory class BotManager: - """ - Bot实例管理器 - 统一管理所有bot相关操作 - - 与 DDD 架构集成,为每个 bot 实例创建对应的 PlatformAdapter, - 实现跨平台支持。 - """ + """Quản lý bot và tạo PlatformAdapter tương ứng để hỗ trợ đa nền tảng.""" def __init__(self, config_manager): self.config_manager = config_manager self._bot_instances: dict[str, object] = {} # {platform_id: bot_instance} self._adapters: dict[ str, PlatformAdapter - ] = {} # {platform_id: PlatformAdapter} - DDD 集成 - self._platforms: dict[str, object] = {} # 存储平台对象以访问配置 - self._bot_self_ids: list[str] = [] # 支持多个机器人账号 ID (原 _bot_qq_ids) + ] = {} # {platform_id: PlatformAdapter} - tích hợp DDD. + self._platforms: dict[str, object] = {} # Lưu platform để truy cập cấu hình. + self._bot_self_ids: list[ + str + ] = [] # Hỗ trợ nhiều ID bot, trước đây là _bot_qq_ids. self._context: object | None = None self._is_initialized = False - self._default_platform = "default" # 默认平台 - self._plugin_instance: object | None = None # 插件实例引用,用于适配器回调 + self._default_platform = "default" # Nền tảng mặc định. + self._plugin_instance: object | None = None # Dùng cho callback adapter. def set_context(self, context): - """设置AstrBot上下文,并传递给所有支持的适配器""" + """Thiết lập context AstrBot và truyền tới adapter có hỗ trợ.""" self._context = context - # 将 context 传递给所有支持 set_context 的适配器 + # Truyền context tới mọi adapter hỗ trợ set_context. for adapter in self._adapters.values(): if hasattr(adapter, "set_context"): adapter.set_context(context) def set_plugin_instance(self, plugin_instance: object): - """设置插件实例引用""" + """Thiết lập tham chiếu plugin instance.""" self._plugin_instance = plugin_instance def set_bot_instance(self, bot_instance, platform_id=None, platform_name=None): """ - 设置bot实例,支持指定平台ID + Thiết lập bot instance với platform ID tùy chọn. - 同时会创建对应的 PlatformAdapter(如果平台被支持)。 + Đồng thời tạo PlatformAdapter nếu nền tảng được hỗ trợ. """ if not platform_id: platform_id = self._get_platform_id_from_instance(bot_instance) if bot_instance and platform_id: - # 如果 bot_instance 没变,且已经有适配器,跳过重新创建,防止丢失内部状态(如缓存等) + # Không tạo lại adapter nếu instance không đổi để giữ trạng thái nội bộ. old_instance = self._bot_instances.get(platform_id) if bot_instance is old_instance and platform_id in self._adapters: bot_self_id = self._extract_bot_self_id(bot_instance) @@ -65,7 +59,7 @@ class BotManager: self._bot_instances[platform_id] = bot_instance - # 为 DDD 集成创建 PlatformAdapter + # Tạo PlatformAdapter để tích hợp DDD. if platform_name is None: platform_name = self._detect_platform_name(bot_instance) @@ -84,91 +78,91 @@ class BotManager: platform_name, bot_instance, adapter_config ) if adapter: - # 如果有 context,传递给适配器 + # Truyền context tới adapter nếu có. if self._context is not None: adapter.set_context(self._context) self._adapters[platform_id] = adapter logger.debug( - f"已为 {platform_id} ({platform_name}) 创建 PlatformAdapter" + f"Đã tạo PlatformAdapter cho {platform_id} ({platform_name})" ) - # 自动提取机器人 ID + # Tự trích xuất ID bot. bot_self_id = self._extract_bot_self_id(bot_instance) if bot_self_id and bot_self_id not in self._bot_self_ids: self._bot_self_ids.append(str(bot_self_id)) def set_bot_self_ids(self, bot_self_ids): - """设置机器人 ID 列表(支持单个 ID 或 ID 列表)""" + """Thiết lập một ID bot hoặc danh sách ID bot.""" if isinstance(bot_self_ids, list): self._bot_self_ids = [str(uid) for uid in bot_self_ids if uid] elif bot_self_ids: self._bot_self_ids = [str(bot_self_ids)] - # 同步更新所有现有适配器的 ID 列表 + # Đồng bộ danh sách ID tới mọi adapter hiện có. for adapter in self._adapters.values(): if hasattr(adapter, "bot_self_ids"): adapter.bot_self_ids = self._bot_self_ids.copy() def get_bot_instance(self, platform_id=None): - """获取指定平台的bot实例,如果不指定则返回第一个可用的实例""" + """Lấy bot instance theo platform hoặc instance khả dụng duy nhất.""" if platform_id: - # 如果指定了平台ID,尝试获取 + # Thử lấy theo platform ID được chỉ định. instance = self._bot_instances.get(platform_id) if not instance and platform_id in self._platforms: self._refresh_from_stored_platforms() instance = self._bot_instances.get(platform_id) return instance - # 没有指定平台ID + # Không có platform ID. if not self._bot_instances and self._platforms: self._refresh_from_stored_platforms() if self._bot_instances: - # 如果只有一个实例,直接返回 + # Trả trực tiếp nếu chỉ có một instance. if len(self._bot_instances) == 1: return list(self._bot_instances.values())[0] - # 如果有多个实例,必须指定 platform_id + # Bắt buộc chỉ định platform_id khi có nhiều instance. logger.error( - f"存在多个Bot实例 {list(self._bot_instances.keys())} 但未指定 platform_id," - "无法确定使用哪个实例。请明确指定 platform_id。" + f"Có nhiều bot instance {list(self._bot_instances.keys())} nhưng chưa chỉ định platform_id; " + "không thể xác định instance cần dùng" ) return None - # 没有任何平台可用 - logger.error("没有任何可用的bot实例") + # Không có nền tảng khả dụng. + logger.error("Không có bot instance khả dụng") return None def _refresh_from_stored_platforms(self): - """尝试从已存储的平台对象中刷新 bot 实例 (Lazy Load)""" + """Thử làm mới bot instance từ platform đã lưu theo lazy load.""" for platform_id, platform in self._platforms.items(): bot_client = None - # Lark 平台优先使用 API client,避免拿到仅支持长连接的 ws client + # Lark ưu tiên API client thay vì ws client chỉ hỗ trợ kết nối dài. bot_client = getattr(platform, "lark_api", None) - # 优先尝试 get_client() + # Ưu tiên get_client(). get_client = getattr(platform, "get_client", None) if not bot_client and callable(get_client): bot_client = get_client() - # 如果 get_client() 返回 None,尝试直接访问属性 + # Nếu get_client() trả None, thử truy cập thuộc tính trực tiếp. if not bot_client: bot_client = getattr(platform, "bot", None) if not bot_client: - # AstrBot v4.14.4 DiscordPlatformAdapter 使用 'client' 属性 + # DiscordPlatformAdapter AstrBot v4.14.4 dùng thuộc tính client. bot_client = getattr(platform, "client", None) if bot_client: - # 检查是否已存在且是否发生变化(防止重复创建适配器) + # Kiểm tra client có thay đổi để tránh tạo adapter lặp. old_client = self._bot_instances.get(platform_id) - # 如果 client 对象没变且已经有适配器,跳过 + # Bỏ qua nếu client không đổi và adapter đã tồn tại. if bot_client is old_client and platform_id in self._adapters: continue platform_name = None metadata_obj = getattr(platform, "metadata", None) if metadata_obj is not None: - # 优先使用 type + # Ưu tiên type. type_val = getattr(metadata_obj, "type", None) if isinstance(type_val, str): platform_name = type_val @@ -177,7 +171,7 @@ class BotManager: if isinstance(name_val, str): platform_name = name_val - # 兼容不同版本的元数据获取 + # Tương thích cách lấy metadata ở các phiên bản. if not platform_name: meta = getattr(platform, "meta", None) if callable(meta): @@ -187,7 +181,7 @@ class BotManager: except Exception: pass - # 后备检测:如果不支持名称 + # Phát hiện fallback nếu tên không được hỗ trợ. if not platform_name or not PlatformAdapterFactory.is_supported( str(platform_name) ): @@ -196,76 +190,77 @@ class BotManager: platform_name = detected self.set_bot_instance(bot_client, platform_id, platform_name) - logger.info(f"已刷新/发现平台 {platform_id} 的 bot 实例 (变动或懒加载)") + logger.info( + f"Đã làm mới/phát hiện bot instance của platform {platform_id}" + ) def get_all_bot_instances(self) -> dict: - """获取所有已加载的bot实例 {platform_id: bot_instance}""" + """Lấy mọi bot instance đã tải theo platform_id.""" return self._bot_instances.copy() def get_platform_count(self) -> int: - """获取当前已加载的平台数量""" + """Lấy số nền tảng hiện đã tải.""" return len(self._bot_instances) def get_platform_ids(self) -> list[str]: - """获取所有已加载的平台 ID 列表""" + """Lấy danh sách platform ID đã tải.""" return list(self._bot_instances.keys()) def has_bot_instance(self) -> bool: - """检查是否有可用的bot实例""" + """Kiểm tra có bot instance khả dụng hay không.""" return bool(self._bot_instances) def has_bot_self_id(self) -> bool: - """检查是否有配置的机器人 ID""" + """Kiểm tra có ID bot đã cấu hình hay không.""" return bool(self._bot_self_ids) def is_ready_for_auto_analysis(self) -> bool: - """检查是否准备好进行了自动分析""" + """Kiểm tra đã sẵn sàng phân tích tự động hay chưa.""" if not self.has_bot_instance(): - logger.debug("[BotManager] 自动分析就绪检查失败:没有可用的 bot 实例。") + logger.debug( + "[BotManager] Chưa sẵn sàng phân tích tự động: không có bot instance" + ) return False if not self.has_bot_self_id(): - # 允许在没有配置/自动提取到 ID 的情况下尝试,但记录调试信息 - # 这有助于诊断某些平台(如 Telegram)自动获取 ID 失败的情况 + # Vẫn cho phép thử khi chưa cấu hình/trích xuất được ID và ghi debug. logger.debug( - "[BotManager] 自动分析就绪检查警告:bot_self_ids 列表为空。可能会影响消息过滤能力。" + "[BotManager] Cảnh báo sẵn sàng tự động: bot_self_ids rỗng, có thể ảnh hưởng lọc tin nhắn" ) - # 为了解决 #128 及其后续反馈,我们将此检查放宽 - # 只要有 bot 实例就可以尝试运行 + # Nới lỏng kiểm tra theo #128: có bot instance là có thể thử chạy. return True return True def _get_platform_id_from_instance(self, bot_instance): - """从bot实例获取平台ID""" + """Lấy platform ID từ bot instance.""" if hasattr(bot_instance, "platform") and isinstance(bot_instance.platform, str): return bot_instance.platform return self._default_platform def _detect_platform_name(self, bot_instance) -> str | None: """ - 从 bot 实例检测平台名称,用于创建适配器。 + Phát hiện tên nền tảng từ bot instance để tạo adapter. - 返回平台名称如 'aiocqhttp', 'discord' 等。 + Trả về tên như ``aiocqhttp`` hoặc ``discord``. """ - # 优先使用 platform 属性 + # Ưu tiên thuộc tính platform. if hasattr(bot_instance, "platform"): platform = bot_instance.platform if isinstance(platform, str): return platform - # 检查已知的 API 特征(平台无关的方式) - # OneBot/aiocqhttp 特征: 有 call_action 方法 + # Kiểm tra đặc trưng API đã biết; OneBot/aiocqhttp có call_action. if hasattr(bot_instance, "call_action"): return "aiocqhttp" - # 使用工厂的已注册平台列表进行类名匹配 + # Khớp tên class với nền tảng đã đăng ký trong factory. class_name = type(bot_instance).__name__.lower() for platform_name in PlatformAdapterFactory.get_supported_platforms(): if platform_name in class_name: return platform_name - # 通用类名模式匹配(用于尚未注册的平台) + # Khớp mẫu tên class chung cho nền tảng chưa đăng ký. known_patterns = { "cqhttp": "aiocqhttp", "onebot": "aiocqhttp", @@ -276,16 +271,16 @@ class BotManager: return None - # ==================== DDD 集成方法 ==================== + # ==================== Phương thức tích hợp DDD ==================== def get_adapter(self, platform_id: str | None = None) -> PlatformAdapter | None: """ - 获取指定平台的 PlatformAdapter。 + Lấy PlatformAdapter của nền tảng được chỉ định. - 这是 DDD 架构操作的主要方法。 + Đây là phương thức chính cho thao tác kiến trúc DDD. """ if platform_id: - # 无论是否存在适配器,都尝试检测一次 client 是否有变(如重启后 session 变化) + # Luôn kiểm tra client có đổi hay không, ví dụ sau khi restart phiên. if platform_id in self._platforms: self._refresh_from_stored_platforms() @@ -296,11 +291,11 @@ class BotManager: return list(self._adapters.values())[0] logger.warning( - f"存在多个适配器 {list(self._adapters.keys())},但未指定 platform_id。" + f"Có nhiều adapter {list(self._adapters.keys())} nhưng chưa chỉ định platform_id" ) return None - # 如果没有任何适配器,尝试全局刷新一次 + # Nếu không có adapter, thử làm mới toàn cục một lần. self._refresh_from_stored_platforms() if self._adapters: if platform_id: @@ -311,17 +306,17 @@ class BotManager: return None def get_all_adapters(self) -> dict: - """获取所有 PlatformAdapter 实例 {platform_id: adapter}""" + """Lấy mọi PlatformAdapter theo platform_id.""" return self._adapters.copy() def has_adapter(self, platform_id: str | None = None) -> bool: - """检查指定平台是否有适配器""" + """Kiểm tra nền tảng chỉ định có adapter hay không.""" if platform_id: return platform_id in self._adapters return bool(self._adapters) def can_analyze(self, platform_id: str | None = None) -> bool: - """使用 DDD 能力检查平台是否支持分析""" + """Dùng capability DDD để kiểm tra nền tảng có hỗ trợ phân tích hay không.""" adapter = self.get_adapter(platform_id) if adapter: return adapter.get_capabilities().can_analyze() @@ -329,16 +324,16 @@ class BotManager: async def auto_discover_bot_instances(self): """ - 自动发现所有可用的bot实例 + Tự phát hiện mọi bot instance khả dụng. - 同时为每个发现的 bot 创建对应的 PlatformAdapter。 + Đồng thời tạo PlatformAdapter tương ứng cho mỗi bot. """ platform_manager = getattr(self._context, "platform_manager", None) get_insts = getattr(platform_manager, "get_insts", None) if self._context is None or not callable(get_insts): return {} - # 使用新版 API 获取所有平台实例 + # Dùng API mới để lấy mọi platform instance. raw_platforms = get_insts() if isinstance(raw_platforms, list): platforms: list[object] = raw_platforms @@ -352,11 +347,11 @@ class BotManager: discovered = {} logger.info( - f"auto_discover_bot_instances: 在管理器中发现 {len(platforms)} 个平台。" + f"auto_discover_bot_instances: phát hiện {len(platforms)} nền tảng trong manager" ) for platform in platforms: - # 获取bot实例 + # Lấy bot instance. bot_client = None bot_client = getattr(platform, "lark_api", None) platform_get_client = getattr(platform, "get_client", None) @@ -368,7 +363,7 @@ class BotManager: if not bot_client: bot_client = getattr(platform, "client", None) - # 健壮地获取元数据 + # Lấy metadata an toàn. metadata = getattr(platform, "metadata", None) platform_meta_method = getattr(platform, "meta", None) if not metadata and callable(platform_meta_method): @@ -377,7 +372,7 @@ class BotManager: except Exception: pass - # 检查是否有有效的元数据和ID + # Kiểm tra metadata và ID hợp lệ. platform_id = None if metadata: metadata_id = getattr(metadata, "id", None) @@ -387,17 +382,17 @@ class BotManager: platform_id = metadata.get("id") if platform_id: - # 确保平台 ID 是 str + # Chuẩn hoá platform ID thành str. platform_id = str(platform_id) - # 知识点发现: 记录元数据以调试自定义 ID + # Ghi metadata để debug ID tuỳ chỉnh. logger.info( - f"[群分析插件 BotManager]: Log metadata for debugging custom IDs ,Platform: {platform_id}, Metadata Type: {getattr(metadata, 'type', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('type', 'N/A')}, Metadata Name: {getattr(metadata, 'name', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('name', 'N/A')}" + f"[Plugin phân tích nhóm BotManager] Metadata debug ID tuỳ chỉnh, platform: {platform_id}, type: {getattr(metadata, 'type', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('type', 'N/A')}, name: {getattr(metadata, 'name', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('name', 'N/A')}" ) - # 从元数据检测平台名称 + # Phát hiện tên nền tảng từ metadata. platform_name = None - # 优先使用 type + # Ưu tiên type. type_val = getattr(metadata, "type", None) if isinstance(type_val, str): platform_name = type_val @@ -414,7 +409,7 @@ class BotManager: if isinstance(dict_name, str): platform_name = dict_name - # 验证此平台名称是否受支持,如果不支持,尝试从bot实例检测(如果可用) + # Nếu tên chưa được hỗ trợ, thử phát hiện từ bot instance. if ( not platform_name or not PlatformAdapterFactory.is_supported(str(platform_name)) @@ -424,47 +419,47 @@ class BotManager: platform_name = detected logger.debug( - f"发现平台: {platform_id} ({platform_name}), 客户端就绪: {bool(bot_client)}" + f"Phát hiện platform: {platform_id} ({platform_name}), client sẵn sàng: {bool(bot_client)}" ) - # 无论bot客户端状态如何,都存储平台实例 + # Luôn lưu platform instance dù client đã sẵn sàng hay chưa. self._platforms[platform_id] = platform if bot_client: logger.info( - f"[BotManager] 为平台 {platform_id} ({platform_name}) 注册 bot 实例" + f"[BotManager] Đăng ký bot instance cho {platform_id} ({platform_name})" ) self.set_bot_instance(bot_client, platform_id, platform_name) discovered[platform_id] = bot_client else: logger.info( - f"发现平台 {platform_id} 但客户端未就绪。将进行懒加载。" + f"Phát hiện platform {platform_id} nhưng client chưa sẵn sàng; sẽ lazy load" ) discovered[platform_id] = platform if self._adapters: logger.info( - f"已创建 {len(self._adapters)} 个 PlatformAdapter: " + f"Đã tạo {len(self._adapters)} PlatformAdapter: " f"{list(self._adapters.keys())}" ) return discovered async def initialize_from_config(self): - """从配置初始化bot管理器""" - # 设置配置的bot ID 列表 + """Khởi tạo bot manager từ cấu hình.""" + # Thiết lập danh sách ID bot trong cấu hình. bot_self_ids = self.config_manager.get_bot_self_ids() if bot_self_ids: self.set_bot_self_ids(bot_self_ids) - # 自动发现所有bot实例 + # Tự phát hiện mọi bot instance. discovered = await self.auto_discover_bot_instances() self._is_initialized = True return discovered def get_status_info(self) -> dict[str, object]: - """获取bot管理器状态信息""" + """Lấy thông tin trạng thái bot manager.""" adapter_info = {} for pid, adapter in self._adapters.items(): caps = adapter.get_capabilities() @@ -479,17 +474,17 @@ class BotManager: "bot_self_ids": self._bot_self_ids, "platform_count": len(self._bot_instances), "platforms": list(self._bot_instances.keys()), - "adapters": adapter_info, # DDD 集成信息 + "adapters": adapter_info, # Thông tin tích hợp DDD. "ready_for_auto_analysis": self.is_ready_for_auto_analysis(), } def update_from_event(self, event): - """从事件更新bot实例(用于手动命令)""" - # 兼容不同平台的 bot 实例属性名 (OneBot 使用 bot, Discord 使用 client) + """Cập nhật bot instance từ event cho command thủ công.""" + # Tương thích tên thuộc tính giữa các nền tảng. bot_instance = getattr(event, "bot", None) or getattr(event, "client", None) if bot_instance: - # 从事件中获取平台ID + # Lấy platform ID từ event. platform_id = None if hasattr(event, "get_platform_id"): platform_id = event.get_platform_id() @@ -500,7 +495,7 @@ class BotManager: self.set_bot_instance(bot_instance, platform_id) - # 优先从事件中提取机器人自身 ID,避免获取到 functools.partial 等异常对象 + # Ưu tiên lấy ID bot từ event, tránh object bất thường như functools.partial. bot_self_id = None if hasattr(event, "get_self_id"): val = event.get_self_id() @@ -516,10 +511,10 @@ class BotManager: bot_self_id = self._extract_bot_self_id(bot_instance) if bot_self_id: - # 将单个ID转换为列表,保持统一处理 + # Chuyển một ID thành list để xử lý thống nhất. self.set_bot_self_ids([bot_self_id]) else: - # 如果bot实例没有ID,尝试使用配置的ID列表 + # Nếu instance không có ID, thử danh sách trong cấu hình. config_self_ids = self.config_manager.get_bot_self_ids() if config_self_ids: self.set_bot_self_ids(config_self_ids) @@ -527,12 +522,12 @@ class BotManager: return False def _extract_bot_self_id(self, bot_instance): - """从bot实例中提取自身ID(单个)""" + """Trích xuất một self ID từ bot instance.""" return self._extract_bot_self_id_impl(bot_instance) def _extract_bot_self_id_impl(self, bot_instance): - """从bot实例中提取ID(通用实现)""" - # 尝试多种方式获取bot ID,并严格限制类型为 str/int 且不可调用,防止 OneBot (aiocqhttp) 动态代理返回 functools.partial + """Trích xuất ID từ bot instance bằng triển khai chung.""" + # Chỉ nhận str/int không callable để tránh dynamic proxy trả functools.partial. if hasattr(bot_instance, "self_id") and bot_instance.self_id: val = bot_instance.self_id if isinstance(val, (str, int)) and not callable(val): @@ -554,20 +549,20 @@ class BotManager: return None def validate_for_message_fetching(self, group_id: str) -> bool: - """验证是否可以进行消息获取""" + """Xác thực có thể lấy tin nhắn hay không.""" return self.has_bot_instance() and bool(group_id) def should_filter_bot_message(self, sender_id: str) -> bool: - """判断是否应该过滤bot自己的消息(支持多个ID)""" + """Kiểm tra có nên lọc tin nhắn của bot, hỗ trợ nhiều ID.""" if not self._bot_self_ids: return False sender_id_str = str(sender_id) - # 检查是否在ID列表中 + # Kiểm tra sender có trong danh sách ID. return sender_id_str in self._bot_self_ids def is_plugin_enabled(self, platform_id: str, plugin_name: str) -> bool: - """检查指定平台是否启用了该插件""" + """Kiểm tra plugin có được bật trên nền tảng chỉ định hay không.""" if platform_id not in self._platforms: return True diff --git a/src/infrastructure/platform/factory.py b/src/infrastructure/platform/factory.py index 973dccb..e249524 100644 --- a/src/infrastructure/platform/factory.py +++ b/src/infrastructure/platform/factory.py @@ -1,6 +1,4 @@ -""" -平台适配器工厂 -""" +"""Factory adapter nền tảng.""" from __future__ import annotations @@ -12,17 +10,16 @@ from .base import PlatformAdapter class PlatformAdapterFactory: """ - 平台适配器工厂 + Factory adapter nền tảng. - 根据平台名称创建适配器实例。 - 使用注册表模式便于扩展。 + Tạo instance adapter theo tên nền tảng bằng registry dễ mở rộng. """ _adapters: dict[str, type[PlatformAdapter]] = {} @classmethod def register(cls, platform_name: str, adapter_class: type[PlatformAdapter]): - """注册新适配器""" + """Đăng ký adapter mới.""" cls._adapters[platform_name.lower()] = adapter_class @classmethod @@ -33,15 +30,15 @@ class PlatformAdapterFactory: config: Mapping[str, object] | None = None, ) -> PlatformAdapter | None: """ - 创建平台适配器 + Tạo adapter nền tảng. - 参数: - platform_name: 平台名称(如 "aiocqhttp"、"telegram") - bot_instance: AstrBot 机器人实例 - config: 配置字典 + Args: + platform_name: Tên nền tảng như ``aiocqhttp`` hoặc ``telegram``. + bot_instance: Instance bot AstrBot. + config: Dict cấu hình. - 返回: - 平台适配器实例,如果不支持则返回 None + Returns: + Instance adapter hoặc None nếu không hỗ trợ. """ adapter_class = cls._adapters.get(platform_name.lower()) @@ -51,22 +48,22 @@ class PlatformAdapterFactory: try: return adapter_class(bot_instance, config) except Exception: - # 记录异常,但不崩溃 - logger.error(f"为 {platform_name} 创建适配器时出错", exc_info=True) + # Ghi lỗi nhưng không làm sập plugin. + logger.error(f"Lỗi khi tạo adapter cho {platform_name}", exc_info=True) return None @classmethod def get_supported_platforms(cls) -> list[str]: - """获取所有支持的平台名称""" + """Lấy tên tất cả nền tảng được hỗ trợ.""" return list(cls._adapters.keys()) @classmethod def is_supported(cls, platform_name: str) -> bool: - """检查平台是否被支持""" + """Kiểm tra nền tảng có được hỗ trợ hay không.""" return platform_name.lower() in cls._adapters -# 导入适配器以注册它们 +# Import các adapter để đăng ký. def _register_adapters(): try: from .adapters.onebot_adapter import OneBotAdapter @@ -80,7 +77,7 @@ def _register_adapters(): from .adapters.discord_adapter import DiscordAdapter PlatformAdapterFactory.register("discord", DiscordAdapter) - PlatformAdapterFactory.register("discord_bot", DiscordAdapter) # 添加别名 + PlatformAdapterFactory.register("discord_bot", DiscordAdapter) # Alias except ImportError: pass diff --git a/src/infrastructure/platform/template_preview/__init__.py b/src/infrastructure/platform/template_preview/__init__.py index e8175ea..d28d9c3 100644 --- a/src/infrastructure/platform/template_preview/__init__.py +++ b/src/infrastructure/platform/template_preview/__init__.py @@ -1,4 +1,4 @@ -"""平台模板预览交互能力。""" +"""Khả năng tương tác xem trước template theo nền tảng.""" from .router import TemplatePreviewRouter from .telegram_preview_handler import TelegramTemplatePreviewHandler diff --git a/src/infrastructure/platform/template_preview/router.py b/src/infrastructure/platform/template_preview/router.py index fc4ca5b..b70dd52 100644 --- a/src/infrastructure/platform/template_preview/router.py +++ b/src/infrastructure/platform/template_preview/router.py @@ -1,4 +1,4 @@ -"""模板预览平台路由。""" +"""Router nền tảng cho chức năng xem trước template.""" from __future__ import annotations @@ -6,17 +6,17 @@ from typing import Any class TemplatePreviewRouter: - """统一分发不同平台的模板预览处理器。""" + """Điều phối handler xem trước template cho các nền tảng.""" def __init__(self, handlers: list[Any] | None = None): self._handlers: list[Any] = handlers or [] def add_handler(self, handler: Any) -> None: - """注册一个平台处理器。""" + """Đăng ký một handler nền tảng.""" self._handlers.append(handler) async def ensure_handlers_registered(self, context: Any) -> None: - """让处理器完成初始化(如注册回调)。""" + """Cho phép handler khởi tạo, chẳng hạn đăng ký callback.""" for handler in self._handlers: register_func = getattr( handler, "ensure_callback_handlers_registered", None @@ -25,7 +25,7 @@ class TemplatePreviewRouter: await register_func(context) async def unregister_handlers(self) -> None: - """统一注销处理器资源。""" + """Huỷ đăng ký tài nguyên của các handler.""" for handler in self._handlers: unregister_func = getattr(handler, "unregister_callback_handlers", None) if callable(unregister_func): @@ -38,11 +38,11 @@ class TemplatePreviewRouter: available_templates: list[str], ) -> tuple[bool, list[Any]]: """ - 处理 /查看模板 交互。 + Xử lý tương tác xem template. - 返回: - - handled: 是否已由某个平台处理器接管 - - results: 需要回传给框架的消息结果列表 + Returns: + Tuple gồm trạng thái đã được handler tiếp nhận và danh sách kết quả + tin nhắn cần trả về framework. """ for handler in self._handlers: supports_func = getattr(handler, "supports", None) diff --git a/src/infrastructure/platform/template_preview/telegram_preview_handler.py b/src/infrastructure/platform/template_preview/telegram_preview_handler.py index bbb8895..7341bb3 100644 --- a/src/infrastructure/platform/template_preview/telegram_preview_handler.py +++ b/src/infrastructure/platform/template_preview/telegram_preview_handler.py @@ -1,4 +1,4 @@ -"""Telegram 模板预览交互处理。""" +"""Xử lý tương tác preview template trên Telegram.""" from __future__ import annotations @@ -58,7 +58,7 @@ class _PreviewSession: class TelegramTemplatePreviewHandler: - """Telegram 按钮预览处理器(←/确定/→)。""" + """Trình xử lý nút preview Telegram (←/Xác nhận/→).""" _SESSION_TTL_SECONDS = 2 * 60 * 60 _MAX_SESSIONS = 200 @@ -82,17 +82,17 @@ class TelegramTemplatePreviewHandler: @staticmethod def supports(event: AstrMessageEvent) -> bool: - """判断是否 Telegram 事件。""" + """Kiểm tra có phải sự kiện Telegram hay không.""" try: return (event.get_platform_name() or "").lower() == "telegram" except Exception: return False - # 向后兼容旧调用名 + # Tương thích ngược với tên gọi cũ. is_telegram_event = supports async def ensure_callback_handlers_registered(self, context: Any) -> None: - """为所有 Telegram 平台注册按钮回调处理器。""" + """Đăng ký callback nút cho mọi nền tảng Telegram.""" if not TELEGRAM_RUNTIME_AVAILABLE: return if not context or not hasattr(context, "platform_manager"): @@ -123,15 +123,15 @@ class TelegramTemplatePreviewHandler: self._registered_platform_ids.add(platform_id) continue - # 平台对象热替换:解绑旧 application 上的 handler 后重绑 + # Hot-swap nền tảng: gỡ handler application cũ rồi gắn lại. try: old_application.remove_handler(old_handler) logger.info( - f"[TemplatePreview][Telegram] 检测到 application 变更,已解绑旧回调: platform_id={platform_id}" + f"[TemplatePreview][Telegram] Phát hiện application thay đổi, đã gỡ callback cũ: platform_id={platform_id}" ) except Exception as e: logger.debug( - f"[TemplatePreview][Telegram] 解绑旧回调失败: platform_id={platform_id}, err={e}" + f"[TemplatePreview][Telegram] Gỡ callback cũ thất bại: platform_id={platform_id}, err={e}" ) self._handlers.pop(platform_id, None) self._registered_platform_ids.discard(platform_id) @@ -145,14 +145,14 @@ class TelegramTemplatePreviewHandler: self._registered_platform_ids.add(platform_id) self._handlers[platform_id] = (application, handler) logger.info( - f"[TemplatePreview][Telegram] 已注册回调处理器: platform_id={platform_id}" + f"[TemplatePreview][Telegram] Đã đăng ký callback: platform_id={platform_id}" ) except Exception as e: logger.warning( - f"[TemplatePreview][Telegram] 注册回调处理器失败: platform_id={platform_id}, err={e}" + f"[TemplatePreview][Telegram] Đăng ký callback thất bại: platform_id={platform_id}, err={e}" ) - # 兜底清理:平台下线后移除残留 handler,避免资源泄漏 + # Dọn handler sót lại khi nền tảng offline để tránh rò rỉ tài nguyên. stale_ids = [ platform_id for platform_id in list(self._handlers.keys()) @@ -163,17 +163,17 @@ class TelegramTemplatePreviewHandler: try: old_application.remove_handler(old_handler) logger.info( - f"[TemplatePreview][Telegram] 已清理离线平台回调: platform_id={stale_platform_id}" + f"[TemplatePreview][Telegram] Đã dọn callback nền tảng offline: platform_id={stale_platform_id}" ) except Exception as e: logger.debug( - f"[TemplatePreview][Telegram] 清理离线平台回调失败: platform_id={stale_platform_id}, err={e}" + f"[TemplatePreview][Telegram] Dọn callback nền tảng offline thất bại: platform_id={stale_platform_id}, err={e}" ) self._registered_platform_ids.discard(stale_platform_id) self._platform_clients.pop(stale_platform_id, None) async def unregister_callback_handlers(self) -> None: - """卸载已注册的回调处理器(插件终止时调用)。""" + """Gỡ callback đã đăng ký khi plugin kết thúc.""" if not TELEGRAM_RUNTIME_AVAILABLE: return @@ -181,11 +181,11 @@ class TelegramTemplatePreviewHandler: try: application.remove_handler(handler) logger.info( - f"[TemplatePreview][Telegram] 已移除回调处理器: platform_id={platform_id}" + f"[TemplatePreview][Telegram] Đã gỡ callback: platform_id={platform_id}" ) except Exception as e: logger.debug( - f"[TemplatePreview][Telegram] 移除回调处理器失败: platform_id={platform_id}, err={e}" + f"[TemplatePreview][Telegram] Gỡ callback thất bại: platform_id={platform_id}, err={e}" ) self._handlers.clear() self._registered_platform_ids.clear() @@ -198,11 +198,9 @@ class TelegramTemplatePreviewHandler: available_templates: list[str], ) -> bool: """ - 在 Telegram 中发送可交互模板预览消息。 + Gửi tin nhắn preview template tương tác trên Telegram. - 返回: - - True: 已由本处理器发送消息(调用方不应再走默认回复) - - False: 无法处理,调用方应走原有降级路径 + Trả về True nếu handler đã gửi; False để caller dùng fallback mặc định. """ if not TELEGRAM_RUNTIME_AVAILABLE: return False @@ -211,7 +209,7 @@ class TelegramTemplatePreviewHandler: client = self._get_event_client(event, platform_id) if client is None: - logger.warning("[TemplatePreview][Telegram] 无法获取 Telegram client") + logger.warning("[TemplatePreview][Telegram] Không thể lấy Telegram client") return False target = self._resolve_chat_target(event) @@ -223,7 +221,7 @@ class TelegramTemplatePreviewHandler: requester_id = int(str(event.get_sender_id())) except Exception: logger.warning( - "[TemplatePreview][Telegram] sender_id 非法,无法创建交互会话" + "[TemplatePreview][Telegram] sender_id không hợp lệ, không thể tạo phiên tương tác" ) return False @@ -289,7 +287,7 @@ class TelegramTemplatePreviewHandler: ) self._cleanup_expired_sessions() logger.info( - "[TemplatePreview][Telegram] 已发送交互预览: " + "[TemplatePreview][Telegram] Đã gửi preview tương tác: " f"platform_id={platform_id} chat_id={chat_id} token={token} templates={len(available_templates)}" ) return True @@ -300,7 +298,7 @@ class TelegramTemplatePreviewHandler: platform_id: str, template_name: str, ) -> bool: - """TG 回退路径:直接发送单张预览图(不经过 event.image_result)。""" + """Fallback Telegram: gửi trực tiếp một ảnh preview.""" if not TELEGRAM_RUNTIME_AVAILABLE: return False @@ -310,7 +308,9 @@ class TelegramTemplatePreviewHandler: client = self._get_event_client(event, platform_id) if client is None: - logger.warning("[TemplatePreview][Telegram] 回退发图失败:无法获取 client") + logger.warning( + "[TemplatePreview][Telegram] Gửi ảnh fallback thất bại: không thể lấy client" + ) return False target = self._resolve_chat_target(event) @@ -320,7 +320,7 @@ class TelegramTemplatePreviewHandler: payload: dict[str, Any] = { "chat_id": chat_id, - "caption": f"🖼 当前模板预览: {template_name}", + "caption": f"🖼 Preview template hiện tại: {template_name}", "connect_timeout": self._CONNECT_TIMEOUT, "read_timeout": self._READ_TIMEOUT, "write_timeout": self._WRITE_TIMEOUT, @@ -345,7 +345,7 @@ class TelegramTemplatePreviewHandler: platform_id: str, available_templates: list[str], ) -> tuple[bool, list[Any]]: - """统一处理 Telegram 的 /查看模板 流程。""" + """Xử lý thống nhất quy trình /xemmau trên Telegram.""" if not self.supports(event): return False, [] @@ -358,12 +358,12 @@ class TelegramTemplatePreviewHandler: ) results.append( event.plain_result( - f"""🎨 可用报告模板列表 -📌 当前使用: {current_template} + f"""🎨 Danh sách template báo cáo khả dụng +📌 Đang dùng: {current_template} {template_list_str} -💡 使用 /设置模板 [序号] 切换""" +💡 Dùng /maubc [số thứ tự] để chuyển""" ) ) @@ -374,10 +374,18 @@ class TelegramTemplatePreviewHandler: template_name=current_template, ) if not sent_preview: - results.append(event.plain_result("⚠️ 当前模板预览图发送失败")) + results.append( + event.plain_result( + "⚠️ Gửi ảnh preview template hiện tại thất bại" + ) + ) except Exception as image_err: - logger.warning(f"[TemplatePreview][Telegram] 回退发图失败: {image_err}") - results.append(event.plain_result("⚠️ 当前模板预览图发送失败")) + logger.warning( + f"[TemplatePreview][Telegram] Gửi ảnh fallback thất bại: {image_err}" + ) + results.append( + event.plain_result("⚠️ Gửi ảnh preview template hiện tại thất bại") + ) try: sent = await self.send_preview_message( @@ -391,7 +399,7 @@ class TelegramTemplatePreviewHandler: return True, results except Exception as e: logger.warning( - f"[TemplatePreview][Telegram] 交互预览发送失败,回退普通模式: {e}" + f"[TemplatePreview][Telegram] Gửi preview tương tác thất bại, chuyển sang chế độ thường: {e}" ) await _append_fallback_results() return True, results @@ -410,34 +418,42 @@ class TelegramTemplatePreviewHandler: data = query.data parts = data.split(":") if len(parts) != 3: - await query.answer("无效操作", show_alert=False) + await query.answer("Thao tác không hợp lệ", show_alert=False) return _, token, action = parts session = self._sessions.get(token) if not session: - await query.answer("预览会话已过期,请重新发送 /查看模板", show_alert=True) + await query.answer( + "Phiên preview đã hết hạn, hãy gửi lại /xemmau", show_alert=True + ) return if time.time() - session.created_at > self._SESSION_TTL_SECONDS: self._sessions.pop(token, None) - await query.answer("预览会话已过期,请重新发送 /查看模板", show_alert=True) + await query.answer( + "Phiên preview đã hết hạn, hãy gửi lại /xemmau", show_alert=True + ) return if not query.from_user: - await query.answer("无法识别操作者", show_alert=False) + await query.answer("Không thể nhận diện người thao tác", show_alert=False) return if int(query.from_user.id) != session.requester_id: - await query.answer("仅命令发起人可操作该预览", show_alert=True) + await query.answer( + "Chỉ người gọi lệnh mới có thể thao tác preview này", show_alert=True + ) return if not query.message: - await query.answer("消息已失效", show_alert=False) + await query.answer("Tin nhắn không còn hiệu lực", show_alert=False) return if query.message.message_id != session.message_id or str( query.message.chat_id ) != str(session.chat_id): - await query.answer("预览状态不一致,请重新发送 /查看模板", show_alert=True) + await query.answer( + "Trạng thái preview không khớp, hãy gửi lại /xemmau", show_alert=True + ) return if action == "prev": @@ -456,14 +472,14 @@ class TelegramTemplatePreviewHandler: template_name = session.current_template self.config_manager.set_report_template(template_name) await self._edit_preview_message(query, session, applied=True) - await query.answer(f"已设置模板: {template_name}", show_alert=False) + await query.answer(f"Đã đặt template: {template_name}", show_alert=False) logger.info( - "[TemplatePreview][Telegram] 已应用模板: " + "[TemplatePreview][Telegram] Đã áp dụng template: " f"platform_id={session.platform_id} template={template_name} requester={session.requester_id}" ) return - await query.answer("未知操作", show_alert=False) + await query.answer("Thao tác không xác định", show_alert=False) async def _edit_preview_message( self, query: Any, session: _PreviewSession, applied: bool = False @@ -526,7 +542,7 @@ class TelegramTemplatePreviewHandler: callback_data=f"{self._callback_prefix}:{token}:prev", ), InlineKeyboardButton( - text="确定", + text="Xác nhận", callback_data=f"{self._callback_prefix}:{token}:apply", ), InlineKeyboardButton( @@ -545,13 +561,15 @@ class TelegramTemplatePreviewHandler: applied: bool = False, ) -> str: current_active = self.config_manager.get_report_template() - active_mark = "✅ 当前生效" if template_name == current_active else "未生效" - apply_mark = "\n\n✅ 已应用该模板" if applied else "" + active_mark = ( + "✅ Đang áp dụng" if template_name == current_active else "Chưa áp dụng" + ) + apply_mark = "\n\n✅ Đã áp dụng template này" if applied else "" return ( - f"🎨 模板预览 ({index + 1}/{total})\n" - f"当前项: {template_name}\n" - f"状态: {active_mark}\n\n" - "操作: ← 上一个 / 确定应用 / → 下一个" + f"🎨 Preview template ({index + 1}/{total})\n" + f"Mục hiện tại: {template_name}\n" + f"Trạng thái: {active_mark}\n\n" + "Thao tác: ← Trước / Xác nhận áp dụng / → Sau" f"{apply_mark}" ) diff --git a/src/infrastructure/reporting/__init__.py b/src/infrastructure/reporting/__init__.py index 52a016a..901d2c9 100644 --- a/src/infrastructure/reporting/__init__.py +++ b/src/infrastructure/reporting/__init__.py @@ -1,7 +1,4 @@ -""" -报告生成模块 -包含HTML、图片、文本报告生成功能 -""" +"""Module tạo báo cáo HTML, ảnh và văn bản.""" from .generators import ReportGenerator from .templates import HTMLTemplates diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index ac1ae0d..5b735ac 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -12,8 +12,7 @@ from ...utils.logger import logger class ReportDispatcher: """ - 报告分发器 - 负责协调报告生成、格式选择、消息发送和失败重试 + Bộ phân phối báo cáo, điều phối tạo báo cáo, chọn định dạng, gửi và fallback. """ def __init__( @@ -28,7 +27,7 @@ class ReportDispatcher: self._html_render_func: Callable | None = None def set_html_render(self, render_func: Callable): - """设置 HTML 渲染函数 (运行时注入)""" + """Thiết lập hàm render HTML được inject lúc runtime.""" self._html_render_func = render_func def _is_qq_official(self, platform_id: str | None) -> bool: @@ -42,13 +41,13 @@ class ReportDispatcher: platform_id: str | None = None, ): """ - 分发分析报告 + Phân phối báo cáo phân tích. """ trace_id = TraceContext.get() output_formats = self.config_manager.get_output_format() logger.info( - f"[{trace_id}] 正在分发群 {group_id} 的报告 (格式: {', '.join(output_formats)})" + f"[{trace_id}] Đang phân phối báo cáo cho nhóm {group_id} (định dạng: {', '.join(output_formats)})" ) dispatch_map = { @@ -61,22 +60,24 @@ class ReportDispatcher: if handler: await handler(group_id, analysis_result, platform_id) - logger.info(f"[{trace_id}] 群 {group_id} 的报告分发完成") + logger.info(f"[{trace_id}] Hoàn tất phân phối báo cáo cho nhóm {group_id}") async def _dispatch_image( self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None ) -> bool: trace_id = TraceContext.get() - # 1. 检查渲染函数 + # 1. Kiểm tra hàm render. if not self._html_render_func: - logger.warning(f"[{trace_id}] 未设置 HTML 渲染函数,回退到文本模式。") + logger.warning( + f"[{trace_id}] Chưa thiết lập hàm render HTML, chuyển sang văn bản" + ) return await self._dispatch_text(group_id, analysis_result, platform_id) - # 2. 生成图片 + # 2. Tạo ảnh. image_url = None html_content = None try: - # 定义头像获取回调,请求小尺寸头像以优化性能 + # Callback lấy avatar kích thước nhỏ để tối ưu hiệu năng. async def avatar_url_getter(user_id: str): if not platform_id: return None @@ -94,10 +95,10 @@ class ReportDispatcher: allow_alphanumeric_user_ids=self._is_qq_official(platform_id), ) except Exception as e: - logger.error(f"[{trace_id}] Failed to generate image report: {e}") + logger.error(f"[{trace_id}] Tạo báo cáo ảnh thất bại: {e}") # image_url and html_content remain None - # 4. 发送图片 + # 4. Gửi ảnh. sent = False if image_url: caption = ( @@ -109,16 +110,16 @@ class ReportDispatcher: group_id, image_url, caption, platform_id ) - # 5. 尝试上传到群文件/群相册(静默处理) - # 无论消息发送是否成功(如超时回退),只要图片生成了,就尝试备份到群文件 + # 5. Thử upload vào tệp/album nhóm và chỉ ghi log khi lỗi. + # Nếu ảnh đã tạo thì luôn thử sao lưu dù gửi tin nhắn có thành công hay không. await self._try_upload_image(group_id, image_url, platform_id) if sent: return True - # 6. 最终回退:如果图片发送失败(包括生成失败或发送接口报错),直接尝试发送文本报告 + # 6. Fallback cuối: gửi báo cáo văn bản nếu ảnh thất bại. logger.warning( - f"[{trace_id}] Image dispatch failed, falling back to text report." + f"[{trace_id}] Phân phối ảnh thất bại, chuyển sang báo cáo văn bản" ) return await self._dispatch_text(group_id, analysis_result, platform_id) @@ -146,7 +147,7 @@ class ReportDispatcher: allow_alphanumeric_user_ids=self._is_qq_official(platform_id), ) except Exception as e: - logger.error(f"[{trace_id}] Failed to generate HTML report: {e}") + logger.error(f"[{trace_id}] Tạo báo cáo HTML thất bại: {e}") if html_path: is_only_url = self.config_manager.get_html_only_url() @@ -154,10 +155,10 @@ class ReportDispatcher: if is_only_url: if base_url and base_url.strip(): - # 获取配置的目录 + # Lấy thư mục đã cấu hình. html_output_dir = self.config_manager.get_html_output_dir() - # 若用户配置为空,使用默认目录 + # Dùng thư mục mặc định nếu cấu hình rỗng. if not html_output_dir: from astrbot.api.star import StarTools @@ -166,14 +167,14 @@ class ReportDispatcher: "self_hosted_html_reports", ) - # 计算相对路径并转换为URL + # Tính đường dẫn tương đối và chuyển thành URL. rel_path = os.path.relpath(html_path, html_output_dir) url_path = rel_path.replace(os.sep, "/") report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}" sent = await self.message_sender.send_text( group_id, - f"📊 今日群聊分析报告已生成:\n{report_url}", + f"📊 Báo cáo phân tích nhóm hôm nay đã sẵn sàng:\n{report_url}", platform_id, ) @@ -181,7 +182,7 @@ class ReportDispatcher: return True else: logger.warning( - f"[{trace_id}] 群 {group_id} 开启了仅发送外链,但未配置 html_base_url,已进行降级,回退至发送 HTML 文件。" + f"[{trace_id}] Nhóm {group_id} chỉ bật gửi liên kết nhưng chưa cấu hình html_base_url; chuyển sang gửi tệp HTML" ) caption = ( @@ -200,15 +201,15 @@ class ReportDispatcher: return True logger.warning( - f"[{trace_id}] HTML dispatch failed, falling back to text report." + f"[{trace_id}] Phân phối HTML thất bại, chuyển sang báo cáo văn bản" ) return await self._dispatch_text(group_id, analysis_result, platform_id) async def _dispatch_text( self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None ) -> bool: - """分发文本报告""" - logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告") + """Phân phối báo cáo văn bản.""" + logger.info(f"[Bộ phân phối] Đang gửi báo cáo văn bản tới nhóm {group_id}") is_qq_official = self._is_qq_official(platform_id) fallback_report = None if is_qq_official: @@ -221,8 +222,10 @@ class ReportDispatcher: else: text_report = self.report_generator.generate_text_report(analysis_result) adapter = self.message_sender.bot_manager.get_adapter(platform_id) - # 尝试通过适配器发送文本报告 - logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}") + # Thử gửi báo cáo văn bản qua adapter. + logger.info( + f"[Bộ phân phối] Đang thử gửi báo cáo văn bản qua adapter, nhóm: {group_id}" + ) try: if adapter: if is_qq_official: @@ -235,14 +238,18 @@ class ReportDispatcher: elif await adapter.send_text_report(group_id, text_report): return True return await self.message_sender.send_text( - group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id + group_id, + f"📊 Báo cáo phân tích nhóm hằng ngày:\n\n{text_report}", + platform_id, ) except Exception as e: - logger.error(f"[分发器] 发送文本报告最终失败。群: {group_id}, 错误: {e}") + logger.error( + f"[Bộ phân phối] Gửi báo cáo văn bản thất bại, nhóm: {group_id}, lỗi: {e}" + ) return False # ================================================================ - # 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式) + # Upload báo cáo ảnh vào tệp/album nhóm, chỉ cho định dạng ảnh trên QQ. # ================================================================ async def _try_upload_image( @@ -252,31 +259,31 @@ class ReportDispatcher: platform_id: str | None, ): """ - 尝试将图片报告上传到群文件和/或群相册。 + Thử upload báo cáo ảnh vào tệp và/hoặc album nhóm. - 仅在配置启用且平台为 OneBot 时执行,失败静默处理。 + Chỉ thực hiện khi bật cấu hình và nền tảng là OneBot; lỗi chỉ ghi log. """ enable_file = self.config_manager.get_enable_group_file_upload() enable_album = self.config_manager.get_enable_group_album_upload() if not enable_file and not enable_album: return - # 仅 OneBot 平台支持 + # Chỉ OneBot hỗ trợ. adapter = self._get_onebot_adapter(platform_id) if not adapter: return - # 将图片保存为临时文件 + # Lưu ảnh thành tệp tạm. image_file = self._save_image_to_temp(image_url, group_id) if not image_file: return try: - # 上传到群文件 + # Upload vào tệp nhóm. if enable_file: await self._do_upload_group_file(adapter, group_id, image_file) - # 上传到群相册 + # Upload vào album nhóm. if enable_album: await self._do_upload_group_album(adapter, group_id, image_file) finally: @@ -286,7 +293,7 @@ class ReportDispatcher: pass async def _do_upload_group_file(self, adapter, group_id: str, file_path: str): - """上传文件到群文件目录,失败静默""" + """Upload tệp vào thư mục nhóm; lỗi chỉ ghi log.""" try: folder_name = self.config_manager.get_group_file_folder() folder_id = None @@ -298,10 +305,10 @@ class ReportDispatcher: folder_id=folder_id, ) except Exception as e: - logger.warning(f"群文件上传失败 (群 {group_id}): {e}") + logger.warning(f"Upload tệp nhóm thất bại (nhóm {group_id}): {e}") async def _do_upload_group_album(self, adapter, group_id: str, file_path: str): - """上传图片到群相册,失败静默""" + """Upload ảnh vào album nhóm; lỗi chỉ ghi log.""" try: album_name = self.config_manager.get_group_album_name() strict_mode = self.config_manager.get_group_album_strict_mode() @@ -312,12 +319,12 @@ class ReportDispatcher: album_id = await adapter.find_album_id(group_id, album_name) if not album_id and strict_mode: logger.info( - f"群相册严格模式开启:在群 {group_id} 中未找到名为 '{album_name}' 的相册,停止上传。" + f"Đã bật chế độ album nghiêm ngặt: không tìm thấy album '{album_name}' trong nhóm {group_id}, dừng upload" ) return elif strict_mode: logger.info( - f"群相册严格模式开启:未设置目标相册名称,停止上传以防止操作群 {group_id} 的默认相册。" + f"Đã bật chế độ album nghiêm ngặt nhưng chưa đặt tên album đích; dừng để tránh thao tác album mặc định của nhóm {group_id}" ) return @@ -329,10 +336,10 @@ class ReportDispatcher: strict_mode=strict_mode, ) except Exception as e: - logger.warning(f"群相册上传失败 (群 {group_id}): {e}") + logger.warning(f"Upload album nhóm thất bại (nhóm {group_id}): {e}") def _save_image_to_temp(self, image_url: str, group_id: str) -> str | None: - """将 base64 图片保存为临时 PNG 文件,返回路径。失败返回 None。""" + """Lưu ảnh Base64 thành PNG tạm và trả về đường dẫn hoặc None.""" try: image_data = None if image_url.startswith("base64://"): @@ -353,17 +360,18 @@ class ReportDispatcher: date_str = datetime.now().strftime("%Y%m%d_%H%M%S") path = os.path.join( - tempfile.gettempdir(), f"群聊分析报告_{group_id}_{date_str}.png" + tempfile.gettempdir(), + f"bao_cao_phan_tich_nhom_{group_id}_{date_str}.png", ) with open(path, "wb") as f: f.write(image_data) return path except Exception as e: - logger.debug(f"保存图片到临时文件失败: {e}") + logger.debug(f"Lưu ảnh vào tệp tạm thất bại: {e}") return None def _get_onebot_adapter(self, platform_id: str | None): - """获取 OneBot 适配器,非 OneBot 平台返回 None。""" + """Lấy adapter OneBot hoặc None cho nền tảng khác.""" if not platform_id: return None adapter = self.message_sender.bot_manager.get_adapter(platform_id) diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index 24c962e..b898c51 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -1,7 +1,4 @@ -""" -报告生成器模块 -负责生成各种格式的分析报告 -""" +"""Tạo báo cáo phân tích ở nhiều định dạng.""" import asyncio import base64 @@ -147,16 +144,15 @@ DEFAULT_PROFILE_NAME_TRANSLATIONS = { class ReportGenerator(IReportGenerator): - """报告生成器""" + """Trình tạo báo cáo phân tích.""" def __init__(self, config_manager, data_dir): self._avatar_session = None self.config_manager = config_manager self.data_dir = data_dir self.activity_visualizer = ActivityVisualizer() - self.html_templates = HTMLTemplates(config_manager) # 实例化HTML模板管理器 - # 全局 T2I 渲染信号量,保护本地资源 - # 使用专用的 T2I 并发配置项 + self.html_templates = HTMLTemplates(config_manager) + # Semaphore render T2I toàn cục bảo vệ tài nguyên local. max_concurrent = self.config_manager.get_t2i_max_concurrent() self._render_semaphore = asyncio.Semaphore(max_concurrent) self._qq_official_markdown_generator = QQOfficialMarkdownReportGenerator( @@ -165,7 +161,7 @@ class ReportGenerator(IReportGenerator): self._render_semaphore, ) - # 运行时缓存,用于在一次分析任务中避免重复下载同一个头像 + # Cache runtime để tránh tải lặp avatar trong một tác vụ phân tích. self._avatar_cache = Cache( str(self.data_dir / "avatar") ) # user_id -> base64_uri @@ -176,7 +172,7 @@ class ReportGenerator(IReportGenerator): self._profile_asset_manifest = self._load_profile_asset_manifest() def _load_profile_asset_manifest(self) -> dict[str, dict]: - """加载人格资源清单。""" + """Tải manifest tài nguyên hồ sơ.""" manifest_path = ( Path(__file__).resolve().parents[3] / "assets" @@ -184,13 +180,13 @@ class ReportGenerator(IReportGenerator): / "manifest.json" ) if not manifest_path.exists(): - logger.warning(f"人格资源清单不存在: {manifest_path}") + logger.warning(f"Manifest tài nguyên hồ sơ không tồn tại: {manifest_path}") return {"sbti": {}, "acgti": {}} try: raw = json.loads(manifest_path.read_text(encoding="utf-8-sig")) except Exception as e: - logger.warning(f"加载人格资源清单失败: {e}") + logger.warning(f"Tải manifest tài nguyên hồ sơ thất bại: {e}") return {"sbti": {}, "acgti": {}} manifest: dict[str, dict] = {"sbti": {}, "acgti": {}} @@ -205,7 +201,7 @@ class ReportGenerator(IReportGenerator): return manifest def _get_profile_mapping_overrides(self) -> dict[str, dict]: - """解析用户配置的人格映射覆盖项。""" + """Parse cấu hình override ánh xạ hồ sơ của người dùng.""" raw = self.config_manager.get_profile_mapping_config() if not raw: return {} @@ -227,13 +223,15 @@ class ReportGenerator(IReportGenerator): profile["name_zh"] = translated_name return data except Exception as e: - logger.warning(f"人格映射配置 JSON 解析失败,已回退到默认映射: {e}") + logger.warning( + f"Parse JSON ánh xạ hồ sơ thất bại, dùng ánh xạ mặc định: {e}" + ) return {} def _build_profile_image_from_manifest_pattern( self, profile_mode: str, asset_code: str ) -> str: - """当 manifest 缺少具体 code 时,根据已有资源路径模式推导图片地址。""" + """Suy ra URL ảnh theo mẫu path khi manifest thiếu code cụ thể.""" system_manifest = self._profile_asset_manifest.get(profile_mode, {}) for item in system_manifest.values(): if not isinstance(item, dict): @@ -251,7 +249,7 @@ class ReportGenerator(IReportGenerator): def _get_manifest_profile_item_by_mbti( self, profile_mode: str, mbti: str ) -> dict | None: - """按 MBTI 从 manifest 中寻找可用资源。""" + """Tìm tài nguyên khả dụng trong manifest theo MBTI.""" normalized_mbti = str(mbti or "").strip().upper() system_manifest = self._profile_asset_manifest.get(profile_mode, {}) for item in system_manifest.values(): @@ -268,14 +266,14 @@ class ReportGenerator(IReportGenerator): profile_mode: str, overrides: dict[str, dict], ) -> dict[str, str | float]: - """根据当前展示模式解析人格标签展示信息。""" + """Phân giải thông tin nhãn hồ sơ theo chế độ hiển thị hiện tại.""" normalized_mbti = str(mbti or "").strip().upper() - # 1. 基础信息获取:从默认映射或用户覆盖中获取核心属性 + # 1. Lấy thuộc tính cốt lõi từ mặc định hoặc override. profile_defaults = DEFAULT_PROFILE_MAPPING.get(profile_mode, {}) base_info = dict(profile_defaults.get(normalized_mbti, {})) - # 用户覆盖优先级最高 + # Override người dùng có ưu tiên cao nhất. user_override = overrides.get(profile_mode, {}).get(normalized_mbti, {}) if isinstance(user_override, dict): base_info.update(user_override) @@ -285,23 +283,23 @@ class ReportGenerator(IReportGenerator): asset_code = str(base_info.get("asset_code", code)).strip() or code image = str(base_info.get("image", "")).strip() - # 2. 图片与属性补全 (基于 manifest.json 可信源) + # 2. Bổ sung ảnh và thuộc tính từ manifest.json. if not image: system_manifest = self._profile_asset_manifest.get(profile_mode, {}) - # A. 优先按 asset_code 索引 + # A. Ưu tiên index theo asset_code. asset_item = system_manifest.get(asset_code) if isinstance(asset_item, dict): image = str(asset_item.get("file", "")).strip() if not name_zh: name_zh = str(asset_item.get("name", "")).strip() - # B. 按照 asset_code 的资源规律推导图片地址 (尝试根据同目录下其他资源的规律猜测当前角色的 CDN 地址) + # B. Suy ra URL ảnh theo mẫu tài nguyên asset_code. if not image: image = self._build_profile_image_from_manifest_pattern( profile_mode, asset_code ) - # C. 对于 acgti 模式,如果没找到明确映射也没能推导出图片,尝试通过 MBTI 反查该类型下的第一个可用资源作为兜底 + # C. Với acgti, fallback về tài nguyên đầu tiên cùng MBTI. if not image and profile_mode == "acgti": fallback_item = self._get_manifest_profile_item_by_mbti( profile_mode, normalized_mbti @@ -313,7 +311,7 @@ class ReportGenerator(IReportGenerator): if not code or code == normalized_mbti: code = str(fallback_item.get("code", code)).strip() - # 3. 构造显示文本 (Code + 中文名) + # 3. Dựng văn bản hiển thị gồm code và tên. display = str(base_info.get("display", "")).strip() if not display: display = f"{code}({name_zh})" if name_zh else code @@ -330,22 +328,22 @@ class ReportGenerator(IReportGenerator): @staticmethod def _sanitize_path_component(name: str) -> str: - """消毒单个路径/文件名片段,禁止路径穿越和非法字符。""" - # 禁止空组件、相对路径控制符:"."、".." + """Làm sạch một thành phần path/tên tệp, chặn traversal và ký tự lỗi.""" + # Chặn thành phần rỗng và ký hiệu path tương đối. if not name or name in {".", ".."}: - raise ValueError(f"无效的路径片段: {name!r}") + raise ValueError(f"Thành phần path không hợp lệ: {name!r}") - # 不允许包含路径分隔符 + # Không cho phép ký tự phân cách path. name = name.replace("/", "_") name = name.replace("\\", "_") - # 去除非打印字符和非法文件名字符 + # Loại ký tự không in được và ký tự tên tệp không hợp lệ. name = re.sub(r'[\x00-\x1f<>:"|?*]', "_", name) - # 保留中文、字母、数字、下划线、横线和点 + # Giữ nội dung hợp lệ sau khi làm sạch. name = name.strip() if not name: - raise ValueError("路径片段经过消毒后为空") + raise ValueError("Thành phần path rỗng sau khi làm sạch") return name @@ -356,7 +354,7 @@ class ReportGenerator(IReportGenerator): group_id: str, date: str, ) -> Path: - """根据格式构建安全输出路径,支持子目录和 {ulid}。""" + """Dựng path output an toàn theo format, hỗ trợ thư mục con và ulid.""" generated_ulid = str(ulid.new()) safe_context = { "group_id": group_id, @@ -367,16 +365,16 @@ class ReportGenerator(IReportGenerator): try: formatted = render_template(filename_format, strict=True, **safe_context) except Exception as e: - raise ValueError(f"文件名模板渲染失败: {e}") from e + raise ValueError(f"Render template tên tệp thất bại: {e}") from e if os.path.isabs(formatted): - raise ValueError("文件名格式不得为绝对路径") + raise ValueError("Định dạng tên tệp không được là path tuyệt đối") relative_path = Path(formatted) sanitized_parts = [] for part in relative_path.parts: if part in {".", ".."}: - raise ValueError("路径中不得包含 '.' 或 '..'。") + raise ValueError("Path không được chứa '.' hoặc '..'") sanitized_parts.append(self._sanitize_path_component(part)) safe_relative = Path(*sanitized_parts) @@ -384,13 +382,15 @@ class ReportGenerator(IReportGenerator): output_dir_resolved = output_dir.resolve(strict=False) target_path = (output_dir_resolved / safe_relative).resolve(strict=False) - # 防止回退到上级目录(使用 Path.relative_to 进行目录包含校验) + # Chặn traversal lên thư mục cha bằng Path.relative_to. try: target_path.relative_to(output_dir_resolved) except ValueError: - raise ValueError("文件路径不在输出目录之内,可能包含路径穿越") + raise ValueError( + "Path tệp nằm ngoài thư mục output, có thể chứa path traversal" + ) - # 防止与已有文件覆盖(如果用户格式没有唯一标记),追加 ULID 后缀 + # Thêm hậu tố ULID để tránh ghi đè khi format không có định danh duy nhất. if target_path.exists(): suffix = target_path.suffix stem = target_path.stem @@ -408,25 +408,25 @@ class ReportGenerator(IReportGenerator): nickname_getter=None, avatar_cache_namespace: str | None = None, hide_user_names: bool = False, - # Also controls ID normalization and fallback display name ("群友"). + # Đồng thời kiểm soát chuẩn hoá ID và tên hiển thị fallback. allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: """ - 生成图片格式的分析报告 + Tạo báo cáo phân tích dạng ảnh. Args: - analysis_result: 分析结果字典 - group_id: 群组ID - html_render_func: HTML渲染函数 - avatar_url_getter: 异步回调函数,接收 user_id 返回 avatar_url/data - nickname_getter: 昵称获取函数 + analysis_result: Dict kết quả phân tích. + group_id: ID nhóm. + html_render_func: Hàm render HTML. + avatar_url_getter: Callback async lấy avatar theo user_id. + nickname_getter: Hàm lấy nickname. Returns: tuple[str | None, str | None]: (image_url, html_content) """ html_content = None try: - # 准备渲染数据 + # Chuẩn bị dữ liệu render. render_payload = await self._prepare_render_data( analysis_result, chart_template="activity_chart.html", @@ -437,7 +437,7 @@ class ReportGenerator(IReportGenerator): allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, ) - # 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签) + # Render template HTML bằng Jinja2. html_content = self.html_templates.render_template( "image_template.html", **render_payload ) @@ -447,19 +447,21 @@ class ReportGenerator(IReportGenerator): render_payload.get("avatar_reuse_aliases", {}), ) - # 检查HTML内容是否有效 + # Kiểm tra nội dung HTML hợp lệ. if not html_content: - logger.error("图片报告HTML渲染失败:返回空内容") + logger.error("Render HTML báo cáo ảnh thất bại: nội dung rỗng") return None, None - logger.info(f"图片报告HTML渲染完成,长度: {len(html_content)} 字符") + logger.info( + f"Render HTML báo cáo ảnh hoàn tất, độ dài: {len(html_content)} ký tự" + ) - # 从配置中获取两轮渲染策略 + # Lấy hai chiến lược render từ cấu hình. render_strategies = self.config_manager.get_t2i_rendering_strategies() - # 使用信号量控制并发进入渲染引擎 + # Dùng semaphore kiểm soát concurrency render. async with self._render_semaphore: - logger.debug(f"[T2I] 已进入渲染队列 (群: {group_id})") + logger.debug(f"[T2I] Đã vào hàng đợi render (nhóm: {group_id})") last_exception = None @@ -469,18 +471,20 @@ class ReportGenerator(IReportGenerator): if image_options.get("type") == "png": image_options.pop("quality", None) - logger.info(f"正在尝试第 {attempt} 轮渲染策略: {image_options}") + logger.info( + f"Đang thử chiến lược render lượt {attempt}: {image_options}" + ) - # 改为获取 bytes 数据,避免 OneBot 无法访问内部 URL + # Lấy bytes để tránh OneBot không truy cập được URL nội bộ. image_data = await html_render_func( - html_content, # 渲染后的HTML内容 - {}, # 空数据字典,因为数据已包含在HTML中 - False, # return_url=False,直接获取图片数据 + html_content, + {}, + False, image_options, ) if image_data: - # 校验是否为合法图片(防止 T2I 返回 500 错误 HTML 字符流) + # Xác thực ảnh để tránh T2I trả trang lỗi HTML. is_valid = False actual_data_head = None @@ -493,16 +497,16 @@ class ReportGenerator(IReportGenerator): with open(image_data, "rb") as f: actual_data_head = f.read(10) except Exception as e: - logger.warning(f"读取图片临时文件失败: {e}") + logger.warning(f"Đọc tệp ảnh tạm thất bại: {e}") if actual_data_head: - # 检查 magic numbers (JPEG: FF D8, PNG: 89 50 4E 47) + # Kiểm tra magic number JPEG/PNG. if actual_data_head.startswith( b"\xff\xd8" ) or actual_data_head.startswith(b"\x89PNG"): is_valid = True else: - # 尝试解析 HTML 错误(如 502 Bad Gateway) + # Thử parse lỗi HTML như 502 Bad Gateway. html_error = None if isinstance(image_data, bytes): html_error = self._extract_html_error_summary( @@ -513,7 +517,7 @@ class ReportGenerator(IReportGenerator): ): try: with open(image_data, "rb") as f: - # 读取前 4KB 即可识别 HTML 错误 + # 4 KB đầu đủ để nhận diện lỗi HTML. html_error = ( self._extract_html_error_summary( f.read(4096) @@ -524,11 +528,11 @@ class ReportGenerator(IReportGenerator): if html_error: logger.warning( - f"[T2I] 渲染引擎返回了错误页面而非图片: {html_error}" + f"[T2I] Engine render trả trang lỗi thay vì ảnh: {html_error}" ) else: logger.warning( - f"渲染结果似乎不是有效的图片数据 (头部: {actual_data_head.hex()})" + f"Kết quả render có vẻ không phải ảnh hợp lệ (header: {actual_data_head.hex()})" ) if is_valid: @@ -536,35 +540,35 @@ class ReportGenerator(IReportGenerator): b64 = base64.b64encode(image_data).decode("utf-8") image_url = f"base64://{b64}" logger.info( - f"图片生成成功 (轮次 {attempt}): [Base64 Data {len(image_data)} bytes]" + f"Tạo ảnh thành công (lượt {attempt}): [Base64 Data {len(image_data)} bytes]" ) return image_url, html_content elif isinstance(image_data, str): logger.info( - f"图片生成成功 (轮次 {attempt}): {image_data}" + f"Tạo ảnh thành công (lượt {attempt}): {image_data}" ) return image_data, html_content logger.warning( - f"渲染轮次 {attempt} ({image_options['type']}) 返回了无效或空数据" + f"Lượt render {attempt} ({image_options['type']}) trả dữ liệu rỗng hoặc không hợp lệ" ) except Exception as e: - logger.warning(f"渲染轮次 {attempt} 失败: {e}") + logger.warning(f"Lượt render {attempt} thất bại: {e}") last_exception = e if attempt < len(render_strategies): - logger.info("准备尝试下一轮回退策略") + logger.info("Chuẩn bị thử chiến lược fallback tiếp theo") continue - # 如果所有策略都失败 - logger.error(f"所有渲染尝试都失败。最后一个错误: {last_exception}") + # Mọi chiến lược đều thất bại. + logger.error(f"Mọi lần render đều thất bại. Lỗi cuối: {last_exception}") return None, html_content except Exception as e: - logger.error(f"生成图片报告过程发生严重错误: {e}", exc_info=True) + logger.error(f"Lỗi nghiêm trọng khi tạo báo cáo ảnh: {e}", exc_info=True) return None, html_content finally: - # 清理本次运行的 session 和缓存 + # Dọn session và cache của lần chạy này. if self._avatar_session: await self._avatar_session.close() self._avatar_session = None @@ -580,25 +584,25 @@ class ReportGenerator(IReportGenerator): allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: """ - 生成HTML格式的分析报告,保存到指定目录 + Tạo báo cáo HTML và lưu vào thư mục chỉ định. Args: - analysis_result: 分析结果字典 - group_id: 群组ID - avatar_url_getter: 异步回调函数,接收 user_id 返回 avatar_url/data - nickname_getter: 昵称获取函数 + analysis_result: Dict kết quả phân tích. + group_id: ID nhóm. + avatar_url_getter: Callback async lấy avatar theo user_id. + nickname_getter: Hàm lấy nickname. Returns: - tuple[str | None, str | None]: (html_path, json_path) - HTML文件路径和JSON文件路径 + Tuple path tệp HTML và JSON. """ try: import json - # 确保输出目录存在(使用 asyncio.to_thread 避免阻塞) + # Đảm bảo thư mục output tồn tại mà không block event loop. output_dir = Path(self.config_manager.get_html_output_dir()) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) - # 生成文件路径 + # Tạo path tệp. current_date = datetime.now().strftime("%Y%m%d") base_html_path = self._build_safe_report_path( output_dir, @@ -615,7 +619,7 @@ class ReportGenerator(IReportGenerator): html_path.parent.mkdir(parents=True, exist_ok=True) - # 准备渲染数据 + # Chuẩn bị dữ liệu render. render_data = await self._prepare_render_data( analysis_result, chart_template="activity_chart.html", @@ -625,9 +629,11 @@ class ReportGenerator(IReportGenerator): hide_user_names=hide_user_names, allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, ) - logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段") + logger.info( + f"Chuẩn bị dữ liệu render HTML hoàn tất, gồm {len(render_data)} trường" + ) - # 生成 HTML 内容(使用 Jinja2 渲染器,尝试 html_template.html,失败则回退到 image_template.html) + # Render bằng html_template.html, fallback sang image_template.html. html_content = None try: html_content = self.html_templates.render_template( @@ -638,10 +644,10 @@ class ReportGenerator(IReportGenerator): render_data.get("avatar_reuse_registry", {}), render_data.get("avatar_reuse_aliases", {}), ) - logger.info("使用 html_template.html 渲染成功") + logger.info("Render bằng html_template.html thành công") except Exception as e: logger.warning( - f"html_template.html 不存在或渲染失败,回退到 image_template.html: {e}" + f"html_template.html không tồn tại hoặc render lỗi, fallback sang image_template.html: {e}" ) html_content = self.html_templates.render_template( "image_template.html", **render_data @@ -651,20 +657,22 @@ class ReportGenerator(IReportGenerator): render_data.get("avatar_reuse_registry", {}), render_data.get("avatar_reuse_aliases", {}), ) - logger.info("使用 image_template.html 渲染成功") + logger.info("Render bằng image_template.html thành công") - # 检查HTML内容是否有效 + # Kiểm tra nội dung HTML hợp lệ. if not html_content: - logger.error("HTML报告渲染失败:返回空内容") + logger.error("Render báo cáo HTML thất bại: nội dung rỗng") return None, None - logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符") + logger.info( + f"Tạo nội dung HTML hoàn tất, độ dài: {len(html_content)} ký tự" + ) - # 保存 HTML 文件 + # Lưu tệp HTML. await asyncio.to_thread( html_path.write_text, html_content, encoding="utf-8" ) - logger.info(f"HTML 报告已保存: {html_path}") + logger.info(f"Đã lưu báo cáo HTML: {html_path}") def json_default_encoder(obj): if hasattr(obj, "to_dict") and callable(obj.to_dict): @@ -681,7 +689,7 @@ class ReportGenerator(IReportGenerator): f"Object of type {type(obj).__name__} is not JSON serializable" ) - # 保存原始 JSON 数据 + # Lưu dữ liệu JSON gốc. json_data = { "analysis_result": ( self._sanitize_analysis_result_for_export(analysis_result) @@ -701,23 +709,23 @@ class ReportGenerator(IReportGenerator): ), encoding="utf-8", ) - logger.info(f"JSON 数据已保存: {json_path}") + logger.info(f"Đã lưu dữ liệu JSON: {json_path}") return str(html_path.absolute()), str(json_path.absolute()) except Exception as e: - logger.error(f"生成 HTML 报告失败: {e}", exc_info=True) + logger.error(f"Tạo báo cáo HTML thất bại: {e}", exc_info=True) return None, None def build_html_caption(self, html_path: str) -> str: - """根据 html_base_url 生成 HTML 报告链接 caption。由调用方决定是否发送。""" + """Tạo caption liên kết báo cáo từ html_base_url.""" caption = "📊 Đã tạo báo cáo phân tích nhóm hàng ngày" base_url = self.config_manager.get_html_base_url() if not base_url or not html_path: return caption - # 支持 html_filename_format 中的子目录,保持相对路径 + # Giữ path tương đối để hỗ trợ thư mục con trong format tên tệp. output_dir = Path(self.config_manager.get_html_output_dir()).resolve( strict=False ) @@ -733,7 +741,7 @@ class ReportGenerator(IReportGenerator): return caption + f"\n{base_url.rstrip('/')}/{encoded_relative_url}" def generate_text_report(self, analysis_result: dict) -> str: - """生成文本格式的分析报告""" + """Tạo báo cáo phân tích dạng văn bản.""" stats = analysis_result["statistics"] topics = analysis_result["topics"] user_titles = analysis_result["user_titles"] @@ -865,13 +873,13 @@ class ReportGenerator(IReportGenerator): hide_user_names: bool = False, allow_alphanumeric_user_ids: bool = False, ) -> dict: - """准备渲染数据""" + """Chuẩn bị dữ liệu render.""" stats = analysis_result["statistics"] topics = analysis_result["topics"] user_titles = analysis_result["user_titles"] activity_viz = stats.activity_visualization - # 使用Jinja2模板构建话题HTML(批量渲染) + # Dựng HTML chủ đề hàng loạt bằng Jinja2. max_topics = self.config_manager.get_max_topics() topics_list = [] user_analysis = analysis_result.get("user_analysis") @@ -879,7 +887,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_aliases: dict[str, str] = {} for i, topic in enumerate(topics[:max_topics], 1): - # 处理话题详情中的用户引用头像 + # Xử lý avatar trong tham chiếu người dùng của chi tiết chủ đề. processed_detail = await self._render_mentions( topic.detail, avatar_url_getter, @@ -900,7 +908,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_aliases, ) else: - contributors = "、".join(topic.contributors) + contributors = ", ".join(topic.contributors) topics_list.append( { "index": i, @@ -914,7 +922,7 @@ class ReportGenerator(IReportGenerator): } ) - # 通用模板上下文,包含可能被子模板引用的全局配置 + # Context chung gồm cấu hình toàn cục dùng bởi template con. common_context = { "hide_user_names": hide_user_names, "t2i_font_source": self.config_manager.get_t2i_font_source(), @@ -926,16 +934,16 @@ class ReportGenerator(IReportGenerator): topics_html = self.html_templates.render_template( "topic_item.html", topics=topics_list, **common_context ) - logger.info(f"话题HTML生成完成,长度: {len(topics_html)}") + logger.info(f"Tạo HTML chủ đề hoàn tất, độ dài: {len(topics_html)}") - # 使用Jinja2模板构建用户称号HTML(批量渲染,包含头像) + # Dựng HTML danh hiệu hàng loạt bằng Jinja2, gồm avatar. max_user_titles = self.config_manager.get_max_user_titles() titles_list = [] profile_mode = self.config_manager.get_profile_display_mode() profile_mapping_overrides = self._get_profile_mapping_overrides() for title in user_titles[:max_user_titles]: user_id = str(title.user_id) - # 获取用户头像 + # Lấy avatar người dùng. avatar_data = await self._get_user_avatar( user_id, avatar_url_getter, avatar_cache_namespace ) @@ -974,9 +982,9 @@ class ReportGenerator(IReportGenerator): titles_html = self.html_templates.render_template( "user_title_item.html", titles=titles_list, **common_context ) - logger.info(f"用户称号HTML生成完成,长度: {len(titles_html)}") + logger.info(f"Tạo HTML danh hiệu hoàn tất, độ dài: {len(titles_html)}") - # 使用Jinja2模板构建金句HTML(批量渲染) + # Dựng HTML trích dẫn hàng loạt bằng Jinja2. max_golden_quotes = self.config_manager.get_max_golden_quotes() quotes_list = [] for golden_quote in stats.golden_quotes[:max_golden_quotes]: @@ -999,7 +1007,7 @@ class ReportGenerator(IReportGenerator): quote_user_id, avatar_cache_namespace ), ) - # 处理解析锐评中的用户引用头像 + # Xử lý avatar trong tham chiếu người dùng của nhận xét. processed_reason = await self._render_mentions( golden_quote.reason, avatar_url_getter, @@ -1025,25 +1033,27 @@ class ReportGenerator(IReportGenerator): quotes_html = self.html_templates.render_template( "quote_item.html", quotes=quotes_list, **common_context ) - logger.info(f"金句HTML生成完成,长度: {len(quotes_html)}") + logger.info(f"Tạo HTML trích dẫn hoàn tất, độ dài: {len(quotes_html)}") - # 生成活跃度可视化HTML + # Tạo HTML biểu đồ hoạt động. chart_data = self.activity_visualizer.get_hourly_chart_data( activity_viz.hourly_activity ) hourly_chart_html = self.html_templates.render_template( chart_template, chart_data=chart_data, **common_context ) - logger.info(f"活跃度图表HTML生成完成,长度: {len(hourly_chart_html)}") + logger.info( + f"Tạo HTML biểu đồ hoạt động hoàn tất, độ dài: {len(hourly_chart_html)}" + ) - # 生成聊天质量锐评HTML + # Tạo HTML đánh giá chất lượng trò chuyện. chat_quality_html = "" chat_quality_review = analysis_result.get("chat_quality_review") if not chat_quality_review and hasattr(stats, "chat_quality_review"): chat_quality_review = stats.chat_quality_review if chat_quality_review: - # 如果是对象,转为字典(为了统一渲染) + # Chuyển object thành dict để render thống nhất. if hasattr(chat_quality_review, "dimensions"): review_data = { "title": chat_quality_review.title, @@ -1092,15 +1102,17 @@ class ReportGenerator(IReportGenerator): chat_quality_html = self.html_templates.render_template( "chat_quality_item.html", **review_data, **common_context ) - logger.info(f"聊天质量锐评HTML生成完成,长度: {len(chat_quality_html)}") + logger.info( + f"Tạo HTML chất lượng trò chuyện hoàn tất, độ dài: {len(chat_quality_html)}" + ) - # 准备最终渲染数据 + # Chuẩn bị dữ liệu render cuối. render_data = { "t2i_font_source": self.config_manager.get_t2i_font_source(), "t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(), "t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(), "t2i_atri_font_mirror": self.config_manager.get_t2i_atri_font_mirror(), - "current_date": datetime.now().strftime("%Y年%m月%d日"), + "current_date": datetime.now().strftime("%d/%m/%Y"), "current_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "message_count": stats.message_count, "participant_count": stats.participant_count, @@ -1125,7 +1137,7 @@ class ReportGenerator(IReportGenerator): "avatar_reuse_aliases": avatar_reuse_aliases, } - logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段") + logger.info(f"Chuẩn bị dữ liệu render hoàn tất, gồm {len(render_data)} trường") return render_data async def _render_avatar_only_ids( @@ -1185,7 +1197,7 @@ class ReportGenerator(IReportGenerator): allow_alphanumeric_user_ids: bool = False, ) -> Markup: """ - 处理文本,将 [用户ID] 格式的引用替换为头像胶囊。 + Thay tham chiếu dạng ``[user ID]`` trong văn bản bằng capsule avatar. """ if not text: return Markup("") @@ -1198,7 +1210,7 @@ class ReportGenerator(IReportGenerator): source_text = str(text) supports_extended_ids = hide_user_names or allow_alphanumeric_user_ids if supports_extended_ids: - # LLM 偶尔会直接输出 ID;先标准化为引用,避免 OpenID 以明文形式显示。 + # Chuẩn hoá ID do LLM trả trực tiếp thành tham chiếu để ẩn OpenID. for user_id in sorted(known_ids, key=len, reverse=True): source_text = re.sub( rf"(? bool: - """判断展示名称是否为占位值。""" + """Kiểm tra tên hiển thị có phải placeholder hay không.""" if not name: return True normalized = str(name).strip() @@ -1342,7 +1354,7 @@ class ReportGenerator(IReportGenerator): @staticmethod def _safe_url_for_log(url: str | None) -> str: - """对日志中的 URL 进行脱敏,避免泄露 token。""" + """Che token trong URL ghi log.""" if not url: return "" # Telegram file URL: .../file/bot/ @@ -1350,7 +1362,7 @@ class ReportGenerator(IReportGenerator): @staticmethod def _build_avatar_ref(avatar_key: str | None, avatar_url: str) -> str: - """根据稳定输入生成不暴露平台或用户 ID 的头像引用。""" + """Tạo tham chiếu avatar ổn định mà không lộ platform hay user ID.""" if avatar_key: digest = hashlib.sha256(avatar_key.encode("utf-8")).hexdigest()[:24] return f"avatar-{digest}" @@ -1365,7 +1377,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_aliases: dict[str, str] | None = None, avatar_key: str | None = None, ) -> str | None: - """将 Data URI 头像登记为可复用资源,并返回短引用 ID。""" + """Đăng ký avatar Data URI làm tài nguyên tái sử dụng và trả ID ngắn.""" if not avatar_url or avatar_reuse_registry is None: return None if not avatar_url.startswith("data:image/"): @@ -1382,7 +1394,7 @@ class ReportGenerator(IReportGenerator): @staticmethod def _build_avatar_reuse_styles(avatar_reuse_registry: dict[str, str]) -> str: - """为头像生成一次性复用样式。""" + """Tạo style tái sử dụng một lần cho avatar.""" if not avatar_reuse_registry: return "" @@ -1406,7 +1418,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_registry: dict[str, str], avatar_reuse_aliases: dict[str, str] | None = None, ) -> str: - """将最终 HTML 中的内联 Data URI 头像 img 改为短引用。""" + """Đổi avatar Data URI inline trong HTML cuối thành tham chiếu ngắn.""" if not html_content: return html_content @@ -1440,7 +1452,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_registry: dict[str, str] | None, avatar_reuse_aliases: dict[str, str] | None = None, ) -> str: - """复用最终 HTML 中所有内联头像资源,并注入复用样式。""" + """Tái sử dụng avatar inline trong HTML cuối và inject style.""" if not html_content: return html_content @@ -1455,7 +1467,7 @@ class ReportGenerator(IReportGenerator): @staticmethod def _inject_avatar_reuse_styles(html_content: str, avatar_reuse_styles: str) -> str: - """将头像复用样式注入最终 HTML。""" + """Inject style tái sử dụng avatar vào HTML cuối.""" if not html_content or not avatar_reuse_styles: return html_content @@ -1472,7 +1484,7 @@ class ReportGenerator(IReportGenerator): def _get_avatar_cache_key( self, avatar_id: str, avatar_cache_namespace: str | None = None ) -> str: - """生成头像缓存键,避免不同平台的同一数字 ID 互相污染。""" + """Tạo cache key avatar để tránh xung đột ID giữa các nền tảng.""" namespace = str(avatar_cache_namespace or "legacy").strip() or "legacy" return f"{namespace}:{avatar_id}" @@ -1483,39 +1495,42 @@ class ReportGenerator(IReportGenerator): avatar_cache_namespace: str | None = None, ) -> str: """ - 获取用户头像的 Base64 Data URI。 - 使用磁盘缓存,支持跨任务复用。获取失败时不缓存结果,以便后续请求重试。 + Lấy Data URI Base64 của avatar người dùng. + + Dùng disk cache giữa các tác vụ; không cache thất bại để có thể retry. """ cache_key = self._get_avatar_cache_key(avatar_id, avatar_cache_namespace) - # 1. 检查缓存 (仅包含成功的头像数据) + # 1. Kiểm tra cache, chỉ chứa avatar thành công. if cache_key in self._avatar_cache: data = self._avatar_cache[cache_key] if isinstance(data, str): return data return str(data) - # 2. 尝试获取头像字节流 + # 2. Thử lấy bytes avatar. avatar_bytes = await self._get_user_avatar_bytes(avatar_id, avatar_url_getter) if not avatar_bytes: - # 获取失败时返回默认头像,但不存入缓存,以便下次重试 - logger.warning(f"获取用户头像失败 {avatar_id},本次将使用回退头像") + # Trả avatar mặc định nhưng không cache để lần sau có thể retry. + logger.warning( + f"Lấy avatar người dùng thất bại {avatar_id}; dùng avatar fallback" + ) return self._get_default_avatar_base64() - # 3. 获取成功:转换并缓存 + # 3. Chuyển đổi và cache khi thành công. avatar = self._b64_with_mime(avatar_bytes) if avatar: self._avatar_cache.set(cache_key, avatar, expire=AVATAR_CACHE_EXPIRE_TIME) return avatar - # 最终兜底 + # Fallback cuối. return self._get_default_avatar_base64() def _b64_with_mime(self, _bytes: bytes) -> str | None: - """将字节数据转换为 Base64 Data URI,并自动识别 MIME 类型。""" + """Chuyển bytes thành Data URI Base64 và tự nhận diện MIME type.""" try: b64 = base64.b64encode(_bytes).decode("utf-8") - # 简单判断 mime type + # Nhận diện MIME type đơn giản. mime = "image/jpeg" if _bytes.startswith(b"\x89PNG"): mime = "image/png" @@ -1528,13 +1543,13 @@ class ReportGenerator(IReportGenerator): return f"data:{mime};base64,{b64}" except Exception as e: - logger.error(f"base64 转换失败: {e}", exc_info=True) + logger.error(f"Chuyển Base64 thất bại: {e}", exc_info=True) return None async def _get_user_avatar_bytes( self, user_id: str, avatar_url_getter=None ) -> bytes | None: - """核心头像获取逻辑""" + """Logic lõi lấy avatar.""" file_content = None if not self._avatar_session: self._avatar_session = aiohttp.ClientSession( @@ -1544,7 +1559,7 @@ class ReportGenerator(IReportGenerator): avatar_url = None if avatar_url_getter: try: - # avatar_url_getter 应该返回 URL + # avatar_url_getter dự kiến trả URL. result = await avatar_url_getter(user_id) if result: if result.startswith("http"): @@ -1557,10 +1572,12 @@ class ReportGenerator(IReportGenerator): return base64.b64decode(parts[1]) else: logger.warning( - f"custom avatar_url_getter 返回了非 HTTP URL: {result[:50]}..." + f"avatar_url_getter tuỳ chỉnh trả URL không phải HTTP: {result[:50]}..." ) except Exception as e: - logger.warning(f"使用 custom avatar_url_getter 获取头像失败: {e}") + logger.warning( + f"Lấy avatar bằng avatar_url_getter tuỳ chỉnh thất bại: {e}" + ) if not avatar_url: if ( @@ -1568,22 +1585,22 @@ class ReportGenerator(IReportGenerator): and user_id.isdigit() and 5 <= len(user_id) <= 12 ): - # 强制使用 spec=40 + # Buộc dùng spec=40. avatar_url = ( f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40" ) else: - # 其他平台若无 URL,无法获取头像 + # Nền tảng khác không thể lấy avatar nếu thiếu URL. return None - # 5. 下载并保存 + # 5. Tải và lưu. safe_avatar_url = self._safe_url_for_log(avatar_url) try: async with self._avatar_session.get(avatar_url) as response: if response.status == 200: content = await response.read() if content: - # 校验文件头 + # Xác thực header tệp. is_valid_image = False if content.startswith(b"\xff\xd8"): # JPEG is_valid_image = True @@ -1600,26 +1617,26 @@ class ReportGenerator(IReportGenerator): file_content = content else: logger.warning( - f"下载的头像数据格式无效 ({safe_avatar_url})" + f"Dữ liệu avatar tải về không hợp lệ ({safe_avatar_url})" ) else: logger.warning( - f"下载头像失败 {safe_avatar_url}: {response.status}" + f"Tải avatar thất bại {safe_avatar_url}: {response.status}" ) except Exception as e: - logger.warning(f"下载头像网络错误 {safe_avatar_url}: {e}") + logger.warning(f"Lỗi mạng khi tải avatar {safe_avatar_url}: {e}") return file_content def _get_default_avatar_base64(self) -> str: - """返回默认头像 (灰色圆形占位符)""" - # 一个简单的灰色圆圈 SVG 转 Base64 + """Trả avatar mặc định là placeholder hình tròn màu xám.""" + # SVG hình tròn xám đơn giản dưới dạng Base64. svg = '' b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8") return f"data:image/svg+xml;base64,{b64}" async def close(self): - """释放资源,关闭缓存和 session""" + """Giải phóng tài nguyên, đóng cache và session.""" if self._avatar_session: await self._avatar_session.close() self._avatar_session = None @@ -1627,31 +1644,31 @@ class ReportGenerator(IReportGenerator): try: if self._avatar_cache: self._avatar_cache.close() - logger.debug("头像缓存已关闭") + logger.debug("Đã đóng cache avatar") except Exception as e: - logger.warning(f"关闭头像缓存失败: {e}") + logger.warning(f"Đóng cache avatar thất bại: {e}") def _extract_html_error_summary(self, data: bytes) -> str | None: - """从返回的字节流中尝试提取 HTML 错误信息(如 )""" + """Thử trích xuất lỗi HTML như title từ bytes phản hồi.""" try: content = data.decode("utf-8", errors="ignore") content_lower = content.lower() if "<html" in content_lower or "<!doctype html" in content_lower: - # 尝试提取标题 + # Thử trích xuất title. title_match = re.search( r"<title>(.*?)", content, re.IGNORECASE | re.DOTALL ) if title_match: - return f"HTML 错误页: {title_match.group(1).strip()}" + return f"Trang lỗi HTML: {title_match.group(1).strip()}" - # 尝试提取 h1 + # Thử trích xuất h1. h1_match = re.search( r"

(.*?)

", content, re.IGNORECASE | re.DOTALL ) if h1_match: - return f"HTML 错误页: {h1_match.group(1).strip()}" + return f"Trang lỗi HTML: {h1_match.group(1).strip()}" - return f"HTML 响应 (前100字): {content[:100].strip()}..." + return f"Phản hồi HTML (100 ký tự đầu): {content[:100].strip()}..." except Exception: pass return None diff --git a/src/infrastructure/reporting/templates.py b/src/infrastructure/reporting/templates.py index 63c63f7..7d67e23 100644 --- a/src/infrastructure/reporting/templates.py +++ b/src/infrastructure/reporting/templates.py @@ -1,7 +1,4 @@ -""" -HTML模板模块 -使用Jinja2加载外部HTML模板文件 -""" +"""Module tải template HTML bên ngoài bằng Jinja2.""" import asyncio import os @@ -13,25 +10,25 @@ from ...utils.logger import logger class HTMLTemplates: - """HTML模板管理类""" + """Trình quản lý template HTML.""" def __init__(self, config_manager): - """初始化Jinja2环境""" + """Khởi tạo môi trường Jinja2.""" self.config_manager = config_manager - # 设置模板根目录 + # Thiết lập thư mục gốc của template. self.base_dir = os.path.join(os.path.dirname(__file__), "templates") self.platform_base_dir = os.path.join( os.path.dirname(__file__), "platform_templates" ) - # 缓存不同模板的Jinja2环境(多线程安全) + # Cache môi trường Jinja2 theo template, an toàn đa luồng. self._envs = {} self._env_lock = threading.Lock() def _get_env_sync(self) -> Environment: - """获取当前配置的模板环境(同步版本,供 asyncio.to_thread 调用)""" + """Lấy đồng bộ môi trường template hiện tại cho asyncio.to_thread.""" template_name = self.config_manager.get_report_template() - # 如果环境已缓存且配置未变(使用锁保证多线程安全) + # Trả cache nếu có; dùng lock để đảm bảo an toàn đa luồng. with self._env_lock: env = self._envs.get(template_name) if env is not None: @@ -39,7 +36,9 @@ class HTMLTemplates: template_dir = os.path.join(self.base_dir, template_name) if not os.path.exists(template_dir): - logger.warning(f"模板目录不存在: {template_dir},回退到 scrapbook") + logger.warning( + f"Thư mục template không tồn tại: {template_dir}, chuyển sang scrapbook" + ) template_dir = os.path.join(self.base_dir, "scrapbook") env = Environment( @@ -49,7 +48,7 @@ class HTMLTemplates: lstrip_blocks=True, ) - # 使用双重检查锁定,避免在高并发下重复创建相同 template_name 的 env + # Double-check locking để tránh tạo trùng môi trường khi tải cao. with self._env_lock: existing = self._envs.get(template_name) if existing is not None: @@ -59,69 +58,69 @@ class HTMLTemplates: return env async def _get_env_async(self) -> Environment: - """获取当前配置的模板环境(异步版本)""" + """Lấy bất đồng bộ môi trường template hiện tại.""" return await asyncio.to_thread(self._get_env_sync) def _get_env(self) -> Environment: - """获取当前配置的模板环境(同步版本,向后兼容)""" + """Lấy đồng bộ môi trường template hiện tại để tương thích ngược.""" return self._get_env_sync() def _read_template_file_sync(self, filename: str) -> str: - """同步读取模板文件内容""" + """Đọc đồng bộ nội dung tệp template.""" with open(filename, encoding="utf-8") as f: return f.read() async def get_image_template_async(self) -> str: - """获取图片报告的HTML模板(异步版本,返回原始模板字符串)""" + """Lấy bất đồng bộ template HTML báo cáo ảnh dưới dạng chuỗi gốc.""" try: env = await self._get_env_async() template = env.get_template("image_template.html") if template.filename is None: - logger.error("图片模板路径为空") + logger.error("Đường dẫn template ảnh rỗng") return "" return await asyncio.to_thread( self._read_template_file_sync, template.filename ) except Exception as e: - logger.error(f"加载图片模板失败: {e}") + logger.error(f"Tải template ảnh thất bại: {e}") return "" def get_image_template(self) -> str: - """获取图片报告的HTML模板(同步版本,向后兼容)""" + """Lấy đồng bộ template HTML báo cáo ảnh để tương thích ngược.""" try: env = self._get_env() template = env.get_template("image_template.html") if template.filename is None: - logger.error("图片模板路径为空") + logger.error("Đường dẫn template ảnh rỗng") return "" with open(template.filename, encoding="utf-8") as f: return f.read() except Exception as e: - logger.error(f"加载图片模板失败: {e}") + logger.error(f"Tải template ảnh thất bại: {e}") return "" def render_template(self, template_name: str, **kwargs) -> str: - """渲染指定的模板文件 + """Render tệp template được chỉ định. Args: - template_name: 模板文件名 - **kwargs: 传递给模板的变量 + template_name: Tên tệp template. + **kwargs: Biến truyền cho template. Returns: - 渲染后的HTML字符串 + Chuỗi HTML đã render. """ try: env = self._get_env() template = env.get_template(template_name) return template.render(**kwargs) except Exception as e: - logger.error(f"渲染模板 {template_name} 失败: {e}") + logger.error(f"Render template {template_name} thất bại: {e}") return "" def render_platform_template( self, platform_name: str, template_name: str, **kwargs ) -> str: - """渲染与报告主题解耦的平台专用模板。""" + """Render template riêng theo nền tảng, độc lập với theme báo cáo.""" try: template_dir = os.path.join(self.platform_base_dir, platform_name) env = Environment( @@ -132,5 +131,7 @@ class HTMLTemplates: ) return env.get_template(template_name).render(**kwargs) except Exception as e: - logger.error(f"渲染平台模板 {platform_name}/{template_name} 失败: {e}") + logger.error( + f"Render template nền tảng {platform_name}/{template_name} thất bại: {e}" + ) return "" diff --git a/src/infrastructure/reporting/templates/ATRI/activity_chart.html b/src/infrastructure/reporting/templates/ATRI/activity_chart.html index 93882bb..5fffb25 100644 --- a/src/infrastructure/reporting/templates/ATRI/activity_chart.html +++ b/src/infrastructure/reporting/templates/ATRI/activity_chart.html @@ -44,7 +44,7 @@ min-height: 5px; box-shadow: 0 6px 14px rgba({{ bottom_red }}, {{ bottom_green }}, {{ bottom_blue }}, 0.14); border: none; - " title="{{hour_data.hour}}时 - {{hour_data.count}}条消息"> + " title="{{hour_data.hour}} giờ - {{hour_data.count}} tin nhắn"> {{hour_data.hour}} diff --git a/src/infrastructure/reporting/templates/ATRI/chat_quality_item.html b/src/infrastructure/reporting/templates/ATRI/chat_quality_item.html index f66cd25..7d6e35d 100644 --- a/src/infrastructure/reporting/templates/ATRI/chat_quality_item.html +++ b/src/infrastructure/reporting/templates/ATRI/chat_quality_item.html @@ -10,7 +10,7 @@ } .quality-card::before { - content: "锐评"; + content: "Đánh giá"; position: absolute; top: 14px; right: 18px; diff --git a/src/infrastructure/reporting/templates/ATRI/html_template.html b/src/infrastructure/reporting/templates/ATRI/html_template.html index ceacdae..2f31b56 100644 --- a/src/infrastructure/reporting/templates/ATRI/html_template.html +++ b/src/infrastructure/reporting/templates/ATRI/html_template.html @@ -4,7 +4,7 @@ - ATRI 群聊日报 · {{ current_date }} + Báo cáo nhóm ATRI · {{ current_date }}