feat: update translate

This commit is contained in:
2026-07-24 10:17:58 +07:00
parent 9356ae6d8b
commit e492b84fc1
91 changed files with 3541 additions and 3438 deletions
+132 -136
View File
@@ -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 import asyncio
@@ -55,9 +55,9 @@ from .src.utils.resilience import GlobalRateLimiter
class GroupDailyAnalysis(Star): 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: AstrBotConfig
config_manager: ConfigManager config_manager: ConfigManager
bot_manager: BotManager bot_manager: BotManager
@@ -82,7 +82,7 @@ class GroupDailyAnalysis(Star):
super().__init__(context) super().__init__(context)
self.config = config self.config = config
# 1. 基础设施层 # 1. Tầng infrastructure.
self.config_manager = ConfigManager(config) self.config_manager = ConfigManager(config)
self.bot_manager = BotManager(self.config_manager) self.bot_manager = BotManager(self.config_manager)
self.bot_manager.set_context(context) self.bot_manager.set_context(context)
@@ -93,22 +93,22 @@ class GroupDailyAnalysis(Star):
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir) self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
# Telegram 注册表 (持久层) # Registry Telegram ở tầng persistence.
self.platform_group_registry = PlatformGroupRegistry(self) self.platform_group_registry = PlatformGroupRegistry(self)
# 2. 领域层 # 2. Tầng domain.
activity_visualizer = ActivityVisualizer() activity_visualizer = ActivityVisualizer()
self.statistics_service = StatisticsService(activity_visualizer) self.statistics_service = StatisticsService(activity_visualizer)
self.analysis_domain_service = AnalysisDomainService() 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) 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_store = IncrementalStore(self)
self.incremental_merge_service = IncrementalMergeService() self.incremental_merge_service = IncrementalMergeService()
# 5. 应用层 # 5. Tầng application.
self.analysis_service = AnalysisApplicationService( self.analysis_service = AnalysisApplicationService(
self.config_manager, self.config_manager,
self.bot_manager, self.bot_manager,
@@ -121,7 +121,7 @@ class GroupDailyAnalysis(Star):
incremental_merge_service=self.incremental_merge_service, incremental_merge_service=self.incremental_merge_service,
) )
# 消息处理服务 # Dịch vụ xử lý tin nhắn.
self.message_processing_service = MessageProcessingService( self.message_processing_service = MessageProcessingService(
context, self.platform_group_registry context, self.platform_group_registry
) )
@@ -136,7 +136,7 @@ class GroupDailyAnalysis(Star):
handlers=[self.telegram_template_preview_handler] handlers=[self.telegram_template_preview_handler]
) )
# 调度与发送 # Lập lịch và gửi.
self.message_sender = MessageSender(self.bot_manager, self.config_manager) self.message_sender = MessageSender(self.bot_manager, self.config_manager)
self.auto_scheduler = AutoScheduler( self.auto_scheduler = AutoScheduler(
self.config_manager, self.config_manager,
@@ -147,15 +147,15 @@ class GroupDailyAnalysis(Star):
plugin_instance=self, 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()) GlobalRateLimiter.get_instance(self.config_manager.get_llm_max_concurrent())
self._initialized = False self._initialized = False
self._terminating = False # 生命周期标志 self._terminating = False # Cờ vòng đời.
self._init_lock = asyncio.Lock() self._init_lock = asyncio.Lock()
self._background_tasks: set[asyncio.Task] = set() self._background_tasks: set[asyncio.Task] = set()
# 异步注册任务,处理插件重载情况 # Đăng ký tác vụ bất đồng bộ để xử lý reload plugin.
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
self._init_task = loop.create_task( self._init_task = loop.create_task(
@@ -166,18 +166,18 @@ class GroupDailyAnalysis(Star):
except RuntimeError: except RuntimeError:
self._init_task = None self._init_task = None
# orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。 # Cache orchestrator đã chuyển vào application service hoặc tạm bỏ để đơn giản.
# 如果需要高性能缓存,后续可由 AnalysisApplicationService 内部维护。 # Nếu cần cache hiệu năng cao, AnalysisApplicationService có thể quản lý nội bộ.
@filter.on_platform_loaded() @filter.on_platform_loaded()
async def on_platform_loaded(self): 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") await self._run_initialization("Platform Loaded")
async def _run_initialization(self, source: str): async def _run_initialization(self, source: str):
"""统一初始化逻辑""" """Logic khởi tạo thống nhất."""
async with self._init_lock: 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 ( if (
self._initialized self._initialized
and self.bot_manager and self.bot_manager
@@ -186,77 +186,78 @@ class GroupDailyAnalysis(Star):
): ):
return return
# 稍微延迟,确保 context 和环境稳定 # Chờ để context, môi trường và platform manager ổn định.
# 针对极少数环境,2秒可能不足以让平台管理器就绪,增加到 5秒
await asyncio.sleep(5) await asyncio.sleep(5)
# [加固] 如果在等待期间插件已被卸载(terminate),则直接退出 # Thoát nếu plugin đã bị gỡ trong thời gian chờ.
if not self.bot_manager: if not self.bot_manager:
return return
try: try:
# 注册 TraceID 过滤器 # Đăng ký bộ lọc TraceID.
trace_filter = TraceLogFilter() trace_filter = TraceLogFilter()
if not any( if not any(
isinstance(f, TraceLogFilter) for f in astrbot_logger.filters isinstance(f, TraceLogFilter) for f in astrbot_logger.filters
): ):
astrbot_logger.addFilter(trace_filter) 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: try:
self.config_manager.upgrade_prompt_templates() self.config_manager.upgrade_prompt_templates()
except Exception as e: 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() await self.bot_manager.initialize_from_config()
# 2. 注册预览路由器 # 2. Đăng ký router preview.
if self.template_preview_router: if self.template_preview_router:
await self.template_preview_router.ensure_handlers_registered( await self.template_preview_router.ensure_handlers_registered(
self.context self.context
) )
# 3. 强制注册定时分析任务 # 3. Đăng ký tác vụ phân tích định kỳ.
if self.auto_scheduler: if self.auto_scheduler:
self.auto_scheduler.schedule_jobs(self.context) self.auto_scheduler.schedule_jobs(self.context)
self._initialized = True self._initialized = True
self._discovery_run = 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: 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): async def terminate(self):
"""插件被卸载/停用时调用,清理资源""" """Dọn tài nguyên khi plugin bị gỡ hoặc vô hiệu hoá."""
if self._terminating: if self._terminating:
return return
self._terminating = True self._terminating = True
try: 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: 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: for task in self._background_tasks:
if not task.done(): if not task.done():
task.cancel() task.cancel()
# 等待任务结束,给予 3 秒宽限期 # Chờ tác vụ kết thúc với thời gian gia hạn 3 giây.
try: try:
await asyncio.wait(list(self._background_tasks), timeout=3.0) await asyncio.wait(list(self._background_tasks), timeout=3.0)
except Exception: except Exception:
pass pass
self._background_tasks.clear() 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: 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) self.auto_scheduler.unschedule_jobs(self.context)
if self.template_preview_router: if self.template_preview_router:
@@ -265,32 +266,29 @@ class GroupDailyAnalysis(Star):
if self.report_generator: if self.report_generator:
await self.report_generator.close() await self.report_generator.close()
# 3. [关键修复] 只有在任务全部清理后,才清理引用。 # 3. Chỉ dọn tham chiếu sau khi mọi tác vụ đã kết thúc.
# 实际上,在 terminate 结束后,self 本身就会被 GC 释放, # Giữ tham chiếu để GC thu hồi tự nhiên, tránh race với tác vụ bất đồng bộ (#125).
# 这里的显式 None 更多是为了协助循环引用清理,但由于异步任务存在竞态, logger.info("Hoàn tất dọn tài nguyên plugin phân tích nhóm")
# 我们可以通过 check _terminating 标志位来保护。
# 为了彻底解决 #125,我们保留引用,让 GC 自然回收。
logger.info("群日常分析插件资源清理完成")
except Exception as e: 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.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
@filter.platform_adapter_type(filter.PlatformAdapterType.TELEGRAM) @filter.platform_adapter_type(filter.PlatformAdapterType.TELEGRAM)
async def intercept_telegram_messages(self, event: AstrMessageEvent): 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: try:
await self.message_processing_service.process_message(event) await self.message_processing_service.process_message(event)
except (ValueError, RuntimeError) as e: 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: 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.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
@filter.platform_adapter_type( @filter.platform_adapter_type(
@@ -298,7 +296,7 @@ class GroupDailyAnalysis(Star):
| filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK | filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK
) )
async def intercept_qq_official_messages(self, event: AstrMessageEvent): 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) raw_message = getattr(getattr(event, "message_obj", None), "raw_message", None)
if isinstance(raw_message, dict): if isinstance(raw_message, dict):
author = raw_message.get("author") or {} author = raw_message.get("author") or {}
@@ -316,23 +314,23 @@ class GroupDailyAnalysis(Star):
try: try:
await self.message_processing_service.process_message(event) await self.message_processing_service.process_message(event)
except (ValueError, RuntimeError) as e: 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: 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( async def get_telegram_seen_group_ids(
self, platform_id: str | None = None self, platform_id: str | None = None
) -> list[str]: ) -> 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) 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]: 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) return await self.platform_group_registry.get_all_group_ids(platform_id)
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None: def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
"""从消息事件中安全获取群组 ID""" """Lấy an toàn ID nhóm từ sự kiện tin nhắn."""
# 保留此辅助方法,因为在其他 command 中仍被频繁使用 # Giữ helper này vì nhiều command khác vẫn dùng.
try: try:
group_id = event.get_group_id() group_id = event.get_group_id()
return group_id if group_id else None return group_id if group_id else None
@@ -340,12 +338,12 @@ class GroupDailyAnalysis(Star):
return None return None
def _get_platform_id_from_event(self, event: AstrMessageEvent) -> str: def _get_platform_id_from_event(self, event: AstrMessageEvent) -> str:
"""从消息事件中获取平台唯一 ID""" """Lấy ID nền tảng duy nhất từ sự kiện tin nhắn."""
# 保留此辅助方法,因为在其他 command 中仍被频繁使用 # Giữ helper này vì nhiều command khác vẫn dùng.
try: try:
return event.get_platform_id() return event.get_platform_id()
except Exception: except Exception:
# 后备方案:从元数据获取 # Fallback: lấy từ metadata.
if ( if (
hasattr(event, "platform_meta") hasattr(event, "platform_meta")
and event.platform_meta and event.platform_meta
@@ -355,12 +353,12 @@ class GroupDailyAnalysis(Star):
return "default" 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): 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 base64
import re import re
@@ -376,12 +374,12 @@ class GroupDailyAnalysis(Star):
if not adapter or not hasattr(adapter, "upload_group_file_to_folder"): if not adapter or not hasattr(adapter, "upload_group_file_to_folder"):
return return
# 1. 构造一个更友好的文件名 # 1. Tạo tên tệp thân thiện hơn.
now = datetime.now() now = datetime.now()
timestamp = now.strftime("%H%M") timestamp = now.strftime("%H%M")
date_str = now.strftime("%Y-%m-%d") date_str = now.strftime("%Y-%m-%d")
# 默认基础名和后缀 # Tên cơ sở và phần mở rộng mặc định.
ext = ( ext = (
".jpg" ".jpg"
if (".jpg" in image_url.lower() or ".jpeg" in image_url.lower()) 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}" nice_filename = f"bao_cao_phan_tich_nhom_{group_id}_{date_str}_{timestamp}{ext}"
try: 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) group_info = await adapter.get_group_info(group_id)
if group_info and group_info.group_name: 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() safe_name = re.sub(r'[\\/:*?"<>|]', "", group_info.group_name).strip()
if safe_name: if safe_name:
nice_filename = f"bao_cao_phan_tich_nhom_{safe_name}_{date_str}_{timestamp}{ext}" nice_filename = f"bao_cao_phan_tich_nhom_{safe_name}_{date_str}_{timestamp}{ext}"
except Exception: except Exception:
pass pass
# 2. 将内容准备为文件或数据 # 2. Chuẩn bị nội dung dưới dạng tệp hoặc dữ liệu.
image_file = None image_file = None
created_temp = False created_temp = False
MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # 20MB 限制 MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # Giới hạn 20 MB.
try: try:
data = None data = None
if image_url.startswith("base64://"): if image_url.startswith("base64://"):
base64_str = image_url[len("base64://") :] base64_str = image_url[len("base64://") :]
if len(base64_str) * 3 / 4 > MAX_PAYLOAD_SIZE: 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 return
data = base64.b64decode(base64_str) data = base64.b64decode(base64_str)
elif image_url.startswith("data:"): elif image_url.startswith("data:"):
parts = image_url.split(",", 1) parts = image_url.split(",", 1)
if len(parts) == 2: if len(parts) == 2:
if len(parts[1]) * 3 / 4 > MAX_PAYLOAD_SIZE: 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 return
data = base64.b64decode(parts[1]) data = base64.b64decode(parts[1])
elif os.path.isfile(image_url): elif os.path.isfile(image_url):
image_file = os.path.abspath(image_url) image_file = os.path.abspath(image_url)
if data and not image_file: 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_") fd, image_file = tempfile.mkstemp(suffix=ext, prefix="group_report_")
try: try:
with os.fdopen(fd, "wb") as f: with os.fdopen(fd, "wb") as f:
@@ -437,7 +435,7 @@ class GroupDailyAnalysis(Star):
if not image_file: if not image_file:
return return
# 3. 执行上传:群文件 # 3. Upload vào tệp nhóm.
if enable_file: if enable_file:
try: try:
folder_name = self.config_manager.get_group_file_folder() folder_name = self.config_manager.get_group_file_folder()
@@ -450,10 +448,10 @@ class GroupDailyAnalysis(Star):
group_id=group_id, group_id=group_id,
file_path=image_file, file_path=image_file,
folder_id=folder_id, 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: 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"): if enable_album and hasattr(adapter, "upload_group_album"):
try: try:
@@ -465,12 +463,12 @@ class GroupDailyAnalysis(Star):
album_id = await adapter.find_album_id(group_id, album_name) # type: ignore[attr-defined] album_id = await adapter.find_album_id(group_id, album_name) # type: ignore[attr-defined]
if not album_id and strict_mode: if not album_id and strict_mode:
logger.info( 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 return
elif strict_mode: elif strict_mode:
logger.info( 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 return
await adapter.upload_group_album( # type: ignore[attr-defined] await adapter.upload_group_album( # type: ignore[attr-defined]
@@ -481,9 +479,9 @@ class GroupDailyAnalysis(Star):
strict_mode=strict_mode, strict_mode=strict_mode,
) )
except Exception as e: 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: except Exception as e:
logger.warning(f"图片上传处理异常: {e}") logger.warning(f"Lỗi xử lý upload ảnh: {e}")
finally: finally:
if created_temp and image_file and os.path.exists(image_file): if created_temp and image_file and os.path.exists(image_file):
try: try:
@@ -497,8 +495,8 @@ class GroupDailyAnalysis(Star):
self, event: AstrMessageEvent, days: int | None = None 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: if self._terminating:
return return
@@ -508,7 +506,7 @@ class GroupDailyAnalysis(Star):
self._background_tasks.add(current_task) self._background_tasks.add(current_task)
try: 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) group_id = self._get_group_id_from_event(event)
platform_id = self._get_platform_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") yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat")
return return
# 更新bot实例 # Cập nhật instance bot.
self.bot_manager.update_from_event(event) 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) check_target = getattr(event, "unified_msg_origin", None)
if not check_target: if not check_target:
check_target = f"{platform_id}:GroupMessage:{group_id}" check_target = f"{platform_id}:GroupMessage:{group_id}"
@@ -534,7 +532,7 @@ class GroupDailyAnalysis(Star):
) )
return return
# 获取群名以生成语义化的 TraceID # Lấy tên nhóm để tạo TraceID có ngữ nghĩa.
group_name = "" group_name = ""
try: try:
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
@@ -545,20 +543,20 @@ class GroupDailyAnalysis(Star):
except Exception: except Exception:
pass pass
# 设置 TraceID (语义化格式: manual_群名_HHmm) # Thiết lập TraceID theo dạng manual_tên_nhóm_HHmm.
trace_id = TraceContext.generate( trace_id = TraceContext.generate(
prefix="manual", group_name=group_name or group_id prefix="manual", group_name=group_name or group_id
) )
TraceContext.set(trace_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) adapter = self.bot_manager.get_adapter(platform_id)
orig_msg_id = getattr(event.message_obj, "message_id", None) orig_msg_id = getattr(event.message_obj, "message_id", None)
adapter_platform_name = ( adapter_platform_name = (
(adapter.get_platform_name() if adapter else "").strip().lower() (adapter.get_platform_name() if adapter else "").strip().lower()
) )
# QQ 官方机器人 API v2 不支持本插件使用的表情回应接口, # API v2 của QQ Official không hỗ trợ reaction plugin đang dùng,
# 因此始终沿用原有的文字进度提示,避免触发无效的 reaction 请求。 # nên luôn dùng thông báo tiến độ dạng văn bản.
use_text_reply = ( use_text_reply = (
adapter_platform_name in {"qq_official", "qq_official_webhook"} adapter_platform_name in {"qq_official", "qq_official_webhook"}
or self.config_manager.get_enable_analysis_reply() or self.config_manager.get_enable_analysis_reply()
@@ -573,7 +571,7 @@ class GroupDailyAnalysis(Star):
event.get_group_id(), orig_msg_id, "analysis_started" event.get_group_id(), orig_msg_id, "analysis_started"
) )
# 调用 DDD 应用级服务 # Gọi application service theo DDD.
result = await self.analysis_service.execute_daily_analysis( result = await self.analysis_service.execute_daily_analysis(
group_id=group_id, platform_id=platform_id, manual=True, days=days group_id=group_id, platform_id=platform_id, manual=True, days=days
) )
@@ -586,7 +584,7 @@ class GroupDailyAnalysis(Star):
) )
elif reason == "muted": elif reason == "muted":
logger.warning( 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: else:
yield event.plain_result( 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é~" "📊 Phân tích cho nhóm này đang chạy, vui lòng thử lại sau nhé~"
) )
except asyncio.CancelledError: 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: 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( 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 " 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" "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( async def _send_analysis_report(
self, event: AstrMessageEvent, result: dict self, event: AstrMessageEvent, result: dict
) -> AsyncGenerator: ) -> AsyncGenerator:
"""处理分析结果的渲染和发送""" """Render và gửi kết quả phân tích."""
if self._terminating or not self.config_manager: if self._terminating or not self.config_manager:
logger.warning("插件正在关闭,停止发送报告") logger.warning("Plugin đang đóng, dừng gửi báo cáo")
return return
group_id = result["group_id"] group_id = result["group_id"]
@@ -633,7 +631,7 @@ class GroupDailyAnalysis(Star):
output_format = self.config_manager.get_output_format()[0] output_format = self.config_manager.get_output_format()[0]
is_qq_official = adapter.get_platform_name() == "qq_official" 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: async def avatar_url_getter(user_id: str) -> str | None:
return await adapter.get_user_avatar_url(user_id) 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) sent = await adapter.send_image(group_id, image_url, caption=caption)
if sent: if sent:
await self._try_upload_image(group_id, image_url, platform_id) await self._try_upload_image(group_id, image_url, platform_id)
return # 成功发送 return # Gửi thành công.
# 如果图片生成或发送失败,直接回退到文本 # 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"图片报告发送失败,正在发送文本回退报告。群: {group_id}") 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( await self._send_text_reports(
group_id, analysis_result, is_qq_official, adapter group_id, analysis_result, is_qq_official, adapter
) )
@@ -690,10 +690,10 @@ class GroupDailyAnalysis(Star):
if is_only_url: if is_only_url:
if base_url and base_url.strip(): 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() 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: if not html_output_dir:
from astrbot.api.star import StarTools from astrbot.api.star import StarTools
@@ -702,7 +702,7 @@ class GroupDailyAnalysis(Star):
"self_hosted_html_reports", "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) rel_path = os.path.relpath(html_path, html_output_dir)
url_path = rel_path.replace(os.sep, "/") url_path = rel_path.replace(os.sep, "/")
report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}" report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}"
@@ -710,15 +710,15 @@ class GroupDailyAnalysis(Star):
yield event.plain_result( 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}" 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: else:
logger.warning( 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) caption = self.report_generator.build_html_caption(html_path)
# 发送 HTML 文件 # Gửi tệp HTML.
sender = getattr(self, "message_sender", None) sender = getattr(self, "message_sender", None)
if sender: if sender:
sent = await sender.send_file( sent = await sender.send_file(
@@ -776,10 +776,10 @@ class GroupDailyAnalysis(Star):
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
async def set_output_format(self, event: AstrMessageEvent, format_input: str = ""): async def set_output_format(self, event: AstrMessageEvent, format_input: str = ""):
""" """
设置分析报告输出格式(跨平台支持) Thiết lập định dạng báo cáo trên nhiều nền tảng.
用法: /设置格式 [格式名称或序号] 或 image,html 等逗号分隔的组合 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) event.should_call_llm(True)
available_formats = ["image", "text", "html"] 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 return
target_format = None target_format = None
# 尝试由序号选择 # Thử chọn theo số thứ tự.
if format_input.isdigit(): if format_input.isdigit():
idx = int(format_input) - 1 idx = int(format_input) - 1
if 0 <= idx < len(available_formats): if 0 <= idx < len(available_formats):
target_format = available_formats[idx] target_format = available_formats[idx]
# 尝试按名称选择 # Thử chọn theo tên.
if not target_format: if not target_format:
input_lower = format_input.lower() input_lower = format_input.lower()
if input_lower in available_formats: if input_lower in available_formats:
target_format = input_lower 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: if not target_format:
parts = [f.strip() for f in format_input.replace("", ",").split(",")] parts = [f.strip() for f in format_input.replace("", ",").split(",")]
if all(p in available_formats for p in parts) and len(parts) > 1: 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 = "" 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) event.should_call_llm(True)
available_templates = ( 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) @filter.permission_type(PermissionType.ADMIN)
async def view_templates(self, event: AstrMessageEvent): 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) event.should_call_llm(True)
available_templates = ( 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) @filter.permission_type(PermissionType.ADMIN)
async def analysis_settings(self, event: AstrMessageEvent, action: str = "status"): async def analysis_settings(self, event: AstrMessageEvent, action: str = "status"):
""" """
管理分析设置(跨平台支持) Quản lý cài đặt phân tích trên nhiều nền tảng.
用法: /分析设置 [enable|disable|status|reload|test]
- enable: 启用当前群的分析功能 Cách dùng: /caidat [enable|disable|status|reload|test|filter_bot|incremental_debug].
- disable: 禁用当前群的分析功能 ``filter_bot`` chuyển chế độ lọc tin bot; ``incremental_debug`` chuyển
- status: 查看当前状态 chế độ gửi ngay báo cáo gia tăng để debug.
- reload: 重新加载配置并重启定时任务
- test: 测试自动分析功能
- filter_bot: 切换是否在分析中包含机器人自己的消息
- incremental_debug: 切换增量分析立即报告模式(调试用)
""" """
group_id = self._get_group_id_from_event(event) 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...") 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) self.bot_manager.update_from_event(event)
try: 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] output_format = self.config_manager.get_output_format()[0]
min_threshold = self.config_manager.get_min_messages_threshold() 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_enabled = self.config_manager.get_incremental_enabled()
incremental_status_text = "Chưa bật" incremental_status_text = "Chưa bật"
if incremental_enabled: 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.command("tangcuong", alias={"incremental_status", "增量状态"})
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
async def incremental_status(self, event: AstrMessageEvent): 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) group_id = self._get_group_id_from_event(event)
if not group_id: if not group_id:
yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat") 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 import time as time_mod
# 计算滑动窗口范围 # Tính phạm vi cửa sổ trượt.
analysis_days = self.config_manager.get_analysis_days() analysis_days = self.config_manager.get_analysis_days()
window_end = time_mod.time() window_end = time_mod.time()
window_start = window_end - (analysis_days * 24 * 3600) 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( batches = await self.incremental_store.query_batches(
group_id, window_start, window_end 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 return
# 合并批次获取聚合视图 # Gộp batch để tạo chế độ xem tổng hợp.
state = self.incremental_merge_service.merge_batches( state = self.incremental_merge_service.merge_batches(
batches, window_start, window_end 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): 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() mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id 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): 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() mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id target_id = event.unified_msg_origin or group_id
+6 -6
View File
@@ -1,8 +1,8 @@
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。 name: astrbot_plugin_qq_group_daily_analysis # Mã định danh duy nhất của plugin.
display_name: 群分析总结插件 # 插件的显示名称 display_name: Plugin tổng hợp và phân tích nhóm # Tên hiển thị của plugin.
desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、TelegramDiscord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述 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, TelegramDiscord; 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 # 插件版本号。格式:v1.1.1 或者 v1.1 version: v4.11.2 # Phiên bản plugin, theo định dạng v1.1.1 hoặc v1.1.
author: SXP-Simon # 作者 author: SXP-Simon # Tác giả.
astrbot_version: ">=4.16.0" astrbot_version: ">=4.16.0"
support_platforms: support_platforms:
- aiocqhttp - aiocqhttp
@@ -10,4 +10,4 @@ support_platforms:
- telegram - telegram
- qq_official - qq_official
- qq_official_webhook - 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.
+13 -13
View File
@@ -1,17 +1,17 @@
""" """
群日常分析插件 - 源代码包 Package mã nguồn của plugin phân tích nhóm hằng ngày.
本包包含插件的核心实现,采用 DDD (领域驱动设计) 架构: Phần triển khai cốt lõi sử dụng kiến trúc DDD:
- application: 应用层 - 编排领域服务,处理用例 - application: điều phối domain service và xử lý use case
- domain: 领域层 - 核心业务逻辑,平台无关 - domain: logic nghiệp vụ cốt lõi, độc lập với nền tảng
- infrastructure: 基础设施层 - 外部服务适配 - infrastructure: adapter cho các dịch vụ bên ngoài
- shared: 共享组件 - 跨层使用的工具和常量 - shared: công cụ và hằng số dùng chung giữa các tầng
遗留模块(渐进式迁移中): Các module cũ đang được chuyển đổi dần:
- analysis: 分析器实现 - analysis: triển khai analyzer
- core: 核心组件 - core: thành phần cốt lõi
- reports: 报告生成 - reports: tạo báo cáo
- scheduler: 定时任务 - scheduler: tác vụ định kỳ
- utils: 工具函数 - utils: hàm tiện ích
- visualization: 可视化组件 - visualization: thành phần trực quan hoá
""" """
+1 -1
View File
@@ -1 +1 @@
# 应用层 - 编排和用例 # Tầng application - điều phối và triển khai use case
+1 -1
View File
@@ -1,4 +1,4 @@
"""命令相关应用服务。""" """Các dịch vụ application liên quan đến command."""
from .template_command_service import TemplateCommandService from .template_command_service import TemplateCommandService
@@ -1,4 +1,4 @@
"""模板管理相关命令服务。""" """Dịch vụ command quản lý template."""
from __future__ import annotations from __future__ import annotations
@@ -9,7 +9,7 @@ from astrbot.api.message_components import Image, Node, Nodes, Plain
class TemplateCommandService: class TemplateCommandService:
"""封装模板命令的文件系统与消息构建逻辑。""" """Đóng gói logic filesystem và tạo tin nhắn cho command template."""
_CIRCLE_NUMBERS = ["", "", "", "", "", "", "", "", "", ""] _CIRCLE_NUMBERS = ["", "", "", "", "", "", "", "", "", ""]
@@ -17,7 +17,7 @@ class TemplateCommandService:
self.plugin_root = plugin_root self.plugin_root = plugin_root
def resolve_template_base_dir(self) -> str: 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 = [ candidate_dirs = [
os.path.join( os.path.join(
self.plugin_root, "src", "infrastructure", "reporting", "templates" self.plugin_root, "src", "infrastructure", "reporting", "templates"
@@ -30,7 +30,7 @@ class TemplateCommandService:
return candidate_dirs[0] return candidate_dirs[0]
def resolve_template_preview_path(self, template_name: str) -> str | None: def resolve_template_preview_path(self, template_name: str) -> str | None:
"""解析模板预览图路径。""" """Xác định đường dẫn ảnh xem trước template."""
candidate_paths = [ candidate_paths = [
os.path.join(self.plugin_root, "assets", f"{template_name}-demo.jpg"), os.path.join(self.plugin_root, "assets", f"{template_name}-demo.jpg"),
] ]
@@ -40,7 +40,7 @@ class TemplateCommandService:
return None return None
async def list_available_templates(self) -> list[str]: 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() template_base_dir = self.resolve_template_base_dir()
def _list_templates_sync() -> list[str]: def _list_templates_sync() -> list[str]:
@@ -58,14 +58,14 @@ class TemplateCommandService:
return await asyncio.to_thread(_list_templates_sync) return await asyncio.to_thread(_list_templates_sync)
async def template_exists(self, template_name: str) -> bool: 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) template_dir = os.path.join(self.resolve_template_base_dir(), template_name)
return await asyncio.to_thread(os.path.exists, template_dir) return await asyncio.to_thread(os.path.exists, template_dir)
def parse_template_input( def parse_template_input(
self, template_input: str, available_templates: list[str] self, template_input: str, available_templates: list[str]
) -> tuple[str | None, str | None]: ) -> 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: if not template_input:
return None, "❌ Tham số mẫu không được để trống" return None, "❌ Tham số mẫu không được để trống"
@@ -87,7 +87,7 @@ class TemplateCommandService:
current_template: str, current_template: str,
bot_id: str, bot_id: str,
) -> Nodes: ) -> Nodes:
"""构建模板预览的合并消息节点。""" """Tạo các node tin nhắn tổng hợp để xem trước template."""
node_list = [] node_list = []
header_content = [ header_content = [
@@ -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 from __future__ import annotations
@@ -31,13 +32,13 @@ from ...utils.logger import logger
class DuplicateGroupTaskError(Exception): 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 pass
class AnalysisApplicationService: 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__( def __init__(
self, self,
@@ -61,44 +62,51 @@ class AnalysisApplicationService:
self.incremental_store = incremental_store self.incremental_store = incremental_store
self.incremental_merge_service = incremental_merge_service self.incremental_merge_service = incremental_merge_service
self._locks = weakref.WeakValueDictionary() self._locks = weakref.WeakValueDictionary()
# 全局 LLM 分析信号量,控制对外 API 的并发压力 # Semaphore LLM toàn cục để kiểm soát tải đồng thời lên API.
# 使用专用的 LLM 并发配置项 # Dùng giá trị cấu hình đồng thời riêng cho LLM.
max_concurrent = self.config_manager.get_llm_max_concurrent() max_concurrent = self.config_manager.get_llm_max_concurrent()
self.llm_semaphore = asyncio.Semaphore(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() self._active_tasks = set()
@asynccontextmanager @asynccontextmanager
async def group_lock(self, group_id: str, task_type: str = "analysis"): 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}" 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) lock = self._locks.get(lock_key)
if lock is None: if lock is None:
lock = asyncio.Lock() lock = asyncio.Lock()
self._locks[lock_key] = lock self._locks[lock_key] = lock
# 使用同步集合实现原子化的“运行中”检查 # Dùng set đồng bộ để kiểm tra trạng thái đang chạy theo cách nguyên tử.
# 在 asyncio 的单线程循环中,同步代码段不会被中断,因此这是原子操作 # 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: 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}") raise DuplicateGroupTaskError(f"Duplicate task for {lock_key}")
# 占位:标记任务开始 # Đánh dấu bắt đầu tác vụ.
self._active_tasks.add(lock_key) self._active_tasks.add(lock_key)
try: try:
async with lock: 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 yield
finally: finally:
# 释放:标记任务结束 # Giải phóng: đánh dấu tác vụ kết thúc.
self._active_tasks.discard(lock_key) 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( async def execute_daily_analysis(
self, self,
@@ -108,45 +116,47 @@ class AnalysisApplicationService:
days: int | None = None, days: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
执行每日分析用例。 Thực thi use case phân tích hằng ngày.
流程: Quy trình:
1. 获取适配器 1. Lấy adapter.
2. 拉取消息 (Infrastructure) 2. Lấy tin nhắn (infrastructure).
3. 基础统计 (Domain Service) 3. Thống kê cơ bản (domain service).
4. 用户分析 (Domain Service) 4. Phân tích thành viên (domain service).
5. LLM 语义分析 (Infrastructure/Analysis Bridge) 5. Phân tích ngữ nghĩa bằng LLM.
6. 生成报告 (Visualization/Infrastructure) 6. Tạo báo cáo.
7. 持久化摘要 (Persistence) 7. Lưu bản tóm tắt.
8. 返回结果 8. Trả kết quả.
""" """
async with self.group_lock(group_id, "daily"): async with self.group_lock(group_id, "daily"):
logger.info( 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) adapter = self.bot_manager.get_adapter(platform_id)
if not adapter: if not adapter:
raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}") 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"): if hasattr(adapter, "is_group_muted"):
try: try:
if await adapter.is_group_muted(group_id): if await adapter.is_group_muted(group_id):
logger.info( 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"} return {"success": False, "reason": "muted"}
except Exception as e: 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"): if hasattr(adapter, "prepare_group_member_cache"):
try: try:
logger.info( 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, group_id,
platform_id or "default", platform_id or "default",
) )
@@ -154,7 +164,7 @@ class AnalysisApplicationService:
if not ok and err: if not ok and err:
raise ValueError(err) raise ValueError(err)
logger.info( 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, group_id,
platform_id or "default", platform_id or "default",
) )
@@ -164,7 +174,7 @@ class AnalysisApplicationService:
f"vui lòng cấp đủ quyền cho ứng dụng: {e}" f"vui lòng cấp đủ quyền cho ứng dụng: {e}"
) from e ) from e
# 2. 拉取消息 # 2. Lấy tin nhắn
if days is None: if days is None:
days = self.config_manager.get_analysis_days() days = self.config_manager.get_analysis_days()
max_count = self.config_manager.get_max_messages() max_count = self.config_manager.get_max_messages()
@@ -173,7 +183,7 @@ class AnalysisApplicationService:
group_id=group_id, days=days, max_count=max_count group_id=group_id, days=days, max_count=max_count
) )
logger.info( 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, group_id,
platform_id or "default", platform_id or "default",
len(raw_messages), len(raw_messages),
@@ -182,10 +192,12 @@ class AnalysisApplicationService:
) )
if not raw_messages: 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"} 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 from ...domain.services.message_cleaner_service import MessageCleanerService
cleaner = MessageCleanerService() cleaner = MessageCleanerService()
@@ -198,32 +210,32 @@ class AnalysisApplicationService:
bot_self_ids, bot_self_ids,
) )
# 对于自动任务,强制过滤指令;对于手动任务,也建议过滤以保持报告纯净 # Luôn lọc command để báo cáo không bị nhiễu.
unified_messages = cleaner.clean_messages( unified_messages = cleaner.clean_messages(
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True raw_messages, bot_self_ids=bot_self_ids, filter_commands=True
) )
logger.info( 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, group_id,
platform_id or "default", platform_id or "default",
len(unified_messages), len(unified_messages),
max(len(raw_messages) - len(unified_messages), 0), 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() threshold = self.config_manager.get_min_messages_threshold()
if len(unified_messages) < threshold and not manual: if len(unified_messages) < threshold and not manual:
logger.info( 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"} return {"success": False, "reason": "below_threshold"}
# 5. 基础统计 (Domain Service) # 5. Thống kê cơ bản (domain service)
statistics = await asyncio.to_thread( statistics = await asyncio.to_thread(
self.statistics_service.calculate_group_statistics, unified_messages 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( user_activity = await asyncio.to_thread(
self.analysis_domain_service.analyze_user_activity, self.analysis_domain_service.analyze_user_activity,
unified_messages, unified_messages,
@@ -235,8 +247,8 @@ class AnalysisApplicationService:
user_activity, limit=max_user_titles user_activity, limit=max_user_titles
) )
# 5. LLM 语义分析 (为了保持兼容,目前直接传 UnifiedMessage,后续如需传 raw dict 再加转换) # 5. Phân tích ngữ nghĩa bằng LLM.
# LLMAnalyzer 内部可能已经处理了转换(见之前代码) # LLMAnalyzer có thể tự xử lý việc chuyển đổi dữ liệu.
topic_enabled = self.config_manager.get_topic_analysis_enabled() topic_enabled = self.config_manager.get_topic_analysis_enabled()
user_title_enabled = self.config_manager.get_user_title_analysis_enabled() user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
golden_quote_enabled = ( golden_quote_enabled = (
@@ -252,8 +264,8 @@ class AnalysisApplicationService:
chat_quality_review = None chat_quality_review = None
total_token_usage = TokenUsage() total_token_usage = TokenUsage()
# Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配 # LLMAnalyzer hiện có thể chỉ nhận format cũ hoặc adapter UnifiedMessage.
# 暂时转换回 legacy 格式以确保稳定性,直到 LLMAnalyzer 被重构 # Tạm chuyển về format cũ để ổn định cho đến khi LLMAnalyzer được refactor.
legacy_messages = self.statistics_service._convert_to_legacy_dict( legacy_messages = self.statistics_service._convert_to_legacy_dict(
unified_messages unified_messages
) )
@@ -269,7 +281,7 @@ class AnalysisApplicationService:
or chat_quality_enabled or chat_quality_enabled
): ):
async with self.llm_semaphore: 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, topics,
user_titles, user_titles,
@@ -287,7 +299,7 @@ class AnalysisApplicationService:
chat_quality_enabled=chat_quality_enabled, chat_quality_enabled=chat_quality_enabled,
) )
# 回填结果 # Gắn kết quả trở lại
statistics.golden_quotes = golden_quotes statistics.golden_quotes = golden_quotes
statistics.token_usage = total_token_usage statistics.token_usage = total_token_usage
@@ -299,11 +311,11 @@ class AnalysisApplicationService:
"chat_quality_review": chat_quality_review, "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) 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 { return {
"success": True, "success": True,
"analysis_result": analysis_result, "analysis_result": analysis_result,
@@ -314,37 +326,29 @@ class AnalysisApplicationService:
} }
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# 增量分析用例 # Use case phân tích gia tăng
# ---------------------------------------------------------------- # ----------------------------------------------------------------
async def execute_incremental_analysis( async def execute_incremental_analysis(
self, group_id: str, platform_id: str | None = None self, group_id: str, platform_id: str | None = None
) -> dict[str, Any]: ) -> 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.
与每日分析不同,增量分析每次仅处理最近一段时间的消息, 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,
提取少量话题和金句,将结果作为独立批次存储到 KV。 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.
流程: Quy trình: lấy adapter và tin nhắn, làm sạch, loại trùng theo timestamp,
1. 获取适配器 kiểm tra ngưỡng, tính thống kê, phân tích gia tăng bằng LLM, lưu
2. 拉取消息(使用增量配置的 max_messages ``IncrementalBatch``, cập nhật tiến độ và trả kết quả batch.
3. 清理消息
4. 按时间戳去重:过滤已分析过的消息
5. 检查最小消息阈值
6. 计算基础统计(小时分布、用户活跃、表情)
7. LLM 增量分析(仅话题 + 金句)
8. 构建 IncrementalBatch 并保存
9. 更新最后分析消息时间戳
10. 返回批次结果
Args: Args:
group_id: 群组 ID group_id: ID nhóm.
platform_id: 平台标识,缺省为默认 platform_id: ID nền tảng; mặc định dùng nền tảng mặc định.
Returns: Returns:
dict: 包含 successbatch_summary 等信息 Dict chứa success, batch_summary và các thông tin liên quan.
""" """
async with self.group_lock(group_id, "incremental"): async with self.group_lock(group_id, "incremental"):
if not self.incremental_store: if not self.incremental_store:
@@ -353,34 +357,36 @@ class AnalysisApplicationService:
) )
logger.info( 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) adapter = self.bot_manager.get_adapter(platform_id)
if not adapter: if not adapter:
raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}") 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"): if hasattr(adapter, "is_group_muted"):
try: try:
if await adapter.is_group_muted(group_id): if await adapter.is_group_muted(group_id):
logger.info( 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"} return {"success": False, "reason": "muted"}
except Exception as e: 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( last_analyzed_ts = await self.incremental_store.get_last_analyzed_timestamp(
group_id group_id
) )
days = self.config_manager.get_analysis_days() 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() 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( raw_messages = await adapter.fetch_messages(
group_id=group_id, group_id=group_id,
days=days, days=days,
@@ -389,10 +395,12 @@ class AnalysisApplicationService:
) )
if not raw_messages: 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"} return {"success": False, "reason": "no_messages"}
# 3. 清理消息 # 3. Làm sạch tin nhắn
from ...domain.services.message_cleaner_service import MessageCleanerService from ...domain.services.message_cleaner_service import MessageCleanerService
cleaner = MessageCleanerService() cleaner = MessageCleanerService()
@@ -408,22 +416,22 @@ class AnalysisApplicationService:
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True 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: if last_analyzed_ts > 0:
unified_messages = [ unified_messages = [
msg for msg in unified_messages if msg.timestamp > last_analyzed_ts 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() min_messages = self.config_manager.get_incremental_min_messages()
if len(unified_messages) < min_messages: if len(unified_messages) < min_messages:
logger.info( logger.info(
f"{group_id} 增量分析:新消息数 ({len(unified_messages)}) " f"Phân tích gia tăng nhóm {group_id}: số tin nhắn mới ({len(unified_messages)}) "
f"未达到阈值 ({min_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"} return {"success": False, "reason": "below_threshold"}
# 6. 计算基础统计 # 6. Tính thống kê cơ bản
statistics = await asyncio.to_thread( statistics = await asyncio.to_thread(
self.statistics_service.calculate_group_statistics, unified_messages self.statistics_service.calculate_group_statistics, unified_messages
) )
@@ -433,16 +441,16 @@ class AnalysisApplicationService:
bot_self_ids, 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( hourly_msg_counts, hourly_char_counts = self._compute_hourly_counts(
unified_messages 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() topics_per_batch = self.config_manager.get_incremental_topics_per_batch()
quotes_per_batch = self.config_manager.get_incremental_quotes_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() topic_enabled = self.config_manager.get_topic_analysis_enabled()
golden_quote_enabled = ( golden_quote_enabled = (
self.config_manager.get_golden_quote_analysis_enabled() self.config_manager.get_golden_quote_analysis_enabled()
@@ -451,7 +459,7 @@ class AnalysisApplicationService:
self.config_manager.get_chat_quality_analysis_enabled() 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( legacy_messages = self.statistics_service._convert_to_legacy_dict(
unified_messages unified_messages
) )
@@ -466,7 +474,9 @@ class AnalysisApplicationService:
if topic_enabled or golden_quote_enabled or chat_quality_enabled: if topic_enabled or golden_quote_enabled or chat_quality_enabled:
async with self.llm_semaphore: 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, topics,
golden_quotes, golden_quotes,
@@ -482,8 +492,8 @@ class AnalysisApplicationService:
chat_quality_enabled=chat_quality_enabled, chat_quality_enabled=chat_quality_enabled,
) )
# 8. 构建 IncrementalBatch # 8. Xây dựng IncrementalBatch
# 8a. 转换话题: SummaryTopic -> dict # 8a. Chuyển chủ đề: SummaryTopic -> dict
new_topics = [ new_topics = [
{ {
"topic": t.topic, "topic": t.topic,
@@ -494,7 +504,7 @@ class AnalysisApplicationService:
for t in topics for t in topics
] ]
# 8b. 转换金句: GoldenQuote -> dict # 8b. Chuyển trích dẫn: GoldenQuote -> dict
new_quotes = [ new_quotes = [
{ {
"content": q.content, "content": q.content,
@@ -505,19 +515,19 @@ class AnalysisApplicationService:
for q in golden_quotes for q in golden_quotes
] ]
# 8c. 转换 token 消耗: TokenUsage -> dict # 8c. Chuyển mức sử dụng token: TokenUsage -> dict
token_usage_dict = { token_usage_dict = {
"prompt_tokens": token_usage.prompt_tokens, "prompt_tokens": token_usage.prompt_tokens,
"completion_tokens": token_usage.completion_tokens, "completion_tokens": token_usage.completion_tokens,
"total_tokens": token_usage.total_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_stats = self._convert_user_activity_for_merge(
user_activity, unified_messages user_activity, unified_messages
) )
# 8e. 转换表情统计: EmojiStatistics -> dict # 8e. Chuyển thống kê biểu cảm: EmojiStatistics -> dict
emoji_stats = { emoji_stats = {
"face_count": statistics.emoji_statistics.face_count, "face_count": statistics.emoji_statistics.face_count,
"mface_count": statistics.emoji_statistics.mface_count, "mface_count": statistics.emoji_statistics.mface_count,
@@ -527,7 +537,7 @@ class AnalysisApplicationService:
"face_details": statistics.emoji_statistics.face_details, "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 chat_quality_dict = None
if chat_quality_review: if chat_quality_review:
chat_quality_dict = { chat_quality_dict = {
@@ -545,16 +555,16 @@ class AnalysisApplicationService:
"summary": chat_quality_review.summary, "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}) participant_ids = list({msg.sender_id for msg in unified_messages})
last_message_timestamp = max( last_message_timestamp = max(
(msg.timestamp for msg in unified_messages), default=0 (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) characters_count = sum(msg.get_text_length() for msg in unified_messages)
# 构建批次对象 # Xây dựng đối tượng batch
batch = IncrementalBatch( batch = IncrementalBatch(
group_id=group_id, group_id=group_id,
timestamp=time_mod.time(), timestamp=time_mod.time(),
@@ -572,10 +582,10 @@ class AnalysisApplicationService:
participant_ids=participant_ids, 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) 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 import time
safe_now = int(time.time()) + 60 safe_now = int(time.time()) + 60
@@ -586,9 +596,9 @@ class AnalysisApplicationService:
) )
logger.info( logger.info(
f" {group_id} 增量分析完成: " f"Hoàn tất phân tích gia tăng nhóm {group_id}: "
f"本批次消息={len(unified_messages)}, " f"tin nhắn batch={len(unified_messages)}, "
f"新话题={len(new_topics)}, 新金句={len(new_quotes)}" f"chủ đề mới={len(new_topics)}, trích dẫn mới={len(new_quotes)}"
) )
return { return {
@@ -603,28 +613,21 @@ class AnalysisApplicationService:
self, group_id: str, platform_id: str | None = None self, group_id: str, platform_id: str | None = None
) -> dict[str, Any]: ) -> 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 的时间窗口查询所有批次, Truy vấn batch theo cửa sổ ``analysis_days × 24 giờ``, gộp thành
合并为 IncrementalState,额外执行用户称号分析, ``IncrementalState``, phân tích thêm danh hiệu thành viên rồi tạo
然后生成与传统每日分析格式完全一致的 analysis_result。 ``analysis_result`` cùng định dạng với phân tích hằng ngày.
流程: Quy trình: tính cửa sổ, truy vấn và kiểm tra batch, gộp trạng thái,
1. 计算滑动窗口范围 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ả.
2. 查询窗口内的所有批次
3. 检查批次有效性
4. 合并批次为 IncrementalState
5. 执行用户称号 LLM 分析(基于合并后的累积数据)
6. 使用 IncrementalMergeService 构建 analysis_result
7. 持久化到 history_manager
8. 返回结果
Args: Args:
group_id: 群组 ID group_id: ID nhóm.
platform_id: 平台标识,缺省为默认 platform_id: ID nền tảng; mặc định dùng nền tảng mặc định.
Returns: Returns:
dict: 包含 successanalysis_resultadapter 等信息 Dict chứa success, analysis_result, adapter và thông tin liên quan.
""" """
async with self.group_lock(group_id, "final"): async with self.group_lock(group_id, "final"):
if not self.incremental_store or not self.incremental_merge_service: if not self.incremental_store or not self.incremental_merge_service:
@@ -634,48 +637,50 @@ class AnalysisApplicationService:
) )
logger.info( 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() analysis_days = self.config_manager.get_analysis_days()
window_end = time_mod.time() window_end = time_mod.time()
window_start = window_end - (analysis_days * 24 * 3600) 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( batches = await self.incremental_store.query_batches(
group_id, window_start, window_end group_id, window_start, window_end
) )
# 3. 检查批次有效性 # 3. Kiểm tra tính hợp lệ của batch
if not batches: if not batches:
logger.warning( 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"} return {"success": False, "reason": "no_incremental_data"}
# 4. 合并批次为 IncrementalState # 4. Gộp batch thành IncrementalState
state = self.incremental_merge_service.merge_batches( state = self.incremental_merge_service.merge_batches(
batches, window_start, window_end 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) adapter = self.bot_manager.get_adapter(platform_id)
if not adapter: if not adapter:
raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}") 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"): if hasattr(adapter, "is_group_muted"):
try: try:
if await adapter.is_group_muted(group_id): if await adapter.is_group_muted(group_id):
logger.info( 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"} return {"success": False, "reason": "muted"}
except Exception as e: 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_titles = []
user_title_enabled = self.config_manager.get_user_title_analysis_enabled() user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
unified_msg_origin = ( unified_msg_origin = (
@@ -684,24 +689,26 @@ class AnalysisApplicationService:
if user_title_enabled and state.user_activities: if user_title_enabled and state.user_activities:
max_user_titles = self.config_manager.get_max_user_titles() 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) top_users = state.get_user_activity_ranking(max_user_titles)
try: try:
async with self.llm_semaphore: 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, user_titles_result,
title_token_usage, title_token_usage,
) = await self.llm_analyzer.analyze_user_titles( ) = 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, user_activity=state.user_activities,
umo=unified_msg_origin, umo=unified_msg_origin,
top_users=top_users, top_users=top_users,
) )
user_titles = user_titles_result 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["prompt_tokens"] = (
state.total_token_usage.get("prompt_tokens", 0) state.total_token_usage.get("prompt_tokens", 0)
+ title_token_usage.prompt_tokens + title_token_usage.prompt_tokens
@@ -715,9 +722,12 @@ class AnalysisApplicationService:
+ title_token_usage.total_tokens + title_token_usage.total_tokens
) )
except Exception as e: 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 ( if (
self.config_manager.get_chat_quality_analysis_enabled() self.config_manager.get_chat_quality_analysis_enabled()
and state.all_quality_reviews and state.all_quality_reviews
@@ -725,7 +735,7 @@ class AnalysisApplicationService:
try: try:
async with self.llm_semaphore: async with self.llm_semaphore:
logger.debug( 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, summarized_review,
@@ -735,9 +745,8 @@ class AnalysisApplicationService:
umo=unified_msg_origin, umo=unified_msg_origin,
) )
if summarized_review: if summarized_review:
# 更新 state 中的 review 为汇总后的结果 # Cập nhật review trong state bằng kết quả tổng hợp.
# 这里我们需要将 QualityReview 对象存回 dict 或直接在后续处理中使用 # build_analysis_result sử dụng state.chat_quality_review.
# build_analysis_result 会使用 state.chat_quality_review
state.chat_quality_review = { state.chat_quality_review = {
"title": summarized_review.title, "title": summarized_review.title,
"subtitle": summarized_review.subtitle, "subtitle": summarized_review.subtitle,
@@ -753,7 +762,7 @@ class AnalysisApplicationService:
"summary": summarized_review.summary, "summary": summarized_review.summary,
} }
# 累加 Token # Cộng dồn token
state.total_token_usage["prompt_tokens"] = ( state.total_token_usage["prompt_tokens"] = (
state.total_token_usage.get("prompt_tokens", 0) state.total_token_usage.get("prompt_tokens", 0)
+ quality_token_usage.prompt_tokens + quality_token_usage.prompt_tokens
@@ -767,22 +776,25 @@ class AnalysisApplicationService:
+ quality_token_usage.total_tokens + quality_token_usage.total_tokens
) )
except Exception as e: 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( analysis_result = self.incremental_merge_service.build_analysis_result(
state, user_titles state, user_titles
) )
# 8. 持久化到 history_manager # 8. Lưu vào history_manager
await self.history_manager.save_analysis(group_id, analysis_result) await self.history_manager.save_analysis(group_id, analysis_result)
logger.info( logger.info(
f" {group_id} 增量最终报告完成: " f"Hoàn tất báo cáo gia tăng cuối của nhóm {group_id}: "
f"窗口={state.get_window_date_str()}, " f"cửa sổ={state.get_window_date_str()}, "
f"累计消息={state.total_message_count}, " f"tin nhắn tích luỹ={state.total_message_count}, "
f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}, " f"chủ đề={len(state.topics)}, trích dẫn={len(state.golden_quotes)}, "
f"批次={state.total_analysis_count}" f"batch={state.total_analysis_count}"
) )
return { return {
@@ -795,7 +807,7 @@ class AnalysisApplicationService:
} }
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# 辅助方法 # Phương thức hỗ trợ
# ---------------------------------------------------------------- # ----------------------------------------------------------------
@staticmethod @staticmethod
@@ -803,13 +815,13 @@ class AnalysisApplicationService:
messages: list[UnifiedMessage], messages: list[UnifiedMessage],
) -> tuple[dict[int, int], dict[int, int]]: ) -> 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: Args:
messages: 统一格式的消息列表 messages: Danh sách tin nhắn thống nhất.
Returns: Returns:
tuple: (每小时消息计数, 每小时字符计数) Tuple gồm số tin nhắn và số ký tự theo giờ.
""" """
hourly_msg: dict[int, int] = defaultdict(int) hourly_msg: dict[int, int] = defaultdict(int)
hourly_char: dict[int, int] = defaultdict(int) hourly_char: dict[int, int] = defaultdict(int)
@@ -827,22 +839,22 @@ class AnalysisApplicationService:
messages: list[UnifiedMessage], messages: list[UnifiedMessage],
) -> dict[str, dict]: ) -> dict[str, dict]:
""" """
AnalysisDomainService.analyze_user_activity() 的返回格式 Chuyển kết quả của ``AnalysisDomainService.analyze_user_activity()``
转换为 IncrementalBatch 所需的 user_stats 格式。 sang format ``user_stats`` mà ``IncrementalBatch`` yêu cầu.
转换映射: Ánh xạ chuyển đổi:
- nickname -> name - nickname -> name
- hours (defaultdict) -> active_hours (list) - hours (defaultdict) -> active_hours (list)
- 新增 last_message_time(从消息时间戳中提取) - Thêm last_message_time lấy từ timestamp tin nhắn.
Args: Args:
user_activity: AnalysisDomainService 返回的用户活跃数据 user_activity: Dữ liệu hoạt động do AnalysisDomainService trả về.
messages: 本批次的消息列表(用于提取每个用户的最后发言时间) messages: Tin nhắn batch dùng để lấy thời gian cuối của từng thành viên.
Returns: 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] = {} user_last_time: dict[str, int] = {}
for msg in messages: for msg in messages:
current = user_last_time.get(msg.sender_id, 0) current = user_last_time.get(msg.sender_id, 0)
@@ -859,7 +871,7 @@ class AnalysisApplicationService:
"reply_count": stats.get("reply_count", 0), "reply_count": stats.get("reply_count", 0),
"hours": dict( "hours": dict(
stats.get("hours", {}) stats.get("hours", {})
), # 这里的 hours defaultdict(int),转为 dict ), # hours defaultdict(int), chuyển thành dict
"last_message_time": user_last_time.get(user_id, 0), "last_message_time": user_last_time.get(user_id, 0),
} }
@@ -13,18 +13,18 @@ _QQ_OFFICIAL_MENTION_PATTERN = re.compile(r"<@!?([A-Za-z0-9_-]+)>")
class MessageProcessingService: class MessageProcessingService:
""" """
消息处理服务 Dịch vụ xử lý tin nhắn.
解析收到的群消息事件,提取内容与发送者信息,持久化历史记录, 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,
并维护事件驱动平台(Telegram、QQ 官方等)的群组注册表。 lưu lịch sử và duy trì registry nhóm cho các nền tảng hướng sự kiện như
QQ 官方平台特有的重复消息去重逻辑也在本服务中处理。 Telegram và QQ Official. Dịch vụ cũng xử lý loại trùng riêng của QQ Official.
职责: Trách nhiệm:
1. 解析消息内容(文本、图片、@提及等) 1. Phân tích nội dung tin nhắn như văn bản, hình ảnh và @mention.
2. 解析发送者展示名(跨平台兼容) 2. Xác định tên hiển thị của người gửi trên nhiều nền tảng.
3. 存储消息历史 3. Lưu lịch sử tin nhắn.
4. 维护群组注册表,供调度器做群组发现(Telegram、QQ 官方等事件驱动平台) 4. Duy trì registry để scheduler khám phá nhóm trên nền tảng hướng sự kiện.
5. QQ 官方事件消息去重(按 message_id 预占 + 确认机制) 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): def __init__(self, context: Context, group_registry: PlatformGroupRegistry):
@@ -36,45 +36,49 @@ class MessageProcessingService:
async def process_message(self, event: AstrMessageEvent) -> None: async def process_message(self, event: AstrMessageEvent) -> None:
""" """
处理并在历史记录中存储消息。 Xử lý và lưu tin nhắn vào lịch sử.
被 main.py 的 Telegram 和 QQ 官方消息拦截器共同调用。
Args: Được interceptor Telegram và QQ Official trong ``main.py`` cùng gọi.
event: AstrBot 消息事件
Raises: Args:
ValueError: 当必要数据无法获取时 event: Sự kiện tin nhắn AstrBot.
RuntimeError: 当消息内容为空时
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) group_id = self._get_group_id_from_event(event)
if not group_id: 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() sender_id = event.get_sender_id()
if not 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) 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) 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() platform_id = event.get_platform_id()
if not platform_id: if not platform_id:
raise ValueError(f"{group_id}: 无法获取平台 ID,拒绝存储消息") 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"
# 5. 提取消息内容
message_parts = self._extract_message_parts(event)
if not message_parts:
# 尝试记录一条警告但不中断流程(或者视为错误)
# 原逻辑是抛出 RuntimeError
raise RuntimeError(
f"{group_id}: 消息内容为空 (sender={sender_name}),拒绝存储"
) )
# 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) msg_obj = getattr(event, "message_obj", None)
event_message_id = str(getattr(msg_obj, "message_id", "") or "") 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: if platform_name in {"qq_official", "qq_official_webhook"} and event_message_id:
reserved_event_id = self._reserve_event_id(event_message_id) reserved_event_id = self._reserve_event_id(event_message_id)
if not reserved_event_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 return
history_content = { history_content = {
"type": "user", "type": "user",
@@ -96,7 +102,7 @@ class MessageProcessingService:
"timestamp": event_timestamp, "timestamp": event_timestamp,
} }
# 7. 存储到数据库 # 7. Lưu vào cơ sở dữ liệu
try: try:
await self.context.message_history_manager.insert( await self.context.message_history_manager.insert(
platform_id=platform_id, platform_id=platform_id,
@@ -130,11 +136,11 @@ class MessageProcessingService:
) )
logger.debug( 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: 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: try:
group_id = event.get_group_id() group_id = event.get_group_id()
return group_id if group_id else None return group_id if group_id else None
@@ -142,7 +148,7 @@ class MessageProcessingService:
return None return None
def _resolve_sender_name(self, event: AstrMessageEvent, sender_id: str) -> str: 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() platform_name = str(event.get_platform_name() or "").lower()
candidates: list[str | None] = [] candidates: list[str | None] = []
@@ -187,7 +193,7 @@ class MessageProcessingService:
return sender_id return sender_id
def _extract_message_parts(self, event: AstrMessageEvent) -> list[dict]: 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_parts = []
message = event.message_obj message = event.message_obj
platform_name = str(event.get_platform_name() or "").strip().lower() platform_name = str(event.get_platform_name() or "").strip().lower()
@@ -197,7 +203,7 @@ class MessageProcessingService:
else None else None
) )
# 收集 @ 标记 # Thu thập các @mention
pending_mentions: Counter[str] = Counter() pending_mentions: Counter[str] = Counter()
if message and hasattr(message, "message"): if message and hasattr(message, "message"):
for seg in message.message: for seg in message.message:
@@ -282,7 +288,7 @@ class MessageProcessingService:
) )
message_parts.append({"type": "plain", "text": fallback_text}) message_parts.append({"type": "plain", "text": fallback_text})
# 清理空文本段 # Loại bỏ phân đoạn văn bản rỗng
message_parts = [ message_parts = [
part part
for part in message_parts for part in message_parts
@@ -338,7 +344,7 @@ class MessageProcessingService:
).strip() ).strip()
display_name = display_name.lstrip("@").strip() display_name = display_name.lstrip("@").strip()
if cls._is_placeholder_sender_name(display_name, mention_id): if cls._is_placeholder_sender_name(display_name, mention_id):
display_name = "群友" display_name = "Thành viên"
replacements[mention_id] = f"@{display_name}" replacements[mention_id] = f"@{display_name}"
return replacements return replacements
@@ -348,8 +354,8 @@ class MessageProcessingService:
def replace_mention(match: re.Match[str]) -> str: def replace_mention(match: re.Match[str]) -> str:
mention_id = match.group(1) mention_id = match.group(1)
if mention_id.lower() in {"all", "everyone"}: if mention_id.lower() in {"all", "everyone"}:
return "@全体成员" return "@Tất cả thành viên"
return replacements.get(mention_id, "@群友") return replacements.get(mention_id, "@Thành viên")
cleaned = _QQ_OFFICIAL_MENTION_PATTERN.sub(replace_mention, str(text)) cleaned = _QQ_OFFICIAL_MENTION_PATTERN.sub(replace_mention, str(text))
return re.sub(r"[^\S\r\n]{2,}", " ", cleaned).strip(" \t") return re.sub(r"[^\S\r\n]{2,}", " ", cleaned).strip(" \t")
@@ -370,7 +376,7 @@ class MessageProcessingService:
@staticmethod @staticmethod
def _strip_known_mentions(text: str, pending_mentions: Counter[str]) -> str: 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) cleaned = str(text)
if not cleaned or not pending_mentions: if not cleaned or not pending_mentions:
return cleaned.strip() return cleaned.strip()
@@ -396,7 +402,7 @@ class MessageProcessingService:
@staticmethod @staticmethod
def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool: 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: if not name:
return True return True
normalized = str(name).strip() normalized = str(name).strip()
@@ -408,7 +414,7 @@ class MessageProcessingService:
@staticmethod @staticmethod
def _extract_event_timestamp(message_obj: object) -> int: 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) raw_message = getattr(message_obj, "raw_message", None)
if isinstance(raw_message, dict): if isinstance(raw_message, dict):
candidate = raw_message.get("timestamp") candidate = raw_message.get("timestamp")
@@ -437,7 +443,7 @@ class MessageProcessingService:
return 0 return 0
def _reserve_event_id(self, event_message_id: str) -> bool: 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 ( if (
event_message_id in self._inflight_event_ids event_message_id in self._inflight_event_ids
or event_message_id in self._seen_event_ids or event_message_id in self._seen_event_ids
@@ -449,7 +455,7 @@ class MessageProcessingService:
return True return True
def _commit_event_id(self, event_message_id: str) -> None: 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) self._inflight_event_ids.discard(event_message_id)
if event_message_id in self._seen_event_ids: if event_message_id in self._seen_event_ids:
self._seen_event_ids.move_to_end(event_message_id) self._seen_event_ids.move_to_end(event_message_id)
@@ -459,5 +465,5 @@ class MessageProcessingService:
self._seen_event_ids.popitem(last=False) self._seen_event_ids.popitem(last=False)
def _release_event_id(self, event_message_id: str) -> None: 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) self._inflight_event_ids.discard(event_message_id)
+1 -1
View File
@@ -1 +1 @@
# 领域层 - 与平台无关的业务逻辑 # Tầng domain - logic nghiệp vụ độc lập với nền tảng
+5 -5
View File
@@ -1,10 +1,10 @@
""" """
领域实体 Các entity thuộc tầng domain.
该模块导出所有领域实体类,包括: Module này export các entity domain, gồm:
- AnalysisTask: 分析任务聚合根 - AnalysisTask: aggregate root của tác vụ phân tích
- IncrementalBatch: 增量分析独立批次实体 - IncrementalBatch: entity đại diện cho một batch phân tích gia tăng
- IncrementalState: 增量分析聚合视图(报告时使用) - 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 from .analysis_task import AnalysisTask, TaskStatus
+8 -10
View File
@@ -1,6 +1,4 @@
""" """Entity tác vụ phân tích - aggregate root."""
分析任务实体 - 聚合根
"""
import time import time
import uuid import uuid
@@ -22,7 +20,7 @@ class TaskStatus(Enum):
@dataclass @dataclass
class AnalysisTask: class AnalysisTask:
"""分析任务实体 - 聚合根""" """Entity tác vụ phân tích - aggregate root."""
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
group_id: str = "" group_id: str = ""
@@ -37,34 +35,34 @@ class AnalysisTask:
error_message: str | None = None error_message: str | None = None
def start(self, can_analyze: bool) -> bool: 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: if not can_analyze:
self.status = TaskStatus.UNSUPPORTED_PLATFORM 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 return False
self.status = TaskStatus.FETCHING_MESSAGES self.status = TaskStatus.FETCHING_MESSAGES
self.started_at = time.time() self.started_at = time.time()
return True return True
def advance_to(self, status: TaskStatus): def advance_to(self, status: TaskStatus):
"""推进到下一个状态""" """Chuyển tác vụ sang trạng thái tiếp theo."""
self.status = status self.status = status
def complete(self, result_id: str): def complete(self, result_id: str):
"""标记任务为已完成""" """Đánh dấu tác vụ đã hoàn tất."""
self.status = TaskStatus.COMPLETED self.status = TaskStatus.COMPLETED
self.result_id = result_id self.result_id = result_id
self.completed_at = time.time() self.completed_at = time.time()
def fail(self, error: str): def fail(self, error: str):
"""标记任务为失败""" """Đánh dấu tác vụ thất bại."""
self.status = TaskStatus.FAILED self.status = TaskStatus.FAILED
self.error_message = error self.error_message = error
self.completed_at = time.time() self.completed_at = time.time()
@property @property
def duration(self) -> float | None: 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: if self.started_at and self.completed_at:
return self.completed_at - self.started_at return self.completed_at - self.started_at
return None return None
+92 -92
View File
@@ -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.
核心概念: Khái niệm cốt lõi:
- IncrementalBatch: 单次增量分析产生的独立批次数据,按批次独立存储 - IncrementalBatch: dữ liệu độc lập sinh ra từ một lần phân tích gia tăng
- IncrementalState: 报告生成时由多个批次合并而成的聚合视图(不再持久化) - 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
滑动窗口设计: Thiết kế cửa sổ trượt:
- 每次增量分析产生一个 IncrementalBatch,独立存储到 KV - Mỗi lần phân tích gia tăng tạo một IncrementalBatch và lưu riêng vào KV
- 最终报告时按 analysis_days × 24h 的时间窗口查询批次并合并 - 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 import time
@@ -21,50 +21,50 @@ from typing import Any
@dataclass @dataclass
class IncrementalBatch: class IncrementalBatch:
""" """
单次增量分析批次数据 Dữ liệu của một batch phân tích gia tăng.
每次增量分析执行完毕后产生一个 IncrementalBatch Mỗi lần phân tích gia tăng hoàn tất sẽ tạo một ``IncrementalBatch`` chứa
包含该批次的所有统计数据和 LLM 分析结果,独立存储到 KV 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: Attributes:
group_id: 群组 ID group_id: ID nhóm.
batch_id: 批次唯一标识(UUID batch_id: UUID duy nhất của batch.
timestamp: 批次创建时间戳(epoch timestamp: Epoch timestamp khi tạo batch.
messages_count: 本批次分析的消息数量 messages_count: Số tin nhắn được phân tích trong batch.
characters_count: 本批次的总字符数 characters_count: Tổng số ký tự trong batch.
hourly_msg_counts: 按小时的消息计数 {hour_str: count} hourly_msg_counts: Số tin nhắn theo giờ.
hourly_char_counts: 按小时的字符计数 {hour_str: count} hourly_char_counts: Số ký tự theo giờ.
user_stats: 用户统计 {user_id: {name, message_count, char_count, ...}} user_stats: Thống kê thành viên.
emoji_stats: 表情统计 {emoji_type: count} emoji_stats: Thống kê biểu cảm.
topics: 本批次提取的话题列表 topics: Danh sách chủ đề trích xuất từ batch.
golden_quotes: 本批次提取的金句列表 golden_quotes: Danh sách trích dẫn nổi bật từ batch.
token_usage: 本批次 token 消耗 {prompt_tokens, completion_tokens, total_tokens} token_usage: Mức sử dụng token của batch.
chat_quality_review: 本批次提取的聊天质量锐评 chat_quality_review: Đánh giá chất lượng trò chuyện của batch.
last_message_timestamp: 本批次最后一条消息的时间戳 last_message_timestamp: Timestamp tin nhắn cuối trong batch.
participant_ids: 本批次参与者 ID 列表 participant_ids: Danh sách ID người tham gia trong batch.
""" """
group_id: str = "" group_id: str = ""
batch_id: str = field(default_factory=lambda: str(uuid.uuid4())) batch_id: str = field(default_factory=lambda: str(uuid.uuid4()))
timestamp: float = field(default_factory=time.time) timestamp: float = field(default_factory=time.time)
# 统计数据 # Dữ liệu thống kê
messages_count: int = 0 messages_count: int = 0
characters_count: int = 0 characters_count: int = 0
hourly_msg_counts: dict[str, int] = field(default_factory=dict) hourly_msg_counts: dict[str, int] = field(default_factory=dict)
hourly_char_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) 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) 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) topics: list[dict] = field(default_factory=list)
golden_quotes: 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( token_usage: dict = field(
default_factory=lambda: { default_factory=lambda: {
"prompt_tokens": 0, "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 chat_quality_review: dict[str, Any] | None = None
last_message_timestamp: int = 0 last_message_timestamp: int = 0
participant_ids: list[str] = field(default_factory=list) participant_ids: list[str] = field(default_factory=list)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""序列化为字典,用于 KV 存储""" """Tuần tự hoá thành dict để lưu trong KV."""
return { return {
"group_id": self.group_id, "group_id": self.group_id,
"batch_id": self.batch_id, "batch_id": self.batch_id,
@@ -100,7 +100,7 @@ class IncrementalBatch:
@classmethod @classmethod
def from_dict(cls, data: dict) -> "IncrementalBatch": def from_dict(cls, data: dict) -> "IncrementalBatch":
"""从字典反序列化""" """Khôi phục đối tượng từ dict."""
return cls( return cls(
group_id=data.get("group_id", ""), group_id=data.get("group_id", ""),
batch_id=data.get("batch_id", ""), batch_id=data.get("batch_id", ""),
@@ -127,7 +127,7 @@ class IncrementalBatch:
) )
def get_summary(self) -> dict: def get_summary(self) -> dict:
"""获取批次摘要信息""" """Lấy thông tin tóm tắt của batch."""
return { return {
"batch_id": self.batch_id[:8], "batch_id": self.batch_id[:8],
"timestamp": datetime.fromtimestamp(self.timestamp).strftime( "timestamp": datetime.fromtimestamp(self.timestamp).strftime(
@@ -143,53 +143,53 @@ class IncrementalBatch:
@dataclass @dataclass
class IncrementalState: class IncrementalState:
""" """
增量分析聚合视图(报告时使用) View tổng hợp phân tích gia tăng dùng khi tạo báo cáo.
由多个 IncrementalBatch 合并而成,不直接持久化。 Được gộp từ nhiều ``IncrementalBatch`` và không lưu trực tiếp.
IncrementalMergeService.merge_batches() 负责从批次列表构建此对象。 ``IncrementalMergeService.merge_batches()`` xây dựng đối tượng từ danh sách batch.
Attributes: Attributes:
group_id: 群组 ID group_id: ID nhóm.
window_start: 滑动窗口起始时间戳 window_start: Timestamp bắt đầu cửa sổ trượt.
window_end: 滑动窗口结束时间戳 window_end: Timestamp kết thúc cửa sổ trượt.
topics: 合并去重后的话题列表 topics: Danh sách chủ đề đã gộp và loại trùng.
golden_quotes: 合并去重后的金句列表 golden_quotes: Danh sách trích dẫn đã gộp và loại trùng.
hourly_message_counts: 合并后的每小时消息计数 {hour_str: count} hourly_message_counts: Số tin nhắn theo giờ sau khi gộp.
hourly_character_counts: 合并后的每小时字符计数 {hour_str: count} hourly_character_counts: Số ký tự theo giờ sau khi gộp.
user_activities: 合并后的用户活跃数据 user_activities: Dữ liệu hoạt động thành viên sau khi gộp.
emoji_counts: 合并后的表情统计 emoji_counts: Thống kê biểu cảm sau khi gộp.
total_message_count: 窗口内总消息数 total_message_count: Tổng số tin nhắn trong cửa sổ.
total_character_count: 窗口内总字符数 total_character_count: Tổng số ký tự trong cửa sổ.
total_analysis_count: 窗口内批次数量 total_analysis_count: Số batch trong cửa sổ.
total_token_usage: 累计 token 消耗 total_token_usage: Tổng mức sử dụng token.
last_analyzed_message_timestamp: 最后分析消息时间戳 last_analyzed_message_timestamp: Timestamp tin nhắn được phân tích cuối cùng.
all_participant_ids: 所有参与者 ID 集合 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 = "" group_id: str = ""
window_start: float = 0.0 window_start: float = 0.0
window_end: 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) topics: list[dict] = field(default_factory=list)
golden_quotes: list[dict] = field(default_factory=list) golden_quotes: list[dict] = field(default_factory=list)
chat_quality_review: dict[str, Any] | None = None chat_quality_review: dict[str, Any] | None = None
all_quality_reviews: list[dict] = field( all_quality_reviews: list[dict] = field(
default_factory=list 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_message_counts: dict[str, int] = field(default_factory=dict)
hourly_character_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) 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) emoji_counts: dict[str, Any] = field(default_factory=dict)
# 汇总统计 # Thống kê tổng hợp
total_message_count: int = 0 total_message_count: int = 0
total_character_count: int = 0 total_character_count: int = 0
total_analysis_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 last_analyzed_message_timestamp: int = 0
all_participant_ids: set[str] = field(default_factory=set) all_participant_ids: set[str] = field(default_factory=set)
# 元数据 # Metadata
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
updated_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]: 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: Args:
top_n: 返回前 N 个最活跃的小时 top_n: Số giờ hoạt động tích cực nhất cần trả về.
Returns: 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: if not self.hourly_message_counts:
return [] return []
@@ -230,26 +230,26 @@ class IncrementalState:
def get_most_active_period(self) -> str: 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: Returns:
str: 如 "20:00-21:00" Chuỗi dạng ``20:00-21:00``.
""" """
peak = self.get_peak_hours(1) peak = self.get_peak_hours(1)
if not peak: if not peak:
return "未知" return "Không xác định"
hour = peak[0] hour = peak[0]
return f"{hour:02d}:00-{hour + 1:02d}:00" return f"{hour:02d}:00-{hour + 1:02d}:00"
def get_user_activity_ranking(self, top_n: int = 10) -> list[dict]: 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: Args:
top_n: 返回前 N 名 top_n: Số thành viên đứng đầu cần trả về.
Returns: Returns:
list[dict]: 按消息数降序排列的用户列表 Danh sách thành viên sắp xếp giảm dần theo số tin nhắn.
""" """
users = [] users = []
for user_id, data in self.user_activities.items(): for user_id, data in self.user_activities.items():
@@ -266,10 +266,10 @@ class IncrementalState:
def get_window_date_str(self) -> str: 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: 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: if self.window_start <= 0 or self.window_end <= 0:
return datetime.now().strftime("%Y-%m-%d") return datetime.now().strftime("%Y-%m-%d")
@@ -283,10 +283,10 @@ class IncrementalState:
def get_summary(self) -> dict: 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: Returns:
dict: 包含关键统计信息的摘要 Bản tóm tắt chứa các số liệu thống kê chính.
""" """
return { return {
"group_id": self.group_id, "group_id": self.group_id,
@@ -301,7 +301,7 @@ class IncrementalState:
"last_analysis_time": ( "last_analysis_time": (
datetime.fromtimestamp(self.updated_at).strftime("%H:%M:%S") datetime.fromtimestamp(self.updated_at).strftime("%H:%M:%S")
if self.updated_at if self.updated_at
else "" else "Không có"
), ),
"peak_hours": self.get_peak_hours(3), "peak_hours": self.get_peak_hours(3),
} }
@@ -311,18 +311,18 @@ class IncrementalState:
new_topic: dict, existing_topics: list[dict], threshold: float = 0.6 new_topic: dict, existing_topics: list[dict], threshold: float = 0.6
) -> bool: ) -> 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: Args:
new_topic: 待检测的新话题 new_topic: Chủ đề mới cần kiểm tra.
existing_topics: 已有话题列表 existing_topics: Danh sách chủ đề hiện có.
threshold: 相似度阈值(0-1),默认 0.6 threshold: Ngưỡng tương đồng từ 0 đến 1, mặc định là 0.6.
Returns: Returns:
bool: 是否重复 Có trùng lặp hay không.
""" """
new_name = new_topic.get("topic", "") new_name = new_topic.get("topic", "")
if not new_name: if not new_name:
@@ -344,15 +344,15 @@ class IncrementalState:
new_quote: dict, existing_quotes: list[dict], threshold: float = 0.7 new_quote: dict, existing_quotes: list[dict], threshold: float = 0.7
) -> bool: ) -> bool:
""" """
检测金句是否与已有金句重复。 Kiểm tra trích dẫn có trùng với trích dẫn hiện có hay không.
Args: Args:
new_quote: 待检测的新金句 new_quote: Trích dẫn mới cần kiểm tra.
existing_quotes: 已有金句列表 existing_quotes: Danh sách trích dẫn hiện có.
threshold: 相似度阈值(0-1),默认 0.7 threshold: Ngưỡng tương đồng từ 0 đến 1, mặc định là 0.7.
Returns: Returns:
bool: 是否重复 Có trùng lặp hay không.
""" """
new_content = new_quote.get("content", "") new_content = new_quote.get("content", "")
if not new_content: if not new_content:
@@ -372,14 +372,14 @@ class IncrementalState:
@staticmethod @staticmethod
def char_overlap_similarity(s1: str, s2: str) -> float: 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: Args:
s1: 第一个字符串 s1: Chuỗi thứ nhất.
s2: 第二个字符串 s2: Chuỗi thứ hai.
Returns: Returns:
float: 相似度值(0-1 Độ tương đồng từ 0 đến 1.
""" """
if not s1 or not s2: if not s1 or not s2:
return 0.0 return 0.0
+35 -35
View File
@@ -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): class DomainException(Exception):
"""所有领域错误的基础异常。""" """Ngoại lệ cơ sở cho mọi lỗi domain."""
def __init__(self, message: str, code: str = "DOMAIN_ERROR"): def __init__(self, message: str, code: str = "DOMAIN_ERROR"):
self.message = message self.message = message
@@ -16,33 +16,33 @@ class DomainException(Exception):
# ============================================================================ # ============================================================================
# 分析异常 # Ngoại lệ phân tích
# ============================================================================ # ============================================================================
class AnalysisException(DomainException): 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"): def __init__(self, message: str, code: str = "ANALYSIS_ERROR"):
super().__init__(message, code) super().__init__(message, code)
class InsufficientDataException(AnalysisException): 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"): def __init__(self, message: str = "Không đủ dữ liệu để phân tích"):
super().__init__(message, "INSUFFICIENT_DATA") super().__init__(message, "INSUFFICIENT_DATA")
class AnalysisTimeoutException(AnalysisException): 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"): def __init__(self, message: str = "Phân tích bị timeout"):
super().__init__(message, "ANALYSIS_TIMEOUT") super().__init__(message, "ANALYSIS_TIMEOUT")
class LLMException(AnalysisException): 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 = ""): def __init__(self, message: str = "Gọi API LLM thất bại", provider: str = ""):
self.provider = provider self.provider = provider
@@ -53,7 +53,7 @@ class LLMException(AnalysisException):
class LLMRateLimitException(LLMException): 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__( def __init__(
self, message: str = "Vượt quá giới hạn tốc độ LLM", provider: str = "" 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): 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 = ""): def __init__(self, message: str = "Vượt quá hạn ngạch LLM", provider: str = ""):
super().__init__(message, provider) super().__init__(message, provider)
@@ -71,12 +71,12 @@ class LLMQuotaExceededException(LLMException):
# ============================================================================ # ============================================================================
# 平台异常 # Ngoại lệ nền tảng
# ============================================================================ # ============================================================================
class PlatformException(DomainException): 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"): def __init__(self, message: str, platform: str = "", code: str = "PLATFORM_ERROR"):
self.platform = platform self.platform = platform
@@ -84,7 +84,7 @@ class PlatformException(DomainException):
class PlatformNotSupportedException(PlatformException): class PlatformNotSupportedException(PlatformException):
"""当平台不被支持时抛出。""" """Được phát sinh khi nền tảng không được hỗ trợ."""
def __init__(self, platform: str): def __init__(self, platform: str):
super().__init__( super().__init__(
@@ -95,21 +95,21 @@ class PlatformNotSupportedException(PlatformException):
class PlatformConnectionException(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 = ""): 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") super().__init__(message, platform, "PLATFORM_CONNECTION_ERROR")
class PlatformAPIException(PlatformException): 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 = ""): 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") super().__init__(message, platform, "PLATFORM_API_ERROR")
class MessageFetchException(PlatformException): class MessageFetchException(PlatformException):
"""当获取消息失败时抛出。""" """Được phát sinh khi lấy tin nhắn thất bại."""
def __init__( def __init__(
self, self,
@@ -126,7 +126,7 @@ class MessageFetchException(PlatformException):
class MessageSendException(PlatformException): class MessageSendException(PlatformException):
"""当发送消息失败时抛出。""" """Được phát sinh khi gửi tin nhắn thất bại."""
def __init__( def __init__(
self, self,
@@ -143,19 +143,19 @@ class MessageSendException(PlatformException):
# ============================================================================ # ============================================================================
# 配置异常 # Ngoại lệ cấu hình
# ============================================================================ # ============================================================================
class ConfigurationException(DomainException): 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"): def __init__(self, message: str, code: str = "CONFIG_ERROR"):
super().__init__(message, code) super().__init__(message, code)
class InvalidConfigurationException(ConfigurationException): 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 = ""): def __init__(self, message: str = "Cấu hình không hợp lệ", key: str = ""):
self.key = key self.key = key
@@ -163,7 +163,7 @@ class InvalidConfigurationException(ConfigurationException):
class MissingConfigurationException(ConfigurationException): class MissingConfigurationException(ConfigurationException):
"""当缺少必需配置时抛出。""" """Được phát sinh khi thiếu cấu hình bắt buộc."""
def __init__(self, key: str): def __init__(self, key: str):
self.key = key self.key = key
@@ -171,19 +171,19 @@ class MissingConfigurationException(ConfigurationException):
# ============================================================================ # ============================================================================
# 仓储异常 # Ngoại lệ repository
# ============================================================================ # ============================================================================
class RepositoryException(DomainException): 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"): def __init__(self, message: str, code: str = "REPOSITORY_ERROR"):
super().__init__(message, code) super().__init__(message, code)
class DataNotFoundException(RepositoryException): class DataNotFoundException(RepositoryException):
"""当请求的数据未找到时抛出。""" """Được phát sinh khi không tìm thấy dữ liệu được yêu cầu."""
def __init__( def __init__(
self, self,
@@ -200,26 +200,26 @@ class DataNotFoundException(RepositoryException):
class DataPersistenceException(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"): def __init__(self, message: str = "Lưu trữ dữ liệu thất bại"):
super().__init__(message, "DATA_PERSISTENCE_ERROR") super().__init__(message, "DATA_PERSISTENCE_ERROR")
# ============================================================================ # ============================================================================
# 调度异常 # Ngoại lệ lập lịch
# ============================================================================ # ============================================================================
class SchedulingException(DomainException): 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"): def __init__(self, message: str, code: str = "SCHEDULING_ERROR"):
super().__init__(message, code) super().__init__(message, code)
class TaskAlreadyScheduledException(SchedulingException): 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): def __init__(self, task_id: str):
self.task_id = task_id self.task_id = task_id
@@ -229,7 +229,7 @@ class TaskAlreadyScheduledException(SchedulingException):
class TaskNotFoundException(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): def __init__(self, task_id: str):
self.task_id = task_id self.task_id = task_id
@@ -239,12 +239,12 @@ class TaskNotFoundException(SchedulingException):
# ============================================================================ # ============================================================================
# 验证异常 # Ngoại lệ xác thực
# ============================================================================ # ============================================================================
class ValidationException(DomainException): 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"): def __init__(self, message: str, field: str = "", code: str = "VALIDATION_ERROR"):
self.field = field self.field = field
@@ -252,7 +252,7 @@ class ValidationException(DomainException):
class InvalidGroupIdException(ValidationException): class InvalidGroupIdException(ValidationException):
"""当群组 ID 无效时抛出。""" """Được phát sinh khi ID nhóm không hợp lệ."""
def __init__(self, group_id: str): def __init__(self, group_id: str):
super().__init__( super().__init__(
@@ -261,7 +261,7 @@ class InvalidGroupIdException(ValidationException):
class InvalidUserIdException(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): def __init__(self, user_id: str):
super().__init__( super().__init__(
@@ -270,7 +270,7 @@ class InvalidUserIdException(ValidationException):
class InvalidMessageException(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ệ"): def __init__(self, message: str = "Định dạng tin nhắn không hợp lệ"):
super().__init__(message, "message", "INVALID_MESSAGE") super().__init__(message, "message", "INVALID_MESSAGE")
+1 -1
View File
@@ -1,4 +1,4 @@
# 仓储接口 # Các giao diện repository
from .avatar_repository import IAvatarRepository from .avatar_repository import IAvatarRepository
from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender
from .visualization_repository import IActivityVisualizer from .visualization_repository import IActivityVisualizer
+10 -11
View File
@@ -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 from abc import ABC, abstractmethod
@@ -15,9 +16,7 @@ from ..models.data_models import (
class IAnalysisProvider(ABC): class IAnalysisProvider(ABC):
""" """Giao diện nhà cung cấp dịch vụ phân tích bằng LLM."""
LLM 分析提供商接口
"""
@abstractmethod @abstractmethod
async def analyze_topics( async def analyze_topics(
@@ -26,7 +25,7 @@ class IAnalysisProvider(ABC):
umo: str | None = None, umo: str | None = None,
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[SummaryTopic], TokenUsage]: ) -> tuple[list[SummaryTopic], TokenUsage]:
"""分析话题""" """Phân tích các chủ đề thảo luận."""
pass pass
@abstractmethod @abstractmethod
@@ -38,7 +37,7 @@ class IAnalysisProvider(ABC):
top_users: list[dict] | None = None, top_users: list[dict] | None = None,
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[UserTitle], TokenUsage]: ) -> tuple[list[UserTitle], TokenUsage]:
"""分析用户称号""" """Phân tích danh hiệu của thành viên."""
pass pass
@abstractmethod @abstractmethod
@@ -48,7 +47,7 @@ class IAnalysisProvider(ABC):
umo: str | None = None, umo: str | None = None,
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[GoldenQuote], TokenUsage]: ) -> tuple[list[GoldenQuote], TokenUsage]:
"""分析金句""" """Phân tích các trích dẫn nổi bật."""
pass pass
@abstractmethod @abstractmethod
@@ -69,7 +68,7 @@ class IAnalysisProvider(ABC):
TokenUsage, TokenUsage,
QualityReview | None, QualityReview | None,
]: ]:
"""并发分析所有内容""" """Phân tích đồng thời tất cả nội dung."""
pass pass
@abstractmethod @abstractmethod
@@ -83,7 +82,7 @@ class IAnalysisProvider(ABC):
golden_quote_enabled: bool = True, golden_quote_enabled: bool = True,
chat_quality_enabled: bool = False, chat_quality_enabled: bool = False,
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]: ) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]:
"""增量模式并发分析""" """Phân tích đồng thời ở chế độ gia tăng."""
pass pass
@abstractmethod @abstractmethod
@@ -93,5 +92,5 @@ class IAnalysisProvider(ABC):
umo: str | None = None, umo: str | None = None,
session_id: str | None = None, session_id: str | None = None,
) -> tuple[QualityReview | None, TokenUsage]: ) -> 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 pass
+21 -24
View File
@@ -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 from abc import ABC, abstractmethod
class IAvatarRepository(ABC): class IAvatarRepository(ABC):
""" """
头像仓储接口 Giao diện repository ảnh đại diện.
不同平台获取头像的方式不同: Cách lấy ảnh đại diện khác nhau giữa các nền tảng:
- QQ/OneBot: URL 模板 (q1.qlogo.cn) - QQ/OneBot: mẫu URL (q1.qlogo.cn)
- Telegram: API 调用 (getUserProfilePhotos + getFile) - Telegram: gọi API (getUserProfilePhotos + getFile)
- Discord: CDN URL 模板 (cdn.discordapp.com) - Discord: mẫu URL CDN (cdn.discordapp.com)
- Slack: users.info API profile.image_* 字段 - Slack: trường profile.image_* của API users.info
""" """
@abstractmethod @abstractmethod
@@ -23,14 +21,14 @@ class IAvatarRepository(ABC):
size: int = 100, size: int = 100,
) -> str | None: ) -> str | None:
""" """
获取用户头像 URL Lấy URL ảnh đại diện thành viên.
参数: Args:
user_id: 用户 ID user_id: ID thành viên.
size: 期望的头像尺寸(将选择最接近的可用尺寸) size: Kích thước mong muốn; chọn kích thước khả dụng gần nhất.
返回: Returns:
头像 URL,如果不可用则返回 None URL ảnh đại diện hoặc ``None`` nếu không khả dụng.
""" """
pass pass
@@ -41,13 +39,12 @@ class IAvatarRepository(ABC):
size: int = 100, size: int = 100,
) -> str | None: ) -> 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.
返回: Returns:
Base64 编码的图片数据 (data:image/png;base64,...) Dữ liệu ảnh mã hoá Base64 hoặc ``None`` nếu không khả dụng.
如果不可用则返回 None
""" """
pass pass
@@ -57,7 +54,7 @@ class IAvatarRepository(ABC):
group_id: str, group_id: str,
size: int = 100, size: int = 100,
) -> str | None: ) -> str | None:
"""获取群组头像 URL""" """Lấy URL ảnh đại diện nhóm."""
pass pass
@abstractmethod @abstractmethod
@@ -67,12 +64,12 @@ class IAvatarRepository(ABC):
size: int = 100, size: int = 100,
) -> dict[str, str | None]: ) -> 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 pass
def get_default_avatar_url(self) -> str: 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==" return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyaDE2di0yYzAtMi42Ni01LjMzLTQtOC00eiIvPjwvc3ZnPg=="
+29 -30
View File
@@ -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 from abc import ABC, abstractmethod
@@ -11,10 +9,10 @@ from ..value_objects.unified_message import UnifiedMessage
class IMessageRepository(ABC): 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 @abstractmethod
@@ -27,33 +25,33 @@ class IMessageRepository(ABC):
since_ts: int | None = None, since_ts: int | None = None,
) -> list[UnifiedMessage]: ) -> list[UnifiedMessage]:
""" """
获取群组消息历史 Lấy lịch sử tin nhắn của nhóm.
参数: Args:
group_id: 群组 ID group_id: ID của nhóm.
days: 获取最近 N 天的消息 days: Số ngày gần nhất cần lấy tin nhắn.
max_count: 最大消息数量 max_count: Số lượng tin nhắn tối đa.
before_id: 获取此 ID 之前的消息(用于分页) before_id: Chỉ lấy tin nhắn trước ID này, dùng để phân trang.
since_ts: 从指定时间戳开始拉取消息(Unix timestamp),优先级高于 days 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 pass
@abstractmethod @abstractmethod
def get_capabilities(self) -> PlatformCapabilities: def get_capabilities(self) -> PlatformCapabilities:
"""获取平台能力""" """Lấy mô tả năng lực của nền tảng."""
pass pass
@abstractmethod @abstractmethod
def get_platform_name(self) -> str: def get_platform_name(self) -> str:
"""获取平台名称""" """Lấy tên nền tảng."""
pass pass
class IMessageSender(ABC): class IMessageSender(ABC):
"""消息发送接口""" """Giao diện gửi tin nhắn."""
@abstractmethod @abstractmethod
async def send_text( async def send_text(
@@ -62,7 +60,7 @@ class IMessageSender(ABC):
text: str, text: str,
reply_to: str | None = None, reply_to: str | None = None,
) -> bool: ) -> bool:
"""发送文本消息""" """Gửi tin nhắn văn bản."""
pass pass
@abstractmethod @abstractmethod
@@ -72,7 +70,7 @@ class IMessageSender(ABC):
image_path: str, image_path: str,
caption: str = "", caption: str = "",
) -> bool: ) -> bool:
"""发送图片消息""" """Gửi tin nhắn hình ảnh."""
pass pass
@abstractmethod @abstractmethod
@@ -82,12 +80,13 @@ class IMessageSender(ABC):
nodes: list[dict], nodes: list[dict],
) -> bool: ) -> bool:
""" """
发送合并转发消息。 Gửi tin nhắn chuyển tiếp tổng hợp.
Args: Args:
group_id: 目标群组 ID group_id: ID nhóm đích.
nodes: 转发节点列表。每个节点通常包含 name, uin (或 user_id), content。 nodes: Danh sách nút chuyển tiếp. Mỗi nút thường chứa ``name``,
目前主要用于 OneBot 兼容性。 ``uin`` (hoặc ``user_id``) và ``content``. Hiện chủ yếu dùng
để tương thích với OneBot.
""" """
pass pass
@@ -98,26 +97,26 @@ class IMessageSender(ABC):
file_path: str, file_path: str,
filename: str | None = None, filename: str | None = None,
) -> bool: ) -> bool:
"""发送文件""" """Gửi tệp."""
pass pass
class IGroupInfoRepository(ABC): class IGroupInfoRepository(ABC):
"""群组信息仓储接口""" """Giao diện repository thông tin nhóm."""
@abstractmethod @abstractmethod
async def get_group_info(self, group_id: str) -> UnifiedGroup | None: async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
"""获取群组信息""" """Lấy thông tin nhóm."""
pass pass
@abstractmethod @abstractmethod
async def get_group_list(self) -> list[str]: 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 pass
@abstractmethod @abstractmethod
async def get_member_list(self, group_id: str) -> list[UnifiedMember]: async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
"""获取群组成员列表""" """Lấy danh sách thành viên nhóm."""
pass pass
@abstractmethod @abstractmethod
@@ -126,5 +125,5 @@ class IGroupInfoRepository(ABC):
group_id: str, group_id: str,
user_id: str, user_id: str,
) -> UnifiedMember | None: ) -> UnifiedMember | None:
"""获取指定成员信息""" """Lấy thông tin của thành viên được chỉ định."""
pass pass
+8 -9
View File
@@ -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 from abc import ABC, abstractmethod
@@ -8,9 +9,7 @@ from typing import Any
class IReportGenerator(ABC): class IReportGenerator(ABC):
""" """Giao diện trình tạo báo cáo."""
报告生成器接口
"""
@abstractmethod @abstractmethod
async def generate_image_report( async def generate_image_report(
@@ -24,7 +23,7 @@ class IReportGenerator(ABC):
hide_user_names: bool = False, hide_user_names: bool = False,
allow_alphanumeric_user_ids: bool = False, allow_alphanumeric_user_ids: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""生成图片报告""" """Tạo báo cáo hình ảnh."""
pass pass
@abstractmethod @abstractmethod
@@ -38,15 +37,15 @@ class IReportGenerator(ABC):
hide_user_names: bool = False, hide_user_names: bool = False,
allow_alphanumeric_user_ids: bool = False, allow_alphanumeric_user_ids: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""生成 HTML 报告""" """Tạo báo cáo HTML."""
pass pass
@abstractmethod @abstractmethod
def generate_text_report(self, analysis_result: dict) -> str: def generate_text_report(self, analysis_result: dict) -> str:
"""生成文本报告""" """Tạo báo cáo văn bản."""
pass pass
@abstractmethod @abstractmethod
async def close(self): async def close(self):
"""释放资源""" """Giải phóng tài nguyên."""
pass pass
@@ -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 from abc import ABC, abstractmethod
@@ -9,11 +10,11 @@ from ..models.data_models import ActivityVisualization
class IActivityVisualizer(ABC): class IActivityVisualizer(ABC):
"""活跃度可视化接口 - 领域层抽象""" """Giao diện trực quan hoá hoạt động của tầng domain."""
@abstractmethod @abstractmethod
def generate_activity_visualization( def generate_activity_visualization(
self, messages: list[dict] self, messages: list[dict]
) -> ActivityVisualization: ) -> ActivityVisualization:
"""从消息列表生成活跃度可视化数据""" """Tạo dữ liệu trực quan hoá hoạt động từ danh sách tin nhắn."""
pass pass
+3 -3
View File
@@ -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 from .incremental_merge_service import IncrementalMergeService
+18 -15
View File
@@ -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 from datetime import datetime
@@ -19,7 +21,7 @@ class UserActivityStats(TypedDict):
class AnalysisDomainService: class AnalysisDomainService:
"""分析领域服务 - 处理用户画像及行为分析""" """Xử lý phân tích chân dung và hành vi thành viên."""
def analyze_user_activity( def analyze_user_activity(
self, self,
@@ -27,9 +29,10 @@ class AnalysisDomainService:
bot_self_ids: list[str] | None = None, bot_self_ids: list[str] | None = None,
) -> dict[str, UserActivityStats]: ) -> 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] = {} user_stats: dict[str, UserActivityStats] = {}
@@ -38,7 +41,7 @@ class AnalysisDomainService:
for msg in messages: for msg in messages:
user_id = msg.sender_id user_id = msg.sender_id
# 跳过机器人自己的消息 # Bỏ qua tin nhắn của chính bot
if user_id in bot_ids: if user_id in bot_ids:
continue continue
@@ -56,12 +59,12 @@ class AnalysisDomainService:
stats["message_count"] += 1 stats["message_count"] += 1
stats["nickname"] = msg.sender_card or msg.sender_name 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) msg_time = datetime.fromtimestamp(msg.timestamp)
hour = msg_time.hour hour = msg_time.hour
stats["hours"][hour] = stats["hours"].get(hour, 0) + 1 stats["hours"][hour] = stats["hours"].get(hour, 0) + 1
# 统计内容 # Thống kê nội dung
for content in msg.contents: for content in msg.contents:
if content.type == MessageContentType.TEXT: if content.type == MessageContentType.TEXT:
stats["char_count"] += len(content.text or "") stats["char_count"] += len(content.text or "")
@@ -70,7 +73,7 @@ class AnalysisDomainService:
stats["emoji_count"] += 1 stats["emoji_count"] += 1
elif content.type == MessageContentType.IMAGE: 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): if self._is_emoji_like_image(content.raw_data):
stats["emoji_count"] += 1 stats["emoji_count"] += 1
@@ -81,7 +84,7 @@ class AnalysisDomainService:
@staticmethod @staticmethod
def _is_emoji_like_image(raw_data: object) -> bool: 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): if isinstance(raw_data, dict):
sub_type = raw_data.get("sub_type") sub_type = raw_data.get("sub_type")
if sub_type is not None: if sub_type is not None:
@@ -98,7 +101,7 @@ class AnalysisDomainService:
def get_top_users( def get_top_users(
self, user_activity: dict[str, UserActivityStats], limit: int = 10 self, user_activity: dict[str, UserActivityStats], limit: int = 10
) -> list[dict]: ) -> list[dict]:
"""获取最活跃的用户列表""" """Lấy danh sách thành viên hoạt động tích cực nhất."""
users = [] users = []
for user_id, stats in user_activity.items(): for user_id, stats in user_activity.items():
users.append( 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) users.sort(key=lambda x: x["message_count"], reverse=True)
return users[:limit] return users[:limit]
def get_user_activity_pattern( def get_user_activity_pattern(
self, user_activity: dict[str, UserActivityStats], user_id: str self, user_activity: dict[str, UserActivityStats], user_id: str
) -> dict: ) -> 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: if user_id not in user_activity:
return {} return {}
stats = user_activity[user_id] stats = user_activity[user_id]
hours = stats["hours"] 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 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_messages = sum(hours[h] for h in range(0, 6))
night_ratio = ( night_ratio = (
night_messages / stats["message_count"] if stats["message_count"] > 0 else 0 night_messages / stats["message_count"] if stats["message_count"] > 0 else 0
+106 -99
View File
@@ -1,15 +1,15 @@
""" """
增量合并领域服务 Dịch vụ domain gộp dữ liệu phân tích gia tăng.
负责将 IncrementalBatch 列表合并为 IncrementalState Phụ trách gộp danh sách ``IncrementalBatch`` thành ``IncrementalState``
以及将 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.
核心职责: Trách nhiệm chính:
- merge_batches: 将多个 IncrementalBatch 合并为一个 IncrementalState(滑动窗口聚合) - ``merge_batches``: gộp nhiều batch thành một trạng thái trong cửa sổ trượt
- IncrementalState → GroupStatistics(含 ActivityVisualization、EmojiStatistics - ``IncrementalState````GroupStatistics``
- IncrementalState → list[SummaryTopic] - ``IncrementalState````list[SummaryTopic]``
- IncrementalState → list[GoldenQuote] - ``IncrementalState````list[GoldenQuote]``
""" """
import time import time
@@ -30,10 +30,11 @@ from ...utils.logger import logger
class IncrementalMergeService: 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( def merge_batches(
@@ -43,18 +44,18 @@ class IncrementalMergeService:
window_end: float, window_end: float,
) -> IncrementalState: ) -> 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: Args:
batches: 时间窗口内的批次列表(按时间升序) batches: Danh sách batch trong cửa sổ, tăng dần theo thời gian.
window_start: 窗口起始时间戳(epoch window_start: Epoch timestamp bắt đầu cửa sổ.
window_end: 窗口结束时间戳(epoch window_end: Epoch timestamp kết thúc cửa sổ.
Returns: Returns:
IncrementalState: 合并后的聚合视图 IncrementalState: View tổng hợp sau khi gộp.
""" """
state = IncrementalState( state = IncrementalState(
group_id=batches[0].group_id if batches else "", group_id=batches[0].group_id if batches else "",
@@ -66,25 +67,25 @@ class IncrementalMergeService:
) )
for batch in batches: for batch in batches:
# 累加消息和字符计数 # Cộng dồn số tin nhắn và ký tự
state.total_message_count += batch.messages_count state.total_message_count += batch.messages_count
state.total_character_count += batch.characters_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(): for hour_key, count in batch.hourly_msg_counts.items():
hour_str = str(hour_key) hour_str = str(hour_key)
state.hourly_message_counts[hour_str] = ( state.hourly_message_counts[hour_str] = (
state.hourly_message_counts.get(hour_str, 0) + count 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(): for hour_key, count in batch.hourly_char_counts.items():
hour_str = str(hour_key) hour_str = str(hour_key)
state.hourly_character_counts[hour_str] = ( state.hourly_character_counts[hour_str] = (
state.hourly_character_counts.get(hour_str, 0) + count 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(): for raw_user_id, stats in batch.user_stats.items():
user_id = str(raw_user_id) user_id = str(raw_user_id)
if user_id not in state.user_activities: if user_id not in state.user_activities:
@@ -103,43 +104,43 @@ class IncrementalMergeService:
existing["emoji_count"] += stats.get("emoji_count", 0) existing["emoji_count"] += stats.get("emoji_count", 0)
existing["reply_count"] += stats.get("reply_count", 0) existing["reply_count"] += stats.get("reply_count", 0)
# 合并每小时统计 # Gộp thống kê theo giờ.
# 兼容旧版本 (active_hours list) 和新版本 (hours dict) # Tương thích phiên bản cũ (active_hours list) và mới (hours dict).
batch_hours = stats.get("hours", {}) batch_hours = stats.get("hours", {})
if isinstance(batch_hours, dict): if isinstance(batch_hours, dict):
# 现代 schema: hours dict {hour: count} # Schema mới: hours dict {hour: count}
for h_str, h_count in batch_hours.items(): for h_str, h_count in batch_hours.items():
h_int = int(h_str) h_int = int(h_str)
existing["hours"][h_int] = ( existing["hours"][h_int] = (
existing["hours"].get(h_int, 0) + h_count existing["hours"].get(h_int, 0) + h_count
) )
else: else:
# 兼容旧 schema: 只有 active_hours (list) # Schema cũ: chỉ có active_hours (list)
active_hours = stats.get("active_hours", []) active_hours = stats.get("active_hours", [])
for h in active_hours: for h in active_hours:
h_int = int(h) h_int = int(h)
existing["hours"][h_int] = existing["hours"].get(h_int, 0) + 1 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) batch_last = stats.get("last_message_time", 0)
if batch_last > existing.get("last_message_time", 0): if batch_last > existing.get("last_message_time", 0):
existing["last_message_time"] = batch_last 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", "")) nickname = stats.get("nickname", stats.get("name", ""))
if nickname and str(nickname).strip(): if nickname and str(nickname).strip():
existing["nickname"] = nickname 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(): for emoji_key, count in batch.emoji_stats.items():
current_val = state.emoji_counts.get(emoji_key, 0) current_val = state.emoji_counts.get(emoji_key, 0)
if isinstance(count, dict): 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): if not isinstance(current_val, dict):
current_val = {} current_val = {}
for sub_key, sub_count in count.items(): 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): if isinstance(current_val, dict):
current_val[sub_key] = ( current_val[sub_key] = (
current_val.get(sub_key, 0) + sub_count current_val.get(sub_key, 0) + sub_count
@@ -147,80 +148,81 @@ class IncrementalMergeService:
state.emoji_counts[emoji_key] = current_val state.emoji_counts[emoji_key] = current_val
else: else:
# 如果是数值,直接累加 # Nếu là số thì cộng trực tiếp
if isinstance(current_val, dict): 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 continue
state.emoji_counts[emoji_key] = current_val + count state.emoji_counts[emoji_key] = current_val + count
# 合并话题(去重) # Gộp chủ đề và loại trùng
for topic in batch.topics: for topic in batch.topics:
if not IncrementalState.is_duplicate_topic(topic, state.topics): if not IncrementalState.is_duplicate_topic(topic, state.topics):
state.topics.append(topic) state.topics.append(topic)
# 合并金句(去重) # Gộp trích dẫn và loại trùng
for quote in batch.golden_quotes: for quote in batch.golden_quotes:
if not IncrementalState.is_duplicate_quote(quote, state.golden_quotes): if not IncrementalState.is_duplicate_quote(quote, state.golden_quotes):
state.golden_quotes.append(quote) 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"): for token_key in ("prompt_tokens", "completion_tokens", "total_tokens"):
state.total_token_usage[token_key] = state.total_token_usage.get( state.total_token_usage[token_key] = state.total_token_usage.get(
token_key, 0 token_key, 0
) + batch.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) 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: if batch.chat_quality_review:
state.all_quality_reviews.append(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: if batch.last_message_timestamp > state.last_analyzed_message_timestamp:
state.last_analyzed_message_timestamp = batch.last_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: if batch.chat_quality_review:
state.chat_quality_review = batch.chat_quality_review state.chat_quality_review = batch.chat_quality_review
logger.info( logger.info(
f"合并批次完成: 群={state.group_id}, " f"Đã gộp batch: nhóm={state.group_id}, "
f"窗口={state.get_window_date_str()}, " f"cửa sổ={state.get_window_date_str()}, "
f"批次数={len(batches)}, " f"số batch={len(batches)}, "
f"总消息={state.total_message_count}, " f"tổng tin nhắn={state.total_message_count}, "
f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}" f"chủ đề={len(state.topics)}, trích dẫn={len(state.golden_quotes)}"
) )
return state return state
def build_final_statistics(self, state: IncrementalState) -> GroupStatistics: 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 Ánh xạ dữ liệu tích luỹ trong ``IncrementalState`` sang
包含完整的 24 小时活跃度分布、表情统计和 token 消耗。 ``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: Args:
state: merge_batches 合并生成的增量分析状态 state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
Returns: 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 = {} hourly_activity = {}
for hour in range(24): for hour in range(24):
hour_key = str(hour) hour_key = str(hour)
hourly_activity[hour] = state.hourly_message_counts.get(hour_key, 0) 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) 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) 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( activity_visualization = ActivityVisualization(
hourly_activity=hourly_activity, hourly_activity=hourly_activity,
daily_activity={state.get_window_date_str(): state.total_message_count}, daily_activity={state.get_window_date_str(): state.total_message_count},
@@ -229,27 +231,27 @@ class IncrementalMergeService:
activity_heatmap_data={}, activity_heatmap_data={},
) )
# 构建表情统计 # Xây dựng thống kê biểu cảm
emoji_statistics = self._build_emoji_statistics(state) emoji_statistics = self._build_emoji_statistics(state)
# 构建 token 消耗 # Xây dựng thống kê sử dụng token
token_usage = TokenUsage( token_usage = TokenUsage(
prompt_tokens=state.total_token_usage.get("prompt_tokens", 0), prompt_tokens=state.total_token_usage.get("prompt_tokens", 0),
completion_tokens=state.total_token_usage.get("completion_tokens", 0), completion_tokens=state.total_token_usage.get("completion_tokens", 0),
total_tokens=state.total_token_usage.get("total_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() 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 chat_quality_review = None
if state.chat_quality_review: if state.chat_quality_review:
review_dict = state.chat_quality_review review_dict = state.chat_quality_review
dimensions_dict = review_dict.get("dimensions", []) dimensions_dict = review_dict.get("dimensions", [])
dimensions = [ dimensions = [
QualityDimension( QualityDimension(
name=d.get("name", "未知"), name=d.get("name", "Không xác định"),
percentage=float(d.get("percentage", 0)), percentage=float(d.get("percentage", 0)),
comment=d.get("comment", ""), comment=d.get("comment", ""),
color=d.get("color", "#607d8b"), color=d.get("color", "#607d8b"),
@@ -257,10 +259,12 @@ class IncrementalMergeService:
for d in dimensions_dict for d in dimensions_dict
] ]
chat_quality_review = QualityReview( chat_quality_review = QualityReview(
title=review_dict.get("title", "聊天质量锐评"), title=review_dict.get("title", "Đánh giá chất lượng trò chuyện"),
subtitle=review_dict.get("subtitle", "今天的群里发生了什么?"), subtitle=review_dict.get("subtitle", "Hôm nay nhóm đã có chuyện gì?"),
dimensions=dimensions, 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( statistics = GroupStatistics(
@@ -268,7 +272,7 @@ class IncrementalMergeService:
total_characters=state.total_character_count, total_characters=state.total_character_count,
participant_count=len(state.all_participant_ids), participant_count=len(state.all_participant_ids),
most_active_period=most_active_period, 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_count=emoji_statistics.total_emoji_count,
emoji_statistics=emoji_statistics, emoji_statistics=emoji_statistics,
activity_visualization=activity_visualization, activity_visualization=activity_visualization,
@@ -277,51 +281,52 @@ class IncrementalMergeService:
) )
logger.debug( logger.debug(
f"从增量状态构建统计: " f"Đã xây dựng thống kê từ trạng thái gia tăng: "
f"消息数={state.total_message_count}, " f"tin nhắn={state.total_message_count}, "
f"参与人数={len(state.all_participant_ids)}, " f"người tham gia={len(state.all_participant_ids)}, "
f"话题数={len(state.topics)}, " f"chủ đề={len(state.topics)}, "
f"金句数={len(state.golden_quotes)}" f"trích dẫn={len(state.golden_quotes)}"
) )
return statistics return statistics
def build_topics_for_report(self, state: IncrementalState) -> list[SummaryTopic]: 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: Args:
state: merge_batches 合并生成的增量分析状态 state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
Returns: Returns:
list[SummaryTopic]: 话题列表,格式与传统分析结果一致 list[SummaryTopic]: Danh sách chủ đề cùng định dạng với kết quả truyền thống.
""" """
topics = [] topics = []
for topic_dict in state.topics: for topic_dict in state.topics:
topic = SummaryTopic( topic = SummaryTopic(
topic=topic_dict.get("topic", "未知话题"), topic=topic_dict.get("topic", "Chủ đề không xác định"),
contributors=topic_dict.get("contributors", []), contributors=topic_dict.get("contributors", []),
detail=topic_dict.get("detail", ""), detail=topic_dict.get("detail", ""),
contributor_ids=topic_dict.get("contributor_ids", []), contributor_ids=topic_dict.get("contributor_ids", []),
) )
topics.append(topic) 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 return topics
def build_quotes_for_report(self, state: IncrementalState) -> list[GoldenQuote]: 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: Args:
state: merge_batches 合并生成的增量分析状态 state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
Returns: 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 = [] quotes = []
for quote_dict in state.golden_quotes: for quote_dict in state.golden_quotes:
@@ -333,7 +338,7 @@ class IncrementalMergeService:
) )
quotes.append(quote) 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 return quotes
def build_analysis_result( def build_analysis_result(
@@ -342,23 +347,24 @@ class IncrementalMergeService:
user_titles: list | None = None, user_titles: list | None = None,
) -> dict: ) -> 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() Định dạng dict giống hoàn toàn với ``analysis_result`` do
返回的 analysis_result 完全一致,可直接传入 ReportDispatcher。 ``AnalysisApplicationService.execute_daily_analysis()`` trả về và có
thể truyền trực tiếp cho ``ReportDispatcher``.
Args: Args:
state: merge_batches 合并生成的增量分析状态 state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
user_titles: 用户称号列表(由最终报告时 LLM 分析生成) user_titles: Danh sách danh hiệu do LLM tạo khi lập báo cáo cuối.
Returns: Returns:
dict: 包含 statisticstopicsuser_titlesuser_analysis 的结果字典 Dict kết quả gồm statistics, topics, user_titlesuser_analysis.
""" """
statistics = self.build_final_statistics(state) statistics = self.build_final_statistics(state)
topics = self.build_topics_for_report(state) topics = self.build_topics_for_report(state)
golden_quotes = self.build_quotes_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 statistics.golden_quotes = golden_quotes
analysis_result = { analysis_result = {
@@ -370,31 +376,32 @@ class IncrementalMergeService:
} }
logger.info( logger.info(
f"从增量状态构建完整分析结果: " 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"={state.group_id}, 窗口={state.get_window_date_str()}, " f"nhóm={state.group_id}, cửa sổ={state.get_window_date_str()}, "
f"消息={state.total_message_count}, " f"tin nhắn={state.total_message_count}, "
f"话题={len(topics)}, " f"chủ đề={len(topics)}, "
f"金句={len(golden_quotes)}, " f"trích dẫn={len(golden_quotes)}, "
f"批次={state.total_analysis_count}" f"batch={state.total_analysis_count}"
) )
return analysis_result return analysis_result
def _build_emoji_statistics(self, state: IncrementalState) -> EmojiStatistics: 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: Args:
state: 增量分析状态 state: Trạng thái phân tích gia tăng.
Returns: Returns:
EmojiStatistics: 表情统计实例 EmojiStatistics: Instance thống kê biểu cảm.
""" """
emoji_counts = state.emoji_counts 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") face_details = emoji_counts.get("face_details")
if not isinstance(face_details, dict): if not isinstance(face_details, dict):
face_details = {} face_details = {}
+22 -20
View File
@@ -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 import re
@@ -12,14 +14,14 @@ from ..value_objects.unified_message import (
UnifiedMessage, UnifiedMessage,
) )
# Discord 自定义表情正则 <:name:id> <a:name:id> # Regex biểu cảm tuỳ chỉnh Discord: <:name:id> hoặc <a:name:id>
_DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"<a?:.+?:\d+>") _DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"<a?:.+?:\d+>")
# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息 # 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+)?/") _COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/")
class MessageCleanerService: class MessageCleanerService:
"""消息清理服务""" """Dịch vụ làm sạch tin nhắn."""
def clean_messages( def clean_messages(
self, self,
@@ -28,25 +30,25 @@ class MessageCleanerService:
filter_commands: bool = True, filter_commands: bool = True,
) -> list[UnifiedMessage]: ) -> list[UnifiedMessage]:
""" """
清理并过滤消息列表。 Làm sạch và lọc danh sách tin nhắn.
Args: Args:
messages: 原始统一格式消息列表 messages: Danh sách tin nhắn thống nhất ban đầu.
bot_self_ids: 机器人自身的 ID 列表 bot_self_ids: Danh sách ID của bot.
filter_commands: 是否过滤指令消息 filter_commands: Có lọc tin nhắn lệnh hay không.
Returns: Returns:
清理后的消息列表 Danh sách tin nhắn sau khi làm sạch.
""" """
bot_ids = set(bot_self_ids or []) bot_ids = set(bot_self_ids or [])
cleaned_list = [] cleaned_list = []
for msg in messages: for msg in messages:
# 1. 过滤机器人发送的消息 # 1. Lọc tin nhắn do bot gửi
if msg.sender_id in bot_ids: if msg.sender_id in bot_ids:
continue 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 is_command = False
first_text = msg.text_content first_text = msg.text_content
if filter_commands and first_text and _COMMAND_PATTERN.match(first_text): if filter_commands and first_text and _COMMAND_PATTERN.match(first_text):
@@ -55,7 +57,7 @@ class MessageCleanerService:
if is_command: if is_command:
continue continue
# 3. 清理消息内容中的技术性噪音 # 3. Làm sạch nhiễu kỹ thuật trong nội dung tin nhắn
cleaned_contents = [] cleaned_contents = []
has_meaningful_content = False has_meaningful_content = False
@@ -63,13 +65,13 @@ class MessageCleanerService:
if content.type == MessageContentType.TEXT: if content.type == MessageContentType.TEXT:
text = content.text or "" text = content.text or ""
# 移除 Discord 原始表情代码 # Xoá mã biểu cảm gốc của Discord
text = _DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text) 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) text = re.sub(r"<@\d+>", "", text)
# 清理多余空格 # Xoá khoảng trắng thừa
text = text.strip() text = text.strip()
if text: if text:
@@ -78,14 +80,14 @@ class MessageCleanerService:
) )
has_meaningful_content = True has_meaningful_content = True
else: 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) cleaned_contents.append(content)
if content.type != MessageContentType.REPLY: if content.type != MessageContentType.REPLY:
has_meaningful_content = True 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: if has_meaningful_content:
# 重新合成 text_content 用于 LLM 分析 # Ghép lại text_content để phân tích bằng LLM
new_text_content = "".join( new_text_content = "".join(
[ [
c.text c.text
@@ -94,7 +96,7 @@ class MessageCleanerService:
] ]
).strip() ).strip()
# 使用 replace 创建新实例(Frozen dataclass 必须如此) # Dùng replace để tạo instance mới (dataclass frozen yêu cầu như vậy)
new_msg = replace( new_msg = replace(
msg, contents=tuple(cleaned_contents), text_content=new_text_content msg, contents=tuple(cleaned_contents), text_content=new_text_content
) )
+21 -18
View File
@@ -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 from collections import defaultdict
@@ -13,7 +15,7 @@ from ..value_objects.unified_message import MessageContentType, UnifiedMessage
class StatisticsService: 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): def __init__(self, activity_visualizer: IActivityVisualizer | None = None):
if activity_visualizer is None: if activity_visualizer is None:
@@ -26,9 +28,10 @@ class StatisticsService:
self, messages: list[UnifiedMessage] self, messages: list[UnifiedMessage]
) -> GroupStatistics: ) -> 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 total_chars = 0
participants = set() participants = set()
@@ -38,35 +41,35 @@ class StatisticsService:
for msg in messages: for msg in messages:
participants.add(msg.sender_id) participants.add(msg.sender_id)
# 统计时间分布 # Thống kê phân bố theo thời gian
msg_time = datetime.fromtimestamp(msg.timestamp) msg_time = datetime.fromtimestamp(msg.timestamp)
hour_counts[msg_time.hour] += 1 hour_counts[msg_time.hour] += 1
# 处理消息内容 # Xử lý nội dung tin nhắn
for content in msg.contents: for content in msg.contents:
if content.type == MessageContentType.TEXT: if content.type == MessageContentType.TEXT:
total_chars += len(content.text or "") total_chars += len(content.text or "")
elif content.type == MessageContentType.EMOJI: elif content.type == MessageContentType.EMOJI:
emoji_statistics.face_count += 1 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" face_id = content.emoji_id or "unknown"
emoji_statistics.face_details[f"emoji_{face_id}"] = ( emoji_statistics.face_details[f"emoji_{face_id}"] = (
emoji_statistics.face_details.get(f"emoji_{face_id}", 0) + 1 emoji_statistics.face_details.get(f"emoji_{face_id}", 0) + 1
) )
elif content.type == MessageContentType.IMAGE: elif content.type == MessageContentType.IMAGE:
# 兼容识别“图片形态的表情”: # Nhận diện tương thích biểu cảm ở dạng hình ảnh:
# 1) 优先使用 onebot sub_type=1 信号 # 1) Ưu tiên tín hiệu sub_type=1 của OneBot
# 2) 若无该字段,再回退到历史 summary 文本匹配 # 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): if self._is_emoji_like_image(content.raw_data):
emoji_statistics.mface_count += 1 emoji_statistics.mface_count += 1
elif content.type in ( elif content.type in (
MessageContentType.VOICE, MessageContentType.VOICE,
MessageContentType.VIDEO, MessageContentType.VIDEO,
): ):
# 其他非文本类型统计(可选) # Có thể bổ sung thống kê các loại phi văn bản khác
pass pass
# 找出最活跃时段 # Xác định khung giờ hoạt động tích cực nhất
most_active_hour = ( most_active_hour = (
max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0 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" f"{most_active_hour:02d}:00-{(most_active_hour + 1) % 24:02d}:00"
) )
# 生成活跃度可视化数据 # Tạo dữ liệu trực quan hoá hoạt động
# 注意:ActivityVisualizer 可能需要迁移以支持 UnifiedMessage # ActivityVisualizer có thể cần được chuyển đổi để hỗ trợ UnifiedMessage.
# 目前先转换回 dict 以保持兼容性,或者之后重构它 # 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) raw_msgs = self._convert_to_legacy_dict(messages)
activity_visualization = ( activity_visualization = (
self.activity_visualizer.generate_activity_visualization(raw_msgs) self.activity_visualizer.generate_activity_visualization(raw_msgs)
@@ -96,7 +99,7 @@ class StatisticsService:
@staticmethod @staticmethod
def _is_emoji_like_image(raw_data: object) -> bool: 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): if isinstance(raw_data, dict):
sub_type = raw_data.get("sub_type") sub_type = raw_data.get("sub_type")
if sub_type is not None: if sub_type is not None:
@@ -111,7 +114,7 @@ class StatisticsService:
return "动画表情" in text or "表情" in text return "动画表情" in text or "表情" in text
def _convert_to_legacy_dict(self, messages: list[UnifiedMessage]) -> list[dict]: 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 = [] legacy_list = []
for msg in messages: for msg in messages:
legacy_list.append( legacy_list.append(
+2 -2
View File
@@ -1,10 +1,10 @@
# 值对象 # Các value object
from .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities from .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities
from .unified_group import UnifiedGroup, UnifiedMember from .unified_group import UnifiedGroup, UnifiedMember
from .unified_message import MessageContent, MessageContentType, UnifiedMessage from .unified_message import MessageContent, MessageContentType, UnifiedMessage
__all__ = [ __all__ = [
# 核心平台抽象 # Lớp trừu tượng nền tảng cốt lõi
"UnifiedMessage", "UnifiedMessage",
"MessageContent", "MessageContent",
"MessageContentType", "MessageContentType",
@@ -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ợ. 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_name: str
platform_version: str = "unknown" platform_version: str = "unknown"
# 消息获取能力 # Khả năng truy xuất tin nhắn
supports_message_history: bool = False supports_message_history: bool = False
max_message_history_days: int = 0 max_message_history_days: int = 0
max_message_count: int = 0 max_message_count: int = 0
supports_message_search: bool = False supports_message_search: bool = False
# 群组信息能力 # Khả năng truy xuất thông tin nhóm
supports_group_list: bool = False supports_group_list: bool = False
supports_group_info: bool = False supports_group_info: bool = False
supports_member_list: bool = False supports_member_list: bool = False
supports_member_info: bool = False supports_member_info: bool = False
# 消息发送能力 # Khả năng gửi tin nhắn
supports_text_message: bool = True supports_text_message: bool = True
supports_image_message: bool = False supports_image_message: bool = False
supports_file_message: bool = False supports_file_message: bool = False
@@ -68,12 +68,12 @@ class PlatformCapabilities:
max_text_length: int = 4096 max_text_length: int = 4096
max_image_size_mb: float = 10.0 max_image_size_mb: float = 10.0
# 特殊能力 # Các khả năng đặc biệt
supports_at_all: bool = False supports_at_all: bool = False
supports_recall: bool = False supports_recall: bool = False
supports_edit: bool = False supports_edit: bool = False
# 头像能力 # Khả năng xử lý ảnh đại diện
supports_user_avatar: bool = True supports_user_avatar: bool = True
supports_group_avatar: bool = False supports_group_avatar: bool = False
avatar_needs_api_call: bool = False avatar_needs_api_call: bool = False
+51 -50
View File
@@ -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 from dataclasses import dataclass, field
@@ -12,9 +12,9 @@ from typing import Any
class MessageContentType(Enum): 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" TEXT = "text"
@@ -33,19 +33,19 @@ class MessageContentType(Enum):
@dataclass(frozen=True) @dataclass(frozen=True)
class MessageContent: 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: Attributes:
type (MessageContentType): 内容类型 type: Loại nội dung.
text (str): 文本内容(仅当类型为 TEXT 或包含文本描述时) text: Nội dung văn bản khi loại là TEXT hoặc có mô tả văn bản.
url (str): 资源链接(图片、视频、文件等) url: Liên kết tài nguyên như hình ảnh, video hoặc tệp.
emoji_id (str): 表情 ID emoji_id: ID biểu cảm.
emoji_name (str): 表情名称 emoji_name: Tên biểu cảm.
at_user_id (str): 被 @ 的用户 ID at_user_id: ID thành viên được nhắc đến.
raw_data (Any): 平台原始数据,用于扩展 raw_data: Dữ liệu nền tảng gốc dùng cho mục đích mở rộng.
""" """
type: MessageContentType type: MessageContentType
@@ -57,17 +57,17 @@ class MessageContent:
raw_data: Any = None raw_data: Any = None
def is_text(self) -> bool: 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 return self.type == MessageContentType.TEXT
def is_emoji(self) -> bool: 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 return self.type == MessageContentType.EMOJI
@property @property
def target_id(self) -> str: 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. Alias for at_user_id.
""" """
@@ -77,101 +77,102 @@ class MessageContent:
@dataclass(frozen=True) @dataclass(frozen=True)
class UnifiedMessage: 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: Attributes:
message_id (str): 消息唯一标识符 message_id: Mã định danh duy nhất của tin nhắn.
sender_id (str): 发送者唯一 ID sender_id: ID duy nhất của người gửi.
sender_name (str): 发送者昵称 sender_name: Biệt danh người gửi.
group_id (str): 群组/会话唯一 ID group_id: ID duy nhất của nhóm hoặc cuộc trò chuyện.
text_content (str): 经过清洗后的纯文本内容,主要用于 LLM 分析 text_content: Nội dung văn bản thuần đã làm sạch, chủ yếu dùng cho LLM.
contents (tuple[MessageContent, ...]): 结构化消息链 contents: Chuỗi nội dung tin nhắn có cấu trúc.
timestamp (int): Unix 时间戳(秒) timestamp: Unix timestamp tính bằng giây.
platform (str): 来源平台名称(如 onebot, discord 等) platform: Tên nền tảng nguồn, ví dụ OneBot hoặc Discord.
reply_to_id (str, optional): 被回复的消息 ID reply_to_id: ID tin nhắn được trả lời.
sender_card (str, optional): 平台特定的群名片或特别备注 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 message_id: str
sender_id: str sender_id: str
sender_name: str sender_name: str
group_id: str group_id: str
# 消息内容 # Nội dung tin nhắn
text_content: str text_content: str
contents: tuple[MessageContent, ...] = field(default_factory=tuple) contents: tuple[MessageContent, ...] = field(default_factory=tuple)
# 时间信息 # Thông tin thời gian
timestamp: int = 0 timestamp: int = 0
# 平台信息 # Thông tin nền tảng
platform: str = "unknown" platform: str = "unknown"
# 可选信息 # Thông tin tuỳ chọn
reply_to_id: str | None = None reply_to_id: str | None = None
sender_card: str | None = None sender_card: str | None = None
# 分析辅助方法 # Phương thức hỗ trợ phân tích
def has_text(self) -> bool: 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: Returns:
bool: 包含有效文本则返回 True ``True`` nếu tin nhắn chứa văn bản hợp lệ.
""" """
return bool(self.text_content.strip()) return bool(self.text_content.strip())
def get_display_name(self) -> str: def get_display_name(self) -> str:
""" """
获取用户显示名称。 Lấy tên hiển thị của thành viên.
优先级:群名片 > 昵称 > 用户 ID。
Thứ tự ưu tiên: tên trong nhóm, biệt danh, ID thành viên.
Returns: Returns:
str: 格式化后的显示名称 Tên hiển thị đã định dạng.
""" """
return self.sender_card or self.sender_name or self.sender_id return self.sender_card or self.sender_name or self.sender_id
def get_emoji_count(self) -> int: def get_emoji_count(self) -> int:
""" """
计算消息链中包含的表情数量。 Tính số biểu cảm có trong chuỗi tin nhắn.
Returns: Returns:
int: 表情总数 Tổng số biểu cảm.
""" """
return sum(1 for c in self.contents if c.is_emoji()) return sum(1 for c in self.contents if c.is_emoji())
def get_text_length(self) -> int: def get_text_length(self) -> int:
""" """
获取文本内容的字符长度。 Lấy độ dài ký tự của nội dung văn bản.
Returns: Returns:
int: 字符数 Số ký tự.
""" """
return len(self.text_content) return len(self.text_content)
def get_datetime(self) -> datetime: def get_datetime(self) -> datetime:
""" """
将 Unix 时间戳转换为 datetime 对象。 Chuyển Unix timestamp thành đối tượng ``datetime``.
Returns: Returns:
datetime: 本地化后的时间对象 Đối tượng thời gian theo múi giờ cục bộ.
""" """
return datetime.fromtimestamp(self.timestamp) return datetime.fromtimestamp(self.timestamp)
def to_analysis_format(self) -> str: def to_analysis_format(self) -> str:
""" """
转换为供 LLM 消费的分析格式。 Chuyển sang định dạng phân tích dành cho LLM.
Returns: Returns:
str: 格式如 "[用户名]: 消息内容" 的字符串 Chuỗi có dạng ``[tên thành viên]: nội dung tin nhắn``.
""" """
name = self.get_display_name() name = self.get_display_name()
return f"[{name}]: {self.text_content}" return f"[{name}]: {self.text_content}"
# 类型别名 # Bí danh kiểu dữ liệu
MessageList = list[UnifiedMessage] MessageList = list[UnifiedMessage]
+1 -1
View File
@@ -1 +1 @@
# 基础设施层 # Tầng infrastructure
+1 -4
View File
@@ -1,7 +1,4 @@
""" """Module phân tích, chứa chức năng phân tích bằng LLM."""
分析模块
包含LLM分析功能
"""
from .llm_analyzer import LLMAnalyzer from .llm_analyzer import LLMAnalyzer
@@ -1,7 +1,4 @@
""" """Module analyzer chứa các triển khai phân tích bằng LLM."""
分析器模块
包含各种LLM分析功能的实现
"""
from .base_analyzer import BaseAnalyzer from .base_analyzer import BaseAnalyzer
from .golden_quote_analyzer import GoldenQuoteAnalyzer from .golden_quote_analyzer import GoldenQuoteAnalyzer
@@ -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 abc import ABC, abstractmethod
from collections.abc import Sized from collections.abc import Sized
@@ -24,91 +21,89 @@ TInputData = TypeVar("TInputData")
class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]): class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
""" """Lớp analyzer trừu tượng với giao diện và quy trình dùng chung."""
基础分析器抽象类
定义所有分析器的通用接口 and 流程
"""
def __init__(self, context, config_manager): def __init__(self, context, config_manager):
""" """
初始化基础分析器 Khởi tạo analyzer cơ sở.
Args: Args:
context: AstrBot上下文对象 context: Context AstrBot.
config_manager: 配置管理器 config_manager: Trình quản lý cấu hình.
""" """
self.context = context self.context = context
self.config_manager = config_manager 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 self._incremental_max_count: int | None = None
def get_provider_id_key(self) -> str | None: def get_provider_id_key(self) -> str | None:
""" """
获取 Provider ID 配置键名 Lấy tên key cấu hình Provider ID.
子类可重写以指定特定的 provider,默认返回 None(使用主 LLM Provider
Lớp con có thể ghi đè để chỉ định provider riêng; mặc định dùng provider LLM chính.
Returns: Returns:
Provider ID 配置键名,如 'topic_provider_id' Tên key cấu hình như ``topic_provider_id``.
""" """
return None return None
@abstractmethod @abstractmethod
def get_data_type(self) -> str: def get_data_type(self) -> str:
""" """
获取数据类型标识 Lấy định danh loại dữ liệu.
Returns: Returns:
数据类型字符串 Chuỗi loại dữ liệu.
""" """
pass pass
@abstractmethod @abstractmethod
def get_max_count(self) -> int: def get_max_count(self) -> int:
""" """
获取最大提取数量 Lấy số lượng trích xuất tối đa.
Returns: Returns:
最大数量 Số lượng tối đa.
""" """
pass pass
@abstractmethod @abstractmethod
def build_prompt(self, data: TInputData) -> str: def build_prompt(self, data: TInputData) -> str:
""" """
构建LLM提示词 Xây dựng prompt LLM.
Args: Args:
data: 输入数据 data: Dữ liệu đầu vào.
Returns: Returns:
提示词字符串 Chuỗi prompt.
""" """
pass pass
@abstractmethod @abstractmethod
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
""" """
使用正则表达式提取数据 Trích xuất dữ liệu bằng regex.
Args: Args:
result_text: LLM响应文本 result_text: Văn bản phản hồi LLM.
max_count: 最大提取数量 max_count: Số lượng trích xuất tối đa.
Returns: Returns:
提取到的数据列表 Danh sách dữ liệu đã trích xuất.
""" """
pass pass
@abstractmethod @abstractmethod
def create_data_objects(self, data_list: list[dict]) -> list[TDataObject]: def create_data_objects(self, data_list: list[dict]) -> list[TDataObject]:
""" """
创建数据对象列表 Tạo danh sách đối tượng dữ liệu.
Args: Args:
data_list: 原始数据列表 data_list: Danh sách dữ liệu gốc.
Returns: Returns:
数据对象列表 Danh sách đối tượng dữ liệu.
""" """
pass pass
@@ -126,7 +121,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
def get_schema_retry_max_attempts(self) -> int: 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 return 2
@@ -134,8 +129,9 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
self, base_temperature: float | None self, base_temperature: float | None
) -> tuple[float, ...]: ) -> 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()) attempts = max(0, self.get_schema_retry_max_attempts())
if attempts == 0: if attempts == 0:
@@ -161,7 +157,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
provider_id: str | None = None, provider_id: str | None = None,
) -> float | None: ) -> float | None:
""" """
尝试从当前将要调用的 Provider 配置中解析基础 temperature。 Thử lấy temperature cơ sở từ cấu hình provider sắp gọi.
""" """
pid = provider_id pid = provider_id
if not pid: if not pid:
@@ -209,8 +205,9 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
self, result_text: str self, result_text: str
) -> tuple[bool, list[dict] | None, str | None]: ) -> 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()) return parse_json_response(result_text, self.get_data_type())
@@ -222,7 +219,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
attempt_index: int, attempt_index: int,
) -> str: ) -> str:
""" """
构建结构化失败后的修复重试提示词。 Xây dựng prompt retry sửa output có cấu trúc.
""" """
err_text = parse_error or "unknown_parse_error" err_text = parse_error or "unknown_parse_error"
return ( return (
@@ -241,7 +238,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
self, result_text: str self, result_text: str
) -> tuple[bool, list[dict] | None, str | None]: ) -> 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) success, parsed_data, error_msg = self.parse_structured_response(result_text)
if success and parsed_data: if success and parsed_data:
@@ -259,7 +256,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
) )
if validated_success and validated_data: if validated_success and validated_data:
logger.info( 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 return True, validated_data, None
error_msg = validated_error or error_msg error_msg = validated_error or error_msg
@@ -270,18 +267,19 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
self, data_list: list[dict] self, data_list: list[dict]
) -> tuple[bool, list[dict] | None, str | None]: ) -> tuple[bool, list[dict] | None, str | None]:
""" """
解析结果的本地二次校验(默认直接通过)。 Kiểm tra cục bộ lần hai cho kết quả parse, mặc định luôn hợp lệ.
子类可重写为 Pydantic 校验。
Lớp con có thể ghi đè bằng kiểm tra Pydantic.
""" """
return True, data_list, None return True, data_list, None
def _save_debug_data(self, prompt: str, session_id: str): def _save_debug_data(self, prompt: str, session_id: str):
""" """
保存调试数据到文件 Lưu dữ liệu debug vào tệp.
Args: Args:
prompt: 提示词内容 prompt: Nội dung prompt.
session_id: 会话ID session_id: ID phiên.
""" """
try: try:
from astrbot.api.star import StarTools 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_name = f"{session_id}_{self.get_data_type()}.txt"
file_path = data_path / file_name 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: with open(file_path, "w", encoding="utf-8") as f:
f.write(prompt) 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: 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( def _apply_persona_reinforcement(
self, prompt: str, system_prompt: str | None self, prompt: str, system_prompt: str | None
) -> str: ) -> 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(): if not system_prompt or not system_prompt.strip():
return prompt 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() persona_content = system_prompt.strip()
return ( return (
"【SYSTEM_CORE_IDENTITY_FIXED】\n" "【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" "--- MISSION_DIRECTIVE_START ---\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"
"这包括但不限于:你的思维切入点、对数据的敏感度、点评的犀利/温情程度、以及你对群聊氛围的感知逻辑。\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"请以该人格的思维方式去处理以下‘{self.get_data_type()}’分析任务:\n\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" f"{prompt}\n"
"--- MISSION_DIRECTIVE_END ---\n\n" "--- MISSION_DIRECTIVE_END ---\n\n"
"【FINAL_IDENTITY_REINFORCEMENT】\n" "【FINAL_IDENTITY_REINFORCEMENT】\n"
f"1. 你不再是通用的 AI 助手,你是上述设定中的角色,我将在此处再次提醒你的身份:\n{persona_content}\n 正在观察并点评这些群聊数据。\n" 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. 请务必使用该角色的第一人称视角 or 其独有的观察视角进行‘{self.get_data_type()}’输出。\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. 你的分析成果必须体现该角色的性格色彩,禁止输出中立、客套、公式化的 AI 话术。\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. ⚠️ 格式铁律:无论人格多么狂放,最终输出的内容必须严格遵守‘ MISSION_DIRECTIVE ’中所要求的纯 JSON 格式。除了 JSON 数据外,严禁输出任何 Markdown 标记或角色扮演的额外闲聊。" "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( async def analyze(
self, data: TInputData, umo: str | None = None, session_id: str | None = None self, data: TInputData, umo: str | None = None, session_id: str | None = None
) -> tuple[list[TDataObject], TokenUsage]: ) -> tuple[list[TDataObject], TokenUsage]:
""" """
统一的分析流程 Quy trình phân tích thống nhất.
Args: Args:
data: 输入数据 data: Dữ liệu đầu vào.
umo: 模型唯一标识符 umo: Định danh model duy nhất.
session_id: 会话ID (用于调试模式) session_id: ID phiên dùng cho debug mode.
Returns: Returns:
(分析结果列表, Token使用统计) Tuple danh sách kết quả và thống kê token.
""" """
try: try:
# 1. 构建提示词 # 1. Xây dựng prompt.
logger.debug( 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" 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) 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( 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( 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() debug_mode = self.config_manager.get_debug_mode()
if debug_mode and session_id and prompt: if debug_mode and session_id and prompt:
self._save_debug_data(prompt, session_id) self._save_debug_data(prompt, session_id)
elif debug_mode and not session_id: elif debug_mode and not session_id:
logger.warning("[Debug] Debug mode enabled but no session_id provided") 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(): if not prompt or not prompt.strip():
logger.warning( 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() return [], TokenUsage()
# 2. 调用LLM(使用配置的 provider # 2. Gọi LLM bằng provider đã cấu hình.
provider_id_key = self.get_provider_id_key() 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 resolved_provider_id = None
if provider_id_key: if provider_id_key:
resolved_provider_id = await get_provider_id_with_fallback( 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 provider_id_key, umo, provider_id=resolved_provider_id
) )
# 获取人格设定 # Lấy cấu hình persona.
system_prompt = await self._build_system_prompt(umo) system_prompt = await self._build_system_prompt(umo)
# 应用人格强化注入 # Inject tăng cường persona.
prompt = self._apply_persona_reinforcement(prompt, system_prompt) 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: if debug_mode:
logger.debug( logger.debug(
f"[Debug] debug_mode={debug_mode}, umo={umo}, session_id={session_id}, prompt_len={len(prompt) if prompt else 0}" 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: if response is None:
logger.error( 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() return [], TokenUsage()
# 3. 提取token使用统计 # 3. Trích xuất thống kê token.
token_usage_dict = extract_token_usage(response) token_usage_dict = extract_token_usage(response)
token_usage = TokenUsage( token_usage = TokenUsage(
prompt_tokens=token_usage_dict["prompt_tokens"], prompt_tokens=token_usage_dict["prompt_tokens"],
@@ -431,14 +438,14 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
total_tokens=token_usage_dict["total_tokens"], 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) 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) 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: if not success and self.get_response_format() is not None:
temperatures = self.get_schema_retry_temperatures(base_temperature) temperatures = self.get_schema_retry_temperatures(base_temperature)
for idx, temperature in enumerate(temperatures, start=1): for idx, temperature in enumerate(temperatures, start=1):
@@ -449,7 +456,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
attempt_index=idx, attempt_index=idx,
) )
logger.warning( 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})" f"(attempt={idx}, temperature={temperature:.1f})"
) )
retry_response = await call_provider_with_retry( retry_response = await call_provider_with_retry(
@@ -481,50 +488,49 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
error_msg = retry_error_msg error_msg = retry_error_msg
if success and parsed_data: 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) data_objects = self.create_data_objects(parsed_data)
logger.info( 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 return data_objects, token_usage
# 6. 全部尝试失败 # 6. Mọi lần thử đều thất bại.
logger.error( 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 return [], token_usage
except Exception as e: 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() return [], TokenUsage()
async def _build_system_prompt(self, umo: str | None) -> str | None: async def _build_system_prompt(self, umo: str | None) -> str | None:
""" """
构建带有会话人格的系统提示词,优先级如下: Xây dựng system prompt kèm persona của phiên theo thứ tự ưu tiên:
1. 插件指定的全局人格 (若核心开关开启) persona toàn cục của plugin, persona phiên/hội thoại, rồi persona mặc định UMO.
2. 会话/对话选定的人格 (若开启了继承开关)
3. 当前 UMO 的默认人格 (若开启了继承开关)
Args: Args:
umo: 用户模型对象标识,用于定位会话上下文 umo: Định danh dùng để xác định context phiên.
Returns: 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() use_specific = self.config_manager.get_use_plugin_specific_persona()
specific_id = self.config_manager.get_plugin_specific_persona_id() specific_id = self.config_manager.get_plugin_specific_persona_id()
keep_original = self.config_manager.get_keep_original_persona() 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) persona_mgr = getattr(self.context, "persona_manager", None)
if persona_mgr is None: if persona_mgr is None:
return None return None
persona_prompt = 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: if use_specific and specific_id:
try: try:
persona_obj = await persona_mgr.get_persona(specific_id) persona_obj = await persona_mgr.get_persona(specific_id)
@@ -534,15 +540,18 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
else None else None
) )
if persona_prompt: 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: 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: if not persona_prompt and keep_original and umo:
try: 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 from astrbot.api import sp
session_service_config = await sp.get_async( session_service_config = await sp.get_async(
@@ -565,9 +574,11 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
else None else None
) )
if persona_prompt: 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: if not persona_prompt:
conv_mgr = getattr(self.context, "conversation_manager", None) conv_mgr = getattr(self.context, "conversation_manager", None)
if conv_mgr: if conv_mgr:
@@ -591,10 +602,10 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
) )
if persona_prompt: if persona_prompt:
logger.debug( 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: if not persona_prompt:
personality = await persona_mgr.get_default_persona_v3(umo) personality = await persona_mgr.get_default_persona_v3(umo)
if isinstance(personality, dict): if isinstance(personality, dict):
@@ -602,12 +613,14 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
else: else:
persona_prompt = getattr(personality, "prompt", None) persona_prompt = getattr(personality, "prompt", None)
if persona_prompt: if persona_prompt:
logger.debug("继承到 UMO 默认人格设定") logger.debug("Đã kế thừa persona mặc định của UMO")
except Exception as e: 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(): if not isinstance(persona_prompt, str) or not persona_prompt.strip():
return None return None
@@ -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 from datetime import datetime
@@ -22,24 +19,22 @@ from .base_analyzer import BaseAnalyzer
class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]): class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]):
""" """
聊天质量分析器 Analyzer chất lượng trò chuyện và đánh giá đa chiều.
专门处理群聊质量的锐评和多维度分析
注意:由于聊天质量分析返回的是 JSON 对象而非数组, Vì kết quả là object JSON thay vì mảng, analyzer ghi đè ``analyze()``, dùng
此分析器重写了 analyze() 方法,使用 parse_json_object_response 解析, ``parse_json_object_response`` và fallback bằng ``extract_quality_with_regex``.
并以 extract_quality_with_regex 作为正则降级方案。
""" """
def get_provider_id_key(self) -> str: def get_provider_id_key(self) -> str:
"""获取 Provider ID 配置键名""" """Lấy tên key cấu hình Provider ID."""
return "quality_provider_id" return "quality_provider_id"
def get_data_type(self) -> str: def get_data_type(self) -> str:
"""获取数据类型标识""" """Lấy định danh loại dữ liệu."""
return "聊天质量" return "Chất lượng trò chuyện"
def get_max_count(self) -> int: def get_max_count(self) -> int:
"""获取最大维度数量""" """Lấy số chiều tối đa."""
return 8 return 8
def get_response_schema_name(self) -> str: 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: 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: if not data:
return "" return ""
# 提取文本消息 # Trích xuất tin nhắn văn bản.
text_messages = [] text_messages = []
for msg in data: for msg in data:
if not isinstance(msg, dict): if not isinstance(msg, dict):
@@ -89,39 +84,39 @@ class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]):
if prompt_template: if prompt_template:
return render_template(prompt_template, messages_text=messages_text) 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".
## 任务目标: ## Mục tiêu:
1. **维度划分**:将聊天内容划分为 3-6 个【高层级、抽象、泛化】的维度(例如:就业焦虑、生涯规划、技术方案研究、情感树洞、无意义水群等)。 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. **严禁在维度名称(name)中出现任何具体的群聊人物名、项目名、具体的报错内容或细碎的事件点。标题必须保持高度抽象且字数简练(2-6个字)。** 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. 为每个维度计算一个大致的百分比占位(总和小于等于 100%)。 3. Ước tính tỷ lệ phần trăm cho từng chiều, tổng không vượt quá 100%.
4. **点评内容**:为每个维度写一句犀利、幽默、毒舌或温情的点评。具体的吐槽内容、具体的细节事件描述请放在这里。 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. **全群表现**:给出一句总结性的评价,作为总结标题对应的“金句”。 5. **Biểu hiện toàn nhóm**: đưa ra một câu tổng kết nổi bật.
6. **主题设定**:设定一个本次报告的主题标题和副标题。 6. **Thiết lập chủ đề**: đặt tiêu đề và phụ đề cho báo cáo.
## 点评风格指南: ## 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.
- **只有维度名称(name)需要抽象,点评(comment)和总结(summary)可以非常具体和生动。** - **Chỉ tên chiều (name) cần trừu tượng; commentsummary có thể cụ thể, sinh động.**
## 返回格式要求: ## Yêu cầu định dạng:
必须以纯 JSON 格式返回,不得包含任何 Markdown 格式。 Chỉ trả về JSON thuần, không chứa Markdown.
```json ```json
{{ {{
"title": "今日群聊主题", "title": "Chủ đề trò chuyện hôm nay",
"subtitle": "副标题", "subtitle": "Phụ đề",
"dimensions": [ "dimensions": [
{{ {{
"name": "抽象维度名", "name": "Tên chiều trừu tượng",
"percentage": 比例, "percentage": 25,
"comment": "维度的毒舌点评" "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} ${messages_text}
""" """
@@ -129,32 +124,30 @@ ${messages_text}
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: 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() 的降级流程使用, Analyzer này ghi đè ``analyze()`` nên thực tế fallback được gọi từ
但由于聊天质量分析重写了 analyze(),实际由 analyze_quality() 中调用 ``analyze_quality()`` qua ``extract_quality_with_regex``.
extract_quality_with_regex 实现。
""" """
return [] return []
def create_data_objects(self, data_list: list[dict]) -> list[QualityReview]: def create_data_objects(self, data_list: list[dict]) -> list[QualityReview]:
""" """
满足 BaseAnalyzer 抽象要求。 Đáp ứng giao diện trừu tượng BaseAnalyzer; object được tạo trong analyze_quality.
聊天质量分析的数据对象创建在 analyze_quality 中完成。
""" """
return [] return []
def _build_review_from_dict(self, data: dict) -> QualityReview: def _build_review_from_dict(self, data: dict) -> QualityReview:
""" """
从解析后的字典构建 QualityReview 对象 Xây dựng object QualityReview từ dict đã parse.
Args: Args:
data: 解析后的 JSON 对象字典 data: Dict object JSON đã parse.
Returns: Returns:
QualityReview 数据对象 Object QualityReview.
""" """
# 控制维度占比总和不超过100% # Đảm bảo tổng tỷ lệ các chiều không vượt 100%.
total_percentage = sum( total_percentage = sum(
max(0.0, min(100.0, float(d.get("percentage", 0)))) max(0.0, min(100.0, float(d.get("percentage", 0))))
for d in data.get("dimensions", []) for d in data.get("dimensions", [])
@@ -172,13 +165,13 @@ ${messages_text}
dimensions.append( dimensions.append(
QualityDimension( QualityDimension(
name=d.get("name", "未知"), name=d.get("name", "Không xác định"),
percentage=final_p, percentage=final_p,
comment=d.get("comment", ""), comment=d.get("comment", ""),
) )
) )
# 自动分配颜色 # Tự phân bổ màu.
colors = [ colors = [
"#607d8b", "#607d8b",
"#2196f3", "#2196f3",
@@ -193,10 +186,10 @@ ${messages_text}
d.color = colors[i % len(colors)] d.color = colors[i % len(colors)]
return QualityReview( return QualityReview(
title=data.get("title", "聊天质量锐评"), title=data.get("title", "Đánh giá chất lượng trò chuyện"),
subtitle=data.get("subtitle", "今天的群里发生了什么?"), subtitle=data.get("subtitle", "Hôm nay nhóm đã có chuyện gì?"),
dimensions=dimensions, 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( def _validate_review_payload(
@@ -228,7 +221,7 @@ ${messages_text}
attempt_index=idx, attempt_index=idx,
) )
logger.warning( 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})" f"(attempt={idx}, temperature={temperature:.1f})"
) )
retry_response = await call_provider_with_retry( retry_response = await call_provider_with_retry(
@@ -271,7 +264,7 @@ ${messages_text}
session_id: str | None = None, session_id: str | None = None,
) -> tuple[QualityReview | None, TokenUsage]: ) -> 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: if not batch_reviews:
return None, TokenUsage() return None, TokenUsage()
@@ -280,10 +273,10 @@ ${messages_text}
return self._build_review_from_dict(batch_reviews[0]), TokenUsage() return self._build_review_from_dict(batch_reviews[0]), TokenUsage()
try: try:
# 构建汇总用的提示词 # Xây dựng prompt tổng hợp.
reviews_text = "" reviews_text = ""
for i, rev in enumerate(batch_reviews): for i, rev in enumerate(batch_reviews):
title = rev.get("title", "未命名") title = rev.get("title", "Chưa đặt tên")
summary = rev.get("summary", "") summary = rev.get("summary", "")
dims = ", ".join( dims = ", ".join(
[ [
@@ -291,53 +284,53 @@ ${messages_text}
for d in rev.get("dimensions", []) 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 = ( prompt_template = (
self.config_manager.get_quality_summary_prompt() 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.
## 任务目标: ## Mục tiêu:
1. **全局抽象维度**:根据各批次的维度表现,平衡权重,提取出 3-6 个覆盖全天的【核心、上层抽象】课题维度(如:职场/行业风向、技术架构演进、社畜心理博弈等)。 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. **严禁在维度名称(name)中出现具体的批次细节。标题必须代表全天的某种趋势。** 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. **百分比融合**:根据全天笔记的频率和强度,给出一个代表全天整体分布的比例(总和不超过100%)。 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. **终极点评**:为每个汇总维度写出一句升华后的全天总结性点评。可以融合具体批次中的有趣槽点。 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. **终极总结**:拟定全天的大型主题标题、副标题,并给出一句霸气的全天表现总结。 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.
## 风格要求: ## Yêu cầu phong cách:
- 只有维度名称(name)需要高度概括抽象。 - Chỉ tên chiều (name) cần khái quát và trừu tượng cao.
- 点评(comment)和总结(summary)请尽量生动、具体,要把一整天的梗串联起来。 - Commentsummary phải sinh động, cụ thể và kết nối các điểm đáng nhớ trong ngày.
## 返回格式要求: ## Yêu cầu định dạng:
必须以纯 JSON 格式返回,不得包含任何 Markdown 格式。 Chỉ trả về JSON thuần, không chứa Markdown.
```json ```json
{{ {{
"title": "今日群聊主题", "title": "Chủ đề trò chuyện hôm nay",
"subtitle": "副标题", "subtitle": "Phụ đề",
"dimensions": [ "dimensions": [
{{ {{
"name": "抽象大类标题", "name": "Tên chiều khái quát",
"percentage": 比例, "percentage": 25,
"comment": "维度的全天锐评" "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) 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) system_prompt = await self._build_system_prompt(umo)
base_temperature = await self._resolve_provider_temperature( base_temperature = await self._resolve_provider_temperature(
self.get_provider_id_key(), umo self.get_provider_id_key(), umo
) )
# 应用人设强化注入 # Inject tăng cường persona.
prompt = self._apply_persona_reinforcement(prompt, system_prompt) prompt = self._apply_persona_reinforcement(prompt, system_prompt)
response = await call_provider_with_retry( response = await call_provider_with_retry(
@@ -365,7 +358,7 @@ ${messages_text}
return None, usage return None, usage
success, parsed_data, error_msg = parse_json_object_response( 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: if success and parsed_data:
@@ -375,7 +368,7 @@ ${messages_text}
if valid and normalized: if valid and normalized:
review = self._build_review_from_dict(normalized) review = self._build_review_from_dict(normalized)
logger.info( 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 return review, usage
error_msg = validation_error or error_msg error_msg = validation_error or error_msg
@@ -391,16 +384,18 @@ ${messages_text}
if repaired_data: if repaired_data:
review = self._build_review_from_dict(repaired_data) review = self._build_review_from_dict(repaired_data)
logger.info( 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 return review, usage
# 降级:如果汇总失败,返回最新的一个 # Fallback về batch mới nhất nếu tổng hợp thất bại.
logger.warning(f"聊天质量汇总分析失败,降级使用最新批次: {error_msg}") 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 return self._build_review_from_dict(batch_reviews[-1]), usage
except Exception as e: 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() return self._build_review_from_dict(batch_reviews[-1]), TokenUsage()
async def analyze_quality( async def analyze_quality(
@@ -410,31 +405,26 @@ ${messages_text}
session_id: str | None = None, session_id: str | None = None,
) -> tuple[QualityReview | None, TokenUsage]: ) -> tuple[QualityReview | None, TokenUsage]:
""" """
分析聊天质量 Phân tích chất lượng trò chuyện.
流程遵循 BaseAnalyzer 的设计模式: Theo mẫu BaseAnalyzer: xây prompt, gọi LLM, lấy token, parse JSON và fallback regex.
1. 构建 prompt
2. 调用 LLM
3. 提取 token 使用统计
4. JSON 解析(使用 parse_json_object_response
5. 正则降级(使用 extract_quality_with_regex
""" """
try: try:
# 1. 获取人格设定 # 1. Lấy persona.
system_prompt = await self._build_system_prompt(umo) system_prompt = await self._build_system_prompt(umo)
base_temperature = await self._resolve_provider_temperature( base_temperature = await self._resolve_provider_temperature(
self.get_provider_id_key(), umo self.get_provider_id_key(), umo
) )
# 2. 构建 prompt # 2. Xây dựng prompt.
prompt = self.build_prompt(messages) prompt = self.build_prompt(messages)
if not prompt: if not prompt:
return None, TokenUsage() return None, TokenUsage()
# 应用人设强化注入 # Inject tăng cường persona.
prompt = self._apply_persona_reinforcement(prompt, system_prompt) prompt = self._apply_persona_reinforcement(prompt, system_prompt)
# 3. 调用 LLM # 3. Gọi LLM.
response = await call_provider_with_retry( response = await call_provider_with_retry(
self.context, self.context,
self.config_manager, self.config_manager,
@@ -448,7 +438,7 @@ ${messages_text}
if response is None: if response is None:
return None, TokenUsage() return None, TokenUsage()
# 4. 提取 token 使用统计 # 4. Trích xuất thống kê token.
token_usage_dict = extract_token_usage(response) token_usage_dict = extract_token_usage(response)
usage = TokenUsage( usage = TokenUsage(
prompt_tokens=token_usage_dict["prompt_tokens"], prompt_tokens=token_usage_dict["prompt_tokens"],
@@ -456,12 +446,12 @@ ${messages_text}
total_tokens=token_usage_dict["total_tokens"], 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) result_text = extract_response_text(response)
if not result_text: if not result_text:
return None, usage 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( success, parsed_data, error_msg = parse_json_object_response(
result_text, self.get_data_type() result_text, self.get_data_type()
) )
@@ -473,7 +463,7 @@ ${messages_text}
if valid and normalized: if valid and normalized:
review = self._build_review_from_dict(normalized) review = self._build_review_from_dict(normalized)
logger.debug( 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 return review, usage
error_msg = validation_error or error_msg error_msg = validation_error or error_msg
@@ -486,7 +476,7 @@ ${messages_text}
if valid and normalized: if valid and normalized:
review = self._build_review_from_dict(normalized) review = self._build_review_from_dict(normalized)
logger.debug( 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 return review, usage
error_msg = validation_error or error_msg error_msg = validation_error or error_msg
@@ -502,16 +492,18 @@ ${messages_text}
if repaired_data: if repaired_data:
review = self._build_review_from_dict(repaired_data) review = self._build_review_from_dict(repaired_data)
logger.debug( 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 return review, usage
# 7. 全部失败 # 7. Mọi cách đều thất bại.
logger.error(f"聊天质量分析失败: JSON解析和正则提取均未成功: {error_msg}") 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 return None, usage
except Exception as e: 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() return None, TokenUsage()
# Override analyze to bridge the base class interface # Override analyze to bridge the base class interface
@@ -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 from datetime import datetime
@@ -16,21 +13,18 @@ from .base_analyzer import BaseAnalyzer
class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]): 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: 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" return "golden_quote_provider_id"
def get_data_type(self) -> str: def get_data_type(self) -> str:
"""获取数据类型标识""" """Lấy định danh loại dữ liệu."""
return "Trích dẫn nổi bật" return "Trích dẫn nổi bật"
def get_max_count(self) -> int: 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: if self._incremental_max_count is not None:
return self._incremental_max_count return self._incremental_max_count
return self.config_manager.get_max_golden_quotes() 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: def build_prompt(self, data: list[dict]) -> str:
""" """
构建金句分析提示词 Xây dựng prompt phân tích trích dẫn.
Args: Args:
messages: 群聊的文本消息列表 messages: Danh sách tin nhắn văn bản của nhóm.
Returns: Returns:
提示词字符串 Chuỗi prompt.
""" """
if not data: if not data:
return "" 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( messages_text = "\n".join(
[f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}" for msg in data] [f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}" for msg in data]
) )
max_golden_quotes = self.get_max_count() 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() prompt_template = self.config_manager.get_golden_quote_analysis_prompt()
if prompt_template: if prompt_template:
@@ -71,50 +65,52 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
max_golden_quotes=max_golden_quotes, max_golden_quotes=max_golden_quotes,
messages_text=messages_text, 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 return prompt
except Exception as e: 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 "" return ""
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: 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: Args:
result_text: LLM响应文本 result_text: Văn bản phản hồi LLM.
max_count: 最大提取数量 max_count: Số lượng tối đa.
Returns: Returns:
金句数据列表 Danh sách dữ liệu trích dẫn.
""" """
return extract_golden_quotes_with_regex(result_text, max_count) return extract_golden_quotes_with_regex(result_text, max_count)
def create_data_objects(self, data_list: list[dict]) -> list[GoldenQuote]: def create_data_objects(self, data_list: list[dict]) -> list[GoldenQuote]:
""" """
创建金句对象列表 Tạo danh sách object trích dẫn.
Args: Args:
quotes_data: 原始金句数据列表 quotes_data: Danh sách dữ liệu trích dẫn gốc.
Returns: Returns:
GoldenQuote对象列表 Danh sách object GoldenQuote.
""" """
try: try:
quotes = [] quotes = []
max_quotes = self.get_max_count() max_quotes = self.get_max_count()
for quote_data in data_list[:max_quotes]: for quote_data in data_list[:max_quotes]:
# 确保数据格式正确 # Đảm bảo định dạng dữ liệu đúng.
content = quote_data.get("content", "").strip() content = quote_data.get("content", "").strip()
sender = quote_data.get("sender", "").strip() sender = quote_data.get("sender", "").strip()
reason = quote_data.get("reason", "").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: 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 continue
quotes.append( quotes.append(
@@ -124,7 +120,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
return quotes return quotes
except Exception as e: except Exception as e:
logger.error(f"创建金句对象失败: {e}") logger.error(f"Tạo object trích dẫn thất bại: {e}")
return [] return []
def validate_parsed_data( def validate_parsed_data(
@@ -139,38 +135,41 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[GoldenQuote], TokenUsage]: ) -> tuple[list[GoldenQuote], TokenUsage]:
""" """
分析群聊金句 Phân tích trích dẫn nổi bật trong nhóm.
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
umo: 模型唯一标识符 umo: Định danh model.
session_id: 会话ID (用于调试模式) session_id: ID phiên dùng cho debug mode.
Returns: Returns:
(金句列表, Token使用统计) Tuple danh sách trích dẫn và thống kê token.
""" """
try: try:
# 提取圣经的文本消息 # Trích xuất tin nhắn văn bản đáng chú ý.
interesting_messages = self.extract_interesting_messages(messages) interesting_messages = self.extract_interesting_messages(messages)
if not interesting_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() 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) 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 = {} id_to_nickname = {}
for msg in interesting_messages: for msg in interesting_messages:
uid = str(msg.get("user_id", "")) uid = str(msg.get("user_id", ""))
if uid: if uid:
id_to_nickname[uid] = msg.get("sender", "") 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: for quote in quotes:
# 此时 quote.sender 包含的是 Prompt 中的 [user_id] # quote.sender hiện chứa [user_id] trong prompt; loại ngoặc nếu có.
# 有些 LLM 可能会带上中括号,尝试清理
potential_id = quote.sender.strip().strip("[]") potential_id = quote.sender.strip().strip("[]")
if potential_id in id_to_nickname: if potential_id in id_to_nickname:
@@ -178,29 +177,29 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
quote.sender = id_to_nickname[potential_id] quote.sender = id_to_nickname[potential_id]
else: else:
logger.warning( 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 return quotes, usage
except Exception as e: 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() return [], TokenUsage()
def extract_interesting_messages(self, messages: list[dict]) -> list[dict]: 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: Args:
messages: 已由 MessageCleaner 处理过的 legacy 消息列表 messages: Danh sách tin nhắn legacy đã qua MessageCleaner.
Returns: Returns:
提取的文本消息列表 Danh sách tin nhắn văn bản đã trích xuất.
""" """
interesting_messages = [] interesting_messages = []
for msg in messages: for msg in messages:
# 获取发送者显示名 # Lấy tên hiển thị của người gửi.
sender = msg.get("sender", {}) sender = msg.get("sender", {})
nickname = InfoUtils.get_user_nickname(self.config_manager, sender) nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") 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", []): for content in msg.get("message", []):
if content.get("type") == "text": if content.get("type") == "text":
text = content.get("data", {}).get("text", "").strip() 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: if 2 <= len(text) <= 500:
interesting_messages.append( interesting_messages.append(
{ {
@@ -1,7 +1,4 @@
""" """Module phân tích chủ đề trò chuyện nhóm."""
话题分析模块
专门处理群聊话题分析
"""
import re import re
from datetime import datetime from datetime import datetime
@@ -17,21 +14,18 @@ from .base_analyzer import BaseAnalyzer
class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): 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: def get_provider_id_key(self) -> str:
"""获取 Provider ID 配置键名""" """Lấy tên key cấu hình Provider ID."""
return "topic_provider_id" return "topic_provider_id"
def get_data_type(self) -> str: def get_data_type(self) -> str:
"""获取数据类型标识""" """Lấy định danh loại dữ liệu."""
return "Chủ đề" return "Chủ đề"
def get_max_count(self) -> int: 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: if self._incremental_max_count is not None:
return self._incremental_max_count return self._incremental_max_count
return self.config_manager.get_max_topics() 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: def build_prompt(self, data: list[dict]) -> str:
""" """
构建话题分析提示词 Xây dựng prompt phân tích chủ đề.
Args: Args:
messages: 群聊消息列表 data: Danh sách tin nhắn nhóm.
Returns: Returns:
提示词字符串 Chuỗi prompt.
""" """
# 验证输入数据格式 # Xác thực định dạng input.
if not isinstance(data, list): 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 "" return ""
# 检查消息列表是否为空 # Kiểm tra danh sách tin nhắn rỗng.
if not data: if not data:
logger.warning("build_prompt 收到空消息列表") logger.warning("build_prompt nhận danh sách tin nhắn rỗng")
return "" return ""
# 提取文本消息 # Trích xuất tin nhắn văn bản.
text_messages = [] text_messages = []
for i, msg in enumerate(data): 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): if not isinstance(msg, dict):
continue continue
try: try:
sender = msg.get("sender", {}) sender = msg.get("sender", {})
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误 # Bỏ qua sender không phải dict.
if not isinstance(sender, dict): if not isinstance(sender, dict):
continue continue
# 获取发送者ID并过滤机器人消息 # Lấy ID người gửi và lọc tin nhắn bot.
user_id = str(sender.get("user_id", "")) user_id = str(sender.get("user_id", ""))
bot_self_ids = self.config_manager.get_bot_self_ids() 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]: if bot_self_ids and user_id in [str(uid) for uid in bot_self_ids]:
continue continue
@@ -88,7 +82,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
message_list = msg.get("message", []) 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 = [] text_parts = []
for j, content in enumerate(message_list): for j, content in enumerate(message_list):
if not isinstance(content, dict): if not isinstance(content, dict):
@@ -101,21 +95,21 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
if text: if text:
text_parts.append(text) text_parts.append(text)
elif content_type == "at": elif content_type == "at":
# 处理 @ 消息,转换为文本 # Chuyển mention thành văn bản.
at_data = content.get("data", {}) 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") at_id = at_data.get("id") or at_data.get("user_id")
if at_id: if at_id:
at_text = f"@{at_id}" at_text = f"@{at_id}"
text_parts.append(at_text) text_parts.append(at_text)
elif content_type == "reply": elif content_type == "reply":
# 处理回复消息,添加标记 # Thêm nhãn cho tin nhắn trả lời.
reply_id = content.get("data", {}).get("id", "") reply_id = content.get("data", {}).get("id", "")
if reply_id: if reply_id:
reply_text = f"[回复:{reply_id}]" reply_text = f"[Trả lời:{reply_id}]"
text_parts.append(reply_text) text_parts.append(reply_text)
# 合并所有文本部分 # Gộp mọi phần văn bản.
combined_text = "".join(text_parts).strip() combined_text = "".join(text_parts).strip()
if ( if (
@@ -123,7 +117,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
and len(combined_text) > 2 and len(combined_text) > 2
and not combined_text.startswith("/") and not combined_text.startswith("/")
): ):
# 清理消息内容 # Làm sạch nội dung tin nhắn.
cleaned_text = combined_text.replace("", '"').replace("", '"') cleaned_text = combined_text.replace("", '"').replace("", '"')
cleaned_text = cleaned_text.replace("", "'").replace("", "'") cleaned_text = cleaned_text.replace("", "'").replace("", "'")
cleaned_text = cleaned_text.replace("\n", " ").replace("\r", " ") cleaned_text = cleaned_text.replace("\n", " ").replace("\r", " ")
@@ -140,16 +134,18 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
) )
except Exception as e: except Exception as e:
logger.error( 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 continue
if not text_messages: 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 "" return ""
# 构建消息文本 # Dựng văn bản theo định dạng chỉ ID: [HH:MM] [ID người dùng]: nội dung.
# 使用用户提供的 ID-Only 格式: [HH:MM] [用户ID]: 消息内容
messages_text = "\n".join( messages_text = "\n".join(
[ [
f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}" f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}"
@@ -159,7 +155,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
max_topics = self.get_max_count() 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() prompt_template = self.config_manager.get_topic_analysis_prompt()
if prompt_template: if prompt_template:
@@ -169,98 +165,104 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
max_topics=max_topics, max_topics=max_topics,
messages_text=messages_text, messages_text=messages_text,
) )
logger.info("使用配置中的话题分析提示词") logger.info("Đang dùng prompt phân tích chủ đề trong cấu hình")
return prompt return prompt
except Exception as e: 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 "" return ""
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: 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: Args:
result_text: LLM响应文本 result_text: Văn bản phản hồi LLM.
max_count: 最大话题数量 max_count: Số chủ đề tối đa.
Returns: Returns:
话题数据列表 Danh sách dữ liệu chủ đề.
""" """
return extract_topics_with_regex(result_text, max_count) return extract_topics_with_regex(result_text, max_count)
def create_data_objects(self, data_list: list[dict]) -> list[SummaryTopic]: def create_data_objects(self, data_list: list[dict]) -> list[SummaryTopic]:
""" """
创建话题对象列表 Tạo danh sách object chủ đề.
Args: Args:
topics_data: 原始话题数据列表 data_list: Danh sách dữ liệu chủ đề thô.
Returns: Returns:
SummaryTopic对象列表 Danh sách object SummaryTopic.
""" """
logger.debug( 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: try:
topics = [] topics = []
max_topics = self.get_max_count() 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]): 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): if not isinstance(topic_data, dict):
logger.warning( 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 continue
try: try:
# 确保数据格式正确 # Chuẩn hoá định dạng dữ liệu.
topic_name = topic_data.get("topic", "").strip() topic_name = topic_data.get("topic", "").strip()
contributors = topic_data.get("contributors", []) contributors = topic_data.get("contributors", [])
detail = topic_data.get("detail", "").strip() detail = topic_data.get("detail", "").strip()
logger.debug( 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: 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 continue
# 确保参与者列表有效 # Đảm bảo danh sách người tham gia hợp lệ.
if not contributors or not isinstance(contributors, list): if not contributors or not isinstance(contributors, list):
contributors = ["群友"] contributors = ["Thành viên nhóm"]
else: else:
# 清理参与者名称 # Làm sạch tên người tham gia.
contributors = [ contributors = [
str(c).strip() for c in contributors if c and str(c).strip() str(c).strip() for c in contributors if c and str(c).strip()
] or ["群友"] ] or ["Thành viên nhóm"]
topics.append( topics.append(
SummaryTopic( SummaryTopic(
topic=topic_name, topic=topic_name,
contributors=contributors[:5], # 最多5个参与者 contributors=contributors[:5], # Tối đa 5 người tham gia.
detail=detail, detail=detail,
) )
) )
except Exception as e: 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 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 return topics
except Exception as e: 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 [] return []
def validate_parsed_data( def validate_parsed_data(
@@ -270,18 +272,18 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
def extract_text_messages(self, messages: list[dict]) -> 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: Args:
messages: 已由 MessageCleaner 处理过的 legacy 消息列表 messages: Danh sách tin nhắn legacy đã qua MessageCleaner.
Returns: Returns:
提取的文本消息列表 Danh sách tin nhắn văn bản được trích xuất.
""" """
text_messages = [] text_messages = []
for msg in messages: for msg in messages:
# 获取发送者显示名 # Lấy tên hiển thị người gửi.
sender = msg.get("sender", {}) sender = msg.get("sender", {})
nickname = InfoUtils.get_user_nickname(self.config_manager, sender) nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") 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", []): for content in msg.get("message", []):
if content.get("type") == "text": if content.get("type") == "text":
text = content.get("data", {}).get("text", "").strip() text = content.get("data", {}).get("text", "").strip()
# 已经在 MessageCleaner 中处理过基本的垃圾内容 # Nội dung rác cơ bản đã được MessageCleaner xử lý.
if text: if text:
# 简单的额外清理 # Làm sạch bổ sung đơn giản.
cleaned_text = text.replace("\n", " ").replace("\r", " ") cleaned_text = text.replace("\n", " ").replace("\r", " ")
cleaned_text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned_text) 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, session_id: str | None = None,
) -> tuple[list[SummaryTopic], TokenUsage]: ) -> tuple[list[SummaryTopic], TokenUsage]:
""" """
分析群聊话题 Phân tích chủ đề trò chuyện nhóm.
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
umo: 模型唯一标识符 umo: Định danh UMO.
session_id: 会话ID (用于调试模式) session_id: ID phiên dùng trong debug mode.
Returns: Returns:
(话题列表, Token使用统计) Tuple danh sách chủ đề và thống kê token.
""" """
try: try:
logger.debug( 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: if messages:
logger.debug( 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) 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: 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() return [], TokenUsage()
logger.info(f"开始分析 {len(text_messages)} 条文本消息中的话题") logger.info(f"Bắt đầu phân tích chủ đề từ {len(text_messages)} tin nhắn")
logger.debug(f"文本消息类型: {type(text_messages)}") logger.debug(f"Loại dữ liệu tin nhắn văn bản: {type(text_messages)}")
if text_messages: if text_messages:
logger.debug(f"第一条文本消息类型: {type(text_messages[0])}") logger.debug(
logger.debug(f"第一条文本消息内容: {text_messages[0]}") 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 = {} id_to_nickname = {}
for msg in text_messages: for msg in text_messages:
sender = msg.get("sender") sender = msg.get("sender")
@@ -355,15 +361,15 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
if sender and user_id: if sender and user_id:
id_to_nickname[user_id] = sender 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) 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: 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 并非纯数字, # member_openid QQ Official không chỉ gồm số; chỉ nhận ID đã biết
# 因此仅接受本批次已知用户或已配置机器人 ID,而不是用 isdigit 过滤。 # 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()} bot_ids = {str(uid) for uid in self.config_manager.get_bot_self_ids()}
known_ids = set(id_to_nickname) | bot_ids known_ids = set(id_to_nickname) | bot_ids
valid_ids = [] valid_ids = []
@@ -373,13 +379,13 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
valid_ids.append(uid) valid_ids.append(uid)
topic.contributor_ids = valid_ids topic.contributor_ids = valid_ids
# 映射回昵称用于显示 # Ánh xạ về nickname để hiển thị.
resolved_names = [] resolved_names = []
for uid in valid_ids: for uid in valid_ids:
# 尝试从当前批次消息映射 # Thử ánh xạ từ batch hiện tại.
name = id_to_nickname.get(uid) name = id_to_nickname.get(uid)
if not name: 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: if uid in bot_ids:
name = "Bot" name = "Bot"
else: else:
@@ -391,5 +397,5 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
return topics, usage return topics, usage
except Exception as e: 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() return [], TokenUsage()
@@ -1,7 +1,4 @@
""" """Module phân tích danh hiệu thành viên và kiểu MBTI."""
用户称号分析模块
专门处理用户称号和MBTI类型分析
"""
from ....domain.models.data_models import TokenUsage, UserTitle from ....domain.models.data_models import TokenUsage, UserTitle
from ....utils.logger import logger from ....utils.logger import logger
@@ -13,21 +10,18 @@ from .base_analyzer import BaseAnalyzer
class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]): class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
""" """Analyzer phân bổ danh hiệu thành viên và phân tích MBTI."""
用户称号分析器
专门处理用户称号分配和MBTI类型分析
"""
def get_provider_id_key(self) -> str: 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" return "user_title_provider_id"
def get_data_type(self) -> str: def get_data_type(self) -> str:
"""获取数据类型标识""" """Lấy định danh loại dữ liệu."""
return "Danh hiệu thành viên" return "Danh hiệu thành viên"
def get_max_count(self) -> int: 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() return self.config_manager.get_max_user_titles()
def get_response_schema_name(self) -> str: def get_response_schema_name(self) -> str:
@@ -38,89 +32,91 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
def build_prompt(self, data: dict) -> str: def build_prompt(self, data: dict) -> str:
""" """
构建用户称号分析提示词 Xây dựng prompt phân tích danh hiệu thành viên.
Args: Args:
user_data: 用户数据字典,包含用户统计信息 user_data: Dict dữ liệu và thống kê thành viên.
Returns: Returns:
提示词字符串 Chuỗi prompt.
""" """
user_summaries = data.get("user_summaries", []) user_summaries = data.get("user_summaries", [])
if not user_summaries: if not user_summaries:
return "" return ""
# 构建用户数据文本 # Xây dựng văn bản dữ liệu thành viên.
users_text = "\n".join( users_text = "\n".join(
[ [
f"- {user['name']} (ID:{user['user_id']}): " f"- {user['name']} (ID:{user['user_id']}): "
f"发言{user['message_count']}条, 平均{user['avg_chars']}, " f"{user['message_count']} tin nhắn, trung bình {user['avg_chars']} ký tự, "
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, " f"tỷ lệ biểu cảm {user['emoji_ratio']}, tỷ lệ chat ban đêm {user['night_ratio']}, "
f"回复比例{user['reply_ratio']}" f"tỷ lệ trả lời {user['reply_ratio']}"
for user in user_summaries 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() prompt_template = self.config_manager.get_user_title_analysis_prompt()
if prompt_template: if prompt_template:
try: try:
prompt = render_template(prompt_template, users_text=users_text) 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 return prompt
except Exception as e: 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 "" return ""
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: 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: Args:
result_text: LLM响应文本 result_text: Văn bản phản hồi LLM.
max_count: 最大提取数量 max_count: Số lượng tối đa.
Returns: Returns:
用户称号数据列表 Danh sách dữ liệu danh hiệu.
""" """
return extract_user_titles_with_regex(result_text, max_count) return extract_user_titles_with_regex(result_text, max_count)
def create_data_objects(self, data_list: list[dict]) -> list[UserTitle]: def create_data_objects(self, data_list: list[dict]) -> list[UserTitle]:
""" """
创建用户称号对象列表 Tạo danh sách object danh hiệu.
Args: Args:
titles_data: 原始用户称号数据列表 titles_data: Danh sách dữ liệu danh hiệu gốc.
Returns: Returns:
UserTitle对象列表 Danh sách object UserTitle.
""" """
try: try:
titles = [] titles = []
max_titles = self.get_max_count() max_titles = self.get_max_count()
for title_data in data_list[:max_titles]: for title_data in data_list[:max_titles]:
# 确保数据格式正确 # Đảm bảo định dạng dữ liệu đúng.
name = title_data.get("name", "").strip() name = title_data.get("name", "").strip()
user_id = title_data.get("user_id") user_id = title_data.get("user_id")
title = title_data.get("title", "").strip() title = title_data.get("title", "").strip()
mbti = title_data.get("mbti", "").strip() mbti = title_data.get("mbti", "").strip()
reason = title_data.get("reason", "").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: 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 continue
# 确保 user_id 是字符串 # Đảm bảo user_id là chuỗi.
if user_id is not None: if user_id is not None:
user_id = str(user_id) user_id = str(user_id)
else: 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 continue
titles.append( titles.append(
@@ -136,7 +132,7 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
return titles return titles
except Exception as e: except Exception as e:
logger.error(f"创建用户称号对象失败: {e}") logger.error(f"Tạo object danh hiệu thất bại: {e}")
return [] return []
def validate_parsed_data( def validate_parsed_data(
@@ -151,31 +147,33 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
top_users: list[dict] | None = None, top_users: list[dict] | None = None,
) -> dict: ) -> dict:
""" """
准备用户数据 Chuẩn bị dữ liệu thành viên.
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
user_analysis: 用户分析统计 user_analysis: Thống kê phân tích thành viên.
top_users: 活跃用户列表(从get_top_users获取) top_users: Danh sách thành viên tích cực từ get_top_users.
Returns: Returns:
准备好的用户数据字典 Dict dữ liệu thành viên đã chuẩn bị.
""" """
try: try:
# 获取机器人 ID 列表用于过滤 # Lấy danh sách ID bot để lọc.
bot_self_ids = self.config_manager.get_bot_self_ids() bot_self_ids = self.config_manager.get_bot_self_ids()
user_summaries = [] 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: if top_users:
logger.info( 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} target_user_ids = {str(user["user_id"]) for user in top_users}
else: else:
# 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户 # Tương thích logic cũ: dùng thành viên có ít nhất 5 tin nhắn.
logger.info("未提供活跃用户列表,使用消息数>=5的用户") 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 = { target_user_ids = {
user_id user_id
for user_id, stats in user_analysis.items() 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(): for user_id, stats in user_analysis.items():
user_id_str = str(user_id) 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]: if bot_self_ids and user_id_str in [str(uid) for uid in bot_self_ids]:
continue continue
# 只处理活跃用户 (top_users 或 消息数>=5) # Chỉ xử lý thành viên tích cực.
if user_id_str not in target_user_ids: if user_id_str not in target_user_ids:
continue continue
# 分析用户特征 (此处已基于已清理的 stats) # Phân tích đặc trưng từ stats đã làm sạch; ưu tiên hours dạng dict.
# 兼容性处理:优先使用 hours (dict),如果没有则尝试从消息推断或使用空
hours_data = stats.get("hours") hours_data = stats.get("hours")
if hours_data is None: 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", []) active_hours = stats.get("active_hours", [])
hours_data = dict.fromkeys(active_hours, 1) 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)) night_messages = sum(hours_data.get(h, 0) for h in range(6))
message_count = stats.get("message_count", 0) 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 avg_chars = stats.get("char_count", 0) / message_count
# 称号所需维度 # Các chiều cần cho danh hiệu.
user_summaries.append( user_summaries.append(
{ {
"name": stats.get("nickname", stats.get("name", user_id_str)), "name": stats.get("nickname", stats.get("name", user_id_str)),
@@ -229,13 +226,13 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
if not user_summaries: if not user_summaries:
return {"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) user_summaries.sort(key=lambda x: x["message_count"], reverse=True)
return {"user_summaries": user_summaries} return {"user_summaries": user_summaries}
except Exception as e: 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": []} return {"user_summaries": []}
async def analyze_user_titles( async def analyze_user_titles(
@@ -247,29 +244,31 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[UserTitle], TokenUsage]: ) -> tuple[list[UserTitle], TokenUsage]:
""" """
分析用户称号 Phân tích danh hiệu thành viên.
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
user_analysis: 用户分析统计 user_analysis: Thống kê phân tích thành viên.
umo: 模型唯一标识符 umo: Định danh model.
top_users: 活跃用户列表(从get_top_users获取,可选) top_users: Danh sách thành viên tích cực, tuỳ chọn.
session_id: 会话ID (用于调试模式) session_id: ID phiên dùng cho debug mode.
Returns: Returns:
(用户称号列表, Token使用统计) Tuple danh sách danh hiệu và thống kê token.
""" """
try: 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) user_data = self.prepare_user_data(messages, user_activity, top_users)
if not user_data["user_summaries"]: 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() 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) return await self.analyze(user_data, umo, session_id)
except Exception as e: 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() return [], TokenUsage()
+82 -92
View File
@@ -1,7 +1,4 @@
""" """Điều phối analyzer LLM cho chủ đề, danh hiệu và trích dẫn nổi bật."""
LLM分析器模块
负责协调各个分析器进行话题分析、用户称号分析和金句分析
"""
import asyncio import asyncio
@@ -25,9 +22,8 @@ from .utils.llm_utils import call_provider_with_retry
class LLMAnalyzer(IAnalysisProvider): 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 topic_analyzer: TopicAnalyzer
@@ -36,16 +32,16 @@ class LLMAnalyzer(IAnalysisProvider):
def __init__(self, context, config_manager): def __init__(self, context, config_manager):
""" """
初始化LLM分析器 Khởi tạo analyzer LLM.
Args: Args:
context: AstrBot上下文对象 context: Context AstrBot.
config_manager: 配置管理器 config_manager: Trình quản lý cấu hình.
""" """
self.context = context self.context = context
self.config_manager = config_manager self.config_manager = config_manager
# 初始化各个专门的分析器 # Khởi tạo các analyzer chuyên biệt.
self.topic_analyzer = TopicAnalyzer(context, config_manager) self.topic_analyzer = TopicAnalyzer(context, config_manager)
self.user_title_analyzer = UserTitleAnalyzer(context, config_manager) self.user_title_analyzer = UserTitleAnalyzer(context, config_manager)
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager) self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
@@ -73,24 +69,23 @@ class LLMAnalyzer(IAnalysisProvider):
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[SummaryTopic], TokenUsage]: ) -> tuple[list[SummaryTopic], TokenUsage]:
""" """
使用LLM分析话题 Phân tích chủ đề bằng LLM qua TopicAnalyzer chuyên biệt.
保持原有接口,委托给专门的TopicAnalyzer处理
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
umo: 模型唯一标识符 umo: Định danh duy nhất của model.
session_id: 会话ID (用于调试模式) session_id: ID phiên dùng cho debug mode.
Returns: Returns:
(话题列表, Token使用统计) Tuple danh sách chủ đề và thống kê token.
""" """
try: try:
session_id = self._make_session_id(session_id, umo) 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) return await self.topic_analyzer.analyze_topics(messages, umo, session_id)
except Exception as e: except Exception as e:
logger.error(f"话题分析失败: {e}") logger.error(f"Phân tích chủ đề thất bại: {e}")
return [], TokenUsage() return [], TokenUsage()
async def analyze_user_titles( async def analyze_user_titles(
@@ -102,28 +97,27 @@ class LLMAnalyzer(IAnalysisProvider):
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[UserTitle], TokenUsage]: ) -> tuple[list[UserTitle], TokenUsage]:
""" """
使用LLM分析用户称号 Phân tích danh hiệu thành viên bằng LLM qua UserTitleAnalyzer.
保持原有接口,委托给专门的UserTitleAnalyzer处理
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
user_activity: 用户分析统计 user_activity: Thống kê hoạt động thành viên.
umo: 模型唯一标识符 umo: Định danh duy nhất của model.
top_users: 活跃用户列表(可选) top_users: Danh sách thành viên tích cực, tuỳ chọn.
session_id: 会话ID (用于调试模式) session_id: ID phiên dùng cho debug mode.
Returns: Returns:
(用户称号列表, Token使用统计) Tuple danh sách danh hiệu và thống kê token.
""" """
try: try:
session_id = self._make_session_id(session_id, umo) 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( return await self.user_title_analyzer.analyze_user_titles(
messages, user_activity, umo, top_users, session_id messages, user_activity, umo, top_users, session_id
) )
except Exception as e: 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() return [], TokenUsage()
async def analyze_golden_quotes( async def analyze_golden_quotes(
@@ -133,26 +127,25 @@ class LLMAnalyzer(IAnalysisProvider):
session_id: str | None = None, session_id: str | None = None,
) -> tuple[list[GoldenQuote], TokenUsage]: ) -> tuple[list[GoldenQuote], TokenUsage]:
""" """
使用LLM分析群聊金句 Phân tích trích dẫn nổi bật bằng LLM qua GoldenQuoteAnalyzer.
保持原有接口,委托给专门的GoldenQuoteAnalyzer处理
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
umo: 模型唯一标识符 umo: Định danh duy nhất của model.
session_id: 会话ID (用于调试模式) session_id: ID phiên dùng cho debug mode.
Returns: Returns:
(金句列表, Token使用统计) Tuple danh sách trích dẫn và thống kê token.
""" """
try: try:
session_id = self._make_session_id(session_id, umo) 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( return await self.golden_quote_analyzer.analyze_golden_quotes(
messages, umo, session_id messages, umo, session_id
) )
except Exception as e: 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() return [], TokenUsage()
async def summarize_quality_reviews( async def summarize_quality_reviews(
@@ -162,7 +155,7 @@ class LLMAnalyzer(IAnalysisProvider):
session_id: str | None = None, session_id: str | None = None,
) -> tuple[QualityReview | None, TokenUsage]: ) -> 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( return await self.chat_quality_analyzer.summarize_batch_reviews(
batch_reviews, umo, session_id batch_reviews, umo, session_id
@@ -186,32 +179,32 @@ class LLMAnalyzer(IAnalysisProvider):
QualityReview | None, QualityReview | None,
]: ]:
""" """
并发执行所有分析任务(话题、用户称号、金句),支持按需启用。 Thực thi đồng thời các tác vụ phân tích được bật.
Args: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
user_activity: 用户分析统计 user_activity: Thống kê hoạt động thành viên.
umo: 模型唯一标识符 umo: Định danh model.
top_users: 活跃用户列表(可选) top_users: Danh sách thành viên tích cực.
topic_enabled: 是否启用话题分析 topic_enabled: Có bật phân tích chủ đề hay không.
user_title_enabled: 是否启用用户称号分析 user_title_enabled: Có bật phân tích danh hiệu hay không.
golden_quote_enabled: 是否启用金句分析 golden_quote_enabled: Có bật phân tích trích dẫn hay không.
Returns: Returns:
(话题列表, 用户称号列表, 金句列表, 总Token使用统计) Danh sách chủ đề, danh hiệu, trích dẫn và tổng token.
""" """
try: try:
session_id = self._make_session_id(None, umo) session_id = self._make_session_id(None, umo)
logger.info( 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(): if self.config_manager.get_debug_mode():
self._save_debug_messages(messages, session_id) self._save_debug_messages(messages, session_id)
# 构建并发任务列表 # Xây dựng danh sách tác vụ đồng thời.
tasks = [] tasks = []
task_names = [] task_names = []
@@ -250,7 +243,7 @@ class LLMAnalyzer(IAnalysisProvider):
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# 处理结果 # Xử lý kết quả.
topics, topic_usage = [], TokenUsage() topics, topic_usage = [], TokenUsage()
user_titles, title_usage = [], TokenUsage() user_titles, title_usage = [], TokenUsage()
golden_quotes, quote_usage = [], TokenUsage() golden_quotes, quote_usage = [], TokenUsage()
@@ -260,7 +253,7 @@ class LLMAnalyzer(IAnalysisProvider):
for i, result in enumerate(results): for i, result in enumerate(results):
name = task_names[i] name = task_names[i]
if isinstance(result, Exception): 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 continue
if name == "topic" and isinstance(result, tuple): if name == "topic" and isinstance(result, tuple):
@@ -274,7 +267,7 @@ class LLMAnalyzer(IAnalysisProvider):
if not isinstance(quality_usage, TokenUsage): if not isinstance(quality_usage, TokenUsage):
quality_usage = TokenUsage() quality_usage = TokenUsage()
# 合并Token使用统计 # Gộp thống kê sử dụng token.
total_usage = TokenUsage( total_usage = TokenUsage(
prompt_tokens=topic_usage.prompt_tokens prompt_tokens=topic_usage.prompt_tokens
+ title_usage.prompt_tokens + title_usage.prompt_tokens
@@ -291,7 +284,7 @@ class LLMAnalyzer(IAnalysisProvider):
) )
logger.info( 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 ( return (
topics, topics,
@@ -302,7 +295,7 @@ class LLMAnalyzer(IAnalysisProvider):
) )
except Exception as e: 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 return [], [], [], TokenUsage(), None
async def analyze_incremental_concurrent( async def analyze_incremental_concurrent(
@@ -316,39 +309,38 @@ class LLMAnalyzer(IAnalysisProvider):
chat_quality_enabled: bool = False, chat_quality_enabled: bool = False,
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]: ) -> 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: Args:
messages: 本次增量分析的群聊消息列表 messages: Tin nhắn nhóm của lần phân tích gia tăng.
umo: 模型唯一标识符 umo: Định danh model.
topics_per_batch: 本次批次最大话题数量 topics_per_batch: Số chủ đề tối đa trong batch.
quotes_per_batch: 本次批次最大金句数量 quotes_per_batch: Số trích dẫn tối đa trong batch.
topic_enabled: 是否启用话题分析 topic_enabled: Có bật phân tích chủ đề hay không.
golden_quote_enabled: 是否启用金句分析 golden_quote_enabled: Có bật phân tích trích dẫn hay không.
Returns: Returns:
(话题列表, 金句列表, 总Token使用统计) Danh sách chủ đề, trích dẫn và tổng token.
""" """
try: try:
session_id = self._make_session_id(None, umo, "incr_") session_id = self._make_session_id(None, umo, "incr_")
logger.info( logger.info(
f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled})" 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"消息数量: {len(messages)},会话ID: {session_id}" 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(): if self.config_manager.get_debug_mode():
self._save_debug_messages(messages, session_id) 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.topic_analyzer._incremental_max_count = topics_per_batch
self.golden_quote_analyzer._incremental_max_count = quotes_per_batch self.golden_quote_analyzer._incremental_max_count = quotes_per_batch
try: try:
# 构建并发任务列表(仅话题和金句,不包含用户称号) # Xây dựng tác vụ đồng thời, không gồm danh hiệu thành viên.
tasks = [] tasks = []
task_names = [] task_names = []
@@ -379,7 +371,7 @@ class LLMAnalyzer(IAnalysisProvider):
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# 处理结果 # Xử lý kết quả.
topics, topic_usage = [], TokenUsage() topics, topic_usage = [], TokenUsage()
golden_quotes, quote_usage = [], TokenUsage() golden_quotes, quote_usage = [], TokenUsage()
chat_quality_review = None chat_quality_review = None
@@ -388,7 +380,7 @@ class LLMAnalyzer(IAnalysisProvider):
for i, result in enumerate(results): for i, result in enumerate(results):
name = task_names[i] name = task_names[i]
if isinstance(result, Exception): 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 continue
if name == "topic" and isinstance(result, tuple): if name == "topic" and isinstance(result, tuple):
@@ -400,7 +392,7 @@ class LLMAnalyzer(IAnalysisProvider):
if not isinstance(quality_usage, TokenUsage): if not isinstance(quality_usage, TokenUsage):
quality_usage = TokenUsage() quality_usage = TokenUsage()
# 合并Token使用统计 # Gộp thống kê sử dụng token.
total_usage = TokenUsage( total_usage = TokenUsage(
prompt_tokens=topic_usage.prompt_tokens prompt_tokens=topic_usage.prompt_tokens
+ quote_usage.prompt_tokens + quote_usage.prompt_tokens
@@ -414,27 +406,27 @@ class LLMAnalyzer(IAnalysisProvider):
) )
logger.info( logger.info(
f"增量并发分析完成 - 话题: {len(topics)}, 金句: {len(golden_quotes)}, 质量锐评: {1 if chat_quality_review else 0}, " 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}" f"token: {total_usage.total_tokens}"
) )
return topics, golden_quotes, total_usage, chat_quality_review return topics, golden_quotes, total_usage, chat_quality_review
finally: 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.topic_analyzer._incremental_max_count = None
self.golden_quote_analyzer._incremental_max_count = None self.golden_quote_analyzer._incremental_max_count = None
except Exception as e: 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 return [], [], TokenUsage(), None
def _save_debug_messages(self, messages: list[dict], session_id: str): 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: Args:
messages: 群聊消息列表 messages: Danh sách tin nhắn nhóm.
session_id: 会话ID session_id: ID phiên.
""" """
try: try:
import json import json
@@ -450,7 +442,7 @@ class LLMAnalyzer(IAnalysisProvider):
except Exception: except Exception:
pass pass
# 向后兼容的方法,保持原有调用方式 # Phương thức tương thích ngược, giữ cách gọi cũ.
async def _call_provider_with_retry( async def _call_provider_with_retry(
self, self,
provider, provider,
@@ -459,17 +451,16 @@ class LLMAnalyzer(IAnalysisProvider):
provider_id_key: str | None = None, provider_id_key: str | None = None,
): ):
""" """
向后兼容的LLM调用方法 Phương thức gọi LLM tương thích ngược, uỷ quyền cho llm_utils.
现在委托给llm_utils模块处理
Args: Args:
provider: LLM服务商实例或None(已弃用,现在使用 provider_id_key provider: Provider LLM hoặc None; đã deprecated.
prompt: 输入的提示语 prompt: Prompt đầu vào.
umo: 指定使用的模型唯一标识符 umo: Định danh model cần dùng.
provider_id_key: 配置中的 provider_id 键名(可选) provider_id_key: Tên key provider_id tuỳ chọn trong cấu hình.
Returns: Returns:
LLM生成的结果 Kết quả do LLM tạo.
""" """
return await call_provider_with_retry( return await call_provider_with_retry(
self.context, self.context,
@@ -481,13 +472,12 @@ class LLMAnalyzer(IAnalysisProvider):
def _fix_json(self, text: str) -> str: def _fix_json(self, text: str) -> str:
""" """
向后兼容的JSON修复方法 Phương thức sửa JSON tương thích ngược, uỷ quyền cho json_utils.
现在委托给json_utils模块处理
Args: Args:
text: 需要修复的JSON文本 text: Văn bản JSON cần sửa.
Returns: Returns:
修复后的JSON文本 Văn bản JSON sau khi sửa.
""" """
return fix_json(text) return fix_json(text)
@@ -1,7 +1,4 @@
""" """Module tiện ích phân tích, xử lý JSON và yêu cầu API LLM."""
分析工具模块
包含JSON处理和LLM API请求处理工具
"""
from .info_utils import InfoUtils from .info_utils import InfoUtils
from .json_utils import ( from .json_utils import (
@@ -2,9 +2,9 @@ class InfoUtils:
@staticmethod @staticmethod
def get_user_nickname(config_manager, sender) -> str: 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() enable_user_card = config_manager.get_enable_user_card()
if enable_user_card: if enable_user_card:
+97 -87
View File
@@ -1,7 +1,4 @@
""" """Công cụ phân tích, sửa JSON và trích xuất bằng regex."""
JSON处理工具模块
提供JSON解析、修复和正则提取功能
"""
import json import json
import re import re
@@ -12,130 +9,132 @@ from ....utils.logger import logger
def fix_json(text: str) -> str: 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: Args:
text: 需要修复的JSON文本 text: Văn bản JSON cần sửa.
Returns: Returns:
修复后的JSON文本 Văn bản JSON sau khi sửa.
""" """
try: try:
# 1. 移除markdown代码块标记 # 1. Xoá marker code block Markdown.
text = re.sub(r"```json\s*", "", text) text = re.sub(r"```json\s*", "", text)
text = re.sub(r"```\s*$", "", text) text = re.sub(r"```\s*$", "", text)
# 2. 基础清理 # 2. Làm sạch cơ bản.
text = text.replace("\n", " ").replace("\r", " ") text = text.replace("\n", " ").replace("\r", " ")
text = re.sub(r"\s+", " ", text) 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("", '"')
text = text.replace("", "'").replace("", "'") text = text.replace("", "'").replace("", "'")
# 中文逗号 -> 英文逗号 # Dấu phẩy tiếng Trung -> dấu phẩy ASCII.
text = text.replace("", ",") text = text.replace("", ",")
# 中文冒号 -> 英文冒号 # Dấu hai chấm tiếng Trung -> dấu hai chấm ASCII.
text = text.replace("", ":") text = text.replace("", ":")
# 中文括号 -> 英文括号 # Dấu ngoặc tiếng Trung -> dấu ngoặc ASCII.
text = text.replace("", "(").replace("", ")") text = text.replace("", "(").replace("", ")")
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): def escape_quotes_in_strings(match):
content = match.group(1) content = match.group(1)
# 转义内部的双引号 # Escape dấu ngoặc kép bên trong.
content = content.replace('"', '\\"') content = content.replace('"', '\\"')
return f'"{content}"' 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) text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
# 5. 修复截断的JSON # 5. Sửa JSON bị cắt ngắn.
if not text.endswith("]"): if not text.endswith("]"):
last_complete = text.rfind("}") last_complete = text.rfind("}")
if last_complete > 0: if last_complete > 0:
text = text[: last_complete + 1] + "]" text = text[: last_complete + 1] + "]"
# 6. 修复常见的JSON格式问题 # 6. Sửa các lỗi định dạng JSON phổ biến.
# 1. 修复缺失的逗号 # 1. Bổ sung dấu phẩy bị thiếu.
text = re.sub(r"}\s*{", "}, {", text) 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): def quote_field_names(match):
prefix = match.group(1) prefix = match.group(1)
key = match.group(2) key = match.group(2)
return f'{prefix}"{key}":' 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) 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)
text = re.sub(r",\s*]", "]", text) text = re.sub(r",\s*]", "]", text)
return text.strip() return text.strip()
except Exception as e: except Exception as e:
logger.error(f"JSON修复失败: {e}") logger.error(f"Sửa JSON thất bại: {e}")
return text return text
def _parse_json_with_pattern( 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]: ) -> 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 fixed_json_text = None
try: try:
# 1. 基础清理:去除 markdown 代码块标记 # 1. Làm sạch cơ bản: xoá marker code block Markdown.
clean_text = result_text.strip() clean_text = result_text.strip()
clean_text = re.sub(r"```(?:json)?\s*", "", clean_text) clean_text = re.sub(r"```(?:json)?\s*", "", clean_text)
clean_text = re.sub(r"```\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) json_match = re.search(pattern, clean_text, re.DOTALL)
if not json_match: 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) logger.warning(error_msg)
return False, None, error_msg return False, None, error_msg
json_text = json_match.group() 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: try:
data = json.loads(json_text) data = json.loads(json_text)
count_info = f",包含 {len(data)} 条数据" if isinstance(data, list) else "" count_info = f", gồm {len(data)} mục" if isinstance(data, list) else ""
logger.info(f"{data_type}直接解析成功{count_info}") logger.info(f"Parse trực tiếp {data_type} thành công{count_info}")
return True, data, None return True, data, None
except json.JSONDecodeError: 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) 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) fixed_match = re.search(pattern, fixed_json_text, re.DOTALL)
if fixed_match: if fixed_match:
try: try:
data = json.loads(fixed_match.group()) data = json.loads(fixed_match.group())
count_info = ( count_info = f", gồm {len(data)} mục" if isinstance(data, list) else ""
f",包含 {len(data)} 条数据" if isinstance(data, list) else "" logger.info(f"Parse {data_type} thành công sau khi sửa{count_info}")
)
logger.info(f"{data_type}修复后解析成功{count_info}")
return True, data, None return True, data, None
except json.JSONDecodeError as e: 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) logger.warning(error_msg)
return False, None, 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 return False, None, error_msg
except Exception as e: except Exception as e:
error_msg = f"{data_type}解析异常: {e}" error_msg = f"Lỗi parse {data_type}: {e}"
logger.error(error_msg) logger.error(error_msg)
return False, None, error_msg return False, None, error_msg
@@ -144,10 +143,10 @@ def parse_json_response(
result_text: str, data_type: str result_text: str, data_type: str
) -> tuple[bool, list[dict] | None, str | None]: ) -> 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( 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 result_text: str, data_type: str
) -> tuple[bool, dict | None, str | None]: ) -> 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( 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: 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", " ") return text.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
def extract_topics_with_regex(result_text: str, max_topics: int) -> list[dict]: 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: Args:
result_text: 需要提取的文本 result_text: Văn bản cần trích xuất.
max_topics: 最大话题数量 max_topics: Số chủ đề tối đa.
Returns: Returns:
话题数据列表 Danh sách dữ liệu chủ đề.
""" """
try: 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*\}' topic_pattern = r'\{\s*"topic":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"contributors":\s*\[(.*?)\],?\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
matches = re.findall(topic_pattern, result_text, re.DOTALL) matches = re.findall(topic_pattern, result_text, re.DOTALL)
if not matches: if not matches:
# 尝试更宽松的匹配 # Thử pattern linh hoạt hơn.
topic_pattern = r'"topic":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"contributors":\s*\[(.*?)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"' topic_pattern = r'"topic":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"contributors":\s*\[(.*?)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"'
matches = re.findall(topic_pattern, result_text, re.DOTALL) 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() contributors_str = match[1].strip()
detail = _clean_json_string(match[2].strip()) detail = _clean_json_string(match[2].strip())
# 解析参与者列表 # Parse danh sách người tham gia.
contributors = [ contributors = [
contrib.strip() contrib.strip()
for contrib in re.findall(r'"([^"]+)"', contributors_str) for contrib in re.findall(r'"([^"]+)"', contributors_str)
] or ["群友"] ] or ["Thành viên"]
topics.append( topics.append(
{ {
"topic": topic_name, "topic": topic_name,
"contributors": contributors[:5], # 最多5个参与者 "contributors": contributors[:5], # Tối đa 5 người tham gia
"detail": detail, "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 return topics
except Exception as e: 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 [] return []
def extract_user_titles_with_regex(result_text: str, max_count: int) -> list[dict]: 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: Args:
result_text: 需要提取的文本 result_text: Văn bản cần trích xuất.
max_count: 最大提取数量 max_count: Số lượng tối đa.
Returns: Returns:
用户称号数据列表 Danh sách dữ liệu danh hiệu.
""" """
try: try:
titles = [] 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*\}' 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) matches = re.findall(pattern, result_text, re.DOTALL)
if not matches: 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*"([^"]*(?:\\.[^"]*)*)"' pattern = r'"name":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"user_id":\s*"([^"]+)"[^}]*"title":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"mbti":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
matches = re.findall(pattern, result_text, re.DOTALL) 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 return titles
except Exception as e: 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 [] return []
def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> list[dict]: 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: Args:
result_text: 需要提取的文本 result_text: Văn bản cần trích xuất.
max_count: 最大提取数量 max_count: Số lượng tối đa.
Returns: Returns:
金句数据列表 Danh sách dữ liệu trích dẫn.
""" """
try: try:
quotes = [] 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*\}' pattern = r'\{\s*"content":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"sender":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
matches = re.findall(pattern, result_text, re.DOTALL) matches = re.findall(pattern, result_text, re.DOTALL)
if not matches: 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*"([^"]*(?:\\.[^"]*)*)"' pattern = r'"content":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"sender":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
matches = re.findall(pattern, result_text, re.DOTALL) 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}) 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 return quotes
except Exception as e: 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 [] return []
def extract_quality_with_regex(result_text: str) -> dict | None: 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: Args:
result_text: LLM 返回的原始文本 result_text: Văn bản gốc do LLM trả về.
Returns: Returns:
解析后的质量分析字典,失败返回 None Dict chất lượng sau khi parse hoặc None nếu thất bại.
""" """
try: try:
title_m = re.search(r'"title"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', result_text) 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: 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 return None
data = { data = {
"title": title_m.group(1) if title_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 "今天的群里发生了什么?", "subtitle": subtitle_m.group(1)
if subtitle_m
else "Hôm nay nhóm đã có chuyện gì?",
"dimensions": dims, "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 return data
except Exception as e: 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 return None
+101 -104
View File
@@ -1,7 +1,4 @@
""" """Công cụ gọi API LLM và thống kê token."""
LLM API请求处理工具模块
提供LLM调用和token统计功能
"""
import asyncio import asyncio
import random import random
@@ -19,7 +16,7 @@ _circuit_breakers = {}
def _is_response_format_unsupported_error(error: Exception) -> bool: 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() text = str(error).lower()
patterns = [ patterns = [
@@ -46,7 +43,7 @@ async def _call_provider_stream(
) -> LLMResponse: ) -> LLMResponse:
provider = context.get_provider_by_id(provider_id=provider_id) provider = context.get_provider_by_id(provider_id=provider_id)
if provider is None: 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 = dict(llm_kwargs)
stream_kwargs.pop("chat_provider_id", None) stream_kwargs.pop("chat_provider_id", None)
@@ -61,7 +58,7 @@ async def _call_provider_stream(
content_parts.append(text) content_parts.append(text)
if final_resp is None: 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) final_text = extract_response_text(final_resp)
if final_text and not getattr(final_resp, "is_chunk", False): 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 context, provider_id: str, description: str
) -> str | None: ) -> str | None:
""" """
尝试通过 ID 获取 Provider ID 的辅助函数 Thử xác thực và lấy Provider ID theo ID cấu hình.
Args: Args:
context: AstrBot上下文对象 context: Context AstrBot.
provider_id: Provider ID provider_id: Provider ID
description: 描述信息,用于日志 description: Mô tả dùng trong log.
Returns: Returns:
Provider ID None Provider ID hoặc None.
""" """
if not provider_id or not isinstance(provider_id, str) or not provider_id.strip(): if not provider_id or not isinstance(provider_id, str) or not provider_id.strip():
return None return None
provider_id = provider_id.strip() provider_id = provider_id.strip()
logger.info(f"尝试使用{description}: {provider_id}") logger.info(f"Thử dùng {description}: {provider_id}")
try: try:
# 验证 Provider 是否存在 # Xác thực provider tồn tại.
provider = context.get_provider_by_id(provider_id=provider_id) provider = context.get_provider_by_id(provider_id=provider_id)
if provider: if provider:
logger.info(f"使用{description}: {provider_id}") logger.info(f"Dùng {description}: {provider_id}")
return provider_id return provider_id
except Exception as e: 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 return None
async def _try_get_session_provider_id(context, umo: str | None) -> str | 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: Args:
context: AstrBot上下文对象 context: Context AstrBot.
umo: unified_msg_origin umo: unified_msg_origin
Returns: Returns:
Provider ID None Provider ID hoặc None.
""" """
try: 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) provider_id = await context.get_current_chat_provider_id(umo=umo)
if provider_id: 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 return provider_id
except Exception as e: 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 return None
async def _try_get_first_available_provider_id(context) -> str | 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: Args:
context: AstrBot上下文对象 context: Context AstrBot.
Returns: Returns:
Provider ID None Provider ID hoặc None.
""" """
try: try:
all_providers = context.get_all_providers() all_providers = context.get_all_providers()
@@ -144,12 +141,12 @@ async def _try_get_first_available_provider_id(context) -> str | None:
try: try:
meta = provider.meta() meta = provider.meta()
provider_id = meta.id 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 return provider_id
except Exception: except Exception:
logger.warning("第一个 Provider 无法获取 ID") logger.warning("Không thể lấy ID của provider đầu tiên")
except Exception as e: except Exception as e:
logger.warning(f"无法获取任何 Provider: {e}") logger.warning(f"Không thể lấy provider nào: {e}")
return None return None
@@ -160,33 +157,30 @@ async def get_provider_id_with_fallback(
umo: str | None = None, umo: str | None = None,
) -> str | None: ) -> str | None:
""" """
根据配置键获取 Provider ID,支持多级回退 Lấy Provider ID theo key cấu hình với fallback nhiều cấp.
回退顺序: Thứ tự fallback: provider riêng của tác vụ, provider LLM chính, provider
1. 尝试从配置获取指定的 provider_id(如 topic_provider_id của phiên hiện tại, rồi provider khả dụng đầu tiên.
2. 回退到主 LLM provider_idllm_provider_id
3. 回退到当前会话的 Provider(通过 umo
4. 回退到第一个可用的 Provider
Args: Args:
context: AstrBot上下文对象 context: Context AstrBot.
config_manager: 配置管理器 config_manager: Trình quản lý cấu hình.
provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id' provider_id_key: Key provider_id trong cấu hình.
umo: unified_msg_origin,用于获取会话默认 Provider umo: unified_msg_origin để lấy provider mặc định của phiên.
Returns: Returns:
Provider ID None Provider ID hoặc None.
""" """
try: try:
# 输出Provider选择开始日志 # Ghi log bắt đầu chọn provider.
task_desc = provider_id_key if provider_id_key else "默认任务" task_desc = provider_id_key if provider_id_key else "tác vụ mặc định"
logger.info(f"[Provider 选择] 开始为 {task_desc} 选择 Provider...") 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 = [] strategies = []
strategy_names = [] strategy_names = []
# 1. 特定任务的 provider_id # 1. provider_id riêng của tác vụ.
if provider_id_key: if provider_id_key:
getter_method = f"get_{provider_id_key}" getter_method = f"get_{provider_id_key}"
if hasattr(config_manager, getter_method): if hasattr(config_manager, getter_method):
@@ -194,46 +188,48 @@ async def get_provider_id_with_fallback(
if specific_provider_id: if specific_provider_id:
strategies.append( strategies.append(
lambda pid=specific_provider_id: _try_get_provider_id_by_id( 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() main_provider_id = config_manager.get_llm_provider_id()
if main_provider_id: if main_provider_id:
strategies.append( strategies.append(
lambda pid=main_provider_id: _try_get_provider_id_by_id( 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)) 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)) 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")
# 输出回退策略列表 # Ghi thứ tự chiến lược fallback.
logger.info(f"[Provider 选择] 回退策略顺序:{' -> '.join(strategy_names)}") 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): for idx, strategy in enumerate(strategies):
provider_id = await strategy() provider_id = await strategy()
if provider_id: if provider_id:
logger.info( 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 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 return None
except Exception as e: 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 return None
@@ -249,31 +245,30 @@ async def call_provider_with_retry(
extra_generate_kwargs: dict[str, JSONValue] | None = None, extra_generate_kwargs: dict[str, JSONValue] | None = None,
) -> LLMResponse | 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: Args:
context: AstrBot上下文对象 context: Context AstrBot.
config_manager: 配置管理器 config_manager: Trình quản lý cấu hình.
prompt: 输入的提示语 prompt: Prompt đầu vào.
umo: 指定使用的模型唯一标识符 umo: Định danh model cần dùng.
provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id'),用于选择特定的 Provider provider_id_key: Key provider_id để chọn provider riêng.
system_prompt: 系统提示词 system_prompt: System prompt.
response_format: 结构化输出约束(OpenAI 风格) response_format: Ràng buộc output có cấu trúc kiểu OpenAI.
extra_generate_kwargs: 传递给 context.llm_generate 的附加参数(用于内部高级重试策略) extra_generate_kwargs: Tham số bổ sung cho context.llm_generate.
Returns: Returns:
LLM生成的结果,失败时返回None Kết quả LLM hoặc None nếu thất bại.
""" """
# 注意: 超时由 AstrBot Provider 内部配置控制,不再使用插件层 asyncio.wait_for # Timeout do provider AstrBot kiểm soát và có thể cấu hình trong WebUI.
# 用户可在 AstrBot WebUI 中为每个 Provider 配置 timeout 参数
retries = config_manager.get_llm_retries() retries = config_manager.get_llm_retries()
backoff = config_manager.get_llm_backoff() backoff = config_manager.get_llm_backoff()
enable_streaming_llm_call = config_manager.get_enable_streaming_llm_call() 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 = [] attempt_queue = []
# 尝试获取指定的 Provider # Thử lấy provider được chỉ định.
specific_provider_id = provider_id specific_provider_id = provider_id
if not specific_provider_id: if not specific_provider_id:
specific_provider_id = await get_provider_id_with_fallback( 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) attempt_queue.extend([(specific_provider_id, False)] * retries)
if not attempt_queue: if not attempt_queue:
logger.error("无可用 Provider,无法调用 llm_generate") logger.error("Không có provider khả dụng để gọi llm_generate")
return None return None
# 2. 核心请求执行闭包 # 2. Closure thực thi yêu cầu cốt lõi.
async def _execute_llm_request( async def _execute_llm_request(
pid: str, r_format: JSONObject | None pid: str, r_format: JSONObject | None
) -> LLMResponse: ) -> LLMResponse:
cb = _get_circuit_breaker(pid) cb = _get_circuit_breaker(pid)
if not cb.allow_request(): 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") raise Exception("Circuit breaker open")
try: try:
@@ -320,31 +317,31 @@ async def call_provider_with_retry(
cb.record_failure() cb.record_failure()
raise err raise err
# 3. 开始执行队列 # 3. Bắt đầu xử lý hàng đợi.
last_exc = None last_exc = None
current_response_format = response_format current_response_format = response_format
# 记录上一次尝试的 Provider ID,用于判断是否发生切换 # Lưu Provider ID trước để phát hiện chuyển provider.
previous_pid = None 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 needs_fallback = provider_id_key is not None
for i, (current_pid, is_fallback) in enumerate(attempt_queue): for i, (current_pid, is_fallback) in enumerate(attempt_queue):
attempt_num = i + 1 attempt_num = i + 1
# 修复状态污染:如果切换了全新的 Provider,必须重置 response_format 约束 # Reset response_format khi chuyển sang provider mới.
if current_pid != previous_pid: if current_pid != previous_pid:
current_response_format = response_format current_response_format = response_format
previous_pid = current_pid previous_pid = current_pid
prefix = "[降级补偿] " if is_fallback else "[LLM 调用] " prefix = "[Fallback] " if is_fallback else "[Gọi LLM] "
logger.info( logger.info(
f"{prefix}尝试 #{attempt_num} | Provider ID: {current_pid} | " f"{prefix}Lần thử #{attempt_num} | Provider ID: {current_pid} | "
f"prompt长度={len(prompt) if prompt else 0}字符" f"độ dài prompt={len(prompt) if prompt else 0} ký tự"
) )
if not prompt or not prompt.strip(): 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 return None
try: try:
@@ -353,16 +350,16 @@ async def call_provider_with_retry(
except Exception as e: except Exception as e:
last_exc = e last_exc = e
# 处理不支持 response_format 的情况 # Xử lý provider không hỗ trợ response_format.
if ( if (
current_response_format is not None current_response_format is not None
and _is_response_format_unsupported_error(e) and _is_response_format_unsupported_error(e)
): ):
logger.warning( 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 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: try:
return await _execute_llm_request( return await _execute_llm_request(
current_pid, current_response_format current_pid, current_response_format
@@ -370,8 +367,8 @@ async def call_provider_with_retry(
except Exception as inner_e: except Exception as inner_e:
last_exc = inner_e last_exc = inner_e
logger.warning(f"{prefix}请求失败: {last_exc}") logger.warning(f"{prefix}Yêu cầu thất bại: {last_exc}")
# 惰性降级:仅当所有 primary provider 的重试都耗尽后才 resolve 并注入 fallback # Chỉ resolve và thêm fallback khi hết retry provider chính.
if not is_fallback and i == retries - 1 and needs_fallback: if not is_fallback and i == retries - 1 and needs_fallback:
fallback_provider_id = await get_provider_id_with_fallback( fallback_provider_id = await get_provider_id_with_fallback(
context, config_manager, None, umo context, config_manager, None, umo
@@ -387,46 +384,46 @@ async def call_provider_with_retry(
if not is_last_attempt: if not is_last_attempt:
# Exponential backoff with jitter: backoff * (2 ^ (attempt_num - 1)) + random jitter # Exponential backoff with jitter: backoff * (2 ^ (attempt_num - 1)) + random jitter
sleep_time = backoff * (2 ** (attempt_num - 1)) + random.uniform(0, 1) 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) 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 return None
def extract_token_usage(response) -> dict: def extract_token_usage(response) -> dict:
""" """
从LLM响应中提取token使用统计 Trích xuất thống kê token từ phản hồi LLM.
Args: Args:
response: LLM响应对象 response: Đối tượng phản hồi LLM.
Returns: Returns:
Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens Dict gồm prompt_tokens, completion_tokens total_tokens.
""" """
token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
try: try:
# 1. 尝试直接获取 response.usage # 1. Thử lấy trực tiếp response.usage.
usage = getattr(response, "usage", None) 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"): if not usage and hasattr(response, "raw_completion"):
usage = getattr(response.raw_completion, "usage", None) usage = getattr(response.raw_completion, "usage", None)
# 3. 如果 response 本身就是 dict (某些特殊情况) # 3. Xử lý trường hợp response dict.
if not usage and isinstance(response, dict): if not usage and isinstance(response, dict):
usage = response.get("usage") usage = response.get("usage")
if 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) # AstrBot TokenUsage define: input (prop), output (attr), total (prop)
if hasattr(usage, "input") and hasattr(usage, "output"): if hasattr(usage, "input") and hasattr(usage, "output"):
token_usage["prompt_tokens"] = getattr(usage, "input", 0) or 0 token_usage["prompt_tokens"] = getattr(usage, "input", 0) or 0
token_usage["completion_tokens"] = getattr(usage, "output", 0) or 0 token_usage["completion_tokens"] = getattr(usage, "output", 0) or 0
token_usage["total_tokens"] = getattr(usage, "total", 0) or 0 token_usage["total_tokens"] = getattr(usage, "total", 0) or 0
# 处理 usage 是字典的情况 # Xử lý usage dạng dict.
elif isinstance(usage, dict): elif isinstance(usage, dict):
token_usage["prompt_tokens"] = usage.get("prompt_tokens", 0) or 0 token_usage["prompt_tokens"] = usage.get("prompt_tokens", 0) or 0
token_usage["completion_tokens"] = ( 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 token_usage["total_tokens"] = usage.get("total_tokens", 0) or 0
# 处理 OpenAI CompletionUsage 等标准对象 # Xử lý object chuẩn như OpenAI CompletionUsage.
else: else:
token_usage["prompt_tokens"] = getattr(usage, "prompt_tokens", 0) or 0 token_usage["prompt_tokens"] = getattr(usage, "prompt_tokens", 0) or 0
token_usage["completion_tokens"] = ( token_usage["completion_tokens"] = (
@@ -445,19 +442,19 @@ def extract_token_usage(response) -> dict:
return token_usage return token_usage
except Exception as e: 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} return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
def extract_response_text(response) -> str: 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: Args:
response: LLM响应对象 response: Đối tượng phản hồi LLM.
Returns: Returns:
响应文本内容 Nội dung văn bản phản hồi.
""" """
try: try:
if hasattr(response, "completion_text"): if hasattr(response, "completion_text"):
@@ -465,5 +462,5 @@ def extract_response_text(response) -> str:
else: else:
return str(response) return str(response)
except Exception as e: 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 "" return ""
+160 -171
View File
@@ -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 import AstrBotConfig
from astrbot.api.star import StarTools from astrbot.api.star import StarTools
@@ -12,43 +9,38 @@ from ..utils.template_utils import upgrade_str_format_template
class ConfigManager: class ConfigManager:
"""配置管理器 """Trình quản lý cấu hình.
配置结构采用分组嵌套方式,顶层分为以下分组: Cấu hình được nhóm lồng nhau ở cấp cao nhất: ``basic``, ``qq_official``,
- basic: 基础设置 ``auto_analysis``, ``llm``, ``analysis_features``, ``incremental`` và ``prompts``.
- qq_official: QQ 官方机器人展示设置
- auto_analysis: 自动分析设置
- llm: LLM 设置
- analysis_features: 分析功能开关
- incremental: 增量分析设置
- prompts: 提示词模板
""" """
def __init__(self, config: AstrBotConfig): def __init__(self, config: AstrBotConfig):
self.config = config self.config = config
def _get_group(self, group: str) -> dict: 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, {}) return self.config.get(group, {})
def _ensure_group(self, group: str) -> dict: 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: if group not in self.config:
self.config[group] = {} self.config[group] = {}
return self.config[group] return self.config[group]
def get_group_list_mode(self) -> str: 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") return self._get_group("basic").get("group_list_mode", "none")
def get_group_list(self) -> list[str]: 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", []) return self._get_group("basic").get("group_list", [])
def is_group_allowed(self, group_id_or_umo: str) -> bool: def is_group_allowed(self, group_id_or_umo: str) -> bool:
""" """
根据配置的白/黑名单判断是否允许在该群聊中使用 Kiểm tra nhóm có được phép theo whitelist/blacklist.
支持传入 simple group_id 或 UMO (Unified Message Origin)
Hỗ trợ group_id đơn giản hoặc UMO (Unified Message Origin).
""" """
mode = self.get_group_list_mode().lower() mode = self.get_group_list_mode().lower()
if mode not in ("whitelist", "blacklist", "none"): if mode not in ("whitelist", "blacklist", "none"):
@@ -71,19 +63,19 @@ class ConfigManager:
def _is_group_match(self, target: str, item: str) -> bool: def _is_group_match(self, target: str, item: str) -> bool:
""" """
核心匹配逻辑:判断名单中的 item 是否匹配目标的 target (Unified Message Origin, UMO 或 纯 ID)。 So khớp item danh sách với UMO hoặc ID đích, hỗ trợ topic Telegram (#)
支持处理 Telegram 话题 (#) 和 独立隔离会话 (_) 的双向穿透匹配。 và phiên cô lập (_) theo cả hai chiều.
""" """
if item == target: if item == target:
return True 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: if ":" in target:
target_prefix, target_id = target.rsplit(":", 1) target_prefix, target_id = target.rsplit(":", 1)
else: else:
target_prefix, target_id = "", target target_prefix, target_id = "", target
# 生成目标 ID 的所有“穿透”候选 (处理隔离模式和话题) # Tạo mọi ứng viên ID cho chế độ cô lập và topic.
candidates = {target_id} candidates = {target_id}
if "#" in target_id: if "#" in target_id:
candidates.add(target_id.split("#", 1)[0]) candidates.add(target_id.split("#", 1)[0])
@@ -91,16 +83,16 @@ class ConfigManager:
for part in target_id.split("_"): for part in target_id.split("_"):
candidates.add(part) candidates.add(part)
# 检查名单项 (item) 的格式 # Kiểm tra định dạng item trong danh sách.
if ":" in item: if ":" in item:
i_prefix, i_id = item.rsplit(":", 1) 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: if target_prefix and i_prefix != target_prefix:
return False return False
else: else:
i_id = item i_id = item
# [修复] 名单项 ID 也可能包含复合形式 (如 UserId_GroupId),需要拆解匹配 # ID trong danh sách có thể ở dạng ghép như UserId_GroupId.
item_variants = {i_id} item_variants = {i_id}
if "#" in i_id: if "#" in i_id:
item_variants.add(i_id.split("#", 1)[0]) item_variants.add(i_id.split("#", 1)[0])
@@ -108,120 +100,119 @@ class ConfigManager:
for part in i_id.split("_"): for part in i_id.split("_"):
item_variants.add(part) 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) return not item_variants.isdisjoint(candidates)
def get_max_messages(self) -> int: def get_max_messages(self) -> int:
"""获取最大消息数量""" """Lấy số tin nhắn tối đa."""
return self._get_group("basic").get("max_messages", 1000) return self._get_group("basic").get("max_messages", 1000)
def get_analysis_days(self) -> int: def get_analysis_days(self) -> int:
"""获取分析天数""" """Lấy số ngày phân tích."""
return self._get_group("basic").get("analysis_days", 1) return self._get_group("basic").get("analysis_days", 1)
def get_auto_analysis_time(self) -> list[str]: 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") group = self._get_group("auto_analysis")
val = group.get("auto_analysis_time", ["09:00"]) 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): if isinstance(val, str):
val_list = [val] val_list = [val]
# 自动修复配置格式 # Tự sửa định dạng cấu hình.
try: try:
auto_group = self._ensure_group("auto_analysis") auto_group = self._ensure_group("auto_analysis")
auto_group["auto_analysis_time"] = val_list auto_group["auto_analysis_time"] = val_list
self.config.save_config() 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: 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_list
return val if isinstance(val, list) else ["09:00"] return val if isinstance(val, list) else ["09:00"]
def get_enable_auto_analysis(self) -> bool: 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 布尔值; Bản cũ dùng boolean ``enable_auto_analysis``; bản mới suy ra từ danh sách lịch.
新版本改为由 scheduled_group_list_mode + scheduled_group_list 推导。
""" """
return self.is_auto_analysis_enabled() return self.is_auto_analysis_enabled()
def get_output_format(self) -> list[str]: def get_output_format(self) -> list[str]:
"""获取输出格式""" """Lấy định dạng output."""
val = self._get_group("basic").get("output_format", ["image"]) val = self._get_group("basic").get("output_format", ["image"])
return val if isinstance(val, list) else [val] return val if isinstance(val, list) else [val]
def get_qq_official_t2i_summary_dashboard_enabled(self) -> bool: 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") group = self._get_group("qq_official")
if "enable_t2i_summary_dashboard" in group: if "enable_t2i_summary_dashboard" in group:
return bool(group["enable_t2i_summary_dashboard"]) return bool(group["enable_t2i_summary_dashboard"])
return bool(group.get("enable_t2i_activity_histogram", True)) return bool(group.get("enable_t2i_activity_histogram", True))
def get_min_messages_threshold(self) -> int: 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) return self._get_group("basic").get("min_messages_threshold", 50)
def get_topic_analysis_enabled(self) -> bool: 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) return self._get_group("analysis_features").get("topic_analysis_enabled", True)
def get_user_title_analysis_enabled(self) -> bool: 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( return self._get_group("analysis_features").get(
"user_title_analysis_enabled", True "user_title_analysis_enabled", True
) )
def get_golden_quote_analysis_enabled(self) -> bool: 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( return self._get_group("analysis_features").get(
"golden_quote_analysis_enabled", True "golden_quote_analysis_enabled", True
) )
def get_chat_quality_analysis_enabled(self) -> bool: 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( return self._get_group("analysis_features").get(
"chat_quality_analysis_enabled", False "chat_quality_analysis_enabled", False
) )
def get_max_topics(self) -> int: def get_max_topics(self) -> int:
"""获取最大话题数量""" """Lấy số chủ đề tối đa."""
return self._get_group("analysis_features").get("max_topics", 5) return self._get_group("analysis_features").get("max_topics", 5)
def get_max_user_titles(self) -> int: 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) return self._get_group("analysis_features").get("max_user_titles", 8)
def get_max_golden_quotes(self) -> int: 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) return self._get_group("analysis_features").get("max_golden_quotes", 5)
def get_llm_retries(self) -> int: 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) return self._get_group("llm").get("llm_retries", 2)
def get_llm_backoff(self) -> int: 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) return self._get_group("llm").get("llm_backoff", 2)
def get_enable_streaming_llm_call(self) -> bool: 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) return self._get_group("llm").get("enable_streaming_llm_call", False)
def get_debug_mode(self) -> bool: 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) return self._get_group("basic").get("debug_mode", False)
def get_enable_base64_image(self) -> bool: 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) return self._get_group("basic").get("enable_base64_image", False)
def get_t2i_rendering_strategies(self) -> list[dict]: 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") group = self._get_group("t2i_rendering")
return [ return [
# 第一轮:质量优先 # Lượt đầu: ưu tiên chất lượng.
{ {
"full_page": True, "full_page": True,
"type": group.get("t2i_r1_type", "png"), "type": group.get("t2i_r1_type", "png"),
@@ -229,7 +220,7 @@ class ConfigManager:
"device_scale_factor_level": group.get("t2i_r1_device_scale", "ultra"), "device_scale_factor_level": group.get("t2i_r1_device_scale", "ultra"),
"timeout": group.get("t2i_r1_timeout", 30000), "timeout": group.get("t2i_r1_timeout", 30000),
}, },
# 第二轮:稳定性/回退优先 # Lượt hai: ưu tiên ổn định và fallback.
{ {
"full_page": True, "full_page": True,
"type": group.get("t2i_r2_type", "jpeg"), "type": group.get("t2i_r2_type", "jpeg"),
@@ -240,11 +231,11 @@ class ConfigManager:
] ]
def get_t2i_font_source(self) -> str: 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") return self._get_group("t2i_rendering").get("t2i_font_source", "Overseas")
def get_t2i_google_fonts_mirror(self) -> str: 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() source = self.get_t2i_font_source()
group = self._get_group("t2i_rendering") group = self._get_group("t2i_rendering")
if source == "Mainland": if source == "Mainland":
@@ -252,7 +243,7 @@ class ConfigManager:
return group.get("t2i_overseas_google_fonts", "https://fonts.googleapis.com") return group.get("t2i_overseas_google_fonts", "https://fonts.googleapis.com")
def get_t2i_gstatic_mirror(self) -> str: def get_t2i_gstatic_mirror(self) -> str:
"""根据环境选择获取 Gstatic 镜像地址""" """Lấy URL mirror Gstatic theo môi trường."""
source = self.get_t2i_font_source() source = self.get_t2i_font_source()
group = self._get_group("t2i_rendering") group = self._get_group("t2i_rendering")
if source == "Mainland": if source == "Mainland":
@@ -260,45 +251,45 @@ class ConfigManager:
return group.get("t2i_overseas_gstatic", "https://fonts.gstatic.com") return group.get("t2i_overseas_gstatic", "https://fonts.gstatic.com")
def get_t2i_atri_font_mirror(self) -> str: 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( return self._get_group("t2i_rendering").get(
"t2i_atri_font_mirror", "https://tc.ciallo.ccwu.cc" "t2i_atri_font_mirror", "https://tc.ciallo.ccwu.cc"
) )
def get_llm_provider_id(self) -> str: 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", "") return self._get_group("llm").get("llm_provider_id", "")
def get_topic_provider_id(self) -> str: 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", "") return self._get_group("llm").get("topic_provider_id", "")
def get_user_title_provider_id(self) -> str: 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", "") return self._get_group("llm").get("user_title_provider_id", "")
def get_golden_quote_provider_id(self) -> str: 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", "") return self._get_group("llm").get("golden_quote_provider_id", "")
def get_keep_original_persona(self) -> bool: 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) return self._get_group("analysis_features").get("keep_original_persona", False)
def get_use_plugin_specific_persona(self) -> bool: 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( return self._get_group("analysis_features").get(
"use_plugin_specific_persona", False "use_plugin_specific_persona", False
) )
def get_plugin_specific_persona_id(self) -> str: 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( return self._get_group("analysis_features").get(
"plugin_specific_persona_id", "" "plugin_specific_persona_id", ""
) )
def get_bot_self_ids(self) -> list: 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") basic = self._get_group("basic")
ids = basic.get("bot_self_ids", []) ids = basic.get("bot_self_ids", [])
if not ids: if not ids:
@@ -306,42 +297,42 @@ class ConfigManager:
return ids return ids
def get_filter_bot_messages(self) -> bool: 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) return self._get_group("basic").get("filter_bot_messages", True)
def set_filter_bot_messages(self, enabled: bool): 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._ensure_group("basic")["filter_bot_messages"] = enabled
self.config.save_config() self.config.save_config()
def get_html_output_dir(self) -> str: 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" default_path = StarTools.get_data_dir(PLUGIN_NAME) / "self_hosted_html_reports"
val = self._get_group("html").get("html_output_dir") val = self._get_group("html").get("html_output_dir")
return val if val else str(default_path) return val if val else str(default_path)
def get_html_base_url(self) -> str: 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", "") return self._get_group("html").get("html_base_url", "")
def get_html_only_url(self) -> bool: 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) return self._get_group("html").get("html_only_url", False)
def set_html_only_url(self, enabled: bool): 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._ensure_group("html")["html_only_url"] = enabled
self.config.save_config() self.config.save_config()
def get_html_filename_format(self) -> str: def get_html_filename_format(self) -> str:
"""获取HTML文件名格式""" """Lấy định dạng tên tệp HTML."""
return self._get_group("html").get( 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: 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", {}) prompts_config = self._get_group("prompts").get("topic_analysis_prompts", {})
prompt = prompts_config.get(style, "") prompt = prompts_config.get(style, "")
if prompt: if prompt:
@@ -349,7 +340,7 @@ class ConfigManager:
return "" return ""
def get_user_title_analysis_prompt(self, style: str = "user_title_prompt") -> str: 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( prompts_config = self._get_group("prompts").get(
"user_title_analysis_prompts", {} "user_title_analysis_prompts", {}
) )
@@ -361,7 +352,7 @@ class ConfigManager:
def get_golden_quote_analysis_prompt( def get_golden_quote_analysis_prompt(
self, style: str = "golden_quote_v2_prompt" self, style: str = "golden_quote_v2_prompt"
) -> str: ) -> str:
"""获取金句分析提示词模板""" """Lấy template prompt phân tích trích dẫn."""
prompts_config = self._get_group("prompts").get( prompts_config = self._get_group("prompts").get(
"golden_quote_analysis_prompts", {} "golden_quote_analysis_prompts", {}
) )
@@ -371,7 +362,7 @@ class ConfigManager:
return "" return ""
def get_quality_analysis_prompt(self, style: str = "quality_v2_prompt") -> str: 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", {}) prompts_config = self._get_group("prompts").get("quality_analysis_prompts", {})
prompt = prompts_config.get(style, "") prompt = prompts_config.get(style, "")
if prompt: if prompt:
@@ -379,7 +370,7 @@ class ConfigManager:
return "" return ""
def set_quality_analysis_prompt(self, prompt: str): 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") prompts = self._ensure_group("prompts")
if "quality_analysis_prompts" not in prompts: if "quality_analysis_prompts" not in prompts:
prompts["quality_analysis_prompts"] = {} prompts["quality_analysis_prompts"] = {}
@@ -387,8 +378,8 @@ class ConfigManager:
self.config.save_config() self.config.save_config()
def _upgrade_config_item(self, group: str, key: str, setter_func): def _upgrade_config_item(self, group: str, key: str, setter_func):
"""升级指定配置项的值(从 str.format -> string.Template),并回写。""" """Nâng cấp mục cấu hình từ str.format sang string.Template và ghi lại."""
# 如果是 prompts,则先取 prompts 分组,再取子分组 (group) # Với prompt, lấy nhóm prompts rồi lấy nhóm con.
if group in ( if group in (
"quality_analysis_prompts", "quality_analysis_prompts",
"topic_analysis_prompts", "topic_analysis_prompts",
@@ -407,15 +398,15 @@ class ConfigManager:
if upgraded and upgraded_val != val: if upgraded and upgraded_val != val:
setter_func(upgraded_val) setter_func(upgraded_val)
logger.info( 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 True
return False return False
def upgrade_prompt_templates(self): 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 modified = False
# 1. 提示词模板升级 # 1. Nâng cấp template prompt.
modified |= self._upgrade_config_item( modified |= self._upgrade_config_item(
"quality_analysis_prompts", "quality_analysis_prompts",
"quality_v2_prompt", "quality_v2_prompt",
@@ -442,7 +433,7 @@ class ConfigManager:
self.set_golden_quote_analysis_prompt, self.set_golden_quote_analysis_prompt,
) )
# 2. 文件名格式升级 # 2. Nâng cấp định dạng tên tệp.
modified |= self._upgrade_config_item( modified |= self._upgrade_config_item(
"html", "html",
"html_filename_format", "html_filename_format",
@@ -451,12 +442,12 @@ class ConfigManager:
if modified: if modified:
logger.info( 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 return modified
def get_quality_summary_prompt(self, style: str = "quality_summary_prompt") -> str: 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", {}) prompts_config = self._get_group("prompts").get("quality_analysis_prompts", {})
prompt = prompts_config.get(style, "") prompt = prompts_config.get(style, "")
if prompt: if prompt:
@@ -464,7 +455,7 @@ class ConfigManager:
return "" return ""
def set_topic_analysis_prompt(self, prompt: str): def set_topic_analysis_prompt(self, prompt: str):
"""设置话题分析提示词模板""" """Thiết lập template prompt phân tích chủ đề."""
prompts = self._ensure_group("prompts") prompts = self._ensure_group("prompts")
if "topic_analysis_prompts" not in prompts: if "topic_analysis_prompts" not in prompts:
prompts["topic_analysis_prompts"] = {} prompts["topic_analysis_prompts"] = {}
@@ -472,7 +463,7 @@ class ConfigManager:
self.config.save_config() self.config.save_config()
def set_quality_summary_prompt(self, prompt: str): 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") prompts = self._ensure_group("prompts")
if "quality_analysis_prompts" not in prompts: if "quality_analysis_prompts" not in prompts:
prompts["quality_analysis_prompts"] = {} prompts["quality_analysis_prompts"] = {}
@@ -480,7 +471,7 @@ class ConfigManager:
self.config.save_config() self.config.save_config()
def set_user_title_analysis_prompt(self, prompt: str): 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") prompts = self._ensure_group("prompts")
if "user_title_analysis_prompts" not in prompts: if "user_title_analysis_prompts" not in prompts:
prompts["user_title_analysis_prompts"] = {} prompts["user_title_analysis_prompts"] = {}
@@ -488,7 +479,7 @@ class ConfigManager:
self.config.save_config() self.config.save_config()
def set_golden_quote_analysis_prompt(self, prompt: str): 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") prompts = self._ensure_group("prompts")
if "golden_quote_analysis_prompts" not in prompts: if "golden_quote_analysis_prompts" not in prompts:
prompts["golden_quote_analysis_prompts"] = {} prompts["golden_quote_analysis_prompts"] = {}
@@ -496,90 +487,93 @@ class ConfigManager:
self.config.save_config() self.config.save_config()
def set_output_format(self, format_types: str | list[str]): def set_output_format(self, format_types: str | list[str]):
"""设置输出格式""" """Thiết lập định dạng output."""
if isinstance(format_types, str): if isinstance(format_types, str):
format_types = [ format_types = [
f.strip() for f in format_types.replace("", ",").split(",") f.strip() for f in format_types.replace("", ",").split(",")
] ]
for f in format_types: for f in format_types:
if f not in ("image", "text", "html"): 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._ensure_group("basic")["output_format"] = format_types
self.config.save_config() self.config.save_config()
def set_group_list_mode(self, mode: str): 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._ensure_group("basic")["group_list_mode"] = mode
self.config.save_config() self.config.save_config()
def set_group_list(self, groups: list[str]): 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._ensure_group("basic")["group_list"] = groups
self.config.save_config() self.config.save_config()
def get_max_concurrent_tasks(self) -> int: 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) return self._get_group("performance").get("max_concurrent_groups", 3)
def get_llm_max_concurrent(self) -> int: 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) return self._get_group("performance").get("max_concurrent_llm", 3)
def get_t2i_max_concurrent(self) -> int: 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) return self._get_group("performance").get("max_concurrent_t2i", 1)
def get_stagger_seconds(self) -> int: 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) return self._get_group("performance").get("stagger_seconds", 2)
def set_max_concurrent_tasks(self, count: int): 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._ensure_group("performance")["max_concurrent_groups"] = count
self.config.save_config() self.config.save_config()
def set_max_messages(self, count: int): 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._ensure_group("basic")["max_messages"] = count
self.config.save_config() self.config.save_config()
def set_analysis_days(self, days: int): 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._ensure_group("basic")["analysis_days"] = days
self.config.save_config() self.config.save_config()
def set_auto_analysis_time(self, time_val: str | list[str]): 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._ensure_group("auto_analysis")["auto_analysis_time"] = time_val
self.config.save_config() self.config.save_config()
def is_auto_analysis_enabled(self) -> bool: 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() mode = self.get_scheduled_group_list_mode()
lst = self.get_scheduled_group_list() lst = self.get_scheduled_group_list()
return (mode == "whitelist" and len(lst) > 0) or (mode == "blacklist") return (mode == "whitelist" and len(lst) > 0) or (mode == "blacklist")
def get_scheduled_group_list_mode(self) -> str: 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( return self._get_group("auto_analysis").get(
"scheduled_group_list_mode", "whitelist" "scheduled_group_list_mode", "whitelist"
) )
def set_scheduled_group_list_mode(self, mode: str): 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._ensure_group("auto_analysis")["scheduled_group_list_mode"] = mode
self.config.save_config() self.config.save_config()
def get_scheduled_group_list(self) -> list[str]: 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", []) return self._get_group("auto_analysis").get("scheduled_group_list", [])
def set_scheduled_group_list(self, groups: list[str]): 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._ensure_group("auto_analysis")["scheduled_group_list"] = groups
self.config.save_config() self.config.save_config()
@@ -587,119 +581,114 @@ class ConfigManager:
self, group_umo_or_id: str, mode: str, group_list: list self, group_umo_or_id: str, mode: str, group_list: list
) -> bool: ) -> bool:
""" """
通用的名单判定逻辑。 Logic kiểm tra danh sách dùng chung.
逻辑如下: Whitelist rỗng nghĩa là cấp này chưa bật; nếu không rỗng chỉ cho item
- whitelist 模式: trong danh sách. Blacklist chặn item trong danh sách; rỗng thì cho tất cả.
- 如果列表为空,则视为“此级别未开启”。
- 如果不为空,仅在列表中的通过。
- blacklist 模式:
- 在列表中的不通过。
- 如果列表为空,则全部通过。
""" """
group_list = [str(x).strip() for x in group_list] group_list = [str(x).strip() for x in group_list]
target = str(group_umo_or_id).strip() target = str(group_umo_or_id).strip()
if mode == "whitelist": if mode == "whitelist":
if not group_list: if not group_list:
# 白名单为空:此级别不开启 (按需开启逻辑) # Whitelist rỗng: cấp này chưa bật.
return False return False
return any(self._is_group_match(target, item) for item in group_list) return any(self._is_group_match(target, item) for item in group_list)
else: # blacklist else: # blacklist
if not group_list: if not group_list:
# 黑名单为空:全通过 # Blacklist rỗng: cho tất cả.
return True return True
return not any(self._is_group_match(target, item) for item in group_list) return not any(self._is_group_match(target, item) for item in group_list)
def set_min_messages_threshold(self, threshold: int): 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._ensure_group("basic")["min_messages_threshold"] = threshold
self.config.save_config() self.config.save_config()
def set_topic_analysis_enabled(self, enabled: bool): 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._ensure_group("analysis_features")["topic_analysis_enabled"] = enabled
self.config.save_config() self.config.save_config()
def set_user_title_analysis_enabled(self, enabled: bool): 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._ensure_group("analysis_features")["user_title_analysis_enabled"] = enabled
self.config.save_config() self.config.save_config()
def set_golden_quote_analysis_enabled(self, enabled: bool): 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"] = ( self._ensure_group("analysis_features")["golden_quote_analysis_enabled"] = (
enabled enabled
) )
self.config.save_config() self.config.save_config()
def set_chat_quality_analysis_enabled(self, enabled: bool): 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"] = ( self._ensure_group("analysis_features")["chat_quality_analysis_enabled"] = (
enabled enabled
) )
self.config.save_config() self.config.save_config()
def set_max_topics(self, count: int): 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._ensure_group("analysis_features")["max_topics"] = count
self.config.save_config() self.config.save_config()
def set_max_user_titles(self, count: int): 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._ensure_group("analysis_features")["max_user_titles"] = count
self.config.save_config() self.config.save_config()
def set_max_golden_quotes(self, count: int): 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._ensure_group("analysis_features")["max_golden_quotes"] = count
self.config.save_config() self.config.save_config()
def set_html_filename_format(self, format_str: str): 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._ensure_group("html")["html_filename_format"] = format_str
self.config.save_config() self.config.save_config()
def get_report_template(self) -> str: def get_report_template(self) -> str:
"""获取报告模板名称""" """Lấy tên template báo cáo."""
return self._get_group("basic").get("report_template", "scrapbook") return self._get_group("basic").get("report_template", "scrapbook")
def set_report_template(self, template_name: str): 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._ensure_group("basic")["report_template"] = template_name
self.config.save_config() self.config.save_config()
def get_enable_user_card(self) -> bool: 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) return self._get_group("basic").get("enable_user_card", False)
def get_enable_analysis_reply(self) -> bool: 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) return self._get_group("basic").get("enable_analysis_reply", False)
def set_enable_analysis_reply(self, enabled: bool): 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._ensure_group("basic")["enable_analysis_reply"] = enabled
self.config.save_config() self.config.save_config()
def get_show_report_caption(self) -> bool: 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) return self._get_group("basic").get("show_report_caption", True)
def set_show_report_caption(self, enabled: bool): 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._ensure_group("basic")["show_report_caption"] = enabled
self.config.save_config() self.config.save_config()
def get_profile_display_mode(self) -> str: 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() mode = str(self._get_group("basic").get("profile_display_mode", "mbti")).lower()
if mode not in {"mbti", "sbti", "acgti"}: if mode not in {"mbti", "sbti", "acgti"}:
return "mbti" return "mbti"
return mode return mode
def get_profile_image_opacity(self) -> float: 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) value = self._get_group("basic").get("profile_image_opacity", 0.12)
try: try:
return max(0.0, min(1.0, float(value))) return max(0.0, min(1.0, float(value)))
@@ -707,7 +696,7 @@ class ConfigManager:
return 0.12 return 0.12
def get_profile_image_size_mode(self) -> str: def get_profile_image_size_mode(self) -> str:
"""获取人格背景图尺寸模式。""" """Lấy chế độ kích thước ảnh nền hồ sơ."""
mode = str( mode = str(
self._get_group("basic").get("profile_image_size_mode", "contain") self._get_group("basic").get("profile_image_size_mode", "contain")
).lower() ).lower()
@@ -716,122 +705,122 @@ class ConfigManager:
return mode return mode
def get_profile_mapping_config(self) -> str: 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() 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: 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) return self._get_group("qq_group_upload").get("enable_group_file_upload", False)
def get_group_file_folder(self) -> str: 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", "") return self._get_group("qq_group_upload").get("group_file_folder", "")
def get_enable_group_album_upload(self) -> bool: 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( return self._get_group("qq_group_upload").get(
"enable_group_album_upload", False "enable_group_album_upload", False
) )
def get_group_album_name(self) -> str: 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", "") return self._get_group("qq_group_upload").get("group_album_name", "")
def get_group_album_strict_mode(self) -> bool: def get_group_album_strict_mode(self) -> bool:
"""获取群相册上传严格模式开关。""" """Lấy trạng thái chế độ upload album nghiêm ngặt."""
return bool( return bool(
self._get_group("qq_group_upload").get("group_album_strict_mode", True) self._get_group("qq_group_upload").get("group_album_strict_mode", True)
) )
def set_group_album_strict_mode(self, enabled: bool): 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._ensure_group("qq_group_upload")["group_album_strict_mode"] = enabled
self.config.save_config() self.config.save_config()
# ========== 增量分析配置 ========== # ========== Cấu hình phân tích gia tăng ==========
def get_incremental_enabled(self) -> bool: 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() mode = self.get_incremental_group_list_mode()
lst = self.get_incremental_group_list() 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") return (mode == "whitelist" and len(lst) > 0) or (mode == "blacklist")
def get_incremental_group_list_mode(self) -> str: 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( return self._get_group("incremental").get(
"incremental_group_list_mode", "whitelist" "incremental_group_list_mode", "whitelist"
) )
def get_incremental_group_list(self) -> list[str]: 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", []) return self._get_group("incremental").get("incremental_group_list", [])
def get_incremental_fallback_enabled(self) -> bool: 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) return self._get_group("incremental").get("incremental_fallback_enabled", True)
def get_incremental_report_immediately(self) -> bool: 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( return self._get_group("incremental").get(
"incremental_report_immediately", False "incremental_report_immediately", False
) )
def set_incremental_report_immediately(self, enabled: bool): 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._ensure_group("incremental")["incremental_report_immediately"] = enabled
self.config.save_config() self.config.save_config()
def get_incremental_interval_minutes(self) -> int: 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) return self._get_group("incremental").get("incremental_interval_minutes", 120)
def get_incremental_max_daily_analyses(self) -> int: 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) return self._get_group("incremental").get("incremental_max_daily_analyses", 8)
def get_incremental_safe_limit(self) -> int: 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) return self._get_group("incremental").get("incremental_safe_limit", 2000)
def get_incremental_min_messages(self) -> int: 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) return self._get_group("incremental").get("incremental_min_messages", 20)
def get_incremental_topics_per_batch(self) -> int: 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) return self._get_group("incremental").get("incremental_topics_per_batch", 3)
def get_incremental_quotes_per_batch(self) -> int: 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) return self._get_group("incremental").get("incremental_quotes_per_batch", 3)
def get_incremental_active_start_hour(self) -> int: 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) return self._get_group("incremental").get("incremental_active_start_hour", 8)
def get_incremental_active_end_hour(self) -> int: 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) return self._get_group("incremental").get("incremental_active_end_hour", 23)
def get_incremental_stagger_seconds(self) -> int: 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) return self._get_group("incremental").get("incremental_stagger_seconds", 30)
def save_config(self): def save_config(self):
"""保存配置到AstrBot配置系统""" """Lưu cấu hình vào hệ thống cấu hình AstrBot."""
try: try:
self.config.save_config() self.config.save_config()
logger.info("配置已保存") logger.info("Đã lưu cấu hình")
except Exception as e: 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): def reload_config(self):
"""重新加载配置""" """Tải lại cấu hình."""
try: try:
logger.info("重新加载配置...") logger.info("Đang tải lại cấu hình...")
logger.info("配置重载完成") logger.info("Đã tải lại cấu hình")
except Exception as e: except Exception as e:
logger.error(f"重新加载配置失败: {e}") logger.error(f"Tải lại cấu hình thất bại: {e}")
+25 -19
View File
@@ -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 from ...utils.logger import logger
class MessageSender: class MessageSender:
""" """Đóng gói lệnh gọi PlatformAdapter và cung cấp giao diện gửi cấp cao."""
消息发送器
封装了 PlatformAdapter 的底层调用,提供更高层的发送接口
"""
def __init__(self, bot_manager, config_manager): def __init__(self, bot_manager, config_manager):
self.bot_manager = bot_manager self.bot_manager = bot_manager
@@ -19,10 +17,12 @@ class MessageSender:
async def send_text( async def send_text(
self, group_id: str, text: str, platform_id: str | None = None self, group_id: str, text: str, platform_id: str | None = None
) -> bool: ) -> bool:
"""发送文本消息""" """Gửi tin nhắn văn bản."""
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
if not adapter: 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 False
return await adapter.send_text(group_id, text) return await adapter.send_text(group_id, text)
@@ -33,10 +33,12 @@ class MessageSender:
caption: str = "", caption: str = "",
platform_id: str | None = None, platform_id: str | None = None,
) -> bool: ) -> bool:
"""智能发送图片,支持自动选择适配器""" """Gửi ảnh và tự động chọn adapter phù hợp."""
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
if not adapter: 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 False
return await adapter.send_image(group_id, image_url, caption) return await adapter.send_image(group_id, image_url, caption)
@@ -47,33 +49,37 @@ class MessageSender:
caption: str = "", caption: str = "",
platform_id: str | None = None, platform_id: str | None = None,
) -> bool: ) -> 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) adapter = self.bot_manager.get_adapter(platform_id)
if not adapter: 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 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) file_sent = await adapter.send_file(group_id, file_path)
if not file_sent: if not file_sent:
# 适配器返回 False,表示文件未成功发送 # Adapter trả False nghĩa là tệp chưa được gửi thành công.
return False 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: if caption:
try: try:
caption_sent = await adapter.send_text(group_id, f"{caption}") caption_sent = await adapter.send_text(group_id, f"{caption}")
if not caption_sent: if not caption_sent:
logger.warning( 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: 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 return True
def _get_available_platforms(self, group_id: str): 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()] return [(pid, None) for pid in self.bot_manager.get_platform_ids()]
+2 -2
View File
@@ -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 from .history_repository import HistoryRepository
@@ -1,7 +1,8 @@
""" """
历史记录管理器模块 - 基础设施持久化层 Module quản lý lịch sử thuộc tầng persistence infrastructure.
负责存储和查询群聊分析报告的摘要信息
使用 AstrBot 的 put_kv_data/get_kv_data 实现 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 import datetime
@@ -12,19 +13,18 @@ from ...utils.logger import logger
class HistoryManager: 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.
该类负责将每日生成的群消息分析报告摘要持久化存储,并提供查询接口。 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.
底层基于 AstrBot 提供的 KV 存储能力(put_kv_data/get_kv_data), Dựa trên KV của AstrBot để có thể truy xuất dữ liệu sau khi bot khởi động lại.
确保即使在 Bot 重启后也能回溯历史数据。
""" """
def __init__(self, star_instance: Any): def __init__(self, star_instance: Any):
""" """
初始化历史记录管理器。 Khởi tạo trình quản lý lịch sử.
Args: Args:
star_instance (Any): Star 插件实例,用于访问底层持久化引擎 star_instance: Instance Star dùng để truy cập persistence engine.
""" """
self.plugin = star_instance self.plugin = star_instance
@@ -36,18 +36,19 @@ class HistoryManager:
time_str: str | None = None, time_str: str | None = None,
) -> bool: ) -> 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: Args:
group_id (str): 群组 ID group_id: ID nhóm.
analysis_result (dict[str, Any]): 包含 statistics, topics, user_titles 的完整分析对象 analysis_result: Đối tượng phân tích gồm statistics, topics user_titles.
date_str (str, optional): 归档日期 (YYYY-MM-DD),缺省为当天 date_str: Ngày lưu trữ (YYYY-MM-DD), mặc định là hôm nay.
time_str (str, optional): 归档时间点 (HH-MM),缺省为当前时刻 time_str: Thời điểm lưu (HH-MM), mặc định là hiện tại.
Returns: Returns:
bool: 存储是否成功 True nếu lưu thành công.
""" """
try: try:
now = datetime.datetime.now() now = datetime.datetime.now()
@@ -56,10 +57,10 @@ class HistoryManager:
if not time_str: if not time_str:
time_str = now.strftime("%H-%M") 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(":", "-") 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") stats = analysis_result.get("statistics")
topics = analysis_result.get("topics", []) topics = analysis_result.get("topics", [])
user_titles = analysis_result.get("user_titles", []) user_titles = analysis_result.get("user_titles", [])
@@ -78,18 +79,18 @@ class HistoryManager:
await self.plugin.put_kv_data(key, summary) await self.plugin.put_kv_data(key, summary)
logger.info( 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 return True
except Exception as e: 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 return False
async def get_history( async def get_history(
self, group_id: str, date_str: str, time_str: str self, group_id: str, date_str: str, time_str: str
) -> dict[str, Any] | None: ) -> 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(":", "-") time_str = time_str.replace(":", "-")
key = f"analysis_{group_id}_{date_str}_{time_str}" 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: 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) history = await self.get_history(group_id, date_str, time_str)
return history is not None return history is not None
@@ -1,8 +1,8 @@
""" """
历史仓库 - 存储分析历史的实现 Repository lịch sử - triển khai lưu trữ lịch sử phân tích.
该模块提供分析结果和历史记录的持久化存储。 Module cung cấp persistence cho kết quả phân tích và bản ghi lịch sử,
它封装了现有的 history_manager 功能。 đóng gói chức năng history_manager hiện có.
""" """
import json import json
@@ -15,33 +15,33 @@ from ...utils.logger import logger
class HistoryRepository: class HistoryRepository:
""" """
基础设施:历史仓库 Infrastructure: repository lịch sử.
负责群聊分析历史记录的持久化存储与检索。目前使用本地 JSON 文件实现, Lưu và truy xuất lịch sử phân tích nhóm bằng file JSON cục bộ,
保持了与旧版 `history_manager` 的数据格式兼容性。 duy trì tương thích định dạng dữ liệu với history_manager cũ.
Attributes: Attributes:
data_dir (Path): 插件数据存储的总根目录 data_dir: Thư mục gốc lưu dữ liệu plugin.
history_dir (Path): 专门存放历史记录的子目录 history_dir: Thư mục con lưu lịch sử.
""" """
def __init__(self, data_dir: str): def __init__(self, data_dir: str):
""" """
初始化历史仓库。 Khởi tạo repository lịch sử.
Args: 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.data_dir = Path(data_dir)
self.history_dir = self.data_dir / "history" self.history_dir = self.data_dir / "history"
self._ensure_directories() self._ensure_directories()
def _ensure_directories(self) -> None: 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) self.history_dir.mkdir(parents=True, exist_ok=True)
def _get_group_history_path(self, group_id: str) -> Path: 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" return self.history_dir / f"group_{group_id}.json"
def save_analysis_result( def save_analysis_result(
@@ -51,52 +51,54 @@ class HistoryRepository:
date_str: str | None = None, date_str: str | None = None,
) -> bool: ) -> bool:
""" """
将分析结果保存到持久化存储。 Lưu kết quả phân tích vào persistence.
Args: Args:
group_id (str): 群组标识符 group_id: Định danh nhóm.
result (dict[str, Any]): 包含统计、金句等信息的分析结果字典 result: Dict kết quả gồm thống kê, trích dẫn và thông tin liên quan.
date_str (str, optional): 关联日期 (YYYY-MM-DD),默认为执行日 date_str: Ngày liên quan (YYYY-MM-DD), mặc định là ngày thực thi.
Returns: Returns:
bool: 保存成功返回 True,发生异常返回 False True nếu lưu thành công, False nếu xảy ra lỗi.
""" """
try: try:
date_str = date_str or datetime.now().strftime("%Y-%m-%d") date_str = date_str or datetime.now().strftime("%Y-%m-%d")
history = self.load_group_history(group_id) history = self.load_group_history(group_id)
# 注入执行时间戳 # Gắn timestamp thực thi.
if "timestamp" not in result: if "timestamp" not in result:
result["timestamp"] = datetime.now().isoformat() 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: if "daily" not in history:
history["daily"] = {} history["daily"] = {}
history["daily"][date_str] = result history["daily"][date_str] = result
history["last_updated"] = datetime.now().isoformat() history["last_updated"] = datetime.now().isoformat()
# 原子写入(覆盖) # Ghi nguyên tử (ghi đè).
history_path = self._get_group_history_path(group_id) history_path = self._get_group_history_path(group_id)
with open(history_path, "w", encoding="utf-8") as f: with open(history_path, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2) 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 return True
except Exception as e: 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 return False
def load_group_history(self, group_id: str) -> dict[str, Any]: 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: Args:
group_id (str): 群组标识符 group_id: Định danh nhóm.
Returns: 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: try:
history_path = self._get_group_history_path(group_id) history_path = self._get_group_history_path(group_id)
@@ -105,77 +107,77 @@ class HistoryRepository:
return json.load(f) return json.load(f)
return {"daily": {}, "group_id": group_id} return {"daily": {}, "group_id": group_id}
except Exception as e: 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} return {"daily": {}, "group_id": group_id}
def get_analysis_result( def get_analysis_result(
self, group_id: str, date_str: str self, group_id: str, date_str: str
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
""" """
获取指定日期已存档的分析结果。 Lấy kết quả phân tích đã lưu của ngày chỉ định.
Args: Args:
group_id (str): 群组 ID group_id: ID nhóm.
date_str (str): 目标日期 (YYYY-MM-DD) date_str: Ngày đích (YYYY-MM-DD).
Returns: 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) history = self.load_group_history(group_id)
return history.get("daily", {}).get(date_str) return history.get("daily", {}).get(date_str)
def get_recent_results(self, group_id: str, limit: int = 7) -> list[dict[str, Any]]: 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: Args:
group_id (str): 群组 ID group_id: ID nhóm.
limit (int): 最大返回条数 limit: Số kết quả tối đa.
Returns: 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) history = self.load_group_history(group_id)
daily = history.get("daily", {}) 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] sorted_dates = sorted(daily.keys(), reverse=True)[:limit]
return [daily[date] for date in sorted_dates] return [daily[date] for date in sorted_dates]
def has_analysis_for_date(self, group_id: str, date_str: str) -> bool: 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: Args:
group_id (str): 群组 ID group_id: ID nhóm.
date_str (str): 日期字符串 date_str: Chuỗi ngày.
Returns: 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 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: 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: Args:
group_id (str): 群组 ID group_id: ID nhóm.
keep_days (int): 保留的天数上限 keep_days: Số ngày tối đa cần giữ lại.
Returns: Returns:
int: 实际删除的记录条数 Số bản ghi thực tế đã xoá.
""" """
try: try:
history = self.load_group_history(group_id) history = self.load_group_history(group_id)
daily = history.get("daily", {}) daily = history.get("daily", {})
# 计算截止日期边界 # Tính mốc ngày giới hạn.
from datetime import timedelta from datetime import timedelta
cutoff = (datetime.now() - timedelta(days=keep_days)).strftime("%Y-%m-%d") 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] dates_to_delete = [date for date in daily.keys() if date < cutoff]
for date in dates_to_delete: for date in dates_to_delete:
@@ -190,23 +192,23 @@ class HistoryRepository:
return len(dates_to_delete) return len(dates_to_delete)
except Exception as e: 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 return 0
def list_groups_with_history(self) -> list[str]: 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: Returns:
list[str]: 群组 ID 字符串列表 Danh sách chuỗi ID nhóm.
""" """
try: try:
groups = [] groups = []
for file_path in self.history_dir.glob("group_*.json"): 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_", "") group_id = file_path.stem.replace("group_", "")
groups.append(group_id) groups.append(group_id)
return groups return groups
except Exception as e: 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 [] return []
@@ -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 键设计: Thiết kế key KV:
- 批次索引: incr_batch_index_{group_id} - Index batch: incr_batch_index_{group_id}
值: [{"batch_id": "xxx", "timestamp": 1234567890.0}, ...] - Dữ liệu batch: incr_batch_{group_id}_{batch_id}
- 批次数据: incr_batch_{group_id}_{batch_id} - Timestamp tin nhắn phân tích cuối: incr_last_ts_{group_id}
值: IncrementalBatch.to_dict()
- 最后分析消息时间戳: incr_last_ts_{group_id}
值: int (epoch timestamp)
""" """
from typing import Any from typing import Any
@@ -21,59 +18,55 @@ from ...utils.logger import logger
class IncrementalStore: class IncrementalStore:
""" """
增量分析批次持久化仓储 Repository persistence cho batch phân tích gia tăng.
核心职责: Trách nhiệm chính: lưu và truy vấn batch, quản lý timestamp loại trùng,
- save_batch: 保存单个批次数据并更新索引 dọn batch hết hạn và cung cấp số lượng batch cho truy vấn trạng thái.
- query_batches: 按时间窗口查询批次列表
- get_last_analyzed_timestamp / update_last_analyzed_timestamp: 跨批次去重
- cleanup_old_batches: 清理过期批次
- get_batch_count: 获取当前批次总数(状态查询用)
""" """
# KV 键前缀 # Tiền tố key KV
INDEX_PREFIX = "incr_batch_index" INDEX_PREFIX = "incr_batch_index"
BATCH_PREFIX = "incr_batch" BATCH_PREFIX = "incr_batch"
LAST_TS_PREFIX = "incr_last_ts" LAST_TS_PREFIX = "incr_last_ts"
def __init__(self, star_instance: Any): def __init__(self, star_instance: Any):
""" """
初始化批次持久化仓储。 Khởi tạo repository persistence cho batch.
Args: Args:
star_instance: Star 插件实例,用于访问底层 KV 存储引擎 star_instance: Instance Star dùng để truy cập KV storage.
""" """
self.plugin = star_instance self.plugin = star_instance
# ================================================================ # ================================================================
# 键构建 # Xây dựng key
# ================================================================ # ================================================================
def _index_key(self, group_id: str) -> str: def _index_key(self, group_id: str) -> str:
"""构建批次索引键""" """Tạo key index batch."""
return f"{self.INDEX_PREFIX}_{group_id}" return f"{self.INDEX_PREFIX}_{group_id}"
def _batch_key(self, group_id: str, batch_id: str) -> str: 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}" return f"{self.BATCH_PREFIX}_{group_id}_{batch_id}"
def _last_ts_key(self, group_id: str) -> str: 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}" return f"{self.LAST_TS_PREFIX}_{group_id}"
# ================================================================ # ================================================================
# 批次索引操作 # Thao tác index batch
# ================================================================ # ================================================================
async def _get_index(self, group_id: str) -> list[dict]: async def _get_index(self, group_id: str) -> list[dict]:
""" """
获取指定群的批次索引列表。 Lấy danh sách index batch của nhóm.
Args: Args:
group_id: 群组 ID group_id: ID nhóm.
Returns: Returns:
list[dict]: 索引条目列表,每项包含 batch_id timestamp Danh sách mục index, mỗi mục chứa batch_id timestamp.
""" """
key = self._index_key(group_id) key = self._index_key(group_id)
try: try:
@@ -82,53 +75,53 @@ class IncrementalStore:
return [] return []
if isinstance(data, list): if isinstance(data, list):
return data 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 [] return []
except Exception as e: 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 [] return []
async def _save_index(self, group_id: str, index: list[dict]) -> None: async def _save_index(self, group_id: str, index: list[dict]) -> None:
""" """
保存批次索引列表。 Lưu danh sách index batch.
Args: Args:
group_id: 群组 ID group_id: ID nhóm.
index: 索引条目列表 index: Danh sách mục index.
""" """
key = self._index_key(group_id) key = self._index_key(group_id)
try: try:
await self.plugin.put_kv_data(key, index) await self.plugin.put_kv_data(key, index)
except Exception as e: 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 raise
# ================================================================ # ================================================================
# 批次数据操作 # Thao tác dữ liệu batch
# ================================================================ # ================================================================
async def save_batch(self, batch: IncrementalBatch) -> bool: async def save_batch(self, batch: IncrementalBatch) -> bool:
""" """
保存单个批次数据并更新索引。 Lưu dữ liệu của một batch và cập nhật index.
流程: Ghi dữ liệu vào key KV riêng rồi thêm metadata batch vào index.
1. 将批次数据写入独立 KV 键
2. 将批次元数据(batch_id + timestamp)追加到索引
Args: Args:
batch: 要保存的增量分析批次 batch: Batch phân tích gia tăng cần lưu.
Returns: Returns:
bool: 保存是否成功 True nếu lưu thành công.
""" """
group_id = batch.group_id group_id = batch.group_id
batch_key = self._batch_key(group_id, batch.batch_id) batch_key = self._batch_key(group_id, batch.batch_id)
try: try:
# 1. 保存批次数据 # 1. Lưu dữ liệu batch
await self.plugin.put_kv_data(batch_key, batch.to_dict()) 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 = await self._get_index(group_id)
index.append( index.append(
{ {
@@ -139,13 +132,13 @@ class IncrementalStore:
await self._save_index(group_id, index) await self._save_index(group_id, index)
logger.debug( logger.debug(
f"已保存批次 {batch.batch_id[:8]}... " f"Đã lưu batch {batch.batch_id[:8]}... "
f"( {group_id}, 消息数={batch.messages_count})" f"(nhóm {group_id}, tin nhắn={batch.messages_count})"
) )
return True return True
except Exception as e: except Exception as e:
logger.error( 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, exc_info=True,
) )
return False return False
@@ -157,29 +150,29 @@ class IncrementalStore:
window_end: float, window_end: float,
) -> list[IncrementalBatch]: ) -> 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: Args:
group_id: 群组 ID group_id: ID nhóm.
window_start: 窗口起始时间戳(epoch window_start: Epoch timestamp bắt đầu cửa sổ.
window_end: 窗口结束时间戳(epoch window_end: Epoch timestamp kết thúc cửa sổ.
Returns: Returns:
list[IncrementalBatch]: 符合窗口范围的批次列表,按时间戳升序 Danh sách batch trong cửa sổ, tăng dần theo timestamp.
""" """
index = await self._get_index(group_id) index = await self._get_index(group_id)
# 筛选在窗口范围内的批次 # Lọc batch trong phạm vi cửa sổ.
matching_entries = [ matching_entries = [
entry entry
for entry in index for entry in index
if window_start <= entry.get("timestamp", 0) <= window_end 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)) matching_entries.sort(key=lambda x: x.get("timestamp", 0))
batches: list[IncrementalBatch] = [] batches: list[IncrementalBatch] = []
@@ -196,89 +189,94 @@ class IncrementalStore:
batches.append(batch) batches.append(batch)
else: else:
logger.warning( 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: except Exception as e:
logger.error( 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, exc_info=True,
) )
logger.debug( logger.debug(
f"窗口查询完成: 群 {group_id}, " f"Hoàn tất truy vấn cửa sổ: nhóm {group_id}, "
f"窗口 [{window_start:.0f}, {window_end:.0f}], " f"cửa sổ [{window_start:.0f}, {window_end:.0f}], "
f"匹配 {len(batches)}/{len(index)} 个批次" f"khớp {len(batches)}/{len(index)} batch"
) )
return batches 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: 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: Args:
group_id: 群组 ID group_id: ID nhóm.
Returns: Returns:
int: 最后分析消息的 epoch 时间戳,不存在则返回 0 Epoch timestamp cuối hoặc 0 nếu chưa có.
""" """
key = self._last_ts_key(group_id) key = self._last_ts_key(group_id)
try: try:
data = await self.plugin.get_kv_data(key, 0) data = await self.plugin.get_kv_data(key, 0)
return int(data) if data else 0 return int(data) if data else 0
except Exception as e: 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 return 0
async def update_last_analyzed_timestamp( async def update_last_analyzed_timestamp(
self, group_id: str, timestamp: int self, group_id: str, timestamp: int
) -> None: ) -> None:
""" """
更新指定群的最后分析消息时间戳。 Cập nhật timestamp tin nhắn phân tích cuối của nhóm.
Args: Args:
group_id: 群组 ID group_id: ID nhóm.
timestamp: 最后分析消息的 epoch 时间戳 timestamp: Epoch timestamp của tin nhắn phân tích cuối.
""" """
key = self._last_ts_key(group_id) key = self._last_ts_key(group_id)
try: try:
await self.plugin.put_kv_data(key, timestamp) 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: 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 raise
# ================================================================ # ================================================================
# 过期批次清理 # Dọn batch hết hạn
# ================================================================ # ================================================================
async def cleanup_old_batches(self, group_id: str, before_timestamp: float) -> int: 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.
流程: 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.
1. 从索引中分离出过期条目和保留条目
2. 逐个删除过期批次的 KV 数据
3. 用保留条目覆盖索引
Args: Args:
group_id: 群组 ID group_id: ID nhóm.
before_timestamp: 清理此时间戳之前的所有批次 before_timestamp: Xoá mọi batch trước timestamp này.
Returns: Returns:
int: 已清理的批次数量 Số batch đã xoá.
""" """
index = await self._get_index(group_id) index = await self._get_index(group_id)
if not index: if not index:
return 0 return 0
# 分离过期和保留 # Tách mục hết hạn và mục cần giữ.
expired = [] expired = []
retained = [] retained = []
for entry in index: for entry in index:
@@ -290,7 +288,7 @@ class IncrementalStore:
if not expired: if not expired:
return 0 return 0
# 删除过期批次数据 # Xoá dữ liệu batch hết hạn.
deleted_count = 0 deleted_count = 0
for entry in expired: for entry in expired:
batch_id = entry.get("batch_id", "") batch_id = entry.get("batch_id", "")
@@ -302,50 +300,50 @@ class IncrementalStore:
deleted_count += 1 deleted_count += 1
except Exception as e: except Exception as e:
logger.error( 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, 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) await self._save_index(group_id, retained)
logger.info( logger.info(
f"清理过期批次: 群 {group_id}, " f"Đã dọn batch hết hạn: nhóm {group_id}, "
f"删除 {deleted_count} 个, 保留 {len(retained)}" f"xoá {deleted_count}, giữ {len(retained)}"
) )
return deleted_count return deleted_count
# ================================================================ # ================================================================
# 状态查询 # Truy vấn trạng thái
# ================================================================ # ================================================================
async def get_batch_count(self, group_id: str) -> int: 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: Args:
group_id: 群组 ID group_id: ID nhóm.
Returns: Returns:
int: 批次总数 Tổng số batch.
""" """
index = await self._get_index(group_id) index = await self._get_index(group_id)
return len(index) return len(index)
async def get_all_batch_summaries(self, group_id: str) -> list[dict]: 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: Args:
group_id: 群组 ID group_id: ID nhóm.
Returns: 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) 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)) index.sort(key=lambda x: x.get("timestamp", 0))
return index return index
@@ -6,10 +6,10 @@ from astrbot.api.star import Star
class TelegramGroupRegistry: class TelegramGroupRegistry:
""" """
Telegram 群组/话题注册表 Registry nhóm và chủ đề Telegram.
负责管理 Telegram 的已见群组和话题列表,用于在无法通过 API 获取群列表时提供回退支持。 Quản lý danh sách nhóm và chủ đề Telegram đã thấy, dùng làm fallback khi
数据存储在 AstrBot 的 KV 存储中。 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" _KV_KEY = "telegram_seen_groups_v1"
@@ -26,7 +26,7 @@ class TelegramGroupRegistry:
sender_name: str, sender_name: str,
event_message_id: str, event_message_id: str,
) -> None: ) -> None:
"""更新 Telegram 已见群/话题注册表(KV)。""" """Cập nhật registry nhóm và chủ đề Telegram đã thấy trong KV."""
async with self._lock: async with self._lock:
registry = await self.plugin.get_kv_data(self._KV_KEY, {}) registry = await self.plugin.get_kv_data(self._KV_KEY, {})
if not isinstance(registry, dict): if not isinstance(registry, dict):
@@ -70,7 +70,7 @@ class TelegramGroupRegistry:
await self.plugin.put_kv_data(self._KV_KEY, registry) await self.plugin.put_kv_data(self._KV_KEY, registry)
async def get_all_group_ids(self, platform_id: str | None = None) -> list[str]: 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: async with self._lock:
registry = await self.plugin.get_kv_data(self._KV_KEY, {}) registry = await self.plugin.get_kv_data(self._KV_KEY, {})
if not isinstance(registry, dict): if not isinstance(registry, dict):
@@ -1,11 +1,8 @@
""" """
Discord 平台适配器 Adapter nền tảng Discord.
Discord 平台提供消息获取发送和群组管理功能 Cung cấp khả năng lấy gửi tin nhắn cùng quản nhóm. Việc lấy tin nhắn
这是一个骨架实现展示如何为新平台创建适配器 phụ thuộc cách AstrBot tích hợp Discord API.
注意Discord 的消息获取需要使用 Discord API
具体实现取决于 AstrBot Discord 集成方式
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -33,47 +30,47 @@ from ..base import PlatformAdapter
class DiscordAdapter(PlatformAdapter): class DiscordAdapter(PlatformAdapter):
""" """
具体实现Discord 平台适配器 Triển khai adapter nền tảng Discord.
利用 Discord API 为群组频道提供消息获取发送及基础元数据查询功能 Dùng Discord API để lấy/gửi tin nhắn truy vấn metadata bản cho kênh.
由于 Discord 的高度异步特性和复杂的权限模型该适配器集成了懒加载客户端和多级频道查询机制 Adapter tích hợp lazy loading client chế truy vấn kênh nhiều cấp.
Attributes: 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): def __init__(self, bot_instance: Any, config: dict | None = None):
""" """
初始化 Discord 适配器 Khởi tạo adapter Discord.
Args: Args:
bot_instance (Any): 宿主机器人实例 bot_instance: Instance bot chủ quản.
config (dict, optional): 配置项用于提取机器人自身的 Discord ID config: Cấu hình dùng để lấy ID Discord của bot.
""" """
super().__init__(bot_instance, config) 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 "" 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 self._cached_client = None
@property @property
def _discord_client(self) -> Any: def _discord_client(self) -> Any:
""" """
内部属性获取实际的 Discord 客户端实例 Lấy instance Discord client thực tế.
具备懒加载和自动身份嗅探功能 Hỗ trợ lazy loading tự nhận diện client.
Returns: Returns:
Any: Discord Client 对象 Đối tượng Discord Client.
""" """
if self._cached_client: if self._cached_client:
return 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() 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 not self.bot_user_id and self._cached_client:
if hasattr(self._cached_client, "user") and self._cached_client.user: if hasattr(self._cached_client, "user") and self._cached_client.user:
self.bot_user_id = str(self._cached_client.user.id) self.bot_user_id = str(self._cached_client.user.id)
@@ -81,27 +78,27 @@ class DiscordAdapter(PlatformAdapter):
return self._cached_client return self._cached_client
def _get_discord_client(self) -> Any: def _get_discord_client(self) -> Any:
"""内部方法:通过多级探测从 bot_instance 中提取 Discord SDK 客户端。""" """Dò nhiều cấp để lấy Discord SDK client từ bot_instance."""
# 路径 Abot 本身就是 Client (如小型集成) # Đường A: bot chính là Client.
if hasattr(self.bot, "get_channel"): if hasattr(self.bot, "get_channel"):
return self.bot 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"): if hasattr(self.bot, "client"):
return 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"): for attr in ("_client", "discord_client", "_discord_client"):
if hasattr(self.bot, attr): if hasattr(self.bot, attr):
client = getattr(self.bot, attr) client = getattr(self.bot, attr)
if hasattr(client, "get_channel"): if hasattr(client, "get_channel"):
return client 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 return None
def _init_capabilities(self) -> PlatformCapabilities: def _init_capabilities(self) -> PlatformCapabilities:
"""返回预定义的 Discord 平台能力集。""" """Trả về bộ năng lực Discord được định nghĩa sẵn."""
return DISCORD_CAPABILITIES return DISCORD_CAPABILITIES
# ==================== IMessageRepository 实现 ==================== # ==================== Triển khai IMessageRepository ====================
async def fetch_messages( async def fetch_messages(
self, self,
@@ -112,36 +109,38 @@ class DiscordAdapter(PlatformAdapter):
since_ts: int | None = None, since_ts: int | None = None,
) -> list[UnifiedMessage]: ) -> list[UnifiedMessage]:
""" """
Discord 频道异步拉取历史消息记录 Lấy bất đồng bộ lịch sử tin nhắn từ kênh Discord.
Args: Args:
group_id (str): Discord 频道 (Channel) ID group_id: ID kênh Discord.
days (int): 查询天数范围 days: Phạm vi số ngày truy vấn.
max_count (int): 最大拉取消息数量上限 max_count: Số tin nhắn tối đa.
before_id (str, optional): 锚点消息 ID从此之前开始拉取 before_id: ID tin nhắn mốc; lấy các tin trước đó.
Returns: Returns:
list[UnifiedMessage]: 统一格式的消息对象列表 Danh sách tin nhắn định dạng thống nhất.
""" """
if not discord: 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 [] return []
try: try:
channel_id = int(group_id) channel_id = int(group_id)
# 先从缓存尝试获取频道 # Thử lấy kênh từ cache trước.
channel = self._discord_client.get_channel(channel_id) channel = self._discord_client.get_channel(channel_id)
if not channel: if not channel:
# 缓存未命中则通过网络 fetch # Nếu cache miss thì fetch qua mạng.
try: try:
channel = await self._discord_client.fetch_channel(channel_id) channel = await self._discord_client.fetch_channel(channel_id)
except Exception as e: 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 [] return []
# 验证权限:确保支持历史消息流 # Xác thực hỗ trợ truy cập lịch sử.
if not hasattr(channel, "history"): 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 [] return []
if since_ts and since_ts > 0: if since_ts and since_ts > 0:
@@ -152,18 +151,18 @@ class DiscordAdapter(PlatformAdapter):
messages = [] 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} history_kwargs = {"limit": max_count, "after": start_time}
if before_id: if before_id:
try: try:
# 使用 Snowflake ID 指向特定消息 # Dùng Snowflake ID để trỏ tới tin nhắn cụ thể.
history_kwargs["before"] = discord.Object(id=int(before_id)) history_kwargs["before"] = discord.Object(id=int(before_id))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# 消息迭代处理 # Duyệt và xử lý tin nhắn.
async for msg in channel.history(**history_kwargs): 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: if self.bot_user_id and str(msg.author.id) == self.bot_user_id:
continue continue
@@ -171,7 +170,7 @@ class DiscordAdapter(PlatformAdapter):
if unified: if unified:
messages.append(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) messages.sort(key=lambda m: m.timestamp)
return messages return messages
@@ -180,17 +179,17 @@ class DiscordAdapter(PlatformAdapter):
return [] return []
def _convert_message(self, raw_msg: Any, group_id: str) -> UnifiedMessage | None: def _convert_message(self, raw_msg: Any, group_id: str) -> UnifiedMessage | None:
"""内部方法:将 `discord.Message` 对象转换为统一的 `UnifiedMessage`""" """Chuyển ``discord.Message`` thành ``UnifiedMessage``."""
try: try:
contents = [] contents = []
# 1. 基础文本 # 1. Văn bản cơ bản.
if raw_msg.content: if raw_msg.content:
contents.append( contents.append(
MessageContent(type=MessageContentType.TEXT, text=raw_msg.content) 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: for attachment in raw_msg.attachments:
content_type = attachment.content_type or "" content_type = attachment.content_type or ""
if content_type.startswith("image/"): 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: for embed in raw_msg.embeds:
if embed.image: if embed.image:
contents.append( contents.append(
@@ -239,12 +238,12 @@ class DiscordAdapter(PlatformAdapter):
) )
) )
# 4. 贴纸处理 (Stickers) # 4. Xử lý sticker.
if raw_msg.stickers: if raw_msg.stickers:
for sticker in raw_msg.stickers: for sticker in raw_msg.stickers:
contents.append( contents.append(
MessageContent( MessageContent(
type=MessageContentType.IMAGE, # 贴纸在逻辑上按图片处理 type=MessageContentType.IMAGE, # Xử lý sticker như ảnh.
url=sticker.url, url=sticker.url,
raw_data={ raw_data={
"sticker_id": str(sticker.id), "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 sender_card = None
if hasattr(raw_msg.author, "nick") and raw_msg.author.nick: if hasattr(raw_msg.author, "nick") and raw_msg.author.nick:
sender_card = raw_msg.author.nick sender_card = raw_msg.author.nick
@@ -275,11 +274,11 @@ class DiscordAdapter(PlatformAdapter):
else None, else None,
) )
except Exception as e: except Exception as e:
logger.debug(f"Discord 消息转换错误: {e}") logger.debug(f"Lỗi chuyển đổi tin nhắn Discord: {e}")
return None return None
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: 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 = [] raw_messages = []
for msg in messages: for msg in messages:
raw_msg = { raw_msg = {
@@ -292,7 +291,7 @@ class DiscordAdapter(PlatformAdapter):
"card": msg.sender_card, "card": msg.sender_card,
}, },
"message": [], "message": [],
"user_id": msg.sender_id, # 后向兼容 "user_id": msg.sender_id, # Tương thích ngược.
} }
for content in msg.contents: for content in msg.contents:
@@ -323,7 +322,7 @@ class DiscordAdapter(PlatformAdapter):
raw_messages.append(raw_msg) raw_messages.append(raw_msg)
return raw_messages return raw_messages
# ==================== IMessageSender 实现 ==================== # ==================== Triển khai IMessageSender ====================
async def send_text( async def send_text(
self, self,
@@ -332,15 +331,15 @@ class DiscordAdapter(PlatformAdapter):
reply_to: str | None = None, reply_to: str | None = None,
) -> bool: ) -> bool:
""" """
Discord 频道发送文本消息 Gửi tin nhắn văn bản tới kênh Discord.
Args: Args:
group_id (str): 频道 ID group_id: ID kênh.
text (str): 文本内容 text: Nội dung văn bản.
reply_to (str, optional): 引用的消息 ID reply_to: ID tin nhắn được trả lời.
Returns: Returns:
bool: 是否发送成功 True nếu gửi thành công.
""" """
if not discord: if not discord:
return False return False
@@ -366,7 +365,7 @@ class DiscordAdapter(PlatformAdapter):
await channel.send(content=text, reference=reference) await channel.send(content=text, reference=reference)
return True return True
except Exception as e: 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 return False
async def send_image( async def send_image(
@@ -376,17 +375,17 @@ class DiscordAdapter(PlatformAdapter):
caption: str = "", caption: str = "",
) -> bool: ) -> 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: Args:
group_id (str): 频道 ID group_id: ID kênh.
image_path (str): 本地路径或 http URL image_path: Đường dẫn cục bộ hoặc URL HTTP.
caption (str): 可选说明文字 caption: Chú thích tuỳ chọn.
Returns: Returns:
bool: 是否发送成功 True nếu gửi thành công.
""" """
if not discord: if not discord:
return False return False
@@ -402,7 +401,7 @@ class DiscordAdapter(PlatformAdapter):
file_to_send = None file_to_send = None
if image_path.startswith("base64://"): if image_path.startswith("base64://"):
# Base64 图片:解码 -> 内存 Object -> Discord # Ảnh Base64: decode -> object trong bộ nhớ -> Discord.
import base64 # Fix: Ensure base64 is imported import base64 # Fix: Ensure base64 is imported
from io import BytesIO from io import BytesIO
@@ -413,11 +412,11 @@ class DiscordAdapter(PlatformAdapter):
BytesIO(image_bytes), filename="daily_report_image.png" BytesIO(image_bytes), filename="daily_report_image.png"
) )
except Exception as e: 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 return False
elif image_path.startswith(("http://", "https://")): elif image_path.startswith(("http://", "https://")):
# 远程图片:下载 -> 内存 Object -> Discord # Ảnh từ xa: tải -> object trong bộ nhớ -> Discord.
from io import BytesIO from io import BytesIO
import aiohttp import aiohttp
@@ -429,7 +428,7 @@ class DiscordAdapter(PlatformAdapter):
) as resp: ) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.read() data = await resp.read()
# 尽量保留原始后缀 # Cố gắng giữ phần mở rộng gốc.
filename = image_path.split("/")[-1].split("?")[0] filename = image_path.split("/")[-1].split("?")[0]
if not filename.lower().endswith( if not filename.lower().endswith(
(".png", ".jpg", ".jpeg", ".gif", ".webp") (".png", ".jpg", ".jpeg", ".gif", ".webp")
@@ -440,7 +439,7 @@ class DiscordAdapter(PlatformAdapter):
BytesIO(data), filename=filename BytesIO(data), filename=filename
) )
else: else:
# 兜底:如果下载失败,直接发 URL Discord 尝试自动解析 # Fallback: gửi URL để Discord tự phân tích.
content = ( content = (
f"{caption}\n{image_path}" f"{caption}\n{image_path}"
if caption if caption
@@ -450,13 +449,13 @@ class DiscordAdapter(PlatformAdapter):
return True return True
except Exception as de: except Exception as de:
logger.warning( 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 content = f"{caption}\n{image_path}" if caption else image_path
await channel.send(content=content) await channel.send(content=content)
return True return True
else: else:
# 本地图片 # Ảnh cục bộ.
file_to_send = discord.File(image_path) file_to_send = discord.File(image_path)
if file_to_send: if file_to_send:
@@ -464,7 +463,7 @@ class DiscordAdapter(PlatformAdapter):
return True return True
except Exception as e: except Exception as e:
logger.error(f"Discord 图片发送失败: {e}") logger.error(f"Gửi ảnh Discord thất bại: {e}")
return False return False
async def send_file( async def send_file(
@@ -473,7 +472,7 @@ class DiscordAdapter(PlatformAdapter):
file_path: str, file_path: str,
filename: str | None = None, filename: str | None = None,
) -> bool: ) -> bool:
"""向 Discord 频道上传任意文件。""" """Tải tệp bất kỳ lên kênh Discord."""
if not discord: if not discord:
return False return False
@@ -490,7 +489,7 @@ class DiscordAdapter(PlatformAdapter):
await channel.send(file=file_to_send) await channel.send(file=file_to_send)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Discord 文件发送失败: {e}") logger.error(f"Gửi tệp Discord thất bại: {e}")
return False return False
async def send_forward_msg( async def send_forward_msg(
@@ -499,9 +498,9 @@ class DiscordAdapter(PlatformAdapter):
nodes: list[dict], nodes: list[dict],
) -> bool: ) -> bool:
""" """
Discord 模拟合并转发 phỏng chuyển tiếp gộp trên Discord.
由于 Discord 没有原生节点转发 API我们将其转换为一组文本消息发送 Discord không API chuyển tiếp node gốc nên chuyển thành nhóm tin văn bản.
""" """
if not discord: if not discord:
return False return False
@@ -515,17 +514,17 @@ class DiscordAdapter(PlatformAdapter):
if not hasattr(channel, "send"): if not hasattr(channel, "send"):
return False return False
# 将节点汇总为美化的文本块 # Tổng hợp node thành khối văn bản có định dạng.
lines = ["📊 **结构化报告摘要 (Structured Report)**\n"] lines = ["📊 **Tóm tắt báo cáo có cấu trúc (Structured Report)**\n"]
for node in nodes: 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") name = data.get("name", "AstrBot")
content = data.get("content", "") content = data.get("content", "")
lines.append(f"**[{name}]**:\n{content}\n") lines.append(f"**[{name}]**:\n{content}\n")
full_text = "\n".join(lines) full_text = "\n".join(lines)
# 分段处理大消息 # Chia nhỏ tin nhắn dài.
if len(full_text) > 1900: if len(full_text) > 1900:
parts = [ parts = [
full_text[i : i + 1900] for i in range(0, len(full_text), 1900) full_text[i : i + 1900] for i in range(0, len(full_text), 1900)
@@ -537,13 +536,13 @@ class DiscordAdapter(PlatformAdapter):
return True return True
except Exception as e: 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 return False
# ==================== IGroupInfoRepository 实现 ==================== # ==================== Triển khai IGroupInfoRepository ====================
async def get_group_info(self, group_id: str) -> UnifiedGroup | None: 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: if not discord:
return None return None
@@ -557,11 +556,11 @@ class DiscordAdapter(PlatformAdapter):
group_name = getattr(channel, "name", str(channel.id)) group_name = getattr(channel, "name", str(channel.id))
if guild: if guild:
# 群聊(服务器频道) # Kênh server.
member_count = guild.member_count member_count = guild.member_count
owner_id = str(guild.owner_id) owner_id = str(guild.owner_id)
else: else:
# 私人对话(DM # Tin nhắn riêng.
member_count = len(getattr(channel, "recipients", [])) + 1 member_count = len(getattr(channel, "recipients", [])) + 1
owner_id = str(getattr(channel, "owner_id", "")) owner_id = str(getattr(channel, "owner_id", ""))
@@ -574,11 +573,11 @@ class DiscordAdapter(PlatformAdapter):
platform="discord", platform="discord",
) )
except Exception as e: 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 return None
async def get_group_list(self) -> list[str]: 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: if not discord:
return [] return []
@@ -593,9 +592,9 @@ class DiscordAdapter(PlatformAdapter):
async def get_member_list(self, group_id: str) -> list[UnifiedMember]: 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: if not discord:
return [] return []
@@ -608,7 +607,7 @@ class DiscordAdapter(PlatformAdapter):
guild = getattr(channel, "guild", None) guild = getattr(channel, "guild", None)
if not guild: if not guild:
# 私聊收件人 # Người nhận tin nhắn riêng.
return [ return [
UnifiedMember( UnifiedMember(
user_id=str(u.id), user_id=str(u.id),
@@ -647,7 +646,7 @@ class DiscordAdapter(PlatformAdapter):
group_id: str, group_id: str,
user_id: str, user_id: str,
) -> UnifiedMember | None: ) -> 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: if not discord:
return None return None
@@ -660,7 +659,7 @@ class DiscordAdapter(PlatformAdapter):
guild = getattr(channel, "guild", None) guild = getattr(channel, "guild", None)
if not guild: if not guild:
# 跨频道/私聊探测 # Dò xuyên kênh hoặc tin nhắn riêng.
user = await self.bot.fetch_user(uid) user = await self.bot.fetch_user(uid)
return UnifiedMember( return UnifiedMember(
user_id=str(user.id), nickname=user.name, card=user.display_name user_id=str(user.id), nickname=user.name, card=user.display_name
@@ -688,14 +687,14 @@ class DiscordAdapter(PlatformAdapter):
except Exception: except Exception:
return None return None
# ==================== IAvatarRepository 实现 ==================== # ==================== Triển khai IAvatarRepository ====================
async def get_user_avatar_url( async def get_user_avatar_url(
self, self,
user_id: str, user_id: str,
size: int = 100, size: int = 100,
) -> str | None: ) -> 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: if not discord or not self._discord_client:
return None return None
@@ -706,14 +705,14 @@ class DiscordAdapter(PlatformAdapter):
) or await self._discord_client.fetch_user(uid) ) or await self._discord_client.fetch_user(uid)
if user: 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) allowed_sizes = (16, 32, 64, 128, 256, 512, 1024, 2048, 4096)
target_size = min(allowed_sizes, key=lambda x: abs(x - size)) target_size = min(allowed_sizes, key=lambda x: abs(x - size))
return user.display_avatar.with_size(target_size).url return user.display_avatar.with_size(target_size).url
return None return None
except Exception as e: except Exception as e:
logger.debug(f"Discord 获取用户头像 URL 错误: {e}") logger.debug(f"Lỗi lấy URL avatar Discord: {e}")
return None return None
async def get_user_avatar_data( async def get_user_avatar_data(
@@ -721,7 +720,7 @@ class DiscordAdapter(PlatformAdapter):
user_id: str, user_id: str,
size: int = 100, size: int = 100,
) -> str | None: ) -> str | None:
"""暂不提供 Base64 转换服务,优先使用 CDN 链接。""" """Chưa hỗ trợ Base64; ưu tiên URL CDN."""
return None return None
async def get_group_avatar_url( async def get_group_avatar_url(
@@ -729,7 +728,7 @@ class DiscordAdapter(PlatformAdapter):
group_id: str, group_id: str,
size: int = 100, size: int = 100,
) -> str | None: ) -> str | None:
"""获取 Discord 服务器(Guild)的图标地址。""" """Lấy URL biểu tượng server Discord."""
if not discord: if not discord:
return None return None
@@ -751,14 +750,14 @@ class DiscordAdapter(PlatformAdapter):
user_ids: list[str], user_ids: list[str],
size: int = 100, size: int = 100,
) -> dict[str, str | None]: ) -> 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} return {uid: await self.get_user_avatar_url(uid, size) for uid in user_ids}
async def set_reaction( async def set_reaction(
self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
) -> bool: ) -> bool:
""" """
Discord 实现消息回应 Triển khai reaction tin nhắn Discord.
""" """
if not discord: if not discord:
return False return False
@@ -779,7 +778,7 @@ class DiscordAdapter(PlatformAdapter):
channel = await self._discord_client.fetch_channel(channel_id) channel = await self._discord_client.fetch_channel(channel_id)
if not hasattr(channel, "get_partial_message"): 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)) msg = await channel.fetch_message(int(message_id))
else: else:
msg = channel.get_partial_message(int(message_id)) 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) await msg.remove_reaction(emoji_to_use, self._discord_client.user)
return True return True
except Exception as e: except Exception as e:
logger.debug(f"Discord set_reaction 失败: {e}") logger.debug(f"Discord set_reaction thất bại: {e}")
return False return False
@@ -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 ảnh đại diện Feishu.
""" """
from __future__ import annotations from __future__ import annotations
@@ -117,18 +118,18 @@ try:
ReplyMessageRequestBody = _ReplyMessageRequestBody ReplyMessageRequestBody = _ReplyMessageRequestBody
LARK_AVAILABLE = True 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 LARK_AVAILABLE = False
class LarkAdapter(PlatformAdapter): class LarkAdapter(PlatformAdapter):
"""飞书平台适配器。""" """Adapter nền tảng Feishu."""
platform_name = "lark" platform_name = "lark"
_DEFAULT_SCOPE_HINT = ( _DEFAULT_SCOPE_HINT = (
"Please grant these Feishu app scopes once: " "Vui lòng cấp các scope Feishu `im:message:readonly`, "
"`im:message:readonly`, `im:chat:readonly`, and user/contact read scopes " "`im:chat:readonly` và quyền đọc người dùng/danh bạ để lấy ảnh đại diện, "
"for profile avatar fields, then reinstall/re-authorize the app." "sau đó cài đặt hoặc uỷ quyền lại ứng dụng."
) )
def __init__( def __init__(
@@ -145,7 +146,7 @@ class LarkAdapter(PlatformAdapter):
self._permission_checked_groups: set[str] = set() self._permission_checked_groups: set[str] = set()
self._permission_error_by_group: dict[str, str | None] = {} self._permission_error_by_group: dict[str, str | None] = {}
logger.info( logger.info(
"飞书适配器初始化完成 (SDK可用=%s, 客户端就绪=%s)", "Đã khởi tạo adapter Feishu (SDK khả dụng=%s, client sẵn sàng=%s)",
LARK_AVAILABLE, LARK_AVAILABLE,
bool(self._lark_client), bool(self._lark_client),
) )
@@ -169,15 +170,15 @@ class LarkAdapter(PlatformAdapter):
def _resolve_lark_client(bot_instance: object) -> _SDKNode | None: def _resolve_lark_client(bot_instance: object) -> _SDKNode | None:
if bot_instance is None: if bot_instance is None:
return None return None
# 直接是 lark.Client # Chính là lark.Client.
if hasattr(bot_instance, "im") and hasattr(bot_instance, "contact"): if hasattr(bot_instance, "im") and hasattr(bot_instance, "contact"):
return cast(_SDKNode, bot_instance) return cast(_SDKNode, bot_instance)
# 平台实例上暴露 lark_api # Instance nền tảng công khai lark_api.
if hasattr(bot_instance, "lark_api"): if hasattr(bot_instance, "lark_api"):
api = getattr(bot_instance, "lark_api") api = getattr(bot_instance, "lark_api")
if hasattr(api, "im"): if hasattr(api, "im"):
return cast(_SDKNode, api) return cast(_SDKNode, api)
# 常见包装层 # Các lớp wrapper phổ biến.
for attr in ("client", "_client", "bot"): for attr in ("client", "_client", "bot"):
if hasattr(bot_instance, attr): if hasattr(bot_instance, attr):
client = getattr(bot_instance, attr) client = getattr(bot_instance, attr)
@@ -305,22 +306,29 @@ class LarkAdapter(PlatformAdapter):
self, group_id: str self, group_id: str
) -> tuple[bool, str | None]: ) -> tuple[bool, str | None]:
""" """
预热群成员缓存并完成权限探测 Làm nóng cache thành viên nhóm 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
ảnh đại diện đã đầy đủ.
""" """
if group_id in self._permission_checked_groups: if group_id in self._permission_checked_groups:
err = self._permission_error_by_group.get(group_id) err = self._permission_error_by_group.get(group_id)
logger.debug( logger.debug(
"飞书预检查命中缓存: 群=%s, 结果=%s", "Kiểm tra trước Feishu dùng cache: nhóm=%s, kết quả=%s",
group_id, group_id,
err is None, err is None,
) )
return err is None, err 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: 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) 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] return False, self._permission_error_by_group[group_id]
try: try:
@@ -330,10 +338,14 @@ class LarkAdapter(PlatformAdapter):
f"Cannot list chat members. {self._DEFAULT_SCOPE_HINT}" f"Cannot list chat members. {self._DEFAULT_SCOPE_HINT}"
) )
self._permission_checked_groups.add(group_id) 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] 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]] target_ids = [m.user_id for m in members[:100]]
avatar_map = await self.batch_get_avatar_urls(target_ids, size=240) 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)) 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) self._permission_checked_groups.add(group_id)
logger.warning( 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, group_id,
len(target_ids), len(target_ids),
) )
@@ -353,7 +365,7 @@ class LarkAdapter(PlatformAdapter):
self._permission_error_by_group[group_id] = None self._permission_error_by_group[group_id] = None
self._permission_checked_groups.add(group_id) self._permission_checked_groups.add(group_id)
logger.info( 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, group_id,
len(members), len(members),
avatar_ok_count, avatar_ok_count,
@@ -362,11 +374,11 @@ class LarkAdapter(PlatformAdapter):
return True, None return True, None
except Exception as e: except Exception as e:
self._permission_error_by_group[group_id] = ( 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) self._permission_checked_groups.add(group_id)
logger.error( logger.error(
"飞书预检查异常: 群=%s, 错误=%s", "Lỗi kiểm tra trước Feishu: nhóm=%s, lỗi=%s",
group_id, group_id,
e, e,
exc_info=True, exc_info=True,
@@ -382,7 +394,10 @@ class LarkAdapter(PlatformAdapter):
since_ts: int | None = None, since_ts: int | None = None,
) -> list[UnifiedMessage]: ) -> list[UnifiedMessage]:
if not LARK_AVAILABLE or not self._lark_client or not self._lark_client.im: 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 [] return []
now_seconds = int(__import__("time").time()) now_seconds = int(__import__("time").time())
start_seconds = ( start_seconds = (
@@ -395,7 +410,7 @@ class LarkAdapter(PlatformAdapter):
seen_ids: set[str] = set() seen_ids: set[str] = set()
page_index = 0 page_index = 0
logger.info( 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, group_id,
days, days,
max_count, max_count,
@@ -431,7 +446,7 @@ class LarkAdapter(PlatformAdapter):
response = await self._lark_client.im.v1.message.alist(request) response = await self._lark_client.im.v1.message.alist(request)
if not response.success(): if not response.success():
logger.warning( 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, group_id,
response.code, response.code,
response.msg, response.msg,
@@ -441,7 +456,7 @@ class LarkAdapter(PlatformAdapter):
items_raw = (response.data.items if response.data else None) or [] items_raw = (response.data.items if response.data else None) or []
items: list[object] = items_raw if isinstance(items_raw, list) else [] items: list[object] = items_raw if isinstance(items_raw, list) else []
logger.debug( 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, page_index,
group_id, group_id,
len(items), len(items),
@@ -471,7 +486,7 @@ class LarkAdapter(PlatformAdapter):
messages.sort(key=lambda m: m.timestamp) messages.sort(key=lambda m: m.timestamp)
logger.info( 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, group_id,
len(messages), len(messages),
page_index, page_index,
@@ -612,7 +627,7 @@ class LarkAdapter(PlatformAdapter):
), ),
) )
except Exception as e: 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 return None
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: 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) response = await self._lark_client.im.v1.message.acreate(request)
return bool(response.success()) return bool(response.success())
except Exception as e: 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 return False
async def send_image( async def send_image(
@@ -790,7 +805,7 @@ class LarkAdapter(PlatformAdapter):
await self.send_text(group_id, caption) await self.send_text(group_id, caption)
return bool(send_resp.success()) return bool(send_resp.success())
except Exception as e: except Exception as e:
logger.error(f"飞书图片发送失败: {e}") logger.error(f"Gửi ảnh Feishu thất bại: {e}")
return False return False
finally: finally:
if temp_path and temp_path.exists(): 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) msg_resp = await self._lark_client.im.v1.message.acreate(msg_req)
return bool(msg_resp.success()) return bool(msg_resp.success())
except Exception as e: except Exception as e:
logger.error(f"飞书文件发送失败: {e}") logger.error(f"Gửi tệp Feishu thất bại: {e}")
return False return False
async def get_group_info(self, group_id: str) -> UnifiedGroup | None: async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
@@ -880,24 +895,24 @@ class LarkAdapter(PlatformAdapter):
platform="lark", platform="lark",
) )
except Exception as e: 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 return None
async def get_group_list(self) -> list[str]: 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 [] return []
async def get_member_list(self, group_id: str) -> list[UnifiedMember]: async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
if not self._lark_client or not self._lark_client.im: if not self._lark_client or not self._lark_client.im:
logger.warning( 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, group_id,
) )
return [] return []
members: list[UnifiedMember] = [] members: list[UnifiedMember] = []
page_token: str | None = None page_token: str | None = None
page_index = 0 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: while True:
page_index += 1 page_index += 1
GetChatMembersRequestClass = self._request_class_or_throw( GetChatMembersRequestClass = self._request_class_or_throw(
@@ -919,14 +934,14 @@ class LarkAdapter(PlatformAdapter):
str(getattr(response, "msg", "") or ""), str(getattr(response, "msg", "") or ""),
): ):
logger.warning( 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, group_id,
response.code, response.code,
response.msg, response.msg,
) )
else: else:
logger.warning( 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, group_id,
getattr(response, "code", "unknown"), getattr(response, "code", "unknown"),
getattr(response, "msg", "unknown"), getattr(response, "msg", "unknown"),
@@ -935,7 +950,7 @@ class LarkAdapter(PlatformAdapter):
items_raw = (response.data.items if response.data else None) or [] items_raw = (response.data.items if response.data else None) or []
items: list[object] = items_raw if isinstance(items_raw, list) else [] items: list[object] = items_raw if isinstance(items_raw, list) else []
logger.debug( logger.debug(
"飞书成员分页: 页=%s, =%s, 条数=%s", "Phân trang thành viên Feishu: trang=%s, nhóm=%s, số lượng=%s",
page_index, page_index,
group_id, group_id,
len(items), len(items),
@@ -962,7 +977,7 @@ class LarkAdapter(PlatformAdapter):
page_token_raw = getattr(response.data, "page_token", None) page_token_raw = getattr(response.data, "page_token", None)
page_token = str(page_token_raw) if page_token_raw else None page_token = str(page_token_raw) if page_token_raw else None
logger.info( 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, group_id,
len(members), len(members),
page_index, page_index,
@@ -989,14 +1004,14 @@ class LarkAdapter(PlatformAdapter):
str(getattr(response, "msg", "") or ""), str(getattr(response, "msg", "") or ""),
): ):
logger.warning( 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), self._short_id(user_id),
response.code, response.code,
response.msg, response.msg,
) )
else: else:
logger.warning( 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), self._short_id(user_id),
getattr(response, "code", "unknown"), getattr(response, "code", "unknown"),
getattr(response, "msg", "unknown"), getattr(response, "msg", "unknown"),
@@ -1004,7 +1019,7 @@ class LarkAdapter(PlatformAdapter):
return None return None
return response.data return response.data
except Exception as e: except Exception as e:
logger.debug(f"飞书用户资料获取失败: {e}") logger.debug(f"Lấy hồ sơ Feishu thất bại: {e}")
return None return None
async def get_member_info( async def get_member_info(
@@ -1057,7 +1072,7 @@ class LarkAdapter(PlatformAdapter):
fallback_avatar = self._build_fallback_avatar(user_id, cached_name) fallback_avatar = self._build_fallback_avatar(user_id, cached_name)
self._avatar_url_cache[user_id] = fallback_avatar self._avatar_url_cache[user_id] = fallback_avatar
logger.debug( 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), self._short_id(user_id),
size, size,
) )
@@ -1106,7 +1121,7 @@ class LarkAdapter(PlatformAdapter):
unique_ids = list(dict.fromkeys(user_ids)) unique_ids = list(dict.fromkeys(user_ids))
semaphore = asyncio.Semaphore(8) semaphore = asyncio.Semaphore(8)
logger.debug( 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(user_ids),
len(unique_ids), len(unique_ids),
size, size,
@@ -1119,11 +1134,15 @@ class LarkAdapter(PlatformAdapter):
pairs = await asyncio.gather(*(_fetch(uid) for uid in unique_ids)) pairs = await asyncio.gather(*(_fetch(uid) for uid in unique_ids))
result = dict(pairs) result = dict(pairs)
ok_count = sum(1 for _, url in pairs if url) 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 return result
async def set_reaction( async def set_reaction(
self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
) -> bool: ) -> 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 return False
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,7 @@ class QQOfficialAdapter(PlatformAdapter):
if not cls._is_placeholder_sender_name(normalized, sender_id): if not cls._is_placeholder_sender_name(normalized, sender_id):
return normalized return normalized
digest = hashlib.sha256(f"{group_id}\0{sender_id}".encode()).hexdigest()[:8] 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: def set_context(self, context: Context) -> None:
self._context = context self._context = context
@@ -97,7 +97,9 @@ class QQOfficialAdapter(PlatformAdapter):
since_ts: int | None = None, since_ts: int | None = None,
) -> list[UnifiedMessage]: ) -> list[UnifiedMessage]:
if not self._context: 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 [] return []
history_mgr = self._context.message_history_manager history_mgr = self._context.message_history_manager
@@ -163,13 +165,17 @@ class QQOfficialAdapter(PlatformAdapter):
if len(messages) > target_count: if len(messages) > target_count:
messages = messages[-target_count:] messages = messages[-target_count:]
logger.info( logger.info(
"[QQOfficial] 从本地历史获取群 %s 消息 %s", "[QQOfficial] Đã lấy %s tin nhắn nhóm %s từ lịch sử cục bộ",
group_id,
len(messages), len(messages),
group_id,
) )
return messages return messages
except Exception as exc: 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 [] return []
def _convert_history_record( def _convert_history_record(
@@ -258,7 +264,9 @@ class QQOfficialAdapter(PlatformAdapter):
platform=self.platform_name, platform=self.platform_name,
) )
except Exception as exc: 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 return None
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: 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: async def _send_chain(self, group_id: str, chain: Any) -> bool:
if not self._context: 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 return False
try: try:
# AstrBot's QQ Official adapter keeps the group/channel scene only # 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}" umo = f"{self.platform_id}:GroupMessage:{group_id}"
return bool(await self._context.send_message(umo, chain)) return bool(await self._context.send_message(umo, chain))
except Exception as exc: 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 return False
async def send_text( async def send_text(
@@ -333,11 +341,11 @@ class QQOfficialAdapter(PlatformAdapter):
sent_markdown_chunks += 1 sent_markdown_chunks += 1
continue continue
logger.warning( 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: except Exception as exc:
logger.warning( 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, exc,
) )
markdown_enabled = False markdown_enabled = False
@@ -488,7 +496,9 @@ class QQOfficialAdapter(PlatformAdapter):
try: try:
return await self._plugin_instance.get_seen_group_ids(self.platform_id) return await self._plugin_instance.get_seen_group_ids(self.platform_id)
except Exception as exc: 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 [] return []
async def get_member_list(self, group_id: str) -> list[UnifiedMember]: 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" mime = "image/png" if payload.startswith(b"\x89PNG") else "image/jpeg"
return f"data:{mime};base64,{base64.b64encode(payload).decode('utf-8')}" return f"data:{mime};base64,{base64.b64encode(payload).decode('utf-8')}"
except Exception as exc: except Exception as exc:
logger.debug("[QQOfficial] 下载头像失败: %s", exc) logger.debug("[QQOfficial] Tải avatar thất bại: %s", exc)
return None return None
async def get_group_avatar_url(self, group_id: str, size: int = 100) -> str | None: async def get_group_avatar_url(self, group_id: str, size: int = 100) -> str | None:
@@ -1,9 +1,4 @@
""" """Adapter Telegram dùng Bot API và lịch sử tin nhắn AstrBot."""
Telegram 平台适配器
支持 Telegram Bot API 的消息发送功能
通过 AstrBot message_history_manager 存储和读取消息历史
"""
import asyncio import asyncio
import base64 import base64
@@ -30,7 +25,7 @@ from ..base import PlatformAdapter
if TYPE_CHECKING: if TYPE_CHECKING:
from astrbot.api.star import Context from astrbot.api.star import Context
# Telegram 依赖 # Dependency Telegram.
try: try:
from telegram.ext import ExtBot from telegram.ext import ExtBot
@@ -45,29 +40,17 @@ TELEGRAM_AVATAR_NEGATIVE_CACHE_MAX_SIZE = 1024
class TelegramAdapter(PlatformAdapter): class TelegramAdapter(PlatformAdapter):
""" """Adapter Telegram hỗ trợ gửi tin, avatar, nhóm và lịch sử AstrBot."""
Telegram Bot API 适配器
实现 PlatformAdapter 接口支持
- 消息发送文本图片文件
- 头像获取
- 群组信息获取
- 消息历史通过 AstrBot message_history_manager
消息历史机制
- 消息通过拦截器存储到 AstrBot 数据库
- fetch_messages 从数据库读取历史消息
"""
def __init__(self, bot_instance: Any, config: dict | None = None): def __init__(self, bot_instance: Any, config: dict | None = None):
super().__init__(bot_instance, config) super().__init__(bot_instance, config)
self._cached_client: Any = None self._cached_client: Any = None
self._context: Context | None = 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 "" 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] = [] self.bot_self_ids: list[str] = []
if config: if config:
ids = config.get("bot_self_ids", []) ids = config.get("bot_self_ids", [])
@@ -81,34 +64,31 @@ class TelegramAdapter(PlatformAdapter):
def set_context(self, context: "Context") -> None: 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 self._context = context
def _init_capabilities(self) -> PlatformCapabilities: def _init_capabilities(self) -> PlatformCapabilities:
"""返回 Telegram 平台能力声明""" """Trả về capability của nền tảng Telegram."""
return TELEGRAM_CAPABILITIES return TELEGRAM_CAPABILITIES
async def get_group_list(self) -> list[str]: async def get_group_list(self) -> list[str]:
""" """
获取群组列表 Lấy danh sách nhóm.
Telegram Bot API 不支持直接获取群列表 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.
1. 尝试调用 API (如果未来支持)
2. 回退从插件的 KV 存储中获取已知群组 (需注入插件实例)
""" """
groups = [] groups = []
# 1. 尝试 API (目前 python-telegram-bot 不支持直接列出所有 chat) # python-telegram-bot hiện chưa hỗ trợ liệt kê toàn bộ chat.
# 如果 client 有扩展方法或未来支持,可在此实现
# 2. 回退:使用 KV 注册表 # Fallback: dùng registry KV.
if not groups and self._plugin_instance: if not groups and self._plugin_instance:
try: 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"): if hasattr(self._plugin_instance, "get_telegram_seen_group_ids"):
kv_groups = await self._plugin_instance.get_telegram_seen_group_ids( kv_groups = await self._plugin_instance.get_telegram_seen_group_ids(
self._platform_id self._platform_id
@@ -116,48 +96,54 @@ class TelegramAdapter(PlatformAdapter):
if kv_groups: if kv_groups:
groups.extend(kv_groups) groups.extend(kv_groups)
logger.debug( logger.debug(
f"[Telegram] 通过 KV 回退获取到 {len(kv_groups)} 个群组" f"[Telegram] Fallback KV lấy được {len(kv_groups)} nhóm"
) )
except Exception as e: 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: 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)) return list(set(groups))
@property @property
def _telegram_client(self) -> Any: 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: if self._cached_client is not None:
return self._cached_client return self._cached_client
if not TELEGRAM_AVAILABLE: 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 return None
# 路径 A: bot 本身就是 ExtBot # Đường dẫn A: bot chính là ExtBot.
if ExtBot is not None and isinstance(self.bot, ExtBot): if ExtBot is not None and isinstance(self.bot, ExtBot):
self._cached_client = self.bot self._cached_client = self.bot
return self._cached_client return self._cached_client
# 路径 B: bot.client # Đường dẫn B: bot.client.
if hasattr(self.bot, "client"): if hasattr(self.bot, "client"):
client = self.bot.client client = self.bot.client
if ExtBot is not None and isinstance(client, ExtBot): if ExtBot is not None and isinstance(client, ExtBot):
self._cached_client = client self._cached_client = client
return self._cached_client return self._cached_client
# 路径 C: bot send_message 方法(ExtBot 的特征) # Đường dẫn C: bot send_message, đặc trưng ExtBot.
if hasattr(self.bot, "send_message") and hasattr(self.bot, "send_photo"): if hasattr(self.bot, "send_message") and hasattr(self.bot, "send_photo"):
self._cached_client = self.bot self._cached_client = self.bot
return self._cached_client 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"): for attr in ("_client", "telegram_client", "_telegram_client", "bot"):
if hasattr(self.bot, attr): if hasattr(self.bot, attr):
client = getattr(self.bot, attr) client = getattr(self.bot, attr)
@@ -165,7 +151,7 @@ class TelegramAdapter(PlatformAdapter):
self._cached_client = client self._cached_client = client
return self._cached_client return self._cached_client
logger.warning("无法从 bot_instance 获取 Telegram 客户端") logger.warning("Không thể lấy Telegram client từ bot_instance")
return None return None
# ==================== IMessageRepository ==================== # ==================== IMessageRepository ====================
@@ -179,12 +165,12 @@ class TelegramAdapter(PlatformAdapter):
since_ts: int | None = None, since_ts: int | None = None,
) -> list[UnifiedMessage]: ) -> 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: 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 [] return []
try: try:
@@ -192,7 +178,7 @@ class TelegramAdapter(PlatformAdapter):
platform_id = self._get_platform_id() platform_id = self._get_platform_id()
logger.info( 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 before_id_int: int | None = None
if before_id: if before_id:
@@ -202,7 +188,7 @@ class TelegramAdapter(PlatformAdapter):
logger.warning(f"[Telegram] before_id invalid: {before_id}") logger.warning(f"[Telegram] before_id invalid: {before_id}")
if since_ts and since_ts > 0: 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) cutoff_time = datetime.fromtimestamp(since_ts, timezone.utc)
else: else:
cutoff_time = datetime.now(timezone.utc) - timedelta(days=days) cutoff_time = datetime.now(timezone.utc) - timedelta(days=days)
@@ -224,14 +210,14 @@ class TelegramAdapter(PlatformAdapter):
if not history_records: if not history_records:
if current_page == 1: if current_page == 1:
logger.info( logger.info(
f"[Telegram] {group_id} 没有存储的消息。" f"[Telegram] Nhóm {group_id} chưa có tin nhắn đã lưu. "
f"提示:消息需要通过拦截器实时存储。" "Tin nhắn cần được interceptor lưu theo thời gian thực."
) )
break break
total_records_loaded += len(history_records) 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: for record in history_records:
sender_id = str(getattr(record, "sender_id", "") or "").strip() sender_id = str(getattr(record, "sender_id", "") or "").strip()
sender_name = str(getattr(record, "sender_name", "") or "").strip() sender_name = str(getattr(record, "sender_name", "") or "").strip()
@@ -264,7 +250,7 @@ class TelegramAdapter(PlatformAdapter):
if not msg: if not msg:
continue continue
# 过滤机器人自己的消息 # Lọc tin nhắn của bot.
if self.bot_user_id and msg.sender_id == self.bot_user_id: if self.bot_user_id and msg.sender_id == self.bot_user_id:
continue continue
if msg.sender_id in self.bot_self_ids: if msg.sender_id in self.bot_self_ids:
@@ -275,11 +261,11 @@ class TelegramAdapter(PlatformAdapter):
) )
messages.append(msg) messages.append(msg)
# 当前页完整处理后已足够,停止继续翻更旧页面。 # Dừng nếu đã đủ sau khi xử lý hết trang hiện tại.
if len(messages) >= target_count: if len(messages) >= target_count:
break 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: if oldest_record_time and oldest_record_time < cutoff_time:
break break
if len(history_records) < page_size: if len(history_records) < page_size:
@@ -291,17 +277,17 @@ class TelegramAdapter(PlatformAdapter):
messages = messages[-target_count:] messages = messages[-target_count:]
logger.info( logger.info(
f"[Telegram] 从数据库获取群 {group_id} 的消息: " f"[Telegram] Lấy tin nhắn nhóm {group_id} từ database: "
f"{len(messages)}/{total_records_loaded} " f"{len(messages)}/{total_records_loaded} mục"
) )
return messages return messages
except Exception as e: 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 [] return []
def _get_platform_id(self) -> str: def _get_platform_id(self) -> str:
"""获取平台 ID""" """Lấy platform ID."""
if self._platform_id: if self._platform_id:
return self._platform_id return self._platform_id
@@ -310,7 +296,7 @@ class TelegramAdapter(PlatformAdapter):
if config_platform_id: if config_platform_id:
return config_platform_id return config_platform_id
# 尝试从 bot 实例获取 # Thử lấy từ bot instance.
if hasattr(self.bot, "meta") and callable(self.bot.meta): if hasattr(self.bot, "meta") and callable(self.bot.meta):
try: try:
meta = self.bot.meta() # type: ignore meta = self.bot.meta() # type: ignore
@@ -322,7 +308,7 @@ class TelegramAdapter(PlatformAdapter):
@staticmethod @staticmethod
def _is_placeholder_sender_name(name: str | None, sender_id: str | None) -> bool: 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: if not name:
return True return True
normalized = str(name).strip() normalized = str(name).strip()
@@ -341,11 +327,9 @@ class TelegramAdapter(PlatformAdapter):
sender_name_cache: dict[str, str], sender_name_cache: dict[str, str],
) -> UnifiedMessage: ) -> UnifiedMessage:
""" """
如果 sender_name 是占位值尝试通过 get_member_info 修复 Nếu sender_name placeholder, thử sửa qua get_member_info.
说明 Tương thích dữ liệu lịch sử bẩn cache theo sender_id để tránh gọi API lặp.
- 兼容历史脏数据sender_name 写成 user_id / Unknown
- 使用 sender_id 级缓存避免重复请求 Telegram API
""" """
if not self._is_placeholder_sender_name(msg.sender_name, msg.sender_id): if not self._is_placeholder_sender_name(msg.sender_name, msg.sender_id):
return msg return msg
@@ -367,7 +351,7 @@ class TelegramAdapter(PlatformAdapter):
if not self._is_placeholder_sender_name(candidate, sender_id): if not self._is_placeholder_sender_name(candidate, sender_id):
resolved_name = candidate resolved_name = candidate
except Exception as e: 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 sender_name_cache[sender_id] = resolved_name
if resolved_name == msg.sender_name: if resolved_name == msg.sender_name:
@@ -378,14 +362,14 @@ class TelegramAdapter(PlatformAdapter):
self, record: Any, group_id: str self, record: Any, group_id: str
) -> UnifiedMessage | None: ) -> UnifiedMessage | None:
""" """
将数据库记录转换为 UnifiedMessage Chuyển bản ghi database thành UnifiedMessage.
""" """
try: try:
content = record.content content = record.content
if not content: if not content:
return None return None
# 提取消息内容 # Trích xuất nội dung tin nhắn.
message_parts = content.get("message", []) message_parts = content.get("message", [])
text_content = "" text_content = ""
contents = [] contents = []
@@ -448,14 +432,14 @@ class TelegramAdapter(PlatformAdapter):
) )
except Exception as e: 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 return None
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: 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 = [] result = []
for msg in messages: for msg in messages:
@@ -472,7 +456,7 @@ class TelegramAdapter(PlatformAdapter):
"user_id": msg.sender_id, "user_id": msg.sender_id,
} }
# 转换消息内容 # Chuyển nội dung tin nhắn.
for content in msg.contents: for content in msg.contents:
if content.type == MessageContentType.TEXT: if content.type == MessageContentType.TEXT:
raw["message"].append( raw["message"].append(
@@ -499,14 +483,14 @@ class TelegramAdapter(PlatformAdapter):
text: str, text: str,
reply_to: str | None = None, reply_to: str | None = None,
) -> bool: ) -> bool:
"""发送文本消息""" """Gửi tin nhắn văn bản."""
client = self._telegram_client client = self._telegram_client
if not 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 return False
try: try:
# 处理群组话题 ID # Xử lý ID topic nhóm.
chat_id, message_thread_id = self._parse_group_id(group_id) chat_id, message_thread_id = self._parse_group_id(group_id)
kwargs: dict[str, Any] = {"chat_id": chat_id, "text": text} kwargs: dict[str, Any] = {"chat_id": chat_id, "text": text}
@@ -518,7 +502,7 @@ class TelegramAdapter(PlatformAdapter):
await client.send_message(**kwargs) await client.send_message(**kwargs)
return True return True
except Exception as e: 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 return False
async def send_image( async def send_image(
@@ -527,10 +511,10 @@ class TelegramAdapter(PlatformAdapter):
image_path: str, image_path: str,
caption: str = "", caption: str = "",
) -> bool: ) -> bool:
"""发送图片消息""" """Gửi tin nhắn ảnh."""
client = self._telegram_client client = self._telegram_client
if not 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 return False
try: try:
@@ -544,7 +528,7 @@ class TelegramAdapter(PlatformAdapter):
if caption: if caption:
kwargs["caption"] = 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://"): if image_path.startswith("base64://"):
data = base64.b64decode(image_path[len("base64://") :]) data = base64.b64decode(image_path[len("base64://") :])
file_obj = BytesIO(data) file_obj = BytesIO(data)
@@ -568,19 +552,21 @@ class TelegramAdapter(PlatformAdapter):
file_obj = BytesIO(data) file_obj = BytesIO(data)
is_temp_obj = True is_temp_obj = True
else: else:
file_obj = image_path # 尝试直接发 URL file_obj = image_path # Thử gửi URL trực tiếp.
except Exception as e: 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 file_obj = image_path
else: else:
# 本地文件 # Tệp local.
if os.path.exists(image_path): if os.path.exists(image_path):
file_obj = open(image_path, "rb") file_obj = open(image_path, "rb")
is_temp_obj = True is_temp_obj = True
else: else:
file_obj = image_path file_obj = image_path
# 2. 发送图片 # 2. Gửi ảnh.
kwargs["photo"] = file_obj kwargs["photo"] = file_obj
try: try:
await client.send_photo(**kwargs) await client.send_photo(**kwargs)
@@ -592,18 +578,20 @@ class TelegramAdapter(PlatformAdapter):
except Exception as e: except Exception as e:
err_msg = str(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 ( if (
"Photo_invalid_dimensions" in err_msg "Photo_invalid_dimensions" in err_msg
or "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") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
fn = f"analysis_report_{group_id}_{ts}.png" fn = f"analysis_report_{group_id}_{ts}.png"
return await self.send_file(group_id, image_path, filename=fn) 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 return False
async def send_file( async def send_file(
@@ -612,10 +600,10 @@ class TelegramAdapter(PlatformAdapter):
file_path: str, file_path: str,
filename: str | None = None, filename: str | None = None,
) -> bool: ) -> bool:
"""发送文件消息""" """Gửi tin nhắn tệp."""
client = self._telegram_client client = self._telegram_client
if not 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 return False
try: try:
@@ -627,7 +615,7 @@ class TelegramAdapter(PlatformAdapter):
if message_thread_id: if message_thread_id:
kwargs["message_thread_id"] = int(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://"): if file_path.startswith("base64://"):
data = base64.b64decode(file_path[len("base64://") :]) data = base64.b64decode(file_path[len("base64://") :])
file_obj = BytesIO(data) file_obj = BytesIO(data)
@@ -648,7 +636,7 @@ class TelegramAdapter(PlatformAdapter):
if not filename: if not filename:
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
else: else:
# 可能是 URL 或缓存 ID # Có thể là URL hoặc cache ID.
file_obj = file_path file_obj = file_path
if not filename: if not filename:
filename = "file" filename = "file"
@@ -664,25 +652,25 @@ class TelegramAdapter(PlatformAdapter):
return True return True
except Exception as e: except Exception as e:
logger.error(f"[Telegram] 发送文件失败: {e}") logger.error(f"[Telegram] Gửi tệp thất bại: {e}")
return False return False
async def send_forward_msg(self, group_id: str, nodes: list[dict]) -> bool: 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: if not nodes:
return True return True
lines = ["📊 **分析报告**\n"] lines = ["📊 **Báo cáo phân tích**\n"]
for node in nodes: for node in nodes:
data = node.get("data", node) data = node.get("data", node)
name = data.get("name", "AstrBot") name = data.get("name", "AstrBot")
content = data.get("content", "") content = data.get("content", "")
if isinstance(content, list): if isinstance(content, list):
# 消息链 # Chuỗi tin nhắn.
text_parts = [] text_parts = []
for seg in content: for seg in content:
if isinstance(seg, dict) and seg.get("type") == "text": if isinstance(seg, dict) and seg.get("type") == "text":
@@ -692,7 +680,7 @@ class TelegramAdapter(PlatformAdapter):
full_text = "\n".join(lines) full_text = "\n".join(lines)
# 分段发送(Telegram 限制 4096 字符) # Chia đoạn vì Telegram giới hạn 4096 ký tự.
max_len = 4000 max_len = 4000
if len(full_text) > max_len: if len(full_text) > max_len:
parts = [ parts = [
@@ -708,7 +696,7 @@ class TelegramAdapter(PlatformAdapter):
# ==================== IGroupInfoRepository ==================== # ==================== IGroupInfoRepository ====================
async def get_group_info(self, group_id: str) -> UnifiedGroup | None: async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
"""获取群组信息""" """Lấy thông tin nhóm."""
client = self._telegram_client client = self._telegram_client
if not client: if not client:
return None return None
@@ -725,14 +713,14 @@ class TelegramAdapter(PlatformAdapter):
platform="telegram", platform="telegram",
) )
except Exception as e: 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 return None
async def get_member_list(self, group_id: str) -> list[UnifiedMember]: 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 client = self._telegram_client
if not client: if not client:
@@ -740,8 +728,7 @@ class TelegramAdapter(PlatformAdapter):
try: try:
chat_id, _ = self._parse_group_id(group_id) 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) admins = await client.get_chat_administrators(chat_id=chat_id)
members = [] members = []
@@ -760,7 +747,7 @@ class TelegramAdapter(PlatformAdapter):
) )
return members return members
except Exception as e: 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 [] return []
async def get_member_info( async def get_member_info(
@@ -768,7 +755,7 @@ class TelegramAdapter(PlatformAdapter):
group_id: str, group_id: str,
user_id: str, user_id: str,
) -> UnifiedMember | None: ) -> UnifiedMember | None:
"""获取成员信息""" """Lấy thông tin thành viên."""
client = self._telegram_client client = self._telegram_client
if not client: if not client:
return None return None
@@ -794,7 +781,7 @@ class TelegramAdapter(PlatformAdapter):
role=role, role=role,
) )
except Exception as e: 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 return None
# ==================== IAvatarRepository ==================== # ==================== IAvatarRepository ====================
@@ -805,14 +792,14 @@ class TelegramAdapter(PlatformAdapter):
size: int = 100, size: int = 100,
) -> str | None: ) -> 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 client = self._telegram_client
if not client: if not client:
logger.warning( 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 return None
@@ -820,67 +807,72 @@ class TelegramAdapter(PlatformAdapter):
cached_reason = self._get_avatar_negative_cache_reason(user_id_str) cached_reason = self._get_avatar_negative_cache_reason(user_id_str)
if cached_reason: if cached_reason:
logger.debug( logger.debug(
f"[Telegram] 跳过用户头像获取 uid={user_id_str}: negative cache 命中," f"[Telegram] Bỏ qua avatar uid={user_id_str}: trúng negative cache, "
f"上次失败原因: {cached_reason}" f"lý do thất bại trước: {cached_reason}"
) )
return None return None
try: try:
tg_user_id = int(user_id_str) tg_user_id = int(user_id_str)
except (TypeError, ValueError): 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) 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 return None
try: try:
photos = await client.get_user_profile_photos(user_id=tg_user_id, limit=1) photos = await client.get_user_profile_photos(user_id=tg_user_id, limit=1)
if photos.photos: if photos.photos:
# 获取最大尺寸的头像 # Lấy avatar kích thước lớn nhất.
photo_sizes = photos.photos[0] photo_sizes = photos.photos[0]
if photo_sizes: if photo_sizes:
# 选择最接近请求尺寸的 # Chọn kích thước gần yêu cầu nhất.
best = photo_sizes[-1] # 通常最后一个是最大的 best = photo_sizes[-1] # Phần tử cuối thường lớn nhất.
file = await client.get_file(best.file_id) file = await client.get_file(best.file_id)
if file.file_path: if file.file_path:
# 构建完整 URL # Dựng URL đầy đủ; File.file_path thường chỉ trả phần path.
# 格式: https://api.telegram.org/file/bot<token>/<file_path>
# python-telegram-bot 的 File.file_path 属性通常只返回路径部分
# 需要手动拼接或使用 instance.file.file_path (取决于版本)
file_path = file.file_path file_path = file.file_path
if file_path.startswith("http"): if file_path.startswith("http"):
return file_path return file_path
# 尝试构建完整 URL # Thử dựng URL đầy đủ.
if hasattr(client, "token"): if hasattr(client, "token"):
return f"https://api.telegram.org/file/bot{client.token}/{file_path}" return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
# 如果无法获取 token,返回 None # Trả None nếu không lấy được token.
reason = "get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL" 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) self._remember_avatar_negative(user_id_str, reason)
logger.warning( 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 return None
reason = "get_file 未返回 file_path" reason = "get_file không trả file_path"
self._remember_avatar_negative(user_id_str, reason) self._remember_avatar_negative(user_id_str, reason)
logger.warning( 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 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) 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 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) 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 return None
except Exception as e: except Exception as e:
reason = f"{type(e).__name__}: {e}" reason = f"{type(e).__name__}: {e}"
self._remember_avatar_negative(user_id_str, reason) 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 return None
async def get_user_avatar_data( async def get_user_avatar_data(
@@ -888,10 +880,10 @@ class TelegramAdapter(PlatformAdapter):
user_id: str, user_id: str,
size: int = 100, size: int = 100,
) -> str | None: ) -> str | None:
"""获取头像的 Base64 数据""" """Lấy dữ liệu Base64 của avatar."""
# 暂不实现,返回 None # Chưa triển khai, trả về None.
logger.debug( 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 return None
@@ -900,11 +892,11 @@ class TelegramAdapter(PlatformAdapter):
group_id: str, group_id: str,
size: int = 100, size: int = 100,
) -> str | None: ) -> str | None:
"""获取群组头像 URL""" """Lấy URL avatar nhóm."""
client = self._telegram_client client = self._telegram_client
if not client: if not client:
logger.warning( 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 return None
@@ -923,26 +915,26 @@ class TelegramAdapter(PlatformAdapter):
return f"https://api.telegram.org/file/bot{client.token}/{file_path}" return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
logger.warning( logger.warning(
f"[Telegram] 获取群头像失败 group_id={group_id}: " f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: "
"get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL" "get_file trả file_path tương đối nhưng client không có token để dựng URL tải"
) )
return None return None
logger.warning( 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 return None
logger.info( 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 return None
except Exception as e: except Exception as e:
logger.warning( 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 return None
def _prune_avatar_negative_cache(self) -> 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 cache = self._avatar_negative_cache
if not cache: if not cache:
return return
@@ -988,11 +980,11 @@ class TelegramAdapter(PlatformAdapter):
user_ids: list[str], user_ids: list[str],
size: int = 100, size: int = 100,
) -> dict[str, str | None]: ) -> dict[str, str | None]:
"""批量获取头像 URL""" """Lấy hàng loạt URL avatar."""
if not user_ids: if not user_ids:
return {} 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) semaphore = asyncio.Semaphore(8)
async def _fetch_avatar(uid: str) -> tuple[str, str | None]: async def _fetch_avatar(uid: str) -> tuple[str, str | None]:
@@ -1000,7 +992,9 @@ class TelegramAdapter(PlatformAdapter):
try: try:
return uid, await self.get_user_avatar_url(uid, size) return uid, await self.get_user_avatar_url(uid, size)
except Exception as e: 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 return uid, None
pairs = await asyncio.gather(*(_fetch_avatar(uid) for uid in user_ids)) 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 self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
) -> bool: ) -> bool:
""" """
Telegram 实现消息回应 Triển khai reaction tin nhắn Telegram.
""" """
client = self._telegram_client client = self._telegram_client
if not client: if not client:
@@ -1019,7 +1013,7 @@ class TelegramAdapter(PlatformAdapter):
try: try:
chat_id, _ = self._parse_group_id(group_id) 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 PTB 20.8 trở lên.
if not hasattr(client, "set_message_reaction"): if not hasattr(client, "set_message_reaction"):
return False return False
@@ -1079,20 +1073,20 @@ class TelegramAdapter(PlatformAdapter):
continue continue
logger.debug( 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 return False
except Exception as e: except Exception as e:
logger.debug(f"[Telegram] set_reaction 失败: {e}") logger.debug(f"[Telegram] set_reaction thất bại: {e}")
return False return False
# ==================== 辅助方法 ==================== # ==================== Phương thức hỗ trợ ====================
def _parse_group_id(self, group_id: str) -> tuple[str, str | None]: 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 dạng ``chat_id#thread_id``.
Returns: Returns:
tuple[str, str | None]: (chat_id, message_thread_id) tuple[str, str | None]: (chat_id, message_thread_id)
+48 -47
View File
@@ -1,6 +1,4 @@
""" """Lớp cơ sở cho adapter nền tảng."""
平台适配器基类
"""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Mapping from collections.abc import Mapping
@@ -20,14 +18,14 @@ class PlatformAdapter(
IMessageRepository, IMessageSender, IGroupInfoRepository, IAvatarRepository, ABC IMessageRepository, IMessageSender, IGroupInfoRepository, IAvatarRepository, ABC
): ):
""" """
基础设施平台适配器基类 Lớp sở adapter tầng infrastructure.
继承自多个领域接口仓储发送器群组信息头像 Kế thừa các giao diện domain về repository, gửi tin nhắn, thông tin nhóm
充当领域层与具体聊天平台 OneBot, Discord之间的中转站 avatar; làm cầu nối giữa domain với nền tảng như OneBot hoặc Discord.
Attributes: Attributes:
bot (Any): 平台对应的机器人 SDK 实例显式标注为 Any 以支持动态属性调用 bot: Instance SDK bot của nền tảng.
config (dict): 针对该平台的特定配置 config: Cấu hình riêng của nền tảng.
""" """
bot: Any bot: Any
@@ -38,11 +36,11 @@ class PlatformAdapter(
config: Mapping[str, Any] | None = None, config: Mapping[str, Any] | None = None,
): ):
""" """
初始化平台适配器 Khởi tạo adapter nền tảng.
Args: Args:
bot_instance (Any): 后端机器人实例 bot_instance: Instance bot backend.
config (dict, optional): 平台特定配置项 config: Cấu hình riêng của nền tảng.
""" """
self.bot = bot_instance self.bot = bot_instance
self.config: dict[str, object] = dict(config) if config is not None else {} 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): def set_context(self, context: Any):
""" """
设置上下文对象用于部分需要 ctx 的平台如 Telegram Thiết lập context cho nền tảng cần context như Telegram.
Args: Args:
context (Any): 上下文对象 context: Đối tượng context.
""" """
pass pass
@property @property
def capabilities(self) -> PlatformCapabilities: def capabilities(self) -> PlatformCapabilities:
""" """
获取当前平台的能力描述对象 Lấy tả năng lực của nền tảng hiện tại.
采用延迟加载机制在首次访问时调用 `_init_capabilities` Dùng lazy loading gọi ``_init_capabilities`` khi truy cập lần đầu.
Returns: Returns:
PlatformCapabilities: 平台能力对象 Đối tượng năng lực nền tảng.
""" """
if self._capabilities is None: if self._capabilities is None:
self._capabilities = self._init_capabilities() self._capabilities = self._init_capabilities()
@@ -75,35 +73,35 @@ class PlatformAdapter(
@abstractmethod @abstractmethod
def _init_capabilities(self) -> PlatformCapabilities: def _init_capabilities(self) -> PlatformCapabilities:
""" """
初始化并返回当前平台的能力定义 Khởi tạo 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 các tính năng khác.
Returns: Returns:
PlatformCapabilities: 初始化后的能力对象 Đối tượng năng lực đã khởi tạo.
""" """
raise NotImplementedError raise NotImplementedError
def get_capabilities(self) -> PlatformCapabilities: 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 return self.capabilities
def get_platform_name(self) -> str: 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 return self.capabilities.platform_name
@abstractmethod @abstractmethod
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: 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 phụ thuộc cấu trúc gốc.
Args: Args:
messages (list[UnifiedMessage]): 待转换的统一消息列表 messages: Danh sách tin nhắn thống nhất cần chuyển.
Returns: Returns:
list[dict]: 转换后的平台原生消息字典列表 Danh sách dict tin nhắn gốc của nền tảng.
""" """
raise NotImplementedError raise NotImplementedError
@@ -113,13 +111,14 @@ class PlatformAdapter(
nodes: list[dict], nodes: list[dict],
) -> bool: ) -> bool:
""" """
发送合并转发消息基类默认实现转换为格式化文本分段发送 Gửi tin nhắn chuyển tiếp gộp.
各适配器可覆盖此方法实现原生合并转发
Mặc định chuyển thành văn bản định dạng chia đoạn; adapter thể ghi đè.
""" """
if not nodes: if not nodes:
return True return True
# 万能回退:将节点重新组合成易读的长文本 # Fallback chung: ghép node thành văn bản dài dễ đọc.
lines = [] lines = []
for node in nodes: for node in nodes:
data = node.get("data", node) data = node.get("data", node)
@@ -130,14 +129,14 @@ class PlatformAdapter(
full_text = "\n\n".join(lines) 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 max_chunk_size = 1800
if len(full_text) > max_chunk_size: if len(full_text) > max_chunk_size:
# 尝试在换行处拆分 # Thử tách tại ký tự xuống dòng.
chunks = [] chunks = []
curr = full_text curr = full_text
while len(curr) > max_chunk_size: 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) split_idx = curr.rfind("\n", 0, max_chunk_size)
if split_idx == -1: if split_idx == -1:
split_idx = max_chunk_size 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 self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
) -> bool: ) -> bool:
""" """
对消息添加/移除表情回应 Thêm hoặc xoá reaction cho tin nhắn.
Args: Args:
group_id (str): 群组/频道 ID group_id: ID nhóm/kênh.
message_id (str): 消息 ID message_id: ID tin nhắn.
emoji (str | int): 表情代码或字符 emoji: hoặc tự emoji.
is_add (bool): True 为添加False 为移除 is_add: True để thêm, False để xoá.
Returns: Returns:
bool: 平台是否支持并成功执行 True nếu nền tảng hỗ trợ thực thi thành công.
""" """
return False return False
async def send_text_report(self, group_id: str, content: str) -> bool: async def send_text_report(self, group_id: str, content: str) -> bool:
""" """
以最适合当前平台的方式发送长文本报告 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.
默认逻辑将长文本切分为多个节点然后调用 send_forward_msg
各平台适配器通过实现 send_forward_msg 来决定最终呈现形式合并转发分段发送等 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 import re
try: 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_id = self.bot_self_ids[0] if self.bot_self_ids else "bot"
self_name = "分析报告" self_name = "Báo cáo phân tích"
# 2. 切分文本为逻辑段落(按标题、空行切分) # 2. Chia thành đoạn logic theo tiêu đề và dòng trống.
raw_content = str(content) raw_content = str(content)
sections = re.split(r"\n+(?=[🎯📊💬🏆])|\n{2,}", raw_content.strip()) sections = re.split(r"\n+(?=[🎯📊💬🏆])|\n{2,}", raw_content.strip())
nodes = [] nodes = []
@@ -204,15 +204,16 @@ class PlatformAdapter(
if not nodes: if not nodes:
return await self.send_text(group_id, raw_content) 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) return await self.send_forward_msg(group_id, nodes)
except Exception: except Exception:
# 兜底:直接发送 # Fallback: gửi trực tiếp.
return await self.send_text(group_id, str(content)) return await self.send_text(group_id, str(content))
async def is_group_muted(self, group_id: str) -> bool: async def is_group_muted(self, group_id: str) -> bool:
""" """
检查群聊是否被禁言包括全体禁言或对 Bot 自身禁言 Kiểm tra nhóm tắt chat toàn bộ hoặc tắt quyền bot hay không.
默认返回 False各平台适配器可以根据需要重写此方法
Mặc định trả về False; adapter thể ghi đè khi cần.
""" """
return False return False
+107 -112
View File
@@ -1,7 +1,4 @@
""" """Quản lý tập trung các bot instance ở tầng infrastructure."""
Bot实例管理模块 - 基础设施层
统一管理bot实例的获取设置和使用
"""
from __future__ import annotations from __future__ import annotations
@@ -12,50 +9,47 @@ from . import PlatformAdapter, PlatformAdapterFactory
class BotManager: class BotManager:
""" """Quản lý bot và tạo PlatformAdapter tương ứng để hỗ trợ đa nền tảng."""
Bot实例管理器 - 统一管理所有bot相关操作
DDD 架构集成为每个 bot 实例创建对应的 PlatformAdapter
实现跨平台支持
"""
def __init__(self, config_manager): def __init__(self, config_manager):
self.config_manager = config_manager self.config_manager = config_manager
self._bot_instances: dict[str, object] = {} # {platform_id: bot_instance} self._bot_instances: dict[str, object] = {} # {platform_id: bot_instance}
self._adapters: dict[ self._adapters: dict[
str, PlatformAdapter str, PlatformAdapter
] = {} # {platform_id: PlatformAdapter} - DDD 集成 ] = {} # {platform_id: PlatformAdapter} - tích hợp DDD.
self._platforms: dict[str, object] = {} # 存储平台对象以访问配置 self._platforms: dict[str, object] = {} # Lưu platform để truy cập cấu hình.
self._bot_self_ids: list[str] = [] # 支持多个机器人账号 ID (原 _bot_qq_ids) 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._context: object | None = None
self._is_initialized = False self._is_initialized = False
self._default_platform = "default" # 默认平台 self._default_platform = "default" # Nền tảng mặc định.
self._plugin_instance: object | None = None # 插件实例引用,用于适配器回调 self._plugin_instance: object | None = None # Dùng cho callback adapter.
def set_context(self, context): def set_context(self, context):
"""设置AstrBot上下文,并传递给所有支持的适配器""" """Thiết lập context AstrBot và truyền tới adapter có hỗ trợ."""
self._context = context self._context = context
# context 传递给所有支持 set_context 的适配器 # Truyền context tới mọi adapter hỗ trợ set_context.
for adapter in self._adapters.values(): for adapter in self._adapters.values():
if hasattr(adapter, "set_context"): if hasattr(adapter, "set_context"):
adapter.set_context(context) adapter.set_context(context)
def set_plugin_instance(self, plugin_instance: object): def set_plugin_instance(self, plugin_instance: object):
"""设置插件实例引用""" """Thiết lập tham chiếu plugin instance."""
self._plugin_instance = plugin_instance self._plugin_instance = plugin_instance
def set_bot_instance(self, bot_instance, platform_id=None, platform_name=None): 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: if not platform_id:
platform_id = self._get_platform_id_from_instance(bot_instance) platform_id = self._get_platform_id_from_instance(bot_instance)
if bot_instance and platform_id: 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) old_instance = self._bot_instances.get(platform_id)
if bot_instance is old_instance and platform_id in self._adapters: if bot_instance is old_instance and platform_id in self._adapters:
bot_self_id = self._extract_bot_self_id(bot_instance) bot_self_id = self._extract_bot_self_id(bot_instance)
@@ -65,7 +59,7 @@ class BotManager:
self._bot_instances[platform_id] = bot_instance self._bot_instances[platform_id] = bot_instance
# 为 DDD 集成创建 PlatformAdapter # Tạo PlatformAdapter để tích hợp DDD.
if platform_name is None: if platform_name is None:
platform_name = self._detect_platform_name(bot_instance) platform_name = self._detect_platform_name(bot_instance)
@@ -84,91 +78,91 @@ class BotManager:
platform_name, bot_instance, adapter_config platform_name, bot_instance, adapter_config
) )
if adapter: if adapter:
# 如果有 context,传递给适配器 # Truyền context tới adapter nếu có.
if self._context is not None: if self._context is not None:
adapter.set_context(self._context) adapter.set_context(self._context)
self._adapters[platform_id] = adapter self._adapters[platform_id] = adapter
logger.debug( 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) bot_self_id = self._extract_bot_self_id(bot_instance)
if bot_self_id and bot_self_id not in self._bot_self_ids: if bot_self_id and bot_self_id not in self._bot_self_ids:
self._bot_self_ids.append(str(bot_self_id)) self._bot_self_ids.append(str(bot_self_id))
def set_bot_self_ids(self, bot_self_ids): 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): if isinstance(bot_self_ids, list):
self._bot_self_ids = [str(uid) for uid in bot_self_ids if uid] self._bot_self_ids = [str(uid) for uid in bot_self_ids if uid]
elif bot_self_ids: elif bot_self_ids:
self._bot_self_ids = [str(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(): for adapter in self._adapters.values():
if hasattr(adapter, "bot_self_ids"): if hasattr(adapter, "bot_self_ids"):
adapter.bot_self_ids = self._bot_self_ids.copy() adapter.bot_self_ids = self._bot_self_ids.copy()
def get_bot_instance(self, platform_id=None): 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: if platform_id:
# 如果指定了平台ID,尝试获取 # Thử lấy theo platform ID được chỉ định.
instance = self._bot_instances.get(platform_id) instance = self._bot_instances.get(platform_id)
if not instance and platform_id in self._platforms: if not instance and platform_id in self._platforms:
self._refresh_from_stored_platforms() self._refresh_from_stored_platforms()
instance = self._bot_instances.get(platform_id) instance = self._bot_instances.get(platform_id)
return instance return instance
# 没有指定平台ID # Không có platform ID.
if not self._bot_instances and self._platforms: if not self._bot_instances and self._platforms:
self._refresh_from_stored_platforms() self._refresh_from_stored_platforms()
if self._bot_instances: if self._bot_instances:
# 如果只有一个实例,直接返回 # Trả trực tiếp nếu chỉ có một instance.
if len(self._bot_instances) == 1: if len(self._bot_instances) == 1:
return list(self._bot_instances.values())[0] return list(self._bot_instances.values())[0]
# 如果有多个实例,必须指定 platform_id # Bắt buộc chỉ định platform_id khi có nhiều instance.
logger.error( logger.error(
f"存在多个Bot实例 {list(self._bot_instances.keys())} 但未指定 platform_id" f"Có nhiều bot instance {list(self._bot_instances.keys())} nhưng chưa chỉ định platform_id; "
"无法确定使用哪个实例。请明确指定 platform_id。" "không thể xác định instance cần dùng"
) )
return None return None
# 没有任何平台可用 # Không có nền tảng khả dụng.
logger.error("没有任何可用的bot实例") logger.error("Không có bot instance khả dụng")
return None return None
def _refresh_from_stored_platforms(self): 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(): for platform_id, platform in self._platforms.items():
bot_client = None 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) bot_client = getattr(platform, "lark_api", None)
# 优先尝试 get_client() # Ưu tiên get_client().
get_client = getattr(platform, "get_client", None) get_client = getattr(platform, "get_client", None)
if not bot_client and callable(get_client): if not bot_client and callable(get_client):
bot_client = 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: if not bot_client:
bot_client = getattr(platform, "bot", None) bot_client = getattr(platform, "bot", None)
if not bot_client: 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) bot_client = getattr(platform, "client", None)
if bot_client: 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) 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: if bot_client is old_client and platform_id in self._adapters:
continue continue
platform_name = None platform_name = None
metadata_obj = getattr(platform, "metadata", None) metadata_obj = getattr(platform, "metadata", None)
if metadata_obj is not None: if metadata_obj is not None:
# 优先使用 type # Ưu tiên type.
type_val = getattr(metadata_obj, "type", None) type_val = getattr(metadata_obj, "type", None)
if isinstance(type_val, str): if isinstance(type_val, str):
platform_name = type_val platform_name = type_val
@@ -177,7 +171,7 @@ class BotManager:
if isinstance(name_val, str): if isinstance(name_val, str):
platform_name = name_val platform_name = name_val
# 兼容不同版本的元数据获取 # Tương thích cách lấy metadata ở các phiên bản.
if not platform_name: if not platform_name:
meta = getattr(platform, "meta", None) meta = getattr(platform, "meta", None)
if callable(meta): if callable(meta):
@@ -187,7 +181,7 @@ class BotManager:
except Exception: except Exception:
pass pass
# 后备检测:如果不支持名称 # Phát hiện fallback nếu tên không được hỗ trợ.
if not platform_name or not PlatformAdapterFactory.is_supported( if not platform_name or not PlatformAdapterFactory.is_supported(
str(platform_name) str(platform_name)
): ):
@@ -196,76 +190,77 @@ class BotManager:
platform_name = detected platform_name = detected
self.set_bot_instance(bot_client, platform_id, platform_name) 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: 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() return self._bot_instances.copy()
def get_platform_count(self) -> int: def get_platform_count(self) -> int:
"""获取当前已加载的平台数量""" """Lấy số nền tảng hiện đã tải."""
return len(self._bot_instances) return len(self._bot_instances)
def get_platform_ids(self) -> list[str]: def get_platform_ids(self) -> list[str]:
"""获取所有已加载的平台 ID 列表""" """Lấy danh sách platform ID đã tải."""
return list(self._bot_instances.keys()) return list(self._bot_instances.keys())
def has_bot_instance(self) -> bool: def has_bot_instance(self) -> bool:
"""检查是否有可用的bot实例""" """Kiểm tra có bot instance khả dụng hay không."""
return bool(self._bot_instances) return bool(self._bot_instances)
def has_bot_self_id(self) -> bool: 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) return bool(self._bot_self_ids)
def is_ready_for_auto_analysis(self) -> bool: 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(): 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 return False
if not self.has_bot_self_id(): if not self.has_bot_self_id():
# 允许在没有配置/自动提取到 ID 的情况下尝试,但记录调试信息 # Vẫn cho phép thử khi chưa cấu hình/trích xuất được ID và ghi debug.
# 这有助于诊断某些平台(如 Telegram)自动获取 ID 失败的情况
logger.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 及其后续反馈,我们将此检查放宽 # Nới lỏng kiểm tra theo #128: có bot instance là có thể thử chạy.
# 只要有 bot 实例就可以尝试运行
return True return True
return True return True
def _get_platform_id_from_instance(self, bot_instance): 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): if hasattr(bot_instance, "platform") and isinstance(bot_instance.platform, str):
return bot_instance.platform return bot_instance.platform
return self._default_platform return self._default_platform
def _detect_platform_name(self, bot_instance) -> str | None: 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"): if hasattr(bot_instance, "platform"):
platform = bot_instance.platform platform = bot_instance.platform
if isinstance(platform, str): if isinstance(platform, str):
return platform return platform
# 检查已知的 API 特征(平台无关的方式) # Kiểm tra đặc trưng API đã biết; OneBot/aiocqhttp có call_action.
# OneBot/aiocqhttp 特征: 有 call_action 方法
if hasattr(bot_instance, "call_action"): if hasattr(bot_instance, "call_action"):
return "aiocqhttp" return "aiocqhttp"
# 使用工厂的已注册平台列表进行类名匹配 # Khớp tên class với nền tảng đã đăng ký trong factory.
class_name = type(bot_instance).__name__.lower() class_name = type(bot_instance).__name__.lower()
for platform_name in PlatformAdapterFactory.get_supported_platforms(): for platform_name in PlatformAdapterFactory.get_supported_platforms():
if platform_name in class_name: if platform_name in class_name:
return platform_name return platform_name
# 通用类名模式匹配(用于尚未注册的平台) # Khớp mẫu tên class chung cho nền tảng chưa đăng ký.
known_patterns = { known_patterns = {
"cqhttp": "aiocqhttp", "cqhttp": "aiocqhttp",
"onebot": "aiocqhttp", "onebot": "aiocqhttp",
@@ -276,16 +271,16 @@ class BotManager:
return None return None
# ==================== DDD 集成方法 ==================== # ==================== Phương thức tích hợp DDD ====================
def get_adapter(self, platform_id: str | None = None) -> PlatformAdapter | None: 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 phương thức chính cho thao tác kiến trúc DDD.
""" """
if platform_id: 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: if platform_id in self._platforms:
self._refresh_from_stored_platforms() self._refresh_from_stored_platforms()
@@ -296,11 +291,11 @@ class BotManager:
return list(self._adapters.values())[0] return list(self._adapters.values())[0]
logger.warning( 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 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() self._refresh_from_stored_platforms()
if self._adapters: if self._adapters:
if platform_id: if platform_id:
@@ -311,17 +306,17 @@ class BotManager:
return None return None
def get_all_adapters(self) -> dict: def get_all_adapters(self) -> dict:
"""获取所有 PlatformAdapter 实例 {platform_id: adapter}""" """Lấy mọi PlatformAdapter theo platform_id."""
return self._adapters.copy() return self._adapters.copy()
def has_adapter(self, platform_id: str | None = None) -> bool: 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: if platform_id:
return platform_id in self._adapters return platform_id in self._adapters
return bool(self._adapters) return bool(self._adapters)
def can_analyze(self, platform_id: str | None = None) -> bool: 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) adapter = self.get_adapter(platform_id)
if adapter: if adapter:
return adapter.get_capabilities().can_analyze() return adapter.get_capabilities().can_analyze()
@@ -329,16 +324,16 @@ class BotManager:
async def auto_discover_bot_instances(self): 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) platform_manager = getattr(self._context, "platform_manager", None)
get_insts = getattr(platform_manager, "get_insts", None) get_insts = getattr(platform_manager, "get_insts", None)
if self._context is None or not callable(get_insts): if self._context is None or not callable(get_insts):
return {} return {}
# 使用新版 API 获取所有平台实例 # Dùng API mới để lấy mọi platform instance.
raw_platforms = get_insts() raw_platforms = get_insts()
if isinstance(raw_platforms, list): if isinstance(raw_platforms, list):
platforms: list[object] = raw_platforms platforms: list[object] = raw_platforms
@@ -352,11 +347,11 @@ class BotManager:
discovered = {} discovered = {}
logger.info( 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: for platform in platforms:
# 获取bot实例 # Lấy bot instance.
bot_client = None bot_client = None
bot_client = getattr(platform, "lark_api", None) bot_client = getattr(platform, "lark_api", None)
platform_get_client = getattr(platform, "get_client", None) platform_get_client = getattr(platform, "get_client", None)
@@ -368,7 +363,7 @@ class BotManager:
if not bot_client: if not bot_client:
bot_client = getattr(platform, "client", None) bot_client = getattr(platform, "client", None)
# 健壮地获取元数据 # Lấy metadata an toàn.
metadata = getattr(platform, "metadata", None) metadata = getattr(platform, "metadata", None)
platform_meta_method = getattr(platform, "meta", None) platform_meta_method = getattr(platform, "meta", None)
if not metadata and callable(platform_meta_method): if not metadata and callable(platform_meta_method):
@@ -377,7 +372,7 @@ class BotManager:
except Exception: except Exception:
pass pass
# 检查是否有有效的元数据和ID # Kiểm tra metadata và ID hợp lệ.
platform_id = None platform_id = None
if metadata: if metadata:
metadata_id = getattr(metadata, "id", None) metadata_id = getattr(metadata, "id", None)
@@ -387,17 +382,17 @@ class BotManager:
platform_id = metadata.get("id") platform_id = metadata.get("id")
if platform_id: if platform_id:
# 确保平台 ID 是 str # Chuẩn hoá platform ID thành str.
platform_id = str(platform_id) platform_id = str(platform_id)
# 知识点发现: 记录元数据以调试自定义 ID # Ghi metadata để debug ID tuỳ chỉnh.
logger.info( 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 platform_name = None
# 优先使用 type # Ưu tiên type.
type_val = getattr(metadata, "type", None) type_val = getattr(metadata, "type", None)
if isinstance(type_val, str): if isinstance(type_val, str):
platform_name = type_val platform_name = type_val
@@ -414,7 +409,7 @@ class BotManager:
if isinstance(dict_name, str): if isinstance(dict_name, str):
platform_name = dict_name platform_name = dict_name
# 验证此平台名称是否受支持,如果不支持,尝试从bot实例检测(如果可用) # Nếu tên chưa được hỗ trợ, thử phát hiện từ bot instance.
if ( if (
not platform_name not platform_name
or not PlatformAdapterFactory.is_supported(str(platform_name)) or not PlatformAdapterFactory.is_supported(str(platform_name))
@@ -424,47 +419,47 @@ class BotManager:
platform_name = detected platform_name = detected
logger.debug( 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 self._platforms[platform_id] = platform
if bot_client: if bot_client:
logger.info( 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) self.set_bot_instance(bot_client, platform_id, platform_name)
discovered[platform_id] = bot_client discovered[platform_id] = bot_client
else: else:
logger.info( 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 discovered[platform_id] = platform
if self._adapters: if self._adapters:
logger.info( logger.info(
f"已创建 {len(self._adapters)} PlatformAdapter: " f"Đã tạo {len(self._adapters)} PlatformAdapter: "
f"{list(self._adapters.keys())}" f"{list(self._adapters.keys())}"
) )
return discovered return discovered
async def initialize_from_config(self): async def initialize_from_config(self):
"""从配置初始化bot管理器""" """Khởi tạo bot manager từ cấu hình."""
# 设置配置的bot ID 列表 # Thiết lập danh sách ID bot trong cấu hình.
bot_self_ids = self.config_manager.get_bot_self_ids() bot_self_ids = self.config_manager.get_bot_self_ids()
if bot_self_ids: if bot_self_ids:
self.set_bot_self_ids(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() discovered = await self.auto_discover_bot_instances()
self._is_initialized = True self._is_initialized = True
return discovered return discovered
def get_status_info(self) -> dict[str, object]: def get_status_info(self) -> dict[str, object]:
"""获取bot管理器状态信息""" """Lấy thông tin trạng thái bot manager."""
adapter_info = {} adapter_info = {}
for pid, adapter in self._adapters.items(): for pid, adapter in self._adapters.items():
caps = adapter.get_capabilities() caps = adapter.get_capabilities()
@@ -479,17 +474,17 @@ class BotManager:
"bot_self_ids": self._bot_self_ids, "bot_self_ids": self._bot_self_ids,
"platform_count": len(self._bot_instances), "platform_count": len(self._bot_instances),
"platforms": list(self._bot_instances.keys()), "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(), "ready_for_auto_analysis": self.is_ready_for_auto_analysis(),
} }
def update_from_event(self, event): def update_from_event(self, event):
"""从事件更新bot实例(用于手动命令)""" """Cập nhật bot instance từ event cho command thủ công."""
# 兼容不同平台的 bot 实例属性名 (OneBot 使用 bot, Discord 使用 client) # 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) bot_instance = getattr(event, "bot", None) or getattr(event, "client", None)
if bot_instance: if bot_instance:
# 从事件中获取平台ID # Lấy platform ID từ event.
platform_id = None platform_id = None
if hasattr(event, "get_platform_id"): if hasattr(event, "get_platform_id"):
platform_id = event.get_platform_id() platform_id = event.get_platform_id()
@@ -500,7 +495,7 @@ class BotManager:
self.set_bot_instance(bot_instance, platform_id) 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 bot_self_id = None
if hasattr(event, "get_self_id"): if hasattr(event, "get_self_id"):
val = 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) bot_self_id = self._extract_bot_self_id(bot_instance)
if bot_self_id: 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]) self.set_bot_self_ids([bot_self_id])
else: 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() config_self_ids = self.config_manager.get_bot_self_ids()
if config_self_ids: if config_self_ids:
self.set_bot_self_ids(config_self_ids) self.set_bot_self_ids(config_self_ids)
@@ -527,12 +522,12 @@ class BotManager:
return False return False
def _extract_bot_self_id(self, bot_instance): 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) return self._extract_bot_self_id_impl(bot_instance)
def _extract_bot_self_id_impl(self, bot_instance): def _extract_bot_self_id_impl(self, bot_instance):
"""从bot实例中提取ID(通用实现)""" """Trích xuất ID từ bot instance bằng triển khai chung."""
# 尝试多种方式获取bot ID,并严格限制类型为 str/int 且不可调用,防止 OneBot (aiocqhttp) 动态代理返回 functools.partial # 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: if hasattr(bot_instance, "self_id") and bot_instance.self_id:
val = bot_instance.self_id val = bot_instance.self_id
if isinstance(val, (str, int)) and not callable(val): if isinstance(val, (str, int)) and not callable(val):
@@ -554,20 +549,20 @@ class BotManager:
return None return None
def validate_for_message_fetching(self, group_id: str) -> bool: 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) return self.has_bot_instance() and bool(group_id)
def should_filter_bot_message(self, sender_id: str) -> bool: 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: if not self._bot_self_ids:
return False return False
sender_id_str = str(sender_id) 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 return sender_id_str in self._bot_self_ids
def is_plugin_enabled(self, platform_id: str, plugin_name: str) -> bool: 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: if platform_id not in self._platforms:
return True return True
+17 -20
View File
@@ -1,6 +1,4 @@
""" """Factory adapter nền tảng."""
平台适配器工厂
"""
from __future__ import annotations from __future__ import annotations
@@ -12,17 +10,16 @@ from .base import PlatformAdapter
class PlatformAdapterFactory: 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]] = {} _adapters: dict[str, type[PlatformAdapter]] = {}
@classmethod @classmethod
def register(cls, platform_name: str, adapter_class: type[PlatformAdapter]): def register(cls, platform_name: str, adapter_class: type[PlatformAdapter]):
"""注册新适配器""" """Đăng ký adapter mới."""
cls._adapters[platform_name.lower()] = adapter_class cls._adapters[platform_name.lower()] = adapter_class
@classmethod @classmethod
@@ -33,15 +30,15 @@ class PlatformAdapterFactory:
config: Mapping[str, object] | None = None, config: Mapping[str, object] | None = None,
) -> PlatformAdapter | None: ) -> PlatformAdapter | None:
""" """
创建平台适配器 Tạo adapter nền tảng.
参数: Args:
platform_name: 平台名称 "aiocqhttp""telegram" platform_name: Tên nền tảng như ``aiocqhttp`` hoặc ``telegram``.
bot_instance: AstrBot 机器人实例 bot_instance: Instance bot AstrBot.
config: 配置字典 config: Dict cấu hình.
返回: Returns:
平台适配器实例如果不支持则返回 None Instance adapter hoặc None nếu không hỗ trợ.
""" """
adapter_class = cls._adapters.get(platform_name.lower()) adapter_class = cls._adapters.get(platform_name.lower())
@@ -51,22 +48,22 @@ class PlatformAdapterFactory:
try: try:
return adapter_class(bot_instance, config) return adapter_class(bot_instance, config)
except Exception: except Exception:
# 记录异常,但不崩溃 # Ghi lỗi nhưng không làm sập plugin.
logger.error(f"{platform_name} 创建适配器时出错", exc_info=True) logger.error(f"Lỗi khi tạo adapter cho {platform_name}", exc_info=True)
return None return None
@classmethod @classmethod
def get_supported_platforms(cls) -> list[str]: 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()) return list(cls._adapters.keys())
@classmethod @classmethod
def is_supported(cls, platform_name: str) -> bool: 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 return platform_name.lower() in cls._adapters
# 导入适配器以注册它们 # Import các adapter để đăng ký.
def _register_adapters(): def _register_adapters():
try: try:
from .adapters.onebot_adapter import OneBotAdapter from .adapters.onebot_adapter import OneBotAdapter
@@ -80,7 +77,7 @@ def _register_adapters():
from .adapters.discord_adapter import DiscordAdapter from .adapters.discord_adapter import DiscordAdapter
PlatformAdapterFactory.register("discord", DiscordAdapter) PlatformAdapterFactory.register("discord", DiscordAdapter)
PlatformAdapterFactory.register("discord_bot", DiscordAdapter) # 添加别名 PlatformAdapterFactory.register("discord_bot", DiscordAdapter) # Alias
except ImportError: except ImportError:
pass pass
@@ -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 .router import TemplatePreviewRouter
from .telegram_preview_handler import TelegramTemplatePreviewHandler from .telegram_preview_handler import TelegramTemplatePreviewHandler
@@ -1,4 +1,4 @@
"""模板预览平台路由。""" """Router nền tảng cho chức năng xem trước template."""
from __future__ import annotations from __future__ import annotations
@@ -6,17 +6,17 @@ from typing import Any
class TemplatePreviewRouter: 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): def __init__(self, handlers: list[Any] | None = None):
self._handlers: list[Any] = handlers or [] self._handlers: list[Any] = handlers or []
def add_handler(self, handler: Any) -> None: def add_handler(self, handler: Any) -> None:
"""注册一个平台处理器。""" """Đăng ký một handler nền tảng."""
self._handlers.append(handler) self._handlers.append(handler)
async def ensure_handlers_registered(self, context: Any) -> None: 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: for handler in self._handlers:
register_func = getattr( register_func = getattr(
handler, "ensure_callback_handlers_registered", None handler, "ensure_callback_handlers_registered", None
@@ -25,7 +25,7 @@ class TemplatePreviewRouter:
await register_func(context) await register_func(context)
async def unregister_handlers(self) -> None: async def unregister_handlers(self) -> None:
"""统一注销处理器资源。""" """Huỷ đăng ký tài nguyên của các handler."""
for handler in self._handlers: for handler in self._handlers:
unregister_func = getattr(handler, "unregister_callback_handlers", None) unregister_func = getattr(handler, "unregister_callback_handlers", None)
if callable(unregister_func): if callable(unregister_func):
@@ -38,11 +38,11 @@ class TemplatePreviewRouter:
available_templates: list[str], available_templates: list[str],
) -> tuple[bool, list[Any]]: ) -> tuple[bool, list[Any]]:
""" """
处理 /查看模板 交互 Xử tương tác xem template.
返回: Returns:
- handled: 是否已由某个平台处理器接管 Tuple gồm trạng thái đã được handler tiếp nhận danh sách kết quả
- results: 需要回传给框架的消息结果列表 tin nhắn cần trả về framework.
""" """
for handler in self._handlers: for handler in self._handlers:
supports_func = getattr(handler, "supports", None) supports_func = getattr(handler, "supports", None)
@@ -1,4 +1,4 @@
"""Telegram 模板预览交互处理。""" """Xử lý tương tác preview template trên Telegram."""
from __future__ import annotations from __future__ import annotations
@@ -58,7 +58,7 @@ class _PreviewSession:
class TelegramTemplatePreviewHandler: class TelegramTemplatePreviewHandler:
"""Telegram 按钮预览处理器(←/确定/→)。""" """Trình xử lý nút preview Telegram (←/Xác nhận/→)."""
_SESSION_TTL_SECONDS = 2 * 60 * 60 _SESSION_TTL_SECONDS = 2 * 60 * 60
_MAX_SESSIONS = 200 _MAX_SESSIONS = 200
@@ -82,17 +82,17 @@ class TelegramTemplatePreviewHandler:
@staticmethod @staticmethod
def supports(event: AstrMessageEvent) -> bool: def supports(event: AstrMessageEvent) -> bool:
"""判断是否 Telegram 事件。""" """Kiểm tra có phải sự kiện Telegram hay không."""
try: try:
return (event.get_platform_name() or "").lower() == "telegram" return (event.get_platform_name() or "").lower() == "telegram"
except Exception: except Exception:
return False return False
# 向后兼容旧调用名 # Tương thích ngược với tên gọi cũ.
is_telegram_event = supports is_telegram_event = supports
async def ensure_callback_handlers_registered(self, context: Any) -> None: 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: if not TELEGRAM_RUNTIME_AVAILABLE:
return return
if not context or not hasattr(context, "platform_manager"): if not context or not hasattr(context, "platform_manager"):
@@ -123,15 +123,15 @@ class TelegramTemplatePreviewHandler:
self._registered_platform_ids.add(platform_id) self._registered_platform_ids.add(platform_id)
continue continue
# 平台对象热替换:解绑旧 application 上的 handler 后重绑 # Hot-swap nền tảng: gỡ handler application cũ rồi gắn lại.
try: try:
old_application.remove_handler(old_handler) old_application.remove_handler(old_handler)
logger.info( 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: except Exception as e:
logger.debug( 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._handlers.pop(platform_id, None)
self._registered_platform_ids.discard(platform_id) self._registered_platform_ids.discard(platform_id)
@@ -145,14 +145,14 @@ class TelegramTemplatePreviewHandler:
self._registered_platform_ids.add(platform_id) self._registered_platform_ids.add(platform_id)
self._handlers[platform_id] = (application, handler) self._handlers[platform_id] = (application, handler)
logger.info( logger.info(
f"[TemplatePreview][Telegram] 已注册回调处理器: platform_id={platform_id}" f"[TemplatePreview][Telegram] Đã đăng ký callback: platform_id={platform_id}"
) )
except Exception as e: except Exception as e:
logger.warning( 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 = [ stale_ids = [
platform_id platform_id
for platform_id in list(self._handlers.keys()) for platform_id in list(self._handlers.keys())
@@ -163,17 +163,17 @@ class TelegramTemplatePreviewHandler:
try: try:
old_application.remove_handler(old_handler) old_application.remove_handler(old_handler)
logger.info( 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: except Exception as e:
logger.debug( 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._registered_platform_ids.discard(stale_platform_id)
self._platform_clients.pop(stale_platform_id, None) self._platform_clients.pop(stale_platform_id, None)
async def unregister_callback_handlers(self) -> None: async def unregister_callback_handlers(self) -> None:
"""卸载已注册的回调处理器(插件终止时调用)。""" """Gỡ callback đã đăng ký khi plugin kết thúc."""
if not TELEGRAM_RUNTIME_AVAILABLE: if not TELEGRAM_RUNTIME_AVAILABLE:
return return
@@ -181,11 +181,11 @@ class TelegramTemplatePreviewHandler:
try: try:
application.remove_handler(handler) application.remove_handler(handler)
logger.info( logger.info(
f"[TemplatePreview][Telegram] 已移除回调处理器: platform_id={platform_id}" f"[TemplatePreview][Telegram] Đã gỡ callback: platform_id={platform_id}"
) )
except Exception as e: except Exception as e:
logger.debug( 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._handlers.clear()
self._registered_platform_ids.clear() self._registered_platform_ids.clear()
@@ -198,11 +198,9 @@ class TelegramTemplatePreviewHandler:
available_templates: list[str], available_templates: list[str],
) -> bool: ) -> bool:
""" """
Telegram 中发送可交互模板预览消息 Gửi tin nhắn preview template tương tác trên Telegram.
返回 Trả về True nếu handler đã gửi; False để caller dùng fallback mặc định.
- True: 已由本处理器发送消息调用方不应再走默认回复
- False: 无法处理调用方应走原有降级路径
""" """
if not TELEGRAM_RUNTIME_AVAILABLE: if not TELEGRAM_RUNTIME_AVAILABLE:
return False return False
@@ -211,7 +209,7 @@ class TelegramTemplatePreviewHandler:
client = self._get_event_client(event, platform_id) client = self._get_event_client(event, platform_id)
if client is None: if client is None:
logger.warning("[TemplatePreview][Telegram] 无法获取 Telegram client") logger.warning("[TemplatePreview][Telegram] Không thể lấy Telegram client")
return False return False
target = self._resolve_chat_target(event) target = self._resolve_chat_target(event)
@@ -223,7 +221,7 @@ class TelegramTemplatePreviewHandler:
requester_id = int(str(event.get_sender_id())) requester_id = int(str(event.get_sender_id()))
except Exception: except Exception:
logger.warning( 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 return False
@@ -289,7 +287,7 @@ class TelegramTemplatePreviewHandler:
) )
self._cleanup_expired_sessions() self._cleanup_expired_sessions()
logger.info( 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)}" f"platform_id={platform_id} chat_id={chat_id} token={token} templates={len(available_templates)}"
) )
return True return True
@@ -300,7 +298,7 @@ class TelegramTemplatePreviewHandler:
platform_id: str, platform_id: str,
template_name: str, template_name: str,
) -> bool: ) -> bool:
"""TG 回退路径:直接发送单张预览图(不经过 event.image_result)。""" """Fallback Telegram: gửi trực tiếp một ảnh preview."""
if not TELEGRAM_RUNTIME_AVAILABLE: if not TELEGRAM_RUNTIME_AVAILABLE:
return False return False
@@ -310,7 +308,9 @@ class TelegramTemplatePreviewHandler:
client = self._get_event_client(event, platform_id) client = self._get_event_client(event, platform_id)
if client is None: 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 return False
target = self._resolve_chat_target(event) target = self._resolve_chat_target(event)
@@ -320,7 +320,7 @@ class TelegramTemplatePreviewHandler:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"chat_id": chat_id, "chat_id": chat_id,
"caption": f"🖼 当前模板预览: {template_name}", "caption": f"🖼 Preview template hiện tại: {template_name}",
"connect_timeout": self._CONNECT_TIMEOUT, "connect_timeout": self._CONNECT_TIMEOUT,
"read_timeout": self._READ_TIMEOUT, "read_timeout": self._READ_TIMEOUT,
"write_timeout": self._WRITE_TIMEOUT, "write_timeout": self._WRITE_TIMEOUT,
@@ -345,7 +345,7 @@ class TelegramTemplatePreviewHandler:
platform_id: str, platform_id: str,
available_templates: list[str], available_templates: list[str],
) -> tuple[bool, list[Any]]: ) -> tuple[bool, list[Any]]:
"""统一处理 Telegram 的 /查看模板 流程。""" """Xử lý thống nhất quy trình /xemmau trên Telegram."""
if not self.supports(event): if not self.supports(event):
return False, [] return False, []
@@ -358,12 +358,12 @@ class TelegramTemplatePreviewHandler:
) )
results.append( results.append(
event.plain_result( event.plain_result(
f"""🎨 可用报告模板列表 f"""🎨 Danh sách template báo cáo khả dụng
📌 当前使用: {current_template} 📌 Đang dùng: {current_template}
{template_list_str} {template_list_str}
💡 使用 /设置模板 [序号] 切换""" 💡 Dùng /maubc [số thứ tự] để chuyển"""
) )
) )
@@ -374,10 +374,18 @@ class TelegramTemplatePreviewHandler:
template_name=current_template, template_name=current_template,
) )
if not sent_preview: 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: except Exception as image_err:
logger.warning(f"[TemplatePreview][Telegram] 回退发图失败: {image_err}") logger.warning(
results.append(event.plain_result("⚠️ 当前模板预览图发送失败")) 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: try:
sent = await self.send_preview_message( sent = await self.send_preview_message(
@@ -391,7 +399,7 @@ class TelegramTemplatePreviewHandler:
return True, results return True, results
except Exception as e: except Exception as e:
logger.warning( 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() await _append_fallback_results()
return True, results return True, results
@@ -410,34 +418,42 @@ class TelegramTemplatePreviewHandler:
data = query.data data = query.data
parts = data.split(":") parts = data.split(":")
if len(parts) != 3: 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 return
_, token, action = parts _, token, action = parts
session = self._sessions.get(token) session = self._sessions.get(token)
if not session: 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 return
if time.time() - session.created_at > self._SESSION_TTL_SECONDS: if time.time() - session.created_at > self._SESSION_TTL_SECONDS:
self._sessions.pop(token, None) 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 return
if not query.from_user: 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 return
if int(query.from_user.id) != session.requester_id: 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 return
if not query.message: 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 return
if query.message.message_id != session.message_id or str( if query.message.message_id != session.message_id or str(
query.message.chat_id query.message.chat_id
) != str(session.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 return
if action == "prev": if action == "prev":
@@ -456,14 +472,14 @@ class TelegramTemplatePreviewHandler:
template_name = session.current_template template_name = session.current_template
self.config_manager.set_report_template(template_name) self.config_manager.set_report_template(template_name)
await self._edit_preview_message(query, session, applied=True) 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( logger.info(
"[TemplatePreview][Telegram] 已应用模板: " "[TemplatePreview][Telegram] Đã áp dụng template: "
f"platform_id={session.platform_id} template={template_name} requester={session.requester_id}" f"platform_id={session.platform_id} template={template_name} requester={session.requester_id}"
) )
return 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( async def _edit_preview_message(
self, query: Any, session: _PreviewSession, applied: bool = False self, query: Any, session: _PreviewSession, applied: bool = False
@@ -526,7 +542,7 @@ class TelegramTemplatePreviewHandler:
callback_data=f"{self._callback_prefix}:{token}:prev", callback_data=f"{self._callback_prefix}:{token}:prev",
), ),
InlineKeyboardButton( InlineKeyboardButton(
text="确定", text="Xác nhận",
callback_data=f"{self._callback_prefix}:{token}:apply", callback_data=f"{self._callback_prefix}:{token}:apply",
), ),
InlineKeyboardButton( InlineKeyboardButton(
@@ -545,13 +561,15 @@ class TelegramTemplatePreviewHandler:
applied: bool = False, applied: bool = False,
) -> str: ) -> str:
current_active = self.config_manager.get_report_template() current_active = self.config_manager.get_report_template()
active_mark = "✅ 当前生效" if template_name == current_active else "未生效" active_mark = (
apply_mark = "\n\n✅ 已应用该模板" if applied else "" "✅ Đ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 ( return (
f"🎨 模板预览 ({index + 1}/{total})\n" f"🎨 Preview template ({index + 1}/{total})\n"
f"当前项: {template_name}\n" f"Mục hiện tại: {template_name}\n"
f"状态: {active_mark}\n\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}" f"{apply_mark}"
) )
+1 -4
View File
@@ -1,7 +1,4 @@
""" """Module tạo báo cáo HTML, ảnh và văn bản."""
报告生成模块
包含HTML图片文本报告生成功能
"""
from .generators import ReportGenerator from .generators import ReportGenerator
from .templates import HTMLTemplates from .templates import HTMLTemplates
+54 -46
View File
@@ -12,8 +12,7 @@ from ...utils.logger import logger
class ReportDispatcher: 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 fallback.
负责协调报告生成格式选择消息发送和失败重试
""" """
def __init__( def __init__(
@@ -28,7 +27,7 @@ class ReportDispatcher:
self._html_render_func: Callable | None = None self._html_render_func: Callable | None = None
def set_html_render(self, render_func: Callable): 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 self._html_render_func = render_func
def _is_qq_official(self, platform_id: str | None) -> bool: def _is_qq_official(self, platform_id: str | None) -> bool:
@@ -42,13 +41,13 @@ class ReportDispatcher:
platform_id: str | None = None, platform_id: str | None = None,
): ):
""" """
分发分析报告 Phân phối báo cáo phân tích.
""" """
trace_id = TraceContext.get() trace_id = TraceContext.get()
output_formats = self.config_manager.get_output_format() output_formats = self.config_manager.get_output_format()
logger.info( 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 = { dispatch_map = {
@@ -61,22 +60,24 @@ class ReportDispatcher:
if handler: if handler:
await handler(group_id, analysis_result, platform_id) 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( async def _dispatch_image(
self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None
) -> bool: ) -> bool:
trace_id = TraceContext.get() trace_id = TraceContext.get()
# 1. 检查渲染函数 # 1. Kiểm tra hàm render.
if not self._html_render_func: 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) return await self._dispatch_text(group_id, analysis_result, platform_id)
# 2. 生成图片 # 2. Tạo ảnh.
image_url = None image_url = None
html_content = None html_content = None
try: 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): async def avatar_url_getter(user_id: str):
if not platform_id: if not platform_id:
return None return None
@@ -94,10 +95,10 @@ class ReportDispatcher:
allow_alphanumeric_user_ids=self._is_qq_official(platform_id), allow_alphanumeric_user_ids=self._is_qq_official(platform_id),
) )
except Exception as e: 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 # image_url and html_content remain None
# 4. 发送图片 # 4. Gửi ảnh.
sent = False sent = False
if image_url: if image_url:
caption = ( caption = (
@@ -109,16 +110,16 @@ class ReportDispatcher:
group_id, image_url, caption, platform_id 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) await self._try_upload_image(group_id, image_url, platform_id)
if sent: if sent:
return True 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( 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) 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), allow_alphanumeric_user_ids=self._is_qq_official(platform_id),
) )
except Exception as e: 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: if html_path:
is_only_url = self.config_manager.get_html_only_url() is_only_url = self.config_manager.get_html_only_url()
@@ -154,10 +155,10 @@ class ReportDispatcher:
if is_only_url: if is_only_url:
if base_url and base_url.strip(): 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() 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: if not html_output_dir:
from astrbot.api.star import StarTools from astrbot.api.star import StarTools
@@ -166,14 +167,14 @@ class ReportDispatcher:
"self_hosted_html_reports", "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) rel_path = os.path.relpath(html_path, html_output_dir)
url_path = rel_path.replace(os.sep, "/") url_path = rel_path.replace(os.sep, "/")
report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}" report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}"
sent = await self.message_sender.send_text( sent = await self.message_sender.send_text(
group_id, 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, platform_id,
) )
@@ -181,7 +182,7 @@ class ReportDispatcher:
return True return True
else: else:
logger.warning( 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 = ( caption = (
@@ -200,15 +201,15 @@ class ReportDispatcher:
return True return True
logger.warning( 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) return await self._dispatch_text(group_id, analysis_result, platform_id)
async def _dispatch_text( async def _dispatch_text(
self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None
) -> bool: ) -> bool:
"""分发文本报告""" """Phân phối báo cáo văn bản."""
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告") 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) is_qq_official = self._is_qq_official(platform_id)
fallback_report = None fallback_report = None
if is_qq_official: if is_qq_official:
@@ -221,8 +222,10 @@ class ReportDispatcher:
else: else:
text_report = self.report_generator.generate_text_report(analysis_result) text_report = self.report_generator.generate_text_report(analysis_result)
adapter = self.message_sender.bot_manager.get_adapter(platform_id) adapter = self.message_sender.bot_manager.get_adapter(platform_id)
# 尝试通过适配器发送文本报告 # Thử gửi báo cáo văn bản qua adapter.
logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}") 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: try:
if adapter: if adapter:
if is_qq_official: if is_qq_official:
@@ -235,14 +238,18 @@ class ReportDispatcher:
elif await adapter.send_text_report(group_id, text_report): elif await adapter.send_text_report(group_id, text_report):
return True return True
return await self.message_sender.send_text( 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: 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 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( async def _try_upload_image(
@@ -252,31 +259,31 @@ class ReportDispatcher:
platform_id: str | None, platform_id: str | None,
): ):
""" """
尝试将图片报告上传到群文件和/或群相册 Thử upload báo cáo ảnh vào tệp /hoặc album nhóm.
仅在配置启用且平台为 OneBot 时执行失败静默处理 Chỉ thực hiện khi bật cấu hình nền tảng OneBot; lỗi chỉ ghi log.
""" """
enable_file = self.config_manager.get_enable_group_file_upload() enable_file = self.config_manager.get_enable_group_file_upload()
enable_album = self.config_manager.get_enable_group_album_upload() enable_album = self.config_manager.get_enable_group_album_upload()
if not enable_file and not enable_album: if not enable_file and not enable_album:
return return
# OneBot 平台支持 # Chỉ OneBot hỗ trợ.
adapter = self._get_onebot_adapter(platform_id) adapter = self._get_onebot_adapter(platform_id)
if not adapter: if not adapter:
return return
# 将图片保存为临时文件 # Lưu ảnh thành tệp tạm.
image_file = self._save_image_to_temp(image_url, group_id) image_file = self._save_image_to_temp(image_url, group_id)
if not image_file: if not image_file:
return return
try: try:
# 上传到群文件 # Upload vào tệp nhóm.
if enable_file: if enable_file:
await self._do_upload_group_file(adapter, group_id, image_file) await self._do_upload_group_file(adapter, group_id, image_file)
# 上传到群相册 # Upload vào album nhóm.
if enable_album: if enable_album:
await self._do_upload_group_album(adapter, group_id, image_file) await self._do_upload_group_album(adapter, group_id, image_file)
finally: finally:
@@ -286,7 +293,7 @@ class ReportDispatcher:
pass pass
async def _do_upload_group_file(self, adapter, group_id: str, file_path: str): 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: try:
folder_name = self.config_manager.get_group_file_folder() folder_name = self.config_manager.get_group_file_folder()
folder_id = None folder_id = None
@@ -298,10 +305,10 @@ class ReportDispatcher:
folder_id=folder_id, folder_id=folder_id,
) )
except Exception as e: 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): 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: try:
album_name = self.config_manager.get_group_album_name() album_name = self.config_manager.get_group_album_name()
strict_mode = self.config_manager.get_group_album_strict_mode() 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) album_id = await adapter.find_album_id(group_id, album_name)
if not album_id and strict_mode: if not album_id and strict_mode:
logger.info( 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 return
elif strict_mode: elif strict_mode:
logger.info( 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 return
@@ -329,10 +336,10 @@ class ReportDispatcher:
strict_mode=strict_mode, strict_mode=strict_mode,
) )
except Exception as e: 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: 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: try:
image_data = None image_data = None
if image_url.startswith("base64://"): if image_url.startswith("base64://"):
@@ -353,17 +360,18 @@ class ReportDispatcher:
date_str = datetime.now().strftime("%Y%m%d_%H%M%S") date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join( 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: with open(path, "wb") as f:
f.write(image_data) f.write(image_data)
return path return path
except Exception as e: 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 return None
def _get_onebot_adapter(self, platform_id: str | 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: if not platform_id:
return None return None
adapter = self.message_sender.bot_manager.get_adapter(platform_id) adapter = self.message_sender.bot_manager.get_adapter(platform_id)
+187 -170
View File
@@ -1,7 +1,4 @@
""" """Tạo báo cáo phân tích ở nhiều định dạng."""
报告生成器模块
负责生成各种格式的分析报告
"""
import asyncio import asyncio
import base64 import base64
@@ -147,16 +144,15 @@ DEFAULT_PROFILE_NAME_TRANSLATIONS = {
class ReportGenerator(IReportGenerator): class ReportGenerator(IReportGenerator):
"""报告生成器""" """Trình tạo báo cáo phân tích."""
def __init__(self, config_manager, data_dir): def __init__(self, config_manager, data_dir):
self._avatar_session = None self._avatar_session = None
self.config_manager = config_manager self.config_manager = config_manager
self.data_dir = data_dir self.data_dir = data_dir
self.activity_visualizer = ActivityVisualizer() self.activity_visualizer = ActivityVisualizer()
self.html_templates = HTMLTemplates(config_manager) # 实例化HTML模板管理器 self.html_templates = HTMLTemplates(config_manager)
# 全局 T2I 渲染信号量,保护本地资源 # Semaphore render T2I toàn cục bảo vệ tài nguyên local.
# 使用专用的 T2I 并发配置项
max_concurrent = self.config_manager.get_t2i_max_concurrent() max_concurrent = self.config_manager.get_t2i_max_concurrent()
self._render_semaphore = asyncio.Semaphore(max_concurrent) self._render_semaphore = asyncio.Semaphore(max_concurrent)
self._qq_official_markdown_generator = QQOfficialMarkdownReportGenerator( self._qq_official_markdown_generator = QQOfficialMarkdownReportGenerator(
@@ -165,7 +161,7 @@ class ReportGenerator(IReportGenerator):
self._render_semaphore, 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( self._avatar_cache = Cache(
str(self.data_dir / "avatar") str(self.data_dir / "avatar")
) # user_id -> base64_uri ) # user_id -> base64_uri
@@ -176,7 +172,7 @@ class ReportGenerator(IReportGenerator):
self._profile_asset_manifest = self._load_profile_asset_manifest() self._profile_asset_manifest = self._load_profile_asset_manifest()
def _load_profile_asset_manifest(self) -> dict[str, dict]: def _load_profile_asset_manifest(self) -> dict[str, dict]:
"""加载人格资源清单。""" """Tải manifest tài nguyên hồ sơ."""
manifest_path = ( manifest_path = (
Path(__file__).resolve().parents[3] Path(__file__).resolve().parents[3]
/ "assets" / "assets"
@@ -184,13 +180,13 @@ class ReportGenerator(IReportGenerator):
/ "manifest.json" / "manifest.json"
) )
if not manifest_path.exists(): 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": {}} return {"sbti": {}, "acgti": {}}
try: try:
raw = json.loads(manifest_path.read_text(encoding="utf-8-sig")) raw = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
except Exception as e: 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": {}} return {"sbti": {}, "acgti": {}}
manifest: dict[str, dict] = {"sbti": {}, "acgti": {}} manifest: dict[str, dict] = {"sbti": {}, "acgti": {}}
@@ -205,7 +201,7 @@ class ReportGenerator(IReportGenerator):
return manifest return manifest
def _get_profile_mapping_overrides(self) -> dict[str, dict]: 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() raw = self.config_manager.get_profile_mapping_config()
if not raw: if not raw:
return {} return {}
@@ -227,13 +223,15 @@ class ReportGenerator(IReportGenerator):
profile["name_zh"] = translated_name profile["name_zh"] = translated_name
return data return data
except Exception as e: 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 {} return {}
def _build_profile_image_from_manifest_pattern( def _build_profile_image_from_manifest_pattern(
self, profile_mode: str, asset_code: str self, profile_mode: str, asset_code: str
) -> 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, {}) system_manifest = self._profile_asset_manifest.get(profile_mode, {})
for item in system_manifest.values(): for item in system_manifest.values():
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -251,7 +249,7 @@ class ReportGenerator(IReportGenerator):
def _get_manifest_profile_item_by_mbti( def _get_manifest_profile_item_by_mbti(
self, profile_mode: str, mbti: str self, profile_mode: str, mbti: str
) -> dict | None: ) -> dict | None:
"""按 MBTI 从 manifest 中寻找可用资源。""" """Tìm tài nguyên khả dụng trong manifest theo MBTI."""
normalized_mbti = str(mbti or "").strip().upper() normalized_mbti = str(mbti or "").strip().upper()
system_manifest = self._profile_asset_manifest.get(profile_mode, {}) system_manifest = self._profile_asset_manifest.get(profile_mode, {})
for item in system_manifest.values(): for item in system_manifest.values():
@@ -268,14 +266,14 @@ class ReportGenerator(IReportGenerator):
profile_mode: str, profile_mode: str,
overrides: dict[str, dict], overrides: dict[str, dict],
) -> dict[str, str | float]: ) -> 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() 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, {}) profile_defaults = DEFAULT_PROFILE_MAPPING.get(profile_mode, {})
base_info = dict(profile_defaults.get(normalized_mbti, {})) 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, {}) user_override = overrides.get(profile_mode, {}).get(normalized_mbti, {})
if isinstance(user_override, dict): if isinstance(user_override, dict):
base_info.update(user_override) base_info.update(user_override)
@@ -285,23 +283,23 @@ class ReportGenerator(IReportGenerator):
asset_code = str(base_info.get("asset_code", code)).strip() or code asset_code = str(base_info.get("asset_code", code)).strip() or code
image = str(base_info.get("image", "")).strip() 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: if not image:
system_manifest = self._profile_asset_manifest.get(profile_mode, {}) 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) asset_item = system_manifest.get(asset_code)
if isinstance(asset_item, dict): if isinstance(asset_item, dict):
image = str(asset_item.get("file", "")).strip() image = str(asset_item.get("file", "")).strip()
if not name_zh: if not name_zh:
name_zh = str(asset_item.get("name", "")).strip() 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: if not image:
image = self._build_profile_image_from_manifest_pattern( image = self._build_profile_image_from_manifest_pattern(
profile_mode, asset_code 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": if not image and profile_mode == "acgti":
fallback_item = self._get_manifest_profile_item_by_mbti( fallback_item = self._get_manifest_profile_item_by_mbti(
profile_mode, normalized_mbti profile_mode, normalized_mbti
@@ -313,7 +311,7 @@ class ReportGenerator(IReportGenerator):
if not code or code == normalized_mbti: if not code or code == normalized_mbti:
code = str(fallback_item.get("code", code)).strip() 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() display = str(base_info.get("display", "")).strip()
if not display: if not display:
display = f"{code}{name_zh}" if name_zh else code display = f"{code}{name_zh}" if name_zh else code
@@ -330,22 +328,22 @@ class ReportGenerator(IReportGenerator):
@staticmethod @staticmethod
def _sanitize_path_component(name: str) -> str: 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 {".", ".."}: 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("/", "_")
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) name = re.sub(r'[\x00-\x1f<>:"|?*]', "_", name)
# 保留中文、字母、数字、下划线、横线和点 # Giữ nội dung hợp lệ sau khi làm sạch.
name = name.strip() name = name.strip()
if not name: if not name:
raise ValueError("路径片段经过消毒后为空") raise ValueError("Thành phần path rỗng sau khi làm sạch")
return name return name
@@ -356,7 +354,7 @@ class ReportGenerator(IReportGenerator):
group_id: str, group_id: str,
date: str, date: str,
) -> Path: ) -> Path:
"""根据格式构建安全输出路径,支持子目录和 {ulid}""" """Dựng path output an toàn theo format, hỗ trợ thư mục con và ulid."""
generated_ulid = str(ulid.new()) generated_ulid = str(ulid.new())
safe_context = { safe_context = {
"group_id": group_id, "group_id": group_id,
@@ -367,16 +365,16 @@ class ReportGenerator(IReportGenerator):
try: try:
formatted = render_template(filename_format, strict=True, **safe_context) formatted = render_template(filename_format, strict=True, **safe_context)
except Exception as e: 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): 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) relative_path = Path(formatted)
sanitized_parts = [] sanitized_parts = []
for part in relative_path.parts: for part in relative_path.parts:
if part in {".", ".."}: if part in {".", ".."}:
raise ValueError("路径中不得包含 '.' '..'") raise ValueError("Path không được chứa '.' hoặc '..'")
sanitized_parts.append(self._sanitize_path_component(part)) sanitized_parts.append(self._sanitize_path_component(part))
safe_relative = Path(*sanitized_parts) safe_relative = Path(*sanitized_parts)
@@ -384,13 +382,15 @@ class ReportGenerator(IReportGenerator):
output_dir_resolved = output_dir.resolve(strict=False) output_dir_resolved = output_dir.resolve(strict=False)
target_path = (output_dir_resolved / safe_relative).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: try:
target_path.relative_to(output_dir_resolved) target_path.relative_to(output_dir_resolved)
except ValueError: 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(): if target_path.exists():
suffix = target_path.suffix suffix = target_path.suffix
stem = target_path.stem stem = target_path.stem
@@ -408,25 +408,25 @@ class ReportGenerator(IReportGenerator):
nickname_getter=None, nickname_getter=None,
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
hide_user_names: bool = False, 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, allow_alphanumeric_user_ids: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
""" """
生成图片格式的分析报告 Tạo báo cáo phân tích dạng ảnh.
Args: Args:
analysis_result: 分析结果字典 analysis_result: Dict kết quả phân tích.
group_id: 群组ID group_id: ID nhóm.
html_render_func: HTML渲染函数 html_render_func: Hàm render HTML.
avatar_url_getter: 异步回调函数接收 user_id 返回 avatar_url/data avatar_url_getter: Callback async lấy avatar theo user_id.
nickname_getter: 昵称获取函数 nickname_getter: Hàm lấy nickname.
Returns: Returns:
tuple[str | None, str | None]: (image_url, html_content) tuple[str | None, str | None]: (image_url, html_content)
""" """
html_content = None html_content = None
try: try:
# 准备渲染数据 # Chuẩn bị dữ liệu render.
render_payload = await self._prepare_render_data( render_payload = await self._prepare_render_data(
analysis_result, analysis_result,
chart_template="activity_chart.html", chart_template="activity_chart.html",
@@ -437,7 +437,7 @@ class ReportGenerator(IReportGenerator):
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
) )
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签) # Render template HTML bằng Jinja2.
html_content = self.html_templates.render_template( html_content = self.html_templates.render_template(
"image_template.html", **render_payload "image_template.html", **render_payload
) )
@@ -447,19 +447,21 @@ class ReportGenerator(IReportGenerator):
render_payload.get("avatar_reuse_aliases", {}), render_payload.get("avatar_reuse_aliases", {}),
) )
# 检查HTML内容是否有效 # Kiểm tra nội dung HTML hợp lệ.
if not html_content: 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 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() render_strategies = self.config_manager.get_t2i_rendering_strategies()
# 使用信号量控制并发进入渲染引擎 # Dùng semaphore kiểm soát concurrency render.
async with self._render_semaphore: 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 last_exception = None
@@ -469,18 +471,20 @@ class ReportGenerator(IReportGenerator):
if image_options.get("type") == "png": if image_options.get("type") == "png":
image_options.pop("quality", None) 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( image_data = await html_render_func(
html_content, # 渲染后的HTML内容 html_content,
{}, # 空数据字典,因为数据已包含在HTML中 {},
False, # return_url=False,直接获取图片数据 False,
image_options, image_options,
) )
if image_data: if image_data:
# 校验是否为合法图片(防止 T2I 返回 500 错误 HTML 字符流) # Xác thực ảnh để tránh T2I trả trang lỗi HTML.
is_valid = False is_valid = False
actual_data_head = None actual_data_head = None
@@ -493,16 +497,16 @@ class ReportGenerator(IReportGenerator):
with open(image_data, "rb") as f: with open(image_data, "rb") as f:
actual_data_head = f.read(10) actual_data_head = f.read(10)
except Exception as e: 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: 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( if actual_data_head.startswith(
b"\xff\xd8" b"\xff\xd8"
) or actual_data_head.startswith(b"\x89PNG"): ) or actual_data_head.startswith(b"\x89PNG"):
is_valid = True is_valid = True
else: else:
# 尝试解析 HTML 错误(如 502 Bad Gateway # Thử parse lỗi HTML như 502 Bad Gateway.
html_error = None html_error = None
if isinstance(image_data, bytes): if isinstance(image_data, bytes):
html_error = self._extract_html_error_summary( html_error = self._extract_html_error_summary(
@@ -513,7 +517,7 @@ class ReportGenerator(IReportGenerator):
): ):
try: try:
with open(image_data, "rb") as f: with open(image_data, "rb") as f:
# 读取前 4KB 即可识别 HTML 错误 # 4 KB đầu đủ để nhận diện lỗi HTML.
html_error = ( html_error = (
self._extract_html_error_summary( self._extract_html_error_summary(
f.read(4096) f.read(4096)
@@ -524,11 +528,11 @@ class ReportGenerator(IReportGenerator):
if html_error: if html_error:
logger.warning( logger.warning(
f"[T2I] 渲染引擎返回了错误页面而非图片: {html_error}" f"[T2I] Engine render trả trang lỗi thay vì ảnh: {html_error}"
) )
else: else:
logger.warning( 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: if is_valid:
@@ -536,35 +540,35 @@ class ReportGenerator(IReportGenerator):
b64 = base64.b64encode(image_data).decode("utf-8") b64 = base64.b64encode(image_data).decode("utf-8")
image_url = f"base64://{b64}" image_url = f"base64://{b64}"
logger.info( 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 return image_url, html_content
elif isinstance(image_data, str): elif isinstance(image_data, str):
logger.info( logger.info(
f"图片生成成功 (轮次 {attempt}): {image_data}" f"Tạo ảnh thành công (lượt {attempt}): {image_data}"
) )
return image_data, html_content return image_data, html_content
logger.warning( 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: except Exception as e:
logger.warning(f"渲染轮次 {attempt} 失败: {e}") logger.warning(f"Lượt render {attempt} thất bại: {e}")
last_exception = e last_exception = e
if attempt < len(render_strategies): if attempt < len(render_strategies):
logger.info("准备尝试下一轮回退策略") logger.info("Chuẩn bị thử chiến lược fallback tiếp theo")
continue continue
# 如果所有策略都失败 # Mọi chiến lược đều thất bại.
logger.error(f"所有渲染尝试都失败。最后一个错误: {last_exception}") logger.error(f"Mọi lần render đều thất bại. Lỗi cuối: {last_exception}")
return None, html_content return None, html_content
except Exception as e: 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 return None, html_content
finally: finally:
# 清理本次运行的 session 和缓存 # Dọn session và cache của lần chạy này.
if self._avatar_session: if self._avatar_session:
await self._avatar_session.close() await self._avatar_session.close()
self._avatar_session = None self._avatar_session = None
@@ -580,25 +584,25 @@ class ReportGenerator(IReportGenerator):
allow_alphanumeric_user_ids: bool = False, allow_alphanumeric_user_ids: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
""" """
生成HTML格式的分析报告保存到指定目录 Tạo báo cáo HTML lưu vào thư mục chỉ định.
Args: Args:
analysis_result: 分析结果字典 analysis_result: Dict kết quả phân tích.
group_id: 群组ID group_id: ID nhóm.
avatar_url_getter: 异步回调函数接收 user_id 返回 avatar_url/data avatar_url_getter: Callback async lấy avatar theo user_id.
nickname_getter: 昵称获取函数 nickname_getter: Hàm lấy nickname.
Returns: Returns:
tuple[str | None, str | None]: (html_path, json_path) - HTML文件路径和JSON文件路径 Tuple path tệp HTML JSON.
""" """
try: try:
import json 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()) output_dir = Path(self.config_manager.get_html_output_dir())
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) 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") current_date = datetime.now().strftime("%Y%m%d")
base_html_path = self._build_safe_report_path( base_html_path = self._build_safe_report_path(
output_dir, output_dir,
@@ -615,7 +619,7 @@ class ReportGenerator(IReportGenerator):
html_path.parent.mkdir(parents=True, exist_ok=True) html_path.parent.mkdir(parents=True, exist_ok=True)
# 准备渲染数据 # Chuẩn bị dữ liệu render.
render_data = await self._prepare_render_data( render_data = await self._prepare_render_data(
analysis_result, analysis_result,
chart_template="activity_chart.html", chart_template="activity_chart.html",
@@ -625,9 +629,11 @@ class ReportGenerator(IReportGenerator):
hide_user_names=hide_user_names, hide_user_names=hide_user_names,
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, 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 html_content = None
try: try:
html_content = self.html_templates.render_template( 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_registry", {}),
render_data.get("avatar_reuse_aliases", {}), 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: except Exception as e:
logger.warning( 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( html_content = self.html_templates.render_template(
"image_template.html", **render_data "image_template.html", **render_data
@@ -651,20 +657,22 @@ class ReportGenerator(IReportGenerator):
render_data.get("avatar_reuse_registry", {}), render_data.get("avatar_reuse_registry", {}),
render_data.get("avatar_reuse_aliases", {}), 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: 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 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( await asyncio.to_thread(
html_path.write_text, html_content, encoding="utf-8" 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): def json_default_encoder(obj):
if hasattr(obj, "to_dict") and callable(obj.to_dict): 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" f"Object of type {type(obj).__name__} is not JSON serializable"
) )
# 保存原始 JSON 数据 # Lưu dữ liệu JSON gốc.
json_data = { json_data = {
"analysis_result": ( "analysis_result": (
self._sanitize_analysis_result_for_export(analysis_result) self._sanitize_analysis_result_for_export(analysis_result)
@@ -701,23 +709,23 @@ class ReportGenerator(IReportGenerator):
), ),
encoding="utf-8", 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()) return str(html_path.absolute()), str(json_path.absolute())
except Exception as e: 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 return None, None
def build_html_caption(self, html_path: str) -> str: 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" 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() base_url = self.config_manager.get_html_base_url()
if not base_url or not html_path: if not base_url or not html_path:
return caption 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( output_dir = Path(self.config_manager.get_html_output_dir()).resolve(
strict=False strict=False
) )
@@ -733,7 +741,7 @@ class ReportGenerator(IReportGenerator):
return caption + f"\n{base_url.rstrip('/')}/{encoded_relative_url}" return caption + f"\n{base_url.rstrip('/')}/{encoded_relative_url}"
def generate_text_report(self, analysis_result: dict) -> str: 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"] stats = analysis_result["statistics"]
topics = analysis_result["topics"] topics = analysis_result["topics"]
user_titles = analysis_result["user_titles"] user_titles = analysis_result["user_titles"]
@@ -865,13 +873,13 @@ class ReportGenerator(IReportGenerator):
hide_user_names: bool = False, hide_user_names: bool = False,
allow_alphanumeric_user_ids: bool = False, allow_alphanumeric_user_ids: bool = False,
) -> dict: ) -> dict:
"""准备渲染数据""" """Chuẩn bị dữ liệu render."""
stats = analysis_result["statistics"] stats = analysis_result["statistics"]
topics = analysis_result["topics"] topics = analysis_result["topics"]
user_titles = analysis_result["user_titles"] user_titles = analysis_result["user_titles"]
activity_viz = stats.activity_visualization 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() max_topics = self.config_manager.get_max_topics()
topics_list = [] topics_list = []
user_analysis = analysis_result.get("user_analysis") user_analysis = analysis_result.get("user_analysis")
@@ -879,7 +887,7 @@ class ReportGenerator(IReportGenerator):
avatar_reuse_aliases: dict[str, str] = {} avatar_reuse_aliases: dict[str, str] = {}
for i, topic in enumerate(topics[:max_topics], 1): 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( processed_detail = await self._render_mentions(
topic.detail, topic.detail,
avatar_url_getter, avatar_url_getter,
@@ -900,7 +908,7 @@ class ReportGenerator(IReportGenerator):
avatar_reuse_aliases, avatar_reuse_aliases,
) )
else: else:
contributors = "".join(topic.contributors) contributors = ", ".join(topic.contributors)
topics_list.append( topics_list.append(
{ {
"index": i, "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 = { common_context = {
"hide_user_names": hide_user_names, "hide_user_names": hide_user_names,
"t2i_font_source": self.config_manager.get_t2i_font_source(), "t2i_font_source": self.config_manager.get_t2i_font_source(),
@@ -926,16 +934,16 @@ class ReportGenerator(IReportGenerator):
topics_html = self.html_templates.render_template( topics_html = self.html_templates.render_template(
"topic_item.html", topics=topics_list, **common_context "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() max_user_titles = self.config_manager.get_max_user_titles()
titles_list = [] titles_list = []
profile_mode = self.config_manager.get_profile_display_mode() profile_mode = self.config_manager.get_profile_display_mode()
profile_mapping_overrides = self._get_profile_mapping_overrides() profile_mapping_overrides = self._get_profile_mapping_overrides()
for title in user_titles[:max_user_titles]: for title in user_titles[:max_user_titles]:
user_id = str(title.user_id) user_id = str(title.user_id)
# 获取用户头像 # Lấy avatar người dùng.
avatar_data = await self._get_user_avatar( avatar_data = await self._get_user_avatar(
user_id, avatar_url_getter, avatar_cache_namespace user_id, avatar_url_getter, avatar_cache_namespace
) )
@@ -974,9 +982,9 @@ class ReportGenerator(IReportGenerator):
titles_html = self.html_templates.render_template( titles_html = self.html_templates.render_template(
"user_title_item.html", titles=titles_list, **common_context "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() max_golden_quotes = self.config_manager.get_max_golden_quotes()
quotes_list = [] quotes_list = []
for golden_quote in stats.golden_quotes[:max_golden_quotes]: for golden_quote in stats.golden_quotes[:max_golden_quotes]:
@@ -999,7 +1007,7 @@ class ReportGenerator(IReportGenerator):
quote_user_id, avatar_cache_namespace 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( processed_reason = await self._render_mentions(
golden_quote.reason, golden_quote.reason,
avatar_url_getter, avatar_url_getter,
@@ -1025,25 +1033,27 @@ class ReportGenerator(IReportGenerator):
quotes_html = self.html_templates.render_template( quotes_html = self.html_templates.render_template(
"quote_item.html", quotes=quotes_list, **common_context "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( chart_data = self.activity_visualizer.get_hourly_chart_data(
activity_viz.hourly_activity activity_viz.hourly_activity
) )
hourly_chart_html = self.html_templates.render_template( hourly_chart_html = self.html_templates.render_template(
chart_template, chart_data=chart_data, **common_context 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_html = ""
chat_quality_review = analysis_result.get("chat_quality_review") chat_quality_review = analysis_result.get("chat_quality_review")
if not chat_quality_review and hasattr(stats, "chat_quality_review"): if not chat_quality_review and hasattr(stats, "chat_quality_review"):
chat_quality_review = stats.chat_quality_review chat_quality_review = stats.chat_quality_review
if chat_quality_review: if chat_quality_review:
# 如果是对象,转为字典(为了统一渲染) # Chuyển object thành dict để render thống nhất.
if hasattr(chat_quality_review, "dimensions"): if hasattr(chat_quality_review, "dimensions"):
review_data = { review_data = {
"title": chat_quality_review.title, "title": chat_quality_review.title,
@@ -1092,15 +1102,17 @@ class ReportGenerator(IReportGenerator):
chat_quality_html = self.html_templates.render_template( chat_quality_html = self.html_templates.render_template(
"chat_quality_item.html", **review_data, **common_context "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 = { render_data = {
"t2i_font_source": self.config_manager.get_t2i_font_source(), "t2i_font_source": self.config_manager.get_t2i_font_source(),
"t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(), "t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(),
"t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(), "t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(),
"t2i_atri_font_mirror": self.config_manager.get_t2i_atri_font_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"), "current_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"message_count": stats.message_count, "message_count": stats.message_count,
"participant_count": stats.participant_count, "participant_count": stats.participant_count,
@@ -1125,7 +1137,7 @@ class ReportGenerator(IReportGenerator):
"avatar_reuse_aliases": avatar_reuse_aliases, "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 return render_data
async def _render_avatar_only_ids( async def _render_avatar_only_ids(
@@ -1185,7 +1197,7 @@ class ReportGenerator(IReportGenerator):
allow_alphanumeric_user_ids: bool = False, allow_alphanumeric_user_ids: bool = False,
) -> Markup: ) -> Markup:
""" """
处理文本 [用户ID] 格式的引用替换为头像胶囊 Thay tham chiếu dạng ``[user ID]`` trong văn bản bằng capsule avatar.
""" """
if not text: if not text:
return Markup("") return Markup("")
@@ -1198,7 +1210,7 @@ class ReportGenerator(IReportGenerator):
source_text = str(text) source_text = str(text)
supports_extended_ids = hide_user_names or allow_alphanumeric_user_ids supports_extended_ids = hide_user_names or allow_alphanumeric_user_ids
if supports_extended_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): for user_id in sorted(known_ids, key=len, reverse=True):
source_text = re.sub( source_text = re.sub(
rf"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}" rf"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}"
@@ -1221,26 +1233,26 @@ class ReportGenerator(IReportGenerator):
return Markup(html.escape(f"[{uid}]", quote=True)) return Markup(html.escape(f"[{uid}]", quote=True))
url = await self._get_user_avatar( url = await self._get_user_avatar(
uid, avatar_url_getter, avatar_cache_namespace uid, avatar_url_getter, avatar_cache_namespace
) # 内部已有缓存,无需顶层并发获取 ) # Đã có cache nội bộ, không cần concurrency cấp trên.
name = None name = None
# 1. 尝试从 LLM 分析结果获取 # 1. Thử lấy từ kết quả phân tích LLM.
if user_analysis and uid in user_analysis: if user_analysis and uid in user_analysis:
stats = user_analysis[uid] stats = user_analysis[uid]
name = stats.get("nickname") or stats.get("name") name = stats.get("nickname") or stats.get("name")
if self._is_placeholder_display_name(name, uid): if self._is_placeholder_display_name(name, uid):
name = None name = None
# 2. 尝试通过回调获取实时昵称 # 2. Thử lấy nickname thời gian thực qua callback.
if not name and nickname_getter: if not name and nickname_getter:
try: try:
name = await nickname_getter(uid) name = await nickname_getter(uid)
if self._is_placeholder_display_name(name, uid): if self._is_placeholder_display_name(name, uid):
name = None name = None
except Exception as e: except Exception as e:
logger.warning(f"获取昵称失败 {uid}: {e}") logger.warning(f"Lấy nickname thất bại {uid}: {e}")
# 胶囊样式 (Capsule Style) - 统一使用 # Dùng thống nhất kiểu capsule.
capsule_style = ( capsule_style = (
"display:inline-flex;align-items:center;background:rgba(0,0,0,0.05);" "display:inline-flex;align-items:center;background:rgba(0,0,0,0.05);"
"padding:2px 6px 2px 2px;border-radius:12px;margin:0 2px;" "padding:2px 6px 2px 2px;border-radius:12px;margin:0 2px;"
@@ -1252,12 +1264,12 @@ class ReportGenerator(IReportGenerator):
) )
name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;" name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;"
# 3. 最终后备: 确保有头像和名称 # 3. Fallback cuối: đảm bảo có avatar và tên.
final_url = url if url else self._get_default_avatar_base64() final_url = url if url else self._get_default_avatar_base64()
final_name = ( final_name = (
name name
if (name and not self._is_placeholder_display_name(name, uid)) if (name and not self._is_placeholder_display_name(name, uid))
else ("群友" if allow_alphanumeric_user_ids else str(uid)) else ("Thành viên nhóm" if allow_alphanumeric_user_ids else str(uid))
) )
avatar_ref = self._register_reusable_avatar( avatar_ref = self._register_reusable_avatar(
@@ -1330,7 +1342,7 @@ class ReportGenerator(IReportGenerator):
@staticmethod @staticmethod
def _is_placeholder_display_name(name: str | None, user_id: str) -> bool: def _is_placeholder_display_name(name: str | None, user_id: str) -> bool:
"""判断展示名称是否为占位值。""" """Kiểm tra tên hiển thị có phải placeholder hay không."""
if not name: if not name:
return True return True
normalized = str(name).strip() normalized = str(name).strip()
@@ -1342,7 +1354,7 @@ class ReportGenerator(IReportGenerator):
@staticmethod @staticmethod
def _safe_url_for_log(url: str | None) -> str: def _safe_url_for_log(url: str | None) -> str:
"""对日志中的 URL 进行脱敏,避免泄露 token。""" """Che token trong URL ghi log."""
if not url: if not url:
return "" return ""
# Telegram file URL: .../file/bot<token>/<file_path> # Telegram file URL: .../file/bot<token>/<file_path>
@@ -1350,7 +1362,7 @@ class ReportGenerator(IReportGenerator):
@staticmethod @staticmethod
def _build_avatar_ref(avatar_key: str | None, avatar_url: str) -> str: 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: if avatar_key:
digest = hashlib.sha256(avatar_key.encode("utf-8")).hexdigest()[:24] digest = hashlib.sha256(avatar_key.encode("utf-8")).hexdigest()[:24]
return f"avatar-{digest}" return f"avatar-{digest}"
@@ -1365,7 +1377,7 @@ class ReportGenerator(IReportGenerator):
avatar_reuse_aliases: dict[str, str] | None = None, avatar_reuse_aliases: dict[str, str] | None = None,
avatar_key: str | None = None, avatar_key: str | None = None,
) -> str | 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: if not avatar_url or avatar_reuse_registry is None:
return None return None
if not avatar_url.startswith("data:image/"): if not avatar_url.startswith("data:image/"):
@@ -1382,7 +1394,7 @@ class ReportGenerator(IReportGenerator):
@staticmethod @staticmethod
def _build_avatar_reuse_styles(avatar_reuse_registry: dict[str, str]) -> str: 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: if not avatar_reuse_registry:
return "" return ""
@@ -1406,7 +1418,7 @@ class ReportGenerator(IReportGenerator):
avatar_reuse_registry: dict[str, str], avatar_reuse_registry: dict[str, str],
avatar_reuse_aliases: dict[str, str] | None = None, avatar_reuse_aliases: dict[str, str] | None = None,
) -> str: ) -> 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: if not html_content:
return html_content return html_content
@@ -1440,7 +1452,7 @@ class ReportGenerator(IReportGenerator):
avatar_reuse_registry: dict[str, str] | None, avatar_reuse_registry: dict[str, str] | None,
avatar_reuse_aliases: dict[str, str] | None = None, avatar_reuse_aliases: dict[str, str] | None = None,
) -> str: ) -> str:
"""复用最终 HTML 中所有内联头像资源,并注入复用样式。""" """Tái sử dụng avatar inline trong HTML cuối và inject style."""
if not html_content: if not html_content:
return html_content return html_content
@@ -1455,7 +1467,7 @@ class ReportGenerator(IReportGenerator):
@staticmethod @staticmethod
def _inject_avatar_reuse_styles(html_content: str, avatar_reuse_styles: str) -> str: 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: if not html_content or not avatar_reuse_styles:
return html_content return html_content
@@ -1472,7 +1484,7 @@ class ReportGenerator(IReportGenerator):
def _get_avatar_cache_key( def _get_avatar_cache_key(
self, avatar_id: str, avatar_cache_namespace: str | None = None self, avatar_id: str, avatar_cache_namespace: str | None = None
) -> str: ) -> 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" namespace = str(avatar_cache_namespace or "legacy").strip() or "legacy"
return f"{namespace}:{avatar_id}" return f"{namespace}:{avatar_id}"
@@ -1483,39 +1495,42 @@ class ReportGenerator(IReportGenerator):
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
) -> str: ) -> 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 để thể retry.
""" """
cache_key = self._get_avatar_cache_key(avatar_id, avatar_cache_namespace) 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: if cache_key in self._avatar_cache:
data = self._avatar_cache[cache_key] data = self._avatar_cache[cache_key]
if isinstance(data, str): if isinstance(data, str):
return data return data
return str(data) return str(data)
# 2. 尝试获取头像字节流 # 2. Thử lấy bytes avatar.
avatar_bytes = await self._get_user_avatar_bytes(avatar_id, avatar_url_getter) avatar_bytes = await self._get_user_avatar_bytes(avatar_id, avatar_url_getter)
if not avatar_bytes: if not avatar_bytes:
# 获取失败时返回默认头像,但不存入缓存,以便下次重试 # Trả avatar mặc định nhưng không cache để lần sau có thể retry.
logger.warning(f"获取用户头像失败 {avatar_id},本次将使用回退头像") 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() 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) avatar = self._b64_with_mime(avatar_bytes)
if avatar: if avatar:
self._avatar_cache.set(cache_key, avatar, expire=AVATAR_CACHE_EXPIRE_TIME) self._avatar_cache.set(cache_key, avatar, expire=AVATAR_CACHE_EXPIRE_TIME)
return avatar return avatar
# 最终兜底 # Fallback cuối.
return self._get_default_avatar_base64() return self._get_default_avatar_base64()
def _b64_with_mime(self, _bytes: bytes) -> str | None: 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: try:
b64 = base64.b64encode(_bytes).decode("utf-8") b64 = base64.b64encode(_bytes).decode("utf-8")
# 简单判断 mime type # Nhận diện MIME type đơn giản.
mime = "image/jpeg" mime = "image/jpeg"
if _bytes.startswith(b"\x89PNG"): if _bytes.startswith(b"\x89PNG"):
mime = "image/png" mime = "image/png"
@@ -1528,13 +1543,13 @@ class ReportGenerator(IReportGenerator):
return f"data:{mime};base64,{b64}" return f"data:{mime};base64,{b64}"
except Exception as e: 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 return None
async def _get_user_avatar_bytes( async def _get_user_avatar_bytes(
self, user_id: str, avatar_url_getter=None self, user_id: str, avatar_url_getter=None
) -> bytes | None: ) -> bytes | None:
"""核心头像获取逻辑""" """Logic lõi lấy avatar."""
file_content = None file_content = None
if not self._avatar_session: if not self._avatar_session:
self._avatar_session = aiohttp.ClientSession( self._avatar_session = aiohttp.ClientSession(
@@ -1544,7 +1559,7 @@ class ReportGenerator(IReportGenerator):
avatar_url = None avatar_url = None
if avatar_url_getter: if avatar_url_getter:
try: try:
# avatar_url_getter 应该返回 URL # avatar_url_getter dự kiến trả URL.
result = await avatar_url_getter(user_id) result = await avatar_url_getter(user_id)
if result: if result:
if result.startswith("http"): if result.startswith("http"):
@@ -1557,10 +1572,12 @@ class ReportGenerator(IReportGenerator):
return base64.b64decode(parts[1]) return base64.b64decode(parts[1])
else: else:
logger.warning( 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: 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 not avatar_url:
if ( if (
@@ -1568,22 +1585,22 @@ class ReportGenerator(IReportGenerator):
and user_id.isdigit() and user_id.isdigit()
and 5 <= len(user_id) <= 12 and 5 <= len(user_id) <= 12
): ):
# 强制使用 spec=40 # Buộc dùng spec=40.
avatar_url = ( avatar_url = (
f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40" f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40"
) )
else: else:
# 其他平台若无 URL,无法获取头像 # Nền tảng khác không thể lấy avatar nếu thiếu URL.
return None return None
# 5. 下载并保存 # 5. Tải và lưu.
safe_avatar_url = self._safe_url_for_log(avatar_url) safe_avatar_url = self._safe_url_for_log(avatar_url)
try: try:
async with self._avatar_session.get(avatar_url) as response: async with self._avatar_session.get(avatar_url) as response:
if response.status == 200: if response.status == 200:
content = await response.read() content = await response.read()
if content: if content:
# 校验文件头 # Xác thực header tệp.
is_valid_image = False is_valid_image = False
if content.startswith(b"\xff\xd8"): # JPEG if content.startswith(b"\xff\xd8"): # JPEG
is_valid_image = True is_valid_image = True
@@ -1600,26 +1617,26 @@ class ReportGenerator(IReportGenerator):
file_content = content file_content = content
else: else:
logger.warning( logger.warning(
f"下载的头像数据格式无效 ({safe_avatar_url})" f"Dữ liệu avatar tải về không hợp lệ ({safe_avatar_url})"
) )
else: else:
logger.warning( 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: 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 return file_content
def _get_default_avatar_base64(self) -> str: def _get_default_avatar_base64(self) -> str:
"""返回默认头像 (灰色圆形占位符)""" """Trả avatar mặc định là placeholder hình tròn màu xám."""
# 一个简单的灰色圆圈 SVG 转 Base64 # SVG hình tròn xám đơn giản dưới dạng Base64.
svg = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50" fill="#ddd"/></svg>' svg = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50" fill="#ddd"/></svg>'
b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8") b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8")
return f"data:image/svg+xml;base64,{b64}" return f"data:image/svg+xml;base64,{b64}"
async def close(self): async def close(self):
"""释放资源,关闭缓存和 session""" """Giải phóng tài nguyên, đóng cache và session."""
if self._avatar_session: if self._avatar_session:
await self._avatar_session.close() await self._avatar_session.close()
self._avatar_session = None self._avatar_session = None
@@ -1627,31 +1644,31 @@ class ReportGenerator(IReportGenerator):
try: try:
if self._avatar_cache: if self._avatar_cache:
self._avatar_cache.close() self._avatar_cache.close()
logger.debug("头像缓存已关闭") logger.debug("Đã đóng cache avatar")
except Exception as e: 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: def _extract_html_error_summary(self, data: bytes) -> str | None:
"""从返回的字节流中尝试提取 HTML 错误信息(如 <title>""" """Thử trích xuất lỗi HTML như title từ bytes phản hồi."""
try: try:
content = data.decode("utf-8", errors="ignore") content = data.decode("utf-8", errors="ignore")
content_lower = content.lower() content_lower = content.lower()
if "<html" in content_lower or "<!doctype html" in content_lower: if "<html" in content_lower or "<!doctype html" in content_lower:
# 尝试提取标题 # Thử trích xuất title.
title_match = re.search( title_match = re.search(
r"<title>(.*?)</title>", content, re.IGNORECASE | re.DOTALL r"<title>(.*?)</title>", content, re.IGNORECASE | re.DOTALL
) )
if title_match: 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( h1_match = re.search(
r"<h1>(.*?)</h1>", content, re.IGNORECASE | re.DOTALL r"<h1>(.*?)</h1>", content, re.IGNORECASE | re.DOTALL
) )
if h1_match: 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: except Exception:
pass pass
return None return None
+29 -28
View File
@@ -1,7 +1,4 @@
""" """Module tải template HTML bên ngoài bằng Jinja2."""
HTML模板模块
使用Jinja2加载外部HTML模板文件
"""
import asyncio import asyncio
import os import os
@@ -13,25 +10,25 @@ from ...utils.logger import logger
class HTMLTemplates: class HTMLTemplates:
"""HTML模板管理类""" """Trình quản lý template HTML."""
def __init__(self, config_manager): def __init__(self, config_manager):
"""初始化Jinja2环境""" """Khởi tạo môi trường Jinja2."""
self.config_manager = config_manager 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.base_dir = os.path.join(os.path.dirname(__file__), "templates")
self.platform_base_dir = os.path.join( self.platform_base_dir = os.path.join(
os.path.dirname(__file__), "platform_templates" os.path.dirname(__file__), "platform_templates"
) )
# 缓存不同模板的Jinja2环境(多线程安全) # Cache môi trường Jinja2 theo template, an toàn đa luồng.
self._envs = {} self._envs = {}
self._env_lock = threading.Lock() self._env_lock = threading.Lock()
def _get_env_sync(self) -> Environment: 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() 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: with self._env_lock:
env = self._envs.get(template_name) env = self._envs.get(template_name)
if env is not None: if env is not None:
@@ -39,7 +36,9 @@ class HTMLTemplates:
template_dir = os.path.join(self.base_dir, template_name) template_dir = os.path.join(self.base_dir, template_name)
if not os.path.exists(template_dir): 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") template_dir = os.path.join(self.base_dir, "scrapbook")
env = Environment( env = Environment(
@@ -49,7 +48,7 @@ class HTMLTemplates:
lstrip_blocks=True, 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: with self._env_lock:
existing = self._envs.get(template_name) existing = self._envs.get(template_name)
if existing is not None: if existing is not None:
@@ -59,69 +58,69 @@ class HTMLTemplates:
return env return env
async def _get_env_async(self) -> Environment: 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) return await asyncio.to_thread(self._get_env_sync)
def _get_env(self) -> Environment: 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() return self._get_env_sync()
def _read_template_file_sync(self, filename: str) -> str: 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: with open(filename, encoding="utf-8") as f:
return f.read() return f.read()
async def get_image_template_async(self) -> str: 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: try:
env = await self._get_env_async() env = await self._get_env_async()
template = env.get_template("image_template.html") template = env.get_template("image_template.html")
if template.filename is None: if template.filename is None:
logger.error("图片模板路径为空") logger.error("Đường dẫn template ảnh rỗng")
return "" return ""
return await asyncio.to_thread( return await asyncio.to_thread(
self._read_template_file_sync, template.filename self._read_template_file_sync, template.filename
) )
except Exception as e: except Exception as e:
logger.error(f"加载图片模板失败: {e}") logger.error(f"Tải template ảnh thất bại: {e}")
return "" return ""
def get_image_template(self) -> str: 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: try:
env = self._get_env() env = self._get_env()
template = env.get_template("image_template.html") template = env.get_template("image_template.html")
if template.filename is None: if template.filename is None:
logger.error("图片模板路径为空") logger.error("Đường dẫn template ảnh rỗng")
return "" return ""
with open(template.filename, encoding="utf-8") as f: with open(template.filename, encoding="utf-8") as f:
return f.read() return f.read()
except Exception as e: except Exception as e:
logger.error(f"加载图片模板失败: {e}") logger.error(f"Tải template ảnh thất bại: {e}")
return "" return ""
def render_template(self, template_name: str, **kwargs) -> str: def render_template(self, template_name: str, **kwargs) -> str:
"""渲染指定的模板文件 """Render tệp template được chỉ định.
Args: Args:
template_name: 模板文件名 template_name: Tên tệp template.
**kwargs: 传递给模板的变量 **kwargs: Biến truyền cho template.
Returns: Returns:
渲染后的HTML字符串 Chuỗi HTML đã render.
""" """
try: try:
env = self._get_env() env = self._get_env()
template = env.get_template(template_name) template = env.get_template(template_name)
return template.render(**kwargs) return template.render(**kwargs)
except Exception as e: except Exception as e:
logger.error(f"渲染模板 {template_name} 失败: {e}") logger.error(f"Render template {template_name} thất bại: {e}")
return "" return ""
def render_platform_template( def render_platform_template(
self, platform_name: str, template_name: str, **kwargs self, platform_name: str, template_name: str, **kwargs
) -> str: ) -> str:
"""渲染与报告主题解耦的平台专用模板。""" """Render template riêng theo nền tảng, độc lập với theme báo cáo."""
try: try:
template_dir = os.path.join(self.platform_base_dir, platform_name) template_dir = os.path.join(self.platform_base_dir, platform_name)
env = Environment( env = Environment(
@@ -132,5 +131,7 @@ class HTMLTemplates:
) )
return env.get_template(template_name).render(**kwargs) return env.get_template(template_name).render(**kwargs)
except Exception as e: 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 "" return ""
@@ -44,7 +44,7 @@
min-height: 5px; min-height: 5px;
box-shadow: 0 6px 14px rgba({{ bottom_red }}, {{ bottom_green }}, {{ bottom_blue }}, 0.14); box-shadow: 0 6px 14px rgba({{ bottom_red }}, {{ bottom_green }}, {{ bottom_blue }}, 0.14);
border: none; border: none;
" title="{{hour_data.hour}} - {{hour_data.count}}条消息"></div> " title="{{hour_data.hour}} giờ - {{hour_data.count}} tin nhắn"></div>
</div> </div>
<span style="font-size: 10px; color: #9a8aa3; margin-top: 8px; line-height: 1;">{{hour_data.hour}}</span> <span style="font-size: 10px; color: #9a8aa3; margin-top: 8px; line-height: 1;">{{hour_data.hour}}</span>
</div> </div>
@@ -10,7 +10,7 @@
} }
.quality-card::before { .quality-card::before {
content: "锐评"; content: "Đánh giá";
position: absolute; position: absolute;
top: 14px; top: 14px;
right: 18px; right: 18px;
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATRI 群聊日报 · {{ current_date }}</title> <title>Báo cáo nhóm ATRI · {{ current_date }}</title>
<style> <style>
@font-face { @font-face {
font-family: 'LXGW WenKai'; font-family: 'LXGW WenKai';
@@ -2090,7 +2090,7 @@
align-items: flex-start; align-items: flex-start;
} }
} }
/* Animations Container */ /* Animations Container */
@keyframes floatUpDown { @keyframes floatUpDown {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
@@ -2116,7 +2116,7 @@
0%, 100% { transform: scale(1); opacity: 0.8; } 0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; } 50% { transform: scale(1.05); opacity: 1; }
} }
@keyframes softGlow { @keyframes softGlow {
0%, 100% { box-shadow: 0 0 15px rgba(255, 255, 255, 0.3), inset 0 0 15px rgba(255, 255, 255, 0.1); } 0%, 100% { box-shadow: 0 0 15px rgba(255, 255, 255, 0.3), inset 0 0 15px rgba(255, 255, 255, 0.1); }
50% { box-shadow: 0 0 30px rgba(255, 255, 255, 0.6), inset 0 0 20px rgba(255, 255, 255, 0.2), 0 0 50px rgba(200, 220, 240, 0.4); } 50% { box-shadow: 0 0 30px rgba(255, 255, 255, 0.6), inset 0 0 20px rgba(255, 255, 255, 0.2), 0 0 50px rgba(200, 220, 240, 0.4); }
@@ -2185,7 +2185,7 @@
/* Floating elements */ /* Floating elements */
.float-sticker:nth-child(even) { animation: floatUpDown 4s ease-in-out infinite; } .float-sticker:nth-child(even) { animation: floatUpDown 4s ease-in-out infinite; }
.float-sticker:nth-child(odd) { animation: floatRotate 5s ease-in-out infinite; } .float-sticker:nth-child(odd) { animation: floatRotate 5s ease-in-out infinite; }
.sticker-heart, .sticker-cherry, .sticker-smile { animation-delay: 0s !important; } .sticker-heart, .sticker-cherry, .sticker-smile { animation-delay: 0s !important; }
.sticker-star, .sticker-sparkle, .sticker-cat { animation-delay: 0.5s !important; } .sticker-star, .sticker-sparkle, .sticker-cat { animation-delay: 0.5s !important; }
.sticker-bow, .sticker-wand, .sticker-ribbon { animation-delay: 1s !important; } .sticker-bow, .sticker-wand, .sticker-ribbon { animation-delay: 1s !important; }
@@ -2289,7 +2289,7 @@
.glass-card { .glass-card {
animation: none; animation: none;
} }
.html-slot .item:hover { .html-slot .item:hover {
transform: translateX(6px); transform: translateX(6px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08), inset 0 2px 6px rgba(255, 255, 255, 0.8) !important; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08), inset 0 2px 6px rgba(255, 255, 255, 0.8) !important;
@@ -2684,9 +2684,9 @@
</div> </div>
<div class="header-copy"> <div class="header-copy">
<div class="eyebrow">A.T.R.I · Daily Communication Report</div> <div class="eyebrow">A.T.R.I · Daily Communication Report</div>
<h1>亚托莉的群聊观测日志</h1> <h1>Nhật ký quan sát nhóm của ATRI</h1>
<div class="header-subtitle"> <div class="header-subtitle">
主人,今天群里的大家也超级精神呢!身为高性能的亚托莉,已经把所有闪闪发光的聊天记忆,像打捞海底宝藏一样全都收集好啦! Chủ nhân, hôm nay mọi người trong nhóm cũng tràn đầy năng lượng! Là ATRI hiệu năng cao, em đã gom hết những ký ức trò chuyện lấp lánh như kho báu dưới đáy biển rồi!
</div> </div>
</div> </div>
<div class="date-box"> <div class="date-box">
@@ -2708,10 +2708,10 @@
<img class="hero-kawaii" src="{{ t2i_atri_font_mirror }}/file/1775132815504_1774881268554_可爱.gif" <img class="hero-kawaii" src="{{ t2i_atri_font_mirror }}/file/1775132815504_1774881268554_可爱.gif"
alt="cute icon"> alt="cute icon">
</div> </div>
<h2 class="hero-title">来看看今天都发生了什么吧!</h2> <h2 class="hero-title">Cùng xem hôm nay đã có chuyện gì nhé!</h2>
<div class="hero-desc"> <div class="hero-desc">
报告主人!今天一共捕获了 <strong>{{ message_count }}</strong> 条消息,有 <strong>{{ participant_count }}</strong> Báo cáo chủ nhân! Hôm nay em đã ghi nhận <strong>{{ message_count }}</strong> tin nhắn, với <strong>{{ participant_count }}</strong>
位群友浮出水面参与了互动哦!我都仔细做好了备忘录哦,哼哼! thành viên tham gia tương tác! Em đã ghi chú thật cẩn thận rồi đó, hừm hừm!
</div> </div>
<img class="hero-character" <img class="hero-character"
src="{{ t2i_atri_font_mirror }}/file/1775130626446_1774881312214_atri1.webp" alt="ATRI main"> src="{{ t2i_atri_font_mirror }}/file/1775130626446_1774881312214_atri1.webp" alt="ATRI main">
@@ -2724,7 +2724,7 @@
<div class="peak-date-subtitle">{{ current_datetime }}</div> <div class="peak-date-subtitle">{{ current_datetime }}</div>
<div class="peak-time">{{ most_active_period }}</div> <div class="peak-time">{{ most_active_period }}</div>
<div class="peak-note"> <div class="peak-note">
这段时间的心跳值达到最高峰!大家的讨论就像退缩时沙滩上的贝壳一样密集,是今天最棒的高光时刻! Nhịp tim đã đạt đỉnh trong khoảng thời gian này! Cuộc trò chuyện dày đặc như vỏ sò trên bãi biển khi thuỷ triều rút, đúng là khoảnh khắc nổi bật nhất hôm nay!
</div> </div>
</div> </div>
</div> </div>
@@ -2740,7 +2740,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ message_count }}</div> <div class="stat-value">{{ message_count }}</div>
<div class="stat-label">消息总数</div> <div class="stat-label">Tổng tin nhắn</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -2749,7 +2749,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ participant_count }}</div> <div class="stat-value">{{ participant_count }}</div>
<div class="stat-label">参与人数</div> <div class="stat-label">Người tham gia</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -2758,7 +2758,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ total_characters }}</div> <div class="stat-value">{{ total_characters }}</div>
<div class="stat-label">总字符数</div> <div class="stat-label">Tổng ký tự</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -2767,7 +2767,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ emoji_count }}</div> <div class="stat-value">{{ emoji_count }}</div>
<div class="stat-label">表情数量</div> <div class="stat-label">Số biểu cảm</div>
</div> </div>
</div> </div>
</section> </section>
@@ -2779,10 +2779,10 @@
<use href="#icon-bar-chart-3"></use> <use href="#icon-bar-chart-3"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">心跳潮汐<img class="section-kawaii" <h3 class="section-title">Thuỷ triều nhịp tim<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775132804506_1774881263342_观察.gif" src="{{ t2i_atri_font_mirror }}/file/1775132804506_1774881263342_观察.gif"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">一整天的心跳节拍,由亚托莉为您完美记录</div> <div class="section-sub">ATRI đã ghi lại hoàn hảo nhịp điệu suốt cả ngày</div>
</div> </div>
</div> </div>
<div class="section-badge">TIDE CHART <img class="badge-kawaii" <div class="section-badge">TIDE CHART <img class="badge-kawaii"
@@ -2799,10 +2799,10 @@
<use href="#icon-flame"></use> <use href="#icon-flame"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">高亮记忆碎片<img class="section-kawaii" <h3 class="section-title">Mảnh ký ức nổi bật<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775132805081_1774881262835_疑惑.gif" src="{{ t2i_atri_font_mirror }}/file/1775132805081_1774881262835_疑惑.gif"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">亚托莉捕获的最亮眼的记忆碎片!</div> <div class="section-sub">Những mảnh ký ức rực rỡ nhất ATRI đã ghi lại!</div>
</div> </div>
</div> </div>
<div class="section-badge">TOPIC BOARD <img class="badge-kawaii" <div class="section-badge">TOPIC BOARD <img class="badge-kawaii"
@@ -2819,10 +2819,10 @@
<use href="#icon-badge-check"></use> <use href="#icon-badge-check"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">神人名片颁发<img class="section-kawaii" <h3 class="section-title">Danh hiệu thành viên<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775132813334_1774881267181_得意.gif" src="{{ t2i_atri_font_mirror }}/file/1775132813334_1774881267181_得意.gif"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">颁发给各位的高性能称号!</div> <div class="section-sub">Những danh hiệu hiệu năng cao dành tặng mọi người!</div>
</div> </div>
</div> </div>
<div class="section-badge">SPOTLIGHT <img class="badge-kawaii" <div class="section-badge">SPOTLIGHT <img class="badge-kawaii"
@@ -2839,10 +2839,10 @@
<use href="#icon-quote"></use> <use href="#icon-quote"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">亚托莉的宝藏瓶<img class="section-kawaii" <h3 class="section-title">Bình kho báu của ATRI<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775132814629_1774881270686_爱心.gif" src="{{ t2i_atri_font_mirror }}/file/1775132814629_1774881270686_爱心.gif"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">亚托莉偷偷记在小本子上的宝藏名言!</div> <div class="section-sub">Những câu nói quý giá ATRI đã bí mật ghi vào sổ!</div>
</div> </div>
</div> </div>
<div class="section-badge">PRECIOUS WORDS <img class="badge-kawaii" <div class="section-badge">PRECIOUS WORDS <img class="badge-kawaii"
@@ -2860,10 +2860,10 @@
<use href="#icon-flower-2"></use> <use href="#icon-flower-2"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">亚托莉观测报告<img class="section-kawaii" <h3 class="section-title">Báo cáo quan sát ATRI<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775132817115_1774881269400_可爱-1.gif" src="{{ t2i_atri_font_mirror }}/file/1775132817115_1774881269400_可爱-1.gif"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">来自高性能亚托莉的每日特别观测报告!</div> <div class="section-sub">Báo cáo quan sát đặc biệt hằng ngày từ ATRI hiệu năng cao!</div>
</div> </div>
</div> </div>
<div class="section-badge">GROUP VIBE <img class="badge-kawaii" <div class="section-badge">GROUP VIBE <img class="badge-kawaii"
@@ -2881,10 +2881,10 @@
<use href="#icon-sparkles"></use> <use href="#icon-sparkles"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">能量解析雷达<img class="section-kawaii" <h3 class="section-title">Radar phân tích năng lượng<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775132809943_1774881264350_睡觉.gif" src="{{ t2i_atri_font_mirror }}/file/1775132809943_1774881264350_睡觉.gif"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">维持亚托莉运行与心跳的能量消耗~</div> <div class="section-sub">Năng lượng duy trì hoạt động và nhịp tim của ATRI~</div>
</div> </div>
</div> </div>
<div class="section-badge">ENERGY COST <img class="badge-kawaii" <div class="section-badge">ENERGY COST <img class="badge-kawaii"
@@ -2893,15 +2893,15 @@
</div> </div>
<div class="token-grid"> <div class="token-grid">
<div class="token-card"> <div class="token-card">
<div class="token-label">总消耗</div> <div class="token-label">Tổng tiêu thụ</div>
<div class="token-value">{{ total_tokens }}</div> <div class="token-value">{{ total_tokens }}</div>
</div> </div>
<div class="token-card"> <div class="token-card">
<div class="token-label">提示词 Token</div> <div class="token-label">Token prompt</div>
<div class="token-value">{{ prompt_tokens }}</div> <div class="token-value">{{ prompt_tokens }}</div>
</div> </div>
<div class="token-card"> <div class="token-card">
<div class="token-label">生成内容 Token</div> <div class="token-label">Token nội dung</div>
<div class="token-value">{{ completion_tokens }}</div> <div class="token-value">{{ completion_tokens }}</div>
</div> </div>
</div> </div>
@@ -2921,7 +2921,7 @@
<span>SXP-Simon/astrbot_plugin_qq_group_daily_analysis</span> <span>SXP-Simon/astrbot_plugin_qq_group_daily_analysis</span>
</a> </a>
</div> </div>
<div style="font-size: 13px; opacity: 0.85;">亚托莉会永远把大家珍贵的聊天记忆藏在海底哦。</div> <div style="font-size: 13px; opacity: 0.85;">ATRI sẽ luôn cất giữ những ký ức trò chuyện quý giá của mọi người dưới đáy biển.</div>
</div> </div>
</footer> </footer>
</div> </div>
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATRI 群聊日报 · {{ current_date }}</title> <title>Báo cáo nhóm ATRI · {{ current_date }}</title>
<style> <style>
@font-face { @font-face {
font-family: 'LXGW WenKai'; font-family: 'LXGW WenKai';
@@ -1674,9 +1674,9 @@
<header class="header"> <header class="header">
<div class="header-copy"> <div class="header-copy">
<div class="eyebrow">A.T.R.I · Daily Communication Report</div> <div class="eyebrow">A.T.R.I · Daily Communication Report</div>
<h1>亚托莉的群聊观测日志</h1> <h1>Nhật ký quan sát nhóm của ATRI</h1>
<div class="header-subtitle"> <div class="header-subtitle">
主人,今天群里的大家也超级精神呢!身为高性能的亚托莉,已经把所有闪闪发光的聊天记忆,像打捞海底宝藏一样全都收集好啦! Chủ nhân, hôm nay mọi người trong nhóm cũng tràn đầy năng lượng! Là ATRI hiệu năng cao, em đã gom hết những ký ức trò chuyện lấp lánh như kho báu dưới đáy biển rồi!
</div> </div>
</div> </div>
<div class="date-box"> <div class="date-box">
@@ -1699,10 +1699,10 @@
src="{{ t2i_atri_font_mirror }}/file/1775130600453_1774881268554_可爱.webp" src="{{ t2i_atri_font_mirror }}/file/1775130600453_1774881268554_可爱.webp"
alt="cute icon"> alt="cute icon">
</div> </div>
<h2 class="hero-title">来看看今天<br>都发生了什<br>么吧!</h2> <h2 class="hero-title">Cùng xem hôm<br>nay đã có chuyện<br>gì nhé!</h2>
<div class="hero-desc"> <div class="hero-desc">
报告主人!今天一共捕获了 <strong>{{ message_count }}</strong> 条消息,有 <strong>{{ participant_count }}</strong> Báo cáo chủ nhân! Hôm nay em đã ghi nhận <strong>{{ message_count }}</strong> tin nhắn, với <strong>{{ participant_count }}</strong>
位群友浮出水面参与了互动哦!我都仔细做好了备忘录哦,哼哼! thành viên tham gia tương tác! Em đã ghi chú thật cẩn thận rồi đó, hừm hừm!
</div> </div>
<img class="hero-character" <img class="hero-character"
src="{{ t2i_atri_font_mirror }}/file/1775130626446_1774881312214_atri1.webp" src="{{ t2i_atri_font_mirror }}/file/1775130626446_1774881312214_atri1.webp"
@@ -1716,7 +1716,7 @@
<div class="peak-date-subtitle">{{ current_datetime }}</div> <div class="peak-date-subtitle">{{ current_datetime }}</div>
<div class="peak-time">{{ most_active_period }}</div> <div class="peak-time">{{ most_active_period }}</div>
<div class="peak-note"> <div class="peak-note">
这段时间的心跳值达到最高峰!大家的讨论就像退缩时沙滩上的贝壳一样密集,是今天最棒的高光时刻! Nhịp tim đã đạt đỉnh trong khoảng thời gian này! Cuộc trò chuyện dày đặc như vỏ sò trên bãi biển khi thuỷ triều rút, đúng là khoảnh khắc nổi bật nhất hôm nay!
</div> </div>
</div> </div>
</div> </div>
@@ -1733,7 +1733,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ message_count }}</div> <div class="stat-value">{{ message_count }}</div>
<div class="stat-label">消息总数</div> <div class="stat-label">Tổng tin nhắn</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -1742,7 +1742,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ participant_count }}</div> <div class="stat-value">{{ participant_count }}</div>
<div class="stat-label">参与人数</div> <div class="stat-label">Người tham gia</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -1751,7 +1751,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ total_characters }}</div> <div class="stat-value">{{ total_characters }}</div>
<div class="stat-label">总字符数</div> <div class="stat-label">Tổng ký tự</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -1760,7 +1760,7 @@
</svg></div> </svg></div>
<div class="stat-copy"> <div class="stat-copy">
<div class="stat-value">{{ emoji_count }}</div> <div class="stat-value">{{ emoji_count }}</div>
<div class="stat-label">表情数量</div> <div class="stat-label">Số biểu cảm</div>
</div> </div>
</div> </div>
</section> </section>
@@ -1772,10 +1772,10 @@
<use href="#icon-bar-chart-3"></use> <use href="#icon-bar-chart-3"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">心跳潮汐<img class="section-kawaii" <h3 class="section-title">Thuỷ triều nhịp tim<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775130585446_1774881263342_观察.webp" src="{{ t2i_atri_font_mirror }}/file/1775130585446_1774881263342_观察.webp"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">一整天的心跳节拍,由亚托莉为您完美记录</div> <div class="section-sub">ATRI đã ghi lại hoàn hảo nhịp điệu suốt cả ngày</div>
</div> </div>
</div> </div>
<div class="section-badge">TIDE CHART <img class="badge-kawaii" <div class="section-badge">TIDE CHART <img class="badge-kawaii"
@@ -1792,10 +1792,10 @@
<use href="#icon-flame"></use> <use href="#icon-flame"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">高亮记忆碎片<img class="section-kawaii" <h3 class="section-title">Mảnh ký ức nổi bật<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775130581843_1774881262835_疑惑.webp" src="{{ t2i_atri_font_mirror }}/file/1775130581843_1774881262835_疑惑.webp"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">亚托莉捕获的最亮眼的记忆碎片!</div> <div class="section-sub">Những mảnh ký ức rực rỡ nhất ATRI đã ghi lại!</div>
</div> </div>
</div> </div>
<div class="section-badge">TOPIC BOARD <img class="badge-kawaii" <div class="section-badge">TOPIC BOARD <img class="badge-kawaii"
@@ -1812,10 +1812,10 @@
<use href="#icon-badge-check"></use> <use href="#icon-badge-check"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">神人名片颁发<img class="section-kawaii" <h3 class="section-title">Danh hiệu thành viên<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775130598778_1774881267181_得意.webp" src="{{ t2i_atri_font_mirror }}/file/1775130598778_1774881267181_得意.webp"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">颁发给各位的高性能称号!</div> <div class="section-sub">Những danh hiệu hiệu năng cao dành tặng mọi người!</div>
</div> </div>
</div> </div>
<div class="section-badge">SPOTLIGHT <img class="badge-kawaii" <div class="section-badge">SPOTLIGHT <img class="badge-kawaii"
@@ -1832,10 +1832,10 @@
<use href="#icon-quote"></use> <use href="#icon-quote"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">亚托莉的宝藏瓶<img class="section-kawaii" <h3 class="section-title">Bình kho báu của ATRI<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775130605165_1774881270686_爱心.webp" src="{{ t2i_atri_font_mirror }}/file/1775130605165_1774881270686_爱心.webp"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">亚托莉偷偷记在小本子上的宝藏名言!</div> <div class="section-sub">Những câu nói quý giá ATRI đã bí mật ghi vào sổ!</div>
</div> </div>
</div> </div>
<div class="section-badge">PRECIOUS WORDS <img class="badge-kawaii" <div class="section-badge">PRECIOUS WORDS <img class="badge-kawaii"
@@ -1853,10 +1853,10 @@
<use href="#icon-flower-2"></use> <use href="#icon-flower-2"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">亚托莉观测报告<img class="section-kawaii" <h3 class="section-title">Báo cáo quan sát ATRI<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775130609119_1774881269400_可爱-1.webp" src="{{ t2i_atri_font_mirror }}/file/1775130609119_1774881269400_可爱-1.webp"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">来自高性能亚托莉的每日特别观测报告!</div> <div class="section-sub">Báo cáo quan sát đặc biệt hằng ngày từ ATRI hiệu năng cao!</div>
</div> </div>
</div> </div>
<div class="section-badge">GROUP VIBE <img class="badge-kawaii" <div class="section-badge">GROUP VIBE <img class="badge-kawaii"
@@ -1874,10 +1874,10 @@
<use href="#icon-sparkles"></use> <use href="#icon-sparkles"></use>
</svg></div> </svg></div>
<div> <div>
<h3 class="section-title">能量解析雷达<img class="section-kawaii" <h3 class="section-title">Radar phân tích năng lượng<img class="section-kawaii"
src="{{ t2i_atri_font_mirror }}/file/1775130591277_1774881264350_睡觉.webp" src="{{ t2i_atri_font_mirror }}/file/1775130591277_1774881264350_睡觉.webp"
alt="kawaii icon"></h3> alt="kawaii icon"></h3>
<div class="section-sub">维持亚托莉运行与心跳的能量消耗~</div> <div class="section-sub">Năng lượng duy trì hoạt động và nhịp tim của ATRI~</div>
</div> </div>
</div> </div>
<div class="section-badge">ENERGY COST <img class="badge-kawaii" <div class="section-badge">ENERGY COST <img class="badge-kawaii"
@@ -1886,15 +1886,15 @@
</div> </div>
<div class="token-grid"> <div class="token-grid">
<div class="token-card"> <div class="token-card">
<div class="token-label">总消耗</div> <div class="token-label">Tổng tiêu thụ</div>
<div class="token-value">{{ total_tokens }}</div> <div class="token-value">{{ total_tokens }}</div>
</div> </div>
<div class="token-card"> <div class="token-card">
<div class="token-label">提示词 Token</div> <div class="token-label">Token prompt</div>
<div class="token-value">{{ prompt_tokens }}</div> <div class="token-value">{{ prompt_tokens }}</div>
</div> </div>
<div class="token-card"> <div class="token-card">
<div class="token-label">生成内容 Token</div> <div class="token-label">Token nội dung</div>
<div class="token-value">{{ completion_tokens }}</div> <div class="token-value">{{ completion_tokens }}</div>
</div> </div>
</div> </div>
@@ -1914,7 +1914,7 @@
<span>SXP-Simon/astrbot_plugin_qq_group_daily_analysis</span> <span>SXP-Simon/astrbot_plugin_qq_group_daily_analysis</span>
</a> </a>
</div> </div>
<div style="font-size: 13px; opacity: 0.85;">亚托莉会永远把大家珍贵的聊天记忆藏在海底哦。</div> <div style="font-size: 13px; opacity: 0.85;">ATRI sẽ luôn cất giữ những ký ức trò chuyện quý giá của mọi người dưới đáy biển.</div>
</div> </div>
</footer> </footer>
</div> </div>
@@ -1,5 +1,5 @@
<div class="miku-activity-chart" style="display: flex; flex-direction: column; gap: 12px; width: 100%;"> <div class="miku-activity-chart" style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
{# 柱状图主体层:确保底部对齐 #} {# Lớp thân biểu đồ cột: căn đáy #}
<div style="display: flex; align-items: flex-end; justify-content: space-between; height: 160px; gap: 4px; padding-bottom: 5px; border-bottom: 2px solid rgba(57, 197, 187, 0.1);"> <div style="display: flex; align-items: flex-end; justify-content: space-between; height: 160px; gap: 4px; padding-bottom: 5px; border-bottom: 2px solid rgba(57, 197, 187, 0.1);">
{% set chart_colors = [ {% set chart_colors = [
'#66D1C7', '#6FD3C6', '#79D5C4', '#85D8C3', '#93DAC1', '#A3DDBF', '#66D1C7', '#6FD3C6', '#79D5C4', '#85D8C3', '#93DAC1', '#A3DDBF',
@@ -32,7 +32,7 @@
{% endfor %} {% endfor %}
</div> </div>
{# X轴标签层:独立容器确保水平对齐且不换行 #} {# Lớp nhãn trục X: container riêng để căn ngang và không xuống dòng #}
<div style="display: flex; justify-content: space-between; gap: 4px;"> <div style="display: flex; justify-content: space-between; gap: 4px;">
{% for item in chart_data %} {% for item in chart_data %}
<div style="flex: 1; text-align: center;"> <div style="flex: 1; text-align: center;">
@@ -35,7 +35,7 @@
<div style="line-height: 1.8; font-size: 1rem; opacity: 0.95;">{{ summary }}</div> <div style="line-height: 1.8; font-size: 1rem; opacity: 0.95;">{{ summary }}</div>
</div> </div>
<div class="miku-quality-deco" style="height: 100px; margin-left: 20px; flex-shrink: 0; display: flex; align-items: flex-end;"> <div class="miku-quality-deco" style="height: 100px; margin-left: 20px; flex-shrink: 0; display: flex; align-items: flex-end;">
<!-- 占位符:Chat Quality 小人 --> <!-- Placeholder nhân vật Chat Quality -->
<img src="https://img.heliar.top/file/1778303907562_retouch_2026032810150449.png" alt="Quality Deco" style="height: 100%; width: auto; max-width: 150px; border: none; outline: none; background: transparent; object-fit: contain;"> <img src="https://img.heliar.top/file/1778303907562_retouch_2026032810150449.png" alt="Quality Deco" style="height: 100%; width: auto; max-width: 150px; border: none; outline: none; background: transparent; object-fit: contain;">
</div> </div>
</div> </div>
@@ -51,7 +51,7 @@
color: var(--text-main); color: var(--text-main);
background-color: var(--bg-page-base); background-color: var(--bg-page-base);
/* 背景图:多层叠加,保证通透和细节 */ /* Ảnh nền nhiều lớp để giữ độ trong và chi tiết */
background-image: background-image:
linear-gradient(rgba(240, 250, 250, 0.95), rgba(240, 250, 250, 0.95)), linear-gradient(rgba(240, 250, 250, 0.95), rgba(240, 250, 250, 0.95)),
linear-gradient(rgba(57, 197, 187, 0.04) 1px, transparent 1px), linear-gradient(rgba(57, 197, 187, 0.04) 1px, transparent 1px),
@@ -77,12 +77,12 @@
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-miku-light); border: 1px solid var(--border-miku-light);
z-index: 1; z-index: 1;
/* 极淡的葱绿波点,消除白底空洞 */ /* Chấm xanh nhạt để nền trắng bớt trống */
background-image: radial-gradient(rgba(57, 197, 187, 0.08) 1.5px, transparent 1.5px); background-image: radial-gradient(rgba(57, 197, 187, 0.08) 1.5px, transparent 1.5px);
background-size: 24px 24px; background-size: 24px 24px;
} }
/* --- 丰富的背景隐含文字装饰 (极低透明度,不影响阅读) --- */ /* Chữ trang trí ẩn trên nền với độ mờ rất thấp */
.deco-01 { .deco-01 {
position: absolute; position: absolute;
top: 40px; top: 40px;
@@ -142,7 +142,7 @@
pointer-events: none; pointer-events: none;
} }
/* 头部设计 */ /* Thiết kế header */
.header { .header {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -182,7 +182,7 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
/* 统计卡片层 */ /* Lớp thẻ thống kê */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@@ -227,21 +227,21 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
/* --- 活跃峰值时段块 (核心修改区) --- */ /* Khối khung giờ hoạt động cao điểm */
.active-peak-block { .active-peak-block {
grid-column: span 12; grid-column: span 12;
background: linear-gradient(135deg, var(--miku-base) 0%, var(--miku-light) 100%); background: linear-gradient(135deg, var(--miku-base) 0%, var(--miku-light) 100%);
color: #fff; color: #fff;
/* 修改 1: 设定固定高度,防止被图片拉大 */ /* Đặt chiều cao cố định để ảnh không kéo giãn */
height: 120px; height: 120px;
/* 修改 2: 移除垂直 padding,让内容占满高度 */ /* Bỏ padding dọc để nội dung phủ hết chiều cao */
padding: 0 35px; padding: 0 35px;
border-radius: var(--radius); border-radius: var(--radius);
margin-bottom: 30px; margin-bottom: 30px;
/* 修改 3: 使用水平 Flex 布局,文字居左,图片居右 */ /* Flex ngang: chữ bên trái, ảnh bên phải */
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -252,7 +252,7 @@
box-shadow: 0 6px 15px rgba(57, 197, 187, 0.15); box-shadow: 0 6px 15px rgba(57, 197, 187, 0.15);
} }
/* 底部 MIKU 字母水印,降低层级 */ /* Watermark MIKU ở đáy với z-index thấp */
.active-peak-block::after { .active-peak-block::after {
content: "MIKU"; content: "MIKU";
position: absolute; position: absolute;
@@ -272,7 +272,7 @@
letter-spacing: -3px; letter-spacing: -3px;
} }
/* 文本区域样式 */ /* Kiểu vùng văn bản */
.peak-label { .peak-label {
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
@@ -303,32 +303,32 @@
overflow-wrap: normal; overflow-wrap: normal;
} }
/* 图片容器样式:顶天立地,完全不拉伸 */ /* Container ảnh phủ chiều cao nhưng không kéo giãn */
.peak-image-container { .peak-image-container {
height: 100%; height: 100%;
/* 核心:容器高度占满 */ /* Container phủ hết chiều cao */
width: auto; width: auto;
max-width: 45%; max-width: 45%;
/* 防止图片太宽挤扁文字 */ /* Tránh ảnh quá rộng ép vùng chữ */
flex: 0 1 150px; flex: 0 1 150px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
position: relative; position: relative;
z-index: 1; z-index: 1;
/* 在水印上方,但在文字下方 */ /* Trên watermark nhưng dưới chữ */
} }
.miku-peak-activity-image { .miku-peak-activity-image {
height: 100%; height: 100%;
/* 核心:图片高度占满容器 */ /* Ảnh phủ hết chiều cao container */
width: 150px; width: 150px;
max-width: 100%; max-width: 100%;
/* 核心:保持原比例,不拉伸,完整显示在框内 */ /* Giữ tỷ lệ gốc, không kéo giãn và hiển thị trọn trong khung */
background: var(--miku-peak-activity-image) center / auto 100% no-repeat; background: var(--miku-peak-activity-image) center / auto 100% no-repeat;
} }
/* 模块标头公共样式 */ /* Kiểu chung cho tiêu đề module */
.section-header { .section-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -358,7 +358,7 @@
gap: 10px; gap: 10px;
} }
/* 全局通用卡片 */ /* Thẻ dùng chung toàn cục */
.card-common { .card-common {
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.98);
border-radius: var(--radius); border-radius: var(--radius);
@@ -376,13 +376,13 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
/* --- 完美对齐的透明马卡龙底部栏 --- */ /* Thanh đáy macaron trong suốt được căn chỉnh */
.footer { .footer {
margin-top: 50px; margin-top: 50px;
/* 使用半透明的马卡龙蓝/葱绿作为底色,告别黑色沉闷 */ /* Nền xanh macaron bán trong suốt thay cho màu đen */
background: rgba(224, 242, 241, 0.65); background: rgba(224, 242, 241, 0.65);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
/* 毛玻璃效果 */ /* Hiệu ứng kính mờ */
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(57, 197, 187, 0.3); border: 1px solid rgba(57, 197, 187, 0.3);
border-radius: var(--radius); border-radius: var(--radius);
@@ -407,7 +407,7 @@
align-items: center; align-items: center;
} }
/* 字体颜色适配浅色背景 */ /* Màu chữ phù hợp nền sáng */
.footer-repo { .footer-repo {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -51,7 +51,7 @@
color: var(--text-main); color: var(--text-main);
background-color: var(--bg-page-base); background-color: var(--bg-page-base);
/* 背景图:多层叠加,保证通透和细节 */ /* Ảnh nền nhiều lớp để giữ độ trong và chi tiết */
background-image: background-image:
linear-gradient(rgba(240, 250, 250, 0.95), rgba(240, 250, 250, 0.95)), linear-gradient(rgba(240, 250, 250, 0.95), rgba(240, 250, 250, 0.95)),
linear-gradient(rgba(57, 197, 187, 0.04) 1px, transparent 1px), linear-gradient(rgba(57, 197, 187, 0.04) 1px, transparent 1px),
@@ -77,12 +77,12 @@
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-miku-light); border: 1px solid var(--border-miku-light);
z-index: 1; z-index: 1;
/* 极淡的葱绿波点,消除白底空洞 */ /* Chấm xanh nhạt để nền trắng bớt trống */
background-image: radial-gradient(rgba(57, 197, 187, 0.08) 1.5px, transparent 1.5px); background-image: radial-gradient(rgba(57, 197, 187, 0.08) 1.5px, transparent 1.5px);
background-size: 24px 24px; background-size: 24px 24px;
} }
/* --- 丰富的背景隐含文字装饰 (极低透明度,不影响阅读) --- */ /* Chữ trang trí ẩn trên nền với độ mờ rất thấp */
.deco-01 { .deco-01 {
position: absolute; position: absolute;
top: 40px; top: 40px;
@@ -142,7 +142,7 @@
pointer-events: none; pointer-events: none;
} }
/* 头部设计 */ /* Thiết kế header */
.header { .header {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -182,7 +182,7 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
/* 统计卡片层 */ /* Lớp thẻ thống kê */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@@ -227,21 +227,21 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
/* --- 活跃峰值时段块 (核心修改区) --- */ /* Khối khung giờ hoạt động cao điểm */
.active-peak-block { .active-peak-block {
grid-column: span 12; grid-column: span 12;
background: linear-gradient(135deg, var(--miku-base) 0%, var(--miku-light) 100%); background: linear-gradient(135deg, var(--miku-base) 0%, var(--miku-light) 100%);
color: #fff; color: #fff;
/* 修改 1: 设定固定高度,防止被图片拉大 */ /* Đặt chiều cao cố định để ảnh không kéo giãn */
height: 120px; height: 120px;
/* 修改 2: 移除垂直 padding,让内容占满高度 */ /* Bỏ padding dọc để nội dung phủ hết chiều cao */
padding: 0 35px; padding: 0 35px;
border-radius: var(--radius); border-radius: var(--radius);
margin-bottom: 30px; margin-bottom: 30px;
/* 修改 3: 使用水平 Flex 布局,文字居左,图片居右 */ /* Flex ngang: chữ bên trái, ảnh bên phải */
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -252,7 +252,7 @@
box-shadow: 0 6px 15px rgba(57, 197, 187, 0.15); box-shadow: 0 6px 15px rgba(57, 197, 187, 0.15);
} }
/* 底部 MIKU 字母水印,降低层级 */ /* Watermark MIKU ở đáy với z-index thấp */
.active-peak-block::after { .active-peak-block::after {
content: "MIKU"; content: "MIKU";
position: absolute; position: absolute;
@@ -272,7 +272,7 @@
letter-spacing: -3px; letter-spacing: -3px;
} }
/* 文本区域样式 */ /* Kiểu vùng văn bản */
.peak-label { .peak-label {
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
@@ -303,32 +303,32 @@
overflow-wrap: normal; overflow-wrap: normal;
} }
/* 图片容器样式:顶天立地,完全不拉伸 */ /* Container ảnh phủ chiều cao nhưng không kéo giãn */
.peak-image-container { .peak-image-container {
height: 100%; height: 100%;
/* 核心:容器高度占满 */ /* Container phủ hết chiều cao */
width: auto; width: auto;
max-width: 45%; max-width: 45%;
/* 防止图片太宽挤扁文字 */ /* Tránh ảnh quá rộng ép vùng chữ */
flex: 0 1 150px; flex: 0 1 150px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
position: relative; position: relative;
z-index: 1; z-index: 1;
/* 在水印上方,但在文字下方 */ /* Trên watermark nhưng dưới chữ */
} }
.miku-peak-activity-image { .miku-peak-activity-image {
height: 100%; height: 100%;
/* 核心:图片高度占满容器 */ /* Ảnh phủ hết chiều cao container */
width: 150px; width: 150px;
max-width: 100%; max-width: 100%;
/* 核心:保持原比例,不拉伸,完整显示在框内 */ /* Giữ tỷ lệ gốc, không kéo giãn và hiển thị trọn trong khung */
background: var(--miku-peak-activity-image) center / auto 100% no-repeat; background: var(--miku-peak-activity-image) center / auto 100% no-repeat;
} }
/* 模块标头公共样式 */ /* Kiểu chung cho tiêu đề module */
.section-header { .section-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -358,7 +358,7 @@
gap: 10px; gap: 10px;
} }
/* 全局通用卡片 */ /* Thẻ dùng chung toàn cục */
.card-common { .card-common {
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.98);
border-radius: var(--radius); border-radius: var(--radius);
@@ -376,13 +376,13 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
/* --- 完美对齐的透明马卡龙底部栏 --- */ /* Thanh đáy macaron trong suốt được căn chỉnh */
.footer { .footer {
margin-top: 50px; margin-top: 50px;
/* 使用半透明的马卡龙蓝/葱绿作为底色,告别黑色沉闷 */ /* Nền xanh macaron bán trong suốt thay cho màu đen */
background: rgba(224, 242, 241, 0.65); background: rgba(224, 242, 241, 0.65);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
/* 毛玻璃效果 */ /* Hiệu ứng kính mờ */
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(57, 197, 187, 0.3); border: 1px solid rgba(57, 197, 187, 0.3);
border-radius: var(--radius); border-radius: var(--radius);
@@ -407,7 +407,7 @@
align-items: center; align-items: center;
} }
/* 字体颜色适配浅色背景 */ /* Màu chữ phù hợp nền sáng */
.footer-repo { .footer-repo {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
File diff suppressed because one or more lines are too long
@@ -7,17 +7,17 @@
<title>Một ngày đầy sắc màu — cùng xem nhóm đã có gì nhé! · {{current_date}}</title> <title>Một ngày đầy sắc màu — cùng xem nhóm đã có gì nhé! · {{current_date}}</title>
<link rel="preconnect" href="{{ t2i_google_fonts_mirror }}"> <link rel="preconnect" href="{{ t2i_google_fonts_mirror }}">
<link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin> <link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin>
<!-- 引入手写字体和正文阅读字体 --> <!-- Tải font viết tay và font nội dung -->
<link <link
href="{{ t2i_google_fonts_mirror }}/css2?family=ZCOOL+KuaiLe&family=Long+Cang&family=Noto+Sans+TC:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Patrick+Hand&display=swap" href="{{ t2i_google_fonts_mirror }}/css2?family=ZCOOL+KuaiLe&family=Long+Cang&family=Noto+Sans+TC:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Patrick+Hand&display=swap"
rel="stylesheet"> rel="stylesheet">
<style> <style>
:root { :root {
/* 卡通手账调色板 */ /* Bảng màu sổ tay hoạt hình */
--bg-paper: #fdfbf7; --bg-paper: #fdfbf7;
--ink-primary: #5d4037; --ink-primary: #5d4037;
/* 像铅笔/墨水的深褐色 */ /* Nâu đậm như bút chì hoặc mực */
--ink-secondary: #8d6e63; --ink-secondary: #8d6e63;
--color-yellow: #fff9c4; --color-yellow: #fff9c4;
@@ -28,22 +28,22 @@
--accent-orange: #ff7043; --accent-orange: #ff7043;
/* MBTI 专属高对比色 */ /* Màu tương phản cao dành cho MBTI */
--mbti-bg: #ede7f6; --mbti-bg: #ede7f6;
--mbti-border: #9575cd; --mbti-border: #9575cd;
--mbti-text: #512da8; --mbti-text: #512da8;
/* 称号专属色 */ /* Màu riêng cho danh hiệu */
--title-bg: #fffde7; --title-bg: #fffde7;
--title-border: #ffb74d; --title-border: #ffb74d;
--title-text: #bf360c; --title-text: #bf360c;
--font-title: 'ZCOOL KuaiLe', cursive; --font-title: 'ZCOOL KuaiLe', cursive;
/* 标题字体 */ /* Font tiêu đề */
--font-hand: 'Patrick Hand', "KaiTi", "STKaiti", serif; --font-hand: 'Patrick Hand', "KaiTi", "STKaiti", serif;
/* 手写体 */ /* Font viết tay */
--font-body: {% if t2i_font_source == 'Mainland' %}'Noto Sans SC', 'Noto Sans TC'{% else %}'Noto Sans TC', 'Noto Sans SC'{% endif %}, sans-serif; --font-body: {% if t2i_font_source == 'Mainland' %}'Noto Sans SC', 'Noto Sans TC'{% else %}'Noto Sans TC', 'Noto Sans SC'{% endif %}, sans-serif;
/* 正文体 */ /* Font nội dung */
} }
* { * {
@@ -164,7 +164,7 @@
font-family: var(--font-body); font-family: var(--font-body);
color: var(--ink-primary); color: var(--ink-primary);
background-color: var(--bg-paper); background-color: var(--bg-paper);
/* 点阵纸背景纹理 */ /* Hoạ tiết nền giấy chấm bi */
background-image: radial-gradient(#ddd 2px, transparent 2px); background-image: radial-gradient(#ddd 2px, transparent 2px);
background-size: 20px 20px; background-size: 20px 20px;
min-height: 100vh; min-height: 100vh;
@@ -172,7 +172,7 @@
line-height: 1.6; line-height: 1.6;
} }
/* SVG 图标通用样式 */ /* Kiểu chung cho biểu tượng SVG */
.doodle { .doodle {
width: 1em; width: 1em;
height: 1em; height: 1em;
@@ -210,7 +210,7 @@
opacity: 0.5; opacity: 0.5;
} }
/* ==================== 1. 头部设计 ==================== */ /* ==================== 1. Thiết kế header ==================== */
.header { .header {
text-align: center; text-align: center;
margin-bottom: 50px; margin-bottom: 50px;
@@ -264,8 +264,8 @@
mask-repeat: repeat-x; mask-repeat: repeat-x;
} }
/* ==================== 2. 统计邮票 ==================== */ /* ==================== 2. Tem thống kê ==================== */
/* ==================== 2. 统计区域 (新版) ==================== */ /* ==================== 2. Vùng thống kê mới ==================== */
.stats-wrapper { .stats-wrapper {
display: flex; display: flex;
gap: 25px; gap: 25px;
@@ -296,7 +296,7 @@
transition: transform 0.2s; transition: transform 0.2s;
} }
/* 装饰性背景纹理 */ /* Hoạ tiết nền trang trí */
.stamp::after { .stamp::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -332,7 +332,7 @@
color: var(--ink-secondary); color: var(--ink-secondary);
} }
/* ==================== 布局容器 ==================== */ /* ==================== Container bố cục ==================== */
.grid-layout { .grid-layout {
display: grid; display: grid;
grid-template-columns: repeat(12, 1fr); grid-template-columns: repeat(12, 1fr);
@@ -353,7 +353,7 @@
font-size: 1.3em; font-size: 1.3em;
} }
/* ==================== 3. 图表区 ==================== */ /* ==================== 3. Vùng biểu đồ ==================== */
.chart-section { .chart-section {
grid-column: span 7; grid-column: span 7;
background: #fff; background: #fff;
@@ -374,7 +374,7 @@
border-left: 2px solid #999; border-left: 2px solid #999;
} }
/* 手绘条形图模拟 */ /* Mô phỏng biểu đồ cột vẽ tay */
.hand-chart-row { .hand-chart-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -450,8 +450,8 @@
text-align: center; text-align: center;
} }
/* ==================== 4. 活跃时段 ==================== */ /* ==================== 4. Khung giờ hoạt động ==================== */
/* ==================== 4. 活跃时段 (新版) ==================== */ /* ==================== 4. Khung giờ hoạt động mới ==================== */
.highlight-section { .highlight-section {
flex: 1; flex: 1;
background: var(--color-yellow); background: var(--color-yellow);
@@ -491,7 +491,7 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
/* ==================== 5. 话题列表 ==================== */ /* ==================== 5. Danh sách chủ đề ==================== */
.topic-section { .topic-section {
grid-column: span 12; grid-column: span 12;
background: #fff; background: #fff;
@@ -522,7 +522,7 @@
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.1);
} }
/* 话题项样式 - 确保在 image_template 中正确定义 */ /* Kiểu mục chủ đề, đồng bộ với image_template */
.topic-item { .topic-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -579,7 +579,7 @@
margin-top: 8px; margin-top: 8px;
} }
/* ==================== 6. 群友画像 ==================== */ /* ==================== 6. Hồ sơ thành viên ==================== */
.user-section { .user-section {
grid-column: span 12; grid-column: span 12;
margin-top: 20px; margin-top: 20px;
@@ -755,7 +755,7 @@
/* Removed big quote mark to clean up */ /* Removed big quote mark to clean up */
} }
/* ==================== 7. 每日回响 ==================== */ /* ==================== 7. Dư âm hằng ngày ==================== */
.quotes-section { .quotes-section {
grid-column: span 12; grid-column: span 12;
display: flex; display: flex;
@@ -1023,7 +1023,7 @@
<body> <body>
<div class="container"> <div class="container">
<!-- 1. 头部 --> <!-- 1. Header -->
<div class="header"> <div class="header">
<div class="title-sticker"> <div class="title-sticker">
<div class="tape"></div> <div class="tape"></div>
@@ -1032,7 +1032,7 @@
</div> </div>
</div> </div>
<!-- 2. 统计邮票和活跃时段 --> <!-- 2. Tem thống kê và khung giờ hoạt động -->
<div class="stats-wrapper"> <div class="stats-wrapper">
<!-- Left: Stats Grid (2x2) --> <!-- Left: Stats Grid (2x2) -->
<div class="stats-grid"> <div class="stats-grid">
@@ -1096,7 +1096,7 @@
<div class="grid-layout"> <div class="grid-layout">
<!-- 3. 24小时活跃图表 --> <!-- 3. Biểu đồ hoạt động 24 giờ -->
<div class="chart-section" style="grid-column: span 12;"> <div class="chart-section" style="grid-column: span 12;">
<div class="coil"></div> <div class="coil"></div>
<div class="section-title"> <div class="section-title">
@@ -7,17 +7,17 @@
<title>Một ngày đầy sắc màu — cùng xem nhóm đã có gì nhé! · {{current_date}}</title> <title>Một ngày đầy sắc màu — cùng xem nhóm đã có gì nhé! · {{current_date}}</title>
<link rel="preconnect" href="{{ t2i_google_fonts_mirror }}"> <link rel="preconnect" href="{{ t2i_google_fonts_mirror }}">
<link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin> <link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin>
<!-- 引入手写字体和正文阅读字体 --> <!-- Tải font viết tay và font nội dung -->
<link <link
href="{{ t2i_google_fonts_mirror }}/css2?family=ZCOOL+KuaiLe&family=Long+Cang&family=Noto+Sans+TC:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Patrick+Hand&display=swap" href="{{ t2i_google_fonts_mirror }}/css2?family=ZCOOL+KuaiLe&family=Long+Cang&family=Noto+Sans+TC:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Patrick+Hand&display=swap"
rel="stylesheet"> rel="stylesheet">
<style> <style>
:root { :root {
/* 卡通手账调色板 */ /* Bảng màu sổ tay hoạt hình */
--bg-paper: #fdfbf7; --bg-paper: #fdfbf7;
--ink-primary: #5d4037; --ink-primary: #5d4037;
/* 像铅笔/墨水的深褐色 */ /* Nâu đậm như bút chì hoặc mực */
--ink-secondary: #8d6e63; --ink-secondary: #8d6e63;
--color-yellow: #fff9c4; --color-yellow: #fff9c4;
@@ -28,22 +28,22 @@
--accent-orange: #ff7043; --accent-orange: #ff7043;
/* MBTI 专属高对比色 */ /* Màu tương phản cao dành cho MBTI */
--mbti-bg: #ede7f6; --mbti-bg: #ede7f6;
--mbti-border: #9575cd; --mbti-border: #9575cd;
--mbti-text: #512da8; --mbti-text: #512da8;
/* 称号专属色 */ /* Màu riêng cho danh hiệu */
--title-bg: #fffde7; --title-bg: #fffde7;
--title-border: #ffb74d; --title-border: #ffb74d;
--title-text: #bf360c; --title-text: #bf360c;
--font-title: 'ZCOOL KuaiLe', cursive; --font-title: 'ZCOOL KuaiLe', cursive;
/* 标题字体 */ /* Font tiêu đề */
--font-hand: 'Patrick Hand', "KaiTi", "STKaiti", serif; --font-hand: 'Patrick Hand', "KaiTi", "STKaiti", serif;
/* 手写体 */ /* Font viết tay */
--font-body: {% if t2i_font_source == 'Mainland' %}'Noto Sans SC', 'Noto Sans TC'{% else %}'Noto Sans TC', 'Noto Sans SC'{% endif %}, sans-serif; --font-body: {% if t2i_font_source == 'Mainland' %}'Noto Sans SC', 'Noto Sans TC'{% else %}'Noto Sans TC', 'Noto Sans SC'{% endif %}, sans-serif;
/* 正文体 */ /* Font nội dung */
} }
* { * {
@@ -164,7 +164,7 @@
font-family: var(--font-body); font-family: var(--font-body);
color: var(--ink-primary); color: var(--ink-primary);
background-color: var(--bg-paper); background-color: var(--bg-paper);
/* 点阵纸背景纹理 */ /* Hoạ tiết nền giấy chấm bi */
background-image: radial-gradient(#ddd 2px, transparent 2px); background-image: radial-gradient(#ddd 2px, transparent 2px);
background-size: 20px 20px; background-size: 20px 20px;
min-height: 100vh; min-height: 100vh;
@@ -172,7 +172,7 @@
line-height: 1.6; line-height: 1.6;
} }
/* SVG 图标通用样式 */ /* Kiểu chung cho biểu tượng SVG */
.doodle { .doodle {
width: 1em; width: 1em;
height: 1em; height: 1em;
@@ -210,7 +210,7 @@
opacity: 0.5; opacity: 0.5;
} }
/* ==================== 1. 头部设计 ==================== */ /* ==================== 1. Thiết kế header ==================== */
.header { .header {
text-align: center; text-align: center;
margin-bottom: 50px; margin-bottom: 50px;
@@ -264,8 +264,8 @@
mask-repeat: repeat-x; mask-repeat: repeat-x;
} }
/* ==================== 2. 统计邮票 ==================== */ /* ==================== 2. Tem thống kê ==================== */
/* ==================== 2. 统计区域 (新版) ==================== */ /* ==================== 2. Vùng thống kê mới ==================== */
.stats-wrapper { .stats-wrapper {
display: flex; display: flex;
gap: 25px; gap: 25px;
@@ -296,7 +296,7 @@
transition: transform 0.2s; transition: transform 0.2s;
} }
/* 装饰性背景纹理 */ /* Hoạ tiết nền trang trí */
.stamp::after { .stamp::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -332,7 +332,7 @@
color: var(--ink-secondary); color: var(--ink-secondary);
} }
/* ==================== 布局容器 ==================== */ /* ==================== Container bố cục ==================== */
.grid-layout { .grid-layout {
display: grid; display: grid;
grid-template-columns: repeat(12, 1fr); grid-template-columns: repeat(12, 1fr);
@@ -353,7 +353,7 @@
font-size: 1.3em; font-size: 1.3em;
} }
/* ==================== 3. 图表区 ==================== */ /* ==================== 3. Vùng biểu đồ ==================== */
.chart-section { .chart-section {
grid-column: span 7; grid-column: span 7;
background: #fff; background: #fff;
@@ -374,7 +374,7 @@
border-left: 2px solid #999; border-left: 2px solid #999;
} }
/* 手绘条形图模拟 */ /* Mô phỏng biểu đồ cột vẽ tay */
.hand-chart-row { .hand-chart-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -450,8 +450,8 @@
text-align: center; text-align: center;
} }
/* ==================== 4. 活跃时段 ==================== */ /* ==================== 4. Khung giờ hoạt động ==================== */
/* ==================== 4. 活跃时段 (新版) ==================== */ /* ==================== 4. Khung giờ hoạt động mới ==================== */
.highlight-section { .highlight-section {
flex: 1; flex: 1;
background: var(--color-yellow); background: var(--color-yellow);
@@ -491,7 +491,7 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
/* ==================== 5. 话题列表 ==================== */ /* ==================== 5. Danh sách chủ đề ==================== */
.topic-section { .topic-section {
grid-column: span 12; grid-column: span 12;
background: #fff; background: #fff;
@@ -522,7 +522,7 @@
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.1);
} }
/* 话题项样式 - 确保在 image_template 中正确定义 */ /* Kiểu mục chủ đề, đồng bộ với image_template */
.topic-item { .topic-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -579,7 +579,7 @@
margin-top: 8px; margin-top: 8px;
} }
/* ==================== 6. 群友画像 ==================== */ /* ==================== 6. Hồ sơ thành viên ==================== */
.user-section { .user-section {
grid-column: span 12; grid-column: span 12;
margin-top: 20px; margin-top: 20px;
@@ -755,7 +755,7 @@
/* Removed big quote mark to clean up */ /* Removed big quote mark to clean up */
} }
/* ==================== 7. 每日回响 ==================== */ /* ==================== 7. Dư âm hằng ngày ==================== */
.quotes-section { .quotes-section {
grid-column: span 12; grid-column: span 12;
display: flex; display: flex;
@@ -1023,7 +1023,7 @@
<body> <body>
<div class="container"> <div class="container">
<!-- 1. 头部 --> <!-- 1. Header -->
<div class="header"> <div class="header">
<div class="title-sticker"> <div class="title-sticker">
<div class="tape"></div> <div class="tape"></div>
@@ -1032,7 +1032,7 @@
</div> </div>
</div> </div>
<!-- 2. 统计邮票和活跃时段 --> <!-- 2. Tem thống kê và khung giờ hoạt động -->
<div class="stats-wrapper"> <div class="stats-wrapper">
<!-- Left: Stats Grid (2x2) --> <!-- Left: Stats Grid (2x2) -->
<div class="stats-grid"> <div class="stats-grid">
@@ -1096,7 +1096,7 @@
<div class="grid-layout"> <div class="grid-layout">
<!-- 3. 24小时活跃图表 --> <!-- 3. Biểu đồ hoạt động 24 giờ -->
<div class="chart-section" style="grid-column: span 12;"> <div class="chart-section" style="grid-column: span 12;">
<div class="coil"></div> <div class="coil"></div>
<div class="section-title"> <div class="section-title">
@@ -4,13 +4,13 @@
{% for item in chart_data %} {% for item in chart_data %}
<div class="sf-chart-col" <div class="sf-chart-col"
style="flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; position: relative;"> style="flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; position: relative;">
<!-- 数值显示 --> <!-- Hiển thị giá trị -->
<div class="sf-chart-val" <div class="sf-chart-val"
style="font-size: 10px; font-weight: bold; color: #b71c1c; margin-bottom: 4px; font-family: sans-serif;"> style="font-size: 10px; font-weight: bold; color: #b71c1c; margin-bottom: 4px; font-family: sans-serif;">
{{ item.count if item.count > 0 else '' }} {{ item.count if item.count > 0 else '' }}
</div> </div>
<!-- 图表柱体 --> <!-- Cột biểu đồ -->
<div class="sf-chart-bar" style=" <div class="sf-chart-bar" style="
width: 70%; width: 70%;
height: {{ item.percentage }}%; height: {{ item.percentage }}%;
@@ -21,7 +21,7 @@
min-height: {{ '2px' if item.count > 0 else '0px' }}; min-height: {{ '2px' if item.count > 0 else '0px' }};
"></div> "></div>
<!-- 刻度文字 --> <!-- Nhãn thang đo -->
<div class="sf-chart-tick" style=" <div class="sf-chart-tick" style="
position: absolute; position: absolute;
bottom: -25px; bottom: -25px;
@@ -39,6 +39,6 @@
{% else %} {% else %}
<div <div
style="display:flex; align-items:center; justify-content:center; height: 200px; width: 100%; color: #888; font-style: italic;"> style="display:flex; align-items:center; justify-content:center; height: 200px; width: 100%; color: #888; font-style: italic;">
🏮 尚无活跃记录 🏮 🏮 Chưa có dữ liệu hoạt động 🏮
</div> </div>
{% endif %} {% endif %}
@@ -11,7 +11,7 @@
} }
.sf-quality-item::before { .sf-quality-item::before {
content: ''; content: 'Phúc';
position: absolute; position: absolute;
top: -15px; top: -15px;
right: -15px; right: -15px;
@@ -6,7 +6,7 @@
<title>Đặc biệt Tết · Báo cáo nhóm hàng ngày</title> <title>Đặc biệt Tết · Báo cáo nhóm hàng ngày</title>
<link rel="preconnect" href="{{ t2i_google_fonts_mirror }}"> <link rel="preconnect" href="{{ t2i_google_fonts_mirror }}">
<link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin> <link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin>
<!-- 引入书法字体和节日字体 --> <!-- Tải font thư pháp và font lễ hội -->
<link <link
href="{{ t2i_google_fonts_mirror }}/css2?family=Ma+Shan+Zheng&family=ZCOOL+XiaoWei&family=Noto+Serif+TC:wght@400;700&family=Noto+Serif+SC:wght@400;700&display=swap" href="{{ t2i_google_fonts_mirror }}/css2?family=Ma+Shan+Zheng&family=ZCOOL+XiaoWei&family=Noto+Serif+TC:wght@400;700&family=Noto+Serif+SC:wght@400;700&display=swap"
rel="stylesheet"> rel="stylesheet">
@@ -18,7 +18,7 @@
--gold-dark: #f9a825; --gold-dark: #f9a825;
--wood-dark: #3e2723; --wood-dark: #3e2723;
--bg-paper: #fff9e6; --bg-paper: #fff9e6;
/* 宣纸色 */ /* Màu giấy tuyên */
--text-main: #3e2723; --text-main: #3e2723;
--text-gold: #ffca28; --text-gold: #ffca28;
@@ -59,7 +59,7 @@
border-radius: 4px; border-radius: 4px;
} }
/* 装饰角标 */ /* Nhãn góc trang trí */
.corner { .corner {
position: absolute; position: absolute;
width: 80px; width: 80px;
@@ -163,7 +163,7 @@
} }
.sf-hongbao::before { .sf-hongbao::before {
content: ''; content: 'Phúc';
position: absolute; position: absolute;
top: -10px; top: -10px;
right: -10px; right: -10px;
@@ -370,7 +370,7 @@
} }
.sf-user-card::before { .sf-user-card::before {
content: ''; content: 'Xuân';
position: absolute; position: absolute;
bottom: 5px; bottom: 5px;
right: 5px; right: 5px;
@@ -17,7 +17,7 @@
--gold-dark: #f9a825; --gold-dark: #f9a825;
--wood-dark: #3e2723; --wood-dark: #3e2723;
--bg-paper: #fff9e6; --bg-paper: #fff9e6;
/* 宣纸色 */ /* Màu giấy tuyên */
--text-main: #3e2723; --text-main: #3e2723;
--text-gold: #ffca28; --text-gold: #ffca28;
@@ -58,7 +58,7 @@
border-radius: 4px; border-radius: 4px;
} }
/* 装饰角标 */ /* Nhãn góc trang trí */
.corner { .corner {
position: absolute; position: absolute;
width: 80px; width: 80px;
@@ -162,7 +162,7 @@
} }
.sf-hongbao::before { .sf-hongbao::before {
content: ''; content: 'Phúc';
position: absolute; position: absolute;
top: -10px; top: -10px;
right: -10px; right: -10px;
@@ -369,7 +369,7 @@
} }
.sf-user-card::before { .sf-user-card::before {
content: ''; content: 'Xuân';
position: absolute; position: absolute;
bottom: 5px; bottom: 5px;
right: 5px; right: 5px;
@@ -10,7 +10,7 @@
<img src="{{ quote.avatar_data }}" alt="{{ quote.sender }}" <img src="{{ quote.avatar_data }}" alt="{{ quote.sender }}"
style="width:100%; height:100%; object-fit:cover;"> style="width:100%; height:100%; object-fit:cover;">
{% else %} {% else %}
<div style="font-family: var(--font-calligraphy); font-size: 2rem; color: var(--red-festive);"></div> <div style="font-family: var(--font-calligraphy); font-size: 2rem; color: var(--red-festive);">Phúc</div>
{% endif %} {% endif %}
</div> </div>
<div class="sf-quote-bubble"> <div class="sf-quote-bubble">
@@ -8,7 +8,7 @@
<img src="{{ title.avatar_data }}" alt="{{ title.name }}" class="sf-avatar"> <img src="{{ title.avatar_data }}" alt="{{ title.name }}" class="sf-avatar">
{% else %} {% else %}
<div class="sf-avatar-placeholder" <div class="sf-avatar-placeholder"
style="font-family: var(--font-calligraphy); font-size: 2.2rem; color: #fff;"></div> style="font-family: var(--font-calligraphy); font-size: 2.2rem; color: #fff;">Phúc</div>
{% endif %} {% endif %}
</div> </div>
<div class="sf-user-info"> <div class="sf-user-info">
+1 -4
View File
@@ -1,7 +1,4 @@
""" """Module lập lịch và tự động hoá, chứa tác vụ định kỳ và phân tích tự động."""
调度和自动化模块
包含定时任务和自动分析功能
"""
from .auto_scheduler import AutoScheduler from .auto_scheduler import AutoScheduler
+249 -200
View File
@@ -1,7 +1,4 @@
""" """Lập lịch phân tích tự động ở chế độ truyền thống và tăng dần."""
自动调度器模块
负责定时任务和自动分析功能支持传统单次分析与增量多次分析两种调度模式
"""
import asyncio import asyncio
import time as time_mod import time as time_mod
@@ -18,7 +15,7 @@ from ..reporting.dispatcher import ReportDispatcher
class AutoScheduler: class AutoScheduler:
"""自动调度器,支持传统模式和增量模式""" """Bộ lập lịch tự động hỗ trợ chế độ truyền thống và tăng dần."""
def __init__( def __init__(
self, self,
@@ -36,7 +33,7 @@ class AutoScheduler:
self.html_render_func = html_render_func self.html_render_func = html_render_func
self.plugin_instance = plugin_instance self.plugin_instance = plugin_instance
# 初始化核心组件 # Khởi tạo các thành phần cốt lõi.
self.message_sender = MessageSender(bot_manager, config_manager) self.message_sender = MessageSender(bot_manager, config_manager)
self.report_dispatcher = ReportDispatcher( self.report_dispatcher = ReportDispatcher(
config_manager, report_generator, self.message_sender config_manager, report_generator, self.message_sender
@@ -44,82 +41,89 @@ class AutoScheduler:
if html_render_func: if html_render_func:
self.report_dispatcher.set_html_render(html_render_func) self.report_dispatcher.set_html_render(html_render_func)
self.scheduler_job_ids = [] # 存储已注册的定时任务 ID self.scheduler_job_ids = [] # Lưu ID của các tác vụ đã đăng ký.
self.last_executed_target = None # 记录上次执行的具体时间点,防止重复执行 self.last_executed_target = None # Ngăn chạy lặp cùng một mốc thời gian.
# Cache: group_id -> group_name (populated lazily) # Cache: group_id -> group_name (populated lazily)
self._group_name_cache: dict[str, str] = {} self._group_name_cache: dict[str, str] = {}
self._terminating = False # 终止标志位 self._terminating = False # Cờ đang dừng plugin.
def set_bot_instance(self, bot_instance): def set_bot_instance(self, bot_instance):
"""设置bot实例(保持向后兼容)""" """Đặt bot instance để giữ tương thích ngược."""
self.bot_manager.set_bot_instance(bot_instance) self.bot_manager.set_bot_instance(bot_instance)
def set_bot_self_ids(self, bot_self_ids): def set_bot_self_ids(self, bot_self_ids):
"""设置bot ID(支持单个ID或ID列表)""" """Đặt một hoặc nhiều ID bot."""
# 确保传入的是列表,保持统一处理 # Chuẩn hoá đầu vào thành danh sách.
if isinstance(bot_self_ids, list): if isinstance(bot_self_ids, list):
self.bot_manager.set_bot_self_ids(bot_self_ids) self.bot_manager.set_bot_self_ids(bot_self_ids)
elif bot_self_ids: elif bot_self_ids:
self.bot_manager.set_bot_self_ids([bot_self_ids]) self.bot_manager.set_bot_self_ids([bot_self_ids])
def set_bot_qq_ids(self, bot_qq_ids): def set_bot_qq_ids(self, bot_qq_ids):
"""设置bot QQ号(已弃用,使用 set_bot_self_ids""" """Đặt ID QQ bot; đã lỗi thời, dùng set_bot_self_ids."""
self.set_bot_self_ids(bot_qq_ids) self.set_bot_self_ids(bot_qq_ids)
async def get_platform_id_for_group(self, group_id): async def get_platform_id_for_group(self, group_id):
"""根据群ID获取对应的平台ID""" """Lấy ID nền tảng tương ứng với ID nhóm."""
try: try:
# 首先检查已注册的bot实例 # Kiểm tra các bot instance đã đăng ký trước.
if ( if (
hasattr(self.bot_manager, "_bot_instances") hasattr(self.bot_manager, "_bot_instances")
and self.bot_manager._bot_instances and self.bot_manager._bot_instances
): ):
# 如果只有一个实例,直接返回 # Trả ngay khi chỉ có một instance.
if self.bot_manager.get_platform_count() == 1: if self.bot_manager.get_platform_count() == 1:
platform_id = self.bot_manager.get_platform_ids()[0] platform_id = self.bot_manager.get_platform_ids()[0]
logger.debug(f"只有一个适配器,使用平台: {platform_id}") logger.debug(f"Chỉ có một adapter, dùng nền tảng: {platform_id}")
return platform_id return platform_id
# 如果有多个实例,尝试通过适配器检查群属于哪个平台 # Khi có nhiều instance, kiểm tra nhóm qua từng adapter.
logger.info(f"检测到多个适配器,正在验证群 {group_id} 属于哪个平台...") logger.info(
f"Phát hiện nhiều adapter, đang xác định nền tảng của nhóm {group_id}..."
)
for platform_id in self.bot_manager.get_platform_ids(): for platform_id in self.bot_manager.get_platform_ids():
try: try:
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
if adapter: if adapter:
# 通过统一接口尝试获取群信息,如果能获取到则说明属于该平台 # Nhóm thuộc nền tảng nếu adapter lấy được thông tin nhóm.
info = await adapter.get_group_info(str(group_id)) info = await adapter.get_group_info(str(group_id))
if info: if info:
logger.info(f"✅ 群 {group_id} 属于平台 {platform_id}") logger.info(
f"✅ Nhóm {group_id} thuộc nền tảng {platform_id}"
)
return platform_id return platform_id
else: else:
logger.debug( logger.debug(
f"平台 {platform_id} 无法获取群 {group_id} 信息" f"Nền tảng {platform_id} không lấy được thông tin nhóm {group_id}"
) )
except Exception as e: except Exception as e:
logger.debug(f"平台 {platform_id} 验证群 {group_id} 失败: {e}") logger.debug(
f"Xác minh nhóm {group_id} trên nền tảng {platform_id} thất bại: {e}"
)
continue continue
# 如果所有适配器都尝试失败,记录错误并返回 None # Không adapter nào xác định được nền tảng.
logger.error( logger.error(
f"无法确定群 {group_id} 属于哪个平台 (已尝试: {list(self.bot_manager._bot_instances.keys())})" f"Không thể xác định nền tảng của nhóm {group_id} "
f"(đã thử: {list(self.bot_manager._bot_instances.keys())})"
) )
return None return None
# 没有任何bot实例,返回None # Chưa có bot instance nào được đăng ký.
logger.error("没有注册的bot实例") logger.error("Chưa đăng ký bot instance nào")
return None return None
except Exception as e: except Exception as e:
logger.error(f"获取平台ID失败: {e}") logger.error(f"Lấy ID nền tảng thất bại: {e}")
return None return None
async def _get_group_name_safe( async def _get_group_name_safe(
self, group_id: str, platform_id: str | None = None self, group_id: str, platform_id: str | None = None
) -> str: ) -> str:
""" """
TraceID 生成解析可读的群名 Lấy tên nhóm dễ đọc để tạo TraceID.
使用内存缓存以避免重复的 API 调用
若名称不可用则回退到 group_id Dùng cache bộ nhớ để tránh gọi API lặp lại fallback về group_id.
""" """
if group_id in self._group_name_cache: if group_id in self._group_name_cache:
return self._group_name_cache[group_id] return self._group_name_cache[group_id]
@@ -139,40 +143,46 @@ class AutoScheduler:
return group_id return group_id
# ================================================================ # ================================================================
# 任务注册与取消 # Đăng ký và huỷ tác vụ
# ================================================================ # ================================================================
def schedule_jobs(self, context): def schedule_jobs(self, context):
"""根据分层名单配置注册定时任务。""" """Đăng ký tác vụ theo cấu hình danh sách phân tầng."""
# 首先清理之前的任务 # Dọn các tác vụ cũ trước.
self.unschedule_jobs(context) self.unschedule_jobs(context)
# unschedule_jobs 会将 _terminating 设为 True (用于关闭场景), # unschedule_jobs đặt _terminating=True cho luồng shutdown;
# schedule_jobs 意味着插件仍在运行;因此需要重置此标志位 # schedule_jobs nghĩa là plugin vẫn chạy nên cần đặt lại cờ.
self._terminating = False self._terminating = False
if not self.config_manager.is_auto_analysis_enabled(): if not self.config_manager.is_auto_analysis_enabled():
logger.info("定时分析名单为空且为白名单模式,不注册定时任务。") logger.info(
"Danh sách phân tích định kỳ trống ở chế độ danh sách trắng; không đăng ký tác vụ."
)
return return
scheduler = context.cron_manager.scheduler scheduler = context.cron_manager.scheduler
# 1. 注册核心报告生成任务(涵盖全量分析与增量总结报告) # 1. Đăng ký tác vụ báo cáo cho cả phân tích đầy đủ và tăng dần.
# 每个配置的时间点都会触发一次解析 # Mỗi mốc thời gian cấu hình sẽ kích hoạt một lần phân giải.
logger.info("注册定时分析报告任务...") logger.info("Đang đăng ký tác vụ báo cáo phân tích định kỳ...")
self._schedule_report_time_jobs(scheduler) self._schedule_report_time_jobs(scheduler)
# 2. 只有在增量功能总开关开启时,才注册全天候的增量提取任务 # 2. Chỉ đăng ký tác vụ trích xuất tăng dần khi tính năng được bật.
if self.config_manager.get_incremental_enabled(): if self.config_manager.get_incremental_enabled():
logger.info("增量分析功能已开启,正在注册全天增量提取任务...") logger.info(
"Phân tích tăng dần đã bật; đang đăng ký tác vụ trích xuất trong ngày..."
)
self._schedule_incremental_cron_jobs(scheduler) self._schedule_incremental_cron_jobs(scheduler)
else: else:
logger.info("增量分析总开关未启用,仅执行传统定时全量分析。") logger.info(
"Phân tích tăng dần chưa bật; chỉ chạy phân tích đầy đủ định kỳ."
)
def _schedule_report_time_jobs(self, scheduler): def _schedule_report_time_jobs(self, scheduler):
"""在配置的时间点注册报告生成任务。 """Đăng ký tác vụ tạo báo cáo tại các mốc đã cấu hình.
这些任务根据运行时解析出的生效模式决定执行传统的全量分析还是增量汇报 Chế độ hiệu lực lúc chạy quyết định phân tích đầy đủ hay báo cáo tăng dần.
""" """
time_config = self.config_manager.get_auto_analysis_time() time_config = self.config_manager.get_auto_analysis_time()
if isinstance(time_config, str): if isinstance(time_config, str):
@@ -194,23 +204,25 @@ class AutoScheduler:
misfire_grace_time=60, misfire_grace_time=60,
) )
self.scheduler_job_ids.append(job_id) self.scheduler_job_ids.append(job_id)
logger.info(f"已注册定时报告任务: {t_str} (Job ID: {job_id})") logger.info(
f"Đã đăng ký tác vụ báo cáo định kỳ: {t_str} (Job ID: {job_id})"
)
except Exception as e: except Exception as e:
logger.error(f"注册定时任务失败 ({t_str}): {e}") logger.error(f"Đăng ký tác vụ định kỳ thất bại ({t_str}): {e}")
def _schedule_incremental_cron_jobs(self, scheduler): def _schedule_incremental_cron_jobs(self, scheduler):
""" """
在活跃时段注册增量分析定时任务 Đăng tác vụ phân tích tăng dần trong khung giờ hoạt động.
这类任务仅执行增量数据的提取而报告生成阶段在配置的每日分析时间点进行 Tác vụ này chỉ trích xuất dữ liệu; báo cáo được tạo tại giờ phân tích hằng ngày.
""" """
active_start_hour = self.config_manager.get_incremental_active_start_hour() active_start_hour = self.config_manager.get_incremental_active_start_hour()
active_end_hour = self.config_manager.get_incremental_active_end_hour() active_end_hour = self.config_manager.get_incremental_active_end_hour()
interval_minutes = self.config_manager.get_incremental_interval_minutes() interval_minutes = self.config_manager.get_incremental_interval_minutes()
max_daily = self.config_manager.get_incremental_max_daily_analyses() max_daily = self.config_manager.get_incremental_max_daily_analyses()
# 计算活跃时段内的触发时间点 # Tính các mốc kích hoạt trong khung giờ hoạt động.
trigger_times = [] trigger_times = []
current_minutes = active_start_hour * 60 current_minutes = active_start_hour * 60
end_minutes = active_end_hour * 60 end_minutes = active_end_hour * 60
@@ -221,7 +233,7 @@ class AutoScheduler:
trigger_times.append((hour, minute)) trigger_times.append((hour, minute))
current_minutes += interval_minutes current_minutes += interval_minutes
# 注册增量分析任务 # Đăng ký tác vụ phân tích tăng dần.
for hour, minute in trigger_times: for hour, minute in trigger_times:
try: try:
trigger = CronTrigger(hour=hour, minute=minute) trigger = CronTrigger(hour=hour, minute=minute)
@@ -236,15 +248,18 @@ class AutoScheduler:
) )
self.scheduler_job_ids.append(job_id) self.scheduler_job_ids.append(job_id)
logger.info( logger.info(
f"已注册增量分析任务: {hour:02d}:{minute:02d} (Job ID: {job_id})" f"Đã đăng ký tác vụ phân tích tăng dần: "
f"{hour:02d}:{minute:02d} (Job ID: {job_id})"
) )
except Exception as e: except Exception as e:
logger.error(f"注册增量分析任务失败 ({hour:02d}:{minute:02d}): {e}") logger.error(
f"Đăng ký tác vụ phân tích tăng dần thất bại ({hour:02d}:{minute:02d}): {e}"
)
logger.info(f"增量调度注册完成: {len(trigger_times)} 个增量分析任务") logger.info(f"Đăng ký lịch tăng dần hoàn tất: {len(trigger_times)} tác vụ")
def unschedule_jobs(self, context): def unschedule_jobs(self, context):
"""取消定时任务""" """Huỷ các tác vụ định kỳ."""
self._terminating = True self._terminating = True
if ( if (
not context not context
@@ -261,33 +276,33 @@ class AutoScheduler:
try: try:
if scheduler.get_job(job_id): if scheduler.get_job(job_id):
scheduler.remove_job(job_id) scheduler.remove_job(job_id)
logger.debug(f"已移除定时任务: {job_id}") logger.debug(f"Đã xoá tác vụ định kỳ: {job_id}")
except Exception as e: except Exception as e:
logger.warning(f"移除定时任务失败 ({job_id}): {e}") logger.warning(f"Xoá tác vụ định kỳ thất bại ({job_id}): {e}")
self.scheduler_job_ids.clear() self.scheduler_job_ids.clear()
# ================================================================ # ================================================================
# 共享辅助方法:解析定时分析目标 # Hàm dùng chung để phân giải mục tiêu phân tích định kỳ
# ================================================================ # ================================================================
async def _get_scheduled_targets( async def _get_scheduled_targets(
self, mode_filter: str | None = None self, mode_filter: str | None = None
) -> list[tuple[str, str, str]]: ) -> list[tuple[str, str, str]]:
""" """
根据分层过滤逻辑判定所有应参与计划分析的目标群组及其分析策略 Xác định nhóm mục tiêu chiến lược theo bộ lọc phân tầng.
判定过程 Quy trình:
1. 准入层群组必须在基础设置的允许名单内 1. Nhóm phải nằm trong danh sách được phép cấu hình sở.
2. 定时层群组需通过定时分析名单的过滤 2. Nhóm phải vượt qua bộ lọc danh sách phân tích định kỳ.
3. 模式层如果群组在增量名单内则使用增量模式否则使用默认策略 3. Nhóm trong danh sách tăng dần dùng chế độ tăng dần, còn lại dùng mặc định.
参数 Args:
mode_filter: 如果提供则只返回匹配指定模式的目标 (traditional incremental) mode_filter: Chỉ trả mục tiêu khớp traditional hoặc incremental.
""" """
# 获取基础信息 # Lấy thông tin cơ sở.
all_groups = await self._get_all_groups() all_groups = await self._get_all_groups()
# 预加载所有配置名单和模式 # Tải trước các danh sách và chế độ.
sched_list = self.config_manager.get_scheduled_group_list() sched_list = self.config_manager.get_scheduled_group_list()
sched_list_mode = self.config_manager.get_scheduled_group_list_mode() sched_list_mode = self.config_manager.get_scheduled_group_list_mode()
@@ -296,70 +311,70 @@ class AutoScheduler:
result = [] result = []
# 遍历所有平台上的群组 # Duyệt nhóm trên mọi nền tảng.
for platform_id, group_id_orig in all_groups: for platform_id, group_id_orig in all_groups:
group_id = str(group_id_orig) group_id = str(group_id_orig)
umo = f"{platform_id}:GroupMessage:{group_id}" umo = f"{platform_id}:GroupMessage:{group_id}"
# 1. 准入层判定 (基础黑白名单) # 1. Lớp truy cập: danh sách đen/trắng cơ sở.
if not self.config_manager.is_group_allowed(umo): if not self.config_manager.is_group_allowed(umo):
continue continue
# 2. 定时层判定 (定时分析黑白名单) # 2. Lớp định kỳ: danh sách đen/trắng phân tích định kỳ.
if not self.config_manager.is_group_in_filtered_list( if not self.config_manager.is_group_in_filtered_list(
umo, sched_list_mode, sched_list umo, sched_list_mode, sched_list
): ):
continue continue
# 3. 模式层判定 (增量黑白名单) # 3. Lớp chế độ: danh sách đen/trắng tăng dần.
# 3. 模式层判定 (增量黑白名单)
if self.config_manager.is_group_in_filtered_list( if self.config_manager.is_group_in_filtered_list(
umo, incr_list_mode, incr_list umo, incr_list_mode, incr_list
): ):
# 如果在增量名单内,则执行增量模式 # Trong danh sách tăng dần.
effective_mode = "incremental" effective_mode = "incremental"
else: else:
# 不在增量名单内,则执行普通模式 # Không trong danh sách tăng dần.
effective_mode = "traditional" effective_mode = "traditional"
# 4. 模式过滤 (如果函数调用者要求过滤) # 4. Lọc chế độ nếu caller yêu cầu.
if mode_filter and effective_mode != mode_filter: if mode_filter and effective_mode != mode_filter:
continue continue
result.append((group_id, platform_id, effective_mode)) result.append((group_id, platform_id, effective_mode))
logger.info( logger.info(
f"分层调度解析完成:符合条件的群组共 {len(result)} " f"Phân giải lịch phân tầng hoàn tất: {len(result)} nhóm hợp lệ"
+ (f" (模式过滤: {mode_filter})" if mode_filter else "") + (f" (bộ lọc chế độ: {mode_filter})" if mode_filter else "")
) )
return result return result
# ================================================================ # ================================================================
# 统一报告调度入口 # Điểm vào thống nhất cho lịch báo cáo
# ================================================================ # ================================================================
async def _run_scheduled_report(self): async def _run_scheduled_report(self):
"""统一的定时分析入口。 """Điểm vào thống nhất cho phân tích định kỳ.
在配置的时间点触发解析所有目标群并根据其分析模式分发任务 Kích hoạt tại giờ cấu hình phân phối theo chế độ:
- traditional: 执行全量拉取分析并发送报告 - traditional: lấy toàn bộ dữ liệu, phân tích gửi báo cáo;
- incremental: 执行增量最终报告阶段合并并汇报 - incremental: hợp nhất dữ liệu tăng dần gửi báo cáo cuối.
""" """
if self._terminating: if self._terminating:
return return
try: try:
logger.info("定时报告触发 — 开始解析调度目标") logger.info("Đã kích hoạt báo cáo định kỳ — bắt đầu phân giải mục tiêu")
all_targets = await self._get_scheduled_targets() all_targets = await self._get_scheduled_targets()
if not all_targets: if not all_targets:
logger.info("没有配置的群聊需要定时分析") logger.info("Không có nhóm nào cần phân tích định kỳ")
return return
max_concurrent = self.config_manager.get_max_concurrent_tasks() max_concurrent = self.config_manager.get_max_concurrent_tasks()
sem = asyncio.Semaphore(max_concurrent) sem = asyncio.Semaphore(max_concurrent)
logger.info( logger.info(
f"定时报告: {len(all_targets)} 个目标 (并发限制: {max_concurrent})" f"Báo cáo định kỳ: {len(all_targets)} mục tiêu "
f"(giới hạn đồng thời: {max_concurrent})"
) )
async def dispatch_group(gid, pid, mode): async def dispatch_group(gid, pid, mode):
@@ -375,13 +390,13 @@ class AutoScheduler:
tasks = [] tasks = []
stagger = self.config_manager.get_stagger_seconds() or 2 stagger = self.config_manager.get_stagger_seconds() or 2
# 针对定时大任务加入交错等待,减少瞬间峰值延迟 # Giãn cách tác vụ lớn để giảm tải đỉnh tức thời.
for idx, (gid, pid, mode) in enumerate(all_targets): for idx, (gid, pid, mode) in enumerate(all_targets):
if self._terminating: if self._terminating:
logger.info("检测到插件正在停止,取消后续任务创建") logger.info("Plugin đang dừng; huỷ tạo các tác vụ tiếp theo")
break break
# 为前几个任务添加微小的启动间隔,均匀分散 API 压力 # Giãn thời điểm khởi động để phân tán tải API.
if idx > 0 and stagger > 0: if idx > 0 and stagger > 0:
await asyncio.sleep(stagger) await asyncio.sleep(stagger)
@@ -393,7 +408,7 @@ class AutoScheduler:
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# 统计结果 # Thống kê kết quả.
success_count = 0 success_count = 0
skip_count = 0 skip_count = 0
error_count = 0 error_count = 0
@@ -403,7 +418,9 @@ class AutoScheduler:
if isinstance(result, DuplicateGroupTaskError): if isinstance(result, DuplicateGroupTaskError):
skip_count += 1 skip_count += 1
elif isinstance(result, Exception): elif isinstance(result, Exception):
logger.error(f"{gid} 定时报告任务异常: {result}") logger.error(
f"Tác vụ báo cáo định kỳ của nhóm {gid} gặp lỗi: {result}"
)
error_count += 1 error_count += 1
elif isinstance(result, dict) and not result.get("success", True): elif isinstance(result, dict) and not result.get("success", True):
skip_count += 1 skip_count += 1
@@ -411,34 +428,35 @@ class AutoScheduler:
success_count += 1 success_count += 1
logger.info( logger.info(
f"定时报告完成 — 成功: {success_count}, 跳过: {skip_count}, " f"Báo cáo định kỳ hoàn tất — thành công: {success_count}, "
f"失败: {error_count}, 总计: {len(all_targets)}" f"bỏ qua: {skip_count}, thất bại: {error_count}, "
f"tổng: {len(all_targets)}"
) )
except Exception as e: except Exception as e:
logger.error(f"定时报告执行失败: {e}", exc_info=True) logger.error(f"Chạy báo cáo định kỳ thất bại: {e}", exc_info=True)
async def _perform_auto_analysis_for_group_with_timeout( async def _perform_auto_analysis_for_group_with_timeout(
self, group_id: str, target_platform_id: str | None = None self, group_id: str, target_platform_id: str | None = None
): ):
"""为指定群执行自动分析(带超时控制)""" """Phân tích tự động một nhóm với giới hạn thời gian."""
try: try:
# 为每个群聊设置独立的超时时间,适当放宽到 30 分钟以支持大型批次 # Mỗi nhóm có timeout 30 phút để hỗ trợ batch lớn.
await asyncio.wait_for( await asyncio.wait_for(
self._perform_auto_analysis_for_group(group_id, target_platform_id), self._perform_auto_analysis_for_group(group_id, target_platform_id),
timeout=1800, timeout=1800,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error(f"{group_id} 分析超时(30分钟),跳过该群分析") logger.error(f"Phân tích nhóm {group_id} quá hạn 30 phút; bỏ qua nhóm")
except Exception as e: except Exception as e:
logger.error(f" {group_id} 分析任务执行失败: {e}") logger.error(f"Tác vụ phân tích nhóm {group_id} thất bại: {e}")
async def _perform_auto_analysis_for_group( async def _perform_auto_analysis_for_group(
self, group_id: str, target_platform_id: str | None = None self, group_id: str, target_platform_id: str | None = None
): ):
"""为指定群执行自动分析(业务逻辑委派给 AnalysisApplicationService""" """Phân tích tự động một nhóm qua AnalysisApplicationService."""
try: try:
# 解析可读群名以生成语义化的 TraceID # Dùng tên nhóm dễ đọc để tạo TraceID có nghĩa.
group_name = await self._get_group_name_safe(group_id, target_platform_id) group_name = await self._get_group_name_safe(group_id, target_platform_id)
trace_id = TraceContext.generate(prefix="group", group_name=group_name) trace_id = TraceContext.generate(prefix="group", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
@@ -447,30 +465,32 @@ class AutoScheduler:
return return
logger.info( logger.info(
f"开始为群 {group_id} 执行自动分析 (Platform: {target_platform_id or 'Auto'})" f"Bắt đầu phân tích tự động nhóm {group_id} "
f"(nền tảng: {target_platform_id or 'Tự động'})"
) )
# 检查平台状态 (BotManager 为基础设施层,用于获取平台就绪状态) # Kiểm tra trạng thái nền tảng qua BotManager.
if not self.bot_manager.is_ready_for_auto_analysis(): if not self.bot_manager.is_ready_for_auto_analysis():
logger.warning(f"{group_id} 自动分析跳过:bot管理器未就绪") logger.warning(
f"Bỏ qua phân tích nhóm {group_id}: trình quản lý bot chưa sẵn sàng"
)
return return
# 委派给应用层服务执行核心用例 # AnalysisApplicationService xử lý use case và khoá nhóm.
# AnalysisApplicationService 内部已处理群锁 (group_lock)
result = await self.analysis_service.execute_daily_analysis( result = await self.analysis_service.execute_daily_analysis(
group_id=group_id, platform_id=target_platform_id, manual=False group_id=group_id, platform_id=target_platform_id, manual=False
) )
if not result.get("success"): if not result.get("success"):
reason = result.get("reason") reason = result.get("reason")
logger.info(f" {group_id} 自动分析跳过: {reason}") logger.info(f"Bỏ qua phân tích tự động nhóm {group_id}: {reason}")
return return
# 获取分析结果及适配器 # Lấy kết quả phân tích và adapter.
analysis_result = result["analysis_result"] analysis_result = result["analysis_result"]
adapter = result["adapter"] adapter = result["adapter"]
# 调度导出并发送报告 # Xuất và gửi báo cáo.
await self.report_dispatcher.dispatch( await self.report_dispatcher.dispatch(
group_id, group_id,
analysis_result, analysis_result,
@@ -479,33 +499,35 @@ class AutoScheduler:
else target_platform_id, else target_platform_id,
) )
logger.info(f" {group_id} 自动分析任务执行成功") logger.info(f"Phân tích tự động nhóm {group_id} thành công")
except DuplicateGroupTaskError: except DuplicateGroupTaskError:
# group_lock 抛出的 DuplicateGroupTaskError 表示任务正在运行,优雅跳过 # DuplicateGroupTaskError nghĩa là tác vụ đang chạy; bỏ qua an toàn.
logger.debug(f"{group_id} 任务因并发锁冲突而跳过(已在运行)") logger.debug(f"Bỏ qua nhóm {group_id} do xung đột khoá đồng thời")
raise # 重新抛出,让上层知道任务并没真正执行而是跳过了 raise # Ném lại để caller biết tác vụ chưa thực sự chạy.
except Exception as e: except Exception as e:
logger.error(f"{group_id} 自动分析执行失败: {e}", exc_info=True) logger.error(
f"Phân tích tự động nhóm {group_id} thất bại: {e}", exc_info=True
)
finally: finally:
logger.debug(f"{group_id} 自动分析流程结束") logger.debug(f"Kết thúc quy trình phân tích tự động nhóm {group_id}")
# ================================================================ # ================================================================
# 增量模式:增量分析 # Chế độ phân tích tăng dần
# ================================================================ # ================================================================
async def _run_incremental_analysis(self): async def _run_incremental_analysis(self):
"""为所有目标模式设定为 incremental 的群执行增量分析任务。""" """Phân tích các nhóm có chế độ mục tiêu là incremental."""
if self._terminating: if self._terminating:
return return
try: try:
logger.info("开始执行自动增量分析(并发模式)") logger.info("Bắt đầu phân tích tăng dần tự động ở chế độ đồng thời")
# 仅选取模式为 incremental 的目标群 # Chỉ chọn nhóm có chế độ incremental.
incr_targets = await self._get_scheduled_targets(mode_filter="incremental") incr_targets = await self._get_scheduled_targets(mode_filter="incremental")
if not incr_targets: if not incr_targets:
logger.info("没有配置为增量模式的群聊需要增量分析") logger.info("Không có nhóm nào được cấu hình cho phân tích tăng dần")
return return
target_list = incr_targets target_list = incr_targets
@@ -513,8 +535,8 @@ class AutoScheduler:
max_concurrent = self.config_manager.get_max_concurrent_tasks() max_concurrent = self.config_manager.get_max_concurrent_tasks()
logger.info( logger.info(
f"将为 {len(target_list)} 个群聊执行增量分析 " f"Sẽ phân tích tăng dần {len(target_list)} nhóm "
f"(并发限制: {max_concurrent}, 交错间隔: {stagger})" f"(giới hạn đồng thời: {max_concurrent}, giãn cách: {stagger} giây)"
) )
sem = asyncio.Semaphore(max_concurrent) sem = asyncio.Semaphore(max_concurrent)
@@ -530,11 +552,11 @@ class AutoScheduler:
) )
) )
# 为调试提供的立即上报选项 # Tuỳ chọn báo cáo ngay phục vụ debug.
if self.config_manager.get_incremental_report_immediately(): if self.config_manager.get_incremental_report_immediately():
if isinstance(result, dict) and result.get("success"): if isinstance(result, dict) and result.get("success"):
logger.info( logger.info(
f"增量分析立即报告模式生效,正在为群 {gid} 生成报告..." f"Chế độ báo cáo ngay đang bật; đang tạo báo cáo cho nhóm {gid}..."
) )
await self._perform_incremental_final_report_for_group_with_timeout( await self._perform_incremental_final_report_for_group_with_timeout(
gid, pid gid, pid
@@ -545,7 +567,9 @@ class AutoScheduler:
analysis_tasks = [] analysis_tasks = []
for idx, (gid, pid, _mode) in enumerate(target_list): for idx, (gid, pid, _mode) in enumerate(target_list):
if self._terminating: if self._terminating:
logger.info("检测到插件正在停止,取消后续增量分析任务创建") logger.info(
"Plugin đang dừng; huỷ tạo các tác vụ tăng dần tiếp theo"
)
break break
task = asyncio.create_task( task = asyncio.create_task(
staggered_incremental(idx, gid, pid), staggered_incremental(idx, gid, pid),
@@ -564,7 +588,9 @@ class AutoScheduler:
if isinstance(result, DuplicateGroupTaskError): if isinstance(result, DuplicateGroupTaskError):
skip_count += 1 skip_count += 1
elif isinstance(result, Exception): elif isinstance(result, Exception):
logger.error(f"{gid} 增量分析任务异常: {result}") logger.error(
f"Tác vụ phân tích tăng dần nhóm {gid} gặp lỗi: {result}"
)
error_count += 1 error_count += 1
elif isinstance(result, dict) and not result.get("success", True): elif isinstance(result, dict) and not result.get("success", True):
skip_count += 1 skip_count += 1
@@ -572,17 +598,18 @@ class AutoScheduler:
success_count += 1 success_count += 1
logger.info( logger.info(
f"增量分析完成 - 成功: {success_count}, 跳过: {skip_count}, " f"Phân tích tăng dần hoàn tất — thành công: {success_count}, "
f"失败: {error_count}, 总计: {len(target_list)}" f"bỏ qua: {skip_count}, thất bại: {error_count}, "
f"tổng: {len(target_list)}"
) )
except Exception as e: except Exception as e:
logger.error(f"增量分析执行失败: {e}", exc_info=True) logger.error(f"Chạy phân tích tăng dần thất bại: {e}", exc_info=True)
async def _perform_incremental_analysis_for_group_with_timeout( async def _perform_incremental_analysis_for_group_with_timeout(
self, group_id: str, target_platform_id: str | None = None self, group_id: str, target_platform_id: str | None = None
): ):
"""为指定群执行增量分析(带超时控制,10分钟)""" """Phân tích tăng dần một nhóm với timeout 10 phút."""
try: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
self._perform_incremental_analysis_for_group( self._perform_incremental_analysis_for_group(
@@ -592,18 +619,18 @@ class AutoScheduler:
) )
return result return result
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error(f"{group_id} 增量分析超时(10分钟),跳过") logger.error(f"Phân tích tăng dần nhóm {group_id} quá hạn 10 phút; bỏ qua")
return {"success": False, "reason": "timeout"} return {"success": False, "reason": "timeout"}
except Exception as e: except Exception as e:
logger.error(f" {group_id} 增量分析任务执行失败: {e}") logger.error(f"Tác vụ phân tích tăng dần nhóm {group_id} thất bại: {e}")
return {"success": False, "reason": str(e)} return {"success": False, "reason": str(e)}
async def _perform_incremental_analysis_for_group( async def _perform_incremental_analysis_for_group(
self, group_id: str, target_platform_id: str | None = None self, group_id: str, target_platform_id: str | None = None
): ):
"""为指定群执行增量分析(业务逻辑委派给 AnalysisApplicationService""" """Phân tích tăng dần một nhóm qua AnalysisApplicationService."""
try: try:
# 解析可读群名以生成语义化的 TraceID # Dùng tên nhóm dễ đọc để tạo TraceID có nghĩa.
group_name = await self._get_group_name_safe(group_id, target_platform_id) group_name = await self._get_group_name_safe(group_id, target_platform_id)
trace_id = TraceContext.generate(prefix="incr", group_name=group_name) trace_id = TraceContext.generate(prefix="incr", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
@@ -612,57 +639,60 @@ class AutoScheduler:
return return
logger.info( logger.info(
f"开始为群 {group_id} 执行增量分析 " f"Bắt đầu phân tích tăng dần nhóm {group_id} "
f"(Platform: {target_platform_id or 'Auto'})" f"(nền tảng: {target_platform_id or 'Tự động'})"
) )
# 检查平台状态 # Kiểm tra trạng thái nền tảng.
if not self.bot_manager.is_ready_for_auto_analysis(): if not self.bot_manager.is_ready_for_auto_analysis():
logger.warning(f"{group_id} 增量分析跳过:bot管理器未就绪") logger.warning(
f"Bỏ qua phân tích tăng dần nhóm {group_id}: trình quản lý bot chưa sẵn sàng"
)
return {"success": False, "reason": "bot_not_ready"} return {"success": False, "reason": "bot_not_ready"}
# 委派给应用层服务执行增量分析用例 # AnalysisApplicationService xử lý use case và khoá nhóm.
# AnalysisApplicationService 内部已处理群锁 (group_lock)
result = await self.analysis_service.execute_incremental_analysis( result = await self.analysis_service.execute_incremental_analysis(
group_id=group_id, platform_id=target_platform_id group_id=group_id, platform_id=target_platform_id
) )
if not result.get("success"): if not result.get("success"):
reason = result.get("reason", "unknown") reason = result.get("reason", "unknown")
logger.info(f" {group_id} 增量分析跳过: {reason}") logger.info(f"Bỏ qua phân tích tăng dần nhóm {group_id}: {reason}")
return result return result
# 增量分析只累积数据,不发送报告 # Phân tích tăng dần chỉ tích luỹ dữ liệu, không gửi báo cáo.
batch_summary = result.get("batch_summary", {}) batch_summary = result.get("batch_summary", {})
logger.info( logger.info(
f" {group_id} 增量分析完成: " f"Phân tích tăng dần nhóm {group_id} hoàn tất: "
f"消息数={result.get('messages_count', 0)}, " f"tin nhắn={result.get('messages_count', 0)}, "
f"话题={batch_summary.get('topics_count', 0)}, " f"chủ đề={batch_summary.get('topics_count', 0)}, "
f"金句={batch_summary.get('quotes_count', 0)}" f"trích dẫn={batch_summary.get('quotes_count', 0)}"
) )
return result return result
except DuplicateGroupTaskError: except DuplicateGroupTaskError:
# group_lock 抛出的 DuplicateGroupTaskError 表示任务正在运行,优雅跳过 # Tác vụ đang chạy; bỏ qua an toàn.
logger.debug(f"{group_id} 增量分析因并发锁冲突而跳过(已在运行)") logger.debug(f"Bỏ qua phân tích tăng dần nhóm {group_id} do xung đột khoá")
return {"success": False, "reason": "already_running"} return {"success": False, "reason": "already_running"}
except Exception as e: except Exception as e:
logger.error(f"{group_id} 增量分析执行失败: {e}", exc_info=True) logger.error(
f"Phân tích tăng dần nhóm {group_id} thất bại: {e}", exc_info=True
)
return {"success": False, "reason": str(e)} return {"success": False, "reason": str(e)}
finally: finally:
logger.debug(f"{group_id} 增量分析流程结束") logger.debug(f"Kết thúc quy trình tăng dần nhóm {group_id}")
# ================================================================ # ================================================================
# 增量最终报告(单群)与回退逻辑 # Báo cáo tăng dần cuối cho một nhóm và logic fallback
# ================================================================ # ================================================================
async def _perform_incremental_final_report_for_group_with_timeout( async def _perform_incremental_final_report_for_group_with_timeout(
self, group_id: str, target_platform_id: str | None = None self, group_id: str, target_platform_id: str | None = None
): ):
"""带超时及回退机制的增量最终报告生成。 """Tạo báo cáo tăng dần cuối với timeout và fallback.
若增量汇报失败 '消息不足' '正在运行' 导致的 Nếu báo cáo thất bại do khác thiếu tin nhắn hoặc đang chạy,
且启用了自动回退则将该群转由传统模式执行全量分析 chuyển sang phân tích đầy đủ khi fallback tự động được bật.
""" """
try: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
@@ -672,15 +702,15 @@ class AutoScheduler:
timeout=1800, timeout=1800,
) )
# 判定是否需要触发回退 (例如:无增量数据等) # Xác định có cần fallback hay không, ví dụ không có dữ liệu tăng dần.
if isinstance(result, dict) and not result.get("success"): if isinstance(result, dict) and not result.get("success"):
reason = result.get("reason", "") reason = result.get("reason", "")
if reason in ("below_threshold", "already_running"): if reason in ("below_threshold", "already_running"):
return result # 正常跳过,无需回退 return result # Bỏ qua bình thường, không cần fallback.
if self.config_manager.get_incremental_fallback_enabled(): if self.config_manager.get_incremental_fallback_enabled():
logger.warning( logger.warning(
f"{group_id} 增量最终报告失败 (reason={reason})" f"Báo cáo tăng dần cuối nhóm {group_id} thất bại "
f"正在回退到传统全量分析..." f"(reason={reason}); đang fallback về phân tích đầy đủ..."
) )
return await self._fallback_to_traditional( return await self._fallback_to_traditional(
group_id, target_platform_id group_id, target_platform_id
@@ -689,27 +719,31 @@ class AutoScheduler:
return result return result
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error(f"{group_id} 最终报告超时(30分钟)") logger.error(f"Báo cáo cuối nhóm {group_id} quá hạn 30 phút")
if self.config_manager.get_incremental_fallback_enabled(): if self.config_manager.get_incremental_fallback_enabled():
logger.warning(f"{group_id} 增量报告超时,正在回退到传统全量分析...") logger.warning(
f"Báo cáo tăng dần nhóm {group_id} quá hạn; đang fallback về phân tích đầy đủ..."
)
return await self._fallback_to_traditional(group_id, target_platform_id) return await self._fallback_to_traditional(group_id, target_platform_id)
return {"success": False, "reason": "timeout"} return {"success": False, "reason": "timeout"}
except Exception as e: except Exception as e:
logger.error(f" {group_id} 最终报告任务执行失败: {e}") logger.error(f"Tác vụ báo cáo cuối nhóm {group_id} thất bại: {e}")
if self.config_manager.get_incremental_fallback_enabled(): if self.config_manager.get_incremental_fallback_enabled():
logger.warning(f"{group_id} 增量报告异常,正在回退到传统全量分析...") logger.warning(
f"Báo cáo tăng dần nhóm {group_id} gặp lỗi; đang fallback về phân tích đầy đủ..."
)
return await self._fallback_to_traditional(group_id, target_platform_id) return await self._fallback_to_traditional(group_id, target_platform_id)
return {"success": False, "reason": str(e)} return {"success": False, "reason": str(e)}
async def _fallback_to_traditional( async def _fallback_to_traditional(
self, group_id: str, target_platform_id: str | None = None self, group_id: str, target_platform_id: str | None = None
): ):
"""回退操作:在增量报告失败时,执行传统的全量拉取分析。""" """Fallback về phân tích đầy đủ khi báo cáo tăng dần thất bại."""
try: try:
logger.info( logger.info(
f"⬆️ {group_id} 回退到传统全量分析 " f"⬆️ Nhóm {group_id} fallback về phân tích đầy đủ "
f"(Platform: {target_platform_id or 'Auto'})" f"(nền tảng: {target_platform_id or 'Tự động'})"
) )
await self._perform_auto_analysis_for_group_with_timeout( await self._perform_auto_analysis_for_group_with_timeout(
group_id, target_platform_id group_id, target_platform_id
@@ -717,7 +751,7 @@ class AutoScheduler:
return {"success": True, "fallback": True} return {"success": True, "fallback": True}
except Exception as fallback_err: except Exception as fallback_err:
logger.error( logger.error(
f"{group_id} 回退传统分析也失败: {fallback_err}", f"Fallback phân tích đầy đủ nhóm {group_id} cũng thất bại: {fallback_err}",
exc_info=True, exc_info=True,
) )
return {"success": False, "reason": f"fallback_failed: {fallback_err}"} return {"success": False, "reason": f"fallback_failed: {fallback_err}"}
@@ -725,9 +759,9 @@ class AutoScheduler:
async def _perform_incremental_final_report_for_group( async def _perform_incremental_final_report_for_group(
self, group_id: str, target_platform_id: str | None = None self, group_id: str, target_platform_id: str | None = None
): ):
"""为指定群生成增量最终报告(业务逻辑委派给 AnalysisApplicationService""" """Tạo báo cáo tăng dần cuối qua AnalysisApplicationService."""
try: try:
# 解析可读群名以生成语义化的 TraceID # Dùng tên nhóm dễ đọc để tạo TraceID có nghĩa.
group_name = await self._get_group_name_safe(group_id, target_platform_id) group_name = await self._get_group_name_safe(group_id, target_platform_id)
trace_id = TraceContext.generate(prefix="report", group_name=group_name) trace_id = TraceContext.generate(prefix="report", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
@@ -736,27 +770,28 @@ class AutoScheduler:
return return
logger.info( logger.info(
f"开始为群 {group_id} 生成增量最终报告 " f"Bắt đầu tạo báo cáo tăng dần cuối cho nhóm {group_id} "
f"(Platform: {target_platform_id or 'Auto'})" f"(nền tảng: {target_platform_id or 'Tự động'})"
) )
# 检查平台状态 # Kiểm tra trạng thái nền tảng.
if not self.bot_manager.is_ready_for_auto_analysis(): if not self.bot_manager.is_ready_for_auto_analysis():
logger.warning(f"{group_id} 最终报告跳过:bot管理器未就绪") logger.warning(
f"Bỏ qua báo cáo cuối nhóm {group_id}: trình quản lý bot chưa sẵn sàng"
)
return {"success": False, "reason": "bot_not_ready"} return {"success": False, "reason": "bot_not_ready"}
# 委派给应用层服务执行最终报告用例 # AnalysisApplicationService xử lý use case và khoá nhóm.
# AnalysisApplicationService 内部已处理群锁 (group_lock)
result = await self.analysis_service.execute_incremental_final_report( result = await self.analysis_service.execute_incremental_final_report(
group_id=group_id, platform_id=target_platform_id group_id=group_id, platform_id=target_platform_id
) )
if not result.get("success"): if not result.get("success"):
reason = result.get("reason", "unknown") reason = result.get("reason", "unknown")
logger.info(f" {group_id} 最终报告跳过: {reason}") logger.info(f"Bỏ qua báo cáo cuối nhóm {group_id}: {reason}")
return result return result
# 获取分析结果及适配器,分发报告 # Lấy kết quả, adapter và phân phối báo cáo.
analysis_result = result["analysis_result"] analysis_result = result["analysis_result"]
adapter = result["adapter"] adapter = result["adapter"]
@@ -768,7 +803,7 @@ class AutoScheduler:
else target_platform_id, else target_platform_id,
) )
# 清理过期批次(保留 2 倍窗口范围的数据作为缓冲) # Dọn batch quá hạn, giữ dữ liệu bằng hai lần cửa sổ làm buffer.
try: try:
analysis_days = self.config_manager.get_analysis_days() analysis_days = self.config_manager.get_analysis_days()
before_ts = time_mod.time() - (analysis_days * 2 * 24 * 3600) before_ts = time_mod.time() - (analysis_days * 2 * 24 * 3600)
@@ -779,70 +814,77 @@ class AutoScheduler:
) )
if cleaned > 0: if cleaned > 0:
logger.info( logger.info(
f" {group_id} 报告发送后清理了 {cleaned} 个过期批次" f"Đã dọn {cleaned} batch quá hạn sau khi gửi báo cáo nhóm {group_id}"
) )
except Exception as cleanup_err: except Exception as cleanup_err:
logger.warning( logger.warning(
f"{group_id} 过期批次清理失败(不影响报告): {cleanup_err}" f"Dọn batch quá hạn của nhóm {group_id} thất bại, "
f"không ảnh hưởng báo cáo: {cleanup_err}"
) )
logger.info(f" {group_id} 增量最终报告发送成功") logger.info(f"Gửi báo cáo tăng dần cuối nhóm {group_id} thành công")
return result return result
except DuplicateGroupTaskError: except DuplicateGroupTaskError:
# group_lock 抛出的 DuplicateGroupTaskError 表示任务正在运行,优雅跳过 # Tác vụ đang chạy; bỏ qua an toàn.
logger.debug(f"{group_id} 最终报告因并发锁冲突而跳过(已在运行)") logger.debug(f"Bỏ qua báo cáo cuối nhóm {group_id} do xung đột khoá")
return {"success": False, "reason": "already_running"} return {"success": False, "reason": "already_running"}
except Exception as e: except Exception as e:
logger.error(f" {group_id} 最终报告执行失败: {e}", exc_info=True) logger.error(f"Báo cáo cuối nhóm {group_id} thất bại: {e}", exc_info=True)
return {"success": False, "reason": str(e)} return {"success": False, "reason": str(e)}
finally: finally:
logger.debug(f"{group_id} 最终报告流程结束") logger.debug(f"Kết thúc quy trình báo cáo cuối nhóm {group_id}")
# ================================================================ # ================================================================
# 群列表获取(基础设施层) # Lấy danh sách nhóm ở tầng infrastructure
# ================================================================ # ================================================================
async def _get_all_groups(self) -> list[tuple[str, str]]: async def _get_all_groups(self) -> list[tuple[str, str]]:
""" """
获取所有bot实例所在的群列表使用 PlatformAdapter Lấy danh sách nhóm của mọi bot instance qua PlatformAdapter.
Returns: Returns:
list[tuple[str, str]]: [(platform_id, group_id), ...] list[tuple[str, str]]: [(platform_id, group_id), ...]
""" """
all_groups = set() all_groups = set()
# 1. [韧性增强] 进入扫描前,尝试最后一次实时发现机器人 # 1. Thử khám phá bot lần cuối trước khi quét.
# 这确保了即使冷启动初始化失败,定时任务触发时仍能刷新状态 # Nhờ đó tác vụ định kỳ vẫn làm mới trạng thái nếu cold start thất bại.
if hasattr(self.bot_manager, "auto_discover_bot_instances"): if hasattr(self.bot_manager, "auto_discover_bot_instances"):
try: try:
await self.bot_manager.auto_discover_bot_instances() await self.bot_manager.auto_discover_bot_instances()
except Exception as e: except Exception as e:
logger.warning(f"[AutoScheduler] 周期性扫描中的平台发现失败: {e}") logger.warning(
f"[AutoScheduler] Khám phá nền tảng khi quét định kỳ thất bại: {e}"
)
bot_ids = list(self.bot_manager._bot_instances.keys()) bot_ids = list(self.bot_manager._bot_instances.keys())
if not bot_ids: if not bot_ids:
logger.warning( logger.warning(
"[AutoScheduler] 分析周期开启,但全局未发现任何在线 Bot。任务将跳过。" "[AutoScheduler] Lịch phân tích đã bật nhưng không có bot online; bỏ qua tác vụ."
) )
return [] return []
logger.info(f"[AutoScheduler] 正在扫描 {len(bot_ids)} 个平台的群聊资源...") logger.info(
f"[AutoScheduler] Đang quét tài nguyên nhóm trên {len(bot_ids)} nền tảng..."
)
for platform_id, bot_instance in self.bot_manager._bot_instances.items(): for platform_id, bot_instance in self.bot_manager._bot_instances.items():
# 检查该平台是否启用了此插件 # Kiểm tra plugin có được bật trên nền tảng này không.
if not self.bot_manager.is_plugin_enabled( if not self.bot_manager.is_plugin_enabled(
platform_id, "astrbot_plugin_qq_group_daily_analysis" platform_id, "astrbot_plugin_qq_group_daily_analysis"
): ):
logger.debug(f"平台 {platform_id} 未启用此插件,跳过获取群列表") logger.debug(
f"Plugin chưa bật trên nền tảng {platform_id}; bỏ qua danh sách nhóm"
)
continue continue
try: try:
# 1. 优先从 BotManager 获取已创建的适配器 # 1. Ưu tiên adapter đã được BotManager tạo.
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
# 2. 如果没有,尝试临时创建(降级方案) # 2. Tạo tạm adapter làm phương án dự phòng.
platform_name = None platform_name = None
if not adapter: if not adapter:
platform_name = self.bot_manager._detect_platform_name(bot_instance) platform_name = self.bot_manager._detect_platform_name(bot_instance)
@@ -856,7 +898,7 @@ class AutoScheduler:
}, },
) )
# 3. 使用适配器获取群列表 # 3. Lấy danh sách nhóm qua adapter.
if adapter: if adapter:
try: try:
groups = await adapter.get_group_list() groups = await adapter.get_group_list()
@@ -866,7 +908,7 @@ class AutoScheduler:
if str(group_id).strip() if str(group_id).strip()
] ]
# 获取平台名称(仅用于日志) # Lấy tên nền tảng chỉ để ghi log.
p_name = None p_name = None
if hasattr(adapter, "get_platform_name"): if hasattr(adapter, "get_platform_name"):
try: try:
@@ -878,17 +920,24 @@ class AutoScheduler:
all_groups.add((platform_id, str(group_id))) all_groups.add((platform_id, str(group_id)))
logger.info( logger.info(
f"平台 {platform_id} ({p_name or 'unknown'}) 成功获取 {len(groups)} 个群组" f"Nền tảng {platform_id} ({p_name or 'không rõ'}) "
f"đã lấy thành công {len(groups)} nhóm"
) )
continue continue
except Exception as e: except Exception as e:
logger.warning(f"适配器 {platform_id} 获取群列表失败: {e}") logger.warning(
f"Adapter {platform_id} lấy danh sách nhóm thất bại: {e}"
)
# 4. 降级:无法通过适配器获取 # 4. Adapter không lấy được danh sách nhóm.
logger.debug(f"平台 {platform_id} 无法通过适配器获取群列表") logger.debug(
f"Nền tảng {platform_id} không lấy được danh sách nhóm qua adapter"
)
except Exception as e: except Exception as e:
logger.error(f"平台 {platform_id} 获取群列表异常: {e}") logger.error(
f"Lấy danh sách nhóm trên nền tảng {platform_id} gặp lỗi: {e}"
)
return list(all_groups) return list(all_groups)
+26 -27
View File
@@ -1,19 +1,19 @@
"""安全模板渲染工具(String Template 兼容)""" """Công cụ render template an toàn, tương thích String Template."""
import re import re
from string import Template from string import Template
from ...utils.logger import logger from ...utils.logger import logger
# 统一默认 placeholder # Placeholder mặc định dùng chung
PLACEHOLDERS = { PLACEHOLDERS = {
# 分析类核心变量 # Biến cốt lõi của phân tích
"messages_text": "${messages_text}", "messages_text": "${messages_text}",
"reviews_text": "${reviews_text}", "reviews_text": "${reviews_text}",
"max_topics": "${max_topics}", "max_topics": "${max_topics}",
"users_text": "${users_text}", "users_text": "${users_text}",
"max_golden_quotes": "${max_golden_quotes}", "max_golden_quotes": "${max_golden_quotes}",
# 文件名渲染类变量 # Biến dùng khi render tên tệp
"group_id": "${group_id}", "group_id": "${group_id}",
"date": "${date}", "date": "${date}",
"ulid": "${ulid}", "ulid": "${ulid}",
@@ -21,32 +21,30 @@ PLACEHOLDERS = {
def is_str_format_template(template: str) -> bool: def is_str_format_template(template: str) -> bool:
"""判断模板是否为 str.format 风格。 """Kiểm tra template có dùng cú pháp str.format hay không.
只认为满足 Chỉ coi str.format khi không chứa placeholder String Template
1) 不包含 String Template `${var}` `$var` `${var}` hoặc `$var`, đồng thời `{var}` nhưng không phải `{{...}}`.
2) 包含 str.format `{var}` `{{...}}`
""" """
if not template: if not template:
return False return False
# 1. 預先建立排除模式 (匹配 ${var} $var) # 1. Tạo pattern loại trừ cho ${var} hoặc $var.
# 使用 set 去重並組合
dollar_patterns = [re.escape(v) for v in PLACEHOLDERS.values()] + [ dollar_patterns = [re.escape(v) for v in PLACEHOLDERS.values()] + [
rf"\${re.escape(k)}" for k in PLACEHOLDERS.keys() rf"\${re.escape(k)}" for k in PLACEHOLDERS.keys()
] ]
exclude_regex = "|".join(dollar_patterns) exclude_regex = "|".join(dollar_patterns)
# 如果包含任何 $ 相關的佔位符,則不視為 str.format 模板 # Có placeholder liên quan đến $ thì không coi là template str.format.
if re.search(exclude_regex, template): if re.search(exclude_regex, template):
return False return False
# 2. 檢查是否包含標準的 {key},確保匹配單個花括號包裹的 Key # 2. Kiểm tra {key} chuẩn trong một cặp ngoặc đơn.
for key in PLACEHOLDERS.keys(): for key in PLACEHOLDERS.keys():
# (?<!\{) 前面不能有 { # (?<!\{): phía trước không được là {
# \{{key}\} 匹配 {key} # \{{key}\}: khớp {key}
# (?!\}) 後面不能有 } # (?!\}): phía sau không được là }
# (?<!\$) 前面不能有 $ # (?<!\$): phía trước không được là $
pattern = rf"(?<![\{{\$])\{{{key}\}}(?!\}})" pattern = rf"(?<![\{{\$])\{{{key}\}}(?!\}})"
if re.search(pattern, template): if re.search(pattern, template):
return True return True
@@ -54,9 +52,9 @@ def is_str_format_template(template: str) -> bool:
def upgrade_str_format_template(template: str) -> tuple[str, bool]: def upgrade_str_format_template(template: str) -> tuple[str, bool]:
"""如果模板是 str.format 风格,则自动升级为 string.Template """Tự nâng cấp template str.format sang string.Template.
返回 (升级后的模板, 是否升级) Trả về tuple gồm template sau nâng cấp trạng thái đã nâng cấp.
""" """
if template is None: if template is None:
return "", False return "", False
@@ -64,31 +62,32 @@ def upgrade_str_format_template(template: str) -> tuple[str, bool]:
if not is_str_format_template(template): if not is_str_format_template(template):
return template, False return template, False
# 先转义原文中的 $,避免被 Template 误解释为占位符 # Escape $ trong văn bản gốc để Template không hiểu nhầm là placeholder.
safe_template = template.replace("$", "$$") safe_template = template.replace("$", "$$")
# {var} 转为 ${var} # Chuyển {var} thành ${var}.
safe_template = re.sub( safe_template = re.sub(
r"(?<![\{\$])\{([_a-zA-Z][_a-zA-Z0-9]*)\}(?!\})", r"(?<![\{\$])\{([_a-zA-Z][_a-zA-Z0-9]*)\}(?!\})",
lambda m: f"${{{m.group(1)}}}", lambda m: f"${{{m.group(1)}}}",
safe_template, safe_template,
) )
# 将双括号回退为单括号(str.format 里表示字面量大括号) # Chuyển ngoặc kép về ngoặc đơn (ngoặc literal trong str.format).
safe_template = safe_template.replace("{{", "{").replace("}}", "}") safe_template = safe_template.replace("{{", "{").replace("}}", "}")
return safe_template, True return safe_template, True
def render_template(template: str, strict: bool = False, **kwargs) -> str: def render_template(template: str, strict: bool = False, **kwargs) -> str:
"""渲染模板(String Template)。 """Render template bằng String Template.
Args: Args:
template: 模板字符串 template: Chuỗi template.
strict: 是否使用严格模式变量缺失则抛出异常 strict: dùng strict mode hay không; thiếu biến sẽ phát sinh lỗi.
**kwargs: 渲染变量 **kwargs: Biến dùng để render.
由于插件启动时已完成 str.format 兼容升级运行时直接按 string.Template 渲染 Plugin nâng cấp tương thích str.format khi khởi động nên runtime render
trực tiếp bằng string.Template.
""" """
if template is None: if template is None:
return "" return ""
@@ -100,7 +99,7 @@ def render_template(template: str, strict: bool = False, **kwargs) -> str:
if strict: if strict:
raise raise
logger.warning( logger.warning(
f"[template_utils] 模板渲染失败,返回原始文本,错误: {e}", f"[template_utils] Render template thất bại, trả văn bản gốc; lỗi: {e}",
exc_info=True, exc_info=True,
) )
return template return template
+1 -3
View File
@@ -1,6 +1,4 @@
""" """Module trực quan hoá."""
可视化模块
"""
from .activity_charts import ActivityVisualizer from .activity_charts import ActivityVisualizer
@@ -1,7 +1,4 @@
""" """Module trực quan hoá mức độ hoạt động của nhóm."""
群聊活跃度可视化模块
参考 astrbot_plugin_github_analyzer 的实现方式
"""
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
@@ -11,7 +8,7 @@ from ...domain.repositories.visualization_repository import IActivityVisualizer
class ActivityVisualizer(IActivityVisualizer): class ActivityVisualizer(IActivityVisualizer):
"""活跃度可视化器""" """Trình trực quan hoá mức độ hoạt động."""
def __init__(self): def __init__(self):
pass pass
@@ -19,32 +16,32 @@ class ActivityVisualizer(IActivityVisualizer):
def generate_activity_visualization( def generate_activity_visualization(
self, messages: list[dict] self, messages: list[dict]
) -> ActivityVisualization: ) -> ActivityVisualization:
"""生成活跃度可视化数据 - 专注于小时级别分析""" """Tạo dữ liệu trực quan hoạt động theo giờ."""
hourly_activity = defaultdict(int) hourly_activity = defaultdict(int)
user_activity = defaultdict(int) user_activity = defaultdict(int)
emoji_activity = defaultdict(int) # 每小时表情统计 emoji_activity = defaultdict(int) # Thống kê biểu cảm theo giờ.
# 分析消息数据 # Phân tích dữ liệu tin nhắn.
for msg in messages: for msg in messages:
# 时间分析 - 只关注小时 # Chỉ phân tích theo giờ.
msg_time = datetime.fromtimestamp(msg.get("time", 0)) msg_time = datetime.fromtimestamp(msg.get("time", 0))
hour = msg_time.hour hour = msg_time.hour
# # 用户分析 # # Phân tích thành viên.
# sender = msg.get("sender", {}) # sender = msg.get("sender", {})
# user_id = str(sender.get("user_id", "")) # user_id = str(sender.get("user_id", ""))
# nickname = InfoUtils.get_user_nickname(self.config_manager, sender) # nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
# 统计每小时消息数 # Đếm tin nhắn theo giờ.
hourly_activity[hour] += 1 hourly_activity[hour] += 1
# # 统计用户活跃度 # # Thống kê hoạt động thành viên.
# user_activity[user_id] = { # user_activity[user_id] = {
# "nickname": nickname, # "nickname": nickname,
# "count": user_activity.get(user_id, {}).get("count", 0) + 1 # "count": user_activity.get(user_id, {}).get("count", 0) + 1
# } # }
# 统计每小时表情数 # Đếm biểu cảm theo giờ.
for content in msg.get("message", []): for content in msg.get("message", []):
if content.get("type") in ["face", "mface", "bface", "sface"]: if content.get("type") in ["face", "mface", "bface", "sface"]:
emoji_activity[hour] += 1 emoji_activity[hour] += 1
@@ -54,7 +51,7 @@ class ActivityVisualizer(IActivityVisualizer):
if "动画表情" in summary or "表情" in summary: if "动画表情" in summary or "表情" in summary:
emoji_activity[hour] += 1 emoji_activity[hour] += 1
# 生成用户活跃度排行 # Tạo bảng xếp hạng hoạt động thành viên.
user_ranking = [] user_ranking = []
for user_id, data in user_activity.items(): for user_id, data in user_activity.items():
user_ranking.append( user_ranking.append(
@@ -66,7 +63,7 @@ class ActivityVisualizer(IActivityVisualizer):
) )
user_ranking.sort(key=lambda x: x["message_count"], reverse=True) user_ranking.sort(key=lambda x: x["message_count"], reverse=True)
# 找出高峰时段(活跃度最高的3个小时) # Tìm ba khung giờ hoạt động cao nhất.
peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[ peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[
:3 :3
] ]
@@ -74,8 +71,8 @@ class ActivityVisualizer(IActivityVisualizer):
return ActivityVisualization( return ActivityVisualization(
hourly_activity=dict(hourly_activity), hourly_activity=dict(hourly_activity),
daily_activity={}, # 不使用日期分析 daily_activity={}, # Không phân tích theo ngày.
user_activity_ranking=user_ranking[:10], # 10 user_activity_ranking=user_ranking[:10], # Top 10.
peak_hours=peak_hours, peak_hours=peak_hours,
activity_heatmap_data=self._generate_hourly_heatmap_data( activity_heatmap_data=self._generate_hourly_heatmap_data(
hourly_activity, emoji_activity hourly_activity, emoji_activity
@@ -85,8 +82,8 @@ class ActivityVisualizer(IActivityVisualizer):
def _generate_hourly_heatmap_data( def _generate_hourly_heatmap_data(
self, hourly_activity: dict, emoji_activity: dict self, hourly_activity: dict, emoji_activity: dict
) -> dict: ) -> dict:
"""生成小时级热力图数据""" """Tạo dữ liệu heatmap theo giờ."""
# 计算活跃度等级 # Tính cấp độ hoạt động.
max_hourly = max(hourly_activity.values()) if hourly_activity else 1 max_hourly = max(hourly_activity.values()) if hourly_activity else 1
max_emoji = max(emoji_activity.values()) if emoji_activity else 1 max_emoji = max(emoji_activity.values()) if emoji_activity else 1
@@ -105,7 +102,7 @@ class ActivityVisualizer(IActivityVisualizer):
} }
def _calculate_activity_levels(self, hourly_activity: dict) -> dict: def _calculate_activity_levels(self, hourly_activity: dict) -> dict:
"""计算活跃度等级""" """Tính cấp độ hoạt động."""
if not hourly_activity: if not hourly_activity:
return {} return {}
@@ -127,7 +124,7 @@ class ActivityVisualizer(IActivityVisualizer):
return levels return levels
def get_hourly_chart_data(self, hourly_activity: dict) -> list[dict]: def get_hourly_chart_data(self, hourly_activity: dict) -> list[dict]:
"""生成每小时活动分布的数据""" """Tạo dữ liệu phân bố hoạt động theo giờ."""
chart_data = [] chart_data = []
max_activity = max(hourly_activity.values()) if hourly_activity else 1 max_activity = max(hourly_activity.values()) if hourly_activity else 1
+1 -1
View File
@@ -207,7 +207,7 @@ class TraceContext:
str: Chuỗi Caption của báo cáo. str: Chuỗi Caption của báo cáo.
""" """
ts = datetime.now().strftime("%m-%d %H:%M:%S") ts = datetime.now().strftime("%m-%d %H:%M:%S")
return f"📊 每日群聊分析报告已生成 | {ts}" return f"📊 Báo cáo phân tích nhóm hằng ngày đã được tạo | {ts}"
@classmethod @classmethod
def set(cls, trace_id: str) -> None: def set(cls, trace_id: str) -> None:
+1 -4
View File
@@ -1,6 +1,3 @@
""" """Module chứa các hàm tiện ích dùng chung."""
工具函数模块
包含通用工具函数
"""
__all__ = [] __all__ = []
+6 -6
View File
@@ -5,14 +5,14 @@ from ..shared.trace_context import TraceContext
class PluginLogger: class PluginLogger:
""" """
日志代理类插件级统一日志装饰器 Proxy logger thống nhất cấp plugin.
自动向所有通过该实例输出的日志信息前缀添加 `[群分析插件]` 标签 Tự động thêm tiền tố ``[Phân tích nhóm]`` để dễ nhận diện log của plugin
以便用户在 AstrBot 混合日志流中快速定位属于本插件的输出 trong luồng log hỗn hợp của AstrBot. Không kế thừa trực tiếp
不直接继承 logging.LoggerAdapter 以符合框架规范 ``logging.LoggerAdapter`` để phù hợp quy chuẩn framework.
""" """
def __init__(self, prefix: str = "[群分析插件]"): def __init__(self, prefix: str = "[Phân tích nhóm]"):
self.prefix = prefix self.prefix = prefix
def _format_msg(self, msg: str) -> str: def _format_msg(self, msg: str) -> str:
@@ -40,5 +40,5 @@ class PluginLogger:
astrbot_logger.exception(self._format_msg(msg), *args, **kwargs) astrbot_logger.exception(self._format_msg(msg), *args, **kwargs)
# 导出带前缀的 logger # Export logger có tiền tố.
logger = PluginLogger() logger = PluginLogger()
+33 -35
View File
@@ -6,15 +6,15 @@ from .logger import logger
class CircuitBreaker: class CircuitBreaker:
""" """
韧性设计熔断器 (Circuit Breaker) Circuit breaker cho khả năng phục hồi hệ thống.
用于监控外部服务 LLM API的调用状态当错误率达到阈值时自动开启熔断 Theo dõi lời gọi dịch vụ ngoài như API LLM. Khi lỗi đạt ngưỡng, tự mở
拦截对故障服务的进一步请求保护系统不被连锁故障拖累直到服务窗口恢复 mạch để chặn yêu cầu tiếp theo tránh lỗi dây chuyền cho đến khi hồi phục.
States: States:
CLOSED: 正常工作状态允许请求 CLOSED: Hoạt động bình thường, cho phép yêu cầu.
OPEN: 熔断状态拒绝请求 OPEN: Mạch mở, từ chối yêu cầu.
HALF_OPEN: 尝试恢复状态允许少量测试请求 HALF_OPEN: Thử hồi phục, cho phép một số yêu cầu kiểm tra.
""" """
STATE_CLOSED = "CLOSED" STATE_CLOSED = "CLOSED"
@@ -28,12 +28,12 @@ class CircuitBreaker:
name: str = "default", name: str = "default",
): ):
""" """
初始化熔断器 Khởi tạo circuit breaker.
Args: Args:
failure_threshold (int): 连续失败触发熔断的次数上限 failure_threshold: Số lỗi liên tiếp để mở mạch.
recovery_timeout (int): 熔断开启后尝试恢复之前的冷却时间 recovery_timeout: Thời gian chờ trước khi thử hồi phục, tính bằng giây.
name (str): 熔断器标识符用于日志区分 name: Định danh circuit breaker dùng trong log.
""" """
self.name = name self.name = name
self.failure_threshold = failure_threshold self.failure_threshold = failure_threshold
@@ -44,7 +44,7 @@ class CircuitBreaker:
self.last_failure_time = 0.0 self.last_failure_time = 0.0
def record_failure(self) -> None: def record_failure(self) -> None:
"""记录一次调用失败,并根据阈值决定是否切换到 OPEN 状态。""" """Ghi nhận một lỗi và mở mạch nếu đạt ngưỡng."""
self.failure_count += 1 self.failure_count += 1
if ( if (
self.state == self.STATE_CLOSED self.state == self.STATE_CLOSED
@@ -52,26 +52,26 @@ class CircuitBreaker:
): ):
self._open_circuit() self._open_circuit()
elif self.state == self.STATE_HALF_OPEN: elif self.state == self.STATE_HALF_OPEN:
# 半开状态下任何一次失败都将立即导致熔断重开 # Một lỗi trong trạng thái half-open sẽ mở mạch ngay.
self._open_circuit() self._open_circuit()
def record_success(self) -> None: def record_success(self) -> None:
"""记录一次调用成功,并尝试重置或关闭熔断器。""" """Ghi nhận thành công và thử reset hoặc đóng mạch."""
if self.state == self.STATE_HALF_OPEN: if self.state == self.STATE_HALF_OPEN:
self._close_circuit() self._close_circuit()
elif self.state == self.STATE_CLOSED: elif self.state == self.STATE_CLOSED:
# 正常状态下的成功重置累积计数值 # Thành công khi mạch đóng sẽ reset bộ đếm lỗi.
self.failure_count = 0 self.failure_count = 0
def allow_request(self) -> bool: def allow_request(self) -> bool:
""" """
判断是否允许本次服务请求 Kiểm tra cho phép yêu cầu dịch vụ này hay không.
Returns: Returns:
bool: True 为允许False 为拦截 True nếu cho phép, False nếu chặn.
""" """
if self.state == self.STATE_OPEN: if self.state == self.STATE_OPEN:
# 检查冷却时间是否已过,过则进入试探性的半开状态 # Chuyển sang half-open nếu đã hết thời gian chờ.
if time.monotonic() - self.last_failure_time > self.recovery_timeout: if time.monotonic() - self.last_failure_time > self.recovery_timeout:
self._half_open_circuit() self._half_open_circuit()
return True return True
@@ -79,33 +79,31 @@ class CircuitBreaker:
return True return True
def _open_circuit(self) -> None: def _open_circuit(self) -> None:
"""动作:开启熔断""" """Mở circuit breaker."""
self.state = self.STATE_OPEN self.state = self.STATE_OPEN
self.last_failure_time = time.monotonic() self.last_failure_time = time.monotonic()
logger.warning( logger.warning(
f"熔断器 CircuitBreaker[{self.name}] 已激活!将拦截请求 {self.recovery_timeout} 秒。" f"CircuitBreaker[{self.name}] đã mở; chặn yêu cầu trong {self.recovery_timeout} giây."
) )
def _close_circuit(self) -> None: def _close_circuit(self) -> None:
"""动作:关闭熔断,恢复常态""" """Đóng circuit breaker và trở về trạng thái bình thường."""
self.state = self.STATE_CLOSED self.state = self.STATE_CLOSED
self.failure_count = 0 self.failure_count = 0
logger.info(f"熔断器 CircuitBreaker[{self.name}] 已恢复至关闭 (CLOSED) 状态。") logger.info(f"CircuitBreaker[{self.name}] đã trở về trạng thái CLOSED.")
def _half_open_circuit(self) -> None: def _half_open_circuit(self) -> None:
"""动作:进入半开状态""" """Chuyển circuit breaker sang trạng thái half-open."""
self.state = self.STATE_HALF_OPEN self.state = self.STATE_HALF_OPEN
logger.info( logger.info(f"CircuitBreaker[{self.name}] đã vào chế độ kiểm tra HALF_OPEN.")
f"熔断器 CircuitBreaker[{self.name}] 进入半开 (HALF_OPEN) 测试模式。"
)
class GlobalRateLimiter: class GlobalRateLimiter:
""" """
韧性设计全局并发动态限流器 Bộ giới hạn đồng thời động toàn cục.
基于单例模式管理 asyncio.Semaphore确保在插件内的异步任务 Quản ``asyncio.Semaphore`` theo singleton để tác vụ bất đồng bộ không
不会超过设定的最大并发限制如保护 LLM 账单或避免 API 拥塞 vượt giới hạn, giúp kiểm soát chi phí LLM tránh nghẽn API.
""" """
_instance: "GlobalRateLimiter | None" = None _instance: "GlobalRateLimiter | None" = None
@@ -119,24 +117,24 @@ class GlobalRateLimiter:
@classmethod @classmethod
def get_instance(cls, max_concurrency: int | None = None) -> "GlobalRateLimiter": def get_instance(cls, max_concurrency: int | None = None) -> "GlobalRateLimiter":
""" """
获取或创建限流器单例 Lấy hoặc tạo singleton rate limiter.
Args: Args:
max_concurrency (int, optional): 允许的最大并发数如果提供且与当前不同则重置信号量 max_concurrency: Số tác vụ đồng thời tối đa; thay đổi sẽ reset semaphore.
Returns: Returns:
GlobalRateLimiter: 唯一实例 Instance GlobalRateLimiter duy nhất.
""" """
instance = cls() instance = cls()
if max_concurrency is not None: if max_concurrency is not None:
instance.reconfigure(max_concurrency) instance.reconfigure(max_concurrency)
elif cls._semaphore is None: elif cls._semaphore is None:
# 默认兜底 # Giá trị fallback mặc định.
cls._semaphore = asyncio.Semaphore(3) cls._semaphore = asyncio.Semaphore(3)
return instance return instance
def reconfigure(self, max_concurrency: int): def reconfigure(self, max_concurrency: int):
"""重新配置并发上限。注意:这会替换信号量对象。""" """Cấu hình lại giới hạn đồng thời và thay thế semaphore."""
if self._semaphore is None or ( if self._semaphore is None or (
hasattr(self._semaphore, "_value") hasattr(self._semaphore, "_value")
and self._semaphore._value != max_concurrency # type: ignore and self._semaphore._value != max_concurrency # type: ignore
@@ -147,13 +145,13 @@ class GlobalRateLimiter:
else "None" else "None"
) )
logger.info( logger.info(
f"GlobalRateLimiter 重新配置并发上限:{old_val} -> {max_concurrency}" f"GlobalRateLimiter đổi giới hạn đồng thời: {old_val} -> {max_concurrency}"
) )
self.__class__._semaphore = asyncio.Semaphore(max_concurrency) self.__class__._semaphore = asyncio.Semaphore(max_concurrency)
@property @property
def semaphore(self) -> asyncio.Semaphore: def semaphore(self) -> asyncio.Semaphore:
"""返回核心的异步信号量对象。""" """Trả về semaphore bất đồng bộ cốt lõi."""
if self._semaphore is None: if self._semaphore is None:
self.__class__._semaphore = asyncio.Semaphore(3) self.__class__._semaphore = asyncio.Semaphore(3)
assert self._semaphore is not None assert self._semaphore is not None
+1 -1
View File
@@ -505,7 +505,7 @@ def test_qq_official_avatar_mentions_hide_placeholder_openid():
rendered_text = str(rendered) rendered_text = str(rendered)
assert "A_OPENID" not in rendered_text assert "A_OPENID" not in rendered_text
assert "群友" in rendered_text assert "Thành viên nhóm" in rendered_text
def test_mentions_support_alphanumeric_openid_and_hide_text(): def test_mentions_support_alphanumeric_openid_and_hide_text():
+1 -1
View File
@@ -113,7 +113,7 @@ def test_new_qq_official_message_replaces_mentions_before_storage():
stored_parts = history_manager.calls[0]["content"]["message"] stored_parts = history_manager.calls[0]["content"]["message"]
assert stored_parts == [ assert stored_parts == [
{"type": "plain", "text": "请问 @随风潜入夜 和 @群友 怎么看"} {"type": "plain", "text": "请问 @随风潜入夜 和 @Thành viên 怎么看"}
] ]
assert "KNOWN_OPENID" not in stored_parts[0]["text"] assert "KNOWN_OPENID" not in stored_parts[0]["text"]
assert "UNKNOWN_OPENID" not in stored_parts[0]["text"] assert "UNKNOWN_OPENID" not in stored_parts[0]["text"]
+1 -1
View File
@@ -119,7 +119,7 @@ def test_local_history_sender_name_uses_group_scoped_alias_for_placeholder():
assert empty_name_message is not None assert empty_name_message is not None
assert openid_name_message is not None assert openid_name_message is not None
assert other_group_message is not None assert other_group_message is not None
assert empty_name_message.sender_name.startswith("群友-") assert empty_name_message.sender_name.startswith("Thành viên-")
assert "A_OPENID" not in empty_name_message.sender_name assert "A_OPENID" not in empty_name_message.sender_name
assert openid_name_message.sender_name == empty_name_message.sender_name assert openid_name_message.sender_name == empty_name_message.sender_name
assert other_group_message.sender_name != empty_name_message.sender_name assert other_group_message.sender_name != empty_name_message.sender_name