mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 05:31:52 +00:00
feat: update translate
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
群日常分析插件
|
||||
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
|
||||
Plugin phân tích hoạt động nhóm hằng ngày.
|
||||
|
||||
重构版本 - 使用模块化架构,支持跨平台
|
||||
Tạo báo cáo từ lịch sử trò chuyện, gồm tóm tắt chủ đề, hồ sơ thành viên và
|
||||
thống kê. Phiên bản module hoá hỗ trợ đa nền tảng.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -55,9 +55,9 @@ from .src.utils.resilience import GlobalRateLimiter
|
||||
|
||||
|
||||
class GroupDailyAnalysis(Star):
|
||||
"""群分析插件主类"""
|
||||
"""Lớp plugin phân tích nhóm chính."""
|
||||
|
||||
# ── 显式类型声明 (由 __init__ 初始化) ──
|
||||
# ── Khai báo kiểu tường minh, được khởi tạo trong __init__ ──
|
||||
config: AstrBotConfig
|
||||
config_manager: ConfigManager
|
||||
bot_manager: BotManager
|
||||
@@ -82,7 +82,7 @@ class GroupDailyAnalysis(Star):
|
||||
super().__init__(context)
|
||||
self.config = config
|
||||
|
||||
# 1. 基础设施层
|
||||
# 1. Tầng infrastructure.
|
||||
self.config_manager = ConfigManager(config)
|
||||
self.bot_manager = BotManager(self.config_manager)
|
||||
self.bot_manager.set_context(context)
|
||||
@@ -93,22 +93,22 @@ class GroupDailyAnalysis(Star):
|
||||
|
||||
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
|
||||
|
||||
# Telegram 注册表 (持久层)
|
||||
# Registry Telegram ở tầng persistence.
|
||||
self.platform_group_registry = PlatformGroupRegistry(self)
|
||||
|
||||
# 2. 领域层
|
||||
# 2. Tầng domain.
|
||||
activity_visualizer = ActivityVisualizer()
|
||||
self.statistics_service = StatisticsService(activity_visualizer)
|
||||
self.analysis_domain_service = AnalysisDomainService()
|
||||
|
||||
# 3. 分析核心 (LLM Bridge)
|
||||
# 3. Lõi phân tích, cầu nối LLM.
|
||||
self.llm_analyzer = LLMAnalyzer(context, self.config_manager)
|
||||
|
||||
# 4. 增量分析组件
|
||||
# 4. Thành phần phân tích gia tăng.
|
||||
self.incremental_store = IncrementalStore(self)
|
||||
self.incremental_merge_service = IncrementalMergeService()
|
||||
|
||||
# 5. 应用层
|
||||
# 5. Tầng application.
|
||||
self.analysis_service = AnalysisApplicationService(
|
||||
self.config_manager,
|
||||
self.bot_manager,
|
||||
@@ -121,7 +121,7 @@ class GroupDailyAnalysis(Star):
|
||||
incremental_merge_service=self.incremental_merge_service,
|
||||
)
|
||||
|
||||
# 消息处理服务
|
||||
# Dịch vụ xử lý tin nhắn.
|
||||
self.message_processing_service = MessageProcessingService(
|
||||
context, self.platform_group_registry
|
||||
)
|
||||
@@ -136,7 +136,7 @@ class GroupDailyAnalysis(Star):
|
||||
handlers=[self.telegram_template_preview_handler]
|
||||
)
|
||||
|
||||
# 调度与发送
|
||||
# Lập lịch và gửi.
|
||||
self.message_sender = MessageSender(self.bot_manager, self.config_manager)
|
||||
self.auto_scheduler = AutoScheduler(
|
||||
self.config_manager,
|
||||
@@ -147,15 +147,15 @@ class GroupDailyAnalysis(Star):
|
||||
plugin_instance=self,
|
||||
)
|
||||
|
||||
# 同步全局限流并进行初始化配置
|
||||
# Đồng bộ cấu hình bộ giới hạn toàn cục.
|
||||
GlobalRateLimiter.get_instance(self.config_manager.get_llm_max_concurrent())
|
||||
|
||||
self._initialized = False
|
||||
self._terminating = False # 生命周期标志
|
||||
self._terminating = False # Cờ vòng đời.
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
# 异步注册任务,处理插件重载情况
|
||||
# Đăng ký tác vụ bất đồng bộ để xử lý reload plugin.
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._init_task = loop.create_task(
|
||||
@@ -166,18 +166,18 @@ class GroupDailyAnalysis(Star):
|
||||
except RuntimeError:
|
||||
self._init_task = None
|
||||
|
||||
# orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。
|
||||
# 如果需要高性能缓存,后续可由 AnalysisApplicationService 内部维护。
|
||||
# Cache orchestrator đã chuyển vào application service hoặc tạm bỏ để đơn giản.
|
||||
# Nếu cần cache hiệu năng cao, AnalysisApplicationService có thể quản lý nội bộ.
|
||||
|
||||
@filter.on_platform_loaded()
|
||||
async def on_platform_loaded(self):
|
||||
"""平台加载完成后初始化"""
|
||||
"""Khởi tạo sau khi nền tảng tải xong."""
|
||||
await self._run_initialization("Platform Loaded")
|
||||
|
||||
async def _run_initialization(self, source: str):
|
||||
"""统一初始化逻辑"""
|
||||
"""Logic khởi tạo thống nhất."""
|
||||
async with self._init_lock:
|
||||
# 如果已经成功发现过平台,且不是来自 Platform Loaded 的强制触发,则跳过
|
||||
# Bỏ qua nếu đã phát hiện nền tảng và không phải trigger Platform Loaded.
|
||||
if (
|
||||
self._initialized
|
||||
and self.bot_manager
|
||||
@@ -186,77 +186,78 @@ class GroupDailyAnalysis(Star):
|
||||
):
|
||||
return
|
||||
|
||||
# 稍微延迟,确保 context 和环境稳定
|
||||
# 针对极少数环境,2秒可能不足以让平台管理器就绪,增加到 5秒
|
||||
# Chờ để context, môi trường và platform manager ổn định.
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# [加固] 如果在等待期间插件已被卸载(terminate),则直接退出
|
||||
# Thoát nếu plugin đã bị gỡ trong thời gian chờ.
|
||||
if not self.bot_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
# 注册 TraceID 过滤器
|
||||
# Đăng ký bộ lọc TraceID.
|
||||
trace_filter = TraceLogFilter()
|
||||
if not any(
|
||||
isinstance(f, TraceLogFilter) for f in astrbot_logger.filters
|
||||
):
|
||||
astrbot_logger.addFilter(trace_filter)
|
||||
astrbot_logger.info("[Trace] TraceID 日志追踪已启用")
|
||||
astrbot_logger.info("[Trace] Đã bật theo dõi log bằng TraceID")
|
||||
|
||||
logger.info(f"正在执行插件初始化 (来源: {source})...")
|
||||
logger.info(f"Đang khởi tạo plugin (nguồn: {source})...")
|
||||
|
||||
# 0. 自动升级旧版 prompt 模板(str.format -> string.Template)并回写配置
|
||||
# 0. Tự nâng cấp prompt cũ từ str.format sang string.Template.
|
||||
try:
|
||||
self.config_manager.upgrade_prompt_templates()
|
||||
except Exception as e:
|
||||
logger.warning(f"自动升级 prompt 模板失败: {e}")
|
||||
logger.warning(f"Tự nâng cấp template prompt thất bại: {e}")
|
||||
|
||||
# 1. 尝试发现 bot 实例
|
||||
# 1. Thử phát hiện instance bot.
|
||||
await self.bot_manager.initialize_from_config()
|
||||
|
||||
# 2. 注册预览路由器
|
||||
# 2. Đăng ký router preview.
|
||||
if self.template_preview_router:
|
||||
await self.template_preview_router.ensure_handlers_registered(
|
||||
self.context
|
||||
)
|
||||
|
||||
# 3. 强制注册定时分析任务
|
||||
# 3. Đăng ký tác vụ phân tích định kỳ.
|
||||
if self.auto_scheduler:
|
||||
self.auto_scheduler.schedule_jobs(self.context)
|
||||
|
||||
self._initialized = True
|
||||
self._discovery_run = True
|
||||
logger.info(f"插件任务注册完成 (来源: {source})")
|
||||
logger.info(f"Hoàn tất đăng ký tác vụ plugin (nguồn: {source})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插件初始化失败: {e}", exc_info=True)
|
||||
logger.error(f"Khởi tạo plugin thất bại: {e}", exc_info=True)
|
||||
|
||||
async def terminate(self):
|
||||
"""插件被卸载/停用时调用,清理资源"""
|
||||
"""Dọn tài nguyên khi plugin bị gỡ hoặc vô hiệu hoá."""
|
||||
if self._terminating:
|
||||
return
|
||||
self._terminating = True
|
||||
|
||||
try:
|
||||
logger.info("开始清理群日常分析插件资源...")
|
||||
logger.info("Bắt đầu dọn tài nguyên plugin phân tích nhóm...")
|
||||
|
||||
# 1. 停止所有后台任务
|
||||
# 1. Dừng mọi tác vụ đang chạy.
|
||||
if self._background_tasks:
|
||||
logger.info(f"正在取消 {len(self._background_tasks)} 个运行中的任务...")
|
||||
logger.info(
|
||||
f"Đang huỷ {len(self._background_tasks)} tác vụ đang chạy..."
|
||||
)
|
||||
for task in self._background_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
# 等待任务结束,给予 3 秒宽限期
|
||||
# Chờ tác vụ kết thúc với thời gian gia hạn 3 giây.
|
||||
try:
|
||||
await asyncio.wait(list(self._background_tasks), timeout=3.0)
|
||||
except Exception:
|
||||
pass
|
||||
self._background_tasks.clear()
|
||||
|
||||
# 2. 停止各个组件 (顺序:先调度器,后底层服务)
|
||||
# 2. Dừng các thành phần: scheduler trước, dịch vụ tầng dưới sau.
|
||||
if self.auto_scheduler:
|
||||
logger.debug("正在停止自动调度器...")
|
||||
logger.debug("Đang dừng bộ lập lịch tự động...")
|
||||
self.auto_scheduler.unschedule_jobs(self.context)
|
||||
|
||||
if self.template_preview_router:
|
||||
@@ -265,32 +266,29 @@ class GroupDailyAnalysis(Star):
|
||||
if self.report_generator:
|
||||
await self.report_generator.close()
|
||||
|
||||
# 3. [关键修复] 只有在任务全部清理后,才清理引用。
|
||||
# 实际上,在 terminate 结束后,self 本身就会被 GC 释放,
|
||||
# 这里的显式 None 更多是为了协助循环引用清理,但由于异步任务存在竞态,
|
||||
# 我们可以通过 check _terminating 标志位来保护。
|
||||
# 为了彻底解决 #125,我们保留引用,让 GC 自然回收。
|
||||
logger.info("群日常分析插件资源清理完成")
|
||||
# 3. Chỉ dọn tham chiếu sau khi mọi tác vụ đã kết thúc.
|
||||
# Giữ tham chiếu để GC thu hồi tự nhiên, tránh race với tác vụ bất đồng bộ (#125).
|
||||
logger.info("Hoàn tất dọn tài nguyên plugin phân tích nhóm")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插件资源清理失败: {e}")
|
||||
logger.error(f"Dọn tài nguyên plugin thất bại: {e}")
|
||||
|
||||
# ==================== Telegram 消息拦截器 ====================
|
||||
# ==================== Bộ chặn tin nhắn Telegram ====================
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
|
||||
@filter.platform_adapter_type(filter.PlatformAdapterType.TELEGRAM)
|
||||
async def intercept_telegram_messages(self, event: AstrMessageEvent):
|
||||
"""
|
||||
拦截 Telegram 群消息并存储到数据库
|
||||
Chặn tin nhắn nhóm Telegram và lưu vào cơ sở dữ liệu.
|
||||
|
||||
委托给 MessageProcessingService 处理
|
||||
Uỷ quyền xử lý cho MessageProcessingService.
|
||||
"""
|
||||
try:
|
||||
await self.message_processing_service.process_message(event)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.warning(f"[Telegram] 消息存储失败: {e}")
|
||||
logger.warning(f"[Telegram] Lưu tin nhắn thất bại: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True)
|
||||
logger.error(f"[Telegram] Lỗi lưu tin nhắn: {e}", exc_info=True)
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
|
||||
@filter.platform_adapter_type(
|
||||
@@ -298,7 +296,7 @@ class GroupDailyAnalysis(Star):
|
||||
| filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK
|
||||
)
|
||||
async def intercept_qq_official_messages(self, event: AstrMessageEvent):
|
||||
"""缓存 QQ 官方机器人群消息;频道消息不在本插件适配范围内。"""
|
||||
"""Cache tin nhắn nhóm QQ Official; không xử lý tin nhắn kênh."""
|
||||
raw_message = getattr(getattr(event, "message_obj", None), "raw_message", None)
|
||||
if isinstance(raw_message, dict):
|
||||
author = raw_message.get("author") or {}
|
||||
@@ -316,23 +314,23 @@ class GroupDailyAnalysis(Star):
|
||||
try:
|
||||
await self.message_processing_service.process_message(event)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.warning(f"[QQOfficial] 消息存储失败: {e}")
|
||||
logger.warning(f"[QQOfficial] Lưu tin nhắn thất bại: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[QQOfficial] 消息存储异常: {e}", exc_info=True)
|
||||
logger.error(f"[QQOfficial] Lỗi lưu tin nhắn: {e}", exc_info=True)
|
||||
|
||||
async def get_telegram_seen_group_ids(
|
||||
self, platform_id: str | None = None
|
||||
) -> list[str]:
|
||||
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。"""
|
||||
"""Đọc nhóm/chủ đề Telegram đã thấy cho scheduler fallback."""
|
||||
return await self.platform_group_registry.get_all_group_ids(platform_id)
|
||||
|
||||
async def get_seen_group_ids(self, platform_id: str | None = None) -> list[str]:
|
||||
"""读取任意事件驱动平台已经见过的群组。"""
|
||||
"""Đọc các nhóm đã thấy trên mọi nền tảng hướng sự kiện."""
|
||||
return await self.platform_group_registry.get_all_group_ids(platform_id)
|
||||
|
||||
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
||||
"""从消息事件中安全获取群组 ID"""
|
||||
# 保留此辅助方法,因为在其他 command 中仍被频繁使用
|
||||
"""Lấy an toàn ID nhóm từ sự kiện tin nhắn."""
|
||||
# Giữ helper này vì nhiều command khác vẫn dùng.
|
||||
try:
|
||||
group_id = event.get_group_id()
|
||||
return group_id if group_id else None
|
||||
@@ -340,12 +338,12 @@ class GroupDailyAnalysis(Star):
|
||||
return None
|
||||
|
||||
def _get_platform_id_from_event(self, event: AstrMessageEvent) -> str:
|
||||
"""从消息事件中获取平台唯一 ID"""
|
||||
# 保留此辅助方法,因为在其他 command 中仍被频繁使用
|
||||
"""Lấy ID nền tảng duy nhất từ sự kiện tin nhắn."""
|
||||
# Giữ helper này vì nhiều command khác vẫn dùng.
|
||||
try:
|
||||
return event.get_platform_id()
|
||||
except Exception:
|
||||
# 后备方案:从元数据获取
|
||||
# Fallback: lấy từ metadata.
|
||||
if (
|
||||
hasattr(event, "platform_meta")
|
||||
and event.platform_meta
|
||||
@@ -355,12 +353,12 @@ class GroupDailyAnalysis(Star):
|
||||
return "default"
|
||||
|
||||
# ================================================================
|
||||
# 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式)
|
||||
# Upload báo cáo ảnh vào tệp/album nhóm, chỉ cho định dạng ảnh trên QQ.
|
||||
# ================================================================
|
||||
|
||||
async def _try_upload_image(self, group_id: str, image_url: str, platform_id: str):
|
||||
"""
|
||||
尝试将图片报告上传到群文件和/或群相册(静默处理,失败仅日志提示)。
|
||||
Thử upload báo cáo ảnh vào tệp và/hoặc album nhóm; lỗi chỉ ghi log.
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
@@ -376,12 +374,12 @@ class GroupDailyAnalysis(Star):
|
||||
if not adapter or not hasattr(adapter, "upload_group_file_to_folder"):
|
||||
return
|
||||
|
||||
# 1. 构造一个更友好的文件名
|
||||
# 1. Tạo tên tệp thân thiện hơn.
|
||||
now = datetime.now()
|
||||
timestamp = now.strftime("%H%M")
|
||||
date_str = now.strftime("%Y-%m-%d")
|
||||
|
||||
# 默认基础名和后缀
|
||||
# Tên cơ sở và phần mở rộng mặc định.
|
||||
ext = (
|
||||
".jpg"
|
||||
if (".jpg" in image_url.lower() or ".jpeg" in image_url.lower())
|
||||
@@ -390,41 +388,41 @@ class GroupDailyAnalysis(Star):
|
||||
nice_filename = f"bao_cao_phan_tich_nhom_{group_id}_{date_str}_{timestamp}{ext}"
|
||||
|
||||
try:
|
||||
# 尝试通过适配器获取群名称,使文件名更具辨识度
|
||||
# Thử lấy tên nhóm qua adapter để tên tệp dễ nhận diện hơn.
|
||||
group_info = await adapter.get_group_info(group_id)
|
||||
if group_info and group_info.group_name:
|
||||
# 过滤非法文件名字符:\ / : * ? " < > |
|
||||
# Lọc ký tự không hợp lệ trong tên tệp: \ / : * ? " < > |
|
||||
safe_name = re.sub(r'[\\/:*?"<>|]', "", group_info.group_name).strip()
|
||||
if safe_name:
|
||||
nice_filename = f"bao_cao_phan_tich_nhom_{safe_name}_{date_str}_{timestamp}{ext}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 将内容准备为文件或数据
|
||||
# 2. Chuẩn bị nội dung dưới dạng tệp hoặc dữ liệu.
|
||||
image_file = None
|
||||
created_temp = False
|
||||
MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # 20MB 限制
|
||||
MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # Giới hạn 20 MB.
|
||||
|
||||
try:
|
||||
data = None
|
||||
if image_url.startswith("base64://"):
|
||||
base64_str = image_url[len("base64://") :]
|
||||
if len(base64_str) * 3 / 4 > MAX_PAYLOAD_SIZE:
|
||||
logger.warning("图片上传失败:Base64 负载过大")
|
||||
logger.warning("Upload ảnh thất bại: payload Base64 quá lớn")
|
||||
return
|
||||
data = base64.b64decode(base64_str)
|
||||
elif image_url.startswith("data:"):
|
||||
parts = image_url.split(",", 1)
|
||||
if len(parts) == 2:
|
||||
if len(parts[1]) * 3 / 4 > MAX_PAYLOAD_SIZE:
|
||||
logger.warning("图片上传失败:Data URI 负载过大")
|
||||
logger.warning("Upload ảnh thất bại: payload Data URI quá lớn")
|
||||
return
|
||||
data = base64.b64decode(parts[1])
|
||||
elif os.path.isfile(image_url):
|
||||
image_file = os.path.abspath(image_url)
|
||||
|
||||
if data and not image_file:
|
||||
# 使用 tempfile 生成唯一后缀,防止并发冲突
|
||||
# Dùng tempfile tạo hậu tố duy nhất để tránh xung đột đồng thời.
|
||||
fd, image_file = tempfile.mkstemp(suffix=ext, prefix="group_report_")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
@@ -437,7 +435,7 @@ class GroupDailyAnalysis(Star):
|
||||
if not image_file:
|
||||
return
|
||||
|
||||
# 3. 执行上传:群文件
|
||||
# 3. Upload vào tệp nhóm.
|
||||
if enable_file:
|
||||
try:
|
||||
folder_name = self.config_manager.get_group_file_folder()
|
||||
@@ -450,10 +448,10 @@ class GroupDailyAnalysis(Star):
|
||||
group_id=group_id,
|
||||
file_path=image_file,
|
||||
folder_id=folder_id,
|
||||
filename=nice_filename, # 显式传递漂亮的文件名
|
||||
filename=nice_filename, # Truyền tường minh tên tệp thân thiện.
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"群文件上传失败 (群 {group_id}): {e}")
|
||||
logger.warning(f"Upload tệp nhóm thất bại (nhóm {group_id}): {e}")
|
||||
|
||||
if enable_album and hasattr(adapter, "upload_group_album"):
|
||||
try:
|
||||
@@ -465,12 +463,12 @@ class GroupDailyAnalysis(Star):
|
||||
album_id = await adapter.find_album_id(group_id, album_name) # type: ignore[attr-defined]
|
||||
if not album_id and strict_mode:
|
||||
logger.info(
|
||||
f"群相册严格模式开启:在群 {group_id} 中未找到名为 '{album_name}' 的相册,停止上传。"
|
||||
f"Đã bật chế độ album nghiêm ngặt: không tìm thấy album '{album_name}' trong nhóm {group_id}, dừng upload"
|
||||
)
|
||||
return
|
||||
elif strict_mode:
|
||||
logger.info(
|
||||
f"群相册严格模式开启:未设置目标相册名称,停止上传以防止操作群 {group_id} 的默认相册。"
|
||||
f"Đã bật chế độ album nghiêm ngặt nhưng chưa đặt tên album đích; dừng để tránh thao tác album mặc định của nhóm {group_id}"
|
||||
)
|
||||
return
|
||||
await adapter.upload_group_album( # type: ignore[attr-defined]
|
||||
@@ -481,9 +479,9 @@ class GroupDailyAnalysis(Star):
|
||||
strict_mode=strict_mode,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"群相册上传失败 (群 {group_id}): {e}")
|
||||
logger.warning(f"Upload album nhóm thất bại (nhóm {group_id}): {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"图片上传处理异常: {e}")
|
||||
logger.warning(f"Lỗi xử lý upload ảnh: {e}")
|
||||
finally:
|
||||
if created_temp and image_file and os.path.exists(image_file):
|
||||
try:
|
||||
@@ -497,8 +495,8 @@ class GroupDailyAnalysis(Star):
|
||||
self, event: AstrMessageEvent, days: int | None = None
|
||||
):
|
||||
"""
|
||||
分析群聊日常活动(跨平台支持)
|
||||
用法: /群分析 [天数]
|
||||
Phân tích hoạt động nhóm hằng ngày trên nhiều nền tảng.
|
||||
Cách dùng: /phantichnhom [số ngày]
|
||||
"""
|
||||
if self._terminating:
|
||||
return
|
||||
@@ -508,7 +506,7 @@ class GroupDailyAnalysis(Star):
|
||||
self._background_tasks.add(current_task)
|
||||
|
||||
try:
|
||||
event.should_call_llm(True) # 阻止默认 LLM 解析
|
||||
event.should_call_llm(True) # Ngăn LLM mặc định phân tích.
|
||||
group_id = self._get_group_id_from_event(event)
|
||||
platform_id = self._get_platform_id_from_event(event)
|
||||
|
||||
@@ -516,10 +514,10 @@ class GroupDailyAnalysis(Star):
|
||||
yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat")
|
||||
return
|
||||
|
||||
# 更新bot实例
|
||||
# Cập nhật instance bot.
|
||||
self.bot_manager.update_from_event(event)
|
||||
|
||||
# 优先使用 UMO 进行权限检查 (兼容白名单 UMO 格式)
|
||||
# Ưu tiên UMO để kiểm tra quyền và tương thích whitelist UMO.
|
||||
check_target = getattr(event, "unified_msg_origin", None)
|
||||
if not check_target:
|
||||
check_target = f"{platform_id}:GroupMessage:{group_id}"
|
||||
@@ -534,7 +532,7 @@ class GroupDailyAnalysis(Star):
|
||||
)
|
||||
return
|
||||
|
||||
# 获取群名以生成语义化的 TraceID
|
||||
# Lấy tên nhóm để tạo TraceID có ngữ nghĩa.
|
||||
group_name = ""
|
||||
try:
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
@@ -545,20 +543,20 @@ class GroupDailyAnalysis(Star):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 设置 TraceID (语义化格式: manual_群名_HHmm)
|
||||
# Thiết lập TraceID theo dạng manual_tên_nhóm_HHmm.
|
||||
trace_id = TraceContext.generate(
|
||||
prefix="manual", group_name=group_name or group_id
|
||||
)
|
||||
TraceContext.set(trace_id)
|
||||
|
||||
# 表情回应 或 文本提示(二选一,由配置开关控制)
|
||||
# Reaction hoặc thông báo văn bản, chọn theo cấu hình.
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
orig_msg_id = getattr(event.message_obj, "message_id", None)
|
||||
adapter_platform_name = (
|
||||
(adapter.get_platform_name() if adapter else "").strip().lower()
|
||||
)
|
||||
# QQ 官方机器人 API v2 不支持本插件使用的表情回应接口,
|
||||
# 因此始终沿用原有的文字进度提示,避免触发无效的 reaction 请求。
|
||||
# API v2 của QQ Official không hỗ trợ reaction plugin đang dùng,
|
||||
# nên luôn dùng thông báo tiến độ dạng văn bản.
|
||||
use_text_reply = (
|
||||
adapter_platform_name in {"qq_official", "qq_official_webhook"}
|
||||
or self.config_manager.get_enable_analysis_reply()
|
||||
@@ -573,7 +571,7 @@ class GroupDailyAnalysis(Star):
|
||||
event.get_group_id(), orig_msg_id, "analysis_started"
|
||||
)
|
||||
|
||||
# 调用 DDD 应用级服务
|
||||
# Gọi application service theo DDD.
|
||||
result = await self.analysis_service.execute_daily_analysis(
|
||||
group_id=group_id, platform_id=platform_id, manual=True, days=days
|
||||
)
|
||||
@@ -586,7 +584,7 @@ class GroupDailyAnalysis(Star):
|
||||
)
|
||||
elif reason == "muted":
|
||||
logger.warning(
|
||||
f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过回复以防抛出发送异常"
|
||||
f"Nhóm {group_id} đã tắt chat toàn nhóm hoặc tắt quyền bot; bỏ qua phản hồi để tránh lỗi gửi"
|
||||
)
|
||||
else:
|
||||
yield event.plain_result(
|
||||
@@ -607,9 +605,9 @@ class GroupDailyAnalysis(Star):
|
||||
"📊 Phân tích cho nhóm này đang chạy, vui lòng thử lại sau nhé~"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("群分析任务被取消 (插件重载或卸载)")
|
||||
logger.info("Tác vụ phân tích nhóm đã bị huỷ do plugin reload hoặc bị gỡ")
|
||||
except Exception as e:
|
||||
logger.error(f"群分析失败: {e}", exc_info=True)
|
||||
logger.error(f"Phân tích nhóm thất bại: {e}", exc_info=True)
|
||||
yield event.plain_result(
|
||||
f"❌ Phân tích thất bại: {str(e)}. Vui lòng kiểm tra kết nối "
|
||||
"mạng, cấu hình LLM hoặc liên hệ quản trị viên"
|
||||
@@ -621,9 +619,9 @@ class GroupDailyAnalysis(Star):
|
||||
async def _send_analysis_report(
|
||||
self, event: AstrMessageEvent, result: dict
|
||||
) -> AsyncGenerator:
|
||||
"""处理分析结果的渲染和发送"""
|
||||
"""Render và gửi kết quả phân tích."""
|
||||
if self._terminating or not self.config_manager:
|
||||
logger.warning("插件正在关闭,停止发送报告")
|
||||
logger.warning("Plugin đang đóng, dừng gửi báo cáo")
|
||||
return
|
||||
|
||||
group_id = result["group_id"]
|
||||
@@ -633,7 +631,7 @@ class GroupDailyAnalysis(Star):
|
||||
output_format = self.config_manager.get_output_format()[0]
|
||||
is_qq_official = adapter.get_platform_name() == "qq_official"
|
||||
|
||||
# 定义获取回调
|
||||
# Định nghĩa callback truy xuất dữ liệu.
|
||||
async def avatar_url_getter(user_id: str) -> str | None:
|
||||
return await adapter.get_user_avatar_url(user_id)
|
||||
|
||||
@@ -666,10 +664,12 @@ class GroupDailyAnalysis(Star):
|
||||
sent = await adapter.send_image(group_id, image_url, caption=caption)
|
||||
if sent:
|
||||
await self._try_upload_image(group_id, image_url, platform_id)
|
||||
return # 成功发送
|
||||
return # Gửi thành công.
|
||||
|
||||
# 如果图片生成或发送失败,直接回退到文本
|
||||
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
|
||||
# Chuyển thẳng sang văn bản nếu tạo hoặc gửi ảnh thất bại.
|
||||
logger.warning(
|
||||
f"Gửi báo cáo ảnh thất bại, đang gửi fallback văn bản cho nhóm {group_id}"
|
||||
)
|
||||
await self._send_text_reports(
|
||||
group_id, analysis_result, is_qq_official, adapter
|
||||
)
|
||||
@@ -690,10 +690,10 @@ class GroupDailyAnalysis(Star):
|
||||
|
||||
if is_only_url:
|
||||
if base_url and base_url.strip():
|
||||
# 获取配置中的输出目录
|
||||
# Lấy thư mục output trong cấu hình.
|
||||
html_output_dir = self.config_manager.get_html_output_dir()
|
||||
|
||||
# 若用户配置为空,使用默认目录
|
||||
# Dùng thư mục mặc định nếu cấu hình rỗng.
|
||||
if not html_output_dir:
|
||||
from astrbot.api.star import StarTools
|
||||
|
||||
@@ -702,7 +702,7 @@ class GroupDailyAnalysis(Star):
|
||||
"self_hosted_html_reports",
|
||||
)
|
||||
|
||||
# 计算相对路径并转换为URL
|
||||
# Tính đường dẫn tương đối và chuyển thành URL.
|
||||
rel_path = os.path.relpath(html_path, html_output_dir)
|
||||
url_path = rel_path.replace(os.sep, "/")
|
||||
report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}"
|
||||
@@ -710,15 +710,15 @@ class GroupDailyAnalysis(Star):
|
||||
yield event.plain_result(
|
||||
f"📊 Báo cáo phân tích nhóm hôm nay đã sẵn sàng:\n{report_url}"
|
||||
)
|
||||
return # 拦截成功,直接退出,不再发文件
|
||||
return # Đã gửi liên kết, không gửi tệp nữa.
|
||||
else:
|
||||
logger.warning(
|
||||
f"手动触发群 {group_id} 开启了仅发送外链,但未配置 html_base_url,回退至发送文件。"
|
||||
f"Nhóm {group_id} được kích hoạt thủ công và chỉ bật gửi liên kết nhưng chưa cấu hình html_base_url; chuyển sang gửi tệp"
|
||||
)
|
||||
|
||||
caption = self.report_generator.build_html_caption(html_path)
|
||||
|
||||
# 发送 HTML 文件
|
||||
# Gửi tệp HTML.
|
||||
sender = getattr(self, "message_sender", None)
|
||||
if sender:
|
||||
sent = await sender.send_file(
|
||||
@@ -776,10 +776,10 @@ class GroupDailyAnalysis(Star):
|
||||
@filter.permission_type(PermissionType.ADMIN)
|
||||
async def set_output_format(self, event: AstrMessageEvent, format_input: str = ""):
|
||||
"""
|
||||
设置分析报告输出格式(跨平台支持)
|
||||
用法: /设置格式 [格式名称或序号] 或 image,html 等逗号分隔的组合
|
||||
Thiết lập định dạng báo cáo trên nhiều nền tảng.
|
||||
Cách dùng: /dinhdang [tên hoặc số thứ tự], có thể dùng ``image,html``.
|
||||
"""
|
||||
# 命令由插件处理,禁用默认 LLM 回退。
|
||||
# Plugin xử lý command, tắt fallback LLM mặc định.
|
||||
event.should_call_llm(True)
|
||||
|
||||
available_formats = ["image", "text", "html"]
|
||||
@@ -806,19 +806,19 @@ Cách dùng: /dinhdang [tên hoặc số thứ tự], ví dụ: /dinhdang image,
|
||||
return
|
||||
|
||||
target_format = None
|
||||
# 尝试由序号选择
|
||||
# Thử chọn theo số thứ tự.
|
||||
if format_input.isdigit():
|
||||
idx = int(format_input) - 1
|
||||
if 0 <= idx < len(available_formats):
|
||||
target_format = available_formats[idx]
|
||||
|
||||
# 尝试按名称选择
|
||||
# Thử chọn theo tên.
|
||||
if not target_format:
|
||||
input_lower = format_input.lower()
|
||||
if input_lower in available_formats:
|
||||
target_format = input_lower
|
||||
|
||||
# 支持逗号分隔的多个格式
|
||||
# Hỗ trợ nhiều định dạng phân tách bằng dấu phẩy.
|
||||
if not target_format:
|
||||
parts = [f.strip() for f in format_input.replace(",", ",").split(",")]
|
||||
if all(p in available_formats for p in parts) and len(parts) > 1:
|
||||
@@ -853,10 +853,10 @@ Cách dùng: /dinhdang [tên hoặc số thứ tự], ví dụ: /dinhdang image,
|
||||
self, event: AstrMessageEvent, template_input: str = ""
|
||||
):
|
||||
"""
|
||||
设置分析报告模板(跨平台支持)
|
||||
用法: /设置模板 [模板名称或序号]
|
||||
Thiết lập mẫu báo cáo trên nhiều nền tảng.
|
||||
Cách dùng: /maubc [tên mẫu hoặc số thứ tự].
|
||||
"""
|
||||
# 命令由插件处理,禁用默认 LLM 回退。
|
||||
# Plugin xử lý command, tắt fallback LLM mặc định.
|
||||
event.should_call_llm(True)
|
||||
|
||||
available_templates = (
|
||||
@@ -901,10 +901,10 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
@filter.permission_type(PermissionType.ADMIN)
|
||||
async def view_templates(self, event: AstrMessageEvent):
|
||||
"""
|
||||
查看所有可用的报告模板及预览图(跨平台支持)
|
||||
用法: /查看模板
|
||||
Xem mọi mẫu báo cáo khả dụng và ảnh preview trên nhiều nền tảng.
|
||||
Cách dùng: /xemmau.
|
||||
"""
|
||||
# 命令由插件处理,禁用默认 LLM 回退。
|
||||
# Plugin xử lý command, tắt fallback LLM mặc định.
|
||||
event.should_call_llm(True)
|
||||
|
||||
available_templates = (
|
||||
@@ -943,15 +943,11 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
@filter.permission_type(PermissionType.ADMIN)
|
||||
async def analysis_settings(self, event: AstrMessageEvent, action: str = "status"):
|
||||
"""
|
||||
管理分析设置(跨平台支持)
|
||||
用法: /分析设置 [enable|disable|status|reload|test]
|
||||
- enable: 启用当前群的分析功能
|
||||
- disable: 禁用当前群的分析功能
|
||||
- status: 查看当前状态
|
||||
- reload: 重新加载配置并重启定时任务
|
||||
- test: 测试自动分析功能
|
||||
- filter_bot: 切换是否在分析中包含机器人自己的消息
|
||||
- incremental_debug: 切换增量分析立即报告模式(调试用)
|
||||
Quản lý cài đặt phân tích trên nhiều nền tảng.
|
||||
|
||||
Cách dùng: /caidat [enable|disable|status|reload|test|filter_bot|incremental_debug].
|
||||
``filter_bot`` chuyển chế độ lọc tin bot; ``incremental_debug`` chuyển
|
||||
chế độ gửi ngay báo cáo gia tăng để debug.
|
||||
"""
|
||||
group_id = self._get_group_id_from_event(event)
|
||||
|
||||
@@ -987,7 +983,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
|
||||
yield event.plain_result("🧪 Đang kiểm tra tính năng phân tích tự động...")
|
||||
|
||||
# 更新bot实例(用于测试)
|
||||
# Cập nhật instance bot để kiểm tra.
|
||||
self.bot_manager.update_from_event(event)
|
||||
|
||||
try:
|
||||
@@ -1041,7 +1037,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
output_format = self.config_manager.get_output_format()[0]
|
||||
min_threshold = self.config_manager.get_min_messages_threshold()
|
||||
|
||||
# 增量分析状态
|
||||
# Trạng thái phân tích gia tăng.
|
||||
incremental_enabled = self.config_manager.get_incremental_enabled()
|
||||
incremental_status_text = "Chưa bật"
|
||||
if incremental_enabled:
|
||||
@@ -1075,7 +1071,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
@filter.command("tangcuong", alias={"incremental_status", "增量状态"})
|
||||
@filter.permission_type(PermissionType.ADMIN)
|
||||
async def incremental_status(self, event: AstrMessageEvent):
|
||||
"""查看当前增量分析状态(滑动窗口)"""
|
||||
"""Xem trạng thái phân tích gia tăng trong cửa sổ trượt."""
|
||||
group_id = self._get_group_id_from_event(event)
|
||||
if not group_id:
|
||||
yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat")
|
||||
@@ -1089,12 +1085,12 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
|
||||
import time as time_mod
|
||||
|
||||
# 计算滑动窗口范围
|
||||
# Tính phạm vi cửa sổ trượt.
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
window_end = time_mod.time()
|
||||
window_start = window_end - (analysis_days * 24 * 3600)
|
||||
|
||||
# 查询窗口内的批次
|
||||
# Truy vấn các batch trong cửa sổ.
|
||||
batches = await self.incremental_store.query_batches(
|
||||
group_id, window_start, window_end
|
||||
)
|
||||
@@ -1110,7 +1106,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
)
|
||||
return
|
||||
|
||||
# 合并批次获取聚合视图
|
||||
# Gộp batch để tạo chế độ xem tổng hợp.
|
||||
state = self.incremental_merge_service.merge_batches(
|
||||
batches, window_start, window_end
|
||||
)
|
||||
@@ -1127,7 +1123,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
)
|
||||
|
||||
async def _handle_settings_enable(self, event: AstrMessageEvent, group_id: str):
|
||||
"""协助逻辑:处理启用设置的分支逻辑"""
|
||||
"""Helper xử lý nhánh bật cài đặt."""
|
||||
mode = self.config_manager.get_group_list_mode()
|
||||
target_id = event.unified_msg_origin or group_id
|
||||
|
||||
@@ -1164,7 +1160,7 @@ Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
|
||||
)
|
||||
|
||||
async def _handle_settings_disable(self, event: AstrMessageEvent, group_id: str):
|
||||
"""协助逻辑:处理禁用设置的分支逻辑"""
|
||||
"""Helper xử lý nhánh tắt cài đặt."""
|
||||
mode = self.config_manager.get_group_list_mode()
|
||||
target_id = event.unified_msg_origin or group_id
|
||||
|
||||
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。
|
||||
display_name: 群分析总结插件 # 插件的显示名称
|
||||
desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
|
||||
version: v4.11.2 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
author: SXP-Simon # 作者
|
||||
name: astrbot_plugin_qq_group_daily_analysis # Mã định danh duy nhất của plugin.
|
||||
display_name: Plugin tổng hợp và phân tích nhóm # Tên hiển thị của plugin.
|
||||
desc: "Plugin tổng hợp và phân tích hoạt động nhóm hằng ngày - hỗ trợ OneBot (NapCat, LLOneBot, Snowluma), bot QQ chính thức, Telegram và Discord; tạo báo cáo phân tích trò chuyện nhóm trực quan, hỗ trợ phân tích chủ đề, chân dung thành viên và trích dẫn nổi bật." # Mô tả ngắn của plugin.
|
||||
version: v4.11.2 # Phiên bản plugin, theo định dạng v1.1.1 hoặc v1.1.
|
||||
author: SXP-Simon # Tác giả.
|
||||
astrbot_version: ">=4.16.0"
|
||||
support_platforms:
|
||||
- aiocqhttp
|
||||
@@ -10,4 +10,4 @@ support_platforms:
|
||||
- telegram
|
||||
- qq_official
|
||||
- qq_official_webhook
|
||||
repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址
|
||||
repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # Kho mã nguồn của plugin.
|
||||
|
||||
+13
-13
@@ -1,17 +1,17 @@
|
||||
"""
|
||||
群日常分析插件 - 源代码包
|
||||
Package mã nguồn của plugin phân tích nhóm hằng ngày.
|
||||
|
||||
本包包含插件的核心实现,采用 DDD (领域驱动设计) 架构:
|
||||
- application: 应用层 - 编排领域服务,处理用例
|
||||
- domain: 领域层 - 核心业务逻辑,平台无关
|
||||
- infrastructure: 基础设施层 - 外部服务适配
|
||||
- shared: 共享组件 - 跨层使用的工具和常量
|
||||
Phần triển khai cốt lõi sử dụng kiến trúc DDD:
|
||||
- application: điều phối domain service và xử lý use case
|
||||
- domain: logic nghiệp vụ cốt lõi, độc lập với nền tảng
|
||||
- infrastructure: adapter cho các dịch vụ bên ngoài
|
||||
- shared: công cụ và hằng số dùng chung giữa các tầng
|
||||
|
||||
遗留模块(渐进式迁移中):
|
||||
- analysis: 分析器实现
|
||||
- core: 核心组件
|
||||
- reports: 报告生成
|
||||
- scheduler: 定时任务
|
||||
- utils: 工具函数
|
||||
- visualization: 可视化组件
|
||||
Các module cũ đang được chuyển đổi dần:
|
||||
- analysis: triển khai analyzer
|
||||
- core: thành phần cốt lõi
|
||||
- reports: tạo báo cáo
|
||||
- scheduler: tác vụ định kỳ
|
||||
- utils: hàm tiện ích
|
||||
- visualization: thành phần trực quan hoá
|
||||
"""
|
||||
|
||||
@@ -1 +1 @@
|
||||
# 应用层 - 编排和用例
|
||||
# Tầng application - điều phối và triển khai use case
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""命令相关应用服务。"""
|
||||
"""Các dịch vụ application liên quan đến command."""
|
||||
|
||||
from .template_command_service import TemplateCommandService
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""模板管理相关命令服务。"""
|
||||
"""Dịch vụ command quản lý template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ from astrbot.api.message_components import Image, Node, Nodes, Plain
|
||||
|
||||
|
||||
class TemplateCommandService:
|
||||
"""封装模板命令的文件系统与消息构建逻辑。"""
|
||||
"""Đóng gói logic filesystem và tạo tin nhắn cho command template."""
|
||||
|
||||
_CIRCLE_NUMBERS = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩"]
|
||||
|
||||
@@ -17,7 +17,7 @@ class TemplateCommandService:
|
||||
self.plugin_root = plugin_root
|
||||
|
||||
def resolve_template_base_dir(self) -> str:
|
||||
"""解析报告模板目录(兼容新旧目录结构)。"""
|
||||
"""Xác định thư mục template báo cáo, tương thích cấu trúc cũ và mới."""
|
||||
candidate_dirs = [
|
||||
os.path.join(
|
||||
self.plugin_root, "src", "infrastructure", "reporting", "templates"
|
||||
@@ -30,7 +30,7 @@ class TemplateCommandService:
|
||||
return candidate_dirs[0]
|
||||
|
||||
def resolve_template_preview_path(self, template_name: str) -> str | None:
|
||||
"""解析模板预览图路径。"""
|
||||
"""Xác định đường dẫn ảnh xem trước template."""
|
||||
candidate_paths = [
|
||||
os.path.join(self.plugin_root, "assets", f"{template_name}-demo.jpg"),
|
||||
]
|
||||
@@ -40,7 +40,7 @@ class TemplateCommandService:
|
||||
return None
|
||||
|
||||
async def list_available_templates(self) -> list[str]:
|
||||
"""列出所有可用模板。"""
|
||||
"""Liệt kê mọi template khả dụng."""
|
||||
template_base_dir = self.resolve_template_base_dir()
|
||||
|
||||
def _list_templates_sync() -> list[str]:
|
||||
@@ -58,14 +58,14 @@ class TemplateCommandService:
|
||||
return await asyncio.to_thread(_list_templates_sync)
|
||||
|
||||
async def template_exists(self, template_name: str) -> bool:
|
||||
"""检查模板目录是否存在。"""
|
||||
"""Kiểm tra thư mục template có tồn tại hay không."""
|
||||
template_dir = os.path.join(self.resolve_template_base_dir(), template_name)
|
||||
return await asyncio.to_thread(os.path.exists, template_dir)
|
||||
|
||||
def parse_template_input(
|
||||
self, template_input: str, available_templates: list[str]
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""解析模板输入(支持模板名或序号)。"""
|
||||
"""Phân tích đầu vào theo tên hoặc số thứ tự template."""
|
||||
if not template_input:
|
||||
return None, "❌ Tham số mẫu không được để trống"
|
||||
|
||||
@@ -87,7 +87,7 @@ class TemplateCommandService:
|
||||
current_template: str,
|
||||
bot_id: str,
|
||||
) -> Nodes:
|
||||
"""构建模板预览的合并消息节点。"""
|
||||
"""Tạo các node tin nhắn tổng hợp để xem trước template."""
|
||||
node_list = []
|
||||
|
||||
header_content = [
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
分析应用服务 - 应用层
|
||||
实现"每日群聊分析并生成报告"及"增量分析"核心用例。
|
||||
负责协调领域服务、基础设施适配器及持久化层。
|
||||
Dịch vụ application cho phân tích.
|
||||
|
||||
Triển khai các use case cốt lõi gồm phân tích nhóm hằng ngày, tạo báo cáo
|
||||
và phân tích gia tăng; điều phối domain service, platform adapter và persistence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,13 +32,13 @@ from ...utils.logger import logger
|
||||
|
||||
|
||||
class DuplicateGroupTaskError(Exception):
|
||||
"""当同一个群组在同一时间尝试启动相同类型的重复分析任务时抛出。"""
|
||||
"""Được phát sinh khi một nhóm khởi chạy trùng loại tác vụ cùng lúc."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AnalysisApplicationService:
|
||||
"""分析应用服务 - 协调业务流程(每日分析 + 增量分析)"""
|
||||
"""Điều phối quy trình phân tích hằng ngày và phân tích gia tăng."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -61,44 +62,51 @@ class AnalysisApplicationService:
|
||||
self.incremental_store = incremental_store
|
||||
self.incremental_merge_service = incremental_merge_service
|
||||
self._locks = weakref.WeakValueDictionary()
|
||||
# 全局 LLM 分析信号量,控制对外 API 的并发压力
|
||||
# 使用专用的 LLM 并发配置项
|
||||
# Semaphore LLM toàn cục để kiểm soát tải đồng thời lên API.
|
||||
# Dùng giá trị cấu hình đồng thời riêng cho LLM.
|
||||
max_concurrent = self.config_manager.get_llm_max_concurrent()
|
||||
self.llm_semaphore = asyncio.Semaphore(max_concurrent)
|
||||
# 用于追踪当前正在执行的任务,实现原子的“检查并设置”逻辑,避免 locked() 竞态
|
||||
# Theo dõi tác vụ đang chạy để kiểm tra và thiết lập nguyên tử,
|
||||
# tránh điều kiện tranh chấp khi dùng locked().
|
||||
self._active_tasks = set()
|
||||
|
||||
@asynccontextmanager
|
||||
async def group_lock(self, group_id: str, task_type: str = "analysis"):
|
||||
"""
|
||||
同一时间、同一个群、同一种任务只能有一个在执行
|
||||
锁将在退出上下文时自动释放。
|
||||
Chỉ cho phép một tác vụ cùng loại của cùng một nhóm chạy tại một thời điểm.
|
||||
Khoá tự động được giải phóng khi thoát khỏi context.
|
||||
"""
|
||||
lock_key = f"{task_type}:{group_id}"
|
||||
|
||||
# 获取或创建该群组特有的锁(保留锁作为第二道资源限流防线)
|
||||
# Lấy hoặc tạo khoá riêng cho nhóm, làm lớp giới hạn tài nguyên thứ hai.
|
||||
lock = self._locks.get(lock_key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[lock_key] = lock
|
||||
|
||||
# 使用同步集合实现原子化的“运行中”检查
|
||||
# 在 asyncio 的单线程循环中,同步代码段不会被中断,因此这是原子操作
|
||||
# Dùng set đồng bộ để kiểm tra trạng thái đang chạy theo cách nguyên tử.
|
||||
# Trong event loop đơn luồng của asyncio, đoạn đồng bộ không bị ngắt.
|
||||
if lock_key in self._active_tasks:
|
||||
logger.warning(f"群 {group_id} 的 {task_type} 任务已在运行,跳过本次请求")
|
||||
logger.warning(
|
||||
f"Tác vụ {task_type} của nhóm {group_id} đang chạy; bỏ qua yêu cầu này"
|
||||
)
|
||||
raise DuplicateGroupTaskError(f"Duplicate task for {lock_key}")
|
||||
|
||||
# 占位:标记任务开始
|
||||
# Đánh dấu bắt đầu tác vụ.
|
||||
self._active_tasks.add(lock_key)
|
||||
|
||||
try:
|
||||
async with lock:
|
||||
logger.debug(f"[Lock] 已获取群 {group_id} 的 {task_type} 排他锁")
|
||||
logger.debug(
|
||||
f"[Lock] Đã lấy khoá độc quyền {task_type} của nhóm {group_id}"
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
# 释放:标记任务结束
|
||||
# Giải phóng: đánh dấu tác vụ kết thúc.
|
||||
self._active_tasks.discard(lock_key)
|
||||
logger.debug(f"[Lock] 已释放群 {group_id} 的 {task_type} 排他锁")
|
||||
logger.debug(
|
||||
f"[Lock] Đã giải phóng khoá độc quyền {task_type} của nhóm {group_id}"
|
||||
)
|
||||
|
||||
async def execute_daily_analysis(
|
||||
self,
|
||||
@@ -108,45 +116,47 @@ class AnalysisApplicationService:
|
||||
days: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
执行每日分析用例。
|
||||
Thực thi use case phân tích hằng ngày.
|
||||
|
||||
流程:
|
||||
1. 获取适配器
|
||||
2. 拉取消息 (Infrastructure)
|
||||
3. 基础统计 (Domain Service)
|
||||
4. 用户分析 (Domain Service)
|
||||
5. LLM 语义分析 (Infrastructure/Analysis Bridge)
|
||||
6. 生成报告 (Visualization/Infrastructure)
|
||||
7. 持久化摘要 (Persistence)
|
||||
8. 返回结果
|
||||
Quy trình:
|
||||
1. Lấy adapter.
|
||||
2. Lấy tin nhắn (infrastructure).
|
||||
3. Thống kê cơ bản (domain service).
|
||||
4. Phân tích thành viên (domain service).
|
||||
5. Phân tích ngữ nghĩa bằng LLM.
|
||||
6. Tạo báo cáo.
|
||||
7. Lưu bản tóm tắt.
|
||||
8. Trả kết quả.
|
||||
"""
|
||||
|
||||
async with self.group_lock(group_id, "daily"):
|
||||
logger.info(
|
||||
f"开始执行分析用例: 群 {group_id}, platform_id={platform_id or '默认'}, days={days or '默认'}"
|
||||
f"Bắt đầu use case phân tích: nhóm {group_id}, platform_id={platform_id or 'mặc định'}, days={days or 'mặc định'}"
|
||||
)
|
||||
|
||||
# 1. 获取适配器
|
||||
# 1. Lấy adapter
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}")
|
||||
|
||||
# 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)
|
||||
# Kiểm tra nhóm có bị tắt quyền gửi tin hay không.
|
||||
if hasattr(adapter, "is_group_muted"):
|
||||
try:
|
||||
if await adapter.is_group_muted(group_id):
|
||||
logger.info(
|
||||
f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次群分析"
|
||||
f"Nhóm {group_id} đang tắt quyền gửi tin toàn nhóm hoặc với bot; bỏ qua phân tích"
|
||||
)
|
||||
return {"success": False, "reason": "muted"}
|
||||
except Exception as e:
|
||||
logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}")
|
||||
logger.warning(
|
||||
f"Lỗi khi kiểm tra trạng thái tắt quyền gửi của nhóm {group_id}: {e}"
|
||||
)
|
||||
|
||||
# 飞书平台在分析前进行一次性权限与成员头像预热,避免报告阶段出现大面积默认头像。
|
||||
# Feishu kiểm tra quyền và làm nóng cache ảnh thành viên trước khi phân tích.
|
||||
if hasattr(adapter, "prepare_group_member_cache"):
|
||||
try:
|
||||
logger.info(
|
||||
"执行平台成员预检查: group=%s, platform=%s",
|
||||
"Kiểm tra trước thành viên nền tảng: group=%s, platform=%s",
|
||||
group_id,
|
||||
platform_id or "default",
|
||||
)
|
||||
@@ -154,7 +164,7 @@ class AnalysisApplicationService:
|
||||
if not ok and err:
|
||||
raise ValueError(err)
|
||||
logger.info(
|
||||
"平台成员预检查通过: group=%s, platform=%s",
|
||||
"Kiểm tra trước thành viên nền tảng thành công: group=%s, platform=%s",
|
||||
group_id,
|
||||
platform_id or "default",
|
||||
)
|
||||
@@ -164,7 +174,7 @@ class AnalysisApplicationService:
|
||||
f"vui lòng cấp đủ quyền cho ứng dụng: {e}"
|
||||
) from e
|
||||
|
||||
# 2. 拉取消息
|
||||
# 2. Lấy tin nhắn
|
||||
if days is None:
|
||||
days = self.config_manager.get_analysis_days()
|
||||
max_count = self.config_manager.get_max_messages()
|
||||
@@ -173,7 +183,7 @@ class AnalysisApplicationService:
|
||||
group_id=group_id, days=days, max_count=max_count
|
||||
)
|
||||
logger.info(
|
||||
"消息拉取完成: group=%s, platform=%s, raw_count=%s, days=%s, max_count=%s",
|
||||
"Đã lấy tin nhắn: group=%s, platform=%s, raw_count=%s, days=%s, max_count=%s",
|
||||
group_id,
|
||||
platform_id or "default",
|
||||
len(raw_messages),
|
||||
@@ -182,10 +192,12 @@ class AnalysisApplicationService:
|
||||
)
|
||||
|
||||
if not raw_messages:
|
||||
logger.warning(f"群 {group_id} 在最近 {days} 天内无消息或无法获取")
|
||||
logger.warning(
|
||||
f"Nhóm {group_id} không có tin nhắn hoặc không thể lấy tin trong {days} ngày gần đây"
|
||||
)
|
||||
return {"success": False, "reason": "no_messages"}
|
||||
|
||||
# 3. 清理消息 (Filter commands, bot messages, noise)
|
||||
# 3. Làm sạch tin nhắn: lọc command, tin nhắn bot và nhiễu.
|
||||
from ...domain.services.message_cleaner_service import MessageCleanerService
|
||||
|
||||
cleaner = MessageCleanerService()
|
||||
@@ -198,32 +210,32 @@ class AnalysisApplicationService:
|
||||
bot_self_ids,
|
||||
)
|
||||
|
||||
# 对于自动任务,强制过滤指令;对于手动任务,也建议过滤以保持报告纯净
|
||||
# Luôn lọc command để báo cáo không bị nhiễu.
|
||||
unified_messages = cleaner.clean_messages(
|
||||
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True
|
||||
)
|
||||
logger.info(
|
||||
"消息清洗完成: group=%s, platform=%s, cleaned_count=%s, dropped=%s",
|
||||
"Đã làm sạch tin nhắn: group=%s, platform=%s, cleaned_count=%s, dropped=%s",
|
||||
group_id,
|
||||
platform_id or "default",
|
||||
len(unified_messages),
|
||||
max(len(raw_messages) - len(unified_messages), 0),
|
||||
)
|
||||
|
||||
# 4. 检查最小消息阈值 (在清理后进行)
|
||||
# 4. Kiểm tra ngưỡng tin nhắn tối thiểu sau khi làm sạch.
|
||||
threshold = self.config_manager.get_min_messages_threshold()
|
||||
if len(unified_messages) < threshold and not manual:
|
||||
logger.info(
|
||||
f"群 {group_id} 有效消息数 ({len(unified_messages)}) 未达到自动分析阈值 ({threshold})"
|
||||
f"Số tin nhắn hợp lệ của nhóm {group_id} ({len(unified_messages)}) chưa đạt ngưỡng phân tích tự động ({threshold})"
|
||||
)
|
||||
return {"success": False, "reason": "below_threshold"}
|
||||
|
||||
# 5. 基础统计 (Domain Service)
|
||||
# 5. Thống kê cơ bản (domain service)
|
||||
statistics = await asyncio.to_thread(
|
||||
self.statistics_service.calculate_group_statistics, unified_messages
|
||||
)
|
||||
|
||||
# 4. 用户分析 (Domain Service)
|
||||
# 4. Phân tích thành viên (domain service)
|
||||
user_activity = await asyncio.to_thread(
|
||||
self.analysis_domain_service.analyze_user_activity,
|
||||
unified_messages,
|
||||
@@ -235,8 +247,8 @@ class AnalysisApplicationService:
|
||||
user_activity, limit=max_user_titles
|
||||
)
|
||||
|
||||
# 5. LLM 语义分析 (为了保持兼容,目前直接传 UnifiedMessage,后续如需传 raw dict 再加转换)
|
||||
# LLMAnalyzer 内部可能已经处理了转换(见之前代码)
|
||||
# 5. Phân tích ngữ nghĩa bằng LLM.
|
||||
# LLMAnalyzer có thể tự xử lý việc chuyển đổi dữ liệu.
|
||||
topic_enabled = self.config_manager.get_topic_analysis_enabled()
|
||||
user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
|
||||
golden_quote_enabled = (
|
||||
@@ -252,8 +264,8 @@ class AnalysisApplicationService:
|
||||
chat_quality_review = None
|
||||
total_token_usage = TokenUsage()
|
||||
|
||||
# Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配
|
||||
# 暂时转换回 legacy 格式以确保稳定性,直到 LLMAnalyzer 被重构
|
||||
# LLMAnalyzer hiện có thể chỉ nhận format cũ hoặc adapter UnifiedMessage.
|
||||
# Tạm chuyển về format cũ để ổn định cho đến khi LLMAnalyzer được refactor.
|
||||
legacy_messages = self.statistics_service._convert_to_legacy_dict(
|
||||
unified_messages
|
||||
)
|
||||
@@ -269,7 +281,7 @@ class AnalysisApplicationService:
|
||||
or chat_quality_enabled
|
||||
):
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入分析队列 (群: {group_id})")
|
||||
logger.debug(f"[LLM] Đã vào hàng đợi phân tích (nhóm: {group_id})")
|
||||
(
|
||||
topics,
|
||||
user_titles,
|
||||
@@ -287,7 +299,7 @@ class AnalysisApplicationService:
|
||||
chat_quality_enabled=chat_quality_enabled,
|
||||
)
|
||||
|
||||
# 回填结果
|
||||
# Gắn kết quả trở lại
|
||||
statistics.golden_quotes = golden_quotes
|
||||
statistics.token_usage = total_token_usage
|
||||
|
||||
@@ -299,11 +311,11 @@ class AnalysisApplicationService:
|
||||
"chat_quality_review": chat_quality_review,
|
||||
}
|
||||
|
||||
# 6. 持久化摘要 (Persistence)
|
||||
# 6. Lưu bản tóm tắt (persistence)
|
||||
await self.history_manager.save_analysis(group_id, analysis_result)
|
||||
|
||||
# 7. 生成报告并发送 (应用层编排发送动作)
|
||||
# 这里由调用方处理发送,本服务只返回分析结果和可能的视觉产物
|
||||
# 7. Tạo và gửi báo cáo (application điều phối thao tác gửi).
|
||||
# Caller xử lý việc gửi; service chỉ trả kết quả và sản phẩm trực quan.
|
||||
return {
|
||||
"success": True,
|
||||
"analysis_result": analysis_result,
|
||||
@@ -314,37 +326,29 @@ class AnalysisApplicationService:
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 增量分析用例
|
||||
# Use case phân tích gia tăng
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def execute_incremental_analysis(
|
||||
self, group_id: str, platform_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
执行一次增量分析用例(滑动窗口批次架构)。
|
||||
Thực thi một use case phân tích gia tăng theo kiến trúc batch cửa sổ trượt.
|
||||
|
||||
与每日分析不同,增量分析每次仅处理最近一段时间的消息,
|
||||
提取少量话题和金句,将结果作为独立批次存储到 KV。
|
||||
不生成用户称号(留到最终报告时再做),不生成报告。
|
||||
Khác với phân tích hằng ngày, mỗi lần chỉ xử lý tin nhắn gần đây,
|
||||
trích xuất một số chủ đề và trích dẫn rồi lưu kết quả thành batch độc
|
||||
lập trong KV. Danh hiệu thành viên và báo cáo được tạo ở bước cuối.
|
||||
|
||||
流程:
|
||||
1. 获取适配器
|
||||
2. 拉取消息(使用增量配置的 max_messages)
|
||||
3. 清理消息
|
||||
4. 按时间戳去重:过滤已分析过的消息
|
||||
5. 检查最小消息阈值
|
||||
6. 计算基础统计(小时分布、用户活跃、表情)
|
||||
7. LLM 增量分析(仅话题 + 金句)
|
||||
8. 构建 IncrementalBatch 并保存
|
||||
9. 更新最后分析消息时间戳
|
||||
10. 返回批次结果
|
||||
Quy trình: lấy adapter và tin nhắn, làm sạch, loại trùng theo timestamp,
|
||||
kiểm tra ngưỡng, tính thống kê, phân tích gia tăng bằng LLM, lưu
|
||||
``IncrementalBatch``, cập nhật tiến độ và trả kết quả batch.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
platform_id: 平台标识,缺省为默认
|
||||
group_id: ID nhóm.
|
||||
platform_id: ID nền tảng; mặc định dùng nền tảng mặc định.
|
||||
|
||||
Returns:
|
||||
dict: 包含 success、batch_summary 等信息
|
||||
Dict chứa success, batch_summary và các thông tin liên quan.
|
||||
"""
|
||||
async with self.group_lock(group_id, "incremental"):
|
||||
if not self.incremental_store:
|
||||
@@ -353,34 +357,36 @@ class AnalysisApplicationService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"开始增量分析用例: 群 {group_id}, 平台 {platform_id or '默认'}"
|
||||
f"Bắt đầu phân tích gia tăng: nhóm {group_id}, nền tảng {platform_id or 'mặc định'}"
|
||||
)
|
||||
|
||||
# 1. 获取适配器
|
||||
# 1. Lấy adapter
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}")
|
||||
|
||||
# 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)
|
||||
# Kiểm tra nhóm có tắt quyền gửi tin hay không.
|
||||
if hasattr(adapter, "is_group_muted"):
|
||||
try:
|
||||
if await adapter.is_group_muted(group_id):
|
||||
logger.info(
|
||||
f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次增量群分析"
|
||||
f"Nhóm {group_id} đang tắt quyền gửi tin toàn nhóm hoặc với bot; bỏ qua phân tích gia tăng"
|
||||
)
|
||||
return {"success": False, "reason": "muted"}
|
||||
except Exception as e:
|
||||
logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}")
|
||||
logger.warning(
|
||||
f"Lỗi khi kiểm tra trạng thái tắt quyền gửi của nhóm {group_id}: {e}"
|
||||
)
|
||||
|
||||
# 2. 拉取消息,获取进度并确定拉取量
|
||||
# 2. Lấy tiến độ và xác định số lượng tin nhắn cần truy xuất.
|
||||
last_analyzed_ts = await self.incremental_store.get_last_analyzed_timestamp(
|
||||
group_id
|
||||
)
|
||||
days = self.config_manager.get_analysis_days()
|
||||
# 在增量模式下,拉取上限由安全限制 (Safe Count) 统一控制,确保能追平进度且不溢出
|
||||
# Giới hạn an toàn giúp bắt kịp tiến độ mà không gây tràn dữ liệu.
|
||||
max_count = self.config_manager.get_incremental_safe_limit()
|
||||
|
||||
# 3. 拉取消息(优先从上次进度点开始回溯,确保不遗漏高活跃期间的 Gap)
|
||||
# 3. Lấy tin nhắn từ điểm tiến độ gần nhất để không bỏ sót khoảng trống.
|
||||
raw_messages = await adapter.fetch_messages(
|
||||
group_id=group_id,
|
||||
days=days,
|
||||
@@ -389,10 +395,12 @@ class AnalysisApplicationService:
|
||||
)
|
||||
|
||||
if not raw_messages:
|
||||
logger.warning(f"群 {group_id} 在最近 {days} 天内无消息或无法获取")
|
||||
logger.warning(
|
||||
f"Nhóm {group_id} không có tin nhắn hoặc không thể lấy tin trong {days} ngày gần đây"
|
||||
)
|
||||
return {"success": False, "reason": "no_messages"}
|
||||
|
||||
# 3. 清理消息
|
||||
# 3. Làm sạch tin nhắn
|
||||
from ...domain.services.message_cleaner_service import MessageCleanerService
|
||||
|
||||
cleaner = MessageCleanerService()
|
||||
@@ -408,22 +416,22 @@ class AnalysisApplicationService:
|
||||
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True
|
||||
)
|
||||
|
||||
# 5. 二次去重,确保只保留断点之后的真正新消息
|
||||
# 5. Loại trùng lần hai để chỉ giữ tin nhắn mới sau điểm tiến độ.
|
||||
if last_analyzed_ts > 0:
|
||||
unified_messages = [
|
||||
msg for msg in unified_messages if msg.timestamp > last_analyzed_ts
|
||||
]
|
||||
|
||||
# 5. 检查最小消息阈值
|
||||
# 5. Kiểm tra ngưỡng tin nhắn tối thiểu.
|
||||
min_messages = self.config_manager.get_incremental_min_messages()
|
||||
if len(unified_messages) < min_messages:
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析:新消息数 ({len(unified_messages)}) "
|
||||
f"未达到阈值 ({min_messages}),跳过本次分析"
|
||||
f"Phân tích gia tăng nhóm {group_id}: số tin nhắn mới ({len(unified_messages)}) "
|
||||
f"chưa đạt ngưỡng ({min_messages}); bỏ qua lần phân tích này"
|
||||
)
|
||||
return {"success": False, "reason": "below_threshold"}
|
||||
|
||||
# 6. 计算基础统计
|
||||
# 6. Tính thống kê cơ bản
|
||||
statistics = await asyncio.to_thread(
|
||||
self.statistics_service.calculate_group_statistics, unified_messages
|
||||
)
|
||||
@@ -433,16 +441,16 @@ class AnalysisApplicationService:
|
||||
bot_self_ids,
|
||||
)
|
||||
|
||||
# 计算本批次的小时分布
|
||||
# Tính phân bố theo giờ của batch này
|
||||
hourly_msg_counts, hourly_char_counts = self._compute_hourly_counts(
|
||||
unified_messages
|
||||
)
|
||||
|
||||
# 7. LLM 增量分析(仅话题 + 金句)
|
||||
# 7. Phân tích gia tăng bằng LLM (chủ đề và trích dẫn)
|
||||
topics_per_batch = self.config_manager.get_incremental_topics_per_batch()
|
||||
quotes_per_batch = self.config_manager.get_incremental_quotes_per_batch()
|
||||
|
||||
# 获取功能开关状态
|
||||
# Lấy trạng thái các công tắc tính năng
|
||||
topic_enabled = self.config_manager.get_topic_analysis_enabled()
|
||||
golden_quote_enabled = (
|
||||
self.config_manager.get_golden_quote_analysis_enabled()
|
||||
@@ -451,7 +459,7 @@ class AnalysisApplicationService:
|
||||
self.config_manager.get_chat_quality_analysis_enabled()
|
||||
)
|
||||
|
||||
# 需要将 UnifiedMessage 转换为 legacy 格式供 LLM 分析器使用
|
||||
# Chuyển UnifiedMessage sang format cũ cho analyzer LLM
|
||||
legacy_messages = self.statistics_service._convert_to_legacy_dict(
|
||||
unified_messages
|
||||
)
|
||||
@@ -466,7 +474,9 @@ class AnalysisApplicationService:
|
||||
|
||||
if topic_enabled or golden_quote_enabled or chat_quality_enabled:
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入增量分析队列 (群: {group_id})")
|
||||
logger.debug(
|
||||
f"[LLM] Đã vào hàng đợi phân tích gia tăng (nhóm: {group_id})"
|
||||
)
|
||||
(
|
||||
topics,
|
||||
golden_quotes,
|
||||
@@ -482,8 +492,8 @@ class AnalysisApplicationService:
|
||||
chat_quality_enabled=chat_quality_enabled,
|
||||
)
|
||||
|
||||
# 8. 构建 IncrementalBatch
|
||||
# 8a. 转换话题: SummaryTopic -> dict
|
||||
# 8. Xây dựng IncrementalBatch
|
||||
# 8a. Chuyển chủ đề: SummaryTopic -> dict
|
||||
new_topics = [
|
||||
{
|
||||
"topic": t.topic,
|
||||
@@ -494,7 +504,7 @@ class AnalysisApplicationService:
|
||||
for t in topics
|
||||
]
|
||||
|
||||
# 8b. 转换金句: GoldenQuote -> dict
|
||||
# 8b. Chuyển trích dẫn: GoldenQuote -> dict
|
||||
new_quotes = [
|
||||
{
|
||||
"content": q.content,
|
||||
@@ -505,19 +515,19 @@ class AnalysisApplicationService:
|
||||
for q in golden_quotes
|
||||
]
|
||||
|
||||
# 8c. 转换 token 消耗: TokenUsage -> dict
|
||||
# 8c. Chuyển mức sử dụng token: TokenUsage -> dict
|
||||
token_usage_dict = {
|
||||
"prompt_tokens": token_usage.prompt_tokens,
|
||||
"completion_tokens": token_usage.completion_tokens,
|
||||
"total_tokens": token_usage.total_tokens,
|
||||
}
|
||||
|
||||
# 8d. 转换用户统计: AnalysisDomainService 格式 -> IncrementalBatch 格式
|
||||
# 8d. Chuyển thống kê thành viên sang format IncrementalBatch
|
||||
user_stats = self._convert_user_activity_for_merge(
|
||||
user_activity, unified_messages
|
||||
)
|
||||
|
||||
# 8e. 转换表情统计: EmojiStatistics -> dict
|
||||
# 8e. Chuyển thống kê biểu cảm: EmojiStatistics -> dict
|
||||
emoji_stats = {
|
||||
"face_count": statistics.emoji_statistics.face_count,
|
||||
"mface_count": statistics.emoji_statistics.mface_count,
|
||||
@@ -527,7 +537,7 @@ class AnalysisApplicationService:
|
||||
"face_details": statistics.emoji_statistics.face_details,
|
||||
}
|
||||
|
||||
# 8f. 转换聊天质量锐评: QualityReview -> dict
|
||||
# 8f. Chuyển đánh giá chất lượng: QualityReview -> dict
|
||||
chat_quality_dict = None
|
||||
if chat_quality_review:
|
||||
chat_quality_dict = {
|
||||
@@ -545,16 +555,16 @@ class AnalysisApplicationService:
|
||||
"summary": chat_quality_review.summary,
|
||||
}
|
||||
|
||||
# 8g. 获取参与者 ID 和最后消息时间戳
|
||||
# 8g. Lấy ID người tham gia và timestamp tin nhắn cuối
|
||||
participant_ids = list({msg.sender_id for msg in unified_messages})
|
||||
last_message_timestamp = max(
|
||||
(msg.timestamp for msg in unified_messages), default=0
|
||||
)
|
||||
|
||||
# 8g. 计算本批次总字符数
|
||||
# 8g. Tính tổng số ký tự của batch
|
||||
characters_count = sum(msg.get_text_length() for msg in unified_messages)
|
||||
|
||||
# 构建批次对象
|
||||
# Xây dựng đối tượng batch
|
||||
batch = IncrementalBatch(
|
||||
group_id=group_id,
|
||||
timestamp=time_mod.time(),
|
||||
@@ -572,10 +582,10 @@ class AnalysisApplicationService:
|
||||
participant_ids=participant_ids,
|
||||
)
|
||||
|
||||
# 9. 保存批次并更新最后分析时间戳
|
||||
# 9. Lưu batch và cập nhật timestamp phân tích cuối.
|
||||
await self.incremental_store.save_batch(batch)
|
||||
|
||||
# 安全更新水位线:取消息最大时间戳,但不能超过当前时间+1分钟,防止未来时间戳毒化导致后续分析死锁
|
||||
# Cập nhật mốc an toàn, không vượt quá hiện tại + 1 phút để tránh timestamp tương lai.
|
||||
import time
|
||||
|
||||
safe_now = int(time.time()) + 60
|
||||
@@ -586,9 +596,9 @@ class AnalysisApplicationService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析完成: "
|
||||
f"本批次消息={len(unified_messages)}, "
|
||||
f"新话题={len(new_topics)}, 新金句={len(new_quotes)}"
|
||||
f"Hoàn tất phân tích gia tăng nhóm {group_id}: "
|
||||
f"tin nhắn batch={len(unified_messages)}, "
|
||||
f"chủ đề mới={len(new_topics)}, trích dẫn mới={len(new_quotes)}"
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -603,28 +613,21 @@ class AnalysisApplicationService:
|
||||
self, group_id: str, platform_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
基于滑动窗口内的增量批次生成最终报告。
|
||||
Tạo báo cáo cuối từ các batch gia tăng trong cửa sổ trượt.
|
||||
|
||||
按 analysis_days × 24h 的时间窗口查询所有批次,
|
||||
合并为 IncrementalState,额外执行用户称号分析,
|
||||
然后生成与传统每日分析格式完全一致的 analysis_result。
|
||||
Truy vấn batch theo cửa sổ ``analysis_days × 24 giờ``, gộp thành
|
||||
``IncrementalState``, phân tích thêm danh hiệu thành viên rồi tạo
|
||||
``analysis_result`` cùng định dạng với phân tích hằng ngày.
|
||||
|
||||
流程:
|
||||
1. 计算滑动窗口范围
|
||||
2. 查询窗口内的所有批次
|
||||
3. 检查批次有效性
|
||||
4. 合并批次为 IncrementalState
|
||||
5. 执行用户称号 LLM 分析(基于合并后的累积数据)
|
||||
6. 使用 IncrementalMergeService 构建 analysis_result
|
||||
7. 持久化到 history_manager
|
||||
8. 返回结果
|
||||
Quy trình: tính cửa sổ, truy vấn và kiểm tra batch, gộp trạng thái,
|
||||
phân tích danh hiệu bằng LLM, dựng kết quả, lưu lịch sử và trả kết quả.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
platform_id: 平台标识,缺省为默认
|
||||
group_id: ID nhóm.
|
||||
platform_id: ID nền tảng; mặc định dùng nền tảng mặc định.
|
||||
|
||||
Returns:
|
||||
dict: 包含 success、analysis_result、adapter 等信息
|
||||
Dict chứa success, analysis_result, adapter và thông tin liên quan.
|
||||
"""
|
||||
async with self.group_lock(group_id, "final"):
|
||||
if not self.incremental_store or not self.incremental_merge_service:
|
||||
@@ -634,48 +637,50 @@ class AnalysisApplicationService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"开始增量最终报告: 群 {group_id}, 平台 {platform_id or '默认'}"
|
||||
f"Bắt đầu báo cáo gia tăng cuối: nhóm {group_id}, nền tảng {platform_id or 'mặc định'}"
|
||||
)
|
||||
|
||||
# 1. 计算滑动窗口范围
|
||||
# 1. Tính phạm vi cửa sổ trượt
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
window_end = time_mod.time()
|
||||
window_start = window_end - (analysis_days * 24 * 3600)
|
||||
|
||||
# 2. 查询窗口内的所有批次
|
||||
# 2. Truy vấn mọi batch trong cửa sổ
|
||||
batches = await self.incremental_store.query_batches(
|
||||
group_id, window_start, window_end
|
||||
)
|
||||
|
||||
# 3. 检查批次有效性
|
||||
# 3. Kiểm tra tính hợp lệ của batch
|
||||
if not batches:
|
||||
logger.warning(
|
||||
f"群 {group_id} 滑动窗口内无增量分析数据,无法生成最终报告"
|
||||
f"Nhóm {group_id} không có dữ liệu gia tăng trong cửa sổ trượt; không thể tạo báo cáo cuối"
|
||||
)
|
||||
return {"success": False, "reason": "no_incremental_data"}
|
||||
|
||||
# 4. 合并批次为 IncrementalState
|
||||
# 4. Gộp batch thành IncrementalState
|
||||
state = self.incremental_merge_service.merge_batches(
|
||||
batches, window_start, window_end
|
||||
)
|
||||
|
||||
# 5. 获取适配器(报告发送需要)
|
||||
# 5. Lấy adapter cần cho việc gửi báo cáo
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"Không tìm thấy adapter cho nền tảng {platform_id}")
|
||||
|
||||
# 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)
|
||||
# Kiểm tra nhóm có tắt quyền gửi tin hay không.
|
||||
if hasattr(adapter, "is_group_muted"):
|
||||
try:
|
||||
if await adapter.is_group_muted(group_id):
|
||||
logger.info(
|
||||
f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次增量最终报告生成"
|
||||
f"Nhóm {group_id} đang tắt quyền gửi tin toàn nhóm hoặc với bot; bỏ qua báo cáo cuối"
|
||||
)
|
||||
return {"success": False, "reason": "muted"}
|
||||
except Exception as e:
|
||||
logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}")
|
||||
logger.warning(
|
||||
f"Lỗi khi kiểm tra trạng thái tắt quyền gửi của nhóm {group_id}: {e}"
|
||||
)
|
||||
|
||||
# 6. 执行分析相关的变量准备
|
||||
# 6. Chuẩn bị biến cho quá trình phân tích
|
||||
user_titles = []
|
||||
user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
|
||||
unified_msg_origin = (
|
||||
@@ -684,24 +689,26 @@ class AnalysisApplicationService:
|
||||
|
||||
if user_title_enabled and state.user_activities:
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
# 从合并后的 user_activities 中取出 top 用户
|
||||
# Lấy các thành viên hàng đầu từ user_activities đã gộp
|
||||
top_users = state.get_user_activity_ranking(max_user_titles)
|
||||
|
||||
try:
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入称号分析队列 (群: {group_id})")
|
||||
logger.debug(
|
||||
f"[LLM] Đã vào hàng đợi phân tích danh hiệu (nhóm: {group_id})"
|
||||
)
|
||||
(
|
||||
user_titles_result,
|
||||
title_token_usage,
|
||||
) = await self.llm_analyzer.analyze_user_titles(
|
||||
messages=[], # 增量模式下不传原始消息
|
||||
messages=[], # Không truyền tin nhắn gốc ở chế độ gia tăng
|
||||
user_activity=state.user_activities,
|
||||
umo=unified_msg_origin,
|
||||
top_users=top_users,
|
||||
)
|
||||
user_titles = user_titles_result
|
||||
|
||||
# 将称号分析的 token 消耗追加到状态中
|
||||
# Cộng mức sử dụng token của phân tích danh hiệu vào trạng thái
|
||||
state.total_token_usage["prompt_tokens"] = (
|
||||
state.total_token_usage.get("prompt_tokens", 0)
|
||||
+ title_token_usage.prompt_tokens
|
||||
@@ -715,9 +722,12 @@ class AnalysisApplicationService:
|
||||
+ title_token_usage.total_tokens
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"增量最终报告用户称号分析失败: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Phân tích danh hiệu cho báo cáo gia tăng cuối thất bại: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 6.5 执行聊天质量汇总分析 (如果有多个批次的质量报告)
|
||||
# 6.5 Tổng hợp chất lượng trò chuyện nếu có đánh giá từ nhiều batch
|
||||
if (
|
||||
self.config_manager.get_chat_quality_analysis_enabled()
|
||||
and state.all_quality_reviews
|
||||
@@ -725,7 +735,7 @@ class AnalysisApplicationService:
|
||||
try:
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(
|
||||
f"[LLM] 已进入聊天质量汇总分析队列 (群: {group_id})"
|
||||
f"[LLM] Đã vào hàng đợi tổng hợp chất lượng trò chuyện (nhóm: {group_id})"
|
||||
)
|
||||
(
|
||||
summarized_review,
|
||||
@@ -735,9 +745,8 @@ class AnalysisApplicationService:
|
||||
umo=unified_msg_origin,
|
||||
)
|
||||
if summarized_review:
|
||||
# 更新 state 中的 review 为汇总后的结果
|
||||
# 这里我们需要将 QualityReview 对象存回 dict 或直接在后续处理中使用
|
||||
# build_analysis_result 会使用 state.chat_quality_review
|
||||
# Cập nhật review trong state bằng kết quả tổng hợp.
|
||||
# build_analysis_result sử dụng state.chat_quality_review.
|
||||
state.chat_quality_review = {
|
||||
"title": summarized_review.title,
|
||||
"subtitle": summarized_review.subtitle,
|
||||
@@ -753,7 +762,7 @@ class AnalysisApplicationService:
|
||||
"summary": summarized_review.summary,
|
||||
}
|
||||
|
||||
# 累加 Token
|
||||
# Cộng dồn token
|
||||
state.total_token_usage["prompt_tokens"] = (
|
||||
state.total_token_usage.get("prompt_tokens", 0)
|
||||
+ quality_token_usage.prompt_tokens
|
||||
@@ -767,22 +776,25 @@ class AnalysisApplicationService:
|
||||
+ quality_token_usage.total_tokens
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"增量最终报告聊天质量汇总失败: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Tổng hợp chất lượng trò chuyện cho báo cáo cuối thất bại: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 7. 构建 analysis_result
|
||||
# 7. Xây dựng analysis_result
|
||||
analysis_result = self.incremental_merge_service.build_analysis_result(
|
||||
state, user_titles
|
||||
)
|
||||
|
||||
# 8. 持久化到 history_manager
|
||||
# 8. Lưu vào history_manager
|
||||
await self.history_manager.save_analysis(group_id, analysis_result)
|
||||
|
||||
logger.info(
|
||||
f"群 {group_id} 增量最终报告完成: "
|
||||
f"窗口={state.get_window_date_str()}, "
|
||||
f"累计消息={state.total_message_count}, "
|
||||
f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}, "
|
||||
f"批次={state.total_analysis_count}"
|
||||
f"Hoàn tất báo cáo gia tăng cuối của nhóm {group_id}: "
|
||||
f"cửa sổ={state.get_window_date_str()}, "
|
||||
f"tin nhắn tích luỹ={state.total_message_count}, "
|
||||
f"chủ đề={len(state.topics)}, trích dẫn={len(state.golden_quotes)}, "
|
||||
f"batch={state.total_analysis_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -795,7 +807,7 @@ class AnalysisApplicationService:
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 辅助方法
|
||||
# Phương thức hỗ trợ
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
@@ -803,13 +815,13 @@ class AnalysisApplicationService:
|
||||
messages: list[UnifiedMessage],
|
||||
) -> tuple[dict[int, int], dict[int, int]]:
|
||||
"""
|
||||
从消息列表计算按小时的消息数和字符数分布。
|
||||
Tính phân bố số tin nhắn và ký tự theo giờ từ danh sách tin nhắn.
|
||||
|
||||
Args:
|
||||
messages: 统一格式的消息列表
|
||||
messages: Danh sách tin nhắn thống nhất.
|
||||
|
||||
Returns:
|
||||
tuple: (每小时消息计数, 每小时字符计数)
|
||||
Tuple gồm số tin nhắn và số ký tự theo giờ.
|
||||
"""
|
||||
hourly_msg: dict[int, int] = defaultdict(int)
|
||||
hourly_char: dict[int, int] = defaultdict(int)
|
||||
@@ -827,22 +839,22 @@ class AnalysisApplicationService:
|
||||
messages: list[UnifiedMessage],
|
||||
) -> dict[str, dict]:
|
||||
"""
|
||||
将 AnalysisDomainService.analyze_user_activity() 的返回格式
|
||||
转换为 IncrementalBatch 所需的 user_stats 格式。
|
||||
Chuyển kết quả của ``AnalysisDomainService.analyze_user_activity()``
|
||||
sang format ``user_stats`` mà ``IncrementalBatch`` yêu cầu.
|
||||
|
||||
转换映射:
|
||||
Ánh xạ chuyển đổi:
|
||||
- nickname -> name
|
||||
- hours (defaultdict) -> active_hours (list)
|
||||
- 新增 last_message_time(从消息时间戳中提取)
|
||||
- Thêm last_message_time lấy từ timestamp tin nhắn.
|
||||
|
||||
Args:
|
||||
user_activity: AnalysisDomainService 返回的用户活跃数据
|
||||
messages: 本批次的消息列表(用于提取每个用户的最后发言时间)
|
||||
user_activity: Dữ liệu hoạt động do AnalysisDomainService trả về.
|
||||
messages: Tin nhắn batch dùng để lấy thời gian cuối của từng thành viên.
|
||||
|
||||
Returns:
|
||||
dict: IncrementalBatch 所需的 user_stats 格式
|
||||
Dict ``user_stats`` theo format IncrementalBatch yêu cầu.
|
||||
"""
|
||||
# 预先计算每个用户的最后消息时间戳
|
||||
# Tính trước timestamp tin nhắn cuối của từng thành viên
|
||||
user_last_time: dict[str, int] = {}
|
||||
for msg in messages:
|
||||
current = user_last_time.get(msg.sender_id, 0)
|
||||
@@ -859,7 +871,7 @@ class AnalysisApplicationService:
|
||||
"reply_count": stats.get("reply_count", 0),
|
||||
"hours": dict(
|
||||
stats.get("hours", {})
|
||||
), # 这里的 hours 是 defaultdict(int),转为 dict
|
||||
), # hours là defaultdict(int), chuyển thành dict
|
||||
"last_message_time": user_last_time.get(user_id, 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,18 +13,18 @@ _QQ_OFFICIAL_MENTION_PATTERN = re.compile(r"<@!?([A-Za-z0-9_-]+)>")
|
||||
|
||||
class MessageProcessingService:
|
||||
"""
|
||||
消息处理服务
|
||||
Dịch vụ xử lý tin nhắn.
|
||||
|
||||
解析收到的群消息事件,提取内容与发送者信息,持久化历史记录,
|
||||
并维护事件驱动平台(Telegram、QQ 官方等)的群组注册表。
|
||||
QQ 官方平台特有的重复消息去重逻辑也在本服务中处理。
|
||||
Phân tích sự kiện tin nhắn nhóm, trích xuất nội dung và thông tin người gửi,
|
||||
lưu lịch sử và duy trì registry nhóm cho các nền tảng hướng sự kiện như
|
||||
Telegram và QQ Official. Dịch vụ cũng xử lý loại trùng riêng của QQ Official.
|
||||
|
||||
职责:
|
||||
1. 解析消息内容(文本、图片、@提及等)
|
||||
2. 解析发送者展示名(跨平台兼容)
|
||||
3. 存储消息历史
|
||||
4. 维护群组注册表,供调度器做群组发现(Telegram、QQ 官方等事件驱动平台)
|
||||
5. QQ 官方事件消息去重(按 message_id 预占 + 确认机制)
|
||||
Trách nhiệm:
|
||||
1. Phân tích nội dung tin nhắn như văn bản, hình ảnh và @mention.
|
||||
2. Xác định tên hiển thị của người gửi trên nhiều nền tảng.
|
||||
3. Lưu lịch sử tin nhắn.
|
||||
4. Duy trì registry để scheduler khám phá nhóm trên nền tảng hướng sự kiện.
|
||||
5. Loại trùng sự kiện QQ Official bằng cơ chế giữ chỗ và xác nhận message_id.
|
||||
"""
|
||||
|
||||
def __init__(self, context: Context, group_registry: PlatformGroupRegistry):
|
||||
@@ -36,45 +36,49 @@ class MessageProcessingService:
|
||||
|
||||
async def process_message(self, event: AstrMessageEvent) -> None:
|
||||
"""
|
||||
处理并在历史记录中存储消息。
|
||||
被 main.py 的 Telegram 和 QQ 官方消息拦截器共同调用。
|
||||
Xử lý và lưu tin nhắn vào lịch sử.
|
||||
|
||||
Args:
|
||||
event: AstrBot 消息事件
|
||||
Được interceptor Telegram và QQ Official trong ``main.py`` cùng gọi.
|
||||
|
||||
Raises:
|
||||
ValueError: 当必要数据无法获取时
|
||||
RuntimeError: 当消息内容为空时
|
||||
Args:
|
||||
event: Sự kiện tin nhắn AstrBot.
|
||||
|
||||
Raises:
|
||||
ValueError: Khi không lấy được dữ liệu bắt buộc.
|
||||
RuntimeError: Khi nội dung tin nhắn rỗng.
|
||||
"""
|
||||
# 1. 获取群组 ID(必需)
|
||||
# 1. Lấy ID nhóm (bắt buộc)
|
||||
group_id = self._get_group_id_from_event(event)
|
||||
if not group_id:
|
||||
raise ValueError("无法获取群组 ID,拒绝存储消息")
|
||||
raise ValueError("Không thể lấy ID nhóm; từ chối lưu tin nhắn")
|
||||
|
||||
# 2. 获取发送者 ID(必需)
|
||||
# 2. Lấy ID người gửi (bắt buộc)
|
||||
sender_id = event.get_sender_id()
|
||||
if not sender_id:
|
||||
raise ValueError(f"群 {group_id}: 无法获取发送者 ID,拒绝存储消息")
|
||||
raise ValueError(
|
||||
f"Nhóm {group_id}: không thể lấy ID người gửi; từ chối lưu tin nhắn"
|
||||
)
|
||||
sender_id = str(sender_id)
|
||||
|
||||
# 3. 获取发送者名称(昵称优先,必要时回退)
|
||||
# 3. Lấy tên người gửi, ưu tiên biệt danh và dùng fallback khi cần
|
||||
sender_name = self._resolve_sender_name(event, sender_id)
|
||||
|
||||
# 4. 获取平台 ID(必需)
|
||||
# 4. Lấy ID nền tảng (bắt buộc)
|
||||
platform_id = event.get_platform_id()
|
||||
if not platform_id:
|
||||
raise ValueError(f"群 {group_id}: 无法获取平台 ID,拒绝存储消息")
|
||||
|
||||
# 5. 提取消息内容
|
||||
message_parts = self._extract_message_parts(event)
|
||||
if not message_parts:
|
||||
# 尝试记录一条警告但不中断流程(或者视为错误)
|
||||
# 原逻辑是抛出 RuntimeError
|
||||
raise RuntimeError(
|
||||
f"群 {group_id}: 消息内容为空 (sender={sender_name}),拒绝存储"
|
||||
raise ValueError(
|
||||
f"Nhóm {group_id}: không thể lấy ID nền tảng; từ chối lưu tin nhắn"
|
||||
)
|
||||
|
||||
# 6. 提取事件消息 ID 和事件时间
|
||||
# 5. Trích xuất nội dung tin nhắn
|
||||
message_parts = self._extract_message_parts(event)
|
||||
if not message_parts:
|
||||
# Nội dung rỗng được coi là lỗi như hành vi ban đầu.
|
||||
raise RuntimeError(
|
||||
f"Nhóm {group_id}: nội dung tin nhắn rỗng (người gửi={sender_name}); từ chối lưu"
|
||||
)
|
||||
|
||||
# 6. Trích xuất ID tin nhắn sự kiện và thời gian sự kiện
|
||||
msg_obj = getattr(event, "message_obj", None)
|
||||
event_message_id = str(getattr(msg_obj, "message_id", "") or "")
|
||||
|
||||
@@ -83,7 +87,9 @@ class MessageProcessingService:
|
||||
if platform_name in {"qq_official", "qq_official_webhook"} and event_message_id:
|
||||
reserved_event_id = self._reserve_event_id(event_message_id)
|
||||
if not reserved_event_id:
|
||||
logger.debug("[QQOfficial] 跳过重复消息事件: %s", event_message_id)
|
||||
logger.debug(
|
||||
"[QQOfficial] Bỏ qua sự kiện tin nhắn trùng: %s", event_message_id
|
||||
)
|
||||
return
|
||||
history_content = {
|
||||
"type": "user",
|
||||
@@ -96,7 +102,7 @@ class MessageProcessingService:
|
||||
"timestamp": event_timestamp,
|
||||
}
|
||||
|
||||
# 7. 存储到数据库
|
||||
# 7. Lưu vào cơ sở dữ liệu
|
||||
try:
|
||||
await self.context.message_history_manager.insert(
|
||||
platform_id=platform_id,
|
||||
@@ -130,11 +136,11 @@ class MessageProcessingService:
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})"
|
||||
f"[{platform_id}] Đã lưu tin nhắn của nhóm {group_id} (người gửi: {sender_name})"
|
||||
)
|
||||
|
||||
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
||||
"""从消息事件中安全获取群组 ID"""
|
||||
"""Lấy ID nhóm an toàn từ sự kiện tin nhắn."""
|
||||
try:
|
||||
group_id = event.get_group_id()
|
||||
return group_id if group_id else None
|
||||
@@ -142,7 +148,7 @@ class MessageProcessingService:
|
||||
return None
|
||||
|
||||
def _resolve_sender_name(self, event: AstrMessageEvent, sender_id: str) -> str:
|
||||
"""解析发送者展示名"""
|
||||
"""Xác định tên hiển thị của người gửi."""
|
||||
platform_name = str(event.get_platform_name() or "").lower()
|
||||
candidates: list[str | None] = []
|
||||
|
||||
@@ -187,7 +193,7 @@ class MessageProcessingService:
|
||||
return sender_id
|
||||
|
||||
def _extract_message_parts(self, event: AstrMessageEvent) -> list[dict]:
|
||||
"""从事件中提取消息内容"""
|
||||
"""Trích xuất nội dung tin nhắn từ sự kiện."""
|
||||
message_parts = []
|
||||
message = event.message_obj
|
||||
platform_name = str(event.get_platform_name() or "").strip().lower()
|
||||
@@ -197,7 +203,7 @@ class MessageProcessingService:
|
||||
else None
|
||||
)
|
||||
|
||||
# 收集 @ 标记
|
||||
# Thu thập các @mention
|
||||
pending_mentions: Counter[str] = Counter()
|
||||
if message and hasattr(message, "message"):
|
||||
for seg in message.message:
|
||||
@@ -282,7 +288,7 @@ class MessageProcessingService:
|
||||
)
|
||||
message_parts.append({"type": "plain", "text": fallback_text})
|
||||
|
||||
# 清理空文本段
|
||||
# Loại bỏ phân đoạn văn bản rỗng
|
||||
message_parts = [
|
||||
part
|
||||
for part in message_parts
|
||||
@@ -338,7 +344,7 @@ class MessageProcessingService:
|
||||
).strip()
|
||||
display_name = display_name.lstrip("@").strip()
|
||||
if cls._is_placeholder_sender_name(display_name, mention_id):
|
||||
display_name = "群友"
|
||||
display_name = "Thành viên"
|
||||
replacements[mention_id] = f"@{display_name}"
|
||||
|
||||
return replacements
|
||||
@@ -348,8 +354,8 @@ class MessageProcessingService:
|
||||
def replace_mention(match: re.Match[str]) -> str:
|
||||
mention_id = match.group(1)
|
||||
if mention_id.lower() in {"all", "everyone"}:
|
||||
return "@全体成员"
|
||||
return replacements.get(mention_id, "@群友")
|
||||
return "@Tất cả thành viên"
|
||||
return replacements.get(mention_id, "@Thành viên")
|
||||
|
||||
cleaned = _QQ_OFFICIAL_MENTION_PATTERN.sub(replace_mention, str(text))
|
||||
return re.sub(r"[^\S\r\n]{2,}", " ", cleaned).strip(" \t")
|
||||
@@ -370,7 +376,7 @@ class MessageProcessingService:
|
||||
|
||||
@staticmethod
|
||||
def _strip_known_mentions(text: str, pending_mentions: Counter[str]) -> str:
|
||||
"""从文本中移除已识别的 @ 提及"""
|
||||
"""Xoá các @mention đã nhận diện khỏi văn bản."""
|
||||
cleaned = str(text)
|
||||
if not cleaned or not pending_mentions:
|
||||
return cleaned.strip()
|
||||
@@ -396,7 +402,7 @@ class MessageProcessingService:
|
||||
|
||||
@staticmethod
|
||||
def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool:
|
||||
"""判断 sender_name 是否为占位值"""
|
||||
"""Kiểm tra sender_name có phải giá trị placeholder hay không."""
|
||||
if not name:
|
||||
return True
|
||||
normalized = str(name).strip()
|
||||
@@ -408,7 +414,7 @@ class MessageProcessingService:
|
||||
|
||||
@staticmethod
|
||||
def _extract_event_timestamp(message_obj: object) -> int:
|
||||
"""从消息对象中提取平台事件时间戳。"""
|
||||
"""Trích xuất timestamp sự kiện nền tảng từ đối tượng tin nhắn."""
|
||||
raw_message = getattr(message_obj, "raw_message", None)
|
||||
if isinstance(raw_message, dict):
|
||||
candidate = raw_message.get("timestamp")
|
||||
@@ -437,7 +443,7 @@ class MessageProcessingService:
|
||||
return 0
|
||||
|
||||
def _reserve_event_id(self, event_message_id: str) -> bool:
|
||||
"""预占事件消息 ID:在历史记录持久化期间防止重复入库。"""
|
||||
"""Giữ chỗ ID sự kiện để tránh lưu trùng trong lúc ghi lịch sử."""
|
||||
if (
|
||||
event_message_id in self._inflight_event_ids
|
||||
or event_message_id in self._seen_event_ids
|
||||
@@ -449,7 +455,7 @@ class MessageProcessingService:
|
||||
return True
|
||||
|
||||
def _commit_event_id(self, event_message_id: str) -> None:
|
||||
"""确认事件消息 ID:标记为已持久化,纳入后续去重。"""
|
||||
"""Xác nhận ID sự kiện đã lưu để dùng cho các lần loại trùng sau."""
|
||||
self._inflight_event_ids.discard(event_message_id)
|
||||
if event_message_id in self._seen_event_ids:
|
||||
self._seen_event_ids.move_to_end(event_message_id)
|
||||
@@ -459,5 +465,5 @@ class MessageProcessingService:
|
||||
self._seen_event_ids.popitem(last=False)
|
||||
|
||||
def _release_event_id(self, event_message_id: str) -> None:
|
||||
"""释放事件消息 ID:持久化失败或取消时清理预占状态。"""
|
||||
"""Giải phóng ID sự kiện khi việc lưu thất bại hoặc bị huỷ."""
|
||||
self._inflight_event_ids.discard(event_message_id)
|
||||
|
||||
@@ -1 +1 @@
|
||||
# 领域层 - 与平台无关的业务逻辑
|
||||
# Tầng domain - logic nghiệp vụ độc lập với nền tảng
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
领域实体
|
||||
Các entity thuộc tầng domain.
|
||||
|
||||
该模块导出所有领域实体类,包括:
|
||||
- AnalysisTask: 分析任务聚合根
|
||||
- IncrementalBatch: 增量分析独立批次实体
|
||||
- IncrementalState: 增量分析聚合视图(报告时使用)
|
||||
Module này export các entity domain, gồm:
|
||||
- AnalysisTask: aggregate root của tác vụ phân tích
|
||||
- IncrementalBatch: entity đại diện cho một batch phân tích gia tăng
|
||||
- IncrementalState: view tổng hợp phân tích gia tăng dùng khi tạo báo cáo
|
||||
"""
|
||||
|
||||
from .analysis_task import AnalysisTask, TaskStatus
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
分析任务实体 - 聚合根
|
||||
"""
|
||||
"""Entity tác vụ phân tích - aggregate root."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
@@ -22,7 +20,7 @@ class TaskStatus(Enum):
|
||||
|
||||
@dataclass
|
||||
class AnalysisTask:
|
||||
"""分析任务实体 - 聚合根"""
|
||||
"""Entity tác vụ phân tích - aggregate root."""
|
||||
|
||||
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
||||
group_id: str = ""
|
||||
@@ -37,34 +35,34 @@ class AnalysisTask:
|
||||
error_message: str | None = None
|
||||
|
||||
def start(self, can_analyze: bool) -> bool:
|
||||
"""启动任务,验证平台能力"""
|
||||
"""Khởi động tác vụ và kiểm tra năng lực nền tảng."""
|
||||
if not can_analyze:
|
||||
self.status = TaskStatus.UNSUPPORTED_PLATFORM
|
||||
self.error_message = f"平台 {self.platform_name} 不支持分析"
|
||||
self.error_message = f"Nền tảng {self.platform_name} không hỗ trợ phân tích"
|
||||
return False
|
||||
self.status = TaskStatus.FETCHING_MESSAGES
|
||||
self.started_at = time.time()
|
||||
return True
|
||||
|
||||
def advance_to(self, status: TaskStatus):
|
||||
"""推进到下一个状态"""
|
||||
"""Chuyển tác vụ sang trạng thái tiếp theo."""
|
||||
self.status = status
|
||||
|
||||
def complete(self, result_id: str):
|
||||
"""标记任务为已完成"""
|
||||
"""Đánh dấu tác vụ đã hoàn tất."""
|
||||
self.status = TaskStatus.COMPLETED
|
||||
self.result_id = result_id
|
||||
self.completed_at = time.time()
|
||||
|
||||
def fail(self, error: str):
|
||||
"""标记任务为失败"""
|
||||
"""Đánh dấu tác vụ thất bại."""
|
||||
self.status = TaskStatus.FAILED
|
||||
self.error_message = error
|
||||
self.completed_at = time.time()
|
||||
|
||||
@property
|
||||
def duration(self) -> float | None:
|
||||
"""获取任务持续时间(秒)"""
|
||||
"""Lấy thời lượng thực thi tác vụ tính bằng giây."""
|
||||
if self.started_at and self.completed_at:
|
||||
return self.completed_at - self.started_at
|
||||
return None
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
增量分析实体 — 滑动窗口批次存储架构
|
||||
Entity phân tích gia tăng theo kiến trúc lưu batch trong cửa sổ trượt.
|
||||
|
||||
核心概念:
|
||||
- IncrementalBatch: 单次增量分析产生的独立批次数据,按批次独立存储
|
||||
- IncrementalState: 报告生成时由多个批次合并而成的聚合视图(不再持久化)
|
||||
Khái niệm cốt lõi:
|
||||
- IncrementalBatch: dữ liệu độc lập sinh ra từ một lần phân tích gia tăng
|
||||
- IncrementalState: view tổng hợp từ nhiều batch khi tạo báo cáo, không lưu bền vững
|
||||
|
||||
滑动窗口设计:
|
||||
- 每次增量分析产生一个 IncrementalBatch,独立存储到 KV
|
||||
- 最终报告时按 analysis_days × 24h 的时间窗口查询批次并合并
|
||||
- 支持同一天多次发送报告,每次都基于当前时间窗口内的所有批次
|
||||
Thiết kế cửa sổ trượt:
|
||||
- Mỗi lần phân tích gia tăng tạo một IncrementalBatch và lưu riêng vào KV
|
||||
- Khi tạo báo cáo cuối, truy vấn và gộp batch theo cửa sổ analysis_days × 24 giờ
|
||||
- Có thể gửi nhiều báo cáo trong ngày, mỗi báo cáo dựa trên mọi batch trong cửa sổ hiện tại
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -21,50 +21,50 @@ from typing import Any
|
||||
@dataclass
|
||||
class IncrementalBatch:
|
||||
"""
|
||||
单次增量分析批次数据
|
||||
Dữ liệu của một batch phân tích gia tăng.
|
||||
|
||||
每次增量分析执行完毕后产生一个 IncrementalBatch,
|
||||
包含该批次的所有统计数据和 LLM 分析结果,独立存储到 KV。
|
||||
Mỗi lần phân tích gia tăng hoàn tất sẽ tạo một ``IncrementalBatch`` chứa
|
||||
toàn bộ số liệu thống kê và kết quả LLM của batch, được lưu riêng vào KV.
|
||||
|
||||
Attributes:
|
||||
group_id: 群组 ID
|
||||
batch_id: 批次唯一标识(UUID)
|
||||
timestamp: 批次创建时间戳(epoch)
|
||||
messages_count: 本批次分析的消息数量
|
||||
characters_count: 本批次的总字符数
|
||||
hourly_msg_counts: 按小时的消息计数 {hour_str: count}
|
||||
hourly_char_counts: 按小时的字符计数 {hour_str: count}
|
||||
user_stats: 用户统计 {user_id: {name, message_count, char_count, ...}}
|
||||
emoji_stats: 表情统计 {emoji_type: count}
|
||||
topics: 本批次提取的话题列表
|
||||
golden_quotes: 本批次提取的金句列表
|
||||
token_usage: 本批次 token 消耗 {prompt_tokens, completion_tokens, total_tokens}
|
||||
chat_quality_review: 本批次提取的聊天质量锐评
|
||||
last_message_timestamp: 本批次最后一条消息的时间戳
|
||||
participant_ids: 本批次参与者 ID 列表
|
||||
group_id: ID nhóm.
|
||||
batch_id: UUID duy nhất của batch.
|
||||
timestamp: Epoch timestamp khi tạo batch.
|
||||
messages_count: Số tin nhắn được phân tích trong batch.
|
||||
characters_count: Tổng số ký tự trong batch.
|
||||
hourly_msg_counts: Số tin nhắn theo giờ.
|
||||
hourly_char_counts: Số ký tự theo giờ.
|
||||
user_stats: Thống kê thành viên.
|
||||
emoji_stats: Thống kê biểu cảm.
|
||||
topics: Danh sách chủ đề trích xuất từ batch.
|
||||
golden_quotes: Danh sách trích dẫn nổi bật từ batch.
|
||||
token_usage: Mức sử dụng token của batch.
|
||||
chat_quality_review: Đánh giá chất lượng trò chuyện của batch.
|
||||
last_message_timestamp: Timestamp tin nhắn cuối trong batch.
|
||||
participant_ids: Danh sách ID người tham gia trong batch.
|
||||
"""
|
||||
|
||||
group_id: str = ""
|
||||
batch_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
# 统计数据
|
||||
# Dữ liệu thống kê
|
||||
messages_count: int = 0
|
||||
characters_count: int = 0
|
||||
hourly_msg_counts: dict[str, int] = field(default_factory=dict)
|
||||
hourly_char_counts: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# 用户活跃数据
|
||||
# Dữ liệu hoạt động của thành viên
|
||||
user_stats: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# 表情统计
|
||||
# Thống kê biểu cảm
|
||||
emoji_stats: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# LLM 分析结果
|
||||
# Kết quả phân tích bằng LLM
|
||||
topics: list[dict] = field(default_factory=list)
|
||||
golden_quotes: list[dict] = field(default_factory=list)
|
||||
|
||||
# Token 消耗
|
||||
# Mức sử dụng token
|
||||
token_usage: dict = field(
|
||||
default_factory=lambda: {
|
||||
"prompt_tokens": 0,
|
||||
@@ -73,13 +73,13 @@ class IncrementalBatch:
|
||||
}
|
||||
)
|
||||
|
||||
# 增量追踪
|
||||
# Theo dõi phân tích gia tăng
|
||||
chat_quality_review: dict[str, Any] | None = None
|
||||
last_message_timestamp: int = 0
|
||||
participant_ids: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""序列化为字典,用于 KV 存储"""
|
||||
"""Tuần tự hoá thành dict để lưu trong KV."""
|
||||
return {
|
||||
"group_id": self.group_id,
|
||||
"batch_id": self.batch_id,
|
||||
@@ -100,7 +100,7 @@ class IncrementalBatch:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "IncrementalBatch":
|
||||
"""从字典反序列化"""
|
||||
"""Khôi phục đối tượng từ dict."""
|
||||
return cls(
|
||||
group_id=data.get("group_id", ""),
|
||||
batch_id=data.get("batch_id", ""),
|
||||
@@ -127,7 +127,7 @@ class IncrementalBatch:
|
||||
)
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""获取批次摘要信息"""
|
||||
"""Lấy thông tin tóm tắt của batch."""
|
||||
return {
|
||||
"batch_id": self.batch_id[:8],
|
||||
"timestamp": datetime.fromtimestamp(self.timestamp).strftime(
|
||||
@@ -143,53 +143,53 @@ class IncrementalBatch:
|
||||
@dataclass
|
||||
class IncrementalState:
|
||||
"""
|
||||
增量分析聚合视图(报告时使用)
|
||||
View tổng hợp phân tích gia tăng dùng khi tạo báo cáo.
|
||||
|
||||
由多个 IncrementalBatch 合并而成,不直接持久化。
|
||||
IncrementalMergeService.merge_batches() 负责从批次列表构建此对象。
|
||||
Được gộp từ nhiều ``IncrementalBatch`` và không lưu trực tiếp.
|
||||
``IncrementalMergeService.merge_batches()`` xây dựng đối tượng từ danh sách batch.
|
||||
|
||||
Attributes:
|
||||
group_id: 群组 ID
|
||||
window_start: 滑动窗口起始时间戳
|
||||
window_end: 滑动窗口结束时间戳
|
||||
topics: 合并去重后的话题列表
|
||||
golden_quotes: 合并去重后的金句列表
|
||||
hourly_message_counts: 合并后的每小时消息计数 {hour_str: count}
|
||||
hourly_character_counts: 合并后的每小时字符计数 {hour_str: count}
|
||||
user_activities: 合并后的用户活跃数据
|
||||
emoji_counts: 合并后的表情统计
|
||||
total_message_count: 窗口内总消息数
|
||||
total_character_count: 窗口内总字符数
|
||||
total_analysis_count: 窗口内批次数量
|
||||
total_token_usage: 累计 token 消耗
|
||||
last_analyzed_message_timestamp: 最后分析消息时间戳
|
||||
all_participant_ids: 所有参与者 ID 集合
|
||||
group_id: ID nhóm.
|
||||
window_start: Timestamp bắt đầu cửa sổ trượt.
|
||||
window_end: Timestamp kết thúc cửa sổ trượt.
|
||||
topics: Danh sách chủ đề đã gộp và loại trùng.
|
||||
golden_quotes: Danh sách trích dẫn đã gộp và loại trùng.
|
||||
hourly_message_counts: Số tin nhắn theo giờ sau khi gộp.
|
||||
hourly_character_counts: Số ký tự theo giờ sau khi gộp.
|
||||
user_activities: Dữ liệu hoạt động thành viên sau khi gộp.
|
||||
emoji_counts: Thống kê biểu cảm sau khi gộp.
|
||||
total_message_count: Tổng số tin nhắn trong cửa sổ.
|
||||
total_character_count: Tổng số ký tự trong cửa sổ.
|
||||
total_analysis_count: Số batch trong cửa sổ.
|
||||
total_token_usage: Tổng mức sử dụng token.
|
||||
last_analyzed_message_timestamp: Timestamp tin nhắn được phân tích cuối cùng.
|
||||
all_participant_ids: Tập hợp ID của tất cả người tham gia.
|
||||
"""
|
||||
|
||||
# 标识信息
|
||||
# Thông tin định danh
|
||||
group_id: str = ""
|
||||
window_start: float = 0.0
|
||||
window_end: float = 0.0
|
||||
|
||||
# 合并后的 LLM 分析结果
|
||||
# Kết quả phân tích LLM sau khi gộp
|
||||
topics: list[dict] = field(default_factory=list)
|
||||
golden_quotes: list[dict] = field(default_factory=list)
|
||||
chat_quality_review: dict[str, Any] | None = None
|
||||
all_quality_reviews: list[dict] = field(
|
||||
default_factory=list
|
||||
) # 存储所有批次的质量锐评,用于最终报告时的汇总分析
|
||||
) # Lưu đánh giá của mọi batch để tổng hợp khi tạo báo cáo cuối
|
||||
|
||||
# 合并后的统计数据(按小时)
|
||||
# Dữ liệu thống kê theo giờ sau khi gộp
|
||||
hourly_message_counts: dict[str, int] = field(default_factory=dict)
|
||||
hourly_character_counts: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# 用户活跃数据
|
||||
# Dữ liệu hoạt động của thành viên
|
||||
user_activities: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# 表情统计
|
||||
# Thống kê biểu cảm
|
||||
emoji_counts: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 汇总统计
|
||||
# Thống kê tổng hợp
|
||||
total_message_count: int = 0
|
||||
total_character_count: int = 0
|
||||
total_analysis_count: int = 0
|
||||
@@ -201,23 +201,23 @@ class IncrementalState:
|
||||
}
|
||||
)
|
||||
|
||||
# 增量跟踪
|
||||
# Theo dõi phân tích gia tăng
|
||||
last_analyzed_message_timestamp: int = 0
|
||||
all_participant_ids: set[str] = field(default_factory=set)
|
||||
|
||||
# 元数据
|
||||
# Metadata
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
|
||||
def get_peak_hours(self, top_n: int = 3) -> list[int]:
|
||||
"""
|
||||
获取消息最活跃的时段。
|
||||
Lấy các giờ có nhiều tin nhắn nhất.
|
||||
|
||||
Args:
|
||||
top_n: 返回前 N 个最活跃的小时
|
||||
top_n: Số giờ hoạt động tích cực nhất cần trả về.
|
||||
|
||||
Returns:
|
||||
list[int]: 活跃小时列表,按消息量降序
|
||||
Danh sách giờ, sắp xếp giảm dần theo số tin nhắn.
|
||||
"""
|
||||
if not self.hourly_message_counts:
|
||||
return []
|
||||
@@ -230,26 +230,26 @@ class IncrementalState:
|
||||
|
||||
def get_most_active_period(self) -> str:
|
||||
"""
|
||||
获取最活跃时段的描述字符串。
|
||||
Lấy chuỗi mô tả khung giờ hoạt động tích cực nhất.
|
||||
|
||||
Returns:
|
||||
str: 如 "20:00-21:00"
|
||||
Chuỗi dạng ``20:00-21:00``.
|
||||
"""
|
||||
peak = self.get_peak_hours(1)
|
||||
if not peak:
|
||||
return "未知"
|
||||
return "Không xác định"
|
||||
hour = peak[0]
|
||||
return f"{hour:02d}:00-{hour + 1:02d}:00"
|
||||
|
||||
def get_user_activity_ranking(self, top_n: int = 10) -> list[dict]:
|
||||
"""
|
||||
获取用户活跃度排名。
|
||||
Lấy bảng xếp hạng mức độ hoạt động của thành viên.
|
||||
|
||||
Args:
|
||||
top_n: 返回前 N 名
|
||||
top_n: Số thành viên đứng đầu cần trả về.
|
||||
|
||||
Returns:
|
||||
list[dict]: 按消息数降序排列的用户列表
|
||||
Danh sách thành viên sắp xếp giảm dần theo số tin nhắn.
|
||||
"""
|
||||
users = []
|
||||
for user_id, data in self.user_activities.items():
|
||||
@@ -266,10 +266,10 @@ class IncrementalState:
|
||||
|
||||
def get_window_date_str(self) -> str:
|
||||
"""
|
||||
获取窗口的日期范围字符串,用于报告显示。
|
||||
Lấy chuỗi phạm vi ngày của cửa sổ để hiển thị trong báo cáo.
|
||||
|
||||
Returns:
|
||||
str: 如 "2024-01-15" 或 "2024-01-14 ~ 2024-01-15"
|
||||
Chuỗi như ``2024-01-15`` hoặc ``2024-01-14 ~ 2024-01-15``.
|
||||
"""
|
||||
if self.window_start <= 0 or self.window_end <= 0:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
@@ -283,10 +283,10 @@ class IncrementalState:
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""
|
||||
获取当前增量状态的摘要信息,用于状态查询命令。
|
||||
Lấy tóm tắt trạng thái gia tăng cho lệnh truy vấn trạng thái.
|
||||
|
||||
Returns:
|
||||
dict: 包含关键统计信息的摘要
|
||||
Bản tóm tắt chứa các số liệu thống kê chính.
|
||||
"""
|
||||
return {
|
||||
"group_id": self.group_id,
|
||||
@@ -301,7 +301,7 @@ class IncrementalState:
|
||||
"last_analysis_time": (
|
||||
datetime.fromtimestamp(self.updated_at).strftime("%H:%M:%S")
|
||||
if self.updated_at
|
||||
else "无"
|
||||
else "Không có"
|
||||
),
|
||||
"peak_hours": self.get_peak_hours(3),
|
||||
}
|
||||
@@ -311,18 +311,18 @@ class IncrementalState:
|
||||
new_topic: dict, existing_topics: list[dict], threshold: float = 0.6
|
||||
) -> bool:
|
||||
"""
|
||||
检测话题是否与已有话题重复。
|
||||
Kiểm tra chủ đề có trùng với chủ đề hiện có hay không.
|
||||
|
||||
使用简单的字符重叠相似度判断。
|
||||
当新话题的名称与已有话题名称相似度超过阈值时,认为是重复话题。
|
||||
Dùng độ tương đồng giao nhau ký tự đơn giản. Nếu độ tương đồng giữa
|
||||
tên chủ đề mới và tên hiện có vượt ngưỡng thì coi là trùng lặp.
|
||||
|
||||
Args:
|
||||
new_topic: 待检测的新话题
|
||||
existing_topics: 已有话题列表
|
||||
threshold: 相似度阈值(0-1),默认 0.6
|
||||
new_topic: Chủ đề mới cần kiểm tra.
|
||||
existing_topics: Danh sách chủ đề hiện có.
|
||||
threshold: Ngưỡng tương đồng từ 0 đến 1, mặc định là 0.6.
|
||||
|
||||
Returns:
|
||||
bool: 是否重复
|
||||
Có trùng lặp hay không.
|
||||
"""
|
||||
new_name = new_topic.get("topic", "")
|
||||
if not new_name:
|
||||
@@ -344,15 +344,15 @@ class IncrementalState:
|
||||
new_quote: dict, existing_quotes: list[dict], threshold: float = 0.7
|
||||
) -> bool:
|
||||
"""
|
||||
检测金句是否与已有金句重复。
|
||||
Kiểm tra trích dẫn có trùng với trích dẫn hiện có hay không.
|
||||
|
||||
Args:
|
||||
new_quote: 待检测的新金句
|
||||
existing_quotes: 已有金句列表
|
||||
threshold: 相似度阈值(0-1),默认 0.7
|
||||
new_quote: Trích dẫn mới cần kiểm tra.
|
||||
existing_quotes: Danh sách trích dẫn hiện có.
|
||||
threshold: Ngưỡng tương đồng từ 0 đến 1, mặc định là 0.7.
|
||||
|
||||
Returns:
|
||||
bool: 是否重复
|
||||
Có trùng lặp hay không.
|
||||
"""
|
||||
new_content = new_quote.get("content", "")
|
||||
if not new_content:
|
||||
@@ -372,14 +372,14 @@ class IncrementalState:
|
||||
@staticmethod
|
||||
def char_overlap_similarity(s1: str, s2: str) -> float:
|
||||
"""
|
||||
计算两个字符串的字符重叠相似度(Jaccard 相似系数)。
|
||||
Tính độ tương đồng giao nhau ký tự của hai chuỗi theo hệ số Jaccard.
|
||||
|
||||
Args:
|
||||
s1: 第一个字符串
|
||||
s2: 第二个字符串
|
||||
s1: Chuỗi thứ nhất.
|
||||
s2: Chuỗi thứ hai.
|
||||
|
||||
Returns:
|
||||
float: 相似度值(0-1)
|
||||
Độ tương đồng từ 0 đến 1.
|
||||
"""
|
||||
if not s1 or not s2:
|
||||
return 0.0
|
||||
|
||||
+35
-35
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
领域异常 - 领域层自定义异常
|
||||
Ngoại lệ domain - các ngoại lệ tuỳ chỉnh của tầng domain.
|
||||
|
||||
该模块包含插件中使用的所有领域特定异常。
|
||||
这些异常是平台无关的,表示业务逻辑错误。
|
||||
Module này chứa các ngoại lệ đặc thù nghiệp vụ được plugin sử dụng.
|
||||
Các ngoại lệ độc lập với nền tảng và biểu diễn lỗi logic nghiệp vụ.
|
||||
"""
|
||||
|
||||
|
||||
class DomainException(Exception):
|
||||
"""所有领域错误的基础异常。"""
|
||||
"""Ngoại lệ cơ sở cho mọi lỗi domain."""
|
||||
|
||||
def __init__(self, message: str, code: str = "DOMAIN_ERROR"):
|
||||
self.message = message
|
||||
@@ -16,33 +16,33 @@ class DomainException(Exception):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 分析异常
|
||||
# Ngoại lệ phân tích
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AnalysisException(DomainException):
|
||||
"""分析相关错误的基础异常。"""
|
||||
"""Ngoại lệ cơ sở cho các lỗi liên quan đến phân tích."""
|
||||
|
||||
def __init__(self, message: str, code: str = "ANALYSIS_ERROR"):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class InsufficientDataException(AnalysisException):
|
||||
"""当数据不足以进行分析时抛出。"""
|
||||
"""Được phát sinh khi dữ liệu không đủ để phân tích."""
|
||||
|
||||
def __init__(self, message: str = "Không đủ dữ liệu để phân tích"):
|
||||
super().__init__(message, "INSUFFICIENT_DATA")
|
||||
|
||||
|
||||
class AnalysisTimeoutException(AnalysisException):
|
||||
"""当分析超时时抛出。"""
|
||||
"""Được phát sinh khi phân tích quá thời gian cho phép."""
|
||||
|
||||
def __init__(self, message: str = "Phân tích bị timeout"):
|
||||
super().__init__(message, "ANALYSIS_TIMEOUT")
|
||||
|
||||
|
||||
class LLMException(AnalysisException):
|
||||
"""当 LLM API 调用失败时抛出。"""
|
||||
"""Được phát sinh khi gọi API LLM thất bại."""
|
||||
|
||||
def __init__(self, message: str = "Gọi API LLM thất bại", provider: str = ""):
|
||||
self.provider = provider
|
||||
@@ -53,7 +53,7 @@ class LLMException(AnalysisException):
|
||||
|
||||
|
||||
class LLMRateLimitException(LLMException):
|
||||
"""当 LLM API 速率限制超出时抛出。"""
|
||||
"""Được phát sinh khi vượt quá giới hạn tốc độ của API LLM."""
|
||||
|
||||
def __init__(
|
||||
self, message: str = "Vượt quá giới hạn tốc độ LLM", provider: str = ""
|
||||
@@ -63,7 +63,7 @@ class LLMRateLimitException(LLMException):
|
||||
|
||||
|
||||
class LLMQuotaExceededException(LLMException):
|
||||
"""当 LLM API 配额超出时抛出。"""
|
||||
"""Được phát sinh khi vượt quá hạn ngạch của API LLM."""
|
||||
|
||||
def __init__(self, message: str = "Vượt quá hạn ngạch LLM", provider: str = ""):
|
||||
super().__init__(message, provider)
|
||||
@@ -71,12 +71,12 @@ class LLMQuotaExceededException(LLMException):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 平台异常
|
||||
# Ngoại lệ nền tảng
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PlatformException(DomainException):
|
||||
"""平台相关错误的基础异常。"""
|
||||
"""Ngoại lệ cơ sở cho các lỗi liên quan đến nền tảng."""
|
||||
|
||||
def __init__(self, message: str, platform: str = "", code: str = "PLATFORM_ERROR"):
|
||||
self.platform = platform
|
||||
@@ -84,7 +84,7 @@ class PlatformException(DomainException):
|
||||
|
||||
|
||||
class PlatformNotSupportedException(PlatformException):
|
||||
"""当平台不被支持时抛出。"""
|
||||
"""Được phát sinh khi nền tảng không được hỗ trợ."""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
@@ -95,21 +95,21 @@ class PlatformNotSupportedException(PlatformException):
|
||||
|
||||
|
||||
class PlatformConnectionException(PlatformException):
|
||||
"""当连接平台失败时抛出。"""
|
||||
"""Được phát sinh khi kết nối đến nền tảng thất bại."""
|
||||
|
||||
def __init__(self, message: str = "Kết nối nền tảng thất bại", platform: str = ""):
|
||||
super().__init__(message, platform, "PLATFORM_CONNECTION_ERROR")
|
||||
|
||||
|
||||
class PlatformAPIException(PlatformException):
|
||||
"""当平台 API 调用失败时抛出。"""
|
||||
"""Được phát sinh khi gọi API nền tảng thất bại."""
|
||||
|
||||
def __init__(self, message: str = "Gọi API nền tảng thất bại", platform: str = ""):
|
||||
super().__init__(message, platform, "PLATFORM_API_ERROR")
|
||||
|
||||
|
||||
class MessageFetchException(PlatformException):
|
||||
"""当获取消息失败时抛出。"""
|
||||
"""Được phát sinh khi lấy tin nhắn thất bại."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -126,7 +126,7 @@ class MessageFetchException(PlatformException):
|
||||
|
||||
|
||||
class MessageSendException(PlatformException):
|
||||
"""当发送消息失败时抛出。"""
|
||||
"""Được phát sinh khi gửi tin nhắn thất bại."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -143,19 +143,19 @@ class MessageSendException(PlatformException):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 配置异常
|
||||
# Ngoại lệ cấu hình
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ConfigurationException(DomainException):
|
||||
"""配置相关错误的基础异常。"""
|
||||
"""Ngoại lệ cơ sở cho các lỗi liên quan đến cấu hình."""
|
||||
|
||||
def __init__(self, message: str, code: str = "CONFIG_ERROR"):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class InvalidConfigurationException(ConfigurationException):
|
||||
"""当配置无效时抛出。"""
|
||||
"""Được phát sinh khi cấu hình không hợp lệ."""
|
||||
|
||||
def __init__(self, message: str = "Cấu hình không hợp lệ", key: str = ""):
|
||||
self.key = key
|
||||
@@ -163,7 +163,7 @@ class InvalidConfigurationException(ConfigurationException):
|
||||
|
||||
|
||||
class MissingConfigurationException(ConfigurationException):
|
||||
"""当缺少必需配置时抛出。"""
|
||||
"""Được phát sinh khi thiếu cấu hình bắt buộc."""
|
||||
|
||||
def __init__(self, key: str):
|
||||
self.key = key
|
||||
@@ -171,19 +171,19 @@ class MissingConfigurationException(ConfigurationException):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 仓储异常
|
||||
# Ngoại lệ repository
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class RepositoryException(DomainException):
|
||||
"""仓储相关错误的基础异常。"""
|
||||
"""Ngoại lệ cơ sở cho các lỗi liên quan đến repository."""
|
||||
|
||||
def __init__(self, message: str, code: str = "REPOSITORY_ERROR"):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class DataNotFoundException(RepositoryException):
|
||||
"""当请求的数据未找到时抛出。"""
|
||||
"""Được phát sinh khi không tìm thấy dữ liệu được yêu cầu."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -200,26 +200,26 @@ class DataNotFoundException(RepositoryException):
|
||||
|
||||
|
||||
class DataPersistenceException(RepositoryException):
|
||||
"""当数据持久化失败时抛出。"""
|
||||
"""Được phát sinh khi lưu trữ dữ liệu thất bại."""
|
||||
|
||||
def __init__(self, message: str = "Lưu trữ dữ liệu thất bại"):
|
||||
super().__init__(message, "DATA_PERSISTENCE_ERROR")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 调度异常
|
||||
# Ngoại lệ lập lịch
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SchedulingException(DomainException):
|
||||
"""调度相关错误的基础异常。"""
|
||||
"""Ngoại lệ cơ sở cho các lỗi liên quan đến lập lịch."""
|
||||
|
||||
def __init__(self, message: str, code: str = "SCHEDULING_ERROR"):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class TaskAlreadyScheduledException(SchedulingException):
|
||||
"""当尝试调度已调度的任务时抛出。"""
|
||||
"""Được phát sinh khi cố lập lịch cho tác vụ đã được lập lịch."""
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
self.task_id = task_id
|
||||
@@ -229,7 +229,7 @@ class TaskAlreadyScheduledException(SchedulingException):
|
||||
|
||||
|
||||
class TaskNotFoundException(SchedulingException):
|
||||
"""当找不到已调度的任务时抛出。"""
|
||||
"""Được phát sinh khi không tìm thấy tác vụ đã lập lịch."""
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
self.task_id = task_id
|
||||
@@ -239,12 +239,12 @@ class TaskNotFoundException(SchedulingException):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 验证异常
|
||||
# Ngoại lệ xác thực
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ValidationException(DomainException):
|
||||
"""验证错误的基础异常。"""
|
||||
"""Ngoại lệ cơ sở cho các lỗi xác thực."""
|
||||
|
||||
def __init__(self, message: str, field: str = "", code: str = "VALIDATION_ERROR"):
|
||||
self.field = field
|
||||
@@ -252,7 +252,7 @@ class ValidationException(DomainException):
|
||||
|
||||
|
||||
class InvalidGroupIdException(ValidationException):
|
||||
"""当群组 ID 无效时抛出。"""
|
||||
"""Được phát sinh khi ID nhóm không hợp lệ."""
|
||||
|
||||
def __init__(self, group_id: str):
|
||||
super().__init__(
|
||||
@@ -261,7 +261,7 @@ class InvalidGroupIdException(ValidationException):
|
||||
|
||||
|
||||
class InvalidUserIdException(ValidationException):
|
||||
"""当用户 ID 无效时抛出。"""
|
||||
"""Được phát sinh khi ID thành viên không hợp lệ."""
|
||||
|
||||
def __init__(self, user_id: str):
|
||||
super().__init__(
|
||||
@@ -270,7 +270,7 @@ class InvalidUserIdException(ValidationException):
|
||||
|
||||
|
||||
class InvalidMessageException(ValidationException):
|
||||
"""当消息格式无效时抛出。"""
|
||||
"""Được phát sinh khi định dạng tin nhắn không hợp lệ."""
|
||||
|
||||
def __init__(self, message: str = "Định dạng tin nhắn không hợp lệ"):
|
||||
super().__init__(message, "message", "INVALID_MESSAGE")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 仓储接口
|
||||
# Các giao diện repository
|
||||
from .avatar_repository import IAvatarRepository
|
||||
from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender
|
||||
from .visualization_repository import IActivityVisualizer
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
分析服务接口 - 领域层
|
||||
定义语义分析的抽象契约
|
||||
Giao diện dịch vụ phân tích thuộc tầng domain.
|
||||
|
||||
Định nghĩa hợp đồng trừu tượng cho chức năng phân tích ngữ nghĩa.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -15,9 +16,7 @@ from ..models.data_models import (
|
||||
|
||||
|
||||
class IAnalysisProvider(ABC):
|
||||
"""
|
||||
LLM 分析提供商接口
|
||||
"""
|
||||
"""Giao diện nhà cung cấp dịch vụ phân tích bằng LLM."""
|
||||
|
||||
@abstractmethod
|
||||
async def analyze_topics(
|
||||
@@ -26,7 +25,7 @@ class IAnalysisProvider(ABC):
|
||||
umo: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[SummaryTopic], TokenUsage]:
|
||||
"""分析话题"""
|
||||
"""Phân tích các chủ đề thảo luận."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -38,7 +37,7 @@ class IAnalysisProvider(ABC):
|
||||
top_users: list[dict] | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[UserTitle], TokenUsage]:
|
||||
"""分析用户称号"""
|
||||
"""Phân tích danh hiệu của thành viên."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -48,7 +47,7 @@ class IAnalysisProvider(ABC):
|
||||
umo: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[GoldenQuote], TokenUsage]:
|
||||
"""分析金句"""
|
||||
"""Phân tích các trích dẫn nổi bật."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -69,7 +68,7 @@ class IAnalysisProvider(ABC):
|
||||
TokenUsage,
|
||||
QualityReview | None,
|
||||
]:
|
||||
"""并发分析所有内容"""
|
||||
"""Phân tích đồng thời tất cả nội dung."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -83,7 +82,7 @@ class IAnalysisProvider(ABC):
|
||||
golden_quote_enabled: bool = True,
|
||||
chat_quality_enabled: bool = False,
|
||||
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]:
|
||||
"""增量模式并发分析"""
|
||||
"""Phân tích đồng thời ở chế độ gia tăng."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -93,5 +92,5 @@ class IAnalysisProvider(ABC):
|
||||
umo: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> tuple[QualityReview | None, TokenUsage]:
|
||||
"""汇总多个聊天质量报告(增量模式使用)"""
|
||||
"""Tổng hợp nhiều báo cáo chất lượng trò chuyện ở chế độ gia tăng."""
|
||||
pass
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
"""
|
||||
头像仓储接口 - 跨平台头像抽象
|
||||
"""
|
||||
"""Giao diện repository ảnh đại diện - lớp trừu tượng đa nền tảng."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class IAvatarRepository(ABC):
|
||||
"""
|
||||
头像仓储接口
|
||||
Giao diện repository ảnh đại diện.
|
||||
|
||||
不同平台获取头像的方式不同:
|
||||
- QQ/OneBot: URL 模板 (q1.qlogo.cn)
|
||||
- Telegram: API 调用 (getUserProfilePhotos + getFile)
|
||||
- Discord: CDN URL 模板 (cdn.discordapp.com)
|
||||
- Slack: users.info API profile.image_* 字段
|
||||
Cách lấy ảnh đại diện khác nhau giữa các nền tảng:
|
||||
- QQ/OneBot: mẫu URL (q1.qlogo.cn)
|
||||
- Telegram: gọi API (getUserProfilePhotos + getFile)
|
||||
- Discord: mẫu URL CDN (cdn.discordapp.com)
|
||||
- Slack: trường profile.image_* của API users.info
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -23,14 +21,14 @@ class IAvatarRepository(ABC):
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""
|
||||
获取用户头像 URL
|
||||
Lấy URL ảnh đại diện thành viên.
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID
|
||||
size: 期望的头像尺寸(将选择最接近的可用尺寸)
|
||||
Args:
|
||||
user_id: ID thành viên.
|
||||
size: Kích thước mong muốn; chọn kích thước khả dụng gần nhất.
|
||||
|
||||
返回:
|
||||
头像 URL,如果不可用则返回 None
|
||||
Returns:
|
||||
URL ảnh đại diện hoặc ``None`` nếu không khả dụng.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -41,13 +39,12 @@ class IAvatarRepository(ABC):
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""
|
||||
获取用户头像的 Base64 数据
|
||||
Lấy dữ liệu Base64 của ảnh đại diện thành viên.
|
||||
|
||||
用于需要嵌入图片的场景(如 HTML 模板渲染)
|
||||
Dùng trong các trường hợp cần nhúng ảnh, chẳng hạn khi render template HTML.
|
||||
|
||||
返回:
|
||||
Base64 编码的图片数据 (data:image/png;base64,...),
|
||||
如果不可用则返回 None
|
||||
Returns:
|
||||
Dữ liệu ảnh mã hoá Base64 hoặc ``None`` nếu không khả dụng.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -57,7 +54,7 @@ class IAvatarRepository(ABC):
|
||||
group_id: str,
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""获取群组头像 URL"""
|
||||
"""Lấy URL ảnh đại diện nhóm."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -67,12 +64,12 @@ class IAvatarRepository(ABC):
|
||||
size: int = 100,
|
||||
) -> dict[str, str | None]:
|
||||
"""
|
||||
批量获取用户头像 URL
|
||||
Lấy URL ảnh đại diện của nhiều thành viên.
|
||||
|
||||
用于报告生成时需要一次获取多个头像
|
||||
Dùng khi tạo báo cáo cần lấy nhiều ảnh đại diện trong một lần.
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_default_avatar_url(self) -> str:
|
||||
"""获取默认头像 URL(当用户头像不可用时)"""
|
||||
"""Lấy URL ảnh đại diện mặc định khi ảnh của thành viên không khả dụng."""
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyaDE2di0yYzAtMi42Ni01LjMzLTQtOC00eiIvPjwvc3ZnPg=="
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
消息仓储接口 - 平台无关的抽象
|
||||
"""
|
||||
"""Giao diện repository tin nhắn độc lập với nền tảng."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
@@ -11,10 +9,10 @@ from ..value_objects.unified_message import UnifiedMessage
|
||||
|
||||
class IMessageRepository(ABC):
|
||||
"""
|
||||
消息仓储接口
|
||||
Giao diện repository tin nhắn.
|
||||
|
||||
每个平台适配器必须实现此接口。
|
||||
所有方法返回统一格式,隐藏平台差异。
|
||||
Mỗi adapter nền tảng phải triển khai giao diện này. Tất cả phương thức
|
||||
trả về định dạng thống nhất để che giấu khác biệt giữa các nền tảng.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -27,33 +25,33 @@ class IMessageRepository(ABC):
|
||||
since_ts: int | None = None,
|
||||
) -> list[UnifiedMessage]:
|
||||
"""
|
||||
获取群组消息历史
|
||||
Lấy lịch sử tin nhắn của nhóm.
|
||||
|
||||
参数:
|
||||
group_id: 群组 ID
|
||||
days: 获取最近 N 天的消息
|
||||
max_count: 最大消息数量
|
||||
before_id: 获取此 ID 之前的消息(用于分页)
|
||||
since_ts: 从指定时间戳开始拉取消息(Unix timestamp),优先级高于 days。
|
||||
Args:
|
||||
group_id: ID của nhóm.
|
||||
days: Số ngày gần nhất cần lấy tin nhắn.
|
||||
max_count: Số lượng tin nhắn tối đa.
|
||||
before_id: Chỉ lấy tin nhắn trước ID này, dùng để phân trang.
|
||||
since_ts: Lấy tin nhắn từ Unix timestamp này; ưu tiên hơn ``days``.
|
||||
|
||||
返回:
|
||||
统一消息列表,按时间升序排列
|
||||
Returns:
|
||||
Danh sách tin nhắn thống nhất, sắp xếp tăng dần theo thời gian.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_capabilities(self) -> PlatformCapabilities:
|
||||
"""获取平台能力"""
|
||||
"""Lấy mô tả năng lực của nền tảng."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_platform_name(self) -> str:
|
||||
"""获取平台名称"""
|
||||
"""Lấy tên nền tảng."""
|
||||
pass
|
||||
|
||||
|
||||
class IMessageSender(ABC):
|
||||
"""消息发送接口"""
|
||||
"""Giao diện gửi tin nhắn."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_text(
|
||||
@@ -62,7 +60,7 @@ class IMessageSender(ABC):
|
||||
text: str,
|
||||
reply_to: str | None = None,
|
||||
) -> bool:
|
||||
"""发送文本消息"""
|
||||
"""Gửi tin nhắn văn bản."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -72,7 +70,7 @@ class IMessageSender(ABC):
|
||||
image_path: str,
|
||||
caption: str = "",
|
||||
) -> bool:
|
||||
"""发送图片消息"""
|
||||
"""Gửi tin nhắn hình ảnh."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -82,12 +80,13 @@ class IMessageSender(ABC):
|
||||
nodes: list[dict],
|
||||
) -> bool:
|
||||
"""
|
||||
发送合并转发消息。
|
||||
Gửi tin nhắn chuyển tiếp tổng hợp.
|
||||
|
||||
Args:
|
||||
group_id: 目标群组 ID
|
||||
nodes: 转发节点列表。每个节点通常包含 name, uin (或 user_id), content。
|
||||
目前主要用于 OneBot 兼容性。
|
||||
group_id: ID nhóm đích.
|
||||
nodes: Danh sách nút chuyển tiếp. Mỗi nút thường chứa ``name``,
|
||||
``uin`` (hoặc ``user_id``) và ``content``. Hiện chủ yếu dùng
|
||||
để tương thích với OneBot.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -98,26 +97,26 @@ class IMessageSender(ABC):
|
||||
file_path: str,
|
||||
filename: str | None = None,
|
||||
) -> bool:
|
||||
"""发送文件"""
|
||||
"""Gửi tệp."""
|
||||
pass
|
||||
|
||||
|
||||
class IGroupInfoRepository(ABC):
|
||||
"""群组信息仓储接口"""
|
||||
"""Giao diện repository thông tin nhóm."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
|
||||
"""获取群组信息"""
|
||||
"""Lấy thông tin nhóm."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_group_list(self) -> list[str]:
|
||||
"""获取机器人所在的所有群组 ID"""
|
||||
"""Lấy ID của tất cả nhóm mà bot đang tham gia."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
|
||||
"""获取群组成员列表"""
|
||||
"""Lấy danh sách thành viên nhóm."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -126,5 +125,5 @@ class IGroupInfoRepository(ABC):
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
) -> UnifiedMember | None:
|
||||
"""获取指定成员信息"""
|
||||
"""Lấy thông tin của thành viên được chỉ định."""
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
报告生成接口 - 领域层
|
||||
定义分析报告生成的抽象契约
|
||||
Giao diện tạo báo cáo thuộc tầng domain.
|
||||
|
||||
Định nghĩa hợp đồng trừu tượng cho chức năng tạo báo cáo phân tích.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -8,9 +9,7 @@ from typing import Any
|
||||
|
||||
|
||||
class IReportGenerator(ABC):
|
||||
"""
|
||||
报告生成器接口
|
||||
"""
|
||||
"""Giao diện trình tạo báo cáo."""
|
||||
|
||||
@abstractmethod
|
||||
async def generate_image_report(
|
||||
@@ -24,7 +23,7 @@ class IReportGenerator(ABC):
|
||||
hide_user_names: bool = False,
|
||||
allow_alphanumeric_user_ids: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""生成图片报告"""
|
||||
"""Tạo báo cáo hình ảnh."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -38,15 +37,15 @@ class IReportGenerator(ABC):
|
||||
hide_user_names: bool = False,
|
||||
allow_alphanumeric_user_ids: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""生成 HTML 报告"""
|
||||
"""Tạo báo cáo HTML."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_text_report(self, analysis_result: dict) -> str:
|
||||
"""生成文本报告"""
|
||||
"""Tạo báo cáo văn bản."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self):
|
||||
"""释放资源"""
|
||||
"""Giải phóng tài nguyên."""
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
可视化仓储接口 - 领域层
|
||||
定义活跃度可视化的抽象契约。
|
||||
Giao diện repository trực quan hoá thuộc tầng domain.
|
||||
|
||||
Định nghĩa hợp đồng trừu tượng cho chức năng trực quan hoá hoạt động.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -9,11 +10,11 @@ from ..models.data_models import ActivityVisualization
|
||||
|
||||
|
||||
class IActivityVisualizer(ABC):
|
||||
"""活跃度可视化接口 - 领域层抽象"""
|
||||
"""Giao diện trực quan hoá hoạt động của tầng domain."""
|
||||
|
||||
@abstractmethod
|
||||
def generate_activity_visualization(
|
||||
self, messages: list[dict]
|
||||
) -> ActivityVisualization:
|
||||
"""从消息列表生成活跃度可视化数据"""
|
||||
"""Tạo dữ liệu trực quan hoá hoạt động từ danh sách tin nhắn."""
|
||||
pass
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
分析领域服务 - 领域层
|
||||
负责用户维度的活跃度分析、发言习惯及活动模式识别。
|
||||
Dịch vụ phân tích thuộc tầng domain.
|
||||
|
||||
Phụ trách phân tích mức độ hoạt động, thói quen trò chuyện và nhận diện
|
||||
mẫu hoạt động của từng thành viên.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -19,7 +21,7 @@ class UserActivityStats(TypedDict):
|
||||
|
||||
|
||||
class AnalysisDomainService:
|
||||
"""分析领域服务 - 处理用户画像及行为分析"""
|
||||
"""Xử lý phân tích chân dung và hành vi thành viên."""
|
||||
|
||||
def analyze_user_activity(
|
||||
self,
|
||||
@@ -27,9 +29,10 @@ class AnalysisDomainService:
|
||||
bot_self_ids: list[str] | None = None,
|
||||
) -> dict[str, UserActivityStats]:
|
||||
"""
|
||||
分析用户活跃度。
|
||||
Phân tích mức độ hoạt động của thành viên.
|
||||
|
||||
基于 UnifiedMessage 计算每个用户的发言数、字数、表情数等。
|
||||
Dựa trên ``UnifiedMessage`` để tính số tin nhắn, số ký tự,
|
||||
số biểu cảm và các chỉ số khác của từng thành viên.
|
||||
"""
|
||||
user_stats: dict[str, UserActivityStats] = {}
|
||||
|
||||
@@ -38,7 +41,7 @@ class AnalysisDomainService:
|
||||
for msg in messages:
|
||||
user_id = msg.sender_id
|
||||
|
||||
# 跳过机器人自己的消息
|
||||
# Bỏ qua tin nhắn của chính bot
|
||||
if user_id in bot_ids:
|
||||
continue
|
||||
|
||||
@@ -56,12 +59,12 @@ class AnalysisDomainService:
|
||||
stats["message_count"] += 1
|
||||
stats["nickname"] = msg.sender_card or msg.sender_name
|
||||
|
||||
# 统计时间分布
|
||||
# Thống kê phân bố theo thời gian
|
||||
msg_time = datetime.fromtimestamp(msg.timestamp)
|
||||
hour = msg_time.hour
|
||||
stats["hours"][hour] = stats["hours"].get(hour, 0) + 1
|
||||
|
||||
# 统计内容
|
||||
# Thống kê nội dung
|
||||
for content in msg.contents:
|
||||
if content.type == MessageContentType.TEXT:
|
||||
stats["char_count"] += len(content.text or "")
|
||||
@@ -70,7 +73,7 @@ class AnalysisDomainService:
|
||||
stats["emoji_count"] += 1
|
||||
|
||||
elif content.type == MessageContentType.IMAGE:
|
||||
# 与 GroupStatistics 口径保持一致
|
||||
# Giữ cách tính nhất quán với GroupStatistics
|
||||
if self._is_emoji_like_image(content.raw_data):
|
||||
stats["emoji_count"] += 1
|
||||
|
||||
@@ -81,7 +84,7 @@ class AnalysisDomainService:
|
||||
|
||||
@staticmethod
|
||||
def _is_emoji_like_image(raw_data: object) -> bool:
|
||||
"""判断 IMAGE 段是否应按表情计数。"""
|
||||
"""Kiểm tra phân đoạn IMAGE có được tính là biểu cảm hay không."""
|
||||
if isinstance(raw_data, dict):
|
||||
sub_type = raw_data.get("sub_type")
|
||||
if sub_type is not None:
|
||||
@@ -98,7 +101,7 @@ class AnalysisDomainService:
|
||||
def get_top_users(
|
||||
self, user_activity: dict[str, UserActivityStats], limit: int = 10
|
||||
) -> list[dict]:
|
||||
"""获取最活跃的用户列表"""
|
||||
"""Lấy danh sách thành viên hoạt động tích cực nhất."""
|
||||
users = []
|
||||
for user_id, stats in user_activity.items():
|
||||
users.append(
|
||||
@@ -112,24 +115,24 @@ class AnalysisDomainService:
|
||||
}
|
||||
)
|
||||
|
||||
# 按消息数量排序
|
||||
# Sắp xếp theo số lượng tin nhắn
|
||||
users.sort(key=lambda x: x["message_count"], reverse=True)
|
||||
return users[:limit]
|
||||
|
||||
def get_user_activity_pattern(
|
||||
self, user_activity: dict[str, UserActivityStats], user_id: str
|
||||
) -> dict:
|
||||
"""获取并识别指定用户的活动模式"""
|
||||
"""Lấy và nhận diện mẫu hoạt động của thành viên được chỉ định."""
|
||||
if user_id not in user_activity:
|
||||
return {}
|
||||
|
||||
stats = user_activity[user_id]
|
||||
hours = stats["hours"]
|
||||
|
||||
# 找出最活跃的时间段
|
||||
# Xác định khung giờ hoạt động tích cực nhất
|
||||
most_active_hour = max(hours.items(), key=lambda x: x[1])[0] if hours else 0
|
||||
|
||||
# 计算夜间活跃度 (0-6点)
|
||||
# Tính mức độ hoạt động ban đêm (0-6 giờ)
|
||||
night_messages = sum(hours[h] for h in range(0, 6))
|
||||
night_ratio = (
|
||||
night_messages / stats["message_count"] if stats["message_count"] > 0 else 0
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""
|
||||
增量合并领域服务
|
||||
Dịch vụ domain gộp dữ liệu phân tích gia tăng.
|
||||
|
||||
负责将 IncrementalBatch 列表合并为 IncrementalState,
|
||||
以及将 IncrementalState 累积数据转换为现有实体类型,
|
||||
以便复用现有的报告生成器和分发器。
|
||||
Phụ trách gộp danh sách ``IncrementalBatch`` thành ``IncrementalState``
|
||||
và chuyển dữ liệu tích luỹ thành các entity hiện có để tái sử dụng trình
|
||||
tạo và phân phối báo cáo.
|
||||
|
||||
核心职责:
|
||||
- merge_batches: 将多个 IncrementalBatch 合并为一个 IncrementalState(滑动窗口聚合)
|
||||
- IncrementalState → GroupStatistics(含 ActivityVisualization、EmojiStatistics)
|
||||
- IncrementalState → list[SummaryTopic]
|
||||
- IncrementalState → list[GoldenQuote]
|
||||
Trách nhiệm chính:
|
||||
- ``merge_batches``: gộp nhiều batch thành một trạng thái trong cửa sổ trượt
|
||||
- ``IncrementalState`` → ``GroupStatistics``
|
||||
- ``IncrementalState`` → ``list[SummaryTopic]``
|
||||
- ``IncrementalState`` → ``list[GoldenQuote]``
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -30,10 +30,11 @@ from ...utils.logger import logger
|
||||
|
||||
class IncrementalMergeService:
|
||||
"""
|
||||
增量合并服务
|
||||
Dịch vụ gộp dữ liệu gia tăng.
|
||||
|
||||
将滑动窗口内的多个批次数据合并为报告所需的数据结构,
|
||||
确保增量模式下生成的最终报告与传统单次分析报告格式完全一致。
|
||||
Gộp dữ liệu của nhiều batch trong cửa sổ trượt thành cấu trúc cần cho
|
||||
báo cáo, đảm bảo báo cáo cuối ở chế độ gia tăng có cùng định dạng với
|
||||
báo cáo phân tích một lần truyền thống.
|
||||
"""
|
||||
|
||||
def merge_batches(
|
||||
@@ -43,18 +44,18 @@ class IncrementalMergeService:
|
||||
window_end: float,
|
||||
) -> IncrementalState:
|
||||
"""
|
||||
从批次列表合并构建 IncrementalState。
|
||||
Gộp danh sách batch để xây dựng ``IncrementalState``.
|
||||
|
||||
遍历所有批次,累加统计数据并对话题和金句执行去重,
|
||||
生成可用于报告的聚合视图。
|
||||
Duyệt toàn bộ batch, cộng dồn số liệu và loại trùng chủ đề cùng
|
||||
trích dẫn để tạo view tổng hợp dùng cho báo cáo.
|
||||
|
||||
Args:
|
||||
batches: 时间窗口内的批次列表(按时间升序)
|
||||
window_start: 窗口起始时间戳(epoch)
|
||||
window_end: 窗口结束时间戳(epoch)
|
||||
batches: Danh sách batch trong cửa sổ, tăng dần theo thời gian.
|
||||
window_start: Epoch timestamp bắt đầu cửa sổ.
|
||||
window_end: Epoch timestamp kết thúc cửa sổ.
|
||||
|
||||
Returns:
|
||||
IncrementalState: 合并后的聚合视图
|
||||
IncrementalState: View tổng hợp sau khi gộp.
|
||||
"""
|
||||
state = IncrementalState(
|
||||
group_id=batches[0].group_id if batches else "",
|
||||
@@ -66,25 +67,25 @@ class IncrementalMergeService:
|
||||
)
|
||||
|
||||
for batch in batches:
|
||||
# 累加消息和字符计数
|
||||
# Cộng dồn số tin nhắn và ký tự
|
||||
state.total_message_count += batch.messages_count
|
||||
state.total_character_count += batch.characters_count
|
||||
|
||||
# 合并每小时消息分布(按键累加)
|
||||
# Gộp phân bố tin nhắn theo giờ bằng cách cộng theo key
|
||||
for hour_key, count in batch.hourly_msg_counts.items():
|
||||
hour_str = str(hour_key)
|
||||
state.hourly_message_counts[hour_str] = (
|
||||
state.hourly_message_counts.get(hour_str, 0) + count
|
||||
)
|
||||
|
||||
# 合并每小时字符分布
|
||||
# Gộp phân bố ký tự theo giờ
|
||||
for hour_key, count in batch.hourly_char_counts.items():
|
||||
hour_str = str(hour_key)
|
||||
state.hourly_character_counts[hour_str] = (
|
||||
state.hourly_character_counts.get(hour_str, 0) + count
|
||||
)
|
||||
|
||||
# 合并用户统计(按用户累加消息数、字符数等)
|
||||
# Gộp thống kê thành viên bằng cách cộng dồn theo người dùng
|
||||
for raw_user_id, stats in batch.user_stats.items():
|
||||
user_id = str(raw_user_id)
|
||||
if user_id not in state.user_activities:
|
||||
@@ -103,43 +104,43 @@ class IncrementalMergeService:
|
||||
existing["emoji_count"] += stats.get("emoji_count", 0)
|
||||
existing["reply_count"] += stats.get("reply_count", 0)
|
||||
|
||||
# 合并每小时统计
|
||||
# 兼容旧版本 (active_hours 是 list) 和新版本 (hours 是 dict)
|
||||
# Gộp thống kê theo giờ.
|
||||
# Tương thích phiên bản cũ (active_hours là list) và mới (hours là dict).
|
||||
batch_hours = stats.get("hours", {})
|
||||
if isinstance(batch_hours, dict):
|
||||
# 现代 schema: hours 是 dict {hour: count}
|
||||
# Schema mới: hours là dict {hour: count}
|
||||
for h_str, h_count in batch_hours.items():
|
||||
h_int = int(h_str)
|
||||
existing["hours"][h_int] = (
|
||||
existing["hours"].get(h_int, 0) + h_count
|
||||
)
|
||||
else:
|
||||
# 兼容旧 schema: 只有 active_hours (list)
|
||||
# Schema cũ: chỉ có active_hours (list)
|
||||
active_hours = stats.get("active_hours", [])
|
||||
for h in active_hours:
|
||||
h_int = int(h)
|
||||
existing["hours"][h_int] = existing["hours"].get(h_int, 0) + 1
|
||||
|
||||
# 取最后消息时间的较大值
|
||||
# Lấy thời điểm của tin nhắn cuối lớn hơn
|
||||
batch_last = stats.get("last_message_time", 0)
|
||||
if batch_last > existing.get("last_message_time", 0):
|
||||
existing["last_message_time"] = batch_last
|
||||
|
||||
# 更新昵称(使用最新批次的有效昵称)
|
||||
# Cập nhật biệt danh bằng giá trị hợp lệ mới nhất
|
||||
nickname = stats.get("nickname", stats.get("name", ""))
|
||||
if nickname and str(nickname).strip():
|
||||
existing["nickname"] = nickname
|
||||
|
||||
# 合并表情统计(按键累加)
|
||||
# Gộp thống kê biểu cảm bằng cách cộng theo key
|
||||
for emoji_key, count in batch.emoji_stats.items():
|
||||
current_val = state.emoji_counts.get(emoji_key, 0)
|
||||
if isinstance(count, dict):
|
||||
# 如果是嵌套字典(如 face_details),则合并内部计数
|
||||
# Gộp bộ đếm bên trong nếu là dict lồng nhau như face_details
|
||||
if not isinstance(current_val, dict):
|
||||
current_val = {}
|
||||
|
||||
for sub_key, sub_count in count.items():
|
||||
# 确保 current_val 是字典且 sub_count 是数字
|
||||
# Đảm bảo current_val là dict và sub_count là số
|
||||
if isinstance(current_val, dict):
|
||||
current_val[sub_key] = (
|
||||
current_val.get(sub_key, 0) + sub_count
|
||||
@@ -147,80 +148,81 @@ class IncrementalMergeService:
|
||||
|
||||
state.emoji_counts[emoji_key] = current_val
|
||||
else:
|
||||
# 如果是数值,直接累加
|
||||
# Nếu là số thì cộng trực tiếp
|
||||
if isinstance(current_val, dict):
|
||||
# 异常情况:现有值是字典但新值是数字,通常不应发生,除非 schema 变更
|
||||
# 此时保留字典,忽略数字或记录错误,这里选择保留字典
|
||||
# Trường hợp bất thường: giá trị cũ là dict nhưng giá trị mới là số.
|
||||
# Giữ dict và bỏ qua giá trị số để tương thích khi schema thay đổi.
|
||||
continue
|
||||
|
||||
state.emoji_counts[emoji_key] = current_val + count
|
||||
|
||||
# 合并话题(去重)
|
||||
# Gộp chủ đề và loại trùng
|
||||
for topic in batch.topics:
|
||||
if not IncrementalState.is_duplicate_topic(topic, state.topics):
|
||||
state.topics.append(topic)
|
||||
|
||||
# 合并金句(去重)
|
||||
# Gộp trích dẫn và loại trùng
|
||||
for quote in batch.golden_quotes:
|
||||
if not IncrementalState.is_duplicate_quote(quote, state.golden_quotes):
|
||||
state.golden_quotes.append(quote)
|
||||
|
||||
# 累加 token 消耗
|
||||
# Cộng dồn mức sử dụng token
|
||||
for token_key in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
||||
state.total_token_usage[token_key] = state.total_token_usage.get(
|
||||
token_key, 0
|
||||
) + batch.token_usage.get(token_key, 0)
|
||||
|
||||
# 合并参与者 ID(取并集)
|
||||
# Gộp ID người tham gia bằng phép hợp
|
||||
state.all_participant_ids.update(batch.participant_ids)
|
||||
|
||||
# 收集所有批次的质量锐评(用于最终汇总)
|
||||
# Thu thập đánh giá chất lượng của mọi batch để tổng hợp cuối
|
||||
if batch.chat_quality_review:
|
||||
state.all_quality_reviews.append(batch.chat_quality_review)
|
||||
|
||||
# 记录最后分析消息时间戳(取最大值)
|
||||
# Lưu timestamp tin nhắn phân tích cuối cùng bằng giá trị lớn nhất
|
||||
if batch.last_message_timestamp > state.last_analyzed_message_timestamp:
|
||||
state.last_analyzed_message_timestamp = batch.last_message_timestamp
|
||||
# 更新锐评为最新批次的 (如果没有汇总分析,则作为兜底)
|
||||
# Dùng đánh giá của batch mới nhất làm fallback nếu chưa có tổng hợp
|
||||
if batch.chat_quality_review:
|
||||
state.chat_quality_review = batch.chat_quality_review
|
||||
|
||||
logger.info(
|
||||
f"合并批次完成: 群={state.group_id}, "
|
||||
f"窗口={state.get_window_date_str()}, "
|
||||
f"批次数={len(batches)}, "
|
||||
f"总消息={state.total_message_count}, "
|
||||
f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}"
|
||||
f"Đã gộp batch: nhóm={state.group_id}, "
|
||||
f"cửa sổ={state.get_window_date_str()}, "
|
||||
f"số batch={len(batches)}, "
|
||||
f"tổng tin nhắn={state.total_message_count}, "
|
||||
f"chủ đề={len(state.topics)}, trích dẫn={len(state.golden_quotes)}"
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
def build_final_statistics(self, state: IncrementalState) -> GroupStatistics:
|
||||
"""
|
||||
从增量状态构建最终的群组统计数据。
|
||||
Xây dựng số liệu thống kê nhóm cuối từ trạng thái gia tăng.
|
||||
|
||||
将 IncrementalState 中的累积数据映射到 GroupStatistics,
|
||||
包含完整的 24 小时活跃度分布、表情统计和 token 消耗。
|
||||
Ánh xạ dữ liệu tích luỹ trong ``IncrementalState`` sang
|
||||
``GroupStatistics``, gồm phân bố hoạt động 24 giờ, thống kê biểu cảm
|
||||
và mức sử dụng token.
|
||||
|
||||
Args:
|
||||
state: 由 merge_batches 合并生成的增量分析状态
|
||||
state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
|
||||
|
||||
Returns:
|
||||
GroupStatistics: 与传统分析格式一致的统计数据
|
||||
GroupStatistics: Số liệu có cùng định dạng với phân tích truyền thống.
|
||||
"""
|
||||
# 构建 24 小时活跃度分布
|
||||
# Xây dựng phân bố hoạt động trong 24 giờ
|
||||
hourly_activity = {}
|
||||
for hour in range(24):
|
||||
hour_key = str(hour)
|
||||
hourly_activity[hour] = state.hourly_message_counts.get(hour_key, 0)
|
||||
|
||||
# 获取高峰时段
|
||||
# Lấy các khung giờ cao điểm
|
||||
peak_hours = state.get_peak_hours(3)
|
||||
|
||||
# 构建用户活跃排名
|
||||
# Xây dựng bảng xếp hạng hoạt động của thành viên
|
||||
user_ranking = state.get_user_activity_ranking(10)
|
||||
|
||||
# 构建活跃度可视化数据
|
||||
# Xây dựng dữ liệu trực quan hoá hoạt động
|
||||
activity_visualization = ActivityVisualization(
|
||||
hourly_activity=hourly_activity,
|
||||
daily_activity={state.get_window_date_str(): state.total_message_count},
|
||||
@@ -229,27 +231,27 @@ class IncrementalMergeService:
|
||||
activity_heatmap_data={},
|
||||
)
|
||||
|
||||
# 构建表情统计
|
||||
# Xây dựng thống kê biểu cảm
|
||||
emoji_statistics = self._build_emoji_statistics(state)
|
||||
|
||||
# 构建 token 消耗
|
||||
# Xây dựng thống kê sử dụng token
|
||||
token_usage = TokenUsage(
|
||||
prompt_tokens=state.total_token_usage.get("prompt_tokens", 0),
|
||||
completion_tokens=state.total_token_usage.get("completion_tokens", 0),
|
||||
total_tokens=state.total_token_usage.get("total_tokens", 0),
|
||||
)
|
||||
|
||||
# 获取最活跃时段描述
|
||||
# Lấy mô tả khung giờ hoạt động tích cực nhất
|
||||
most_active_period = state.get_most_active_period()
|
||||
|
||||
# 转换聊天质量锐评 (如果有)
|
||||
# Chuyển đổi đánh giá chất lượng trò chuyện nếu có
|
||||
chat_quality_review = None
|
||||
if state.chat_quality_review:
|
||||
review_dict = state.chat_quality_review
|
||||
dimensions_dict = review_dict.get("dimensions", [])
|
||||
dimensions = [
|
||||
QualityDimension(
|
||||
name=d.get("name", "未知"),
|
||||
name=d.get("name", "Không xác định"),
|
||||
percentage=float(d.get("percentage", 0)),
|
||||
comment=d.get("comment", ""),
|
||||
color=d.get("color", "#607d8b"),
|
||||
@@ -257,10 +259,12 @@ class IncrementalMergeService:
|
||||
for d in dimensions_dict
|
||||
]
|
||||
chat_quality_review = QualityReview(
|
||||
title=review_dict.get("title", "聊天质量锐评"),
|
||||
subtitle=review_dict.get("subtitle", "今天的群里发生了什么?"),
|
||||
title=review_dict.get("title", "Đánh giá chất lượng trò chuyện"),
|
||||
subtitle=review_dict.get("subtitle", "Hôm nay nhóm đã có chuyện gì?"),
|
||||
dimensions=dimensions,
|
||||
summary=review_dict.get("summary", "今天也是充满活力的一天。"),
|
||||
summary=review_dict.get(
|
||||
"summary", "Hôm nay cũng là một ngày đầy năng lượng."
|
||||
),
|
||||
)
|
||||
|
||||
statistics = GroupStatistics(
|
||||
@@ -268,7 +272,7 @@ class IncrementalMergeService:
|
||||
total_characters=state.total_character_count,
|
||||
participant_count=len(state.all_participant_ids),
|
||||
most_active_period=most_active_period,
|
||||
golden_quotes=[], # 金句通过 build_quotes_for_report 单独构建
|
||||
golden_quotes=[], # Trích dẫn được xây dựng riêng bởi build_quotes_for_report
|
||||
emoji_count=emoji_statistics.total_emoji_count,
|
||||
emoji_statistics=emoji_statistics,
|
||||
activity_visualization=activity_visualization,
|
||||
@@ -277,51 +281,52 @@ class IncrementalMergeService:
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"从增量状态构建统计: "
|
||||
f"消息数={state.total_message_count}, "
|
||||
f"参与人数={len(state.all_participant_ids)}, "
|
||||
f"话题数={len(state.topics)}, "
|
||||
f"金句数={len(state.golden_quotes)}"
|
||||
f"Đã xây dựng thống kê từ trạng thái gia tăng: "
|
||||
f"tin nhắn={state.total_message_count}, "
|
||||
f"người tham gia={len(state.all_participant_ids)}, "
|
||||
f"chủ đề={len(state.topics)}, "
|
||||
f"trích dẫn={len(state.golden_quotes)}"
|
||||
)
|
||||
|
||||
return statistics
|
||||
|
||||
def build_topics_for_report(self, state: IncrementalState) -> list[SummaryTopic]:
|
||||
"""
|
||||
从增量状态构建报告用的话题列表。
|
||||
Xây dựng danh sách chủ đề dùng cho báo cáo từ trạng thái gia tăng.
|
||||
|
||||
将 IncrementalState 中累积的话题字典转换为 SummaryTopic 实例列表。
|
||||
Chuyển các dict chủ đề tích luỹ trong ``IncrementalState`` thành
|
||||
danh sách instance ``SummaryTopic``.
|
||||
|
||||
Args:
|
||||
state: 由 merge_batches 合并生成的增量分析状态
|
||||
state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
|
||||
|
||||
Returns:
|
||||
list[SummaryTopic]: 话题列表,格式与传统分析结果一致
|
||||
list[SummaryTopic]: Danh sách chủ đề cùng định dạng với kết quả truyền thống.
|
||||
"""
|
||||
topics = []
|
||||
for topic_dict in state.topics:
|
||||
topic = SummaryTopic(
|
||||
topic=topic_dict.get("topic", "未知话题"),
|
||||
topic=topic_dict.get("topic", "Chủ đề không xác định"),
|
||||
contributors=topic_dict.get("contributors", []),
|
||||
detail=topic_dict.get("detail", ""),
|
||||
contributor_ids=topic_dict.get("contributor_ids", []),
|
||||
)
|
||||
topics.append(topic)
|
||||
|
||||
logger.debug(f"从增量状态构建了 {len(topics)} 个话题")
|
||||
logger.debug(f"Đã xây dựng {len(topics)} chủ đề từ trạng thái gia tăng")
|
||||
return topics
|
||||
|
||||
def build_quotes_for_report(self, state: IncrementalState) -> list[GoldenQuote]:
|
||||
"""
|
||||
从增量状态构建报告用的金句列表。
|
||||
Xây dựng danh sách trích dẫn dùng cho báo cáo từ trạng thái gia tăng.
|
||||
|
||||
将 IncrementalState 中累积的金句字典转换为 GoldenQuote 实例列表。
|
||||
Chuyển các dict trích dẫn tích luỹ thành danh sách instance ``GoldenQuote``.
|
||||
|
||||
Args:
|
||||
state: 由 merge_batches 合并生成的增量分析状态
|
||||
state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
|
||||
|
||||
Returns:
|
||||
list[GoldenQuote]: 金句列表,格式与传统分析结果一致
|
||||
list[GoldenQuote]: Danh sách trích dẫn cùng định dạng với kết quả truyền thống.
|
||||
"""
|
||||
quotes = []
|
||||
for quote_dict in state.golden_quotes:
|
||||
@@ -333,7 +338,7 @@ class IncrementalMergeService:
|
||||
)
|
||||
quotes.append(quote)
|
||||
|
||||
logger.debug(f"从增量状态构建了 {len(quotes)} 条金句")
|
||||
logger.debug(f"Đã xây dựng {len(quotes)} trích dẫn từ trạng thái gia tăng")
|
||||
return quotes
|
||||
|
||||
def build_analysis_result(
|
||||
@@ -342,23 +347,24 @@ class IncrementalMergeService:
|
||||
user_titles: list | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
从增量状态构建完整的 analysis_result 字典。
|
||||
Xây dựng dict ``analysis_result`` hoàn chỉnh từ trạng thái gia tăng.
|
||||
|
||||
该字典格式与 AnalysisApplicationService.execute_daily_analysis()
|
||||
返回的 analysis_result 完全一致,可直接传入 ReportDispatcher。
|
||||
Định dạng dict giống hoàn toàn với ``analysis_result`` do
|
||||
``AnalysisApplicationService.execute_daily_analysis()`` trả về và có
|
||||
thể truyền trực tiếp cho ``ReportDispatcher``.
|
||||
|
||||
Args:
|
||||
state: 由 merge_batches 合并生成的增量分析状态
|
||||
user_titles: 用户称号列表(由最终报告时 LLM 分析生成)
|
||||
state: Trạng thái phân tích gia tăng do ``merge_batches`` tạo ra.
|
||||
user_titles: Danh sách danh hiệu do LLM tạo khi lập báo cáo cuối.
|
||||
|
||||
Returns:
|
||||
dict: 包含 statistics、topics、user_titles、user_analysis 的结果字典
|
||||
Dict kết quả gồm statistics, topics, user_titles và user_analysis.
|
||||
"""
|
||||
statistics = self.build_final_statistics(state)
|
||||
topics = self.build_topics_for_report(state)
|
||||
golden_quotes = self.build_quotes_for_report(state)
|
||||
|
||||
# 将金句回填到 statistics 中(与传统流程一致)
|
||||
# Gắn trích dẫn trở lại statistics để khớp quy trình truyền thống
|
||||
statistics.golden_quotes = golden_quotes
|
||||
|
||||
analysis_result = {
|
||||
@@ -370,31 +376,32 @@ class IncrementalMergeService:
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"从增量状态构建完整分析结果: "
|
||||
f"群={state.group_id}, 窗口={state.get_window_date_str()}, "
|
||||
f"消息={state.total_message_count}, "
|
||||
f"话题={len(topics)}, "
|
||||
f"金句={len(golden_quotes)}, "
|
||||
f"批次={state.total_analysis_count}"
|
||||
f"Đã xây dựng kết quả phân tích hoàn chỉnh từ trạng thái gia tăng: "
|
||||
f"nhóm={state.group_id}, cửa sổ={state.get_window_date_str()}, "
|
||||
f"tin nhắn={state.total_message_count}, "
|
||||
f"chủ đề={len(topics)}, "
|
||||
f"trích dẫn={len(golden_quotes)}, "
|
||||
f"batch={state.total_analysis_count}"
|
||||
)
|
||||
|
||||
return analysis_result
|
||||
|
||||
def _build_emoji_statistics(self, state: IncrementalState) -> EmojiStatistics:
|
||||
"""
|
||||
从增量状态构建表情统计。
|
||||
Xây dựng thống kê biểu cảm từ trạng thái gia tăng.
|
||||
|
||||
将 IncrementalState 中的 emoji_counts 字典映射到 EmojiStatistics 字段。
|
||||
Ánh xạ dict ``emoji_counts`` trong ``IncrementalState`` vào các
|
||||
trường của ``EmojiStatistics``.
|
||||
|
||||
Args:
|
||||
state: 增量分析状态
|
||||
state: Trạng thái phân tích gia tăng.
|
||||
|
||||
Returns:
|
||||
EmojiStatistics: 表情统计实例
|
||||
EmojiStatistics: Instance thống kê biểu cảm.
|
||||
"""
|
||||
emoji_counts = state.emoji_counts
|
||||
|
||||
# 显式提取并检查类型,辅助 Pylance 类型推断
|
||||
# Trích xuất và kiểm tra kiểu rõ ràng để hỗ trợ Pylance suy luận kiểu
|
||||
face_details = emoji_counts.get("face_details")
|
||||
if not isinstance(face_details, dict):
|
||||
face_details = {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
消息清理服务 - 领域层
|
||||
负责过滤掉机器人消息、指令、技术性内容(如原始表情代码)及敏感内容。
|
||||
Dịch vụ làm sạch tin nhắn thuộc tầng domain.
|
||||
|
||||
Phụ trách lọc tin nhắn của bot, lệnh, nội dung kỹ thuật như mã biểu cảm gốc
|
||||
và các nội dung nhạy cảm.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -12,14 +14,14 @@ from ..value_objects.unified_message import (
|
||||
UnifiedMessage,
|
||||
)
|
||||
|
||||
# Discord 自定义表情正则 <:name:id> 或 <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+>")
|
||||
# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息
|
||||
# Regex lệnh: khớp tin nhắn bắt đầu bằng / hoặc @thành viên /.
|
||||
_COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/")
|
||||
|
||||
|
||||
class MessageCleanerService:
|
||||
"""消息清理服务"""
|
||||
"""Dịch vụ làm sạch tin nhắn."""
|
||||
|
||||
def clean_messages(
|
||||
self,
|
||||
@@ -28,25 +30,25 @@ class MessageCleanerService:
|
||||
filter_commands: bool = True,
|
||||
) -> list[UnifiedMessage]:
|
||||
"""
|
||||
清理并过滤消息列表。
|
||||
Làm sạch và lọc danh sách tin nhắn.
|
||||
|
||||
Args:
|
||||
messages: 原始统一格式消息列表
|
||||
bot_self_ids: 机器人自身的 ID 列表
|
||||
filter_commands: 是否过滤指令消息
|
||||
messages: Danh sách tin nhắn thống nhất ban đầu.
|
||||
bot_self_ids: Danh sách ID của bot.
|
||||
filter_commands: Có lọc tin nhắn lệnh hay không.
|
||||
|
||||
Returns:
|
||||
清理后的消息列表
|
||||
Danh sách tin nhắn sau khi làm sạch.
|
||||
"""
|
||||
bot_ids = set(bot_self_ids or [])
|
||||
cleaned_list = []
|
||||
|
||||
for msg in messages:
|
||||
# 1. 过滤机器人发送的消息
|
||||
# 1. Lọc tin nhắn do bot gửi
|
||||
if msg.sender_id in bot_ids:
|
||||
continue
|
||||
|
||||
# 2. 预检指令消息(首个内容块通常是文本)
|
||||
# 2. Kiểm tra trước tin nhắn lệnh (khối nội dung đầu thường là văn bản)
|
||||
is_command = False
|
||||
first_text = msg.text_content
|
||||
if filter_commands and first_text and _COMMAND_PATTERN.match(first_text):
|
||||
@@ -55,7 +57,7 @@ class MessageCleanerService:
|
||||
if is_command:
|
||||
continue
|
||||
|
||||
# 3. 清理消息内容中的技术性噪音
|
||||
# 3. Làm sạch nhiễu kỹ thuật trong nội dung tin nhắn
|
||||
cleaned_contents = []
|
||||
has_meaningful_content = False
|
||||
|
||||
@@ -63,13 +65,13 @@ class MessageCleanerService:
|
||||
if content.type == MessageContentType.TEXT:
|
||||
text = content.text or ""
|
||||
|
||||
# 移除 Discord 原始表情代码
|
||||
# Xoá mã biểu cảm gốc của Discord
|
||||
text = _DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text)
|
||||
|
||||
# 移除 @mentions 文本 (e.g. <@123456>)
|
||||
# Xoá văn bản @mention, ví dụ <@123456>
|
||||
text = re.sub(r"<@\d+>", "", text)
|
||||
|
||||
# 清理多余空格
|
||||
# Xoá khoảng trắng thừa
|
||||
text = text.strip()
|
||||
|
||||
if text:
|
||||
@@ -78,14 +80,14 @@ class MessageCleanerService:
|
||||
)
|
||||
has_meaningful_content = True
|
||||
else:
|
||||
# 其他类型(图片、回复等)暂时保留,但由后续分析器决定是否使用
|
||||
# Tạm giữ loại khác như ảnh và reply; analyzer quyết định việc sử dụng
|
||||
cleaned_contents.append(content)
|
||||
if content.type != MessageContentType.REPLY:
|
||||
has_meaningful_content = True
|
||||
|
||||
# 4. 如果清理后仍有内容,则保留消息
|
||||
# 4. Chỉ giữ tin nhắn nếu vẫn còn nội dung sau khi làm sạch
|
||||
if has_meaningful_content:
|
||||
# 重新合成 text_content 用于 LLM 分析
|
||||
# Ghép lại text_content để phân tích bằng LLM
|
||||
new_text_content = "".join(
|
||||
[
|
||||
c.text
|
||||
@@ -94,7 +96,7 @@ class MessageCleanerService:
|
||||
]
|
||||
).strip()
|
||||
|
||||
# 使用 replace 创建新实例(Frozen dataclass 必须如此)
|
||||
# Dùng replace để tạo instance mới (dataclass frozen yêu cầu như vậy)
|
||||
new_msg = replace(
|
||||
msg, contents=tuple(cleaned_contents), text_content=new_text_content
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
统计领域服务 - 领域层
|
||||
负责核心统计逻辑的计算,不依赖于具体的平台或基础设施。
|
||||
Dịch vụ thống kê thuộc tầng domain.
|
||||
|
||||
Phụ trách tính toán logic thống kê cốt lõi, không phụ thuộc nền tảng hoặc
|
||||
cơ sở hạ tầng cụ thể.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
@@ -13,7 +15,7 @@ from ..value_objects.unified_message import MessageContentType, UnifiedMessage
|
||||
|
||||
|
||||
class StatisticsService:
|
||||
"""统计服务 - 处理群聊数据的聚合统计"""
|
||||
"""Xử lý thống kê tổng hợp dữ liệu trò chuyện nhóm."""
|
||||
|
||||
def __init__(self, activity_visualizer: IActivityVisualizer | None = None):
|
||||
if activity_visualizer is None:
|
||||
@@ -26,9 +28,10 @@ class StatisticsService:
|
||||
self, messages: list[UnifiedMessage]
|
||||
) -> GroupStatistics:
|
||||
"""
|
||||
计算群组基础统计数据。
|
||||
Tính các số liệu thống kê cơ bản của nhóm.
|
||||
|
||||
基于统一消息格式(UnifiedMessage)进行计算,确保跨平台一致性。
|
||||
Tính toán dựa trên định dạng ``UnifiedMessage`` để đảm bảo kết quả
|
||||
nhất quán giữa các nền tảng.
|
||||
"""
|
||||
total_chars = 0
|
||||
participants = set()
|
||||
@@ -38,35 +41,35 @@ class StatisticsService:
|
||||
for msg in messages:
|
||||
participants.add(msg.sender_id)
|
||||
|
||||
# 统计时间分布
|
||||
# Thống kê phân bố theo thời gian
|
||||
msg_time = datetime.fromtimestamp(msg.timestamp)
|
||||
hour_counts[msg_time.hour] += 1
|
||||
|
||||
# 处理消息内容
|
||||
# Xử lý nội dung tin nhắn
|
||||
for content in msg.contents:
|
||||
if content.type == MessageContentType.TEXT:
|
||||
total_chars += len(content.text or "")
|
||||
elif content.type == MessageContentType.EMOJI:
|
||||
emoji_statistics.face_count += 1
|
||||
# 尝试保留原始表情详情(如果适配器提供了)
|
||||
# Giữ chi tiết biểu cảm gốc nếu adapter cung cấp
|
||||
face_id = content.emoji_id or "unknown"
|
||||
emoji_statistics.face_details[f"emoji_{face_id}"] = (
|
||||
emoji_statistics.face_details.get(f"emoji_{face_id}", 0) + 1
|
||||
)
|
||||
elif content.type == MessageContentType.IMAGE:
|
||||
# 兼容识别“图片形态的表情”:
|
||||
# 1) 优先使用 onebot sub_type=1 信号
|
||||
# 2) 若无该字段,再回退到历史 summary 文本匹配
|
||||
# Nhận diện tương thích biểu cảm ở dạng hình ảnh:
|
||||
# 1) Ưu tiên tín hiệu sub_type=1 của OneBot
|
||||
# 2) Nếu không có, đối chiếu văn bản summary kiểu cũ
|
||||
if self._is_emoji_like_image(content.raw_data):
|
||||
emoji_statistics.mface_count += 1
|
||||
elif content.type in (
|
||||
MessageContentType.VOICE,
|
||||
MessageContentType.VIDEO,
|
||||
):
|
||||
# 其他非文本类型统计(可选)
|
||||
# Có thể bổ sung thống kê các loại phi văn bản khác
|
||||
pass
|
||||
|
||||
# 找出最活跃时段
|
||||
# Xác định khung giờ hoạt động tích cực nhất
|
||||
most_active_hour = (
|
||||
max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0
|
||||
)
|
||||
@@ -74,9 +77,9 @@ class StatisticsService:
|
||||
f"{most_active_hour:02d}:00-{(most_active_hour + 1) % 24:02d}:00"
|
||||
)
|
||||
|
||||
# 生成活跃度可视化数据
|
||||
# 注意:ActivityVisualizer 可能需要迁移以支持 UnifiedMessage
|
||||
# 目前先转换回 dict 以保持兼容性,或者之后重构它
|
||||
# Tạo dữ liệu trực quan hoá hoạt động
|
||||
# ActivityVisualizer có thể cần được chuyển đổi để hỗ trợ UnifiedMessage.
|
||||
# Hiện tại chuyển ngược về dict để duy trì khả năng tương thích.
|
||||
raw_msgs = self._convert_to_legacy_dict(messages)
|
||||
activity_visualization = (
|
||||
self.activity_visualizer.generate_activity_visualization(raw_msgs)
|
||||
@@ -96,7 +99,7 @@ class StatisticsService:
|
||||
|
||||
@staticmethod
|
||||
def _is_emoji_like_image(raw_data: object) -> bool:
|
||||
"""判断 IMAGE 段是否应按表情计数。"""
|
||||
"""Kiểm tra phân đoạn IMAGE có được tính là biểu cảm hay không."""
|
||||
if isinstance(raw_data, dict):
|
||||
sub_type = raw_data.get("sub_type")
|
||||
if sub_type is not None:
|
||||
@@ -111,7 +114,7 @@ class StatisticsService:
|
||||
return "动画表情" in text or "表情" in text
|
||||
|
||||
def _convert_to_legacy_dict(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||
"""内部辅助:将 UnifiedMessage 转换为 Legacy Dict 格式,用于兼容可视化组件"""
|
||||
"""Chuyển ``UnifiedMessage`` sang dict cũ để tương thích trình trực quan."""
|
||||
legacy_list = []
|
||||
for msg in messages:
|
||||
legacy_list.append(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 值对象
|
||||
# Các value object
|
||||
from .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities
|
||||
from .unified_group import UnifiedGroup, UnifiedMember
|
||||
from .unified_message import MessageContent, MessageContentType, UnifiedMessage
|
||||
|
||||
__all__ = [
|
||||
# 核心平台抽象
|
||||
# Lớp trừu tượng nền tảng cốt lõi
|
||||
"UnifiedMessage",
|
||||
"MessageContent",
|
||||
"MessageContentType",
|
||||
|
||||
@@ -43,23 +43,23 @@ class PlatformCapabilities:
|
||||
avatar_sizes (tuple[int, ...]): Các kích thước ảnh đại diện tính bằng pixel mà nền tảng hỗ trợ.
|
||||
"""
|
||||
|
||||
# 平台标识
|
||||
# Định danh nền tảng
|
||||
platform_name: str
|
||||
platform_version: str = "unknown"
|
||||
|
||||
# 消息获取能力
|
||||
# Khả năng truy xuất tin nhắn
|
||||
supports_message_history: bool = False
|
||||
max_message_history_days: int = 0
|
||||
max_message_count: int = 0
|
||||
supports_message_search: bool = False
|
||||
|
||||
# 群组信息能力
|
||||
# Khả năng truy xuất thông tin nhóm
|
||||
supports_group_list: bool = False
|
||||
supports_group_info: bool = False
|
||||
supports_member_list: bool = False
|
||||
supports_member_info: bool = False
|
||||
|
||||
# 消息发送能力
|
||||
# Khả năng gửi tin nhắn
|
||||
supports_text_message: bool = True
|
||||
supports_image_message: bool = False
|
||||
supports_file_message: bool = False
|
||||
@@ -68,12 +68,12 @@ class PlatformCapabilities:
|
||||
max_text_length: int = 4096
|
||||
max_image_size_mb: float = 10.0
|
||||
|
||||
# 特殊能力
|
||||
# Các khả năng đặc biệt
|
||||
supports_at_all: bool = False
|
||||
supports_recall: bool = False
|
||||
supports_edit: bool = False
|
||||
|
||||
# 头像能力
|
||||
# Khả năng xử lý ảnh đại diện
|
||||
supports_user_avatar: bool = True
|
||||
supports_group_avatar: bool = False
|
||||
avatar_needs_api_call: bool = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
统一消息值对象 - 跨平台核心抽象
|
||||
Value object tin nhắn thống nhất - lớp trừu tượng cốt lõi đa nền tảng.
|
||||
|
||||
所有平台消息都转换为此格式进行分析。
|
||||
Tin nhắn từ mọi nền tảng đều được chuyển sang định dạng này để phân tích.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -12,9 +12,9 @@ from typing import Any
|
||||
|
||||
class MessageContentType(Enum):
|
||||
"""
|
||||
枚举:消息内容类型
|
||||
Enum loại nội dung tin nhắn.
|
||||
|
||||
用于标识 MessageContent 的具体类型。
|
||||
Dùng để xác định loại cụ thể của ``MessageContent``.
|
||||
"""
|
||||
|
||||
TEXT = "text"
|
||||
@@ -33,19 +33,19 @@ class MessageContentType(Enum):
|
||||
@dataclass(frozen=True)
|
||||
class MessageContent:
|
||||
"""
|
||||
值对象:消息内容段
|
||||
Value object biểu diễn một phân đoạn nội dung tin nhắn.
|
||||
|
||||
表示消息链中的一个组成部分(如文本、图片、表情等)。
|
||||
该对象是不可变的,用于保证数据流的纯净。
|
||||
Biểu diễn một thành phần trong chuỗi tin nhắn, chẳng hạn văn bản,
|
||||
hình ảnh hoặc biểu cảm. Đối tượng bất biến để đảm bảo luồng dữ liệu sạch.
|
||||
|
||||
Attributes:
|
||||
type (MessageContentType): 内容类型
|
||||
text (str): 文本内容(仅当类型为 TEXT 或包含文本描述时)
|
||||
url (str): 资源链接(图片、视频、文件等)
|
||||
emoji_id (str): 表情 ID
|
||||
emoji_name (str): 表情名称
|
||||
at_user_id (str): 被 @ 的用户 ID
|
||||
raw_data (Any): 平台原始数据,用于扩展
|
||||
type: Loại nội dung.
|
||||
text: Nội dung văn bản khi loại là TEXT hoặc có mô tả văn bản.
|
||||
url: Liên kết tài nguyên như hình ảnh, video hoặc tệp.
|
||||
emoji_id: ID biểu cảm.
|
||||
emoji_name: Tên biểu cảm.
|
||||
at_user_id: ID thành viên được nhắc đến.
|
||||
raw_data: Dữ liệu nền tảng gốc dùng cho mục đích mở rộng.
|
||||
"""
|
||||
|
||||
type: MessageContentType
|
||||
@@ -57,17 +57,17 @@ class MessageContent:
|
||||
raw_data: Any = None
|
||||
|
||||
def is_text(self) -> bool:
|
||||
"""检查是否为文本内容。"""
|
||||
"""Kiểm tra đây có phải nội dung văn bản hay không."""
|
||||
return self.type == MessageContentType.TEXT
|
||||
|
||||
def is_emoji(self) -> bool:
|
||||
"""检查是否为表情内容。"""
|
||||
"""Kiểm tra đây có phải nội dung biểu cảm hay không."""
|
||||
return self.type == MessageContentType.EMOJI
|
||||
|
||||
@property
|
||||
def target_id(self) -> str:
|
||||
"""
|
||||
获取被 @ 的用户 ID(兼容旧代码)。
|
||||
Lấy ID thành viên được nhắc đến để tương thích mã cũ.
|
||||
|
||||
Alias for at_user_id.
|
||||
"""
|
||||
@@ -77,101 +77,102 @@ class MessageContent:
|
||||
@dataclass(frozen=True)
|
||||
class UnifiedMessage:
|
||||
"""
|
||||
核心值对象:统一消息格式
|
||||
Value object cốt lõi biểu diễn định dạng tin nhắn thống nhất.
|
||||
|
||||
跨平台抽象层,将不同平台的原始消息转换为统一格式进行分析。
|
||||
采用“只读”设计,确保分析逻辑的一致性。
|
||||
Lớp trừu tượng đa nền tảng chuyển tin nhắn gốc sang một định dạng chung
|
||||
để phân tích. Thiết kế chỉ đọc đảm bảo logic phân tích nhất quán.
|
||||
|
||||
Attributes:
|
||||
message_id (str): 消息唯一标识符
|
||||
sender_id (str): 发送者唯一 ID
|
||||
sender_name (str): 发送者昵称
|
||||
group_id (str): 群组/会话唯一 ID
|
||||
text_content (str): 经过清洗后的纯文本内容,主要用于 LLM 分析
|
||||
contents (tuple[MessageContent, ...]): 结构化消息链
|
||||
timestamp (int): Unix 时间戳(秒)
|
||||
platform (str): 来源平台名称(如 onebot, discord 等)
|
||||
reply_to_id (str, optional): 被回复的消息 ID
|
||||
sender_card (str, optional): 平台特定的群名片或特别备注
|
||||
message_id: Mã định danh duy nhất của tin nhắn.
|
||||
sender_id: ID duy nhất của người gửi.
|
||||
sender_name: Biệt danh người gửi.
|
||||
group_id: ID duy nhất của nhóm hoặc cuộc trò chuyện.
|
||||
text_content: Nội dung văn bản thuần đã làm sạch, chủ yếu dùng cho LLM.
|
||||
contents: Chuỗi nội dung tin nhắn có cấu trúc.
|
||||
timestamp: Unix timestamp tính bằng giây.
|
||||
platform: Tên nền tảng nguồn, ví dụ OneBot hoặc Discord.
|
||||
reply_to_id: ID tin nhắn được trả lời.
|
||||
sender_card: Tên hiển thị hoặc ghi chú riêng của nền tảng.
|
||||
"""
|
||||
|
||||
# 基础标识
|
||||
# Thông tin định danh cơ bản
|
||||
message_id: str
|
||||
sender_id: str
|
||||
sender_name: str
|
||||
group_id: str
|
||||
|
||||
# 消息内容
|
||||
# Nội dung tin nhắn
|
||||
text_content: str
|
||||
contents: tuple[MessageContent, ...] = field(default_factory=tuple)
|
||||
|
||||
# 时间信息
|
||||
# Thông tin thời gian
|
||||
timestamp: int = 0
|
||||
|
||||
# 平台信息
|
||||
# Thông tin nền tảng
|
||||
platform: str = "unknown"
|
||||
|
||||
# 可选信息
|
||||
# Thông tin tuỳ chọn
|
||||
reply_to_id: str | None = None
|
||||
sender_card: str | None = None
|
||||
|
||||
# 分析辅助方法
|
||||
# Phương thức hỗ trợ phân tích
|
||||
def has_text(self) -> bool:
|
||||
"""
|
||||
判断消息是否包含非空文本。
|
||||
Kiểm tra tin nhắn có chứa văn bản không rỗng hay không.
|
||||
|
||||
Returns:
|
||||
bool: 包含有效文本则返回 True
|
||||
``True`` nếu tin nhắn chứa văn bản hợp lệ.
|
||||
"""
|
||||
return bool(self.text_content.strip())
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
"""
|
||||
获取用户显示名称。
|
||||
优先级:群名片 > 昵称 > 用户 ID。
|
||||
Lấy tên hiển thị của thành viên.
|
||||
|
||||
Thứ tự ưu tiên: tên trong nhóm, biệt danh, ID thành viên.
|
||||
|
||||
Returns:
|
||||
str: 格式化后的显示名称
|
||||
Tên hiển thị đã định dạng.
|
||||
"""
|
||||
return self.sender_card or self.sender_name or self.sender_id
|
||||
|
||||
def get_emoji_count(self) -> int:
|
||||
"""
|
||||
计算消息链中包含的表情数量。
|
||||
Tính số biểu cảm có trong chuỗi tin nhắn.
|
||||
|
||||
Returns:
|
||||
int: 表情总数
|
||||
Tổng số biểu cảm.
|
||||
"""
|
||||
return sum(1 for c in self.contents if c.is_emoji())
|
||||
|
||||
def get_text_length(self) -> int:
|
||||
"""
|
||||
获取文本内容的字符长度。
|
||||
Lấy độ dài ký tự của nội dung văn bản.
|
||||
|
||||
Returns:
|
||||
int: 字符数
|
||||
Số ký tự.
|
||||
"""
|
||||
return len(self.text_content)
|
||||
|
||||
def get_datetime(self) -> datetime:
|
||||
"""
|
||||
将 Unix 时间戳转换为 datetime 对象。
|
||||
Chuyển Unix timestamp thành đối tượng ``datetime``.
|
||||
|
||||
Returns:
|
||||
datetime: 本地化后的时间对象
|
||||
Đối tượng thời gian theo múi giờ cục bộ.
|
||||
"""
|
||||
return datetime.fromtimestamp(self.timestamp)
|
||||
|
||||
def to_analysis_format(self) -> str:
|
||||
"""
|
||||
转换为供 LLM 消费的分析格式。
|
||||
Chuyển sang định dạng phân tích dành cho LLM.
|
||||
|
||||
Returns:
|
||||
str: 格式如 "[用户名]: 消息内容" 的字符串
|
||||
Chuỗi có dạng ``[tên thành viên]: nội dung tin nhắn``.
|
||||
"""
|
||||
name = self.get_display_name()
|
||||
return f"[{name}]: {self.text_content}"
|
||||
|
||||
|
||||
# 类型别名
|
||||
# Bí danh kiểu dữ liệu
|
||||
MessageList = list[UnifiedMessage]
|
||||
|
||||
@@ -1 +1 @@
|
||||
# 基础设施层
|
||||
# Tầng infrastructure
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
分析模块
|
||||
包含LLM分析功能
|
||||
"""
|
||||
"""Module phân tích, chứa chức năng phân tích bằng LLM."""
|
||||
|
||||
from .llm_analyzer import LLMAnalyzer
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
分析器模块
|
||||
包含各种LLM分析功能的实现
|
||||
"""
|
||||
"""Module analyzer chứa các triển khai phân tích bằng LLM."""
|
||||
|
||||
from .base_analyzer import BaseAnalyzer
|
||||
from .golden_quote_analyzer import GoldenQuoteAnalyzer
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
基础分析器抽象类
|
||||
定义通用分析流程和接口
|
||||
"""
|
||||
"""Lớp analyzer cơ sở định nghĩa quy trình và giao diện chung."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sized
|
||||
@@ -24,91 +21,89 @@ TInputData = TypeVar("TInputData")
|
||||
|
||||
|
||||
class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
"""
|
||||
基础分析器抽象类
|
||||
定义所有分析器的通用接口 and 流程
|
||||
"""
|
||||
"""Lớp analyzer trừu tượng với giao diện và quy trình dùng chung."""
|
||||
|
||||
def __init__(self, context, config_manager):
|
||||
"""
|
||||
初始化基础分析器
|
||||
Khởi tạo analyzer cơ sở.
|
||||
|
||||
Args:
|
||||
context: AstrBot上下文对象
|
||||
config_manager: 配置管理器
|
||||
context: Context AstrBot.
|
||||
config_manager: Trình quản lý cấu hình.
|
||||
"""
|
||||
self.context = context
|
||||
self.config_manager = config_manager
|
||||
# 增量分析模式下的最大数量覆盖值,为 None 时使用配置默认值
|
||||
# Giới hạn ghi đè cho phân tích gia tăng; None dùng cấu hình mặc định.
|
||||
self._incremental_max_count: int | None = None
|
||||
|
||||
def get_provider_id_key(self) -> str | None:
|
||||
"""
|
||||
获取 Provider ID 配置键名
|
||||
子类可重写以指定特定的 provider,默认返回 None(使用主 LLM Provider)
|
||||
Lấy tên key cấu hình Provider ID.
|
||||
|
||||
Lớp con có thể ghi đè để chỉ định provider riêng; mặc định dùng provider LLM chính.
|
||||
|
||||
Returns:
|
||||
Provider ID 配置键名,如 'topic_provider_id'
|
||||
Tên key cấu hình như ``topic_provider_id``.
|
||||
"""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def get_data_type(self) -> str:
|
||||
"""
|
||||
获取数据类型标识
|
||||
Lấy định danh loại dữ liệu.
|
||||
|
||||
Returns:
|
||||
数据类型字符串
|
||||
Chuỗi loại dữ liệu.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_max_count(self) -> int:
|
||||
"""
|
||||
获取最大提取数量
|
||||
Lấy số lượng trích xuất tối đa.
|
||||
|
||||
Returns:
|
||||
最大数量
|
||||
Số lượng tối đa.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build_prompt(self, data: TInputData) -> str:
|
||||
"""
|
||||
构建LLM提示词
|
||||
Xây dựng prompt LLM.
|
||||
|
||||
Args:
|
||||
data: 输入数据
|
||||
data: Dữ liệu đầu vào.
|
||||
|
||||
Returns:
|
||||
提示词字符串
|
||||
Chuỗi prompt.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取数据
|
||||
Trích xuất dữ liệu bằng regex.
|
||||
|
||||
Args:
|
||||
result_text: LLM响应文本
|
||||
max_count: 最大提取数量
|
||||
result_text: Văn bản phản hồi LLM.
|
||||
max_count: Số lượng trích xuất tối đa.
|
||||
|
||||
Returns:
|
||||
提取到的数据列表
|
||||
Danh sách dữ liệu đã trích xuất.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_data_objects(self, data_list: list[dict]) -> list[TDataObject]:
|
||||
"""
|
||||
创建数据对象列表
|
||||
Tạo danh sách đối tượng dữ liệu.
|
||||
|
||||
Args:
|
||||
data_list: 原始数据列表
|
||||
data_list: Danh sách dữ liệu gốc.
|
||||
|
||||
Returns:
|
||||
数据对象列表
|
||||
Danh sách đối tượng dữ liệu.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -126,7 +121,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
|
||||
def get_schema_retry_max_attempts(self) -> int:
|
||||
"""
|
||||
schema 解析失败后的最大重试次数(不含首轮请求)。
|
||||
Số lần retry tối đa sau khi parse schema thất bại, không gồm lần đầu.
|
||||
"""
|
||||
return 2
|
||||
|
||||
@@ -134,8 +129,9 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
self, base_temperature: float | None
|
||||
) -> tuple[float, ...]:
|
||||
"""
|
||||
schema 解析失败后的温度重试序列(不含首轮请求)。
|
||||
采用动态降温,提高结构化稳定性。
|
||||
Chuỗi temperature retry sau khi parse schema thất bại.
|
||||
|
||||
Giảm temperature động để tăng tính ổn định của output có cấu trúc.
|
||||
"""
|
||||
attempts = max(0, self.get_schema_retry_max_attempts())
|
||||
if attempts == 0:
|
||||
@@ -161,7 +157,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
provider_id: str | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
尝试从当前将要调用的 Provider 配置中解析基础 temperature。
|
||||
Thử lấy temperature cơ sở từ cấu hình provider sắp gọi.
|
||||
"""
|
||||
pid = provider_id
|
||||
if not pid:
|
||||
@@ -209,8 +205,9 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
self, result_text: str
|
||||
) -> tuple[bool, list[dict] | None, str | None]:
|
||||
"""
|
||||
解析结构化响应(默认 JSON 数组解析)。
|
||||
子类可重写此方法定制对象解析逻辑。
|
||||
Parse phản hồi có cấu trúc, mặc định là mảng JSON.
|
||||
|
||||
Lớp con có thể ghi đè để tuỳ chỉnh logic parse object.
|
||||
"""
|
||||
return parse_json_response(result_text, self.get_data_type())
|
||||
|
||||
@@ -222,7 +219,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
attempt_index: int,
|
||||
) -> str:
|
||||
"""
|
||||
构建结构化失败后的修复重试提示词。
|
||||
Xây dựng prompt retry sửa output có cấu trúc.
|
||||
"""
|
||||
err_text = parse_error or "unknown_parse_error"
|
||||
return (
|
||||
@@ -241,7 +238,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
self, result_text: str
|
||||
) -> tuple[bool, list[dict] | None, str | None]:
|
||||
"""
|
||||
先尝试结构化 JSON 解析(含修复逻辑),失败后立即尝试正则降级。
|
||||
Thử parse JSON có cấu trúc trước, sau đó fallback regex nếu thất bại.
|
||||
"""
|
||||
success, parsed_data, error_msg = self.parse_structured_response(result_text)
|
||||
if success and parsed_data:
|
||||
@@ -259,7 +256,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
)
|
||||
if validated_success and validated_data:
|
||||
logger.info(
|
||||
f"{self.get_data_type()}结构化解析失败后,正则降级提取成功,获得 {len(validated_data)} 条数据"
|
||||
f"Parse có cấu trúc {self.get_data_type()} thất bại; fallback regex lấy được {len(validated_data)} mục"
|
||||
)
|
||||
return True, validated_data, None
|
||||
error_msg = validated_error or error_msg
|
||||
@@ -270,18 +267,19 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
self, data_list: list[dict]
|
||||
) -> tuple[bool, list[dict] | None, str | None]:
|
||||
"""
|
||||
解析结果的本地二次校验(默认直接通过)。
|
||||
子类可重写为 Pydantic 校验。
|
||||
Kiểm tra cục bộ lần hai cho kết quả parse, mặc định luôn hợp lệ.
|
||||
|
||||
Lớp con có thể ghi đè bằng kiểm tra Pydantic.
|
||||
"""
|
||||
return True, data_list, None
|
||||
|
||||
def _save_debug_data(self, prompt: str, session_id: str):
|
||||
"""
|
||||
保存调试数据到文件
|
||||
Lưu dữ liệu debug vào tệp.
|
||||
|
||||
Args:
|
||||
prompt: 提示词内容
|
||||
session_id: 会话ID
|
||||
prompt: Nội dung prompt.
|
||||
session_id: ID phiên.
|
||||
"""
|
||||
try:
|
||||
from astrbot.api.star import StarTools
|
||||
@@ -293,95 +291,102 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
file_name = f"{session_id}_{self.get_data_type()}.txt"
|
||||
file_path = data_path / file_name
|
||||
|
||||
logger.info(f"正在保存调试数据到: {file_path}")
|
||||
logger.info(f"Đang lưu dữ liệu debug vào: {file_path}")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt)
|
||||
|
||||
logger.info(f"已保存 {self.get_data_type()} 分析 Prompt 到 {file_path}")
|
||||
logger.info(
|
||||
f"Đã lưu prompt phân tích {self.get_data_type()} vào {file_path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存调试数据失败: {e}", exc_info=True)
|
||||
logger.error(f"Lưu dữ liệu debug thất bại: {e}", exc_info=True)
|
||||
|
||||
def _apply_persona_reinforcement(
|
||||
self, prompt: str, system_prompt: str | None
|
||||
) -> str:
|
||||
"""
|
||||
核心的人格强化注入逻辑。采用首尾深度注入与指令交织策略。
|
||||
不仅强化输出口吻,更强调使用人格的逻辑视角进行分析过程。
|
||||
Inject tăng cường persona ở đầu và cuối prompt.
|
||||
|
||||
Không chỉ củng cố giọng điệu mà còn yêu cầu phân tích theo góc nhìn persona.
|
||||
"""
|
||||
if not system_prompt or not system_prompt.strip():
|
||||
return prompt
|
||||
|
||||
logger.info(f"[{self.get_data_type()}分析] 已启用人格设定(深度强化模式)")
|
||||
logger.info(
|
||||
f"[Phân tích {self.get_data_type()}] Đã bật persona ở chế độ tăng cường sâu"
|
||||
)
|
||||
|
||||
# 构造更具强制性的标识符
|
||||
# Xây dựng marker có tính bắt buộc cao hơn.
|
||||
persona_content = system_prompt.strip()
|
||||
|
||||
return (
|
||||
"【SYSTEM_CORE_IDENTITY_FIXED】\n"
|
||||
f"你现在的身份已由系统初始化为:\n{persona_content}\n\n"
|
||||
f"Danh tính hiện tại của bạn đã được hệ thống khởi tạo là:\n{persona_content}\n\n"
|
||||
"--- MISSION_DIRECTIVE_START ---\n"
|
||||
"⚠️ 核心任务警告:你接下来的所有分析行为必须基于上述【身份设定】进行。\n"
|
||||
"这包括但不限于:你的思维切入点、对数据的敏感度、点评的犀利/温情程度、以及你对群聊氛围的感知逻辑。\n"
|
||||
f"请以该人格的思维方式去处理以下‘{self.get_data_type()}’分析任务:\n\n"
|
||||
"⚠️ Yêu cầu cốt lõi: mọi phân tích tiếp theo phải dựa trên 【persona】 ở trên.\n"
|
||||
"Điều này bao gồm góc tiếp cận, độ nhạy với dữ liệu, mức sắc sảo/ấm áp khi nhận xét và cách cảm nhận không khí nhóm.\n"
|
||||
f"Hãy xử lý tác vụ phân tích ‘{self.get_data_type()}’ sau theo tư duy của persona này:\n\n"
|
||||
f"{prompt}\n"
|
||||
"--- MISSION_DIRECTIVE_END ---\n\n"
|
||||
"【FINAL_IDENTITY_REINFORCEMENT】\n"
|
||||
f"1. 你不再是通用的 AI 助手,你是上述设定中的角色,我将在此处再次提醒你的身份:\n{persona_content}\n 正在观察并点评这些群聊数据。\n"
|
||||
f"2. 请务必使用该角色的第一人称视角 or 其独有的观察视角进行‘{self.get_data_type()}’输出。\n"
|
||||
"3. 你的分析成果必须体现该角色的性格色彩,禁止输出中立、客套、公式化的 AI 话术。\n"
|
||||
"4. ⚠️ 格式铁律:无论人格多么狂放,最终输出的内容必须严格遵守‘ MISSION_DIRECTIVE ’中所要求的纯 JSON 格式。除了 JSON 数据外,严禁输出任何 Markdown 标记或角色扮演的额外闲聊。"
|
||||
f"1. Bạn không còn là trợ lý AI chung chung mà là nhân vật đã thiết lập ở trên. Đây là lời nhắc lại persona:\n{persona_content}\n Persona này đang quan sát và nhận xét dữ liệu trò chuyện nhóm.\n"
|
||||
f"2. Hãy xuất ‘{self.get_data_type()}’ theo ngôi thứ nhất hoặc góc quan sát riêng của nhân vật.\n"
|
||||
"3. Kết quả phải thể hiện cá tính của nhân vật; không dùng lời lẽ AI trung lập, khách sáo hoặc rập khuôn.\n"
|
||||
"4. ⚠️ Quy tắc định dạng: dù persona có phóng khoáng đến đâu, output cuối phải tuân thủ nghiêm ngặt JSON thuần được yêu cầu trong MISSION_DIRECTIVE. Ngoài JSON, không xuất Markdown hoặc trò chuyện nhập vai bổ sung."
|
||||
)
|
||||
|
||||
async def analyze(
|
||||
self, data: TInputData, umo: str | None = None, session_id: str | None = None
|
||||
) -> tuple[list[TDataObject], TokenUsage]:
|
||||
"""
|
||||
统一的分析流程
|
||||
Quy trình phân tích thống nhất.
|
||||
|
||||
Args:
|
||||
data: 输入数据
|
||||
umo: 模型唯一标识符
|
||||
session_id: 会话ID (用于调试模式)
|
||||
data: Dữ liệu đầu vào.
|
||||
umo: Định danh model duy nhất.
|
||||
session_id: ID phiên dùng cho debug mode.
|
||||
|
||||
Returns:
|
||||
(分析结果列表, Token使用统计)
|
||||
Tuple danh sách kết quả và thống kê token.
|
||||
"""
|
||||
try:
|
||||
# 1. 构建提示词
|
||||
# 1. Xây dựng prompt.
|
||||
logger.debug(
|
||||
f"{self.get_data_type()}分析开始构建prompt,输入数据类型: {type(data)}"
|
||||
f"Bắt đầu xây dựng prompt {self.get_data_type()}, kiểu dữ liệu đầu vào: {type(data)}"
|
||||
)
|
||||
data_length = len(data) if isinstance(data, Sized) else "N/A"
|
||||
logger.debug(f"{self.get_data_type()}分析输入数据长度: {data_length}")
|
||||
logger.debug(
|
||||
f"Độ dài dữ liệu đầu vào {self.get_data_type()}: {data_length}"
|
||||
)
|
||||
|
||||
prompt = self.build_prompt(data)
|
||||
logger.info(f"开始{self.get_data_type()}分析,构建提示词完成")
|
||||
logger.info(f"Bắt đầu phân tích {self.get_data_type()}, đã xây dựng prompt")
|
||||
logger.debug(
|
||||
f"{self.get_data_type()}分析prompt长度: {len(prompt) if prompt else 0}"
|
||||
f"Độ dài prompt {self.get_data_type()}: {len(prompt) if prompt else 0}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{self.get_data_type()}分析prompt前100字符: {prompt[:100] if prompt else 'None'}..."
|
||||
f"100 ký tự đầu prompt {self.get_data_type()}: {prompt[:100] if prompt else 'None'}..."
|
||||
)
|
||||
|
||||
# 保存调试数据
|
||||
# Lưu dữ liệu debug.
|
||||
debug_mode = self.config_manager.get_debug_mode()
|
||||
if debug_mode and session_id and prompt:
|
||||
self._save_debug_data(prompt, session_id)
|
||||
elif debug_mode and not session_id:
|
||||
logger.warning("[Debug] Debug mode enabled but no session_id provided")
|
||||
|
||||
# 检查 prompt 是否为空
|
||||
# Kiểm tra prompt rỗng.
|
||||
if not prompt or not prompt.strip():
|
||||
logger.warning(
|
||||
f"{self.get_data_type()}分析: prompt 为空或只包含空白字符,跳过LLM调用"
|
||||
f"Phân tích {self.get_data_type()}: prompt rỗng hoặc chỉ có khoảng trắng, bỏ qua lời gọi LLM"
|
||||
)
|
||||
return [], TokenUsage()
|
||||
|
||||
# 2. 调用LLM(使用配置的 provider)
|
||||
# 2. Gọi LLM bằng provider đã cấu hình.
|
||||
provider_id_key = self.get_provider_id_key()
|
||||
|
||||
# 只 resolve 一次 provider ID,同时传递给温度解析和 LLM 调用,避免重复日志
|
||||
# Chỉ resolve provider ID một lần để tránh log lặp.
|
||||
resolved_provider_id = None
|
||||
if provider_id_key:
|
||||
resolved_provider_id = await get_provider_id_with_fallback(
|
||||
@@ -392,15 +397,17 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
provider_id_key, umo, provider_id=resolved_provider_id
|
||||
)
|
||||
|
||||
# 获取人格设定
|
||||
# Lấy cấu hình persona.
|
||||
system_prompt = await self._build_system_prompt(umo)
|
||||
|
||||
# 应用人格强化注入
|
||||
# Inject tăng cường persona.
|
||||
prompt = self._apply_persona_reinforcement(prompt, system_prompt)
|
||||
|
||||
logger.info(f"[{self.get_data_type()}分析] 开始发起 LLM 请求, umo: {umo}")
|
||||
logger.info(
|
||||
f"[Phân tích {self.get_data_type()}] Bắt đầu yêu cầu LLM, umo: {umo}"
|
||||
)
|
||||
|
||||
# [Debug] 记录调试信息
|
||||
# Ghi thông tin debug.
|
||||
if debug_mode:
|
||||
logger.debug(
|
||||
f"[Debug] debug_mode={debug_mode}, umo={umo}, session_id={session_id}, prompt_len={len(prompt) if prompt else 0}"
|
||||
@@ -419,11 +426,11 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
|
||||
if response is None:
|
||||
logger.error(
|
||||
f"{self.get_data_type()}分析调用LLM失败: provider返回None(重试失败)"
|
||||
f"Phân tích {self.get_data_type()} gọi LLM thất bại: provider trả về None sau retry"
|
||||
)
|
||||
return [], TokenUsage()
|
||||
|
||||
# 3. 提取token使用统计
|
||||
# 3. Trích xuất thống kê token.
|
||||
token_usage_dict = extract_token_usage(response)
|
||||
token_usage = TokenUsage(
|
||||
prompt_tokens=token_usage_dict["prompt_tokens"],
|
||||
@@ -431,14 +438,14 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
total_tokens=token_usage_dict["total_tokens"],
|
||||
)
|
||||
|
||||
# 4. 提取响应文本
|
||||
# 4. Trích xuất văn bản phản hồi.
|
||||
result_text = extract_response_text(response)
|
||||
logger.debug(f"{self.get_data_type()}分析原始响应: {result_text[:500]}...")
|
||||
logger.debug(f"Phản hồi gốc {self.get_data_type()}: {result_text[:500]}...")
|
||||
|
||||
# 5. 尝试结构化解析 + 正则降级解析
|
||||
# 5. Thử parse có cấu trúc rồi fallback regex.
|
||||
success, parsed_data, error_msg = self._try_parse_with_fallback(result_text)
|
||||
|
||||
# 5.1 仅在两种解析方式都失败时,进入 schema 修复重试(温度递减)
|
||||
# 5.1 Chỉ retry sửa schema với temperature giảm khi cả hai cách thất bại.
|
||||
if not success and self.get_response_format() is not None:
|
||||
temperatures = self.get_schema_retry_temperatures(base_temperature)
|
||||
for idx, temperature in enumerate(temperatures, start=1):
|
||||
@@ -449,7 +456,7 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
attempt_index=idx,
|
||||
)
|
||||
logger.warning(
|
||||
f"{self.get_data_type()}结构化解析失败,触发 schema 修复重试 "
|
||||
f"Parse có cấu trúc {self.get_data_type()} thất bại, retry sửa schema "
|
||||
f"(attempt={idx}, temperature={temperature:.1f})"
|
||||
)
|
||||
retry_response = await call_provider_with_retry(
|
||||
@@ -481,50 +488,49 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
error_msg = retry_error_msg
|
||||
|
||||
if success and parsed_data:
|
||||
# JSON解析成功,创建数据对象
|
||||
# Parse JSON thành công, tạo object dữ liệu.
|
||||
data_objects = self.create_data_objects(parsed_data)
|
||||
logger.info(
|
||||
f"{self.get_data_type()}分析成功,解析到 {len(data_objects)} 条数据"
|
||||
f"Phân tích {self.get_data_type()} thành công, parse được {len(data_objects)} mục"
|
||||
)
|
||||
return data_objects, token_usage
|
||||
|
||||
# 6. 全部尝试失败
|
||||
# 6. Mọi lần thử đều thất bại.
|
||||
logger.error(
|
||||
f"{self.get_data_type()}分析失败: JSON解析与正则降级均未成功: {error_msg}"
|
||||
f"Phân tích {self.get_data_type()} thất bại: cả parse JSON và fallback regex đều lỗi: {error_msg}"
|
||||
)
|
||||
return [], token_usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{self.get_data_type()}分析失败: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Phân tích {self.get_data_type()} thất bại: {e}", exc_info=True
|
||||
)
|
||||
return [], TokenUsage()
|
||||
|
||||
async def _build_system_prompt(self, umo: str | None) -> str | None:
|
||||
"""
|
||||
构建带有会话人格的系统提示词,优先级如下:
|
||||
1. 插件指定的全局人格 (若核心开关开启)
|
||||
2. 会话/对话选定的人格 (若开启了继承开关)
|
||||
3. 当前 UMO 的默认人格 (若开启了继承开关)
|
||||
Xây dựng system prompt kèm persona của phiên theo thứ tự ưu tiên:
|
||||
persona toàn cục của plugin, persona phiên/hội thoại, rồi persona mặc định UMO.
|
||||
|
||||
Args:
|
||||
umo: 用户模型对象标识,用于定位会话上下文
|
||||
umo: Định danh dùng để xác định context phiên.
|
||||
|
||||
Returns:
|
||||
最终生成的 System Prompt 字符串,若无则返回 None
|
||||
System prompt cuối hoặc None nếu không có.
|
||||
"""
|
||||
# 获取配置
|
||||
# Lấy cấu hình.
|
||||
use_specific = self.config_manager.get_use_plugin_specific_persona()
|
||||
specific_id = self.config_manager.get_plugin_specific_persona_id()
|
||||
keep_original = self.config_manager.get_keep_original_persona()
|
||||
|
||||
# 获取 AstrBot 核心的人格管理器
|
||||
# Lấy persona manager cốt lõi của AstrBot.
|
||||
persona_mgr = getattr(self.context, "persona_manager", None)
|
||||
if persona_mgr is None:
|
||||
return None
|
||||
|
||||
persona_prompt = None
|
||||
|
||||
# --- 优先级 1: 插件指定的全局固定人格 ---
|
||||
# 适用于希望所有分析报告都呈现同一种风格的情况
|
||||
# Ưu tiên 1: persona toàn cục cố định do plugin chỉ định.
|
||||
if use_specific and specific_id:
|
||||
try:
|
||||
persona_obj = await persona_mgr.get_persona(specific_id)
|
||||
@@ -534,15 +540,18 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
else None
|
||||
)
|
||||
if persona_prompt:
|
||||
logger.debug(f"已应用插件指定的全局强制人格设定: {specific_id}")
|
||||
logger.debug(
|
||||
f"Đã áp dụng persona toàn cục bắt buộc của plugin: {specific_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取插件指定人格失败 (ID: {specific_id}): {e}")
|
||||
logger.warning(
|
||||
f"Lấy persona do plugin chỉ định thất bại (ID: {specific_id}): {e}"
|
||||
)
|
||||
|
||||
# --- 优先级 2: 继承当前会话/群聊的原始人格 ---
|
||||
# 只有在未开启“强制人格”且开启了“继承设定”时生效
|
||||
# Ưu tiên 2: kế thừa persona gốc của phiên/nhóm hiện tại.
|
||||
if not persona_prompt and keep_original and umo:
|
||||
try:
|
||||
# 2.1 尝试获取 SharedPreferences 中会话绑定的 Persona ID (通常是 /persona 命令设置的)
|
||||
# 2.1 Thử lấy Persona ID gắn với phiên trong SharedPreferences.
|
||||
from astrbot.api import sp
|
||||
|
||||
session_service_config = await sp.get_async(
|
||||
@@ -565,9 +574,11 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
else None
|
||||
)
|
||||
if persona_prompt:
|
||||
logger.debug(f"继承到会话选定人格: {persona_id}")
|
||||
logger.debug(
|
||||
f"Đã kế thừa persona được chọn cho phiên: {persona_id}"
|
||||
)
|
||||
|
||||
# 2.2 若无会话绑定,尝试获取当前对话(Dialogue)级别的人格
|
||||
# 2.2 Nếu phiên chưa gắn persona, thử persona cấp hội thoại.
|
||||
if not persona_prompt:
|
||||
conv_mgr = getattr(self.context, "conversation_manager", None)
|
||||
if conv_mgr:
|
||||
@@ -591,10 +602,10 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
)
|
||||
if persona_prompt:
|
||||
logger.debug(
|
||||
f"继承到对话(Dialogue)设定人格: {conv_obj.persona_id}"
|
||||
f"Đã kế thừa persona của hội thoại: {conv_obj.persona_id}"
|
||||
)
|
||||
|
||||
# 2.3 若仍无结果,尝试获取 UMO 设定的默认人格
|
||||
# 2.3 Nếu vẫn chưa có, thử persona mặc định của UMO.
|
||||
if not persona_prompt:
|
||||
personality = await persona_mgr.get_default_persona_v3(umo)
|
||||
if isinstance(personality, dict):
|
||||
@@ -602,12 +613,14 @@ class BaseAnalyzer(ABC, Generic[TDataObject, TInputData]):
|
||||
else:
|
||||
persona_prompt = getattr(personality, "prompt", None)
|
||||
if persona_prompt:
|
||||
logger.debug("继承到 UMO 默认人格设定")
|
||||
logger.debug("Đã kế thừa persona mặc định của UMO")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"分析人格回溯识别失败 (umo: {umo}): {e}")
|
||||
logger.warning(
|
||||
f"Truy ngược persona phân tích thất bại (umo: {umo}): {e}"
|
||||
)
|
||||
|
||||
# 检查生成结果
|
||||
# Kiểm tra kết quả.
|
||||
if not isinstance(persona_prompt, str) or not persona_prompt.strip():
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
聊天质量分析模块
|
||||
专门处理群聊质量锐评分析
|
||||
"""
|
||||
"""Module phân tích và đánh giá đa chiều chất lượng trò chuyện nhóm."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -22,24 +19,22 @@ from .base_analyzer import BaseAnalyzer
|
||||
|
||||
class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]):
|
||||
"""
|
||||
聊天质量分析器
|
||||
专门处理群聊质量的锐评和多维度分析
|
||||
Analyzer chất lượng trò chuyện và đánh giá đa chiều.
|
||||
|
||||
注意:由于聊天质量分析返回的是 JSON 对象而非数组,
|
||||
此分析器重写了 analyze() 方法,使用 parse_json_object_response 解析,
|
||||
并以 extract_quality_with_regex 作为正则降级方案。
|
||||
Vì kết quả là object JSON thay vì mảng, analyzer ghi đè ``analyze()``, dùng
|
||||
``parse_json_object_response`` và fallback bằng ``extract_quality_with_regex``.
|
||||
"""
|
||||
|
||||
def get_provider_id_key(self) -> str:
|
||||
"""获取 Provider ID 配置键名"""
|
||||
"""Lấy tên key cấu hình Provider ID."""
|
||||
return "quality_provider_id"
|
||||
|
||||
def get_data_type(self) -> str:
|
||||
"""获取数据类型标识"""
|
||||
return "聊天质量"
|
||||
"""Lấy định danh loại dữ liệu."""
|
||||
return "Chất lượng trò chuyện"
|
||||
|
||||
def get_max_count(self) -> int:
|
||||
"""获取最大维度数量"""
|
||||
"""Lấy số chiều tối đa."""
|
||||
return 8
|
||||
|
||||
def get_response_schema_name(self) -> str:
|
||||
@@ -50,12 +45,12 @@ class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]):
|
||||
|
||||
def build_prompt(self, data: list[dict]) -> str:
|
||||
"""
|
||||
构建聊天质量分析提示词
|
||||
Xây dựng prompt phân tích chất lượng trò chuyện.
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
# 提取文本消息
|
||||
# Trích xuất tin nhắn văn bản.
|
||||
text_messages = []
|
||||
for msg in data:
|
||||
if not isinstance(msg, dict):
|
||||
@@ -89,39 +84,39 @@ class ChatQualityAnalyzer(BaseAnalyzer[QualityReview, list[dict]]):
|
||||
if prompt_template:
|
||||
return render_template(prompt_template, messages_text=messages_text)
|
||||
|
||||
prompt_template = """请分析以下群聊记录,输出一份"聊天质量锐评"。
|
||||
prompt_template = """Hãy phân tích lịch sử trò chuyện nhóm sau và đưa ra một bản "đánh giá chất lượng trò chuyện".
|
||||
|
||||
## 任务目标:
|
||||
1. **维度划分**:将聊天内容划分为 3-6 个【高层级、抽象、泛化】的维度(例如:就业焦虑、生涯规划、技术方案研究、情感树洞、无意义水群等)。
|
||||
2. **严禁在维度名称(name)中出现任何具体的群聊人物名、项目名、具体的报错内容或细碎的事件点。标题必须保持高度抽象且字数简练(2-6个字)。**
|
||||
3. 为每个维度计算一个大致的百分比占位(总和小于等于 100%)。
|
||||
4. **点评内容**:为每个维度写一句犀利、幽默、毒舌或温情的点评。具体的吐槽内容、具体的细节事件描述请放在这里。
|
||||
5. **全群表现**:给出一句总结性的评价,作为总结标题对应的“金句”。
|
||||
6. **主题设定**:设定一个本次报告的主题标题和副标题。
|
||||
## Mục tiêu:
|
||||
1. **Phân chia chiều**: chia nội dung thành 3-6 chiều 【cấp cao, trừu tượng, khái quát】, ví dụ: lo âu nghề nghiệp, hoạch định tương lai, nghiên cứu giải pháp kỹ thuật, chia sẻ cảm xúc hoặc trò chuyện lan man.
|
||||
2. **Tên chiều (name) tuyệt đối không chứa tên thành viên, dự án, lỗi cụ thể hay sự kiện vụn vặt. Tiêu đề phải trừu tượng và ngắn gọn (2-6 từ).**
|
||||
3. Ước tính tỷ lệ phần trăm cho từng chiều, tổng không vượt quá 100%.
|
||||
4. **Nội dung nhận xét**: viết một câu sắc sảo, hài hước, châm biếm hoặc ấm áp cho từng chiều; đặt chi tiết cụ thể ở đây.
|
||||
5. **Biểu hiện toàn nhóm**: đưa ra một câu tổng kết nổi bật.
|
||||
6. **Thiết lập chủ đề**: đặt tiêu đề và phụ đề cho báo cáo.
|
||||
|
||||
## 点评风格指南:
|
||||
- 语言要接地气,多用互联网黑话。吐槽要精准,避重就轻。
|
||||
- **只有维度名称(name)需要抽象,点评(comment)和总结(summary)可以非常具体和生动。**
|
||||
## Hướng dẫn phong cách:
|
||||
- Ngôn ngữ gần gũi, tự nhiên, có thể dùng tiếng lóng Internet; nhận xét phải chính xác.
|
||||
- **Chỉ tên chiều (name) cần trừu tượng; comment và summary có thể cụ thể, sinh động.**
|
||||
|
||||
## 返回格式要求:
|
||||
必须以纯 JSON 格式返回,不得包含任何 Markdown 格式。
|
||||
## Yêu cầu định dạng:
|
||||
Chỉ trả về JSON thuần, không chứa Markdown.
|
||||
|
||||
```json
|
||||
{{
|
||||
"title": "今日群聊主题",
|
||||
"subtitle": "副标题",
|
||||
"title": "Chủ đề trò chuyện hôm nay",
|
||||
"subtitle": "Phụ đề",
|
||||
"dimensions": [
|
||||
{{
|
||||
"name": "抽象维度名",
|
||||
"percentage": 比例,
|
||||
"comment": "维度的毒舌点评"
|
||||
"name": "Tên chiều trừu tượng",
|
||||
"percentage": 25,
|
||||
"comment": "Nhận xét sắc sảo về chiều này"
|
||||
}}
|
||||
],
|
||||
"summary": "一句总结性的金句"
|
||||
"summary": "Một câu tổng kết nổi bật"
|
||||
}}
|
||||
```
|
||||
|
||||
群聊记录:
|
||||
Lịch sử trò chuyện nhóm:
|
||||
${messages_text}
|
||||
"""
|
||||
|
||||
@@ -129,32 +124,30 @@ ${messages_text}
|
||||
|
||||
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取质量分析数据(BaseAnalyzer 要求的接口)
|
||||
Trích xuất dữ liệu chất lượng bằng regex theo giao diện BaseAnalyzer.
|
||||
|
||||
注意: 此方法供 BaseAnalyzer.analyze() 的降级流程使用,
|
||||
但由于聊天质量分析重写了 analyze(),实际由 analyze_quality() 中调用
|
||||
extract_quality_with_regex 实现。
|
||||
Analyzer này ghi đè ``analyze()`` nên thực tế fallback được gọi từ
|
||||
``analyze_quality()`` qua ``extract_quality_with_regex``.
|
||||
"""
|
||||
return []
|
||||
|
||||
def create_data_objects(self, data_list: list[dict]) -> list[QualityReview]:
|
||||
"""
|
||||
满足 BaseAnalyzer 抽象要求。
|
||||
聊天质量分析的数据对象创建在 analyze_quality 中完成。
|
||||
Đáp ứng giao diện trừu tượng BaseAnalyzer; object được tạo trong analyze_quality.
|
||||
"""
|
||||
return []
|
||||
|
||||
def _build_review_from_dict(self, data: dict) -> QualityReview:
|
||||
"""
|
||||
从解析后的字典构建 QualityReview 对象
|
||||
Xây dựng object QualityReview từ dict đã parse.
|
||||
|
||||
Args:
|
||||
data: 解析后的 JSON 对象字典
|
||||
data: Dict object JSON đã parse.
|
||||
|
||||
Returns:
|
||||
QualityReview 数据对象
|
||||
Object QualityReview.
|
||||
"""
|
||||
# 控制维度占比总和不超过100%
|
||||
# Đảm bảo tổng tỷ lệ các chiều không vượt 100%.
|
||||
total_percentage = sum(
|
||||
max(0.0, min(100.0, float(d.get("percentage", 0))))
|
||||
for d in data.get("dimensions", [])
|
||||
@@ -172,13 +165,13 @@ ${messages_text}
|
||||
|
||||
dimensions.append(
|
||||
QualityDimension(
|
||||
name=d.get("name", "未知"),
|
||||
name=d.get("name", "Không xác định"),
|
||||
percentage=final_p,
|
||||
comment=d.get("comment", ""),
|
||||
)
|
||||
)
|
||||
|
||||
# 自动分配颜色
|
||||
# Tự phân bổ màu.
|
||||
colors = [
|
||||
"#607d8b",
|
||||
"#2196f3",
|
||||
@@ -193,10 +186,10 @@ ${messages_text}
|
||||
d.color = colors[i % len(colors)]
|
||||
|
||||
return QualityReview(
|
||||
title=data.get("title", "聊天质量锐评"),
|
||||
subtitle=data.get("subtitle", "今天的群里发生了什么?"),
|
||||
title=data.get("title", "Đánh giá chất lượng trò chuyện"),
|
||||
subtitle=data.get("subtitle", "Hôm nay nhóm đã có chuyện gì?"),
|
||||
dimensions=dimensions,
|
||||
summary=data.get("summary", "今天也是充满活力的一天。"),
|
||||
summary=data.get("summary", "Hôm nay cũng là một ngày đầy năng lượng."),
|
||||
)
|
||||
|
||||
def _validate_review_payload(
|
||||
@@ -228,7 +221,7 @@ ${messages_text}
|
||||
attempt_index=idx,
|
||||
)
|
||||
logger.warning(
|
||||
f"聊天质量结构化解析失败,触发 schema 修复重试 "
|
||||
f"Parse có cấu trúc chất lượng trò chuyện thất bại, retry sửa schema "
|
||||
f"(attempt={idx}, temperature={temperature:.1f})"
|
||||
)
|
||||
retry_response = await call_provider_with_retry(
|
||||
@@ -271,7 +264,7 @@ ${messages_text}
|
||||
session_id: str | None = None,
|
||||
) -> tuple[QualityReview | None, TokenUsage]:
|
||||
"""
|
||||
汇总多个增量批次的质量报告,生成最终的每日全天总评。
|
||||
Tổng hợp báo cáo chất lượng từ nhiều batch gia tăng thành đánh giá cả ngày.
|
||||
"""
|
||||
if not batch_reviews:
|
||||
return None, TokenUsage()
|
||||
@@ -280,10 +273,10 @@ ${messages_text}
|
||||
return self._build_review_from_dict(batch_reviews[0]), TokenUsage()
|
||||
|
||||
try:
|
||||
# 构建汇总用的提示词
|
||||
# Xây dựng prompt tổng hợp.
|
||||
reviews_text = ""
|
||||
for i, rev in enumerate(batch_reviews):
|
||||
title = rev.get("title", "未命名")
|
||||
title = rev.get("title", "Chưa đặt tên")
|
||||
summary = rev.get("summary", "")
|
||||
dims = ", ".join(
|
||||
[
|
||||
@@ -291,53 +284,53 @@ ${messages_text}
|
||||
for d in rev.get("dimensions", [])
|
||||
]
|
||||
)
|
||||
reviews_text += f"\n批次 {i + 1} [{title}]:\n- 维度表现: {dims}\n- 核心摘要: {summary}\n"
|
||||
reviews_text += f"\nBatch {i + 1} [{title}]:\n- Biểu hiện theo chiều: {dims}\n- Tóm tắt cốt lõi: {summary}\n"
|
||||
|
||||
# 获取配置中的汇总提示词模板,如果没有则使用默认模板
|
||||
# Dùng prompt tổng hợp trong cấu hình hoặc template mặc định.
|
||||
prompt_template = (
|
||||
self.config_manager.get_quality_summary_prompt()
|
||||
or """你现在有一份今天全天分散时间段的多个“增量批次点评笔记”。
|
||||
你的任务是将这些分散的笔记汇总成一份最终的“全天聊天质量终极锐评”。
|
||||
or """Bạn có nhiều ghi chú đánh giá theo batch gia tăng ở các khoảng thời gian trong ngày.
|
||||
Nhiệm vụ là tổng hợp chúng thành một bản đánh giá chất lượng trò chuyện cuối cùng cho cả ngày.
|
||||
|
||||
## 任务目标:
|
||||
1. **全局抽象维度**:根据各批次的维度表现,平衡权重,提取出 3-6 个覆盖全天的【核心、上层抽象】课题维度(如:职场/行业风向、技术架构演进、社畜心理博弈等)。
|
||||
2. **严禁在维度名称(name)中出现具体的批次细节。标题必须代表全天的某种趋势。**
|
||||
3. **百分比融合**:根据全天笔记的频率和强度,给出一个代表全天整体分布的比例(总和不超过100%)。
|
||||
4. **终极点评**:为每个汇总维度写出一句升华后的全天总结性点评。可以融合具体批次中的有趣槽点。
|
||||
5. **终极总结**:拟定全天的大型主题标题、副标题,并给出一句霸气的全天表现总结。
|
||||
## Mục tiêu:
|
||||
1. **Chiều trừu tượng toàn cục**: cân bằng trọng số giữa các batch và rút ra 3-6 chiều cốt lõi cấp cao bao phủ cả ngày, như xu hướng nghề nghiệp/ngành, phát triển kiến trúc kỹ thuật hoặc tâm lý nơi làm việc.
|
||||
2. **Tên chiều (name) không được chứa chi tiết batch; tiêu đề phải đại diện cho xu hướng cả ngày.**
|
||||
3. **Hợp nhất phần trăm**: dựa trên tần suất và cường độ để đưa ra phân bố cả ngày, tổng không vượt 100%.
|
||||
4. **Nhận xét cuối**: viết nhận xét tổng kết nâng cao cho từng chiều, có thể kết hợp chi tiết thú vị từ các batch.
|
||||
5. **Tổng kết cuối**: đặt tiêu đề, phụ đề và một câu kết mạnh mẽ cho cả ngày.
|
||||
|
||||
## 风格要求:
|
||||
- 只有维度名称(name)需要高度概括抽象。
|
||||
- 点评(comment)和总结(summary)请尽量生动、具体,要把一整天的梗串联起来。
|
||||
## Yêu cầu phong cách:
|
||||
- Chỉ tên chiều (name) cần khái quát và trừu tượng cao.
|
||||
- Comment và summary phải sinh động, cụ thể và kết nối các điểm đáng nhớ trong ngày.
|
||||
|
||||
## 返回格式要求:
|
||||
必须以纯 JSON 格式返回,不得包含任何 Markdown 格式。
|
||||
## Yêu cầu định dạng:
|
||||
Chỉ trả về JSON thuần, không chứa Markdown.
|
||||
|
||||
```json
|
||||
{{
|
||||
"title": "今日群聊主题",
|
||||
"subtitle": "副标题",
|
||||
"title": "Chủ đề trò chuyện hôm nay",
|
||||
"subtitle": "Phụ đề",
|
||||
"dimensions": [
|
||||
{{
|
||||
"name": "抽象大类标题",
|
||||
"percentage": 比例,
|
||||
"comment": "维度的全天锐评"
|
||||
"name": "Tên chiều khái quát",
|
||||
"percentage": 25,
|
||||
"comment": "Nhận xét cả ngày cho chiều này"
|
||||
}}
|
||||
],
|
||||
"summary": "全天总结金句"
|
||||
"summary": "Câu tổng kết nổi bật cho cả ngày"
|
||||
}}
|
||||
```
|
||||
"""
|
||||
)
|
||||
prompt = render_template(prompt_template, reviews_text=reviews_text)
|
||||
|
||||
# 调用 LLM 进行汇总
|
||||
# Gọi LLM để tổng hợp.
|
||||
system_prompt = await self._build_system_prompt(umo)
|
||||
base_temperature = await self._resolve_provider_temperature(
|
||||
self.get_provider_id_key(), umo
|
||||
)
|
||||
|
||||
# 应用人设强化注入
|
||||
# Inject tăng cường persona.
|
||||
prompt = self._apply_persona_reinforcement(prompt, system_prompt)
|
||||
|
||||
response = await call_provider_with_retry(
|
||||
@@ -365,7 +358,7 @@ ${messages_text}
|
||||
return None, usage
|
||||
|
||||
success, parsed_data, error_msg = parse_json_object_response(
|
||||
result_text, "汇总质量分析"
|
||||
result_text, "tổng hợp chất lượng"
|
||||
)
|
||||
|
||||
if success and parsed_data:
|
||||
@@ -375,7 +368,7 @@ ${messages_text}
|
||||
if valid and normalized:
|
||||
review = self._build_review_from_dict(normalized)
|
||||
logger.info(
|
||||
f"聊天质量汇总分析成功,解析到 {len(review.dimensions)} 个汇总维度"
|
||||
f"Tổng hợp chất lượng trò chuyện thành công, parse được {len(review.dimensions)} chiều"
|
||||
)
|
||||
return review, usage
|
||||
error_msg = validation_error or error_msg
|
||||
@@ -391,16 +384,18 @@ ${messages_text}
|
||||
if repaired_data:
|
||||
review = self._build_review_from_dict(repaired_data)
|
||||
logger.info(
|
||||
f"聊天质量汇总 schema 修复重试成功,解析到 {len(review.dimensions)} 个汇总维度"
|
||||
f"Retry sửa schema tổng hợp chất lượng thành công, parse được {len(review.dimensions)} chiều"
|
||||
)
|
||||
return review, usage
|
||||
|
||||
# 降级:如果汇总失败,返回最新的一个
|
||||
logger.warning(f"聊天质量汇总分析失败,降级使用最新批次: {error_msg}")
|
||||
# Fallback về batch mới nhất nếu tổng hợp thất bại.
|
||||
logger.warning(
|
||||
f"Tổng hợp chất lượng thất bại, dùng batch mới nhất: {error_msg}"
|
||||
)
|
||||
return self._build_review_from_dict(batch_reviews[-1]), usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"聊天质量汇总分析异常: {e}", exc_info=True)
|
||||
logger.error(f"Lỗi tổng hợp chất lượng trò chuyện: {e}", exc_info=True)
|
||||
return self._build_review_from_dict(batch_reviews[-1]), TokenUsage()
|
||||
|
||||
async def analyze_quality(
|
||||
@@ -410,31 +405,26 @@ ${messages_text}
|
||||
session_id: str | None = None,
|
||||
) -> tuple[QualityReview | None, TokenUsage]:
|
||||
"""
|
||||
分析聊天质量
|
||||
Phân tích chất lượng trò chuyện.
|
||||
|
||||
流程遵循 BaseAnalyzer 的设计模式:
|
||||
1. 构建 prompt
|
||||
2. 调用 LLM
|
||||
3. 提取 token 使用统计
|
||||
4. JSON 解析(使用 parse_json_object_response)
|
||||
5. 正则降级(使用 extract_quality_with_regex)
|
||||
Theo mẫu BaseAnalyzer: xây prompt, gọi LLM, lấy token, parse JSON và fallback regex.
|
||||
"""
|
||||
try:
|
||||
# 1. 获取人格设定
|
||||
# 1. Lấy persona.
|
||||
system_prompt = await self._build_system_prompt(umo)
|
||||
base_temperature = await self._resolve_provider_temperature(
|
||||
self.get_provider_id_key(), umo
|
||||
)
|
||||
|
||||
# 2. 构建 prompt
|
||||
# 2. Xây dựng prompt.
|
||||
prompt = self.build_prompt(messages)
|
||||
if not prompt:
|
||||
return None, TokenUsage()
|
||||
|
||||
# 应用人设强化注入
|
||||
# Inject tăng cường persona.
|
||||
prompt = self._apply_persona_reinforcement(prompt, system_prompt)
|
||||
|
||||
# 3. 调用 LLM
|
||||
# 3. Gọi LLM.
|
||||
response = await call_provider_with_retry(
|
||||
self.context,
|
||||
self.config_manager,
|
||||
@@ -448,7 +438,7 @@ ${messages_text}
|
||||
if response is None:
|
||||
return None, TokenUsage()
|
||||
|
||||
# 4. 提取 token 使用统计
|
||||
# 4. Trích xuất thống kê token.
|
||||
token_usage_dict = extract_token_usage(response)
|
||||
usage = TokenUsage(
|
||||
prompt_tokens=token_usage_dict["prompt_tokens"],
|
||||
@@ -456,12 +446,12 @@ ${messages_text}
|
||||
total_tokens=token_usage_dict["total_tokens"],
|
||||
)
|
||||
|
||||
# 5. 提取响应文本
|
||||
# 5. Trích xuất văn bản phản hồi.
|
||||
result_text = extract_response_text(response)
|
||||
if not result_text:
|
||||
return None, usage
|
||||
|
||||
# 6. JSON 解析(使用 parse_json_object_response)
|
||||
# 6. Parse JSON bằng parse_json_object_response.
|
||||
success, parsed_data, error_msg = parse_json_object_response(
|
||||
result_text, self.get_data_type()
|
||||
)
|
||||
@@ -473,7 +463,7 @@ ${messages_text}
|
||||
if valid and normalized:
|
||||
review = self._build_review_from_dict(normalized)
|
||||
logger.debug(
|
||||
f"聊天质量分析成功,解析到 {len(review.dimensions)} 个维度"
|
||||
f"Phân tích chất lượng thành công, parse được {len(review.dimensions)} chiều"
|
||||
)
|
||||
return review, usage
|
||||
error_msg = validation_error or error_msg
|
||||
@@ -486,7 +476,7 @@ ${messages_text}
|
||||
if valid and normalized:
|
||||
review = self._build_review_from_dict(normalized)
|
||||
logger.debug(
|
||||
f"聊天质量首轮结构化失败后,正则提取成功,获得 {len(review.dimensions)} 个维度"
|
||||
f"Lần parse có cấu trúc đầu thất bại; regex lấy được {len(review.dimensions)} chiều"
|
||||
)
|
||||
return review, usage
|
||||
error_msg = validation_error or error_msg
|
||||
@@ -502,16 +492,18 @@ ${messages_text}
|
||||
if repaired_data:
|
||||
review = self._build_review_from_dict(repaired_data)
|
||||
logger.debug(
|
||||
f"聊天质量 schema 修复重试成功,解析到 {len(review.dimensions)} 个维度"
|
||||
f"Retry sửa schema chất lượng thành công, parse được {len(review.dimensions)} chiều"
|
||||
)
|
||||
return review, usage
|
||||
|
||||
# 7. 全部失败
|
||||
logger.error(f"聊天质量分析失败: JSON解析和正则提取均未成功: {error_msg}")
|
||||
# 7. Mọi cách đều thất bại.
|
||||
logger.error(
|
||||
f"Phân tích chất lượng thất bại: cả parse JSON và regex đều lỗi: {error_msg}"
|
||||
)
|
||||
return None, usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"聊天质量分析失败: {e}", exc_info=True)
|
||||
logger.error(f"Phân tích chất lượng thất bại: {e}", exc_info=True)
|
||||
return None, TokenUsage()
|
||||
|
||||
# Override analyze to bridge the base class interface
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
金句分析模块
|
||||
专门处理群聊金句提取和分析
|
||||
"""
|
||||
"""Module trích xuất và phân tích trích dẫn nổi bật trong nhóm."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -16,21 +13,18 @@ from .base_analyzer import BaseAnalyzer
|
||||
|
||||
|
||||
class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
|
||||
"""
|
||||
金句分析器
|
||||
专门处理群聊金句的提取和分析
|
||||
"""
|
||||
"""Analyzer trích xuất và phân tích trích dẫn nổi bật."""
|
||||
|
||||
def get_provider_id_key(self) -> str:
|
||||
"""获取 Provider ID 配置键名"""
|
||||
"""Lấy tên key cấu hình Provider ID."""
|
||||
return "golden_quote_provider_id"
|
||||
|
||||
def get_data_type(self) -> str:
|
||||
"""获取数据类型标识"""
|
||||
"""Lấy định danh loại dữ liệu."""
|
||||
return "Trích dẫn nổi bật"
|
||||
|
||||
def get_max_count(self) -> int:
|
||||
"""获取最大金句数量,增量模式下使用覆盖值"""
|
||||
"""Lấy số trích dẫn tối đa, dùng giá trị ghi đè trong chế độ gia tăng."""
|
||||
if self._incremental_max_count is not None:
|
||||
return self._incremental_max_count
|
||||
return self.config_manager.get_max_golden_quotes()
|
||||
@@ -43,25 +37,25 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
|
||||
|
||||
def build_prompt(self, data: list[dict]) -> str:
|
||||
"""
|
||||
构建金句分析提示词
|
||||
Xây dựng prompt phân tích trích dẫn.
|
||||
|
||||
Args:
|
||||
messages: 群聊的文本消息列表
|
||||
messages: Danh sách tin nhắn văn bản của nhóm.
|
||||
|
||||
Returns:
|
||||
提示词字符串
|
||||
Chuỗi prompt.
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
# 构建消息文本 (用 [user_id] 替代 nickname 以确保回填 100% 准确,避免 Emoji 等干扰)
|
||||
# Dùng [user_id] thay nickname để khôi phục chính xác và tránh nhiễu emoji.
|
||||
messages_text = "\n".join(
|
||||
[f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}" for msg in data]
|
||||
)
|
||||
|
||||
max_golden_quotes = self.get_max_count()
|
||||
|
||||
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
||||
# Đọc template prompt từ cấu hình, mặc định kiểu ``default``.
|
||||
prompt_template = self.config_manager.get_golden_quote_analysis_prompt()
|
||||
|
||||
if prompt_template:
|
||||
@@ -71,50 +65,52 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
|
||||
max_golden_quotes=max_golden_quotes,
|
||||
messages_text=messages_text,
|
||||
)
|
||||
logger.info("使用配置中的金句分析提示词")
|
||||
logger.info("Đang dùng prompt phân tích trích dẫn trong cấu hình")
|
||||
return prompt
|
||||
except Exception as e:
|
||||
logger.warning(f"应用金句分析提示词失败: {e}")
|
||||
logger.warning(f"Áp dụng prompt phân tích trích dẫn thất bại: {e}")
|
||||
|
||||
logger.warning("未找到有效的金句分析提示词配置,请检查配置文件")
|
||||
logger.warning("Không tìm thấy cấu hình prompt phân tích trích dẫn hợp lệ")
|
||||
return ""
|
||||
|
||||
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取金句信息
|
||||
Trích xuất trích dẫn bằng regex.
|
||||
|
||||
Args:
|
||||
result_text: LLM响应文本
|
||||
max_count: 最大提取数量
|
||||
result_text: Văn bản phản hồi LLM.
|
||||
max_count: Số lượng tối đa.
|
||||
|
||||
Returns:
|
||||
金句数据列表
|
||||
Danh sách dữ liệu trích dẫn.
|
||||
"""
|
||||
return extract_golden_quotes_with_regex(result_text, max_count)
|
||||
|
||||
def create_data_objects(self, data_list: list[dict]) -> list[GoldenQuote]:
|
||||
"""
|
||||
创建金句对象列表
|
||||
Tạo danh sách object trích dẫn.
|
||||
|
||||
Args:
|
||||
quotes_data: 原始金句数据列表
|
||||
quotes_data: Danh sách dữ liệu trích dẫn gốc.
|
||||
|
||||
Returns:
|
||||
GoldenQuote对象列表
|
||||
Danh sách object GoldenQuote.
|
||||
"""
|
||||
try:
|
||||
quotes = []
|
||||
max_quotes = self.get_max_count()
|
||||
|
||||
for quote_data in data_list[:max_quotes]:
|
||||
# 确保数据格式正确
|
||||
# Đảm bảo định dạng dữ liệu đúng.
|
||||
content = quote_data.get("content", "").strip()
|
||||
sender = quote_data.get("sender", "").strip()
|
||||
reason = quote_data.get("reason", "").strip()
|
||||
|
||||
# 验证必要字段
|
||||
# Xác thực trường bắt buộc.
|
||||
if not content or not sender or not reason:
|
||||
logger.warning(f"金句数据格式不完整,跳过: {quote_data}")
|
||||
logger.warning(
|
||||
f"Dữ liệu trích dẫn không đầy đủ, bỏ qua: {quote_data}"
|
||||
)
|
||||
continue
|
||||
|
||||
quotes.append(
|
||||
@@ -124,7 +120,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
|
||||
return quotes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建金句对象失败: {e}")
|
||||
logger.error(f"Tạo object trích dẫn thất bại: {e}")
|
||||
return []
|
||||
|
||||
def validate_parsed_data(
|
||||
@@ -139,38 +135,41 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[GoldenQuote], TokenUsage]:
|
||||
"""
|
||||
分析群聊金句
|
||||
Phân tích trích dẫn nổi bật trong nhóm.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
umo: 模型唯一标识符
|
||||
session_id: 会话ID (用于调试模式)
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
umo: Định danh model.
|
||||
session_id: ID phiên dùng cho debug mode.
|
||||
|
||||
Returns:
|
||||
(金句列表, Token使用统计)
|
||||
Tuple danh sách trích dẫn và thống kê token.
|
||||
"""
|
||||
try:
|
||||
# 提取圣经的文本消息
|
||||
# Trích xuất tin nhắn văn bản đáng chú ý.
|
||||
interesting_messages = self.extract_interesting_messages(messages)
|
||||
|
||||
if not interesting_messages:
|
||||
logger.info("没有符合条件的圣经消息,返回空结果")
|
||||
logger.info(
|
||||
"Không có tin nhắn phù hợp để trích dẫn, trả về kết quả rỗng"
|
||||
)
|
||||
return [], TokenUsage()
|
||||
|
||||
logger.info(f"开始从 {len(interesting_messages)} 条圣经消息中提取金句")
|
||||
logger.info(
|
||||
f"Bắt đầu trích xuất trích dẫn từ {len(interesting_messages)} tin nhắn đáng chú ý"
|
||||
)
|
||||
quotes, usage = await self.analyze(interesting_messages, umo, session_id)
|
||||
|
||||
# 建立 ID 到昵称的映射表用于恢复显示
|
||||
# Lập ánh xạ ID sang biệt danh để khôi phục hiển thị.
|
||||
id_to_nickname = {}
|
||||
for msg in interesting_messages:
|
||||
uid = str(msg.get("user_id", ""))
|
||||
if uid:
|
||||
id_to_nickname[uid] = msg.get("sender", "")
|
||||
|
||||
# 回填 User ID 并恢复发送者昵称
|
||||
# Điền User ID và khôi phục biệt danh người gửi.
|
||||
for quote in quotes:
|
||||
# 此时 quote.sender 包含的是 Prompt 中的 [user_id]
|
||||
# 有些 LLM 可能会带上中括号,尝试清理
|
||||
# quote.sender hiện chứa [user_id] trong prompt; loại ngoặc nếu có.
|
||||
potential_id = quote.sender.strip().strip("[]")
|
||||
|
||||
if potential_id in id_to_nickname:
|
||||
@@ -178,29 +177,29 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
|
||||
quote.sender = id_to_nickname[potential_id]
|
||||
else:
|
||||
logger.warning(
|
||||
f"[金句分析] 无法匹配 User ID: {potential_id},金句将无法显示真实头像。"
|
||||
f"[Phân tích trích dẫn] Không khớp User ID: {potential_id}; không thể hiển thị avatar thật"
|
||||
)
|
||||
|
||||
return quotes, usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"金句分析失败: {e}")
|
||||
logger.error(f"Phân tích trích dẫn thất bại: {e}")
|
||||
return [], TokenUsage()
|
||||
|
||||
def extract_interesting_messages(self, messages: list[dict]) -> list[dict]:
|
||||
"""
|
||||
根据清理后的消息提取可能有意义的消息片段用于金句分析。
|
||||
Trích xuất đoạn tin nhắn có ý nghĩa từ dữ liệu đã làm sạch.
|
||||
|
||||
Args:
|
||||
messages: 已由 MessageCleaner 处理过的 legacy 消息列表
|
||||
messages: Danh sách tin nhắn legacy đã qua MessageCleaner.
|
||||
|
||||
Returns:
|
||||
提取的文本消息列表
|
||||
Danh sách tin nhắn văn bản đã trích xuất.
|
||||
"""
|
||||
interesting_messages = []
|
||||
|
||||
for msg in messages:
|
||||
# 获取发送者显示名
|
||||
# Lấy tên hiển thị của người gửi.
|
||||
sender = msg.get("sender", {})
|
||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||
@@ -208,7 +207,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer[GoldenQuote, list[dict]]):
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") == "text":
|
||||
text = content.get("data", {}).get("text", "").strip()
|
||||
# 过滤掉过短或过长的噪音(已经在 cleaner 处理过一遍基本垃圾)
|
||||
# Lọc nhiễu quá ngắn hoặc quá dài sau bước cleaner cơ bản.
|
||||
if 2 <= len(text) <= 500:
|
||||
interesting_messages.append(
|
||||
{
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
话题分析模块
|
||||
专门处理群聊话题分析
|
||||
"""
|
||||
"""Module phân tích chủ đề trò chuyện nhóm."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -17,21 +14,18 @@ from .base_analyzer import BaseAnalyzer
|
||||
|
||||
|
||||
class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
"""
|
||||
话题分析器
|
||||
专门处理群聊话题的提取和分析
|
||||
"""
|
||||
"""Trích xuất và phân tích chủ đề trò chuyện nhóm."""
|
||||
|
||||
def get_provider_id_key(self) -> str:
|
||||
"""获取 Provider ID 配置键名"""
|
||||
"""Lấy tên key cấu hình Provider ID."""
|
||||
return "topic_provider_id"
|
||||
|
||||
def get_data_type(self) -> str:
|
||||
"""获取数据类型标识"""
|
||||
"""Lấy định danh loại dữ liệu."""
|
||||
return "Chủ đề"
|
||||
|
||||
def get_max_count(self) -> int:
|
||||
"""获取最大话题数量,增量模式下使用覆盖值"""
|
||||
"""Lấy số chủ đề tối đa, ưu tiên giá trị override ở chế độ gia tăng."""
|
||||
if self._incremental_max_count is not None:
|
||||
return self._incremental_max_count
|
||||
return self.config_manager.get_max_topics()
|
||||
@@ -44,42 +38,42 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
|
||||
def build_prompt(self, data: list[dict]) -> str:
|
||||
"""
|
||||
构建话题分析提示词
|
||||
Xây dựng prompt phân tích chủ đề.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
data: Danh sách tin nhắn nhóm.
|
||||
|
||||
Returns:
|
||||
提示词字符串
|
||||
Chuỗi prompt.
|
||||
"""
|
||||
# 验证输入数据格式
|
||||
# Xác thực định dạng input.
|
||||
if not isinstance(data, list):
|
||||
logger.error(f"build_prompt 期望列表,但收到: {type(data)}")
|
||||
logger.error(f"build_prompt cần list nhưng nhận được: {type(data)}")
|
||||
return ""
|
||||
|
||||
# 检查消息列表是否为空
|
||||
# Kiểm tra danh sách tin nhắn rỗng.
|
||||
if not data:
|
||||
logger.warning("build_prompt 收到空消息列表")
|
||||
logger.warning("build_prompt nhận danh sách tin nhắn rỗng")
|
||||
return ""
|
||||
|
||||
# 提取文本消息
|
||||
# Trích xuất tin nhắn văn bản.
|
||||
text_messages = []
|
||||
for i, msg in enumerate(data):
|
||||
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误
|
||||
# Bỏ qua msg không phải dict để tránh lỗi thuộc tính get.
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
sender = msg.get("sender", {})
|
||||
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误
|
||||
# Bỏ qua sender không phải dict.
|
||||
if not isinstance(sender, dict):
|
||||
continue
|
||||
|
||||
# 获取发送者ID并过滤机器人消息
|
||||
# Lấy ID người gửi và lọc tin nhắn bot.
|
||||
user_id = str(sender.get("user_id", ""))
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
|
||||
# 跳过机器人自己的消息
|
||||
# Bỏ qua tin nhắn của bot.
|
||||
if bot_self_ids and user_id in [str(uid) for uid in bot_self_ids]:
|
||||
continue
|
||||
|
||||
@@ -88,7 +82,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
|
||||
message_list = msg.get("message", [])
|
||||
|
||||
# 提取文本内容,可能分布在多个 content 中
|
||||
# Nội dung văn bản có thể nằm trong nhiều phần content.
|
||||
text_parts = []
|
||||
for j, content in enumerate(message_list):
|
||||
if not isinstance(content, dict):
|
||||
@@ -101,21 +95,21 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif content_type == "at":
|
||||
# 处理 @ 消息,转换为文本
|
||||
# Chuyển mention thành văn bản.
|
||||
at_data = content.get("data", {})
|
||||
# 兼容不同平台的 ID 字段
|
||||
# Tương thích trường ID giữa các nền tảng.
|
||||
at_id = at_data.get("id") or at_data.get("user_id")
|
||||
if at_id:
|
||||
at_text = f"@{at_id}"
|
||||
text_parts.append(at_text)
|
||||
elif content_type == "reply":
|
||||
# 处理回复消息,添加标记
|
||||
# Thêm nhãn cho tin nhắn trả lời.
|
||||
reply_id = content.get("data", {}).get("id", "")
|
||||
if reply_id:
|
||||
reply_text = f"[回复:{reply_id}]"
|
||||
reply_text = f"[Trả lời:{reply_id}]"
|
||||
text_parts.append(reply_text)
|
||||
|
||||
# 合并所有文本部分
|
||||
# Gộp mọi phần văn bản.
|
||||
combined_text = "".join(text_parts).strip()
|
||||
|
||||
if (
|
||||
@@ -123,7 +117,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
and len(combined_text) > 2
|
||||
and not combined_text.startswith("/")
|
||||
):
|
||||
# 清理消息内容
|
||||
# Làm sạch nội dung tin nhắn.
|
||||
cleaned_text = combined_text.replace("“", '"').replace("”", '"')
|
||||
cleaned_text = cleaned_text.replace("‘", "'").replace("’", "'")
|
||||
cleaned_text = cleaned_text.replace("\n", " ").replace("\r", " ")
|
||||
@@ -140,16 +134,18 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"build_prompt 处理第 {i + 1} 条消息时出错: {e}", exc_info=True
|
||||
f"build_prompt lỗi khi xử lý tin nhắn thứ {i + 1}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if not text_messages:
|
||||
logger.warning("build_prompt 没有提取到有效的文本消息,返回空prompt")
|
||||
logger.warning(
|
||||
"build_prompt không trích xuất được tin nhắn hợp lệ; trả về prompt rỗng"
|
||||
)
|
||||
return ""
|
||||
|
||||
# 构建消息文本
|
||||
# 使用用户提供的 ID-Only 格式: [HH:MM] [用户ID]: 消息内容
|
||||
# Dựng văn bản theo định dạng chỉ ID: [HH:MM] [ID người dùng]: nội dung.
|
||||
messages_text = "\n".join(
|
||||
[
|
||||
f"[{msg['time']}] [{msg['user_id']}]: {msg['content']}"
|
||||
@@ -159,7 +155,7 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
|
||||
max_topics = self.get_max_count()
|
||||
|
||||
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
||||
# Đọc template prompt từ cấu hình.
|
||||
prompt_template = self.config_manager.get_topic_analysis_prompt()
|
||||
|
||||
if prompt_template:
|
||||
@@ -169,98 +165,104 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
max_topics=max_topics,
|
||||
messages_text=messages_text,
|
||||
)
|
||||
logger.info("使用配置中的话题分析提示词")
|
||||
logger.info("Đang dùng prompt phân tích chủ đề trong cấu hình")
|
||||
return prompt
|
||||
except Exception as e:
|
||||
logger.warning(f"应用话题分析提示词失败: {e}")
|
||||
logger.warning(f"Áp dụng prompt phân tích chủ đề thất bại: {e}")
|
||||
|
||||
logger.warning("未找到有效的话题分析提示词配置,请检查配置文件")
|
||||
logger.warning(
|
||||
"Không tìm thấy prompt phân tích chủ đề hợp lệ; hãy kiểm tra cấu hình"
|
||||
)
|
||||
return ""
|
||||
|
||||
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取话题信息
|
||||
Trích xuất thông tin chủ đề bằng regex.
|
||||
|
||||
Args:
|
||||
result_text: LLM响应文本
|
||||
max_count: 最大话题数量
|
||||
result_text: Văn bản phản hồi LLM.
|
||||
max_count: Số chủ đề tối đa.
|
||||
|
||||
Returns:
|
||||
话题数据列表
|
||||
Danh sách dữ liệu chủ đề.
|
||||
"""
|
||||
return extract_topics_with_regex(result_text, max_count)
|
||||
|
||||
def create_data_objects(self, data_list: list[dict]) -> list[SummaryTopic]:
|
||||
"""
|
||||
创建话题对象列表
|
||||
Tạo danh sách object chủ đề.
|
||||
|
||||
Args:
|
||||
topics_data: 原始话题数据列表
|
||||
data_list: Danh sách dữ liệu chủ đề thô.
|
||||
|
||||
Returns:
|
||||
SummaryTopic对象列表
|
||||
Danh sách object SummaryTopic.
|
||||
"""
|
||||
logger.debug(
|
||||
f"create_data_objects 开始处理,输入数据数量: {len(data_list) if data_list else 0}"
|
||||
f"create_data_objects bắt đầu, số mục input: {len(data_list) if data_list else 0}"
|
||||
)
|
||||
logger.debug(f"输入数据类型: {type(data_list)}")
|
||||
logger.debug(f"Loại dữ liệu input: {type(data_list)}")
|
||||
|
||||
try:
|
||||
topics = []
|
||||
max_topics = self.get_max_count()
|
||||
|
||||
logger.debug(f"处理前 {max_topics} 条话题数据")
|
||||
logger.debug(f"Đang xử lý tối đa {max_topics} chủ đề đầu")
|
||||
|
||||
for i, topic_data in enumerate(data_list[:max_topics]):
|
||||
logger.debug(f"处理第 {i + 1} 条话题数据,类型: {type(topic_data)}")
|
||||
logger.debug(f"Đang xử lý chủ đề thứ {i + 1}, loại: {type(topic_data)}")
|
||||
|
||||
# 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误
|
||||
# Bỏ qua dữ liệu chủ đề không phải dict.
|
||||
if not isinstance(topic_data, dict):
|
||||
logger.warning(
|
||||
f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}"
|
||||
f"Bỏ qua dữ liệu chủ đề không phải dict: {type(topic_data)} - {topic_data}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
# 确保数据格式正确
|
||||
# Chuẩn hoá định dạng dữ liệu.
|
||||
topic_name = topic_data.get("topic", "").strip()
|
||||
contributors = topic_data.get("contributors", [])
|
||||
detail = topic_data.get("detail", "").strip()
|
||||
|
||||
logger.debug(
|
||||
f"话题数据 - 名称: {topic_name}, 参与者: {contributors}, 详情: {detail[:50]}..."
|
||||
f"Chủ đề - tên: {topic_name}, người tham gia: {contributors}, chi tiết: {detail[:50]}..."
|
||||
)
|
||||
|
||||
# 验证必要字段
|
||||
# Xác thực các trường bắt buộc.
|
||||
if not topic_name or not detail:
|
||||
logger.warning(f"话题数据格式不完整,跳过: {topic_data}")
|
||||
logger.warning(
|
||||
f"Dữ liệu chủ đề không đầy đủ, bỏ qua: {topic_data}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 确保参与者列表有效
|
||||
# Đảm bảo danh sách người tham gia hợp lệ.
|
||||
if not contributors or not isinstance(contributors, list):
|
||||
contributors = ["群友"]
|
||||
contributors = ["Thành viên nhóm"]
|
||||
else:
|
||||
# 清理参与者名称
|
||||
# Làm sạch tên người tham gia.
|
||||
contributors = [
|
||||
str(c).strip() for c in contributors if c and str(c).strip()
|
||||
] or ["群友"]
|
||||
] or ["Thành viên nhóm"]
|
||||
|
||||
topics.append(
|
||||
SummaryTopic(
|
||||
topic=topic_name,
|
||||
contributors=contributors[:5], # 最多5个参与者
|
||||
contributors=contributors[:5], # Tối đa 5 người tham gia.
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"处理第 {i + 1} 条话题数据时出错: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Lỗi khi xử lý chủ đề thứ {i + 1}: {e}", exc_info=True
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(f"create_data_objects 完成,创建了 {len(topics)} 个话题对象")
|
||||
logger.debug(f"create_data_objects hoàn tất, đã tạo {len(topics)} chủ đề")
|
||||
return topics
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建话题对象失败: {e}", exc_info=True)
|
||||
logger.error(f"Tạo object chủ đề thất bại: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def validate_parsed_data(
|
||||
@@ -270,18 +272,18 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
|
||||
def extract_text_messages(self, messages: list[dict]) -> list[dict]:
|
||||
"""
|
||||
从已清理的消息中提取文本消息用于话题分析。
|
||||
Trích xuất tin nhắn văn bản đã làm sạch để phân tích chủ đề.
|
||||
|
||||
Args:
|
||||
messages: 已由 MessageCleaner 处理过的 legacy 消息列表
|
||||
messages: Danh sách tin nhắn legacy đã qua MessageCleaner.
|
||||
|
||||
Returns:
|
||||
提取的文本消息列表
|
||||
Danh sách tin nhắn văn bản được trích xuất.
|
||||
"""
|
||||
text_messages = []
|
||||
|
||||
for msg in messages:
|
||||
# 获取发送者显示名
|
||||
# Lấy tên hiển thị người gửi.
|
||||
sender = msg.get("sender", {})
|
||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||
@@ -289,9 +291,9 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") == "text":
|
||||
text = content.get("data", {}).get("text", "").strip()
|
||||
# 已经在 MessageCleaner 中处理过基本的垃圾内容
|
||||
# Nội dung rác cơ bản đã được MessageCleaner xử lý.
|
||||
if text:
|
||||
# 简单的额外清理
|
||||
# Làm sạch bổ sung đơn giản.
|
||||
cleaned_text = text.replace("\n", " ").replace("\r", " ")
|
||||
cleaned_text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned_text)
|
||||
|
||||
@@ -312,42 +314,46 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[SummaryTopic], TokenUsage]:
|
||||
"""
|
||||
分析群聊话题
|
||||
Phân tích chủ đề trò chuyện nhóm.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
umo: 模型唯一标识符
|
||||
session_id: 会话ID (用于调试模式)
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
umo: Định danh UMO.
|
||||
session_id: ID phiên dùng trong debug mode.
|
||||
|
||||
Returns:
|
||||
(话题列表, Token使用统计)
|
||||
Tuple danh sách chủ đề và thống kê token.
|
||||
"""
|
||||
try:
|
||||
logger.debug(
|
||||
f"analyze_topics 开始处理,消息数量: {len(messages) if messages else 0}"
|
||||
f"analyze_topics bắt đầu, số tin nhắn: {len(messages) if messages else 0}"
|
||||
)
|
||||
logger.debug(f"消息类型: {type(messages)}")
|
||||
logger.debug(f"Loại dữ liệu tin nhắn: {type(messages)}")
|
||||
if messages:
|
||||
logger.debug(
|
||||
f"第一条消息类型: {type(messages[0]) if messages else '无'}"
|
||||
f"Loại tin nhắn đầu tiên: {type(messages[0]) if messages else 'không có'}"
|
||||
)
|
||||
logger.debug(
|
||||
f"Nội dung tin nhắn đầu tiên: {messages[0] if messages else 'không có'}"
|
||||
)
|
||||
logger.debug(f"第一条消息内容: {messages[0] if messages else '无'}")
|
||||
|
||||
# 检查是否有有效的文本消息
|
||||
# Kiểm tra tin nhắn văn bản hợp lệ.
|
||||
text_messages = self.extract_text_messages(messages)
|
||||
logger.debug(f"提取到 {len(text_messages)} 条文本消息")
|
||||
logger.debug(f"Đã trích xuất {len(text_messages)} tin nhắn văn bản")
|
||||
|
||||
if not text_messages:
|
||||
logger.info("没有有效的文本消息,返回空结果")
|
||||
logger.info("Không có tin nhắn văn bản hợp lệ; trả về kết quả rỗng")
|
||||
return [], TokenUsage()
|
||||
|
||||
logger.info(f"开始分析 {len(text_messages)} 条文本消息中的话题")
|
||||
logger.debug(f"文本消息类型: {type(text_messages)}")
|
||||
logger.info(f"Bắt đầu phân tích chủ đề từ {len(text_messages)} tin nhắn")
|
||||
logger.debug(f"Loại dữ liệu tin nhắn văn bản: {type(text_messages)}")
|
||||
if text_messages:
|
||||
logger.debug(f"第一条文本消息类型: {type(text_messages[0])}")
|
||||
logger.debug(f"第一条文本消息内容: {text_messages[0]}")
|
||||
logger.debug(
|
||||
f"Loại tin nhắn văn bản đầu tiên: {type(text_messages[0])}"
|
||||
)
|
||||
logger.debug(f"Nội dung tin nhắn văn bản đầu tiên: {text_messages[0]}")
|
||||
|
||||
# 建立 ID 到昵称的映射表
|
||||
# Lập bảng ánh xạ ID sang nickname.
|
||||
id_to_nickname = {}
|
||||
for msg in text_messages:
|
||||
sender = msg.get("sender")
|
||||
@@ -355,15 +361,15 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
if sender and user_id:
|
||||
id_to_nickname[user_id] = sender
|
||||
|
||||
# 直接传入原始消息,让 build_prompt 方法处理
|
||||
# Truyền tin nhắn gốc để build_prompt xử lý.
|
||||
topics, usage = await self.analyze(messages, umo, session_id)
|
||||
|
||||
# 后处理:contributors 此时包含的是 ID,需要映射回昵称
|
||||
# Hậu xử lý: ánh xạ contributor ID về nickname.
|
||||
for topic in topics:
|
||||
raw_ids = topic.contributors # LLM 返回的是 ID 列表
|
||||
raw_ids = topic.contributors # LLM trả về danh sách ID.
|
||||
|
||||
# 填充 contributor_ids。QQ 官方 member_openid 并非纯数字,
|
||||
# 因此仅接受本批次已知用户或已配置机器人 ID,而不是用 isdigit 过滤。
|
||||
# member_openid QQ Official không chỉ gồm số; chỉ nhận ID đã biết
|
||||
# trong batch hoặc ID bot cấu hình thay vì lọc bằng isdigit.
|
||||
bot_ids = {str(uid) for uid in self.config_manager.get_bot_self_ids()}
|
||||
known_ids = set(id_to_nickname) | bot_ids
|
||||
valid_ids = []
|
||||
@@ -373,13 +379,13 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
valid_ids.append(uid)
|
||||
topic.contributor_ids = valid_ids
|
||||
|
||||
# 映射回昵称用于显示
|
||||
# Ánh xạ về nickname để hiển thị.
|
||||
resolved_names = []
|
||||
for uid in valid_ids:
|
||||
# 尝试从当前批次消息映射
|
||||
# Thử ánh xạ từ batch hiện tại.
|
||||
name = id_to_nickname.get(uid)
|
||||
if not name:
|
||||
# 尝试去全局配置里找 (e.g. 机器人自己)
|
||||
# Thử tìm trong cấu hình toàn cục, ví dụ bot.
|
||||
if uid in bot_ids:
|
||||
name = "Bot"
|
||||
else:
|
||||
@@ -391,5 +397,5 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
return topics, usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"话题分析失败: {e}", exc_info=True)
|
||||
logger.error(f"Phân tích chủ đề thất bại: {e}", exc_info=True)
|
||||
return [], TokenUsage()
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
用户称号分析模块
|
||||
专门处理用户称号和MBTI类型分析
|
||||
"""
|
||||
"""Module phân tích danh hiệu thành viên và kiểu MBTI."""
|
||||
|
||||
from ....domain.models.data_models import TokenUsage, UserTitle
|
||||
from ....utils.logger import logger
|
||||
@@ -13,21 +10,18 @@ from .base_analyzer import BaseAnalyzer
|
||||
|
||||
|
||||
class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
"""
|
||||
用户称号分析器
|
||||
专门处理用户称号分配和MBTI类型分析
|
||||
"""
|
||||
"""Analyzer phân bổ danh hiệu thành viên và phân tích MBTI."""
|
||||
|
||||
def get_provider_id_key(self) -> str:
|
||||
"""获取 Provider ID 配置键名"""
|
||||
"""Lấy tên key cấu hình Provider ID."""
|
||||
return "user_title_provider_id"
|
||||
|
||||
def get_data_type(self) -> str:
|
||||
"""获取数据类型标识"""
|
||||
"""Lấy định danh loại dữ liệu."""
|
||||
return "Danh hiệu thành viên"
|
||||
|
||||
def get_max_count(self) -> int:
|
||||
"""获取最大用户称号数量"""
|
||||
"""Lấy số danh hiệu thành viên tối đa."""
|
||||
return self.config_manager.get_max_user_titles()
|
||||
|
||||
def get_response_schema_name(self) -> str:
|
||||
@@ -38,89 +32,91 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
|
||||
def build_prompt(self, data: dict) -> str:
|
||||
"""
|
||||
构建用户称号分析提示词
|
||||
Xây dựng prompt phân tích danh hiệu thành viên.
|
||||
|
||||
Args:
|
||||
user_data: 用户数据字典,包含用户统计信息
|
||||
user_data: Dict dữ liệu và thống kê thành viên.
|
||||
|
||||
Returns:
|
||||
提示词字符串
|
||||
Chuỗi prompt.
|
||||
"""
|
||||
user_summaries = data.get("user_summaries", [])
|
||||
|
||||
if not user_summaries:
|
||||
return ""
|
||||
|
||||
# 构建用户数据文本
|
||||
# Xây dựng văn bản dữ liệu thành viên.
|
||||
users_text = "\n".join(
|
||||
[
|
||||
f"- {user['name']} (ID:{user['user_id']}): "
|
||||
f"发言{user['message_count']}条, 平均{user['avg_chars']}字, "
|
||||
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, "
|
||||
f"回复比例{user['reply_ratio']}"
|
||||
f"{user['message_count']} tin nhắn, trung bình {user['avg_chars']} ký tự, "
|
||||
f"tỷ lệ biểu cảm {user['emoji_ratio']}, tỷ lệ chat ban đêm {user['night_ratio']}, "
|
||||
f"tỷ lệ trả lời {user['reply_ratio']}"
|
||||
for user in user_summaries
|
||||
]
|
||||
)
|
||||
|
||||
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
||||
# Đọc template prompt từ cấu hình, mặc định kiểu ``default``.
|
||||
prompt_template = self.config_manager.get_user_title_analysis_prompt()
|
||||
|
||||
if prompt_template:
|
||||
try:
|
||||
prompt = render_template(prompt_template, users_text=users_text)
|
||||
logger.info("使用配置中的用户称号分析提示词")
|
||||
logger.info("Đang dùng prompt phân tích danh hiệu trong cấu hình")
|
||||
return prompt
|
||||
except Exception as e:
|
||||
logger.warning(f"应用用户称号分析提示词失败: {e}")
|
||||
logger.warning(f"Áp dụng prompt phân tích danh hiệu thất bại: {e}")
|
||||
|
||||
logger.warning("未找到有效的用户称号分析提示词配置,请检查配置文件")
|
||||
logger.warning("Không tìm thấy cấu hình prompt phân tích danh hiệu hợp lệ")
|
||||
return ""
|
||||
|
||||
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取用户称号信息
|
||||
Trích xuất thông tin danh hiệu bằng regex.
|
||||
|
||||
Args:
|
||||
result_text: LLM响应文本
|
||||
max_count: 最大提取数量
|
||||
result_text: Văn bản phản hồi LLM.
|
||||
max_count: Số lượng tối đa.
|
||||
|
||||
Returns:
|
||||
用户称号数据列表
|
||||
Danh sách dữ liệu danh hiệu.
|
||||
"""
|
||||
return extract_user_titles_with_regex(result_text, max_count)
|
||||
|
||||
def create_data_objects(self, data_list: list[dict]) -> list[UserTitle]:
|
||||
"""
|
||||
创建用户称号对象列表
|
||||
Tạo danh sách object danh hiệu.
|
||||
|
||||
Args:
|
||||
titles_data: 原始用户称号数据列表
|
||||
titles_data: Danh sách dữ liệu danh hiệu gốc.
|
||||
|
||||
Returns:
|
||||
UserTitle对象列表
|
||||
Danh sách object UserTitle.
|
||||
"""
|
||||
try:
|
||||
titles = []
|
||||
max_titles = self.get_max_count()
|
||||
|
||||
for title_data in data_list[:max_titles]:
|
||||
# 确保数据格式正确
|
||||
# Đảm bảo định dạng dữ liệu đúng.
|
||||
name = title_data.get("name", "").strip()
|
||||
user_id = title_data.get("user_id")
|
||||
title = title_data.get("title", "").strip()
|
||||
mbti = title_data.get("mbti", "").strip()
|
||||
reason = title_data.get("reason", "").strip()
|
||||
|
||||
# 验证必要字段
|
||||
# Xác thực trường bắt buộc.
|
||||
if not name or not title or not mbti or not reason:
|
||||
logger.warning(f"用户称号数据格式不完整,跳过: {title_data}")
|
||||
logger.warning(
|
||||
f"Dữ liệu danh hiệu không đầy đủ, bỏ qua: {title_data}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 确保 user_id 是字符串
|
||||
# Đảm bảo user_id là chuỗi.
|
||||
if user_id is not None:
|
||||
user_id = str(user_id)
|
||||
else:
|
||||
logger.warning(f"未找到用户ID (user_id),跳过: {title_data}")
|
||||
logger.warning(f"Không tìm thấy user_id, bỏ qua: {title_data}")
|
||||
continue
|
||||
|
||||
titles.append(
|
||||
@@ -136,7 +132,7 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
return titles
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建用户称号对象失败: {e}")
|
||||
logger.error(f"Tạo object danh hiệu thất bại: {e}")
|
||||
return []
|
||||
|
||||
def validate_parsed_data(
|
||||
@@ -151,31 +147,33 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
top_users: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
准备用户数据
|
||||
Chuẩn bị dữ liệu thành viên.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
user_analysis: 用户分析统计
|
||||
top_users: 活跃用户列表(从get_top_users获取)
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
user_analysis: Thống kê phân tích thành viên.
|
||||
top_users: Danh sách thành viên tích cực từ get_top_users.
|
||||
|
||||
Returns:
|
||||
准备好的用户数据字典
|
||||
Dict dữ liệu thành viên đã chuẩn bị.
|
||||
"""
|
||||
try:
|
||||
# 获取机器人 ID 列表用于过滤
|
||||
# Lấy danh sách ID bot để lọc.
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
|
||||
user_summaries = []
|
||||
|
||||
# 如果提供了top_users列表,只分析这些活跃用户
|
||||
# Chỉ phân tích thành viên tích cực nếu có top_users.
|
||||
if top_users:
|
||||
logger.info(
|
||||
f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析"
|
||||
f"Phân tích danh hiệu cho {len(top_users)} thành viên tích cực do get_top_users lọc"
|
||||
)
|
||||
target_user_ids = {str(user["user_id"]) for user in top_users}
|
||||
else:
|
||||
# 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户
|
||||
logger.info("未提供活跃用户列表,使用消息数>=5的用户")
|
||||
# Tương thích logic cũ: dùng thành viên có ít nhất 5 tin nhắn.
|
||||
logger.info(
|
||||
"Không có danh sách tích cực, dùng thành viên có ít nhất 5 tin nhắn"
|
||||
)
|
||||
target_user_ids = {
|
||||
user_id
|
||||
for user_id, stats in user_analysis.items()
|
||||
@@ -184,23 +182,22 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
|
||||
for user_id, stats in user_analysis.items():
|
||||
user_id_str = str(user_id)
|
||||
# 过滤机器人由 MessageCleaner 已处理,此处仅作为二级防御
|
||||
# MessageCleaner đã lọc bot; đây là lớp phòng vệ thứ hai.
|
||||
if bot_self_ids and user_id_str in [str(uid) for uid in bot_self_ids]:
|
||||
continue
|
||||
|
||||
# 只处理活跃用户 (top_users 或 消息数>=5)
|
||||
# Chỉ xử lý thành viên tích cực.
|
||||
if user_id_str not in target_user_ids:
|
||||
continue
|
||||
|
||||
# 分析用户特征 (此处已基于已清理的 stats)
|
||||
# 兼容性处理:优先使用 hours (dict),如果没有则尝试从消息推断或使用空
|
||||
# Phân tích đặc trưng từ stats đã làm sạch; ưu tiên hours dạng dict.
|
||||
hours_data = stats.get("hours")
|
||||
if hours_data is None:
|
||||
# 尝试兼容旧 schema 或简化版
|
||||
# Tương thích schema cũ hoặc bản đơn giản.
|
||||
active_hours = stats.get("active_hours", [])
|
||||
hours_data = dict.fromkeys(active_hours, 1)
|
||||
|
||||
# 安全计算夜间发言数
|
||||
# Tính an toàn số tin nhắn ban đêm.
|
||||
night_messages = sum(hours_data.get(h, 0) for h in range(6))
|
||||
|
||||
message_count = stats.get("message_count", 0)
|
||||
@@ -209,7 +206,7 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
|
||||
avg_chars = stats.get("char_count", 0) / message_count
|
||||
|
||||
# 称号所需维度
|
||||
# Các chiều cần cho danh hiệu.
|
||||
user_summaries.append(
|
||||
{
|
||||
"name": stats.get("nickname", stats.get("name", user_id_str)),
|
||||
@@ -229,13 +226,13 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
if not user_summaries:
|
||||
return {"user_summaries": []}
|
||||
|
||||
# 按消息数量排序
|
||||
# Sắp xếp theo số tin nhắn.
|
||||
user_summaries.sort(key=lambda x: x["message_count"], reverse=True)
|
||||
|
||||
return {"user_summaries": user_summaries}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"准备用户数据失败: {e}")
|
||||
logger.error(f"Chuẩn bị dữ liệu thành viên thất bại: {e}")
|
||||
return {"user_summaries": []}
|
||||
|
||||
async def analyze_user_titles(
|
||||
@@ -247,29 +244,31 @@ class UserTitleAnalyzer(BaseAnalyzer[UserTitle, dict]):
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[UserTitle], TokenUsage]:
|
||||
"""
|
||||
分析用户称号
|
||||
Phân tích danh hiệu thành viên.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
user_analysis: 用户分析统计
|
||||
umo: 模型唯一标识符
|
||||
top_users: 活跃用户列表(从get_top_users获取,可选)
|
||||
session_id: 会话ID (用于调试模式)
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
user_analysis: Thống kê phân tích thành viên.
|
||||
umo: Định danh model.
|
||||
top_users: Danh sách thành viên tích cực, tuỳ chọn.
|
||||
session_id: ID phiên dùng cho debug mode.
|
||||
|
||||
Returns:
|
||||
(用户称号列表, Token使用统计)
|
||||
Tuple danh sách danh hiệu và thống kê token.
|
||||
"""
|
||||
try:
|
||||
# 准备用户数据,传入活跃用户列表
|
||||
# Chuẩn bị dữ liệu và truyền danh sách thành viên tích cực.
|
||||
user_data = self.prepare_user_data(messages, user_activity, top_users)
|
||||
|
||||
if not user_data["user_summaries"]:
|
||||
logger.info("没有符合条件的用户,返回空结果")
|
||||
logger.info("Không có thành viên phù hợp, trả về kết quả rỗng")
|
||||
return [], TokenUsage()
|
||||
|
||||
logger.info(f"开始分析 {len(user_data['user_summaries'])} 个活跃用户的称号")
|
||||
logger.info(
|
||||
f"Bắt đầu phân tích danh hiệu cho {len(user_data['user_summaries'])} thành viên tích cực"
|
||||
)
|
||||
return await self.analyze(user_data, umo, session_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户称号分析失败: {e}")
|
||||
logger.error(f"Phân tích danh hiệu thất bại: {e}")
|
||||
return [], TokenUsage()
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
LLM分析器模块
|
||||
负责协调各个分析器进行话题分析、用户称号分析和金句分析
|
||||
"""
|
||||
"""Điều phối analyzer LLM cho chủ đề, danh hiệu và trích dẫn nổi bật."""
|
||||
|
||||
import asyncio
|
||||
|
||||
@@ -25,9 +22,8 @@ from .utils.llm_utils import call_provider_with_retry
|
||||
|
||||
class LLMAnalyzer(IAnalysisProvider):
|
||||
"""
|
||||
LLM分析器
|
||||
作为统一入口,协调各个专门的分析器进行不同类型的分析
|
||||
保持向后兼容性,提供原有的接口
|
||||
Analyzer LLM làm điểm vào thống nhất cho các loại phân tích chuyên biệt,
|
||||
đồng thời duy trì giao diện tương thích ngược.
|
||||
"""
|
||||
|
||||
topic_analyzer: TopicAnalyzer
|
||||
@@ -36,16 +32,16 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
|
||||
def __init__(self, context, config_manager):
|
||||
"""
|
||||
初始化LLM分析器
|
||||
Khởi tạo analyzer LLM.
|
||||
|
||||
Args:
|
||||
context: AstrBot上下文对象
|
||||
config_manager: 配置管理器
|
||||
context: Context AstrBot.
|
||||
config_manager: Trình quản lý cấu hình.
|
||||
"""
|
||||
self.context = context
|
||||
self.config_manager = config_manager
|
||||
|
||||
# 初始化各个专门的分析器
|
||||
# Khởi tạo các analyzer chuyên biệt.
|
||||
self.topic_analyzer = TopicAnalyzer(context, config_manager)
|
||||
self.user_title_analyzer = UserTitleAnalyzer(context, config_manager)
|
||||
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
|
||||
@@ -73,24 +69,23 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[SummaryTopic], TokenUsage]:
|
||||
"""
|
||||
使用LLM分析话题
|
||||
保持原有接口,委托给专门的TopicAnalyzer处理
|
||||
Phân tích chủ đề bằng LLM qua TopicAnalyzer chuyên biệt.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
umo: 模型唯一标识符
|
||||
session_id: 会话ID (用于调试模式)
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
umo: Định danh duy nhất của model.
|
||||
session_id: ID phiên dùng cho debug mode.
|
||||
|
||||
Returns:
|
||||
(话题列表, Token使用统计)
|
||||
Tuple danh sách chủ đề và thống kê token.
|
||||
"""
|
||||
try:
|
||||
session_id = self._make_session_id(session_id, umo)
|
||||
|
||||
logger.info(f"开始话题分析, session_id: {session_id}")
|
||||
logger.info(f"Bắt đầu phân tích chủ đề, session_id: {session_id}")
|
||||
return await self.topic_analyzer.analyze_topics(messages, umo, session_id)
|
||||
except Exception as e:
|
||||
logger.error(f"话题分析失败: {e}")
|
||||
logger.error(f"Phân tích chủ đề thất bại: {e}")
|
||||
return [], TokenUsage()
|
||||
|
||||
async def analyze_user_titles(
|
||||
@@ -102,28 +97,27 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[UserTitle], TokenUsage]:
|
||||
"""
|
||||
使用LLM分析用户称号
|
||||
保持原有接口,委托给专门的UserTitleAnalyzer处理
|
||||
Phân tích danh hiệu thành viên bằng LLM qua UserTitleAnalyzer.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
user_activity: 用户分析统计
|
||||
umo: 模型唯一标识符
|
||||
top_users: 活跃用户列表(可选)
|
||||
session_id: 会话ID (用于调试模式)
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
user_activity: Thống kê hoạt động thành viên.
|
||||
umo: Định danh duy nhất của model.
|
||||
top_users: Danh sách thành viên tích cực, tuỳ chọn.
|
||||
session_id: ID phiên dùng cho debug mode.
|
||||
|
||||
Returns:
|
||||
(用户称号列表, Token使用统计)
|
||||
Tuple danh sách danh hiệu và thống kê token.
|
||||
"""
|
||||
try:
|
||||
session_id = self._make_session_id(session_id, umo)
|
||||
|
||||
logger.info(f"开始用户称号分析, session_id: {session_id}")
|
||||
logger.info(f"Bắt đầu phân tích danh hiệu, session_id: {session_id}")
|
||||
return await self.user_title_analyzer.analyze_user_titles(
|
||||
messages, user_activity, umo, top_users, session_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"用户称号分析失败: {e}")
|
||||
logger.error(f"Phân tích danh hiệu thất bại: {e}")
|
||||
return [], TokenUsage()
|
||||
|
||||
async def analyze_golden_quotes(
|
||||
@@ -133,26 +127,25 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
session_id: str | None = None,
|
||||
) -> tuple[list[GoldenQuote], TokenUsage]:
|
||||
"""
|
||||
使用LLM分析群聊金句
|
||||
保持原有接口,委托给专门的GoldenQuoteAnalyzer处理
|
||||
Phân tích trích dẫn nổi bật bằng LLM qua GoldenQuoteAnalyzer.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
umo: 模型唯一标识符
|
||||
session_id: 会话ID (用于调试模式)
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
umo: Định danh duy nhất của model.
|
||||
session_id: ID phiên dùng cho debug mode.
|
||||
|
||||
Returns:
|
||||
(金句列表, Token使用统计)
|
||||
Tuple danh sách trích dẫn và thống kê token.
|
||||
"""
|
||||
try:
|
||||
session_id = self._make_session_id(session_id, umo)
|
||||
|
||||
logger.info(f"开始金句分析, session_id: {session_id}")
|
||||
logger.info(f"Bắt đầu phân tích trích dẫn, session_id: {session_id}")
|
||||
return await self.golden_quote_analyzer.analyze_golden_quotes(
|
||||
messages, umo, session_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"金句分析失败: {e}")
|
||||
logger.error(f"Phân tích trích dẫn thất bại: {e}")
|
||||
return [], TokenUsage()
|
||||
|
||||
async def summarize_quality_reviews(
|
||||
@@ -162,7 +155,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
session_id: str | None = None,
|
||||
) -> tuple[QualityReview | None, TokenUsage]:
|
||||
"""
|
||||
汇总多个质量分析报告(增量模式使用)
|
||||
Tổng hợp nhiều báo cáo chất lượng trong chế độ gia tăng.
|
||||
"""
|
||||
return await self.chat_quality_analyzer.summarize_batch_reviews(
|
||||
batch_reviews, umo, session_id
|
||||
@@ -186,32 +179,32 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
QualityReview | None,
|
||||
]:
|
||||
"""
|
||||
并发执行所有分析任务(话题、用户称号、金句),支持按需启用。
|
||||
Thực thi đồng thời các tác vụ phân tích được bật.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
user_activity: 用户分析统计
|
||||
umo: 模型唯一标识符
|
||||
top_users: 活跃用户列表(可选)
|
||||
topic_enabled: 是否启用话题分析
|
||||
user_title_enabled: 是否启用用户称号分析
|
||||
golden_quote_enabled: 是否启用金句分析
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
user_activity: Thống kê hoạt động thành viên.
|
||||
umo: Định danh model.
|
||||
top_users: Danh sách thành viên tích cực.
|
||||
topic_enabled: Có bật phân tích chủ đề hay không.
|
||||
user_title_enabled: Có bật phân tích danh hiệu hay không.
|
||||
golden_quote_enabled: Có bật phân tích trích dẫn hay không.
|
||||
|
||||
Returns:
|
||||
(话题列表, 用户称号列表, 金句列表, 总Token使用统计)
|
||||
Danh sách chủ đề, danh hiệu, trích dẫn và tổng token.
|
||||
"""
|
||||
try:
|
||||
session_id = self._make_session_id(None, umo)
|
||||
|
||||
logger.info(
|
||||
f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}"
|
||||
f"Bắt đầu phân tích đồng thời (chủ đề:{topic_enabled}, danh hiệu:{user_title_enabled}, trích dẫn:{golden_quote_enabled}), session_id: {session_id}"
|
||||
)
|
||||
|
||||
# 保存原始消息数据 (Debug Mode)
|
||||
# Lưu dữ liệu tin nhắn gốc trong debug mode.
|
||||
if self.config_manager.get_debug_mode():
|
||||
self._save_debug_messages(messages, session_id)
|
||||
|
||||
# 构建并发任务列表
|
||||
# Xây dựng danh sách tác vụ đồng thời.
|
||||
tasks = []
|
||||
task_names = []
|
||||
|
||||
@@ -250,7 +243,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 处理结果
|
||||
# Xử lý kết quả.
|
||||
topics, topic_usage = [], TokenUsage()
|
||||
user_titles, title_usage = [], TokenUsage()
|
||||
golden_quotes, quote_usage = [], TokenUsage()
|
||||
@@ -260,7 +253,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
for i, result in enumerate(results):
|
||||
name = task_names[i]
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"分析任务 {name} 失败: {result}")
|
||||
logger.error(f"Tác vụ phân tích {name} thất bại: {result}")
|
||||
continue
|
||||
|
||||
if name == "topic" and isinstance(result, tuple):
|
||||
@@ -274,7 +267,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
if not isinstance(quality_usage, TokenUsage):
|
||||
quality_usage = TokenUsage()
|
||||
|
||||
# 合并Token使用统计
|
||||
# Gộp thống kê sử dụng token.
|
||||
total_usage = TokenUsage(
|
||||
prompt_tokens=topic_usage.prompt_tokens
|
||||
+ title_usage.prompt_tokens
|
||||
@@ -291,7 +284,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}, 质量锐评: {1 if chat_quality_review else 0}"
|
||||
f"Hoàn tất phân tích đồng thời - chủ đề: {len(topics)}, danh hiệu: {len(user_titles)}, trích dẫn: {len(golden_quotes)}, đánh giá chất lượng: {1 if chat_quality_review else 0}"
|
||||
)
|
||||
return (
|
||||
topics,
|
||||
@@ -302,7 +295,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"并发分析失败: {e}")
|
||||
logger.error(f"Phân tích đồng thời thất bại: {e}")
|
||||
return [], [], [], TokenUsage(), None
|
||||
|
||||
async def analyze_incremental_concurrent(
|
||||
@@ -316,39 +309,38 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
chat_quality_enabled: bool = False,
|
||||
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]:
|
||||
"""
|
||||
增量分析模式的并发执行方法。
|
||||
仅执行话题分析和金句分析(用户称号分析在最终报告时执行),
|
||||
使用较小的批次数量以控制单次分析的输出规模。
|
||||
Thực thi đồng thời trong chế độ gia tăng. Chỉ phân tích chủ đề,
|
||||
trích dẫn và chất lượng; danh hiệu được xử lý khi tạo báo cáo cuối.
|
||||
|
||||
Args:
|
||||
messages: 本次增量分析的群聊消息列表
|
||||
umo: 模型唯一标识符
|
||||
topics_per_batch: 本次批次最大话题数量
|
||||
quotes_per_batch: 本次批次最大金句数量
|
||||
topic_enabled: 是否启用话题分析
|
||||
golden_quote_enabled: 是否启用金句分析
|
||||
messages: Tin nhắn nhóm của lần phân tích gia tăng.
|
||||
umo: Định danh model.
|
||||
topics_per_batch: Số chủ đề tối đa trong batch.
|
||||
quotes_per_batch: Số trích dẫn tối đa trong batch.
|
||||
topic_enabled: Có bật phân tích chủ đề hay không.
|
||||
golden_quote_enabled: Có bật phân tích trích dẫn hay không.
|
||||
|
||||
Returns:
|
||||
(话题列表, 金句列表, 总Token使用统计)
|
||||
Danh sách chủ đề, trích dẫn và tổng token.
|
||||
"""
|
||||
try:
|
||||
session_id = self._make_session_id(None, umo, "incr_")
|
||||
|
||||
logger.info(
|
||||
f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled}),"
|
||||
f"消息数量: {len(messages)},会话ID: {session_id}"
|
||||
f"Bắt đầu phân tích gia tăng đồng thời (chủ đề:{topic_enabled}/{topics_per_batch}, trích dẫn:{golden_quote_enabled}/{quotes_per_batch}, chất lượng:{chat_quality_enabled}), "
|
||||
f"tin nhắn: {len(messages)}, session_id: {session_id}"
|
||||
)
|
||||
|
||||
# 保存原始消息数据 (Debug Mode)
|
||||
# Lưu dữ liệu tin nhắn gốc trong debug mode.
|
||||
if self.config_manager.get_debug_mode():
|
||||
self._save_debug_messages(messages, session_id)
|
||||
|
||||
# 设置增量模式的最大数量覆盖值
|
||||
# Thiết lập giới hạn ghi đè cho chế độ gia tăng.
|
||||
self.topic_analyzer._incremental_max_count = topics_per_batch
|
||||
self.golden_quote_analyzer._incremental_max_count = quotes_per_batch
|
||||
|
||||
try:
|
||||
# 构建并发任务列表(仅话题和金句,不包含用户称号)
|
||||
# Xây dựng tác vụ đồng thời, không gồm danh hiệu thành viên.
|
||||
tasks = []
|
||||
task_names = []
|
||||
|
||||
@@ -379,7 +371,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 处理结果
|
||||
# Xử lý kết quả.
|
||||
topics, topic_usage = [], TokenUsage()
|
||||
golden_quotes, quote_usage = [], TokenUsage()
|
||||
chat_quality_review = None
|
||||
@@ -388,7 +380,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
for i, result in enumerate(results):
|
||||
name = task_names[i]
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"增量{name}分析失败: {result}")
|
||||
logger.error(f"Phân tích gia tăng {name} thất bại: {result}")
|
||||
continue
|
||||
|
||||
if name == "topic" and isinstance(result, tuple):
|
||||
@@ -400,7 +392,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
if not isinstance(quality_usage, TokenUsage):
|
||||
quality_usage = TokenUsage()
|
||||
|
||||
# 合并Token使用统计
|
||||
# Gộp thống kê sử dụng token.
|
||||
total_usage = TokenUsage(
|
||||
prompt_tokens=topic_usage.prompt_tokens
|
||||
+ quote_usage.prompt_tokens
|
||||
@@ -414,27 +406,27 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"增量并发分析完成 - 话题: {len(topics)}, 金句: {len(golden_quotes)}, 质量锐评: {1 if chat_quality_review else 0}, "
|
||||
f"Token消耗: {total_usage.total_tokens}"
|
||||
f"Hoàn tất phân tích gia tăng đồng thời - chủ đề: {len(topics)}, trích dẫn: {len(golden_quotes)}, đánh giá chất lượng: {1 if chat_quality_review else 0}, "
|
||||
f"token: {total_usage.total_tokens}"
|
||||
)
|
||||
return topics, golden_quotes, total_usage, chat_quality_review
|
||||
|
||||
finally:
|
||||
# 无论成功或失败,都要恢复原始的最大数量设置
|
||||
# Luôn khôi phục giới hạn ban đầu dù thành công hay thất bại.
|
||||
self.topic_analyzer._incremental_max_count = None
|
||||
self.golden_quote_analyzer._incremental_max_count = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"增量并发分析失败: {e}", exc_info=True)
|
||||
logger.error(f"Phân tích gia tăng đồng thời thất bại: {e}", exc_info=True)
|
||||
return [], [], TokenUsage(), None
|
||||
|
||||
def _save_debug_messages(self, messages: list[dict], session_id: str):
|
||||
"""
|
||||
保存调试消息数据到文件(Debug Mode 专用)
|
||||
Lưu dữ liệu tin nhắn debug vào tệp.
|
||||
|
||||
Args:
|
||||
messages: 群聊消息列表
|
||||
session_id: 会话ID
|
||||
messages: Danh sách tin nhắn nhóm.
|
||||
session_id: ID phiên.
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
@@ -450,7 +442,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 向后兼容的方法,保持原有调用方式
|
||||
# Phương thức tương thích ngược, giữ cách gọi cũ.
|
||||
async def _call_provider_with_retry(
|
||||
self,
|
||||
provider,
|
||||
@@ -459,17 +451,16 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
provider_id_key: str | None = None,
|
||||
):
|
||||
"""
|
||||
向后兼容的LLM调用方法
|
||||
现在委托给llm_utils模块处理
|
||||
Phương thức gọi LLM tương thích ngược, uỷ quyền cho llm_utils.
|
||||
|
||||
Args:
|
||||
provider: LLM服务商实例或None(已弃用,现在使用 provider_id_key)
|
||||
prompt: 输入的提示语
|
||||
umo: 指定使用的模型唯一标识符
|
||||
provider_id_key: 配置中的 provider_id 键名(可选)
|
||||
provider: Provider LLM hoặc None; đã deprecated.
|
||||
prompt: Prompt đầu vào.
|
||||
umo: Định danh model cần dùng.
|
||||
provider_id_key: Tên key provider_id tuỳ chọn trong cấu hình.
|
||||
|
||||
Returns:
|
||||
LLM生成的结果
|
||||
Kết quả do LLM tạo.
|
||||
"""
|
||||
return await call_provider_with_retry(
|
||||
self.context,
|
||||
@@ -481,13 +472,12 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
|
||||
def _fix_json(self, text: str) -> str:
|
||||
"""
|
||||
向后兼容的JSON修复方法
|
||||
现在委托给json_utils模块处理
|
||||
Phương thức sửa JSON tương thích ngược, uỷ quyền cho json_utils.
|
||||
|
||||
Args:
|
||||
text: 需要修复的JSON文本
|
||||
text: Văn bản JSON cần sửa.
|
||||
|
||||
Returns:
|
||||
修复后的JSON文本
|
||||
Văn bản JSON sau khi sửa.
|
||||
"""
|
||||
return fix_json(text)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
分析工具模块
|
||||
包含JSON处理和LLM API请求处理工具
|
||||
"""
|
||||
"""Module tiện ích phân tích, xử lý JSON và yêu cầu API LLM."""
|
||||
|
||||
from .info_utils import InfoUtils
|
||||
from .json_utils import (
|
||||
|
||||
@@ -2,9 +2,9 @@ class InfoUtils:
|
||||
@staticmethod
|
||||
def get_user_nickname(config_manager, sender) -> str:
|
||||
"""
|
||||
获取用户昵称
|
||||
Lấy biệt danh thành viên.
|
||||
|
||||
优先使用nickname字段,如果为空则使用card(群名片)字段
|
||||
Ưu tiên trường nickname; nếu rỗng thì dùng card (tên trong nhóm).
|
||||
"""
|
||||
enable_user_card = config_manager.get_enable_user_card()
|
||||
if enable_user_card:
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
JSON处理工具模块
|
||||
提供JSON解析、修复和正则提取功能
|
||||
"""
|
||||
"""Công cụ phân tích, sửa JSON và trích xuất bằng regex."""
|
||||
|
||||
import json
|
||||
import re
|
||||
@@ -12,130 +9,132 @@ from ....utils.logger import logger
|
||||
|
||||
def fix_json(text: str) -> str:
|
||||
"""
|
||||
修复JSON格式问题,包括中文符号替换
|
||||
Sửa lỗi định dạng JSON, bao gồm thay thế dấu câu tiếng Trung.
|
||||
|
||||
Args:
|
||||
text: 需要修复的JSON文本
|
||||
text: Văn bản JSON cần sửa.
|
||||
|
||||
Returns:
|
||||
修复后的JSON文本
|
||||
Văn bản JSON sau khi sửa.
|
||||
"""
|
||||
try:
|
||||
# 1. 移除markdown代码块标记
|
||||
# 1. Xoá marker code block Markdown.
|
||||
text = re.sub(r"```json\s*", "", text)
|
||||
text = re.sub(r"```\s*$", "", text)
|
||||
|
||||
# 2. 基础清理
|
||||
# 2. Làm sạch cơ bản.
|
||||
text = text.replace("\n", " ").replace("\r", " ")
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
|
||||
# 3. 替换中文符号为英文符号(修复)
|
||||
# 中文引号 -> 英文引号
|
||||
# 3. Thay dấu câu tiếng Trung bằng dấu câu ASCII để sửa JSON.
|
||||
# Dấu ngoặc kép tiếng Trung -> dấu ngoặc kép ASCII.
|
||||
text = text.replace("“", '"').replace("”", '"')
|
||||
text = text.replace("‘", "'").replace("’", "'")
|
||||
# 中文逗号 -> 英文逗号
|
||||
# Dấu phẩy tiếng Trung -> dấu phẩy ASCII.
|
||||
text = text.replace(",", ",")
|
||||
# 中文冒号 -> 英文冒号
|
||||
# Dấu hai chấm tiếng Trung -> dấu hai chấm ASCII.
|
||||
text = text.replace(":", ":")
|
||||
# 中文括号 -> 英文括号
|
||||
# Dấu ngoặc tiếng Trung -> dấu ngoặc ASCII.
|
||||
text = text.replace("(", "(").replace(")", ")")
|
||||
text = text.replace("【", "[").replace("】", "]")
|
||||
|
||||
# 4. 处理字符串内容中的特殊字符
|
||||
# 转义字符串内的双引号
|
||||
# 4. Xử lý ký tự đặc biệt trong chuỗi.
|
||||
# Escape dấu ngoặc kép bên trong chuỗi.
|
||||
def escape_quotes_in_strings(match):
|
||||
content = match.group(1)
|
||||
# 转义内部的双引号
|
||||
# Escape dấu ngoặc kép bên trong.
|
||||
content = content.replace('"', '\\"')
|
||||
return f'"{content}"'
|
||||
|
||||
# 先处理字段值中的引号
|
||||
# Xử lý dấu ngoặc kép trong giá trị trường trước.
|
||||
text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
|
||||
|
||||
# 5. 修复截断的JSON
|
||||
# 5. Sửa JSON bị cắt ngắn.
|
||||
if not text.endswith("]"):
|
||||
last_complete = text.rfind("}")
|
||||
if last_complete > 0:
|
||||
text = text[: last_complete + 1] + "]"
|
||||
|
||||
# 6. 修复常见的JSON格式问题
|
||||
# 1. 修复缺失的逗号
|
||||
# 6. Sửa các lỗi định dạng JSON phổ biến.
|
||||
# 1. Bổ sung dấu phẩy bị thiếu.
|
||||
text = re.sub(r"}\s*{", "}, {", text)
|
||||
|
||||
# 2. 确保字段名有引号(仅在对象开始或逗号后,避免破坏字符串值)
|
||||
# 2. Đảm bảo tên trường có dấu ngoặc kép mà không phá giá trị chuỗi.
|
||||
def quote_field_names(match):
|
||||
prefix = match.group(1)
|
||||
key = match.group(2)
|
||||
return f'{prefix}"{key}":'
|
||||
|
||||
# 只在 { 或 , 后面匹配字段名,避免在字符串值中误匹配
|
||||
# Chỉ khớp tên trường sau { hoặc , để tránh khớp nhầm trong giá trị.
|
||||
text = re.sub(r"([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:", quote_field_names, text)
|
||||
|
||||
# 3. 移除多余的逗号
|
||||
# 3. Xoá dấu phẩy thừa.
|
||||
text = re.sub(r",\s*}", "}", text)
|
||||
text = re.sub(r",\s*]", "]", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"JSON修复失败: {e}")
|
||||
logger.error(f"Sửa JSON thất bại: {e}")
|
||||
return text
|
||||
|
||||
|
||||
def _parse_json_with_pattern(
|
||||
result_text: str, pattern: str, data_type: str, expected_type_name: str = "数据"
|
||||
result_text: str, pattern: str, data_type: str, expected_type_name: str = "dữ liệu"
|
||||
) -> tuple[bool, Any, str | None]:
|
||||
"""
|
||||
通用内部 JSON 解析逻辑,包含提取、直接解析、修复后重试。
|
||||
Logic phân tích JSON nội bộ: trích xuất, parse trực tiếp và thử lại sau sửa.
|
||||
"""
|
||||
fixed_json_text = None
|
||||
try:
|
||||
# 1. 基础清理:去除 markdown 代码块标记
|
||||
# 1. Làm sạch cơ bản: xoá marker code block Markdown.
|
||||
clean_text = result_text.strip()
|
||||
clean_text = re.sub(r"```(?:json)?\s*", "", clean_text)
|
||||
clean_text = re.sub(r"```\s*$", "", clean_text)
|
||||
|
||||
# 2. 提取 JSON 部分
|
||||
# 2. Trích xuất phần JSON.
|
||||
json_match = re.search(pattern, clean_text, re.DOTALL)
|
||||
if not json_match:
|
||||
error_msg = f"{data_type}响应中未找到JSON{expected_type_name}"
|
||||
error_msg = (
|
||||
f"Không tìm thấy JSON {expected_type_name} trong phản hồi {data_type}"
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
return False, None, error_msg
|
||||
|
||||
json_text = json_match.group()
|
||||
logger.debug(f"{data_type}分析JSON原文: {json_text[:500]}...")
|
||||
logger.debug(f"JSON gốc của phân tích {data_type}: {json_text[:500]}...")
|
||||
|
||||
# 3. 尝试直接解析
|
||||
# 3. Thử parse trực tiếp.
|
||||
try:
|
||||
data = json.loads(json_text)
|
||||
count_info = f",包含 {len(data)} 条数据" if isinstance(data, list) else ""
|
||||
logger.info(f"{data_type}直接解析成功{count_info}")
|
||||
count_info = f", gồm {len(data)} mục" if isinstance(data, list) else ""
|
||||
logger.info(f"Parse trực tiếp {data_type} thành công{count_info}")
|
||||
return True, data, None
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"{data_type}直接解析失败,尝试修复JSON...")
|
||||
logger.debug(f"Parse trực tiếp {data_type} thất bại, thử sửa JSON...")
|
||||
|
||||
# 4. 修复后重试
|
||||
# 4. Thử lại sau khi sửa.
|
||||
fixed_json_text = fix_json(json_text)
|
||||
# 修复后需要重新提取,因为 fix_json 可能会改变文本结构(例如补齐括号)
|
||||
# Trích xuất lại vì fix_json có thể thay đổi cấu trúc văn bản.
|
||||
fixed_match = re.search(pattern, fixed_json_text, re.DOTALL)
|
||||
if fixed_match:
|
||||
try:
|
||||
data = json.loads(fixed_match.group())
|
||||
count_info = (
|
||||
f",包含 {len(data)} 条数据" if isinstance(data, list) else ""
|
||||
)
|
||||
logger.info(f"{data_type}修复后解析成功{count_info}")
|
||||
count_info = f", gồm {len(data)} mục" if isinstance(data, list) else ""
|
||||
logger.info(f"Parse {data_type} thành công sau khi sửa{count_info}")
|
||||
return True, data, None
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"{data_type}JSON修复后解析仍失败: {e}"
|
||||
error_msg = f"Parse JSON {data_type} vẫn thất bại sau khi sửa: {e}"
|
||||
logger.warning(error_msg)
|
||||
return False, None, error_msg
|
||||
|
||||
error_msg = f"{data_type}修复后未找到JSON{expected_type_name}"
|
||||
error_msg = (
|
||||
f"Không tìm thấy JSON {expected_type_name} của {data_type} sau khi sửa"
|
||||
)
|
||||
return False, None, error_msg
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{data_type}解析异常: {e}"
|
||||
error_msg = f"Lỗi parse {data_type}: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, None, error_msg
|
||||
|
||||
@@ -144,10 +143,10 @@ def parse_json_response(
|
||||
result_text: str, data_type: str
|
||||
) -> tuple[bool, list[dict] | None, str | None]:
|
||||
"""
|
||||
统一的JSON解析方法(用于JSON数组响应)
|
||||
Phương thức parse thống nhất cho phản hồi mảng JSON.
|
||||
"""
|
||||
return _parse_json_with_pattern(
|
||||
result_text, r"\[.*\]", data_type, expected_type_name="数组"
|
||||
result_text, r"\[.*\]", data_type, expected_type_name="mảng"
|
||||
)
|
||||
|
||||
|
||||
@@ -155,39 +154,38 @@ def parse_json_object_response(
|
||||
result_text: str, data_type: str
|
||||
) -> tuple[bool, dict | None, str | None]:
|
||||
"""
|
||||
统一的JSON解析方法(用于JSON对象响应)
|
||||
Phương thức parse thống nhất cho phản hồi object JSON.
|
||||
"""
|
||||
return _parse_json_with_pattern(
|
||||
result_text, r"\{.*\}", data_type, expected_type_name="对象"
|
||||
result_text, r"\{.*\}", data_type, expected_type_name="đối tượng"
|
||||
)
|
||||
|
||||
|
||||
def _clean_json_string(text: str) -> str:
|
||||
"""
|
||||
清理 JSON 字符串中的转义字符,用于正则提取后的数据清洗。
|
||||
Làm sạch ký tự escape trong chuỗi JSON sau khi trích xuất bằng regex.
|
||||
"""
|
||||
return text.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||
|
||||
|
||||
def extract_topics_with_regex(result_text: str, max_topics: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取话题信息
|
||||
Trích xuất thông tin chủ đề bằng regex.
|
||||
|
||||
Args:
|
||||
result_text: 需要提取的文本
|
||||
max_topics: 最大话题数量
|
||||
result_text: Văn bản cần trích xuất.
|
||||
max_topics: Số chủ đề tối đa.
|
||||
|
||||
Returns:
|
||||
话题数据列表
|
||||
Danh sách dữ liệu chủ đề.
|
||||
"""
|
||||
try:
|
||||
# 更强的正则表达式提取话题信息,处理转义字符
|
||||
# 匹配每个完整的话题对象
|
||||
# Regex mạnh hơn để xử lý ký tự escape và khớp object chủ đề hoàn chỉnh.
|
||||
topic_pattern = r'\{\s*"topic":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"contributors":\s*\[(.*?)\],?\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
||||
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
||||
|
||||
if not matches:
|
||||
# 尝试更宽松的匹配
|
||||
# Thử pattern linh hoạt hơn.
|
||||
topic_pattern = r'"topic":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"contributors":\s*\[(.*?)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"'
|
||||
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
||||
|
||||
@@ -197,48 +195,50 @@ def extract_topics_with_regex(result_text: str, max_topics: int) -> list[dict]:
|
||||
contributors_str = match[1].strip()
|
||||
detail = _clean_json_string(match[2].strip())
|
||||
|
||||
# 解析参与者列表
|
||||
# Parse danh sách người tham gia.
|
||||
contributors = [
|
||||
contrib.strip()
|
||||
for contrib in re.findall(r'"([^"]+)"', contributors_str)
|
||||
] or ["群友"]
|
||||
] or ["Thành viên"]
|
||||
|
||||
topics.append(
|
||||
{
|
||||
"topic": topic_name,
|
||||
"contributors": contributors[:5], # 最多5个参与者
|
||||
"contributors": contributors[:5], # Tối đa 5 người tham gia
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"话题正则表达式提取成功,提取到 {len(topics)} 条有效话题内容")
|
||||
logger.info(
|
||||
f"Trích xuất chủ đề bằng regex thành công: {len(topics)} chủ đề hợp lệ"
|
||||
)
|
||||
return topics
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"话题正则表达式提取失败: {e}")
|
||||
logger.error(f"Trích xuất chủ đề bằng regex thất bại: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def extract_user_titles_with_regex(result_text: str, max_count: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取用户称号信息
|
||||
Trích xuất thông tin danh hiệu bằng regex.
|
||||
|
||||
Args:
|
||||
result_text: 需要提取的文本
|
||||
max_count: 最大提取数量
|
||||
result_text: Văn bản cần trích xuất.
|
||||
max_count: Số lượng tối đa.
|
||||
|
||||
Returns:
|
||||
用户称号数据列表
|
||||
Danh sách dữ liệu danh hiệu.
|
||||
"""
|
||||
try:
|
||||
titles = []
|
||||
|
||||
# 正则模式:匹配完整的用户称号对象
|
||||
# Pattern khớp object danh hiệu hoàn chỉnh.
|
||||
pattern = r'\{\s*"name":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"user_id":\s*"([^"]+)"\s*,\s*"title":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"mbti":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||
|
||||
if not matches:
|
||||
# 尝试更宽松的匹配(字段顺序可变)
|
||||
# Thử pattern linh hoạt hơn, cho phép thứ tự trường thay đổi.
|
||||
pattern = r'"name":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"user_id":\s*"([^"]+)"[^}]*"title":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"mbti":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
|
||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||
|
||||
@@ -259,34 +259,36 @@ def extract_user_titles_with_regex(result_text: str, max_count: int) -> list[dic
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号")
|
||||
logger.info(
|
||||
f"Trích xuất danh hiệu bằng regex thành công: {len(titles)} danh hiệu hợp lệ"
|
||||
)
|
||||
return titles
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户称号正则表达式提取失败: {e}")
|
||||
logger.error(f"Trích xuất danh hiệu bằng regex thất bại: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> list[dict]:
|
||||
"""
|
||||
使用正则表达式提取金句信息
|
||||
Trích xuất trích dẫn nổi bật bằng regex.
|
||||
|
||||
Args:
|
||||
result_text: 需要提取的文本
|
||||
max_count: 最大提取数量
|
||||
result_text: Văn bản cần trích xuất.
|
||||
max_count: Số lượng tối đa.
|
||||
|
||||
Returns:
|
||||
金句数据列表
|
||||
Danh sách dữ liệu trích dẫn.
|
||||
"""
|
||||
try:
|
||||
quotes = []
|
||||
|
||||
# 正则模式:匹配完整的金句对象
|
||||
# Pattern khớp object trích dẫn hoàn chỉnh.
|
||||
pattern = r'\{\s*"content":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"sender":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||
|
||||
if not matches:
|
||||
# 尝试更宽松的匹配(字段顺序可变)
|
||||
# Thử pattern linh hoạt hơn, cho phép thứ tự trường thay đổi.
|
||||
pattern = r'"content":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"sender":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
|
||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||
|
||||
@@ -297,25 +299,27 @@ def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> list[d
|
||||
|
||||
quotes.append({"content": content, "sender": sender, "reason": reason})
|
||||
|
||||
logger.info(f"金句正则表达式提取成功,提取到 {len(quotes)} 条有效金句")
|
||||
logger.info(
|
||||
f"Trích xuất trích dẫn bằng regex thành công: {len(quotes)} mục hợp lệ"
|
||||
)
|
||||
return quotes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"金句正则表达式提取失败: {e}")
|
||||
logger.error(f"Trích xuất trích dẫn bằng regex thất bại: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def extract_quality_with_regex(result_text: str) -> dict | None:
|
||||
"""
|
||||
使用正则表达式提取聊天质量分析数据
|
||||
Trích xuất dữ liệu chất lượng trò chuyện bằng regex.
|
||||
|
||||
当 JSON 解析失败时作为降级方案使用。
|
||||
Dùng làm fallback khi parse JSON thất bại.
|
||||
|
||||
Args:
|
||||
result_text: LLM 返回的原始文本
|
||||
result_text: Văn bản gốc do LLM trả về.
|
||||
|
||||
Returns:
|
||||
解析后的质量分析字典,失败返回 None
|
||||
Dict chất lượng sau khi parse hoặc None nếu thất bại.
|
||||
"""
|
||||
try:
|
||||
title_m = re.search(r'"title"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', result_text)
|
||||
@@ -342,19 +346,25 @@ def extract_quality_with_regex(result_text: str) -> dict | None:
|
||||
)
|
||||
|
||||
if not dims:
|
||||
logger.warning("聊天质量正则提取未找到有效维度数据")
|
||||
logger.warning(
|
||||
"Regex chất lượng trò chuyện không tìm thấy dữ liệu chiều hợp lệ"
|
||||
)
|
||||
return None
|
||||
|
||||
data = {
|
||||
"title": title_m.group(1) if title_m else "聊天质量锐评",
|
||||
"subtitle": subtitle_m.group(1) if subtitle_m else "今天的群里发生了什么?",
|
||||
"title": title_m.group(1) if title_m else "Đánh giá chất lượng trò chuyện",
|
||||
"subtitle": subtitle_m.group(1)
|
||||
if subtitle_m
|
||||
else "Hôm nay nhóm đã có chuyện gì?",
|
||||
"dimensions": dims,
|
||||
"summary": summary_m.group(1) if summary_m else "今天也是充满活力的一天。",
|
||||
"summary": summary_m.group(1)
|
||||
if summary_m
|
||||
else "Hôm nay cũng là một ngày đầy năng lượng.",
|
||||
}
|
||||
|
||||
logger.info(f"聊天质量正则表达式提取成功,提取到 {len(dims)} 个维度")
|
||||
logger.info(f"Trích xuất chất lượng bằng regex thành công: {len(dims)} chiều")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"聊天质量正则表达式提取失败: {e}")
|
||||
logger.error(f"Trích xuất chất lượng bằng regex thất bại: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
LLM API请求处理工具模块
|
||||
提供LLM调用和token统计功能
|
||||
"""
|
||||
"""Công cụ gọi API LLM và thống kê token."""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
@@ -19,7 +16,7 @@ _circuit_breakers = {}
|
||||
|
||||
def _is_response_format_unsupported_error(error: Exception) -> bool:
|
||||
"""
|
||||
判断是否为 Provider/网关不支持 response_format 的兼容性错误。
|
||||
Kiểm tra lỗi tương thích khi provider/gateway không hỗ trợ response_format.
|
||||
"""
|
||||
text = str(error).lower()
|
||||
patterns = [
|
||||
@@ -46,7 +43,7 @@ async def _call_provider_stream(
|
||||
) -> LLMResponse:
|
||||
provider = context.get_provider_by_id(provider_id=provider_id)
|
||||
if provider is None:
|
||||
raise RuntimeError(f"Provider 不存在: {provider_id}")
|
||||
raise RuntimeError(f"Provider không tồn tại: {provider_id}")
|
||||
|
||||
stream_kwargs = dict(llm_kwargs)
|
||||
stream_kwargs.pop("chat_provider_id", None)
|
||||
@@ -61,7 +58,7 @@ async def _call_provider_stream(
|
||||
content_parts.append(text)
|
||||
|
||||
if final_resp is None:
|
||||
raise RuntimeError("流式 LLM 调用未返回任何响应")
|
||||
raise RuntimeError("Lời gọi LLM streaming không trả về phản hồi")
|
||||
|
||||
final_text = extract_response_text(final_resp)
|
||||
if final_text and not getattr(final_resp, "is_chunk", False):
|
||||
@@ -79,63 +76,63 @@ async def _try_get_provider_id_by_id(
|
||||
context, provider_id: str, description: str
|
||||
) -> str | None:
|
||||
"""
|
||||
尝试通过 ID 获取 Provider ID 的辅助函数
|
||||
Thử xác thực và lấy Provider ID theo ID cấu hình.
|
||||
|
||||
Args:
|
||||
context: AstrBot上下文对象
|
||||
context: Context AstrBot.
|
||||
provider_id: Provider ID
|
||||
description: 描述信息,用于日志
|
||||
description: Mô tả dùng trong log.
|
||||
|
||||
Returns:
|
||||
Provider ID 或 None
|
||||
Provider ID hoặc None.
|
||||
"""
|
||||
if not provider_id or not isinstance(provider_id, str) or not provider_id.strip():
|
||||
return None
|
||||
|
||||
provider_id = provider_id.strip()
|
||||
logger.info(f"尝试使用{description}: {provider_id}")
|
||||
logger.info(f"Thử dùng {description}: {provider_id}")
|
||||
try:
|
||||
# 验证 Provider 是否存在
|
||||
# Xác thực provider tồn tại.
|
||||
provider = context.get_provider_by_id(provider_id=provider_id)
|
||||
if provider:
|
||||
logger.info(f"✓ 使用{description}: {provider_id}")
|
||||
logger.info(f"✓ Dùng {description}: {provider_id}")
|
||||
return provider_id
|
||||
except Exception as e:
|
||||
logger.warning(f"无法找到{description} '{provider_id}': {e}")
|
||||
logger.warning(f"Không tìm thấy {description} '{provider_id}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _try_get_session_provider_id(context, umo: str | None) -> str | None:
|
||||
"""
|
||||
尝试获取会话 Provider ID 的辅助函数
|
||||
Thử lấy Provider ID của phiên hiện tại.
|
||||
|
||||
Args:
|
||||
context: AstrBot上下文对象
|
||||
context: Context AstrBot.
|
||||
umo: unified_msg_origin
|
||||
|
||||
Returns:
|
||||
Provider ID 或 None
|
||||
Provider ID hoặc None.
|
||||
"""
|
||||
try:
|
||||
# 使用新 API 获取当前会话的 Provider ID
|
||||
# Dùng API mới để lấy Provider ID của phiên hiện tại.
|
||||
provider_id = await context.get_current_chat_provider_id(umo=umo)
|
||||
if provider_id:
|
||||
logger.info(f"✓ 使用当前会话的 Provider: {provider_id}")
|
||||
logger.info(f"✓ Dùng provider của phiên hiện tại: {provider_id}")
|
||||
return provider_id
|
||||
except Exception as e:
|
||||
logger.warning(f"无法获取会话 Provider ID: {e}")
|
||||
logger.warning(f"Không thể lấy Provider ID của phiên: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _try_get_first_available_provider_id(context) -> str | None:
|
||||
"""
|
||||
尝试获取第一个可用 Provider ID 的辅助函数
|
||||
Thử lấy Provider ID khả dụng đầu tiên.
|
||||
|
||||
Args:
|
||||
context: AstrBot上下文对象
|
||||
context: Context AstrBot.
|
||||
|
||||
Returns:
|
||||
Provider ID 或 None
|
||||
Provider ID hoặc None.
|
||||
"""
|
||||
try:
|
||||
all_providers = context.get_all_providers()
|
||||
@@ -144,12 +141,12 @@ async def _try_get_first_available_provider_id(context) -> str | None:
|
||||
try:
|
||||
meta = provider.meta()
|
||||
provider_id = meta.id
|
||||
logger.info(f"✓ 使用第一个可用 Provider: {provider_id}")
|
||||
logger.info(f"✓ Dùng provider khả dụng đầu tiên: {provider_id}")
|
||||
return provider_id
|
||||
except Exception:
|
||||
logger.warning("第一个 Provider 无法获取 ID")
|
||||
logger.warning("Không thể lấy ID của provider đầu tiên")
|
||||
except Exception as e:
|
||||
logger.warning(f"无法获取任何 Provider: {e}")
|
||||
logger.warning(f"Không thể lấy provider nào: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -160,33 +157,30 @@ async def get_provider_id_with_fallback(
|
||||
umo: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
根据配置键获取 Provider ID,支持多级回退
|
||||
Lấy Provider ID theo key cấu hình với fallback nhiều cấp.
|
||||
|
||||
回退顺序:
|
||||
1. 尝试从配置获取指定的 provider_id(如 topic_provider_id)
|
||||
2. 回退到主 LLM provider_id(llm_provider_id)
|
||||
3. 回退到当前会话的 Provider(通过 umo)
|
||||
4. 回退到第一个可用的 Provider
|
||||
Thứ tự fallback: provider riêng của tác vụ, provider LLM chính, provider
|
||||
của phiên hiện tại, rồi provider khả dụng đầu tiên.
|
||||
|
||||
Args:
|
||||
context: AstrBot上下文对象
|
||||
config_manager: 配置管理器
|
||||
provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id')
|
||||
umo: unified_msg_origin,用于获取会话默认 Provider
|
||||
context: Context AstrBot.
|
||||
config_manager: Trình quản lý cấu hình.
|
||||
provider_id_key: Key provider_id trong cấu hình.
|
||||
umo: unified_msg_origin để lấy provider mặc định của phiên.
|
||||
|
||||
Returns:
|
||||
Provider ID 或 None
|
||||
Provider ID hoặc None.
|
||||
"""
|
||||
try:
|
||||
# 输出Provider选择开始日志
|
||||
task_desc = provider_id_key if provider_id_key else "默认任务"
|
||||
logger.info(f"[Provider 选择] 开始为 {task_desc} 选择 Provider...")
|
||||
# Ghi log bắt đầu chọn provider.
|
||||
task_desc = provider_id_key if provider_id_key else "tác vụ mặc định"
|
||||
logger.info(f"[Chọn provider] Bắt đầu chọn provider cho {task_desc}...")
|
||||
|
||||
# 定义回退策略列表
|
||||
# Định nghĩa danh sách chiến lược fallback.
|
||||
strategies = []
|
||||
strategy_names = []
|
||||
|
||||
# 1. 特定任务的 provider_id
|
||||
# 1. provider_id riêng của tác vụ.
|
||||
if provider_id_key:
|
||||
getter_method = f"get_{provider_id_key}"
|
||||
if hasattr(config_manager, getter_method):
|
||||
@@ -194,46 +188,48 @@ async def get_provider_id_with_fallback(
|
||||
if specific_provider_id:
|
||||
strategies.append(
|
||||
lambda pid=specific_provider_id: _try_get_provider_id_by_id(
|
||||
context, pid, f"配置的 {provider_id_key}"
|
||||
context, pid, f"{provider_id_key} đã cấu hình"
|
||||
)
|
||||
)
|
||||
strategy_names.append(f"1. 配置的 {provider_id_key}")
|
||||
strategy_names.append(f"1. {provider_id_key} đã cấu hình")
|
||||
|
||||
# 2. 主 LLM provider_id
|
||||
# 2. provider_id LLM chính.
|
||||
main_provider_id = config_manager.get_llm_provider_id()
|
||||
if main_provider_id:
|
||||
strategies.append(
|
||||
lambda pid=main_provider_id: _try_get_provider_id_by_id(
|
||||
context, pid, "主 LLM Provider"
|
||||
context, pid, "provider LLM chính"
|
||||
)
|
||||
)
|
||||
strategy_names.append("2. 主 LLM Provider")
|
||||
strategy_names.append("2. Provider LLM chính")
|
||||
|
||||
# 3. 当前会话的 Provider
|
||||
# 3. Provider của phiên hiện tại.
|
||||
strategies.append(lambda: _try_get_session_provider_id(context, umo))
|
||||
strategy_names.append("3. 当前会话 Provider")
|
||||
strategy_names.append("3. Provider phiên hiện tại")
|
||||
|
||||
# 4. 第一个可用的 Provider
|
||||
# 4. Provider khả dụng đầu tiên.
|
||||
strategies.append(lambda: _try_get_first_available_provider_id(context))
|
||||
strategy_names.append("4. 第一个可用 Provider")
|
||||
strategy_names.append("4. Provider khả dụng đầu tiên")
|
||||
|
||||
# 输出回退策略列表
|
||||
logger.info(f"[Provider 选择] 回退策略顺序:{' -> '.join(strategy_names)}")
|
||||
# Ghi thứ tự chiến lược fallback.
|
||||
logger.info(f"[Chọn provider] Thứ tự fallback: {' -> '.join(strategy_names)}")
|
||||
|
||||
# 依次尝试每个策略
|
||||
# Thử lần lượt từng chiến lược.
|
||||
for idx, strategy in enumerate(strategies):
|
||||
provider_id = await strategy()
|
||||
if provider_id:
|
||||
logger.info(
|
||||
f"[Provider 选择] ✓ 成功!使用策略 #{idx + 1},Provider ID: {provider_id}"
|
||||
f"[Chọn provider] ✓ Thành công với chiến lược #{idx + 1}, Provider ID: {provider_id}"
|
||||
)
|
||||
return provider_id
|
||||
|
||||
logger.error("[Provider 选择] ✗ 失败:所有回退策略均无法获取可用 Provider")
|
||||
logger.error(
|
||||
"[Chọn provider] ✗ Thất bại: không chiến lược nào trả về provider khả dụng"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Provider 选择] ✗ 异常:Provider 选择过程出错: {e}")
|
||||
logger.error(f"[Chọn provider] ✗ Lỗi trong quá trình chọn provider: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -249,31 +245,30 @@ async def call_provider_with_retry(
|
||||
extra_generate_kwargs: dict[str, JSONValue] | None = None,
|
||||
) -> LLMResponse | None:
|
||||
"""
|
||||
调用LLM提供者,带超时、重试与退避。支持自定义服务商和配置化 Provider 选择。
|
||||
Gọi provider LLM với retry và backoff, hỗ trợ chọn provider theo cấu hình.
|
||||
|
||||
Args:
|
||||
context: AstrBot上下文对象
|
||||
config_manager: 配置管理器
|
||||
prompt: 输入的提示语
|
||||
umo: 指定使用的模型唯一标识符
|
||||
provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id'),用于选择特定的 Provider
|
||||
system_prompt: 系统提示词
|
||||
response_format: 结构化输出约束(OpenAI 风格)
|
||||
extra_generate_kwargs: 传递给 context.llm_generate 的附加参数(用于内部高级重试策略)
|
||||
context: Context AstrBot.
|
||||
config_manager: Trình quản lý cấu hình.
|
||||
prompt: Prompt đầu vào.
|
||||
umo: Định danh model cần dùng.
|
||||
provider_id_key: Key provider_id để chọn provider riêng.
|
||||
system_prompt: System prompt.
|
||||
response_format: Ràng buộc output có cấu trúc kiểu OpenAI.
|
||||
extra_generate_kwargs: Tham số bổ sung cho context.llm_generate.
|
||||
|
||||
Returns:
|
||||
LLM生成的结果,失败时返回None
|
||||
Kết quả LLM hoặc None nếu thất bại.
|
||||
"""
|
||||
# 注意: 超时由 AstrBot Provider 内部配置控制,不再使用插件层 asyncio.wait_for
|
||||
# 用户可在 AstrBot WebUI 中为每个 Provider 配置 timeout 参数
|
||||
# Timeout do provider AstrBot kiểm soát và có thể cấu hình trong WebUI.
|
||||
retries = config_manager.get_llm_retries()
|
||||
backoff = config_manager.get_llm_backoff()
|
||||
enable_streaming_llm_call = config_manager.get_enable_streaming_llm_call()
|
||||
|
||||
# 1. 确定我们要尝试的 Provider 队列
|
||||
# 1. Xác định hàng đợi provider cần thử.
|
||||
attempt_queue = []
|
||||
|
||||
# 尝试获取指定的 Provider
|
||||
# Thử lấy provider được chỉ định.
|
||||
specific_provider_id = provider_id
|
||||
if not specific_provider_id:
|
||||
specific_provider_id = await get_provider_id_with_fallback(
|
||||
@@ -283,16 +278,18 @@ async def call_provider_with_retry(
|
||||
attempt_queue.extend([(specific_provider_id, False)] * retries)
|
||||
|
||||
if not attempt_queue:
|
||||
logger.error("无可用 Provider,无法调用 llm_generate")
|
||||
logger.error("Không có provider khả dụng để gọi llm_generate")
|
||||
return None
|
||||
|
||||
# 2. 核心请求执行闭包
|
||||
# 2. Closure thực thi yêu cầu cốt lõi.
|
||||
async def _execute_llm_request(
|
||||
pid: str, r_format: JSONObject | None
|
||||
) -> LLMResponse:
|
||||
cb = _get_circuit_breaker(pid)
|
||||
if not cb.allow_request():
|
||||
logger.warning(f"Provider {pid} 熔断器已打开,跳过本次请求")
|
||||
logger.warning(
|
||||
f"Circuit breaker của provider {pid} đang mở, bỏ qua yêu cầu"
|
||||
)
|
||||
raise Exception("Circuit breaker open")
|
||||
|
||||
try:
|
||||
@@ -320,31 +317,31 @@ async def call_provider_with_retry(
|
||||
cb.record_failure()
|
||||
raise err
|
||||
|
||||
# 3. 开始执行队列
|
||||
# 3. Bắt đầu xử lý hàng đợi.
|
||||
last_exc = None
|
||||
current_response_format = response_format
|
||||
|
||||
# 记录上一次尝试的 Provider ID,用于判断是否发生切换
|
||||
# Lưu Provider ID trước để phát hiện chuyển provider.
|
||||
previous_pid = None
|
||||
# 惰性降级标记:仅在 primary provider 重试用尽后才 resolve fallback
|
||||
# Chỉ resolve fallback sau khi hết lượt retry provider chính.
|
||||
needs_fallback = provider_id_key is not None
|
||||
|
||||
for i, (current_pid, is_fallback) in enumerate(attempt_queue):
|
||||
attempt_num = i + 1
|
||||
|
||||
# 修复状态污染:如果切换了全新的 Provider,必须重置 response_format 约束
|
||||
# Reset response_format khi chuyển sang provider mới.
|
||||
if current_pid != previous_pid:
|
||||
current_response_format = response_format
|
||||
previous_pid = current_pid
|
||||
|
||||
prefix = "[降级补偿] " if is_fallback else "[LLM 调用] "
|
||||
prefix = "[Fallback] " if is_fallback else "[Gọi LLM] "
|
||||
logger.info(
|
||||
f"{prefix}尝试 #{attempt_num} | Provider ID: {current_pid} | "
|
||||
f"prompt长度={len(prompt) if prompt else 0}字符"
|
||||
f"{prefix}Lần thử #{attempt_num} | Provider ID: {current_pid} | "
|
||||
f"độ dài prompt={len(prompt) if prompt else 0} ký tự"
|
||||
)
|
||||
|
||||
if not prompt or not prompt.strip():
|
||||
logger.error("LLM provider: prompt 为空,无法调用")
|
||||
logger.error("LLM provider: prompt rỗng, không thể gọi")
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -353,16 +350,16 @@ async def call_provider_with_retry(
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
|
||||
# 处理不支持 response_format 的情况
|
||||
# Xử lý provider không hỗ trợ response_format.
|
||||
if (
|
||||
current_response_format is not None
|
||||
and _is_response_format_unsupported_error(e)
|
||||
):
|
||||
logger.warning(
|
||||
f"{prefix}当前 Provider 可能不支持 response_format,已自动降级为无 schema 约束。"
|
||||
f"{prefix}Provider hiện tại có thể không hỗ trợ response_format; tự chuyển sang không ràng buộc schema."
|
||||
)
|
||||
current_response_format = None
|
||||
# 在当前尝试额度内立即再试一次剥离了 schema 的请求
|
||||
# Thử lại ngay yêu cầu không có schema trong lượt hiện tại.
|
||||
try:
|
||||
return await _execute_llm_request(
|
||||
current_pid, current_response_format
|
||||
@@ -370,8 +367,8 @@ async def call_provider_with_retry(
|
||||
except Exception as inner_e:
|
||||
last_exc = inner_e
|
||||
|
||||
logger.warning(f"{prefix}请求失败: {last_exc}")
|
||||
# 惰性降级:仅当所有 primary provider 的重试都耗尽后才 resolve 并注入 fallback
|
||||
logger.warning(f"{prefix}Yêu cầu thất bại: {last_exc}")
|
||||
# Chỉ resolve và thêm fallback khi hết retry provider chính.
|
||||
if not is_fallback and i == retries - 1 and needs_fallback:
|
||||
fallback_provider_id = await get_provider_id_with_fallback(
|
||||
context, config_manager, None, umo
|
||||
@@ -387,46 +384,46 @@ async def call_provider_with_retry(
|
||||
if not is_last_attempt:
|
||||
# Exponential backoff with jitter: backoff * (2 ^ (attempt_num - 1)) + random jitter
|
||||
sleep_time = backoff * (2 ** (attempt_num - 1)) + random.uniform(0, 1)
|
||||
logger.debug(f"等待 {sleep_time:.2f} 秒后重试...")
|
||||
logger.debug(f"Chờ {sleep_time:.2f} giây trước khi thử lại...")
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
logger.error(f"LLM请求队列全部耗尽,最终失败: {last_exc}")
|
||||
logger.error(f"Đã dùng hết hàng đợi yêu cầu LLM, lỗi cuối: {last_exc}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_token_usage(response) -> dict:
|
||||
"""
|
||||
从LLM响应中提取token使用统计
|
||||
Trích xuất thống kê token từ phản hồi LLM.
|
||||
|
||||
Args:
|
||||
response: LLM响应对象
|
||||
response: Đối tượng phản hồi LLM.
|
||||
|
||||
Returns:
|
||||
Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens
|
||||
Dict gồm prompt_tokens, completion_tokens và total_tokens.
|
||||
"""
|
||||
token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
try:
|
||||
# 1. 尝试直接获取 response.usage
|
||||
# 1. Thử lấy trực tiếp response.usage.
|
||||
usage = getattr(response, "usage", None)
|
||||
|
||||
# 2. 尝试从 response.raw_completion.usage 获取 (兼容旧版)
|
||||
# 2. Thử response.raw_completion.usage để tương thích bản cũ.
|
||||
if not usage and hasattr(response, "raw_completion"):
|
||||
usage = getattr(response.raw_completion, "usage", None)
|
||||
|
||||
# 3. 如果 response 本身就是 dict (某些特殊情况)
|
||||
# 3. Xử lý trường hợp response là dict.
|
||||
if not usage and isinstance(response, dict):
|
||||
usage = response.get("usage")
|
||||
|
||||
if usage:
|
||||
# 优先检查 AstrBot 的 TokenUsage 对象字段 (input, output, total)
|
||||
# Ưu tiên các trường của TokenUsage AstrBot.
|
||||
# AstrBot TokenUsage define: input (prop), output (attr), total (prop)
|
||||
if hasattr(usage, "input") and hasattr(usage, "output"):
|
||||
token_usage["prompt_tokens"] = getattr(usage, "input", 0) or 0
|
||||
token_usage["completion_tokens"] = getattr(usage, "output", 0) or 0
|
||||
token_usage["total_tokens"] = getattr(usage, "total", 0) or 0
|
||||
|
||||
# 处理 usage 是字典的情况
|
||||
# Xử lý usage dạng dict.
|
||||
elif isinstance(usage, dict):
|
||||
token_usage["prompt_tokens"] = usage.get("prompt_tokens", 0) or 0
|
||||
token_usage["completion_tokens"] = (
|
||||
@@ -434,7 +431,7 @@ def extract_token_usage(response) -> dict:
|
||||
)
|
||||
token_usage["total_tokens"] = usage.get("total_tokens", 0) or 0
|
||||
|
||||
# 处理 OpenAI CompletionUsage 等标准对象
|
||||
# Xử lý object chuẩn như OpenAI CompletionUsage.
|
||||
else:
|
||||
token_usage["prompt_tokens"] = getattr(usage, "prompt_tokens", 0) or 0
|
||||
token_usage["completion_tokens"] = (
|
||||
@@ -445,19 +442,19 @@ def extract_token_usage(response) -> dict:
|
||||
return token_usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"提取token使用统计失败: {e}")
|
||||
logger.error(f"Trích xuất thống kê token thất bại: {e}")
|
||||
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
|
||||
def extract_response_text(response) -> str:
|
||||
"""
|
||||
从LLM响应中提取文本内容
|
||||
Trích xuất nội dung văn bản từ phản hồi LLM.
|
||||
|
||||
Args:
|
||||
response: LLM响应对象
|
||||
response: Đối tượng phản hồi LLM.
|
||||
|
||||
Returns:
|
||||
响应文本内容
|
||||
Nội dung văn bản phản hồi.
|
||||
"""
|
||||
try:
|
||||
if hasattr(response, "completion_text"):
|
||||
@@ -465,5 +462,5 @@ def extract_response_text(response) -> str:
|
||||
else:
|
||||
return str(response)
|
||||
except Exception as e:
|
||||
logger.error(f"提取响应文本失败: {e}")
|
||||
logger.error(f"Trích xuất văn bản phản hồi thất bại: {e}")
|
||||
return ""
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
配置管理模块 - 基础设施层
|
||||
负责处理插件配置
|
||||
"""
|
||||
"""Trình quản lý cấu hình plugin ở tầng infrastructure."""
|
||||
|
||||
from astrbot.api import AstrBotConfig
|
||||
from astrbot.api.star import StarTools
|
||||
@@ -12,43 +9,38 @@ from ..utils.template_utils import upgrade_str_format_template
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器
|
||||
"""Trình quản lý cấu hình.
|
||||
|
||||
配置结构采用分组嵌套方式,顶层分为以下分组:
|
||||
- basic: 基础设置
|
||||
- qq_official: QQ 官方机器人展示设置
|
||||
- auto_analysis: 自动分析设置
|
||||
- llm: LLM 设置
|
||||
- analysis_features: 分析功能开关
|
||||
- incremental: 增量分析设置
|
||||
- prompts: 提示词模板
|
||||
Cấu hình được nhóm lồng nhau ở cấp cao nhất: ``basic``, ``qq_official``,
|
||||
``auto_analysis``, ``llm``, ``analysis_features``, ``incremental`` và ``prompts``.
|
||||
"""
|
||||
|
||||
def __init__(self, config: AstrBotConfig):
|
||||
self.config = config
|
||||
|
||||
def _get_group(self, group: str) -> dict:
|
||||
"""获取指定分组的配置字典,不存在时返回空字典"""
|
||||
"""Lấy dict cấu hình của nhóm hoặc dict rỗng nếu không tồn tại."""
|
||||
return self.config.get(group, {})
|
||||
|
||||
def _ensure_group(self, group: str) -> dict:
|
||||
"""确保指定分组存在并返回其字典引用"""
|
||||
"""Đảm bảo nhóm tồn tại và trả về tham chiếu dict."""
|
||||
if group not in self.config:
|
||||
self.config[group] = {}
|
||||
return self.config[group]
|
||||
|
||||
def get_group_list_mode(self) -> str:
|
||||
"""获取群组列表模式 (whitelist/blacklist/none)"""
|
||||
"""Lấy chế độ danh sách nhóm: whitelist, blacklist hoặc none."""
|
||||
return self._get_group("basic").get("group_list_mode", "none")
|
||||
|
||||
def get_group_list(self) -> list[str]:
|
||||
"""获取群组列表(用于黑白名单)"""
|
||||
"""Lấy danh sách nhóm dùng cho whitelist/blacklist."""
|
||||
return self._get_group("basic").get("group_list", [])
|
||||
|
||||
def is_group_allowed(self, group_id_or_umo: str) -> bool:
|
||||
"""
|
||||
根据配置的白/黑名单判断是否允许在该群聊中使用
|
||||
支持传入 simple group_id 或 UMO (Unified Message Origin)
|
||||
Kiểm tra nhóm có được phép theo whitelist/blacklist.
|
||||
|
||||
Hỗ trợ group_id đơn giản hoặc UMO (Unified Message Origin).
|
||||
"""
|
||||
mode = self.get_group_list_mode().lower()
|
||||
if mode not in ("whitelist", "blacklist", "none"):
|
||||
@@ -71,19 +63,19 @@ class ConfigManager:
|
||||
|
||||
def _is_group_match(self, target: str, item: str) -> bool:
|
||||
"""
|
||||
核心匹配逻辑:判断名单中的 item 是否匹配目标的 target (Unified Message Origin, UMO 或 纯 ID)。
|
||||
支持处理 Telegram 话题 (#) 和 独立隔离会话 (_) 的双向穿透匹配。
|
||||
So khớp item danh sách với UMO hoặc ID đích, hỗ trợ topic Telegram (#)
|
||||
và phiên cô lập (_) theo cả hai chiều.
|
||||
"""
|
||||
if item == target:
|
||||
return True
|
||||
|
||||
# 分解目标 UMO 的前缀和 ID 部分 (如 default:GroupMessage:ID)
|
||||
# Tách tiền tố UMO và phần ID, ví dụ default:GroupMessage:ID.
|
||||
if ":" in target:
|
||||
target_prefix, target_id = target.rsplit(":", 1)
|
||||
else:
|
||||
target_prefix, target_id = "", target
|
||||
|
||||
# 生成目标 ID 的所有“穿透”候选 (处理隔离模式和话题)
|
||||
# Tạo mọi ứng viên ID cho chế độ cô lập và topic.
|
||||
candidates = {target_id}
|
||||
if "#" in target_id:
|
||||
candidates.add(target_id.split("#", 1)[0])
|
||||
@@ -91,16 +83,16 @@ class ConfigManager:
|
||||
for part in target_id.split("_"):
|
||||
candidates.add(part)
|
||||
|
||||
# 检查名单项 (item) 的格式
|
||||
# Kiểm tra định dạng item trong danh sách.
|
||||
if ":" in item:
|
||||
i_prefix, i_id = item.rsplit(":", 1)
|
||||
# 名单项带前缀时,前缀必须匹配 (如果 target 本身没前缀,则允许作为跨平台通用 ID 匹配)
|
||||
# Nếu item có tiền tố thì phải khớp, trừ target không có tiền tố.
|
||||
if target_prefix and i_prefix != target_prefix:
|
||||
return False
|
||||
else:
|
||||
i_id = item
|
||||
|
||||
# [修复] 名单项 ID 也可能包含复合形式 (如 UserId_GroupId),需要拆解匹配
|
||||
# ID trong danh sách có thể ở dạng ghép như UserId_GroupId.
|
||||
item_variants = {i_id}
|
||||
if "#" in i_id:
|
||||
item_variants.add(i_id.split("#", 1)[0])
|
||||
@@ -108,120 +100,119 @@ class ConfigManager:
|
||||
for part in i_id.split("_"):
|
||||
item_variants.add(part)
|
||||
|
||||
# 只要两边的 ID “核心部分”存在交集,即视为匹配成功
|
||||
# Khớp nếu hai tập phần ID cốt lõi có giao nhau.
|
||||
return not item_variants.isdisjoint(candidates)
|
||||
|
||||
def get_max_messages(self) -> int:
|
||||
"""获取最大消息数量"""
|
||||
"""Lấy số tin nhắn tối đa."""
|
||||
return self._get_group("basic").get("max_messages", 1000)
|
||||
|
||||
def get_analysis_days(self) -> int:
|
||||
"""获取分析天数"""
|
||||
"""Lấy số ngày phân tích."""
|
||||
return self._get_group("basic").get("analysis_days", 1)
|
||||
|
||||
def get_auto_analysis_time(self) -> list[str]:
|
||||
"""获取自动分析时间列表"""
|
||||
"""Lấy danh sách thời điểm phân tích tự động."""
|
||||
group = self._get_group("auto_analysis")
|
||||
val = group.get("auto_analysis_time", ["09:00"])
|
||||
# 兼容旧版本字符串配置
|
||||
# Tương thích cấu hình chuỗi của bản cũ.
|
||||
if isinstance(val, str):
|
||||
val_list = [val]
|
||||
# 自动修复配置格式
|
||||
# Tự sửa định dạng cấu hình.
|
||||
try:
|
||||
auto_group = self._ensure_group("auto_analysis")
|
||||
auto_group["auto_analysis_time"] = val_list
|
||||
self.config.save_config()
|
||||
logger.info(f"自动修复配置格式 auto_analysis_time: {val} -> {val_list}")
|
||||
logger.info(f"Tự sửa định dạng auto_analysis_time: {val} -> {val_list}")
|
||||
except Exception as e:
|
||||
logger.warning(f"修复配置格式失败: {e}")
|
||||
logger.warning(f"Sửa định dạng cấu hình thất bại: {e}")
|
||||
return val_list
|
||||
return val if isinstance(val, list) else ["09:00"]
|
||||
|
||||
def get_enable_auto_analysis(self) -> bool:
|
||||
"""
|
||||
获取是否启用自动分析(兼容旧接口)。
|
||||
Kiểm tra phân tích tự động có bật hay không, tương thích giao diện cũ.
|
||||
|
||||
旧版本使用 auto_analysis.enable_auto_analysis 布尔值;
|
||||
新版本改为由 scheduled_group_list_mode + scheduled_group_list 推导。
|
||||
Bản cũ dùng boolean ``enable_auto_analysis``; bản mới suy ra từ danh sách lịch.
|
||||
"""
|
||||
return self.is_auto_analysis_enabled()
|
||||
|
||||
def get_output_format(self) -> list[str]:
|
||||
"""获取输出格式"""
|
||||
"""Lấy định dạng output."""
|
||||
val = self._get_group("basic").get("output_format", ["image"])
|
||||
return val if isinstance(val, list) else [val]
|
||||
|
||||
def get_qq_official_t2i_summary_dashboard_enabled(self) -> bool:
|
||||
"""是否启用 QQ 官方 T2I 概览图。"""
|
||||
"""Kiểm tra dashboard tổng quan T2I QQ Official có bật hay không."""
|
||||
group = self._get_group("qq_official")
|
||||
if "enable_t2i_summary_dashboard" in group:
|
||||
return bool(group["enable_t2i_summary_dashboard"])
|
||||
return bool(group.get("enable_t2i_activity_histogram", True))
|
||||
|
||||
def get_min_messages_threshold(self) -> int:
|
||||
"""获取最小消息阈值"""
|
||||
"""Lấy ngưỡng tin nhắn tối thiểu."""
|
||||
return self._get_group("basic").get("min_messages_threshold", 50)
|
||||
|
||||
def get_topic_analysis_enabled(self) -> bool:
|
||||
"""获取是否启用话题分析"""
|
||||
"""Kiểm tra phân tích chủ đề có bật hay không."""
|
||||
return self._get_group("analysis_features").get("topic_analysis_enabled", True)
|
||||
|
||||
def get_user_title_analysis_enabled(self) -> bool:
|
||||
"""获取是否启用用户称号分析"""
|
||||
"""Kiểm tra phân tích danh hiệu có bật hay không."""
|
||||
return self._get_group("analysis_features").get(
|
||||
"user_title_analysis_enabled", True
|
||||
)
|
||||
|
||||
def get_golden_quote_analysis_enabled(self) -> bool:
|
||||
"""获取是否启用金句分析"""
|
||||
"""Kiểm tra phân tích trích dẫn có bật hay không."""
|
||||
return self._get_group("analysis_features").get(
|
||||
"golden_quote_analysis_enabled", True
|
||||
)
|
||||
|
||||
def get_chat_quality_analysis_enabled(self) -> bool:
|
||||
"""获取是否启用聊天质量分析"""
|
||||
"""Kiểm tra phân tích chất lượng có bật hay không."""
|
||||
return self._get_group("analysis_features").get(
|
||||
"chat_quality_analysis_enabled", False
|
||||
)
|
||||
|
||||
def get_max_topics(self) -> int:
|
||||
"""获取最大话题数量"""
|
||||
"""Lấy số chủ đề tối đa."""
|
||||
return self._get_group("analysis_features").get("max_topics", 5)
|
||||
|
||||
def get_max_user_titles(self) -> int:
|
||||
"""获取最大用户称号数量"""
|
||||
"""Lấy số danh hiệu tối đa."""
|
||||
return self._get_group("analysis_features").get("max_user_titles", 8)
|
||||
|
||||
def get_max_golden_quotes(self) -> int:
|
||||
"""获取最大金句数量"""
|
||||
"""Lấy số trích dẫn tối đa."""
|
||||
return self._get_group("analysis_features").get("max_golden_quotes", 5)
|
||||
|
||||
def get_llm_retries(self) -> int:
|
||||
"""获取LLM请求重试次数"""
|
||||
"""Lấy số lần retry yêu cầu LLM."""
|
||||
return self._get_group("llm").get("llm_retries", 2)
|
||||
|
||||
def get_llm_backoff(self) -> int:
|
||||
"""获取LLM请求重试退避基值(秒),实际退避会乘以尝试次数"""
|
||||
"""Lấy giá trị backoff cơ sở của retry LLM, tính bằng giây."""
|
||||
return self._get_group("llm").get("llm_backoff", 2)
|
||||
|
||||
def get_enable_streaming_llm_call(self) -> bool:
|
||||
"""获取是否启用流式 LLM 调用"""
|
||||
"""Kiểm tra lời gọi LLM streaming có bật hay không."""
|
||||
return self._get_group("llm").get("enable_streaming_llm_call", False)
|
||||
|
||||
def get_debug_mode(self) -> bool:
|
||||
"""获取是否启用调试模式"""
|
||||
"""Kiểm tra debug mode có bật hay không."""
|
||||
return self._get_group("basic").get("debug_mode", False)
|
||||
|
||||
def get_enable_base64_image(self) -> bool:
|
||||
"""获取是否启用 Base64 图片传输"""
|
||||
"""Kiểm tra truyền ảnh Base64 có bật hay không."""
|
||||
return self._get_group("basic").get("enable_base64_image", False)
|
||||
|
||||
def get_t2i_rendering_strategies(self) -> list[dict]:
|
||||
"""获取用户配置的两轮 T2I 渲染策略"""
|
||||
"""Lấy hai chiến lược render T2I do người dùng cấu hình."""
|
||||
group = self._get_group("t2i_rendering")
|
||||
|
||||
return [
|
||||
# 第一轮:质量优先
|
||||
# Lượt đầu: ưu tiên chất lượng.
|
||||
{
|
||||
"full_page": True,
|
||||
"type": group.get("t2i_r1_type", "png"),
|
||||
@@ -229,7 +220,7 @@ class ConfigManager:
|
||||
"device_scale_factor_level": group.get("t2i_r1_device_scale", "ultra"),
|
||||
"timeout": group.get("t2i_r1_timeout", 30000),
|
||||
},
|
||||
# 第二轮:稳定性/回退优先
|
||||
# Lượt hai: ưu tiên ổn định và fallback.
|
||||
{
|
||||
"full_page": True,
|
||||
"type": group.get("t2i_r2_type", "jpeg"),
|
||||
@@ -240,11 +231,11 @@ class ConfigManager:
|
||||
]
|
||||
|
||||
def get_t2i_font_source(self) -> str:
|
||||
"""获取 T2I 字体源 (Mainland/Overseas)"""
|
||||
"""Lấy nguồn font T2I: Mainland hoặc Overseas."""
|
||||
return self._get_group("t2i_rendering").get("t2i_font_source", "Overseas")
|
||||
|
||||
def get_t2i_google_fonts_mirror(self) -> str:
|
||||
"""根据环境选择获取 Google Fonts 镜像地址"""
|
||||
"""Lấy URL mirror Google Fonts theo môi trường."""
|
||||
source = self.get_t2i_font_source()
|
||||
group = self._get_group("t2i_rendering")
|
||||
if source == "Mainland":
|
||||
@@ -252,7 +243,7 @@ class ConfigManager:
|
||||
return group.get("t2i_overseas_google_fonts", "https://fonts.googleapis.com")
|
||||
|
||||
def get_t2i_gstatic_mirror(self) -> str:
|
||||
"""根据环境选择获取 Gstatic 镜像地址"""
|
||||
"""Lấy URL mirror Gstatic theo môi trường."""
|
||||
source = self.get_t2i_font_source()
|
||||
group = self._get_group("t2i_rendering")
|
||||
if source == "Mainland":
|
||||
@@ -260,45 +251,45 @@ class ConfigManager:
|
||||
return group.get("t2i_overseas_gstatic", "https://fonts.gstatic.com")
|
||||
|
||||
def get_t2i_atri_font_mirror(self) -> str:
|
||||
"""获取 ATRI 主题字体镜像地址 (目前保持不变,如有需要可后续添加 Mainland/Overseas 配置)"""
|
||||
"""Lấy URL mirror font theme ATRI."""
|
||||
return self._get_group("t2i_rendering").get(
|
||||
"t2i_atri_font_mirror", "https://tc.ciallo.ccwu.cc"
|
||||
)
|
||||
|
||||
def get_llm_provider_id(self) -> str:
|
||||
"""获取主 LLM Provider ID"""
|
||||
"""Lấy Provider ID LLM chính."""
|
||||
return self._get_group("llm").get("llm_provider_id", "")
|
||||
|
||||
def get_topic_provider_id(self) -> str:
|
||||
"""获取话题分析专用 Provider ID"""
|
||||
"""Lấy Provider ID riêng cho phân tích chủ đề."""
|
||||
return self._get_group("llm").get("topic_provider_id", "")
|
||||
|
||||
def get_user_title_provider_id(self) -> str:
|
||||
"""获取用户称号分析专用 Provider ID"""
|
||||
"""Lấy Provider ID riêng cho phân tích danh hiệu."""
|
||||
return self._get_group("llm").get("user_title_provider_id", "")
|
||||
|
||||
def get_golden_quote_provider_id(self) -> str:
|
||||
"""获取金句分析专用 Provider ID"""
|
||||
"""Lấy Provider ID riêng cho phân tích trích dẫn."""
|
||||
return self._get_group("llm").get("golden_quote_provider_id", "")
|
||||
|
||||
def get_keep_original_persona(self) -> bool:
|
||||
"""获取是否继承会话原始人格设定"""
|
||||
"""Kiểm tra có kế thừa persona gốc của phiên hay không."""
|
||||
return self._get_group("analysis_features").get("keep_original_persona", False)
|
||||
|
||||
def get_use_plugin_specific_persona(self) -> bool:
|
||||
"""获取是否强制使用插件指定的人格设定"""
|
||||
"""Kiểm tra có bắt buộc dùng persona do plugin chỉ định hay không."""
|
||||
return self._get_group("analysis_features").get(
|
||||
"use_plugin_specific_persona", False
|
||||
)
|
||||
|
||||
def get_plugin_specific_persona_id(self) -> str:
|
||||
"""获取插件指定的全局人格 ID (通过 select_persona 接口选择)"""
|
||||
"""Lấy ID persona toàn cục do plugin chỉ định."""
|
||||
return self._get_group("analysis_features").get(
|
||||
"plugin_specific_persona_id", ""
|
||||
)
|
||||
|
||||
def get_bot_self_ids(self) -> list:
|
||||
"""获取机器人自身的 ID 列表 (兼容 bot_qq_ids)"""
|
||||
"""Lấy danh sách ID của bot, tương thích bot_qq_ids."""
|
||||
basic = self._get_group("basic")
|
||||
ids = basic.get("bot_self_ids", [])
|
||||
if not ids:
|
||||
@@ -306,42 +297,42 @@ class ConfigManager:
|
||||
return ids
|
||||
|
||||
def get_filter_bot_messages(self) -> bool:
|
||||
"""获取是否过滤机器人自己的消息。"""
|
||||
"""Kiểm tra có lọc tin nhắn của bot hay không."""
|
||||
return self._get_group("basic").get("filter_bot_messages", True)
|
||||
|
||||
def set_filter_bot_messages(self, enabled: bool):
|
||||
"""设置是否过滤机器人自己的消息。"""
|
||||
"""Thiết lập lọc tin nhắn của bot."""
|
||||
self._ensure_group("basic")["filter_bot_messages"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def get_html_output_dir(self) -> str:
|
||||
"""获取HTML输出目录"""
|
||||
"""Lấy thư mục output HTML."""
|
||||
|
||||
default_path = StarTools.get_data_dir(PLUGIN_NAME) / "self_hosted_html_reports"
|
||||
val = self._get_group("html").get("html_output_dir")
|
||||
return val if val else str(default_path)
|
||||
|
||||
def get_html_base_url(self) -> str:
|
||||
"""获取HTML外链Base URL"""
|
||||
"""Lấy base URL liên kết HTML."""
|
||||
return self._get_group("html").get("html_base_url", "")
|
||||
|
||||
def get_html_only_url(self) -> bool:
|
||||
"""获取是否仅输出外链而不发送文件本体"""
|
||||
"""Kiểm tra có chỉ gửi liên kết thay vì tệp HTML hay không."""
|
||||
return self._get_group("html").get("html_only_url", False)
|
||||
|
||||
def set_html_only_url(self, enabled: bool):
|
||||
"""设置是否仅输出外链而不发送文件本体"""
|
||||
"""Thiết lập chỉ gửi liên kết thay vì tệp HTML."""
|
||||
self._ensure_group("html")["html_only_url"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def get_html_filename_format(self) -> str:
|
||||
"""获取HTML文件名格式"""
|
||||
"""Lấy định dạng tên tệp HTML."""
|
||||
return self._get_group("html").get(
|
||||
"html_filename_format", "群聊分析报告_{group_id}_{date}.html"
|
||||
"html_filename_format", "bao_cao_phan_tich_nhom_{group_id}_{date}.html"
|
||||
)
|
||||
|
||||
def get_topic_analysis_prompt(self, style: str = "topic_prompt") -> str:
|
||||
"""获取话题分析提示词模板"""
|
||||
"""Lấy template prompt phân tích chủ đề."""
|
||||
prompts_config = self._get_group("prompts").get("topic_analysis_prompts", {})
|
||||
prompt = prompts_config.get(style, "")
|
||||
if prompt:
|
||||
@@ -349,7 +340,7 @@ class ConfigManager:
|
||||
return ""
|
||||
|
||||
def get_user_title_analysis_prompt(self, style: str = "user_title_prompt") -> str:
|
||||
"""获取用户称号分析提示词模板"""
|
||||
"""Lấy template prompt phân tích danh hiệu."""
|
||||
prompts_config = self._get_group("prompts").get(
|
||||
"user_title_analysis_prompts", {}
|
||||
)
|
||||
@@ -361,7 +352,7 @@ class ConfigManager:
|
||||
def get_golden_quote_analysis_prompt(
|
||||
self, style: str = "golden_quote_v2_prompt"
|
||||
) -> str:
|
||||
"""获取金句分析提示词模板"""
|
||||
"""Lấy template prompt phân tích trích dẫn."""
|
||||
prompts_config = self._get_group("prompts").get(
|
||||
"golden_quote_analysis_prompts", {}
|
||||
)
|
||||
@@ -371,7 +362,7 @@ class ConfigManager:
|
||||
return ""
|
||||
|
||||
def get_quality_analysis_prompt(self, style: str = "quality_v2_prompt") -> str:
|
||||
"""获取聊天质量分析提示词模板"""
|
||||
"""Lấy template prompt phân tích chất lượng."""
|
||||
prompts_config = self._get_group("prompts").get("quality_analysis_prompts", {})
|
||||
prompt = prompts_config.get(style, "")
|
||||
if prompt:
|
||||
@@ -379,7 +370,7 @@ class ConfigManager:
|
||||
return ""
|
||||
|
||||
def set_quality_analysis_prompt(self, prompt: str):
|
||||
"""设置聊天质量分析提示词模板"""
|
||||
"""Thiết lập template prompt phân tích chất lượng."""
|
||||
prompts = self._ensure_group("prompts")
|
||||
if "quality_analysis_prompts" not in prompts:
|
||||
prompts["quality_analysis_prompts"] = {}
|
||||
@@ -387,8 +378,8 @@ class ConfigManager:
|
||||
self.config.save_config()
|
||||
|
||||
def _upgrade_config_item(self, group: str, key: str, setter_func):
|
||||
"""升级指定配置项的值(从 str.format -> string.Template),并回写。"""
|
||||
# 如果是 prompts,则先取 prompts 分组,再取子分组 (group)
|
||||
"""Nâng cấp mục cấu hình từ str.format sang string.Template và ghi lại."""
|
||||
# Với prompt, lấy nhóm prompts rồi lấy nhóm con.
|
||||
if group in (
|
||||
"quality_analysis_prompts",
|
||||
"topic_analysis_prompts",
|
||||
@@ -407,15 +398,15 @@ class ConfigManager:
|
||||
if upgraded and upgraded_val != val:
|
||||
setter_func(upgraded_val)
|
||||
logger.info(
|
||||
f"配置项 {group}.{key} 发现旧版语法并已自动升级为 string.Template 格式。"
|
||||
f"Phát hiện cú pháp cũ ở {group}.{key}; đã tự nâng cấp sang string.Template"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def upgrade_prompt_templates(self):
|
||||
"""启动时调用,扫描并升级所有可配置的模板(含 prompt 和文件名)。"""
|
||||
"""Quét và nâng cấp mọi template cấu hình khi khởi động."""
|
||||
modified = False
|
||||
# 1. 提示词模板升级
|
||||
# 1. Nâng cấp template prompt.
|
||||
modified |= self._upgrade_config_item(
|
||||
"quality_analysis_prompts",
|
||||
"quality_v2_prompt",
|
||||
@@ -442,7 +433,7 @@ class ConfigManager:
|
||||
self.set_golden_quote_analysis_prompt,
|
||||
)
|
||||
|
||||
# 2. 文件名格式升级
|
||||
# 2. Nâng cấp định dạng tên tệp.
|
||||
modified |= self._upgrade_config_item(
|
||||
"html",
|
||||
"html_filename_format",
|
||||
@@ -451,12 +442,12 @@ class ConfigManager:
|
||||
|
||||
if modified:
|
||||
logger.info(
|
||||
"已完成所有配置模板从 str.format 到 string.Template 的安全迁移。(已自动回写配置)"
|
||||
"Đã di chuyển an toàn mọi template cấu hình từ str.format sang string.Template và ghi lại cấu hình"
|
||||
)
|
||||
return modified
|
||||
|
||||
def get_quality_summary_prompt(self, style: str = "quality_summary_prompt") -> str:
|
||||
"""获取聊天质量汇总分析提示词模板"""
|
||||
"""Lấy template prompt tổng hợp chất lượng."""
|
||||
prompts_config = self._get_group("prompts").get("quality_analysis_prompts", {})
|
||||
prompt = prompts_config.get(style, "")
|
||||
if prompt:
|
||||
@@ -464,7 +455,7 @@ class ConfigManager:
|
||||
return ""
|
||||
|
||||
def set_topic_analysis_prompt(self, prompt: str):
|
||||
"""设置话题分析提示词模板"""
|
||||
"""Thiết lập template prompt phân tích chủ đề."""
|
||||
prompts = self._ensure_group("prompts")
|
||||
if "topic_analysis_prompts" not in prompts:
|
||||
prompts["topic_analysis_prompts"] = {}
|
||||
@@ -472,7 +463,7 @@ class ConfigManager:
|
||||
self.config.save_config()
|
||||
|
||||
def set_quality_summary_prompt(self, prompt: str):
|
||||
"""设置聊天质量汇总分析提示词模板"""
|
||||
"""Thiết lập template prompt tổng hợp chất lượng."""
|
||||
prompts = self._ensure_group("prompts")
|
||||
if "quality_analysis_prompts" not in prompts:
|
||||
prompts["quality_analysis_prompts"] = {}
|
||||
@@ -480,7 +471,7 @@ class ConfigManager:
|
||||
self.config.save_config()
|
||||
|
||||
def set_user_title_analysis_prompt(self, prompt: str):
|
||||
"""设置用户称号分析提示词模板"""
|
||||
"""Thiết lập template prompt phân tích danh hiệu."""
|
||||
prompts = self._ensure_group("prompts")
|
||||
if "user_title_analysis_prompts" not in prompts:
|
||||
prompts["user_title_analysis_prompts"] = {}
|
||||
@@ -488,7 +479,7 @@ class ConfigManager:
|
||||
self.config.save_config()
|
||||
|
||||
def set_golden_quote_analysis_prompt(self, prompt: str):
|
||||
"""设置金句分析提示词模板"""
|
||||
"""Thiết lập template prompt phân tích trích dẫn."""
|
||||
prompts = self._ensure_group("prompts")
|
||||
if "golden_quote_analysis_prompts" not in prompts:
|
||||
prompts["golden_quote_analysis_prompts"] = {}
|
||||
@@ -496,90 +487,93 @@ class ConfigManager:
|
||||
self.config.save_config()
|
||||
|
||||
def set_output_format(self, format_types: str | list[str]):
|
||||
"""设置输出格式"""
|
||||
"""Thiết lập định dạng output."""
|
||||
if isinstance(format_types, str):
|
||||
format_types = [
|
||||
f.strip() for f in format_types.replace(",", ",").split(",")
|
||||
]
|
||||
for f in format_types:
|
||||
if f not in ("image", "text", "html"):
|
||||
raise ValueError(f"无效格式: {f}。有效: image, text, html")
|
||||
raise ValueError(
|
||||
f"Định dạng không hợp lệ: {f}. Hợp lệ: image, text, html"
|
||||
)
|
||||
|
||||
self._ensure_group("basic")["output_format"] = format_types
|
||||
self.config.save_config()
|
||||
|
||||
def set_group_list_mode(self, mode: str):
|
||||
"""设置群组列表模式"""
|
||||
"""Thiết lập chế độ danh sách nhóm."""
|
||||
self._ensure_group("basic")["group_list_mode"] = mode
|
||||
self.config.save_config()
|
||||
|
||||
def set_group_list(self, groups: list[str]):
|
||||
"""设置群组列表"""
|
||||
"""Thiết lập danh sách nhóm."""
|
||||
self._ensure_group("basic")["group_list"] = groups
|
||||
self.config.save_config()
|
||||
|
||||
def get_max_concurrent_tasks(self) -> int:
|
||||
"""获取自动分析最大并发群数"""
|
||||
"""Lấy số nhóm phân tích tự động đồng thời tối đa."""
|
||||
return self._get_group("performance").get("max_concurrent_groups", 3)
|
||||
|
||||
def get_llm_max_concurrent(self) -> int:
|
||||
"""获取全局 LLM 最大并发请求数"""
|
||||
"""Lấy số yêu cầu LLM đồng thời toàn cục tối đa."""
|
||||
return self._get_group("performance").get("max_concurrent_llm", 3)
|
||||
|
||||
def get_t2i_max_concurrent(self) -> int:
|
||||
"""获取全局图片渲染(T2I)最大并发数"""
|
||||
"""Lấy số tác vụ render ảnh T2I đồng thời toàn cục tối đa."""
|
||||
return self._get_group("performance").get("max_concurrent_t2i", 1)
|
||||
|
||||
def get_stagger_seconds(self) -> int:
|
||||
"""获取多群分析任务启动时的交错间隔(秒)"""
|
||||
"""Lấy khoảng cách khởi động tác vụ nhiều nhóm, tính bằng giây."""
|
||||
return self._get_group("performance").get("stagger_seconds", 2)
|
||||
|
||||
def set_max_concurrent_tasks(self, count: int):
|
||||
"""设置自动分析最大并发数"""
|
||||
"""Thiết lập số tác vụ phân tích tự động đồng thời tối đa."""
|
||||
self._ensure_group("performance")["max_concurrent_groups"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_messages(self, count: int):
|
||||
"""设置最大消息数量"""
|
||||
"""Thiết lập số tin nhắn tối đa."""
|
||||
self._ensure_group("basic")["max_messages"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_analysis_days(self, days: int):
|
||||
"""设置分析天数"""
|
||||
"""Thiết lập số ngày phân tích."""
|
||||
self._ensure_group("basic")["analysis_days"] = days
|
||||
self.config.save_config()
|
||||
|
||||
def set_auto_analysis_time(self, time_val: str | list[str]):
|
||||
"""设置自动分析时间点"""
|
||||
"""Thiết lập thời điểm phân tích tự động."""
|
||||
self._ensure_group("auto_analysis")["auto_analysis_time"] = time_val
|
||||
self.config.save_config()
|
||||
|
||||
def is_auto_analysis_enabled(self) -> bool:
|
||||
"""
|
||||
判断自动分析功能是否通过名单“按需开启”。
|
||||
逻辑:如果是白名单模式且名单不为空,或者为黑名单模式,则视为开启。
|
||||
Kiểm tra phân tích tự động có được bật theo danh sách hay không.
|
||||
|
||||
Bật khi whitelist không rỗng hoặc đang ở chế độ blacklist.
|
||||
"""
|
||||
mode = self.get_scheduled_group_list_mode()
|
||||
lst = self.get_scheduled_group_list()
|
||||
return (mode == "whitelist" and len(lst) > 0) or (mode == "blacklist")
|
||||
|
||||
def get_scheduled_group_list_mode(self) -> str:
|
||||
"""获取定时分析名单模式 (whitelist/blacklist)"""
|
||||
"""Lấy chế độ danh sách phân tích định kỳ."""
|
||||
return self._get_group("auto_analysis").get(
|
||||
"scheduled_group_list_mode", "whitelist"
|
||||
)
|
||||
|
||||
def set_scheduled_group_list_mode(self, mode: str):
|
||||
"""设置定时分析名单模式"""
|
||||
"""Thiết lập chế độ danh sách phân tích định kỳ."""
|
||||
self._ensure_group("auto_analysis")["scheduled_group_list_mode"] = mode
|
||||
self.config.save_config()
|
||||
|
||||
def get_scheduled_group_list(self) -> list[str]:
|
||||
"""获取定时分析目标群列表"""
|
||||
"""Lấy danh sách nhóm đích phân tích định kỳ."""
|
||||
return self._get_group("auto_analysis").get("scheduled_group_list", [])
|
||||
|
||||
def set_scheduled_group_list(self, groups: list[str]):
|
||||
"""设置定时分析目标群列表"""
|
||||
"""Thiết lập danh sách nhóm đích phân tích định kỳ."""
|
||||
self._ensure_group("auto_analysis")["scheduled_group_list"] = groups
|
||||
self.config.save_config()
|
||||
|
||||
@@ -587,119 +581,114 @@ class ConfigManager:
|
||||
self, group_umo_or_id: str, mode: str, group_list: list
|
||||
) -> bool:
|
||||
"""
|
||||
通用的名单判定逻辑。
|
||||
Logic kiểm tra danh sách dùng chung.
|
||||
|
||||
逻辑如下:
|
||||
- whitelist 模式:
|
||||
- 如果列表为空,则视为“此级别未开启”。
|
||||
- 如果不为空,仅在列表中的通过。
|
||||
- blacklist 模式:
|
||||
- 在列表中的不通过。
|
||||
- 如果列表为空,则全部通过。
|
||||
Whitelist rỗng nghĩa là cấp này chưa bật; nếu không rỗng chỉ cho item
|
||||
trong danh sách. Blacklist chặn item trong danh sách; rỗng thì cho tất cả.
|
||||
"""
|
||||
group_list = [str(x).strip() for x in group_list]
|
||||
target = str(group_umo_or_id).strip()
|
||||
|
||||
if mode == "whitelist":
|
||||
if not group_list:
|
||||
# 白名单为空:此级别不开启 (按需开启逻辑)
|
||||
# Whitelist rỗng: cấp này chưa bật.
|
||||
return False
|
||||
return any(self._is_group_match(target, item) for item in group_list)
|
||||
else: # blacklist
|
||||
if not group_list:
|
||||
# 黑名单为空:全通过
|
||||
# Blacklist rỗng: cho tất cả.
|
||||
return True
|
||||
return not any(self._is_group_match(target, item) for item in group_list)
|
||||
|
||||
def set_min_messages_threshold(self, threshold: int):
|
||||
"""设置最小消息阈值"""
|
||||
"""Thiết lập ngưỡng tin nhắn tối thiểu."""
|
||||
self._ensure_group("basic")["min_messages_threshold"] = threshold
|
||||
self.config.save_config()
|
||||
|
||||
def set_topic_analysis_enabled(self, enabled: bool):
|
||||
"""设置是否启用话题分析"""
|
||||
"""Bật hoặc tắt phân tích chủ đề."""
|
||||
self._ensure_group("analysis_features")["topic_analysis_enabled"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def set_user_title_analysis_enabled(self, enabled: bool):
|
||||
"""设置是否启用用户称号分析"""
|
||||
"""Bật hoặc tắt phân tích danh hiệu."""
|
||||
self._ensure_group("analysis_features")["user_title_analysis_enabled"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def set_golden_quote_analysis_enabled(self, enabled: bool):
|
||||
"""设置是否启用金句分析"""
|
||||
"""Bật hoặc tắt phân tích trích dẫn."""
|
||||
self._ensure_group("analysis_features")["golden_quote_analysis_enabled"] = (
|
||||
enabled
|
||||
)
|
||||
self.config.save_config()
|
||||
|
||||
def set_chat_quality_analysis_enabled(self, enabled: bool):
|
||||
"""设置是否启用聊天质量分析"""
|
||||
"""Bật hoặc tắt phân tích chất lượng."""
|
||||
self._ensure_group("analysis_features")["chat_quality_analysis_enabled"] = (
|
||||
enabled
|
||||
)
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_topics(self, count: int):
|
||||
"""设置最大话题数量"""
|
||||
"""Thiết lập số chủ đề tối đa."""
|
||||
self._ensure_group("analysis_features")["max_topics"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_user_titles(self, count: int):
|
||||
"""设置最大用户称号数量"""
|
||||
"""Thiết lập số danh hiệu tối đa."""
|
||||
self._ensure_group("analysis_features")["max_user_titles"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_golden_quotes(self, count: int):
|
||||
"""设置最大金句数量"""
|
||||
"""Thiết lập số trích dẫn tối đa."""
|
||||
self._ensure_group("analysis_features")["max_golden_quotes"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_html_filename_format(self, format_str: str):
|
||||
"""设置HTML文件名格式"""
|
||||
"""Thiết lập định dạng tên tệp HTML."""
|
||||
self._ensure_group("html")["html_filename_format"] = format_str
|
||||
self.config.save_config()
|
||||
|
||||
def get_report_template(self) -> str:
|
||||
"""获取报告模板名称"""
|
||||
"""Lấy tên template báo cáo."""
|
||||
return self._get_group("basic").get("report_template", "scrapbook")
|
||||
|
||||
def set_report_template(self, template_name: str):
|
||||
"""设置报告模板名称"""
|
||||
"""Thiết lập tên template báo cáo."""
|
||||
self._ensure_group("basic")["report_template"] = template_name
|
||||
self.config.save_config()
|
||||
|
||||
def get_enable_user_card(self) -> bool:
|
||||
"""获取是否使用用户群名片"""
|
||||
"""Kiểm tra có dùng tên thành viên trong nhóm hay không."""
|
||||
return self._get_group("basic").get("enable_user_card", False)
|
||||
|
||||
def get_enable_analysis_reply(self) -> bool:
|
||||
"""获取是否在群分析完成后发送文本回复"""
|
||||
"""Kiểm tra có gửi phản hồi văn bản sau phân tích hay không."""
|
||||
return self._get_group("basic").get("enable_analysis_reply", False)
|
||||
|
||||
def set_enable_analysis_reply(self, enabled: bool):
|
||||
"""设置是否在群分析完成后发送文本回复"""
|
||||
"""Thiết lập gửi phản hồi văn bản sau phân tích."""
|
||||
self._ensure_group("basic")["enable_analysis_reply"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def get_show_report_caption(self) -> bool:
|
||||
"""获取是否发送 \"📊 每日群聊分析报告已生成\" 前缀文字。"""
|
||||
"""Kiểm tra có gửi caption báo cáo hay không."""
|
||||
return self._get_group("basic").get("show_report_caption", True)
|
||||
|
||||
def set_show_report_caption(self, enabled: bool):
|
||||
"""设置是否发送 \"📊 每日群聊分析报告已生成\" 前缀文字。"""
|
||||
"""Thiết lập gửi caption báo cáo."""
|
||||
self._ensure_group("basic")["show_report_caption"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def get_profile_display_mode(self) -> str:
|
||||
"""获取人格标签展示模式。"""
|
||||
"""Lấy chế độ hiển thị nhãn hồ sơ."""
|
||||
mode = str(self._get_group("basic").get("profile_display_mode", "mbti")).lower()
|
||||
if mode not in {"mbti", "sbti", "acgti"}:
|
||||
return "mbti"
|
||||
return mode
|
||||
|
||||
def get_profile_image_opacity(self) -> float:
|
||||
"""获取人格背景图透明度。"""
|
||||
"""Lấy độ trong suốt ảnh nền hồ sơ."""
|
||||
value = self._get_group("basic").get("profile_image_opacity", 0.12)
|
||||
try:
|
||||
return max(0.0, min(1.0, float(value)))
|
||||
@@ -707,7 +696,7 @@ class ConfigManager:
|
||||
return 0.12
|
||||
|
||||
def get_profile_image_size_mode(self) -> str:
|
||||
"""获取人格背景图尺寸模式。"""
|
||||
"""Lấy chế độ kích thước ảnh nền hồ sơ."""
|
||||
mode = str(
|
||||
self._get_group("basic").get("profile_image_size_mode", "contain")
|
||||
).lower()
|
||||
@@ -716,122 +705,122 @@ class ConfigManager:
|
||||
return mode
|
||||
|
||||
def get_profile_mapping_config(self) -> str:
|
||||
"""获取人格映射配置(JSON 文本)。"""
|
||||
"""Lấy cấu hình ánh xạ hồ sơ dưới dạng văn bản JSON."""
|
||||
return str(self._get_group("basic").get("profile_mapping_config", "")).strip()
|
||||
|
||||
# ========== 群文件/群相册上传配置 ==========
|
||||
# ========== Cấu hình upload tệp/album nhóm ==========
|
||||
|
||||
def get_enable_group_file_upload(self) -> bool:
|
||||
"""获取是否启用群文件上传"""
|
||||
"""Kiểm tra upload tệp nhóm có bật hay không."""
|
||||
return self._get_group("qq_group_upload").get("enable_group_file_upload", False)
|
||||
|
||||
def get_group_file_folder(self) -> str:
|
||||
"""获取群文件上传目录名,空字符串表示根目录"""
|
||||
"""Lấy tên thư mục upload tệp nhóm; chuỗi rỗng là thư mục gốc."""
|
||||
return self._get_group("qq_group_upload").get("group_file_folder", "")
|
||||
|
||||
def get_enable_group_album_upload(self) -> bool:
|
||||
"""获取是否启用群相册上传(仅 NapCat)"""
|
||||
"""Kiểm tra upload album nhóm có bật hay không, chỉ NapCat."""
|
||||
return self._get_group("qq_group_upload").get(
|
||||
"enable_group_album_upload", False
|
||||
)
|
||||
|
||||
def get_group_album_name(self) -> str:
|
||||
"""获取目标群相册名称,空字符串表示默认相册"""
|
||||
"""Lấy tên album đích; chuỗi rỗng là album mặc định."""
|
||||
return self._get_group("qq_group_upload").get("group_album_name", "")
|
||||
|
||||
def get_group_album_strict_mode(self) -> bool:
|
||||
"""获取群相册上传严格模式开关。"""
|
||||
"""Lấy trạng thái chế độ upload album nghiêm ngặt."""
|
||||
return bool(
|
||||
self._get_group("qq_group_upload").get("group_album_strict_mode", True)
|
||||
)
|
||||
|
||||
def set_group_album_strict_mode(self, enabled: bool):
|
||||
"""设置群相册上传严格模式"""
|
||||
"""Thiết lập chế độ upload album nghiêm ngặt."""
|
||||
self._ensure_group("qq_group_upload")["group_album_strict_mode"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
# ========== 增量分析配置 ==========
|
||||
# ========== Cấu hình phân tích gia tăng ==========
|
||||
|
||||
def get_incremental_enabled(self) -> bool:
|
||||
"""获取是否开启了增量分析(由名单状态决定)"""
|
||||
"""Kiểm tra phân tích gia tăng có bật theo trạng thái danh sách hay không."""
|
||||
mode = self.get_incremental_group_list_mode()
|
||||
lst = self.get_incremental_group_list()
|
||||
# 如果是白名单且不为空,或者是黑名单模式,则视为功能“开启”
|
||||
# Bật khi whitelist không rỗng hoặc đang ở chế độ blacklist.
|
||||
return (mode == "whitelist" and len(lst) > 0) or (mode == "blacklist")
|
||||
|
||||
def get_incremental_group_list_mode(self) -> str:
|
||||
"""获取增量分析名单模式 (whitelist/blacklist)"""
|
||||
"""Lấy chế độ danh sách phân tích gia tăng."""
|
||||
return self._get_group("incremental").get(
|
||||
"incremental_group_list_mode", "whitelist"
|
||||
)
|
||||
|
||||
def get_incremental_group_list(self) -> list[str]:
|
||||
"""获取增量分析群列表"""
|
||||
"""Lấy danh sách nhóm phân tích gia tăng."""
|
||||
return self._get_group("incremental").get("incremental_group_list", [])
|
||||
|
||||
def get_incremental_fallback_enabled(self) -> bool:
|
||||
"""获取增量分析失败回退到全量分析的开关(默认启用)"""
|
||||
"""Lấy trạng thái fallback sang phân tích đầy đủ khi gia tăng thất bại."""
|
||||
return self._get_group("incremental").get("incremental_fallback_enabled", True)
|
||||
|
||||
def get_incremental_report_immediately(self) -> bool:
|
||||
"""获取是否启用增量分析立即发送报告(调试用)"""
|
||||
"""Kiểm tra có gửi ngay báo cáo gia tăng hay không, dùng để debug."""
|
||||
return self._get_group("incremental").get(
|
||||
"incremental_report_immediately", False
|
||||
)
|
||||
|
||||
def set_incremental_report_immediately(self, enabled: bool):
|
||||
"""设置增量分析是否立即发送报告"""
|
||||
"""Thiết lập gửi ngay báo cáo phân tích gia tăng."""
|
||||
self._ensure_group("incremental")["incremental_report_immediately"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def get_incremental_interval_minutes(self) -> int:
|
||||
"""获取增量分析间隔(分钟)"""
|
||||
"""Lấy khoảng cách phân tích gia tăng, tính bằng phút."""
|
||||
return self._get_group("incremental").get("incremental_interval_minutes", 120)
|
||||
|
||||
def get_incremental_max_daily_analyses(self) -> int:
|
||||
"""获取每天最大增量分析次数"""
|
||||
"""Lấy số lần phân tích gia tăng tối đa mỗi ngày."""
|
||||
return self._get_group("incremental").get("incremental_max_daily_analyses", 8)
|
||||
|
||||
def get_incremental_safe_limit(self) -> int:
|
||||
"""获取单次增量分析的安全分析/同步上限 (Safe Count)"""
|
||||
"""Lấy giới hạn phân tích/đồng bộ an toàn mỗi batch gia tăng."""
|
||||
return self._get_group("incremental").get("incremental_safe_limit", 2000)
|
||||
|
||||
def get_incremental_min_messages(self) -> int:
|
||||
"""获取触发增量分析的最小消息数阈值"""
|
||||
"""Lấy ngưỡng tin nhắn tối thiểu để kích hoạt phân tích gia tăng."""
|
||||
return self._get_group("incremental").get("incremental_min_messages", 20)
|
||||
|
||||
def get_incremental_topics_per_batch(self) -> int:
|
||||
"""获取单次增量分析提取的最大话题数"""
|
||||
"""Lấy số chủ đề tối đa được trích xuất mỗi batch gia tăng."""
|
||||
return self._get_group("incremental").get("incremental_topics_per_batch", 3)
|
||||
|
||||
def get_incremental_quotes_per_batch(self) -> int:
|
||||
"""获取单次增量分析提取的最大金句数"""
|
||||
"""Lấy số trích dẫn tối đa được trích xuất mỗi batch gia tăng."""
|
||||
return self._get_group("incremental").get("incremental_quotes_per_batch", 3)
|
||||
|
||||
def get_incremental_active_start_hour(self) -> int:
|
||||
"""获取增量分析活跃时段起始小时(24小时制)"""
|
||||
"""Lấy giờ bắt đầu hoạt động gia tăng theo định dạng 24 giờ."""
|
||||
return self._get_group("incremental").get("incremental_active_start_hour", 8)
|
||||
|
||||
def get_incremental_active_end_hour(self) -> int:
|
||||
"""获取增量分析活跃时段结束小时(24小时制)"""
|
||||
"""Lấy giờ kết thúc hoạt động gia tăng theo định dạng 24 giờ."""
|
||||
return self._get_group("incremental").get("incremental_active_end_hour", 23)
|
||||
|
||||
def get_incremental_stagger_seconds(self) -> int:
|
||||
"""获取多群增量分析的交错间隔(秒),避免 API 压力"""
|
||||
"""Lấy khoảng cách tác vụ gia tăng nhiều nhóm để giảm tải API."""
|
||||
return self._get_group("incremental").get("incremental_stagger_seconds", 30)
|
||||
|
||||
def save_config(self):
|
||||
"""保存配置到AstrBot配置系统"""
|
||||
"""Lưu cấu hình vào hệ thống cấu hình AstrBot."""
|
||||
try:
|
||||
self.config.save_config()
|
||||
logger.info("配置已保存")
|
||||
logger.info("Đã lưu cấu hình")
|
||||
except Exception as e:
|
||||
logger.error(f"保存配置失败: {e}")
|
||||
logger.error(f"Lưu cấu hình thất bại: {e}")
|
||||
|
||||
def reload_config(self):
|
||||
"""重新加载配置"""
|
||||
"""Tải lại cấu hình."""
|
||||
try:
|
||||
logger.info("重新加载配置...")
|
||||
logger.info("配置重载完成")
|
||||
logger.info("Đang tải lại cấu hình...")
|
||||
logger.info("Đã tải lại cấu hình")
|
||||
except Exception as e:
|
||||
logger.error(f"重新加载配置失败: {e}")
|
||||
logger.error(f"Tải lại cấu hình thất bại: {e}")
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"""
|
||||
消息发送器 - 基础设施层
|
||||
提供高层消息发送接口,支持跨平台智能路由。
|
||||
Trình gửi tin nhắn thuộc tầng infrastructure.
|
||||
|
||||
Cung cấp giao diện gửi cấp cao với khả năng định tuyến đa nền tảng.
|
||||
"""
|
||||
|
||||
from ...utils.logger import logger
|
||||
|
||||
|
||||
class MessageSender:
|
||||
"""
|
||||
消息发送器
|
||||
封装了 PlatformAdapter 的底层调用,提供更高层的发送接口
|
||||
"""
|
||||
"""Đóng gói lệnh gọi PlatformAdapter và cung cấp giao diện gửi cấp cao."""
|
||||
|
||||
def __init__(self, bot_manager, config_manager):
|
||||
self.bot_manager = bot_manager
|
||||
@@ -19,10 +17,12 @@ class MessageSender:
|
||||
async def send_text(
|
||||
self, group_id: str, text: str, platform_id: str | None = None
|
||||
) -> bool:
|
||||
"""发送文本消息"""
|
||||
"""Gửi tin nhắn văn bản."""
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器")
|
||||
logger.error(
|
||||
f"[MessageSender] Không tìm thấy adapter cho nền tảng {platform_id}"
|
||||
)
|
||||
return False
|
||||
return await adapter.send_text(group_id, text)
|
||||
|
||||
@@ -33,10 +33,12 @@ class MessageSender:
|
||||
caption: str = "",
|
||||
platform_id: str | None = None,
|
||||
) -> bool:
|
||||
"""智能发送图片,支持自动选择适配器"""
|
||||
"""Gửi ảnh và tự động chọn adapter phù hợp."""
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器")
|
||||
logger.error(
|
||||
f"[MessageSender] Không tìm thấy adapter cho nền tảng {platform_id}"
|
||||
)
|
||||
return False
|
||||
return await adapter.send_image(group_id, image_url, caption)
|
||||
|
||||
@@ -47,33 +49,37 @@ class MessageSender:
|
||||
caption: str = "",
|
||||
platform_id: str | None = None,
|
||||
) -> bool:
|
||||
"""发送文件(HTML/PDF/其它文件)。支持可选 caption。"""
|
||||
"""Gửi tệp HTML, PDF hoặc loại khác với caption tuỳ chọn."""
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器")
|
||||
logger.error(
|
||||
f"[MessageSender] Không tìm thấy adapter cho nền tảng {platform_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 首先发送文件,本方法的返回值只代表文件是否发送成功。
|
||||
# Gửi tệp trước; kết quả phương thức chỉ phản ánh việc gửi tệp.
|
||||
file_sent = await adapter.send_file(group_id, file_path)
|
||||
|
||||
if not file_sent:
|
||||
# 适配器返回 False,表示文件未成功发送
|
||||
# Adapter trả False nghĩa là tệp chưa được gửi thành công.
|
||||
return False
|
||||
|
||||
# 文件已成功发送,下面的 caption 发送为尽力而为,不影响整体成功与否。
|
||||
# Sau khi gửi tệp, caption được gửi theo nỗ lực tối đa và không đổi kết quả.
|
||||
if caption:
|
||||
try:
|
||||
caption_sent = await adapter.send_text(group_id, f"{caption}")
|
||||
if not caption_sent:
|
||||
logger.warning(
|
||||
"[MessageSender] 文件已发送,但 caption 发送失败(适配器返回 False)"
|
||||
"[MessageSender] Đã gửi tệp nhưng gửi caption thất bại (adapter trả False)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MessageSender] 文件已发送,但 caption 发送异常: {e}")
|
||||
logger.warning(
|
||||
f"[MessageSender] Đã gửi tệp nhưng xảy ra lỗi khi gửi caption: {e}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def _get_available_platforms(self, group_id: str):
|
||||
"""获取可用的平台列表 (Helper for Dispatcher)"""
|
||||
# 简单实现:返回所有已加载的平台
|
||||
"""Lấy danh sách nền tảng khả dụng cho Dispatcher."""
|
||||
# Triển khai đơn giản: trả về mọi nền tảng đã nạp.
|
||||
return [(pid, None) for pid in self.bot_manager.get_platform_ids()]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
历史记录管理器模块 - 基础设施持久化层
|
||||
负责存储和查询群聊分析报告的摘要信息
|
||||
使用 AstrBot 的 put_kv_data/get_kv_data 实现
|
||||
Module quản lý lịch sử thuộc tầng persistence infrastructure.
|
||||
|
||||
Phụ trách lưu và truy vấn thông tin tóm tắt của báo cáo phân tích nhóm,
|
||||
sử dụng put_kv_data/get_kv_data của AstrBot.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -12,19 +13,18 @@ from ...utils.logger import logger
|
||||
|
||||
class HistoryManager:
|
||||
"""
|
||||
核心组件:历史分析存档管理器
|
||||
Thành phần cốt lõi: trình quản lý lưu trữ lịch sử phân tích.
|
||||
|
||||
该类负责将每日生成的群消息分析报告摘要持久化存储,并提供查询接口。
|
||||
底层基于 AstrBot 提供的 KV 存储能力(put_kv_data/get_kv_data),
|
||||
确保即使在 Bot 重启后也能回溯历史数据。
|
||||
Lưu bản tóm tắt báo cáo phân tích nhóm hằng ngày và cung cấp giao diện truy vấn.
|
||||
Dựa trên KV của AstrBot để có thể truy xuất dữ liệu sau khi bot khởi động lại.
|
||||
"""
|
||||
|
||||
def __init__(self, star_instance: Any):
|
||||
"""
|
||||
初始化历史记录管理器。
|
||||
Khởi tạo trình quản lý lịch sử.
|
||||
|
||||
Args:
|
||||
star_instance (Any): Star 插件实例,用于访问底层持久化引擎
|
||||
star_instance: Instance Star dùng để truy cập persistence engine.
|
||||
"""
|
||||
self.plugin = star_instance
|
||||
|
||||
@@ -36,18 +36,19 @@ class HistoryManager:
|
||||
time_str: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
序列化并存储一份分析报告摘要。
|
||||
Tuần tự hoá và lưu một bản tóm tắt báo cáo phân tích.
|
||||
|
||||
摘要包含:发言总量、人数、提取的主题摘要及生成时间,不包含完整的原始消息流。
|
||||
Bản tóm tắt gồm tổng tin nhắn, số thành viên, chủ đề và thời gian tạo;
|
||||
không chứa toàn bộ luồng tin nhắn gốc.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组 ID
|
||||
analysis_result (dict[str, Any]): 包含 statistics, topics, user_titles 的完整分析对象
|
||||
date_str (str, optional): 归档日期 (YYYY-MM-DD),缺省为当天
|
||||
time_str (str, optional): 归档时间点 (HH-MM),缺省为当前时刻
|
||||
group_id: ID nhóm.
|
||||
analysis_result: Đối tượng phân tích gồm statistics, topics và user_titles.
|
||||
date_str: Ngày lưu trữ (YYYY-MM-DD), mặc định là hôm nay.
|
||||
time_str: Thời điểm lưu (HH-MM), mặc định là hiện tại.
|
||||
|
||||
Returns:
|
||||
bool: 存储是否成功
|
||||
True nếu lưu thành công.
|
||||
"""
|
||||
try:
|
||||
now = datetime.datetime.now()
|
||||
@@ -56,10 +57,10 @@ class HistoryManager:
|
||||
if not time_str:
|
||||
time_str = now.strftime("%H-%M")
|
||||
|
||||
# 消解非法字符,确保 Key 兼容性
|
||||
# Loại ký tự không hợp lệ để key tương thích.
|
||||
time_str = time_str.replace(":", "-")
|
||||
|
||||
# 从分析结果中剥离非持久化字段,提取核心统计元数据
|
||||
# Loại trường không cần lưu và lấy metadata thống kê cốt lõi.
|
||||
stats = analysis_result.get("statistics")
|
||||
topics = analysis_result.get("topics", [])
|
||||
user_titles = analysis_result.get("user_titles", [])
|
||||
@@ -78,18 +79,18 @@ class HistoryManager:
|
||||
await self.plugin.put_kv_data(key, summary)
|
||||
|
||||
logger.info(
|
||||
f"已保存群 {group_id} 在 {date_str} {time_str} 的分析摘要到历史记录 (Key: {key})"
|
||||
f"Đã lưu tóm tắt phân tích của nhóm {group_id} lúc {date_str} {time_str} (Key: {key})"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"保存历史分析记录失败: {e}", exc_info=True)
|
||||
logger.error(f"Lưu bản ghi phân tích lịch sử thất bại: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def get_history(
|
||||
self, group_id: str, date_str: str, time_str: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
根据群组、日期和时间点检索一份历史摘要。
|
||||
Truy vấn một bản tóm tắt lịch sử theo nhóm, ngày và thời điểm.
|
||||
"""
|
||||
time_str = time_str.replace(":", "-")
|
||||
key = f"analysis_{group_id}_{date_str}_{time_str}"
|
||||
@@ -97,7 +98,7 @@ class HistoryManager:
|
||||
|
||||
async def has_history(self, group_id: str, date_str: str, time_str: str) -> bool:
|
||||
"""
|
||||
快速判定是否存在指定时间点的历史分析记录。
|
||||
Kiểm tra nhanh bản ghi lịch sử tại thời điểm chỉ định có tồn tại hay không.
|
||||
"""
|
||||
history = await self.get_history(group_id, date_str, time_str)
|
||||
return history is not None
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
历史仓库 - 存储分析历史的实现
|
||||
Repository lịch sử - triển khai lưu trữ lịch sử phân tích.
|
||||
|
||||
该模块提供分析结果和历史记录的持久化存储。
|
||||
它封装了现有的 history_manager 功能。
|
||||
Module cung cấp persistence cho kết quả phân tích và bản ghi lịch sử,
|
||||
đóng gói chức năng history_manager hiện có.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -15,33 +15,33 @@ from ...utils.logger import logger
|
||||
|
||||
class HistoryRepository:
|
||||
"""
|
||||
基础设施:历史仓库
|
||||
Infrastructure: repository lịch sử.
|
||||
|
||||
负责群聊分析历史记录的持久化存储与检索。目前使用本地 JSON 文件实现,
|
||||
保持了与旧版 `history_manager` 的数据格式兼容性。
|
||||
Lưu và truy xuất lịch sử phân tích nhóm bằng file JSON cục bộ,
|
||||
duy trì tương thích định dạng dữ liệu với history_manager cũ.
|
||||
|
||||
Attributes:
|
||||
data_dir (Path): 插件数据存储的总根目录
|
||||
history_dir (Path): 专门存放历史记录的子目录
|
||||
data_dir: Thư mục gốc lưu dữ liệu plugin.
|
||||
history_dir: Thư mục con lưu lịch sử.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: str):
|
||||
"""
|
||||
初始化历史仓库。
|
||||
Khởi tạo repository lịch sử.
|
||||
|
||||
Args:
|
||||
data_dir (str): 存储历史数据的基础目录路径
|
||||
data_dir: Đường dẫn thư mục cơ sở lưu dữ liệu lịch sử.
|
||||
"""
|
||||
self.data_dir = Path(data_dir)
|
||||
self.history_dir = self.data_dir / "history"
|
||||
self._ensure_directories()
|
||||
|
||||
def _ensure_directories(self) -> None:
|
||||
"""内部方法:确保所需的目录结构已创建。"""
|
||||
"""Đảm bảo cấu trúc thư mục cần thiết đã tồn tại."""
|
||||
self.history_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_group_history_path(self, group_id: str) -> Path:
|
||||
"""内部方法:获取特定群组的历史 JSON 文件路径。"""
|
||||
"""Lấy đường dẫn JSON lịch sử của nhóm."""
|
||||
return self.history_dir / f"group_{group_id}.json"
|
||||
|
||||
def save_analysis_result(
|
||||
@@ -51,52 +51,54 @@ class HistoryRepository:
|
||||
date_str: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
将分析结果保存到持久化存储。
|
||||
Lưu kết quả phân tích vào persistence.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组标识符
|
||||
result (dict[str, Any]): 包含统计、金句等信息的分析结果字典
|
||||
date_str (str, optional): 关联日期 (YYYY-MM-DD),默认为执行日
|
||||
group_id: Định danh nhóm.
|
||||
result: Dict kết quả gồm thống kê, trích dẫn và thông tin liên quan.
|
||||
date_str: Ngày liên quan (YYYY-MM-DD), mặc định là ngày thực thi.
|
||||
|
||||
Returns:
|
||||
bool: 保存成功返回 True,发生异常返回 False
|
||||
True nếu lưu thành công, False nếu xảy ra lỗi.
|
||||
"""
|
||||
try:
|
||||
date_str = date_str or datetime.now().strftime("%Y-%m-%d")
|
||||
history = self.load_group_history(group_id)
|
||||
|
||||
# 注入执行时间戳
|
||||
# Gắn timestamp thực thi.
|
||||
if "timestamp" not in result:
|
||||
result["timestamp"] = datetime.now().isoformat()
|
||||
|
||||
# 结构化存储:二级映射 {date -> result}
|
||||
# Lưu có cấu trúc: ánh xạ hai cấp {date -> result}.
|
||||
if "daily" not in history:
|
||||
history["daily"] = {}
|
||||
|
||||
history["daily"][date_str] = result
|
||||
history["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
# 原子写入(覆盖)
|
||||
# Ghi nguyên tử (ghi đè).
|
||||
history_path = self._get_group_history_path(group_id)
|
||||
with open(history_path, "w", encoding="utf-8") as f:
|
||||
json.dump(history, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.debug(f"已保存群 {group_id} 在 {date_str} 的历史分析记录")
|
||||
logger.debug(
|
||||
f"Đã lưu bản ghi phân tích lịch sử của nhóm {group_id} ngày {date_str}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存群 {group_id} 的历史记录失败: {e}")
|
||||
logger.error(f"Lưu lịch sử của nhóm {group_id} thất bại: {e}")
|
||||
return False
|
||||
|
||||
def load_group_history(self, group_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
加载特定群组的完整历史记录字典。
|
||||
Tải toàn bộ dict lịch sử của nhóm.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组标识符
|
||||
group_id: Định danh nhóm.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: 历史数据字典,若文件不存在则返回包含空 daily 结构的初始字典
|
||||
Dict lịch sử; nếu file chưa tồn tại thì trả cấu trúc daily rỗng.
|
||||
"""
|
||||
try:
|
||||
history_path = self._get_group_history_path(group_id)
|
||||
@@ -105,77 +107,77 @@ class HistoryRepository:
|
||||
return json.load(f)
|
||||
return {"daily": {}, "group_id": group_id}
|
||||
except Exception as e:
|
||||
logger.error(f"加载群 {group_id} 的历史记录失败: {e}")
|
||||
logger.error(f"Tải lịch sử của nhóm {group_id} thất bại: {e}")
|
||||
return {"daily": {}, "group_id": group_id}
|
||||
|
||||
def get_analysis_result(
|
||||
self, group_id: str, date_str: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
获取指定日期已存档的分析结果。
|
||||
Lấy kết quả phân tích đã lưu của ngày chỉ định.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组 ID
|
||||
date_str (str): 目标日期 (YYYY-MM-DD)
|
||||
group_id: ID nhóm.
|
||||
date_str: Ngày đích (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
Optional[dict[str, Any]]: 分析结果字典,未找到则返回 None
|
||||
Dict kết quả hoặc None nếu không tìm thấy.
|
||||
"""
|
||||
history = self.load_group_history(group_id)
|
||||
return history.get("daily", {}).get(date_str)
|
||||
|
||||
def get_recent_results(self, group_id: str, limit: int = 7) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取指定群组最近 N 次的分析结果列表。
|
||||
Lấy N kết quả phân tích gần nhất của nhóm.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组 ID
|
||||
limit (int): 最大返回条数
|
||||
group_id: ID nhóm.
|
||||
limit: Số kết quả tối đa.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: 按日期降序排列的结果列表
|
||||
Danh sách kết quả sắp xếp giảm dần theo ngày.
|
||||
"""
|
||||
history = self.load_group_history(group_id)
|
||||
daily = history.get("daily", {})
|
||||
|
||||
# 按日期字符串字典序降序排列(YYYY-MM-DD 天然有序)
|
||||
# Sắp xếp giảm dần theo chuỗi ngày (YYYY-MM-DD vốn có thứ tự).
|
||||
sorted_dates = sorted(daily.keys(), reverse=True)[:limit]
|
||||
return [daily[date] for date in sorted_dates]
|
||||
|
||||
def has_analysis_for_date(self, group_id: str, date_str: str) -> bool:
|
||||
"""
|
||||
检查指定日期是否已经生成过分析。
|
||||
Kiểm tra ngày chỉ định đã có phân tích hay chưa.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组 ID
|
||||
date_str (str): 日期字符串
|
||||
group_id: ID nhóm.
|
||||
date_str: Chuỗi ngày.
|
||||
|
||||
Returns:
|
||||
bool: 存在记录则返回 True
|
||||
True nếu bản ghi tồn tại.
|
||||
"""
|
||||
return self.get_analysis_result(group_id, date_str) is not None
|
||||
|
||||
def delete_old_history(self, group_id: str, keep_days: int = 30) -> int:
|
||||
"""
|
||||
自动清理超过天数限制的陈旧历史记录。
|
||||
Tự động xoá bản ghi lịch sử cũ vượt quá số ngày giữ lại.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组 ID
|
||||
keep_days (int): 保留的天数上限
|
||||
group_id: ID nhóm.
|
||||
keep_days: Số ngày tối đa cần giữ lại.
|
||||
|
||||
Returns:
|
||||
int: 实际删除的记录条数
|
||||
Số bản ghi thực tế đã xoá.
|
||||
"""
|
||||
try:
|
||||
history = self.load_group_history(group_id)
|
||||
daily = history.get("daily", {})
|
||||
|
||||
# 计算截止日期边界
|
||||
# Tính mốc ngày giới hạn.
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=keep_days)).strftime("%Y-%m-%d")
|
||||
|
||||
# 筛选已过期的日期
|
||||
# Lọc các ngày đã hết hạn.
|
||||
dates_to_delete = [date for date in daily.keys() if date < cutoff]
|
||||
|
||||
for date in dates_to_delete:
|
||||
@@ -190,23 +192,23 @@ class HistoryRepository:
|
||||
return len(dates_to_delete)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理群 {group_id} 的陈旧历史记录失败: {e}")
|
||||
logger.error(f"Xoá lịch sử cũ của nhóm {group_id} thất bại: {e}")
|
||||
return 0
|
||||
|
||||
def list_groups_with_history(self) -> list[str]:
|
||||
"""
|
||||
扫描文件系统,列出当前所有具有存档记录的群组 ID。
|
||||
Quét filesystem và liệt kê ID của các nhóm có bản ghi lưu trữ.
|
||||
|
||||
Returns:
|
||||
list[str]: 群组 ID 字符串列表
|
||||
Danh sách chuỗi ID nhóm.
|
||||
"""
|
||||
try:
|
||||
groups = []
|
||||
for file_path in self.history_dir.glob("group_*.json"):
|
||||
# 从文件名反推群组 ID (group_123.json -> 123)
|
||||
# Suy ra ID nhóm từ tên tệp (group_123.json -> 123).
|
||||
group_id = file_path.stem.replace("group_", "")
|
||||
groups.append(group_id)
|
||||
return groups
|
||||
except Exception as e:
|
||||
logger.error(f"列出历史记录群组失败: {e}")
|
||||
logger.error(f"Liệt kê nhóm có lịch sử thất bại: {e}")
|
||||
return []
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"""
|
||||
增量分析批次持久化存储 — 滑动窗口架构
|
||||
Persistence cho batch phân tích gia tăng theo kiến trúc cửa sổ trượt.
|
||||
|
||||
基于 AstrBot 的 put_kv_data/get_kv_data 实现按批次独立存储,
|
||||
支持按时间窗口查询批次、批次索引管理和过期批次清理。
|
||||
Dùng put_kv_data/get_kv_data của AstrBot để lưu riêng từng batch, hỗ trợ
|
||||
truy vấn theo cửa sổ thời gian, quản lý index và dọn batch hết hạn.
|
||||
|
||||
KV 键设计:
|
||||
- 批次索引: incr_batch_index_{group_id}
|
||||
值: [{"batch_id": "xxx", "timestamp": 1234567890.0}, ...]
|
||||
- 批次数据: incr_batch_{group_id}_{batch_id}
|
||||
值: IncrementalBatch.to_dict()
|
||||
- 最后分析消息时间戳: incr_last_ts_{group_id}
|
||||
值: int (epoch timestamp)
|
||||
Thiết kế key KV:
|
||||
- Index batch: incr_batch_index_{group_id}
|
||||
- Dữ liệu batch: incr_batch_{group_id}_{batch_id}
|
||||
- Timestamp tin nhắn phân tích cuối: incr_last_ts_{group_id}
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -21,59 +18,55 @@ from ...utils.logger import logger
|
||||
|
||||
class IncrementalStore:
|
||||
"""
|
||||
增量分析批次持久化仓储
|
||||
Repository persistence cho batch phân tích gia tăng.
|
||||
|
||||
核心职责:
|
||||
- save_batch: 保存单个批次数据并更新索引
|
||||
- query_batches: 按时间窗口查询批次列表
|
||||
- get_last_analyzed_timestamp / update_last_analyzed_timestamp: 跨批次去重
|
||||
- cleanup_old_batches: 清理过期批次
|
||||
- get_batch_count: 获取当前批次总数(状态查询用)
|
||||
Trách nhiệm chính: lưu và truy vấn batch, quản lý timestamp loại trùng,
|
||||
dọn batch hết hạn và cung cấp số lượng batch cho truy vấn trạng thái.
|
||||
"""
|
||||
|
||||
# KV 键前缀
|
||||
# Tiền tố key KV
|
||||
INDEX_PREFIX = "incr_batch_index"
|
||||
BATCH_PREFIX = "incr_batch"
|
||||
LAST_TS_PREFIX = "incr_last_ts"
|
||||
|
||||
def __init__(self, star_instance: Any):
|
||||
"""
|
||||
初始化批次持久化仓储。
|
||||
Khởi tạo repository persistence cho batch.
|
||||
|
||||
Args:
|
||||
star_instance: Star 插件实例,用于访问底层 KV 存储引擎
|
||||
star_instance: Instance Star dùng để truy cập KV storage.
|
||||
"""
|
||||
self.plugin = star_instance
|
||||
|
||||
# ================================================================
|
||||
# 键构建
|
||||
# Xây dựng key
|
||||
# ================================================================
|
||||
|
||||
def _index_key(self, group_id: str) -> str:
|
||||
"""构建批次索引键"""
|
||||
"""Tạo key index batch."""
|
||||
return f"{self.INDEX_PREFIX}_{group_id}"
|
||||
|
||||
def _batch_key(self, group_id: str, batch_id: str) -> str:
|
||||
"""构建单个批次数据键"""
|
||||
"""Tạo key dữ liệu cho một batch."""
|
||||
return f"{self.BATCH_PREFIX}_{group_id}_{batch_id}"
|
||||
|
||||
def _last_ts_key(self, group_id: str) -> str:
|
||||
"""构建最后分析消息时间戳键"""
|
||||
"""Tạo key timestamp của tin nhắn phân tích cuối."""
|
||||
return f"{self.LAST_TS_PREFIX}_{group_id}"
|
||||
|
||||
# ================================================================
|
||||
# 批次索引操作
|
||||
# Thao tác index batch
|
||||
# ================================================================
|
||||
|
||||
async def _get_index(self, group_id: str) -> list[dict]:
|
||||
"""
|
||||
获取指定群的批次索引列表。
|
||||
Lấy danh sách index batch của nhóm.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
group_id: ID nhóm.
|
||||
|
||||
Returns:
|
||||
list[dict]: 索引条目列表,每项包含 batch_id 和 timestamp
|
||||
Danh sách mục index, mỗi mục chứa batch_id và timestamp.
|
||||
"""
|
||||
key = self._index_key(group_id)
|
||||
try:
|
||||
@@ -82,53 +75,53 @@ class IncrementalStore:
|
||||
return []
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
logger.warning(f"批次索引数据格式异常 (Key: {key}): {type(data)}")
|
||||
logger.warning(
|
||||
f"Định dạng dữ liệu index batch bất thường (Key: {key}): {type(data)}"
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"读取批次索引失败 (Key: {key}): {e}", exc_info=True)
|
||||
logger.error(f"Đọc index batch thất bại (Key: {key}): {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def _save_index(self, group_id: str, index: list[dict]) -> None:
|
||||
"""
|
||||
保存批次索引列表。
|
||||
Lưu danh sách index batch.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
index: 索引条目列表
|
||||
group_id: ID nhóm.
|
||||
index: Danh sách mục index.
|
||||
"""
|
||||
key = self._index_key(group_id)
|
||||
try:
|
||||
await self.plugin.put_kv_data(key, index)
|
||||
except Exception as e:
|
||||
logger.error(f"保存批次索引失败 (Key: {key}): {e}", exc_info=True)
|
||||
logger.error(f"Lưu index batch thất bại (Key: {key}): {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
# ================================================================
|
||||
# 批次数据操作
|
||||
# Thao tác dữ liệu batch
|
||||
# ================================================================
|
||||
|
||||
async def save_batch(self, batch: IncrementalBatch) -> bool:
|
||||
"""
|
||||
保存单个批次数据并更新索引。
|
||||
Lưu dữ liệu của một batch và cập nhật index.
|
||||
|
||||
流程:
|
||||
1. 将批次数据写入独立 KV 键
|
||||
2. 将批次元数据(batch_id + timestamp)追加到索引
|
||||
Ghi dữ liệu vào key KV riêng rồi thêm metadata batch vào index.
|
||||
|
||||
Args:
|
||||
batch: 要保存的增量分析批次
|
||||
batch: Batch phân tích gia tăng cần lưu.
|
||||
|
||||
Returns:
|
||||
bool: 保存是否成功
|
||||
True nếu lưu thành công.
|
||||
"""
|
||||
group_id = batch.group_id
|
||||
batch_key = self._batch_key(group_id, batch.batch_id)
|
||||
|
||||
try:
|
||||
# 1. 保存批次数据
|
||||
# 1. Lưu dữ liệu batch
|
||||
await self.plugin.put_kv_data(batch_key, batch.to_dict())
|
||||
|
||||
# 2. 更新索引
|
||||
# 2. Cập nhật index
|
||||
index = await self._get_index(group_id)
|
||||
index.append(
|
||||
{
|
||||
@@ -139,13 +132,13 @@ class IncrementalStore:
|
||||
await self._save_index(group_id, index)
|
||||
|
||||
logger.debug(
|
||||
f"已保存批次 {batch.batch_id[:8]}... "
|
||||
f"(群 {group_id}, 消息数={batch.messages_count})"
|
||||
f"Đã lưu batch {batch.batch_id[:8]}... "
|
||||
f"(nhóm {group_id}, tin nhắn={batch.messages_count})"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"保存批次失败 (群 {group_id}, 批次 {batch.batch_id[:8]}...): {e}",
|
||||
f"Lưu batch thất bại (nhóm {group_id}, batch {batch.batch_id[:8]}...): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
@@ -157,29 +150,29 @@ class IncrementalStore:
|
||||
window_end: float,
|
||||
) -> list[IncrementalBatch]:
|
||||
"""
|
||||
按时间窗口查询批次列表。
|
||||
Truy vấn danh sách batch theo cửa sổ thời gian.
|
||||
|
||||
从索引中筛选时间戳落在 [window_start, window_end] 范围内的批次,
|
||||
逐个加载完整批次数据。
|
||||
Lọc batch có timestamp trong ``[window_start, window_end]`` từ index
|
||||
rồi tải toàn bộ dữ liệu từng batch.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
window_start: 窗口起始时间戳(epoch)
|
||||
window_end: 窗口结束时间戳(epoch)
|
||||
group_id: ID nhóm.
|
||||
window_start: Epoch timestamp bắt đầu cửa sổ.
|
||||
window_end: Epoch timestamp kết thúc cửa sổ.
|
||||
|
||||
Returns:
|
||||
list[IncrementalBatch]: 符合窗口范围的批次列表,按时间戳升序
|
||||
Danh sách batch trong cửa sổ, tăng dần theo timestamp.
|
||||
"""
|
||||
index = await self._get_index(group_id)
|
||||
|
||||
# 筛选在窗口范围内的批次
|
||||
# Lọc batch trong phạm vi cửa sổ.
|
||||
matching_entries = [
|
||||
entry
|
||||
for entry in index
|
||||
if window_start <= entry.get("timestamp", 0) <= window_end
|
||||
]
|
||||
|
||||
# 按时间戳升序排列
|
||||
# Sắp xếp tăng dần theo timestamp.
|
||||
matching_entries.sort(key=lambda x: x.get("timestamp", 0))
|
||||
|
||||
batches: list[IncrementalBatch] = []
|
||||
@@ -196,89 +189,94 @@ class IncrementalStore:
|
||||
batches.append(batch)
|
||||
else:
|
||||
logger.warning(
|
||||
f"批次数据缺失 (群 {group_id}, 批次 {batch_id[:8]}...)"
|
||||
f"Thiếu dữ liệu batch (nhóm {group_id}, batch {batch_id[:8]}...)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"加载批次数据失败 (群 {group_id}, 批次 {batch_id[:8]}...): {e}",
|
||||
f"Tải dữ liệu batch thất bại (nhóm {group_id}, batch {batch_id[:8]}...): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"窗口查询完成: 群 {group_id}, "
|
||||
f"窗口 [{window_start:.0f}, {window_end:.0f}], "
|
||||
f"匹配 {len(batches)}/{len(index)} 个批次"
|
||||
f"Hoàn tất truy vấn cửa sổ: nhóm {group_id}, "
|
||||
f"cửa sổ [{window_start:.0f}, {window_end:.0f}], "
|
||||
f"khớp {len(batches)}/{len(index)} batch"
|
||||
)
|
||||
|
||||
return batches
|
||||
|
||||
# ================================================================
|
||||
# 最后分析消息时间戳(跨批次去重用)
|
||||
# Timestamp tin nhắn phân tích cuối để loại trùng giữa các batch
|
||||
# ================================================================
|
||||
|
||||
async def get_last_analyzed_timestamp(self, group_id: str) -> int:
|
||||
"""
|
||||
获取指定群的最后分析消息时间戳。
|
||||
Lấy timestamp tin nhắn phân tích cuối của nhóm.
|
||||
|
||||
用于增量分析时过滤已分析过的消息。
|
||||
Dùng để lọc tin nhắn đã phân tích trong chế độ gia tăng.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
group_id: ID nhóm.
|
||||
|
||||
Returns:
|
||||
int: 最后分析消息的 epoch 时间戳,不存在则返回 0
|
||||
Epoch timestamp cuối hoặc 0 nếu chưa có.
|
||||
"""
|
||||
key = self._last_ts_key(group_id)
|
||||
try:
|
||||
data = await self.plugin.get_kv_data(key, 0)
|
||||
return int(data) if data else 0
|
||||
except Exception as e:
|
||||
logger.error(f"读取最后分析时间戳失败 (Key: {key}): {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Đọc timestamp phân tích cuối thất bại (Key: {key}): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
async def update_last_analyzed_timestamp(
|
||||
self, group_id: str, timestamp: int
|
||||
) -> None:
|
||||
"""
|
||||
更新指定群的最后分析消息时间戳。
|
||||
Cập nhật timestamp tin nhắn phân tích cuối của nhóm.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
timestamp: 最后分析消息的 epoch 时间戳
|
||||
group_id: ID nhóm.
|
||||
timestamp: Epoch timestamp của tin nhắn phân tích cuối.
|
||||
"""
|
||||
key = self._last_ts_key(group_id)
|
||||
try:
|
||||
await self.plugin.put_kv_data(key, timestamp)
|
||||
logger.debug(f"更新最后分析时间戳: 群 {group_id}, ts={timestamp}")
|
||||
logger.debug(
|
||||
f"Đã cập nhật timestamp phân tích cuối: nhóm {group_id}, ts={timestamp}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"更新最后分析时间戳失败 (Key: {key}): {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Cập nhật timestamp phân tích cuối thất bại (Key: {key}): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
# ================================================================
|
||||
# 过期批次清理
|
||||
# Dọn batch hết hạn
|
||||
# ================================================================
|
||||
|
||||
async def cleanup_old_batches(self, group_id: str, before_timestamp: float) -> int:
|
||||
"""
|
||||
清理指定群中早于给定时间戳的所有批次。
|
||||
Xoá mọi batch của nhóm cũ hơn timestamp chỉ định.
|
||||
|
||||
流程:
|
||||
1. 从索引中分离出过期条目和保留条目
|
||||
2. 逐个删除过期批次的 KV 数据
|
||||
3. 用保留条目覆盖索引
|
||||
Tách mục hết hạn và còn giữ, xoá KV của batch hết hạn rồi ghi lại index.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
before_timestamp: 清理此时间戳之前的所有批次
|
||||
group_id: ID nhóm.
|
||||
before_timestamp: Xoá mọi batch trước timestamp này.
|
||||
|
||||
Returns:
|
||||
int: 已清理的批次数量
|
||||
Số batch đã xoá.
|
||||
"""
|
||||
index = await self._get_index(group_id)
|
||||
if not index:
|
||||
return 0
|
||||
|
||||
# 分离过期和保留
|
||||
# Tách mục hết hạn và mục cần giữ.
|
||||
expired = []
|
||||
retained = []
|
||||
for entry in index:
|
||||
@@ -290,7 +288,7 @@ class IncrementalStore:
|
||||
if not expired:
|
||||
return 0
|
||||
|
||||
# 删除过期批次数据
|
||||
# Xoá dữ liệu batch hết hạn.
|
||||
deleted_count = 0
|
||||
for entry in expired:
|
||||
batch_id = entry.get("batch_id", "")
|
||||
@@ -302,50 +300,50 @@ class IncrementalStore:
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"删除过期批次失败 (群 {group_id}, 批次 {batch_id[:8]}...): {e}",
|
||||
f"Xoá batch hết hạn thất bại (nhóm {group_id}, batch {batch_id[:8]}...): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 更新索引(仅保留未过期条目)
|
||||
# Cập nhật index, chỉ giữ mục chưa hết hạn.
|
||||
await self._save_index(group_id, retained)
|
||||
|
||||
logger.info(
|
||||
f"清理过期批次: 群 {group_id}, "
|
||||
f"删除 {deleted_count} 个, 保留 {len(retained)} 个"
|
||||
f"Đã dọn batch hết hạn: nhóm {group_id}, "
|
||||
f"xoá {deleted_count}, giữ {len(retained)}"
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
# ================================================================
|
||||
# 状态查询
|
||||
# Truy vấn trạng thái
|
||||
# ================================================================
|
||||
|
||||
async def get_batch_count(self, group_id: str) -> int:
|
||||
"""
|
||||
获取指定群的当前批次总数。
|
||||
Lấy tổng số batch hiện tại của nhóm.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
group_id: ID nhóm.
|
||||
|
||||
Returns:
|
||||
int: 批次总数
|
||||
Tổng số batch.
|
||||
"""
|
||||
index = await self._get_index(group_id)
|
||||
return len(index)
|
||||
|
||||
async def get_all_batch_summaries(self, group_id: str) -> list[dict]:
|
||||
"""
|
||||
获取指定群所有批次的摘要信息(不加载完整数据)。
|
||||
Lấy tóm tắt mọi batch của nhóm mà không tải toàn bộ dữ liệu.
|
||||
|
||||
用于状态查询命令展示批次概览。
|
||||
Dùng để hiển thị tổng quan batch trong command trạng thái.
|
||||
|
||||
Args:
|
||||
group_id: 群组 ID
|
||||
group_id: ID nhóm.
|
||||
|
||||
Returns:
|
||||
list[dict]: 批次摘要列表,按时间升序
|
||||
Danh sách tóm tắt batch tăng dần theo thời gian.
|
||||
"""
|
||||
index = await self._get_index(group_id)
|
||||
# 按时间戳升序排列
|
||||
# Sắp xếp tăng dần theo timestamp.
|
||||
index.sort(key=lambda x: x.get("timestamp", 0))
|
||||
return index
|
||||
|
||||
@@ -6,10 +6,10 @@ from astrbot.api.star import Star
|
||||
|
||||
class TelegramGroupRegistry:
|
||||
"""
|
||||
Telegram 群组/话题注册表
|
||||
Registry nhóm và chủ đề Telegram.
|
||||
|
||||
负责管理 Telegram 的已见群组和话题列表,用于在无法通过 API 获取群列表时提供回退支持。
|
||||
数据存储在 AstrBot 的 KV 存储中。
|
||||
Quản lý danh sách nhóm và chủ đề Telegram đã thấy, dùng làm fallback khi
|
||||
API không thể cung cấp danh sách nhóm. Dữ liệu được lưu trong KV của AstrBot.
|
||||
"""
|
||||
|
||||
_KV_KEY = "telegram_seen_groups_v1"
|
||||
@@ -26,7 +26,7 @@ class TelegramGroupRegistry:
|
||||
sender_name: str,
|
||||
event_message_id: str,
|
||||
) -> None:
|
||||
"""更新 Telegram 已见群/话题注册表(KV)。"""
|
||||
"""Cập nhật registry nhóm và chủ đề Telegram đã thấy trong KV."""
|
||||
async with self._lock:
|
||||
registry = await self.plugin.get_kv_data(self._KV_KEY, {})
|
||||
if not isinstance(registry, dict):
|
||||
@@ -70,7 +70,7 @@ class TelegramGroupRegistry:
|
||||
await self.plugin.put_kv_data(self._KV_KEY, registry)
|
||||
|
||||
async def get_all_group_ids(self, platform_id: str | None = None) -> list[str]:
|
||||
"""读取 Telegram 已见群/话题列表。"""
|
||||
"""Đọc danh sách nhóm và chủ đề Telegram đã thấy."""
|
||||
async with self._lock:
|
||||
registry = await self.plugin.get_kv_data(self._KV_KEY, {})
|
||||
if not isinstance(registry, dict):
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""
|
||||
Discord 平台适配器
|
||||
Adapter nền tảng Discord.
|
||||
|
||||
为 Discord 平台提供消息获取、发送和群组管理功能。
|
||||
这是一个骨架实现,展示如何为新平台创建适配器。
|
||||
|
||||
注意:Discord 的消息获取需要使用 Discord API,
|
||||
具体实现取决于 AstrBot 的 Discord 集成方式。
|
||||
Cung cấp khả năng lấy và gửi tin nhắn cùng quản lý nhóm. Việc lấy tin nhắn
|
||||
phụ thuộc cách AstrBot tích hợp Discord API.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -33,47 +30,47 @@ from ..base import PlatformAdapter
|
||||
|
||||
class DiscordAdapter(PlatformAdapter):
|
||||
"""
|
||||
具体实现:Discord 平台适配器
|
||||
Triển khai adapter nền tảng Discord.
|
||||
|
||||
利用 Discord API 为群组(频道)提供消息获取、发送及基础元数据查询功能。
|
||||
由于 Discord 的高度异步特性和复杂的权限模型,该适配器集成了懒加载客户端和多级频道查询机制。
|
||||
Dùng Discord API để lấy/gửi tin nhắn và truy vấn metadata cơ bản cho kênh.
|
||||
Adapter tích hợp lazy loading client và cơ chế truy vấn kênh nhiều cấp.
|
||||
|
||||
Attributes:
|
||||
bot_user_id (str): 机器人自身的 Discord 用户 ID
|
||||
bot_user_id: ID Discord của bot.
|
||||
"""
|
||||
|
||||
def __init__(self, bot_instance: Any, config: dict | None = None):
|
||||
"""
|
||||
初始化 Discord 适配器。
|
||||
Khởi tạo adapter Discord.
|
||||
|
||||
Args:
|
||||
bot_instance (Any): 宿主机器人实例
|
||||
config (dict, optional): 配置项,用于提取机器人自身的 Discord ID
|
||||
bot_instance: Instance bot chủ quản.
|
||||
config: Cấu hình dùng để lấy ID Discord của bot.
|
||||
"""
|
||||
super().__init__(bot_instance, config)
|
||||
# 机器人自己的用户 ID,用于消息过滤(避免分析博取回复)
|
||||
# ID của bot dùng để lọc tin nhắn do bot gửi.
|
||||
self.bot_user_id = str(config.get("bot_user_id", "")) if config else ""
|
||||
|
||||
# 缓存 Discord 客户端(Lazy Loading)
|
||||
# Cache Discord client theo lazy loading.
|
||||
self._cached_client = None
|
||||
|
||||
@property
|
||||
def _discord_client(self) -> Any:
|
||||
"""
|
||||
内部属性:获取实际的 Discord 客户端实例。
|
||||
Lấy instance Discord client thực tế.
|
||||
|
||||
具备懒加载和自动身份嗅探功能。
|
||||
Hỗ trợ lazy loading và tự nhận diện client.
|
||||
|
||||
Returns:
|
||||
Any: Discord Client 对象
|
||||
Đối tượng Discord Client.
|
||||
"""
|
||||
if self._cached_client:
|
||||
return self._cached_client
|
||||
|
||||
# 执行路径探测逻辑,兼容不同版本的 AstrBot 宿主结构
|
||||
# Dò đường dẫn để tương thích nhiều cấu trúc AstrBot.
|
||||
self._cached_client = self._get_discord_client()
|
||||
|
||||
# 兜底:尝试从客户端连接状态中补全机器人 ID
|
||||
# Fallback: lấy ID bot từ trạng thái kết nối client.
|
||||
if not self.bot_user_id and self._cached_client:
|
||||
if hasattr(self._cached_client, "user") and self._cached_client.user:
|
||||
self.bot_user_id = str(self._cached_client.user.id)
|
||||
@@ -81,27 +78,27 @@ class DiscordAdapter(PlatformAdapter):
|
||||
return self._cached_client
|
||||
|
||||
def _get_discord_client(self) -> Any:
|
||||
"""内部方法:通过多级探测从 bot_instance 中提取 Discord SDK 客户端。"""
|
||||
# 路径 A:bot 本身就是 Client (如小型集成)
|
||||
"""Dò nhiều cấp để lấy Discord SDK client từ bot_instance."""
|
||||
# Đường A: bot chính là Client.
|
||||
if hasattr(self.bot, "get_channel"):
|
||||
return self.bot
|
||||
# 路径 B:bot 是包装器,client 在标准成员变量中
|
||||
# Đường B: bot là wrapper và client nằm trong thuộc tính chuẩn.
|
||||
if hasattr(self.bot, "client"):
|
||||
return self.bot.client
|
||||
# 路径 C:其他常见私有属性名
|
||||
# Đường C: các tên thuộc tính riêng phổ biến khác.
|
||||
for attr in ("_client", "discord_client", "_discord_client"):
|
||||
if hasattr(self.bot, attr):
|
||||
client = getattr(self.bot, attr)
|
||||
if hasattr(client, "get_channel"):
|
||||
return client
|
||||
logger.warning(f"无法从 {type(self.bot).__name__} 中提取 Discord 客户端实例")
|
||||
logger.warning(f"Không thể lấy Discord client từ {type(self.bot).__name__}")
|
||||
return None
|
||||
|
||||
def _init_capabilities(self) -> PlatformCapabilities:
|
||||
"""返回预定义的 Discord 平台能力集。"""
|
||||
"""Trả về bộ năng lực Discord được định nghĩa sẵn."""
|
||||
return DISCORD_CAPABILITIES
|
||||
|
||||
# ==================== IMessageRepository 实现 ====================
|
||||
# ==================== Triển khai IMessageRepository ====================
|
||||
|
||||
async def fetch_messages(
|
||||
self,
|
||||
@@ -112,36 +109,38 @@ class DiscordAdapter(PlatformAdapter):
|
||||
since_ts: int | None = None,
|
||||
) -> list[UnifiedMessage]:
|
||||
"""
|
||||
从 Discord 频道异步拉取历史消息记录。
|
||||
Lấy bất đồng bộ lịch sử tin nhắn từ kênh Discord.
|
||||
|
||||
Args:
|
||||
group_id (str): Discord 频道 (Channel) ID
|
||||
days (int): 查询天数范围
|
||||
max_count (int): 最大拉取消息数量上限
|
||||
before_id (str, optional): 锚点消息 ID,从此之前开始拉取
|
||||
group_id: ID kênh Discord.
|
||||
days: Phạm vi số ngày truy vấn.
|
||||
max_count: Số tin nhắn tối đa.
|
||||
before_id: ID tin nhắn mốc; lấy các tin trước đó.
|
||||
|
||||
Returns:
|
||||
list[UnifiedMessage]: 统一格式的消息对象列表
|
||||
Danh sách tin nhắn định dạng thống nhất.
|
||||
"""
|
||||
if not discord:
|
||||
logger.error("未找到 Discord 模块 (py-cord),无法拉取历史消息。")
|
||||
logger.error(
|
||||
"Không tìm thấy module Discord (py-cord), không thể lấy lịch sử"
|
||||
)
|
||||
return []
|
||||
|
||||
try:
|
||||
channel_id = int(group_id)
|
||||
# 先从缓存尝试获取频道
|
||||
# Thử lấy kênh từ cache trước.
|
||||
channel = self._discord_client.get_channel(channel_id)
|
||||
if not channel:
|
||||
# 缓存未命中则通过网络 fetch
|
||||
# Nếu cache miss thì fetch qua mạng.
|
||||
try:
|
||||
channel = await self._discord_client.fetch_channel(channel_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"拉取 Discord 频道 {group_id} 失败: {e}")
|
||||
logger.debug(f"Lấy kênh Discord {group_id} thất bại: {e}")
|
||||
return []
|
||||
|
||||
# 验证权限:确保支持历史消息流
|
||||
# Xác thực hỗ trợ truy cập lịch sử.
|
||||
if not hasattr(channel, "history"):
|
||||
logger.warning(f"频道 {group_id} 不支持历史消息访问。")
|
||||
logger.warning(f"Kênh {group_id} không hỗ trợ truy cập lịch sử")
|
||||
return []
|
||||
|
||||
if since_ts and since_ts > 0:
|
||||
@@ -152,18 +151,18 @@ class DiscordAdapter(PlatformAdapter):
|
||||
|
||||
messages = []
|
||||
|
||||
# 构建 Discord SDK 的 history 查询参数
|
||||
# Xây dựng tham số truy vấn history cho Discord SDK.
|
||||
history_kwargs = {"limit": max_count, "after": start_time}
|
||||
if before_id:
|
||||
try:
|
||||
# 使用 Snowflake ID 指向特定消息
|
||||
# Dùng Snowflake ID để trỏ tới tin nhắn cụ thể.
|
||||
history_kwargs["before"] = discord.Object(id=int(before_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 消息迭代处理
|
||||
# Duyệt và xử lý tin nhắn.
|
||||
async for msg in channel.history(**history_kwargs):
|
||||
# 排除机器人自身发布的消息
|
||||
# Loại tin nhắn do chính bot gửi.
|
||||
if self.bot_user_id and str(msg.author.id) == self.bot_user_id:
|
||||
continue
|
||||
|
||||
@@ -171,7 +170,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
if unified:
|
||||
messages.append(unified)
|
||||
|
||||
# 排序回升序(SDK 通常返回降序)
|
||||
# Sắp xếp tăng dần vì SDK thường trả về giảm dần.
|
||||
messages.sort(key=lambda m: m.timestamp)
|
||||
return messages
|
||||
|
||||
@@ -180,17 +179,17 @@ class DiscordAdapter(PlatformAdapter):
|
||||
return []
|
||||
|
||||
def _convert_message(self, raw_msg: Any, group_id: str) -> UnifiedMessage | None:
|
||||
"""内部方法:将 `discord.Message` 对象转换为统一的 `UnifiedMessage`。"""
|
||||
"""Chuyển ``discord.Message`` thành ``UnifiedMessage``."""
|
||||
try:
|
||||
contents = []
|
||||
|
||||
# 1. 基础文本
|
||||
# 1. Văn bản cơ bản.
|
||||
if raw_msg.content:
|
||||
contents.append(
|
||||
MessageContent(type=MessageContentType.TEXT, text=raw_msg.content)
|
||||
)
|
||||
|
||||
# 2. 附件处理 (图片/视频/语音/普通文件)
|
||||
# 2. Xử lý tệp đính kèm: ảnh, video, âm thanh và tệp thường.
|
||||
for attachment in raw_msg.attachments:
|
||||
content_type = attachment.content_type or ""
|
||||
if content_type.startswith("image/"):
|
||||
@@ -223,7 +222,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
)
|
||||
)
|
||||
|
||||
# 3. 嵌入内容处理 (部分 Embed 可能包含富文本描述)
|
||||
# 3. Xử lý nội dung embed có mô tả rich text.
|
||||
for embed in raw_msg.embeds:
|
||||
if embed.image:
|
||||
contents.append(
|
||||
@@ -239,12 +238,12 @@ class DiscordAdapter(PlatformAdapter):
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 贴纸处理 (Stickers)
|
||||
# 4. Xử lý sticker.
|
||||
if raw_msg.stickers:
|
||||
for sticker in raw_msg.stickers:
|
||||
contents.append(
|
||||
MessageContent(
|
||||
type=MessageContentType.IMAGE, # 贴纸在逻辑上按图片处理
|
||||
type=MessageContentType.IMAGE, # Xử lý sticker như ảnh.
|
||||
url=sticker.url,
|
||||
raw_data={
|
||||
"sticker_id": str(sticker.id),
|
||||
@@ -253,7 +252,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
)
|
||||
)
|
||||
|
||||
# 确定发送者的显示名称(服务器昵称 > 全局名称 > 用户名)
|
||||
# Tên hiển thị: biệt danh server > tên toàn cục > username.
|
||||
sender_card = None
|
||||
if hasattr(raw_msg.author, "nick") and raw_msg.author.nick:
|
||||
sender_card = raw_msg.author.nick
|
||||
@@ -275,11 +274,11 @@ class DiscordAdapter(PlatformAdapter):
|
||||
else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Discord 消息转换错误: {e}")
|
||||
logger.debug(f"Lỗi chuyển đổi tin nhắn Discord: {e}")
|
||||
return None
|
||||
|
||||
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||
"""将统一格式降级转换为 OneBot 风格的字典,以适配下游组件。"""
|
||||
"""Chuyển định dạng thống nhất thành dict kiểu OneBot cho tầng sau."""
|
||||
raw_messages = []
|
||||
for msg in messages:
|
||||
raw_msg = {
|
||||
@@ -292,7 +291,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
"card": msg.sender_card,
|
||||
},
|
||||
"message": [],
|
||||
"user_id": msg.sender_id, # 后向兼容
|
||||
"user_id": msg.sender_id, # Tương thích ngược.
|
||||
}
|
||||
|
||||
for content in msg.contents:
|
||||
@@ -323,7 +322,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
raw_messages.append(raw_msg)
|
||||
return raw_messages
|
||||
|
||||
# ==================== IMessageSender 实现 ====================
|
||||
# ==================== Triển khai IMessageSender ====================
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
@@ -332,15 +331,15 @@ class DiscordAdapter(PlatformAdapter):
|
||||
reply_to: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
向 Discord 频道发送文本消息。
|
||||
Gửi tin nhắn văn bản tới kênh Discord.
|
||||
|
||||
Args:
|
||||
group_id (str): 频道 ID
|
||||
text (str): 文本内容
|
||||
reply_to (str, optional): 引用的消息 ID
|
||||
group_id: ID kênh.
|
||||
text: Nội dung văn bản.
|
||||
reply_to: ID tin nhắn được trả lời.
|
||||
|
||||
Returns:
|
||||
bool: 是否发送成功
|
||||
True nếu gửi thành công.
|
||||
"""
|
||||
if not discord:
|
||||
return False
|
||||
@@ -366,7 +365,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
await channel.send(content=text, reference=reference)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Discord 文本发送失败: {e}")
|
||||
logger.error(f"Gửi văn bản Discord thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def send_image(
|
||||
@@ -376,17 +375,17 @@ class DiscordAdapter(PlatformAdapter):
|
||||
caption: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
向 Discord 频道异步发送图片。
|
||||
Gửi ảnh bất đồng bộ tới kênh Discord.
|
||||
|
||||
对于远程 URL,会先下载到内存再通过 Discord API 发送。
|
||||
URL từ xa được tải vào bộ nhớ trước khi gửi qua Discord API.
|
||||
|
||||
Args:
|
||||
group_id (str): 频道 ID
|
||||
image_path (str): 本地路径或 http URL
|
||||
caption (str): 可选说明文字
|
||||
group_id: ID kênh.
|
||||
image_path: Đường dẫn cục bộ hoặc URL HTTP.
|
||||
caption: Chú thích tuỳ chọn.
|
||||
|
||||
Returns:
|
||||
bool: 是否发送成功
|
||||
True nếu gửi thành công.
|
||||
"""
|
||||
if not discord:
|
||||
return False
|
||||
@@ -402,7 +401,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
|
||||
file_to_send = None
|
||||
if image_path.startswith("base64://"):
|
||||
# Base64 图片:解码 -> 内存 Object -> Discord
|
||||
# Ảnh Base64: decode -> object trong bộ nhớ -> Discord.
|
||||
import base64 # Fix: Ensure base64 is imported
|
||||
from io import BytesIO
|
||||
|
||||
@@ -413,11 +412,11 @@ class DiscordAdapter(PlatformAdapter):
|
||||
BytesIO(image_bytes), filename="daily_report_image.png"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Discord Base64 图片解码失败: {e}")
|
||||
logger.error(f"Giải mã ảnh Base64 Discord thất bại: {e}")
|
||||
return False
|
||||
|
||||
elif image_path.startswith(("http://", "https://")):
|
||||
# 远程图片:下载 -> 内存 Object -> Discord
|
||||
# Ảnh từ xa: tải -> object trong bộ nhớ -> Discord.
|
||||
from io import BytesIO
|
||||
|
||||
import aiohttp
|
||||
@@ -429,7 +428,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.read()
|
||||
# 尽量保留原始后缀
|
||||
# Cố gắng giữ phần mở rộng gốc.
|
||||
filename = image_path.split("/")[-1].split("?")[0]
|
||||
if not filename.lower().endswith(
|
||||
(".png", ".jpg", ".jpeg", ".gif", ".webp")
|
||||
@@ -440,7 +439,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
BytesIO(data), filename=filename
|
||||
)
|
||||
else:
|
||||
# 兜底:如果下载失败,直接发 URL 给 Discord 尝试自动解析
|
||||
# Fallback: gửi URL để Discord tự phân tích.
|
||||
content = (
|
||||
f"{caption}\n{image_path}"
|
||||
if caption
|
||||
@@ -450,13 +449,13 @@ class DiscordAdapter(PlatformAdapter):
|
||||
return True
|
||||
except Exception as de:
|
||||
logger.warning(
|
||||
f"Discord 远程图片下载失败: {de},将回退为发送 URL。"
|
||||
f"Tải ảnh Discord từ xa thất bại: {de}; chuyển sang gửi URL."
|
||||
)
|
||||
content = f"{caption}\n{image_path}" if caption else image_path
|
||||
await channel.send(content=content)
|
||||
return True
|
||||
else:
|
||||
# 本地图片
|
||||
# Ảnh cục bộ.
|
||||
file_to_send = discord.File(image_path)
|
||||
|
||||
if file_to_send:
|
||||
@@ -464,7 +463,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Discord 图片发送失败: {e}")
|
||||
logger.error(f"Gửi ảnh Discord thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def send_file(
|
||||
@@ -473,7 +472,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
file_path: str,
|
||||
filename: str | None = None,
|
||||
) -> bool:
|
||||
"""向 Discord 频道上传任意文件。"""
|
||||
"""Tải tệp bất kỳ lên kênh Discord."""
|
||||
if not discord:
|
||||
return False
|
||||
|
||||
@@ -490,7 +489,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
await channel.send(file=file_to_send)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Discord 文件发送失败: {e}")
|
||||
logger.error(f"Gửi tệp Discord thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def send_forward_msg(
|
||||
@@ -499,9 +498,9 @@ class DiscordAdapter(PlatformAdapter):
|
||||
nodes: list[dict],
|
||||
) -> bool:
|
||||
"""
|
||||
在 Discord 模拟合并转发。
|
||||
Mô phỏng chuyển tiếp gộp trên Discord.
|
||||
|
||||
由于 Discord 没有原生节点转发 API,我们将其转换为一组文本消息发送。
|
||||
Discord không có API chuyển tiếp node gốc nên chuyển thành nhóm tin văn bản.
|
||||
"""
|
||||
if not discord:
|
||||
return False
|
||||
@@ -515,17 +514,17 @@ class DiscordAdapter(PlatformAdapter):
|
||||
if not hasattr(channel, "send"):
|
||||
return False
|
||||
|
||||
# 将节点汇总为美化的文本块
|
||||
lines = ["📊 **结构化报告摘要 (Structured Report)**\n"]
|
||||
# Tổng hợp node thành khối văn bản có định dạng.
|
||||
lines = ["📊 **Tóm tắt báo cáo có cấu trúc (Structured Report)**\n"]
|
||||
for node in nodes:
|
||||
data = node.get("data", node) # 兼容不同格式
|
||||
data = node.get("data", node) # Tương thích nhiều định dạng.
|
||||
name = data.get("name", "AstrBot")
|
||||
content = data.get("content", "")
|
||||
lines.append(f"**[{name}]**:\n{content}\n")
|
||||
|
||||
full_text = "\n".join(lines)
|
||||
|
||||
# 分段处理大消息
|
||||
# Chia nhỏ tin nhắn dài.
|
||||
if len(full_text) > 1900:
|
||||
parts = [
|
||||
full_text[i : i + 1900] for i in range(0, len(full_text), 1900)
|
||||
@@ -537,13 +536,13 @@ class DiscordAdapter(PlatformAdapter):
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Discord 模拟转发失败: {e}")
|
||||
logger.error(f"Mô phỏng chuyển tiếp Discord thất bại: {e}")
|
||||
return False
|
||||
|
||||
# ==================== IGroupInfoRepository 实现 ====================
|
||||
# ==================== Triển khai IGroupInfoRepository ====================
|
||||
|
||||
async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
|
||||
"""解析 Discord 频道及所属服务器的基本信息。"""
|
||||
"""Phân tích thông tin cơ bản của kênh và server Discord."""
|
||||
if not discord:
|
||||
return None
|
||||
|
||||
@@ -557,11 +556,11 @@ class DiscordAdapter(PlatformAdapter):
|
||||
group_name = getattr(channel, "name", str(channel.id))
|
||||
|
||||
if guild:
|
||||
# 群聊(服务器频道)
|
||||
# Kênh server.
|
||||
member_count = guild.member_count
|
||||
owner_id = str(guild.owner_id)
|
||||
else:
|
||||
# 私人对话(DM)
|
||||
# Tin nhắn riêng.
|
||||
member_count = len(getattr(channel, "recipients", [])) + 1
|
||||
owner_id = str(getattr(channel, "owner_id", ""))
|
||||
|
||||
@@ -574,11 +573,11 @@ class DiscordAdapter(PlatformAdapter):
|
||||
platform="discord",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Discord 获取群组信息错误: {e}")
|
||||
logger.debug(f"Lỗi lấy thông tin nhóm Discord: {e}")
|
||||
return None
|
||||
|
||||
async def get_group_list(self) -> list[str]:
|
||||
"""列出机器人所在服务器中所有可访问的文本频道 ID。"""
|
||||
"""Liệt kê ID kênh văn bản bot có thể truy cập trên các server."""
|
||||
if not discord:
|
||||
return []
|
||||
|
||||
@@ -593,9 +592,9 @@ class DiscordAdapter(PlatformAdapter):
|
||||
|
||||
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
|
||||
"""
|
||||
获取频道对应的成员列表。
|
||||
Lấy danh sách thành viên của kênh.
|
||||
|
||||
注意:对于大型服务器,建议启用 GUILD_MEMBERS 意图以保证列表完整性。
|
||||
Nên bật intent GUILD_MEMBERS trên server lớn để đảm bảo đầy đủ.
|
||||
"""
|
||||
if not discord:
|
||||
return []
|
||||
@@ -608,7 +607,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
|
||||
guild = getattr(channel, "guild", None)
|
||||
if not guild:
|
||||
# 私聊收件人
|
||||
# Người nhận tin nhắn riêng.
|
||||
return [
|
||||
UnifiedMember(
|
||||
user_id=str(u.id),
|
||||
@@ -647,7 +646,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
) -> UnifiedMember | None:
|
||||
"""获取并解析特定 Discord 用户的身份信息。"""
|
||||
"""Lấy và phân tích thông tin định danh của thành viên Discord."""
|
||||
if not discord:
|
||||
return None
|
||||
|
||||
@@ -660,7 +659,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
|
||||
guild = getattr(channel, "guild", None)
|
||||
if not guild:
|
||||
# 跨频道/私聊探测
|
||||
# Dò xuyên kênh hoặc tin nhắn riêng.
|
||||
user = await self.bot.fetch_user(uid)
|
||||
return UnifiedMember(
|
||||
user_id=str(user.id), nickname=user.name, card=user.display_name
|
||||
@@ -688,14 +687,14 @@ class DiscordAdapter(PlatformAdapter):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ==================== IAvatarRepository 实现 ====================
|
||||
# ==================== Triển khai IAvatarRepository ====================
|
||||
|
||||
async def get_user_avatar_url(
|
||||
self,
|
||||
user_id: str,
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""根据 Discord 用户 ID 动态解析其头像 CDN 地址。"""
|
||||
"""Phân giải động URL CDN avatar theo ID thành viên Discord."""
|
||||
if not discord or not self._discord_client:
|
||||
return None
|
||||
|
||||
@@ -706,14 +705,14 @@ class DiscordAdapter(PlatformAdapter):
|
||||
) or await self._discord_client.fetch_user(uid)
|
||||
|
||||
if user:
|
||||
# 自动对齐 Discord 支持的尺寸 (2的幂)
|
||||
# Chọn kích thước gần nhất được Discord hỗ trợ.
|
||||
allowed_sizes = (16, 32, 64, 128, 256, 512, 1024, 2048, 4096)
|
||||
target_size = min(allowed_sizes, key=lambda x: abs(x - size))
|
||||
return user.display_avatar.with_size(target_size).url
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Discord 获取用户头像 URL 错误: {e}")
|
||||
logger.debug(f"Lỗi lấy URL avatar Discord: {e}")
|
||||
return None
|
||||
|
||||
async def get_user_avatar_data(
|
||||
@@ -721,7 +720,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
user_id: str,
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""暂不提供 Base64 转换服务,优先使用 CDN 链接。"""
|
||||
"""Chưa hỗ trợ Base64; ưu tiên URL CDN."""
|
||||
return None
|
||||
|
||||
async def get_group_avatar_url(
|
||||
@@ -729,7 +728,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
group_id: str,
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""获取 Discord 服务器(Guild)的图标地址。"""
|
||||
"""Lấy URL biểu tượng server Discord."""
|
||||
if not discord:
|
||||
return None
|
||||
|
||||
@@ -751,14 +750,14 @@ class DiscordAdapter(PlatformAdapter):
|
||||
user_ids: list[str],
|
||||
size: int = 100,
|
||||
) -> dict[str, str | None]:
|
||||
"""批量获取头像的最佳实践。"""
|
||||
"""Lấy hàng loạt URL avatar."""
|
||||
return {uid: await self.get_user_avatar_url(uid, size) for uid in user_ids}
|
||||
|
||||
async def set_reaction(
|
||||
self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Discord 实现消息回应。
|
||||
Triển khai reaction tin nhắn Discord.
|
||||
"""
|
||||
if not discord:
|
||||
return False
|
||||
@@ -779,7 +778,7 @@ class DiscordAdapter(PlatformAdapter):
|
||||
channel = await self._discord_client.fetch_channel(channel_id)
|
||||
|
||||
if not hasattr(channel, "get_partial_message"):
|
||||
# 如果较低版本的 SDK 没这个方法,则直接 fetch
|
||||
# Fetch trực tiếp nếu SDK cũ không có phương thức này.
|
||||
msg = await channel.fetch_message(int(message_id))
|
||||
else:
|
||||
msg = channel.get_partial_message(int(message_id))
|
||||
@@ -790,5 +789,5 @@ class DiscordAdapter(PlatformAdapter):
|
||||
await msg.remove_reaction(emoji_to_use, self._discord_client.user)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Discord set_reaction 失败: {e}")
|
||||
logger.debug(f"Discord set_reaction thất bại: {e}")
|
||||
return False
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Feishu/Lark 平台适配器
|
||||
Adapter nền tảng Feishu/Lark.
|
||||
|
||||
复用 AstrBot 已有 lark_oapi 生态能力,实现飞书群分析消息读取、成员信息与头像获取。
|
||||
Tái sử dụng hệ sinh thái lark_oapi của AstrBot để lấy tin nhắn phân tích nhóm,
|
||||
thông tin thành viên và ảnh đại diện Feishu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -117,18 +118,18 @@ try:
|
||||
ReplyMessageRequestBody = _ReplyMessageRequestBody
|
||||
|
||||
LARK_AVAILABLE = True
|
||||
except Exception: # pragma: no cover - 兼容缺依赖环境
|
||||
except Exception: # pragma: no cover - tương thích môi trường thiếu dependency
|
||||
LARK_AVAILABLE = False
|
||||
|
||||
|
||||
class LarkAdapter(PlatformAdapter):
|
||||
"""飞书平台适配器。"""
|
||||
"""Adapter nền tảng Feishu."""
|
||||
|
||||
platform_name = "lark"
|
||||
_DEFAULT_SCOPE_HINT = (
|
||||
"Please grant these Feishu app scopes once: "
|
||||
"`im:message:readonly`, `im:chat:readonly`, and user/contact read scopes "
|
||||
"for profile avatar fields, then reinstall/re-authorize the app."
|
||||
"Vui lòng cấp các scope Feishu `im:message:readonly`, "
|
||||
"`im:chat:readonly` và quyền đọc người dùng/danh bạ để lấy ảnh đại diện, "
|
||||
"sau đó cài đặt hoặc uỷ quyền lại ứng dụng."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -145,7 +146,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
self._permission_checked_groups: set[str] = set()
|
||||
self._permission_error_by_group: dict[str, str | None] = {}
|
||||
logger.info(
|
||||
"飞书适配器初始化完成 (SDK可用=%s, 客户端就绪=%s)",
|
||||
"Đã khởi tạo adapter Feishu (SDK khả dụng=%s, client sẵn sàng=%s)",
|
||||
LARK_AVAILABLE,
|
||||
bool(self._lark_client),
|
||||
)
|
||||
@@ -169,15 +170,15 @@ class LarkAdapter(PlatformAdapter):
|
||||
def _resolve_lark_client(bot_instance: object) -> _SDKNode | None:
|
||||
if bot_instance is None:
|
||||
return None
|
||||
# 直接是 lark.Client
|
||||
# Chính là lark.Client.
|
||||
if hasattr(bot_instance, "im") and hasattr(bot_instance, "contact"):
|
||||
return cast(_SDKNode, bot_instance)
|
||||
# 平台实例上暴露 lark_api
|
||||
# Instance nền tảng công khai lark_api.
|
||||
if hasattr(bot_instance, "lark_api"):
|
||||
api = getattr(bot_instance, "lark_api")
|
||||
if hasattr(api, "im"):
|
||||
return cast(_SDKNode, api)
|
||||
# 常见包装层
|
||||
# Các lớp wrapper phổ biến.
|
||||
for attr in ("client", "_client", "bot"):
|
||||
if hasattr(bot_instance, attr):
|
||||
client = getattr(bot_instance, attr)
|
||||
@@ -305,22 +306,29 @@ class LarkAdapter(PlatformAdapter):
|
||||
self, group_id: str
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
预热群成员缓存并完成权限探测。
|
||||
该方法用于在分析前一次性确认“成员信息+头像”权限是否齐备。
|
||||
Làm nóng cache thành viên nhóm và kiểm tra quyền.
|
||||
|
||||
Xác nhận một lần trước khi phân tích rằng quyền đọc thông tin thành viên
|
||||
và ảnh đại diện đã đầy đủ.
|
||||
"""
|
||||
if group_id in self._permission_checked_groups:
|
||||
err = self._permission_error_by_group.get(group_id)
|
||||
logger.debug(
|
||||
"飞书预检查命中缓存: 群=%s, 结果=%s",
|
||||
"Kiểm tra trước Feishu dùng cache: nhóm=%s, kết quả=%s",
|
||||
group_id,
|
||||
err is None,
|
||||
)
|
||||
return err is None, err
|
||||
logger.info("飞书预检查开始: 群=%s", group_id)
|
||||
logger.info("Bắt đầu kiểm tra trước Feishu: nhóm=%s", group_id)
|
||||
if not LARK_AVAILABLE or not self._lark_client or not self._lark_client.im:
|
||||
self._permission_error_by_group[group_id] = "飞书 SDK 客户端未初始化。"
|
||||
self._permission_error_by_group[group_id] = (
|
||||
"Client SDK Feishu chưa được khởi tạo."
|
||||
)
|
||||
self._permission_checked_groups.add(group_id)
|
||||
logger.warning("飞书预检查失败: 群=%s, 原因=SDK或客户端不可用", group_id)
|
||||
logger.warning(
|
||||
"Kiểm tra trước Feishu thất bại: nhóm=%s, lý do=SDK hoặc client không khả dụng",
|
||||
group_id,
|
||||
)
|
||||
return False, self._permission_error_by_group[group_id]
|
||||
|
||||
try:
|
||||
@@ -330,10 +338,14 @@ class LarkAdapter(PlatformAdapter):
|
||||
f"Cannot list chat members. {self._DEFAULT_SCOPE_HINT}"
|
||||
)
|
||||
self._permission_checked_groups.add(group_id)
|
||||
logger.warning("飞书预检查失败: 群=%s, 原因=未获取到成员列表", group_id)
|
||||
logger.warning(
|
||||
"Kiểm tra trước Feishu thất bại: nhóm=%s, lý do=không lấy được danh sách thành viên",
|
||||
group_id,
|
||||
)
|
||||
return False, self._permission_error_by_group[group_id]
|
||||
|
||||
# 只预热近期活跃用户常见数量,避免在超大群上引入不必要延迟
|
||||
# Chỉ làm nóng số lượng thành viên hoạt động gần đây thường gặp
|
||||
# để tránh độ trễ không cần thiết ở nhóm rất lớn.
|
||||
target_ids = [m.user_id for m in members[:100]]
|
||||
avatar_map = await self.batch_get_avatar_urls(target_ids, size=240)
|
||||
avatar_ok_count = sum(1 for uid in target_ids if avatar_map.get(uid))
|
||||
@@ -344,7 +356,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
)
|
||||
self._permission_checked_groups.add(group_id)
|
||||
logger.warning(
|
||||
"飞书预检查失败: 群=%s, 原因=头像预热结果为空 (用户数=%s)",
|
||||
"Kiểm tra trước Feishu thất bại: nhóm=%s, lý do=kết quả làm nóng ảnh rỗng (thành viên=%s)",
|
||||
group_id,
|
||||
len(target_ids),
|
||||
)
|
||||
@@ -353,7 +365,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
self._permission_error_by_group[group_id] = None
|
||||
self._permission_checked_groups.add(group_id)
|
||||
logger.info(
|
||||
"飞书预检查通过: 群=%s (成员=%s, 头像成功=%s/%s)",
|
||||
"Kiểm tra trước Feishu thành công: nhóm=%s (thành viên=%s, ảnh thành công=%s/%s)",
|
||||
group_id,
|
||||
len(members),
|
||||
avatar_ok_count,
|
||||
@@ -362,11 +374,11 @@ class LarkAdapter(PlatformAdapter):
|
||||
return True, None
|
||||
except Exception as e:
|
||||
self._permission_error_by_group[group_id] = (
|
||||
f"飞书成员缓存预热失败: {e}. {self._DEFAULT_SCOPE_HINT}"
|
||||
f"Làm nóng cache thành viên Feishu thất bại: {e}. {self._DEFAULT_SCOPE_HINT}"
|
||||
)
|
||||
self._permission_checked_groups.add(group_id)
|
||||
logger.error(
|
||||
"飞书预检查异常: 群=%s, 错误=%s",
|
||||
"Lỗi kiểm tra trước Feishu: nhóm=%s, lỗi=%s",
|
||||
group_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
@@ -382,7 +394,10 @@ class LarkAdapter(PlatformAdapter):
|
||||
since_ts: int | None = None,
|
||||
) -> list[UnifiedMessage]:
|
||||
if not LARK_AVAILABLE or not self._lark_client or not self._lark_client.im:
|
||||
logger.warning("飞书消息拉取跳过: 群=%s, 原因=SDK或客户端不可用", group_id)
|
||||
logger.warning(
|
||||
"Bỏ qua lấy tin nhắn Feishu: nhóm=%s, lý do=SDK hoặc client không khả dụng",
|
||||
group_id,
|
||||
)
|
||||
return []
|
||||
now_seconds = int(__import__("time").time())
|
||||
start_seconds = (
|
||||
@@ -395,7 +410,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
seen_ids: set[str] = set()
|
||||
page_index = 0
|
||||
logger.info(
|
||||
"飞书消息拉取开始: 群=%s (天数=%s, 最大条数=%s, since_ts=%s)",
|
||||
"Bắt đầu lấy tin nhắn Feishu: nhóm=%s (ngày=%s, tối đa=%s, since_ts=%s)",
|
||||
group_id,
|
||||
days,
|
||||
max_count,
|
||||
@@ -431,7 +446,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
response = await self._lark_client.im.v1.message.alist(request)
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
"飞书消息拉取失败: 群=%s, code=%s, msg=%s",
|
||||
"Lấy tin nhắn Feishu thất bại: nhóm=%s, code=%s, msg=%s",
|
||||
group_id,
|
||||
response.code,
|
||||
response.msg,
|
||||
@@ -441,7 +456,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
items_raw = (response.data.items if response.data else None) or []
|
||||
items: list[object] = items_raw if isinstance(items_raw, list) else []
|
||||
logger.debug(
|
||||
"飞书消息分页: 页=%s, 群=%s, 条数=%s, has_more=%s",
|
||||
"Phân trang tin nhắn Feishu: trang=%s, nhóm=%s, số lượng=%s, has_more=%s",
|
||||
page_index,
|
||||
group_id,
|
||||
len(items),
|
||||
@@ -471,7 +486,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
|
||||
messages.sort(key=lambda m: m.timestamp)
|
||||
logger.info(
|
||||
"飞书消息拉取完成: 群=%s (消息=%s, 页数=%s, 起始=%s, 结束=%s)",
|
||||
"Hoàn tất lấy tin nhắn Feishu: nhóm=%s (tin nhắn=%s, trang=%s, bắt đầu=%s, kết thúc=%s)",
|
||||
group_id,
|
||||
len(messages),
|
||||
page_index,
|
||||
@@ -612,7 +627,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"飞书消息转换失败: {e}")
|
||||
logger.debug(f"Chuyển đổi tin nhắn Feishu thất bại: {e}")
|
||||
return None
|
||||
|
||||
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||
@@ -698,7 +713,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
response = await self._lark_client.im.v1.message.acreate(request)
|
||||
return bool(response.success())
|
||||
except Exception as e:
|
||||
logger.error(f"飞书文本发送失败: {e}")
|
||||
logger.error(f"Gửi văn bản Feishu thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def send_image(
|
||||
@@ -790,7 +805,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
await self.send_text(group_id, caption)
|
||||
return bool(send_resp.success())
|
||||
except Exception as e:
|
||||
logger.error(f"飞书图片发送失败: {e}")
|
||||
logger.error(f"Gửi ảnh Feishu thất bại: {e}")
|
||||
return False
|
||||
finally:
|
||||
if temp_path and temp_path.exists():
|
||||
@@ -854,7 +869,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
msg_resp = await self._lark_client.im.v1.message.acreate(msg_req)
|
||||
return bool(msg_resp.success())
|
||||
except Exception as e:
|
||||
logger.error(f"飞书文件发送失败: {e}")
|
||||
logger.error(f"Gửi tệp Feishu thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
|
||||
@@ -880,24 +895,24 @@ class LarkAdapter(PlatformAdapter):
|
||||
platform="lark",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"飞书群信息获取失败: {e}")
|
||||
logger.debug(f"Lấy thông tin nhóm Feishu thất bại: {e}")
|
||||
return None
|
||||
|
||||
async def get_group_list(self) -> list[str]:
|
||||
# 飞书服务端 API 不提供简单“机器人可见群列表”枚举能力
|
||||
# API Feishu không cung cấp cách đơn giản để liệt kê nhóm bot nhìn thấy.
|
||||
return []
|
||||
|
||||
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
|
||||
if not self._lark_client or not self._lark_client.im:
|
||||
logger.warning(
|
||||
"飞书成员列表获取跳过: 群=%s, 原因=客户端不可用",
|
||||
"Bỏ qua lấy danh sách thành viên Feishu: nhóm=%s, lý do=client không khả dụng",
|
||||
group_id,
|
||||
)
|
||||
return []
|
||||
members: list[UnifiedMember] = []
|
||||
page_token: str | None = None
|
||||
page_index = 0
|
||||
logger.debug("飞书成员列表获取开始: 群=%s", group_id)
|
||||
logger.debug("Bắt đầu lấy danh sách thành viên Feishu: nhóm=%s", group_id)
|
||||
while True:
|
||||
page_index += 1
|
||||
GetChatMembersRequestClass = self._request_class_or_throw(
|
||||
@@ -919,14 +934,14 @@ class LarkAdapter(PlatformAdapter):
|
||||
str(getattr(response, "msg", "") or ""),
|
||||
):
|
||||
logger.warning(
|
||||
"飞书成员列表权限不足: 群=%s, code=%s, msg=%s",
|
||||
"Không đủ quyền lấy danh sách thành viên Feishu: nhóm=%s, code=%s, msg=%s",
|
||||
group_id,
|
||||
response.code,
|
||||
response.msg,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"飞书成员列表获取失败: 群=%s, code=%s, msg=%s",
|
||||
"Lấy danh sách thành viên Feishu thất bại: nhóm=%s, code=%s, msg=%s",
|
||||
group_id,
|
||||
getattr(response, "code", "unknown"),
|
||||
getattr(response, "msg", "unknown"),
|
||||
@@ -935,7 +950,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
items_raw = (response.data.items if response.data else None) or []
|
||||
items: list[object] = items_raw if isinstance(items_raw, list) else []
|
||||
logger.debug(
|
||||
"飞书成员分页: 页=%s, 群=%s, 条数=%s",
|
||||
"Phân trang thành viên Feishu: trang=%s, nhóm=%s, số lượng=%s",
|
||||
page_index,
|
||||
group_id,
|
||||
len(items),
|
||||
@@ -962,7 +977,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
page_token_raw = getattr(response.data, "page_token", None)
|
||||
page_token = str(page_token_raw) if page_token_raw else None
|
||||
logger.info(
|
||||
"飞书成员列表获取完成: 群=%s (成员=%s, 页数=%s)",
|
||||
"Hoàn tất lấy thành viên Feishu: nhóm=%s (thành viên=%s, trang=%s)",
|
||||
group_id,
|
||||
len(members),
|
||||
page_index,
|
||||
@@ -989,14 +1004,14 @@ class LarkAdapter(PlatformAdapter):
|
||||
str(getattr(response, "msg", "") or ""),
|
||||
):
|
||||
logger.warning(
|
||||
"飞书用户资料权限不足: 用户=%s, code=%s, msg=%s",
|
||||
"Không đủ quyền lấy hồ sơ Feishu: thành viên=%s, code=%s, msg=%s",
|
||||
self._short_id(user_id),
|
||||
response.code,
|
||||
response.msg,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"飞书用户资料获取失败: 用户=%s, code=%s, msg=%s",
|
||||
"Lấy hồ sơ Feishu thất bại: thành viên=%s, code=%s, msg=%s",
|
||||
self._short_id(user_id),
|
||||
getattr(response, "code", "unknown"),
|
||||
getattr(response, "msg", "unknown"),
|
||||
@@ -1004,7 +1019,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
return None
|
||||
return response.data
|
||||
except Exception as e:
|
||||
logger.debug(f"飞书用户资料获取失败: {e}")
|
||||
logger.debug(f"Lấy hồ sơ Feishu thất bại: {e}")
|
||||
return None
|
||||
|
||||
async def get_member_info(
|
||||
@@ -1057,7 +1072,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
fallback_avatar = self._build_fallback_avatar(user_id, cached_name)
|
||||
self._avatar_url_cache[user_id] = fallback_avatar
|
||||
logger.debug(
|
||||
"飞书头像使用回退图: 用户=%s, 尺寸=%s",
|
||||
"Ảnh Feishu dùng hình fallback: thành viên=%s, kích thước=%s",
|
||||
self._short_id(user_id),
|
||||
size,
|
||||
)
|
||||
@@ -1106,7 +1121,7 @@ class LarkAdapter(PlatformAdapter):
|
||||
unique_ids = list(dict.fromkeys(user_ids))
|
||||
semaphore = asyncio.Semaphore(8)
|
||||
logger.debug(
|
||||
"飞书批量头像获取开始 (请求=%s, 去重后=%s, 尺寸=%s)",
|
||||
"Bắt đầu lấy ảnh Feishu hàng loạt (yêu cầu=%s, sau loại trùng=%s, kích thước=%s)",
|
||||
len(user_ids),
|
||||
len(unique_ids),
|
||||
size,
|
||||
@@ -1119,11 +1134,15 @@ class LarkAdapter(PlatformAdapter):
|
||||
pairs = await asyncio.gather(*(_fetch(uid) for uid in unique_ids))
|
||||
result = dict(pairs)
|
||||
ok_count = sum(1 for _, url in pairs if url)
|
||||
logger.debug("飞书批量头像获取完成 (成功=%s/%s)", ok_count, len(unique_ids))
|
||||
logger.debug(
|
||||
"Hoàn tất lấy ảnh Feishu hàng loạt (thành công=%s/%s)",
|
||||
ok_count,
|
||||
len(unique_ids),
|
||||
)
|
||||
return result
|
||||
|
||||
async def set_reaction(
|
||||
self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
|
||||
) -> bool:
|
||||
# 当前插件分析流程不依赖飞书 reaction,这里返回 False 以保持兼容。
|
||||
# Quy trình hiện tại không phụ thuộc reaction Feishu; trả False để tương thích.
|
||||
return False
|
||||
|
||||
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):
|
||||
return normalized
|
||||
digest = hashlib.sha256(f"{group_id}\0{sender_id}".encode()).hexdigest()[:8]
|
||||
return f"群友-{digest.upper()}"
|
||||
return f"Thành viên-{digest.upper()}"
|
||||
|
||||
def set_context(self, context: Context) -> None:
|
||||
self._context = context
|
||||
@@ -97,7 +97,9 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
since_ts: int | None = None,
|
||||
) -> list[UnifiedMessage]:
|
||||
if not self._context:
|
||||
logger.warning("[QQOfficial] 未设置 context,无法读取本地消息历史")
|
||||
logger.warning(
|
||||
"[QQOfficial] Chưa thiết lập context, không thể đọc lịch sử cục bộ"
|
||||
)
|
||||
return []
|
||||
|
||||
history_mgr = self._context.message_history_manager
|
||||
@@ -163,13 +165,17 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
if len(messages) > target_count:
|
||||
messages = messages[-target_count:]
|
||||
logger.info(
|
||||
"[QQOfficial] 从本地历史获取群 %s 消息 %s 条",
|
||||
group_id,
|
||||
"[QQOfficial] Đã lấy %s tin nhắn nhóm %s từ lịch sử cục bộ",
|
||||
len(messages),
|
||||
group_id,
|
||||
)
|
||||
return messages
|
||||
except Exception as exc:
|
||||
logger.error("[QQOfficial] 读取本地消息历史失败: %s", exc, exc_info=True)
|
||||
logger.error(
|
||||
"[QQOfficial] Đọc lịch sử tin nhắn cục bộ thất bại: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
def _convert_history_record(
|
||||
@@ -258,7 +264,9 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
platform=self.platform_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("[QQOfficial] 转换本地历史记录失败: %s", exc)
|
||||
logger.debug(
|
||||
"[QQOfficial] Chuyển đổi bản ghi lịch sử cục bộ thất bại: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||
@@ -290,7 +298,7 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
|
||||
async def _send_chain(self, group_id: str, chain: Any) -> bool:
|
||||
if not self._context:
|
||||
logger.error("[QQOfficial] 未设置 context,无法发送消息")
|
||||
logger.error("[QQOfficial] Chưa thiết lập context, không thể gửi tin nhắn")
|
||||
return False
|
||||
try:
|
||||
# AstrBot's QQ Official adapter keeps the group/channel scene only
|
||||
@@ -303,7 +311,7 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
umo = f"{self.platform_id}:GroupMessage:{group_id}"
|
||||
return bool(await self._context.send_message(umo, chain))
|
||||
except Exception as exc:
|
||||
logger.error("[QQOfficial] 发送消息失败: %s", exc, exc_info=True)
|
||||
logger.error("[QQOfficial] Gửi tin nhắn thất bại: %s", exc, exc_info=True)
|
||||
return False
|
||||
|
||||
async def send_text(
|
||||
@@ -333,11 +341,11 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
sent_markdown_chunks += 1
|
||||
continue
|
||||
logger.warning(
|
||||
"[QQOfficial] Markdown 接口未返回成功结果,后续改用普通文本"
|
||||
"[QQOfficial] API Markdown không trả về thành công, chuyển sang văn bản thường"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[QQOfficial] Markdown 报告发送失败,后续改用普通文本: %s",
|
||||
"[QQOfficial] Gửi báo cáo Markdown thất bại, chuyển sang văn bản thường: %s",
|
||||
exc,
|
||||
)
|
||||
markdown_enabled = False
|
||||
@@ -488,7 +496,9 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
try:
|
||||
return await self._plugin_instance.get_seen_group_ids(self.platform_id)
|
||||
except Exception as exc:
|
||||
logger.warning("[QQOfficial] 获取已见群列表失败: %s", exc)
|
||||
logger.warning(
|
||||
"[QQOfficial] Lấy danh sách nhóm đã thấy thất bại: %s", exc
|
||||
)
|
||||
return []
|
||||
|
||||
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
|
||||
@@ -529,7 +539,7 @@ class QQOfficialAdapter(PlatformAdapter):
|
||||
mime = "image/png" if payload.startswith(b"\x89PNG") else "image/jpeg"
|
||||
return f"data:{mime};base64,{base64.b64encode(payload).decode('utf-8')}"
|
||||
except Exception as exc:
|
||||
logger.debug("[QQOfficial] 下载头像失败: %s", exc)
|
||||
logger.debug("[QQOfficial] Tải avatar thất bại: %s", exc)
|
||||
return None
|
||||
|
||||
async def get_group_avatar_url(self, group_id: str, size: int = 100) -> str | None:
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
"""
|
||||
Telegram 平台适配器
|
||||
|
||||
支持 Telegram Bot API 的消息发送功能。
|
||||
通过 AstrBot 的 message_history_manager 存储和读取消息历史。
|
||||
"""
|
||||
"""Adapter Telegram dùng Bot API và lịch sử tin nhắn AstrBot."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
@@ -30,7 +25,7 @@ from ..base import PlatformAdapter
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.api.star import Context
|
||||
|
||||
# Telegram 依赖
|
||||
# Dependency Telegram.
|
||||
try:
|
||||
from telegram.ext import ExtBot
|
||||
|
||||
@@ -45,29 +40,17 @@ TELEGRAM_AVATAR_NEGATIVE_CACHE_MAX_SIZE = 1024
|
||||
|
||||
|
||||
class TelegramAdapter(PlatformAdapter):
|
||||
"""
|
||||
Telegram Bot API 适配器
|
||||
|
||||
实现 PlatformAdapter 接口,支持:
|
||||
- 消息发送(文本、图片、文件)
|
||||
- 头像获取
|
||||
- 群组信息获取
|
||||
- 消息历史(通过 AstrBot 的 message_history_manager)
|
||||
|
||||
消息历史机制:
|
||||
- 消息通过拦截器存储到 AstrBot 数据库
|
||||
- fetch_messages 从数据库读取历史消息
|
||||
"""
|
||||
"""Adapter Telegram hỗ trợ gửi tin, avatar, nhóm và lịch sử AstrBot."""
|
||||
|
||||
def __init__(self, bot_instance: Any, config: dict | None = None):
|
||||
super().__init__(bot_instance, config)
|
||||
self._cached_client: Any = None
|
||||
self._context: Context | None = None
|
||||
|
||||
# 机器人自身 ID(用于消息过滤)
|
||||
# ID của bot để lọc tin nhắn.
|
||||
self.bot_user_id = str(config.get("bot_user_id", "")) if config else ""
|
||||
|
||||
# 尝试从配置获取 bot self ids 列表
|
||||
# Thử lấy danh sách self ID của bot từ cấu hình.
|
||||
self.bot_self_ids: list[str] = []
|
||||
if config:
|
||||
ids = config.get("bot_self_ids", [])
|
||||
@@ -81,34 +64,31 @@ class TelegramAdapter(PlatformAdapter):
|
||||
|
||||
def set_context(self, context: "Context") -> None:
|
||||
"""
|
||||
设置 AstrBot 上下文
|
||||
Thiết lập context AstrBot.
|
||||
|
||||
用于访问 message_history_manager 等核心服务。
|
||||
Dùng để truy cập các dịch vụ lõi như message_history_manager.
|
||||
"""
|
||||
self._context = context
|
||||
|
||||
def _init_capabilities(self) -> PlatformCapabilities:
|
||||
"""返回 Telegram 平台能力声明"""
|
||||
"""Trả về capability của nền tảng Telegram."""
|
||||
return TELEGRAM_CAPABILITIES
|
||||
|
||||
async def get_group_list(self) -> list[str]:
|
||||
"""
|
||||
获取群组列表
|
||||
Lấy danh sách nhóm.
|
||||
|
||||
Telegram Bot API 不支持直接获取群列表。
|
||||
因此这里尝试结合多种策略:
|
||||
1. 尝试调用 API (如果未来支持)
|
||||
2. 回退:从插件的 KV 存储中获取已知群组 (需注入插件实例)
|
||||
Telegram Bot API không hỗ trợ lấy trực tiếp danh sách nhóm, do đó
|
||||
fallback sang nhóm đã biết trong KV của plugin.
|
||||
"""
|
||||
groups = []
|
||||
|
||||
# 1. 尝试 API (目前 python-telegram-bot 不支持直接列出所有 chat)
|
||||
# 如果 client 有扩展方法或未来支持,可在此实现
|
||||
# python-telegram-bot hiện chưa hỗ trợ liệt kê toàn bộ chat.
|
||||
|
||||
# 2. 回退:使用 KV 注册表
|
||||
# Fallback: dùng registry KV.
|
||||
if not groups and self._plugin_instance:
|
||||
try:
|
||||
# 检查插件实例是否有 get_telegram_seen_group_ids 方法
|
||||
# Kiểm tra plugin có phương thức lấy nhóm Telegram đã thấy.
|
||||
if hasattr(self._plugin_instance, "get_telegram_seen_group_ids"):
|
||||
kv_groups = await self._plugin_instance.get_telegram_seen_group_ids(
|
||||
self._platform_id
|
||||
@@ -116,48 +96,54 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if kv_groups:
|
||||
groups.extend(kv_groups)
|
||||
logger.debug(
|
||||
f"[Telegram] 通过 KV 回退获取到 {len(kv_groups)} 个群组"
|
||||
f"[Telegram] Fallback KV lấy được {len(kv_groups)} nhóm"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Telegram] KV 回退获取群列表失败: {e}")
|
||||
logger.warning(
|
||||
f"[Telegram] Fallback KV lấy danh sách nhóm thất bại: {e}"
|
||||
)
|
||||
|
||||
if not groups:
|
||||
logger.debug("[Telegram] 无法获取群列表 (API不支持且无KV记录)")
|
||||
logger.debug(
|
||||
"[Telegram] Không thể lấy danh sách nhóm: API không hỗ trợ và KV rỗng"
|
||||
)
|
||||
|
||||
return list(set(groups))
|
||||
|
||||
@property
|
||||
def _telegram_client(self) -> Any:
|
||||
"""
|
||||
懒加载获取 Telegram 客户端
|
||||
Lazy load Telegram client.
|
||||
|
||||
支持多种获取路径,适应 AstrBot 不同版本。
|
||||
Hỗ trợ nhiều đường dẫn để tương thích các phiên bản AstrBot.
|
||||
"""
|
||||
if self._cached_client is not None:
|
||||
return self._cached_client
|
||||
|
||||
if not TELEGRAM_AVAILABLE:
|
||||
logger.warning("python-telegram-bot 库未安装,Telegram 适配器不可用")
|
||||
logger.warning(
|
||||
"Chưa cài python-telegram-bot; adapter Telegram không khả dụng"
|
||||
)
|
||||
return None
|
||||
|
||||
# 路径 A: bot 本身就是 ExtBot
|
||||
# Đường dẫn A: bot chính là ExtBot.
|
||||
if ExtBot is not None and isinstance(self.bot, ExtBot):
|
||||
self._cached_client = self.bot
|
||||
return self._cached_client
|
||||
|
||||
# 路径 B: bot.client
|
||||
# Đường dẫn B: bot.client.
|
||||
if hasattr(self.bot, "client"):
|
||||
client = self.bot.client
|
||||
if ExtBot is not None and isinstance(client, ExtBot):
|
||||
self._cached_client = client
|
||||
return self._cached_client
|
||||
|
||||
# 路径 C: bot 有 send_message 方法(ExtBot 的特征)
|
||||
# Đường dẫn C: bot có send_message, đặc trưng ExtBot.
|
||||
if hasattr(self.bot, "send_message") and hasattr(self.bot, "send_photo"):
|
||||
self._cached_client = self.bot
|
||||
return self._cached_client
|
||||
|
||||
# 尝试从 bot 的其他属性获取
|
||||
# Thử các thuộc tính khác của bot.
|
||||
for attr in ("_client", "telegram_client", "_telegram_client", "bot"):
|
||||
if hasattr(self.bot, attr):
|
||||
client = getattr(self.bot, attr)
|
||||
@@ -165,7 +151,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
self._cached_client = client
|
||||
return self._cached_client
|
||||
|
||||
logger.warning("无法从 bot_instance 获取 Telegram 客户端")
|
||||
logger.warning("Không thể lấy Telegram client từ bot_instance")
|
||||
return None
|
||||
|
||||
# ==================== IMessageRepository ====================
|
||||
@@ -179,12 +165,12 @@ class TelegramAdapter(PlatformAdapter):
|
||||
since_ts: int | None = None,
|
||||
) -> list[UnifiedMessage]:
|
||||
"""
|
||||
获取历史消息。
|
||||
Lấy lịch sử tin nhắn.
|
||||
|
||||
从 AstrBot 的 message_history_manager 读取存储的消息。
|
||||
Đọc tin nhắn đã lưu từ message_history_manager của AstrBot.
|
||||
"""
|
||||
if not self._context:
|
||||
logger.warning("[Telegram] 未设置 context,无法获取消息历史")
|
||||
logger.warning("[Telegram] Chưa thiết lập context, không thể lấy lịch sử")
|
||||
return []
|
||||
|
||||
try:
|
||||
@@ -192,7 +178,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
|
||||
platform_id = self._get_platform_id()
|
||||
logger.info(
|
||||
f"[Telegram] 正在获取群 {group_id} 的历史消息,使用 platform_id: {platform_id}"
|
||||
f"[Telegram] Đang lấy lịch sử nhóm {group_id}, platform_id: {platform_id}"
|
||||
)
|
||||
before_id_int: int | None = None
|
||||
if before_id:
|
||||
@@ -202,7 +188,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
logger.warning(f"[Telegram] before_id invalid: {before_id}")
|
||||
|
||||
if since_ts and since_ts > 0:
|
||||
# 统一使用 UTC 以兼容数据库记录的时间存储
|
||||
# Dùng UTC để tương thích thời gian lưu trong database.
|
||||
cutoff_time = datetime.fromtimestamp(since_ts, timezone.utc)
|
||||
else:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
@@ -224,14 +210,14 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if not history_records:
|
||||
if current_page == 1:
|
||||
logger.info(
|
||||
f"[Telegram] 群 {group_id} 没有存储的消息。"
|
||||
f"提示:消息需要通过拦截器实时存储。"
|
||||
f"[Telegram] Nhóm {group_id} chưa có tin nhắn đã lưu. "
|
||||
"Tin nhắn cần được interceptor lưu theo thời gian thực."
|
||||
)
|
||||
break
|
||||
|
||||
total_records_loaded += len(history_records)
|
||||
|
||||
# 先用当前页已有的有效昵称预热缓存,减少额外 API 请求
|
||||
# Làm nóng cache bằng nickname hợp lệ trên trang để giảm API call.
|
||||
for record in history_records:
|
||||
sender_id = str(getattr(record, "sender_id", "") or "").strip()
|
||||
sender_name = str(getattr(record, "sender_name", "") or "").strip()
|
||||
@@ -264,7 +250,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if not msg:
|
||||
continue
|
||||
|
||||
# 过滤机器人自己的消息
|
||||
# Lọc tin nhắn của bot.
|
||||
if self.bot_user_id and msg.sender_id == self.bot_user_id:
|
||||
continue
|
||||
if msg.sender_id in self.bot_self_ids:
|
||||
@@ -275,11 +261,11 @@ class TelegramAdapter(PlatformAdapter):
|
||||
)
|
||||
messages.append(msg)
|
||||
|
||||
# 当前页完整处理后已足够,停止继续翻更旧页面。
|
||||
# Dừng nếu đã đủ sau khi xử lý hết trang hiện tại.
|
||||
if len(messages) >= target_count:
|
||||
break
|
||||
|
||||
# 下一页一定更旧,若当前页最旧记录已越过时间窗口则可提前停止
|
||||
# Dừng sớm nếu bản ghi cũ nhất đã vượt cửa sổ thời gian.
|
||||
if oldest_record_time and oldest_record_time < cutoff_time:
|
||||
break
|
||||
if len(history_records) < page_size:
|
||||
@@ -291,17 +277,17 @@ class TelegramAdapter(PlatformAdapter):
|
||||
messages = messages[-target_count:]
|
||||
|
||||
logger.info(
|
||||
f"[Telegram] 从数据库获取群 {group_id} 的消息: "
|
||||
f"{len(messages)}/{total_records_loaded} 条"
|
||||
f"[Telegram] Lấy tin nhắn nhóm {group_id} từ database: "
|
||||
f"{len(messages)}/{total_records_loaded} mục"
|
||||
)
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 获取消息历史失败: {e}")
|
||||
logger.error(f"[Telegram] Lấy lịch sử tin nhắn thất bại: {e}")
|
||||
return []
|
||||
|
||||
def _get_platform_id(self) -> str:
|
||||
"""获取平台 ID"""
|
||||
"""Lấy platform ID."""
|
||||
if self._platform_id:
|
||||
return self._platform_id
|
||||
|
||||
@@ -310,7 +296,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if config_platform_id:
|
||||
return config_platform_id
|
||||
|
||||
# 尝试从 bot 实例获取
|
||||
# Thử lấy từ bot instance.
|
||||
if hasattr(self.bot, "meta") and callable(self.bot.meta):
|
||||
try:
|
||||
meta = self.bot.meta() # type: ignore
|
||||
@@ -322,7 +308,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
|
||||
@staticmethod
|
||||
def _is_placeholder_sender_name(name: str | None, sender_id: str | None) -> bool:
|
||||
"""判断 sender_name 是否属于占位值。"""
|
||||
"""Kiểm tra sender_name có phải giá trị placeholder hay không."""
|
||||
if not name:
|
||||
return True
|
||||
normalized = str(name).strip()
|
||||
@@ -341,11 +327,9 @@ class TelegramAdapter(PlatformAdapter):
|
||||
sender_name_cache: dict[str, str],
|
||||
) -> UnifiedMessage:
|
||||
"""
|
||||
如果 sender_name 是占位值,尝试通过 get_member_info 修复。
|
||||
Nếu sender_name là placeholder, thử sửa qua get_member_info.
|
||||
|
||||
说明:
|
||||
- 兼容历史脏数据(sender_name 写成 user_id / Unknown)
|
||||
- 使用 sender_id 级缓存,避免重复请求 Telegram API
|
||||
Tương thích dữ liệu lịch sử bẩn và cache theo sender_id để tránh gọi API lặp.
|
||||
"""
|
||||
if not self._is_placeholder_sender_name(msg.sender_name, msg.sender_id):
|
||||
return msg
|
||||
@@ -367,7 +351,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if not self._is_placeholder_sender_name(candidate, sender_id):
|
||||
resolved_name = candidate
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 修复 sender_name 失败 (uid={sender_id}): {e}")
|
||||
logger.debug(f"[Telegram] Sửa sender_name thất bại (uid={sender_id}): {e}")
|
||||
|
||||
sender_name_cache[sender_id] = resolved_name
|
||||
if resolved_name == msg.sender_name:
|
||||
@@ -378,14 +362,14 @@ class TelegramAdapter(PlatformAdapter):
|
||||
self, record: Any, group_id: str
|
||||
) -> UnifiedMessage | None:
|
||||
"""
|
||||
将数据库记录转换为 UnifiedMessage
|
||||
Chuyển bản ghi database thành UnifiedMessage.
|
||||
"""
|
||||
try:
|
||||
content = record.content
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# 提取消息内容
|
||||
# Trích xuất nội dung tin nhắn.
|
||||
message_parts = content.get("message", [])
|
||||
text_content = ""
|
||||
contents = []
|
||||
@@ -448,14 +432,14 @@ class TelegramAdapter(PlatformAdapter):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 转换历史记录失败: {e}")
|
||||
logger.debug(f"[Telegram] Chuyển bản ghi lịch sử thất bại: {e}")
|
||||
return None
|
||||
|
||||
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||
"""
|
||||
将统一消息格式转换为 OneBot 兼容格式
|
||||
Chuyển định dạng tin nhắn thống nhất sang định dạng tương thích OneBot.
|
||||
|
||||
用于向后兼容现有分析逻辑。
|
||||
Dùng để tương thích ngược với logic phân tích hiện tại.
|
||||
"""
|
||||
result = []
|
||||
for msg in messages:
|
||||
@@ -472,7 +456,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
"user_id": msg.sender_id,
|
||||
}
|
||||
|
||||
# 转换消息内容
|
||||
# Chuyển nội dung tin nhắn.
|
||||
for content in msg.contents:
|
||||
if content.type == MessageContentType.TEXT:
|
||||
raw["message"].append(
|
||||
@@ -499,14 +483,14 @@ class TelegramAdapter(PlatformAdapter):
|
||||
text: str,
|
||||
reply_to: str | None = None,
|
||||
) -> bool:
|
||||
"""发送文本消息"""
|
||||
"""Gửi tin nhắn văn bản."""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
logger.error("[Telegram] 客户端未初始化,无法发送文本")
|
||||
logger.error("[Telegram] Client chưa khởi tạo, không thể gửi văn bản")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 处理群组话题 ID
|
||||
# Xử lý ID topic nhóm.
|
||||
chat_id, message_thread_id = self._parse_group_id(group_id)
|
||||
|
||||
kwargs: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||||
@@ -518,7 +502,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
await client.send_message(**kwargs)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 发送文本失败: {e}")
|
||||
logger.error(f"[Telegram] Gửi văn bản thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def send_image(
|
||||
@@ -527,10 +511,10 @@ class TelegramAdapter(PlatformAdapter):
|
||||
image_path: str,
|
||||
caption: str = "",
|
||||
) -> bool:
|
||||
"""发送图片消息"""
|
||||
"""Gửi tin nhắn ảnh."""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
logger.error("[Telegram] 客户端未初始化,无法发送图片")
|
||||
logger.error("[Telegram] Client chưa khởi tạo, không thể gửi ảnh")
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -544,7 +528,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if caption:
|
||||
kwargs["caption"] = caption
|
||||
|
||||
# 1. 统一处理输入源 (Base64 / URL / Local File)
|
||||
# 1. Xử lý thống nhất nguồn Base64, URL hoặc tệp local.
|
||||
if image_path.startswith("base64://"):
|
||||
data = base64.b64decode(image_path[len("base64://") :])
|
||||
file_obj = BytesIO(data)
|
||||
@@ -568,19 +552,21 @@ class TelegramAdapter(PlatformAdapter):
|
||||
file_obj = BytesIO(data)
|
||||
is_temp_obj = True
|
||||
else:
|
||||
file_obj = image_path # 尝试直接发 URL
|
||||
file_obj = image_path # Thử gửi URL trực tiếp.
|
||||
except Exception as e:
|
||||
logger.warning(f"[Telegram] 下载图片失败,尝试直接发送: {e}")
|
||||
logger.warning(
|
||||
f"[Telegram] Tải ảnh thất bại, thử gửi trực tiếp: {e}"
|
||||
)
|
||||
file_obj = image_path
|
||||
else:
|
||||
# 本地文件
|
||||
# Tệp local.
|
||||
if os.path.exists(image_path):
|
||||
file_obj = open(image_path, "rb")
|
||||
is_temp_obj = True
|
||||
else:
|
||||
file_obj = image_path
|
||||
|
||||
# 2. 发送图片
|
||||
# 2. Gửi ảnh.
|
||||
kwargs["photo"] = file_obj
|
||||
try:
|
||||
await client.send_photo(**kwargs)
|
||||
@@ -592,18 +578,20 @@ class TelegramAdapter(PlatformAdapter):
|
||||
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
# Photo_invalid_dimensions: Telegram 报错提示图片长宽比例或总尺寸不合规
|
||||
# Photo_invalid_dimensions: tỷ lệ hoặc tổng kích thước ảnh không hợp lệ.
|
||||
if (
|
||||
"Photo_invalid_dimensions" in err_msg
|
||||
or "Photo invalid dimensions" in err_msg
|
||||
):
|
||||
logger.warning("[Telegram] 图片尺寸超限,正在尝试以文件形式发送...")
|
||||
# 构造一个更有意义的文件名
|
||||
logger.warning(
|
||||
"[Telegram] Kích thước ảnh vượt giới hạn, thử gửi dạng tệp..."
|
||||
)
|
||||
# Tạo tên tệp dễ hiểu hơn.
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
fn = f"analysis_report_{group_id}_{ts}.png"
|
||||
return await self.send_file(group_id, image_path, filename=fn)
|
||||
|
||||
logger.error(f"[Telegram] 发送图片失败: {e}")
|
||||
logger.error(f"[Telegram] Gửi ảnh thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def send_file(
|
||||
@@ -612,10 +600,10 @@ class TelegramAdapter(PlatformAdapter):
|
||||
file_path: str,
|
||||
filename: str | None = None,
|
||||
) -> bool:
|
||||
"""发送文件消息"""
|
||||
"""Gửi tin nhắn tệp."""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
logger.error("[Telegram] 客户端未初始化,无法发送文件")
|
||||
logger.error("[Telegram] Client chưa khởi tạo, không thể gửi tệp")
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -627,7 +615,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if message_thread_id:
|
||||
kwargs["message_thread_id"] = int(message_thread_id)
|
||||
|
||||
# 1. 统一处理输入源 (Base64 / Local File)
|
||||
# 1. Xử lý thống nhất nguồn Base64 hoặc tệp local.
|
||||
if file_path.startswith("base64://"):
|
||||
data = base64.b64decode(file_path[len("base64://") :])
|
||||
file_obj = BytesIO(data)
|
||||
@@ -648,7 +636,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if not filename:
|
||||
filename = os.path.basename(file_path)
|
||||
else:
|
||||
# 可能是 URL 或缓存 ID
|
||||
# Có thể là URL hoặc cache ID.
|
||||
file_obj = file_path
|
||||
if not filename:
|
||||
filename = "file"
|
||||
@@ -664,25 +652,25 @@ class TelegramAdapter(PlatformAdapter):
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 发送文件失败: {e}")
|
||||
logger.error(f"[Telegram] Gửi tệp thất bại: {e}")
|
||||
return False
|
||||
|
||||
async def send_forward_msg(self, group_id: str, nodes: list[dict]) -> bool:
|
||||
"""
|
||||
发送合并转发消息
|
||||
Gửi tin nhắn chuyển tiếp đã gộp.
|
||||
|
||||
Telegram 不支持原生转发消息链,转换为格式化文本发送。
|
||||
Telegram không hỗ trợ chuỗi chuyển tiếp native nên gửi dạng văn bản định dạng.
|
||||
"""
|
||||
if not nodes:
|
||||
return True
|
||||
|
||||
lines = ["📊 **分析报告**\n"]
|
||||
lines = ["📊 **Báo cáo phân tích**\n"]
|
||||
for node in nodes:
|
||||
data = node.get("data", node)
|
||||
name = data.get("name", "AstrBot")
|
||||
content = data.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# 消息链
|
||||
# Chuỗi tin nhắn.
|
||||
text_parts = []
|
||||
for seg in content:
|
||||
if isinstance(seg, dict) and seg.get("type") == "text":
|
||||
@@ -692,7 +680,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
|
||||
full_text = "\n".join(lines)
|
||||
|
||||
# 分段发送(Telegram 限制 4096 字符)
|
||||
# Chia đoạn vì Telegram giới hạn 4096 ký tự.
|
||||
max_len = 4000
|
||||
if len(full_text) > max_len:
|
||||
parts = [
|
||||
@@ -708,7 +696,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
# ==================== IGroupInfoRepository ====================
|
||||
|
||||
async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
|
||||
"""获取群组信息"""
|
||||
"""Lấy thông tin nhóm."""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
return None
|
||||
@@ -725,14 +713,14 @@ class TelegramAdapter(PlatformAdapter):
|
||||
platform="telegram",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 获取群信息失败: {e}")
|
||||
logger.debug(f"[Telegram] Lấy thông tin nhóm thất bại: {e}")
|
||||
return None
|
||||
|
||||
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
|
||||
"""
|
||||
获取成员列表
|
||||
Lấy danh sách thành viên.
|
||||
|
||||
Telegram Bot API 对成员列表获取有限制。
|
||||
Telegram Bot API giới hạn việc lấy danh sách thành viên.
|
||||
"""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
@@ -740,8 +728,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
|
||||
try:
|
||||
chat_id, _ = self._parse_group_id(group_id)
|
||||
# Telegram Bot API 需要使用 getChatAdministrators
|
||||
# 只能获取管理员列表,无法获取全部成员
|
||||
# Telegram Bot API chỉ cho lấy danh sách quản trị viên.
|
||||
admins = await client.get_chat_administrators(chat_id=chat_id)
|
||||
|
||||
members = []
|
||||
@@ -760,7 +747,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
)
|
||||
return members
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 获取成员列表失败: {e}")
|
||||
logger.debug(f"[Telegram] Lấy danh sách thành viên thất bại: {e}")
|
||||
return []
|
||||
|
||||
async def get_member_info(
|
||||
@@ -768,7 +755,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
) -> UnifiedMember | None:
|
||||
"""获取成员信息"""
|
||||
"""Lấy thông tin thành viên."""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
return None
|
||||
@@ -794,7 +781,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
role=role,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 获取成员信息失败: {e}")
|
||||
logger.debug(f"[Telegram] Lấy thông tin thành viên thất bại: {e}")
|
||||
return None
|
||||
|
||||
# ==================== IAvatarRepository ====================
|
||||
@@ -805,14 +792,14 @@ class TelegramAdapter(PlatformAdapter):
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""
|
||||
获取用户头像 URL
|
||||
Lấy URL avatar người dùng.
|
||||
|
||||
Telegram 需要调用 API 获取头像文件。
|
||||
Telegram cần gọi API để lấy tệp avatar.
|
||||
"""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
logger.warning(
|
||||
f"[Telegram] 获取用户头像失败 uid={user_id}: Telegram 客户端未初始化"
|
||||
f"[Telegram] Lấy avatar người dùng thất bại uid={user_id}: client chưa khởi tạo"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -820,67 +807,72 @@ class TelegramAdapter(PlatformAdapter):
|
||||
cached_reason = self._get_avatar_negative_cache_reason(user_id_str)
|
||||
if cached_reason:
|
||||
logger.debug(
|
||||
f"[Telegram] 跳过用户头像获取 uid={user_id_str}: negative cache 命中,"
|
||||
f"上次失败原因: {cached_reason}"
|
||||
f"[Telegram] Bỏ qua avatar uid={user_id_str}: trúng negative cache, "
|
||||
f"lý do thất bại trước: {cached_reason}"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
tg_user_id = int(user_id_str)
|
||||
except (TypeError, ValueError):
|
||||
reason = f"用户 ID 不是有效整数: {user_id!r}"
|
||||
reason = f"ID người dùng không phải số nguyên hợp lệ: {user_id!r}"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(f"[Telegram] 获取用户头像失败 uid={user_id}: {reason}")
|
||||
logger.warning(
|
||||
f"[Telegram] Lấy avatar người dùng thất bại uid={user_id}: {reason}"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
photos = await client.get_user_profile_photos(user_id=tg_user_id, limit=1)
|
||||
if photos.photos:
|
||||
# 获取最大尺寸的头像
|
||||
# Lấy avatar kích thước lớn nhất.
|
||||
photo_sizes = photos.photos[0]
|
||||
if photo_sizes:
|
||||
# 选择最接近请求尺寸的
|
||||
best = photo_sizes[-1] # 通常最后一个是最大的
|
||||
# Chọn kích thước gần yêu cầu nhất.
|
||||
best = photo_sizes[-1] # Phần tử cuối thường lớn nhất.
|
||||
file = await client.get_file(best.file_id)
|
||||
if file.file_path:
|
||||
# 构建完整 URL
|
||||
# 格式: https://api.telegram.org/file/bot<token>/<file_path>
|
||||
# python-telegram-bot 的 File.file_path 属性通常只返回路径部分
|
||||
# 需要手动拼接或使用 instance.file.file_path (取决于版本)
|
||||
# Dựng URL đầy đủ; File.file_path thường chỉ trả phần path.
|
||||
|
||||
file_path = file.file_path
|
||||
if file_path.startswith("http"):
|
||||
return file_path
|
||||
|
||||
# 尝试构建完整 URL
|
||||
# Thử dựng URL đầy đủ.
|
||||
if hasattr(client, "token"):
|
||||
return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
|
||||
|
||||
# 如果无法获取 token,返回 None
|
||||
reason = "get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL"
|
||||
# Trả None nếu không lấy được token.
|
||||
reason = "get_file trả file_path tương đối nhưng client không có token để dựng URL tải"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(
|
||||
f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}"
|
||||
f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}"
|
||||
)
|
||||
return None
|
||||
reason = "get_file 未返回 file_path"
|
||||
reason = "get_file không trả file_path"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(
|
||||
f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}"
|
||||
f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}"
|
||||
)
|
||||
return None
|
||||
reason = "get_user_profile_photos 返回的首张头像没有可用尺寸"
|
||||
reason = "Avatar đầu tiên từ get_user_profile_photos không có kích thước khả dụng"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.info(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}")
|
||||
logger.info(
|
||||
f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}"
|
||||
)
|
||||
return None
|
||||
reason = "get_user_profile_photos 返回空列表,用户可能没有公开头像或隐私设置不可见"
|
||||
reason = "get_user_profile_photos trả danh sách rỗng; avatar có thể không công khai"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.info(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}")
|
||||
logger.info(
|
||||
f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
reason = f"{type(e).__name__}: {e}"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}")
|
||||
logger.warning(
|
||||
f"[Telegram] Lấy avatar người dùng thất bại uid={user_id_str}: {reason}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_user_avatar_data(
|
||||
@@ -888,10 +880,10 @@ class TelegramAdapter(PlatformAdapter):
|
||||
user_id: str,
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""获取头像的 Base64 数据"""
|
||||
# 暂不实现,返回 None
|
||||
"""Lấy dữ liệu Base64 của avatar."""
|
||||
# Chưa triển khai, trả về None.
|
||||
logger.debug(
|
||||
f"[Telegram] 获取用户头像数据失败 uid={user_id}: get_user_avatar_data 暂未实现"
|
||||
f"[Telegram] Không thể lấy dữ liệu avatar uid={user_id}: get_user_avatar_data chưa triển khai"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -900,11 +892,11 @@ class TelegramAdapter(PlatformAdapter):
|
||||
group_id: str,
|
||||
size: int = 100,
|
||||
) -> str | None:
|
||||
"""获取群组头像 URL"""
|
||||
"""Lấy URL avatar nhóm."""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: Telegram 客户端未初始化"
|
||||
f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: client chưa khởi tạo"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -923,26 +915,26 @@ class TelegramAdapter(PlatformAdapter):
|
||||
return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
|
||||
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: "
|
||||
"get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL"
|
||||
f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: "
|
||||
"get_file trả file_path tương đối nhưng client không có token để dựng URL tải"
|
||||
)
|
||||
return None
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: get_file 未返回 file_path"
|
||||
f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: get_file không trả file_path"
|
||||
)
|
||||
return None
|
||||
logger.info(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: 群组未设置头像或 bot 不可见"
|
||||
f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: nhóm chưa đặt avatar hoặc bot không thấy"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: {type(e).__name__}: {e}"
|
||||
f"[Telegram] Lấy avatar nhóm thất bại group_id={group_id}: {type(e).__name__}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _prune_avatar_negative_cache(self) -> None:
|
||||
"""清理过期项并限制 negative cache 大小,避免长期运行时无界增长。"""
|
||||
"""Xoá mục hết hạn và giới hạn negative cache để tránh tăng vô hạn."""
|
||||
cache = self._avatar_negative_cache
|
||||
if not cache:
|
||||
return
|
||||
@@ -988,11 +980,11 @@ class TelegramAdapter(PlatformAdapter):
|
||||
user_ids: list[str],
|
||||
size: int = 100,
|
||||
) -> dict[str, str | None]:
|
||||
"""批量获取头像 URL"""
|
||||
"""Lấy hàng loạt URL avatar."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
# 适度并发,避免串行等待过久,也避免瞬时过载 Telegram API
|
||||
# Giới hạn concurrency để tránh chờ tuần tự lâu hoặc quá tải Telegram API.
|
||||
semaphore = asyncio.Semaphore(8)
|
||||
|
||||
async def _fetch_avatar(uid: str) -> tuple[str, str | None]:
|
||||
@@ -1000,7 +992,9 @@ class TelegramAdapter(PlatformAdapter):
|
||||
try:
|
||||
return uid, await self.get_user_avatar_url(uid, size)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 批量获取头像失败 uid={uid}: {e}")
|
||||
logger.debug(
|
||||
f"[Telegram] Lấy avatar hàng loạt thất bại uid={uid}: {e}"
|
||||
)
|
||||
return uid, None
|
||||
|
||||
pairs = await asyncio.gather(*(_fetch_avatar(uid) for uid in user_ids))
|
||||
@@ -1010,7 +1004,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Telegram 实现消息回应。
|
||||
Triển khai reaction tin nhắn Telegram.
|
||||
"""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
@@ -1019,7 +1013,7 @@ class TelegramAdapter(PlatformAdapter):
|
||||
try:
|
||||
chat_id, _ = self._parse_group_id(group_id)
|
||||
|
||||
# 只有开启了库支持且版本符合时才尝试。set_message_reaction 是 Bot API 7.0 (PTB 20.8+) 特性。
|
||||
# set_message_reaction cần Bot API 7.0 và PTB 20.8 trở lên.
|
||||
if not hasattr(client, "set_message_reaction"):
|
||||
return False
|
||||
|
||||
@@ -1079,20 +1073,20 @@ class TelegramAdapter(PlatformAdapter):
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"[Telegram] set_reaction 未匹配到可用表情: emoji={emoji}, candidates={candidates}"
|
||||
f"[Telegram] set_reaction không khớp emoji khả dụng: emoji={emoji}, candidates={candidates}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] set_reaction 失败: {e}")
|
||||
logger.debug(f"[Telegram] set_reaction thất bại: {e}")
|
||||
return False
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
# ==================== Phương thức hỗ trợ ====================
|
||||
|
||||
def _parse_group_id(self, group_id: str) -> tuple[str, str | None]:
|
||||
"""
|
||||
解析群组 ID
|
||||
Parse ID nhóm.
|
||||
|
||||
Telegram 话题群的 ID 格式为: "chat_id#thread_id"
|
||||
ID nhóm topic Telegram có dạng ``chat_id#thread_id``.
|
||||
|
||||
Returns:
|
||||
tuple[str, str | None]: (chat_id, message_thread_id)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
平台适配器基类
|
||||
"""
|
||||
"""Lớp cơ sở cho adapter nền tảng."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
@@ -20,14 +18,14 @@ class PlatformAdapter(
|
||||
IMessageRepository, IMessageSender, IGroupInfoRepository, IAvatarRepository, ABC
|
||||
):
|
||||
"""
|
||||
基础设施:平台适配器基类
|
||||
Lớp cơ sở adapter ở tầng infrastructure.
|
||||
|
||||
继承自多个领域接口(仓储、发送器、群组信息、头像),
|
||||
充当领域层与具体聊天平台(如 OneBot, Discord)之间的中转站。
|
||||
Kế thừa các giao diện domain về repository, gửi tin nhắn, thông tin nhóm
|
||||
và avatar; làm cầu nối giữa domain với nền tảng như OneBot hoặc Discord.
|
||||
|
||||
Attributes:
|
||||
bot (Any): 平台对应的机器人 SDK 实例,显式标注为 Any 以支持动态属性调用
|
||||
config (dict): 针对该平台的特定配置
|
||||
bot: Instance SDK bot của nền tảng.
|
||||
config: Cấu hình riêng của nền tảng.
|
||||
"""
|
||||
|
||||
bot: Any
|
||||
@@ -38,11 +36,11 @@ class PlatformAdapter(
|
||||
config: Mapping[str, Any] | None = None,
|
||||
):
|
||||
"""
|
||||
初始化平台适配器。
|
||||
Khởi tạo adapter nền tảng.
|
||||
|
||||
Args:
|
||||
bot_instance (Any): 后端机器人实例
|
||||
config (dict, optional): 平台特定配置项
|
||||
bot_instance: Instance bot backend.
|
||||
config: Cấu hình riêng của nền tảng.
|
||||
"""
|
||||
self.bot = bot_instance
|
||||
self.config: dict[str, object] = dict(config) if config is not None else {}
|
||||
@@ -51,22 +49,22 @@ class PlatformAdapter(
|
||||
|
||||
def set_context(self, context: Any):
|
||||
"""
|
||||
设置上下文对象(用于部分需要 ctx 的平台如 Telegram)。
|
||||
Thiết lập context cho nền tảng cần context như Telegram.
|
||||
|
||||
Args:
|
||||
context (Any): 上下文对象
|
||||
context: Đối tượng context.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def capabilities(self) -> PlatformCapabilities:
|
||||
"""
|
||||
获取当前平台的能力描述对象。
|
||||
Lấy mô tả năng lực của nền tảng hiện tại.
|
||||
|
||||
采用延迟加载机制,在首次访问时调用 `_init_capabilities`。
|
||||
Dùng lazy loading và gọi ``_init_capabilities`` khi truy cập lần đầu.
|
||||
|
||||
Returns:
|
||||
PlatformCapabilities: 平台能力对象
|
||||
Đối tượng năng lực nền tảng.
|
||||
"""
|
||||
if self._capabilities is None:
|
||||
self._capabilities = self._init_capabilities()
|
||||
@@ -75,35 +73,35 @@ class PlatformAdapter(
|
||||
@abstractmethod
|
||||
def _init_capabilities(self) -> PlatformCapabilities:
|
||||
"""
|
||||
初始化并返回当前平台的能力定义。
|
||||
Khởi tạo và trả về định nghĩa năng lực nền tảng hiện tại.
|
||||
|
||||
子类必须实现此方法以声明其对历史记录、图片发送等功能的支持情况。
|
||||
Lớp con phải khai báo hỗ trợ lịch sử, gửi ảnh và các tính năng khác.
|
||||
|
||||
Returns:
|
||||
PlatformCapabilities: 初始化后的能力对象
|
||||
Đối tượng năng lực đã khởi tạo.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_capabilities(self) -> PlatformCapabilities:
|
||||
"""获取平台能力的便捷入口。"""
|
||||
"""Điểm vào tiện lợi để lấy năng lực nền tảng."""
|
||||
return self.capabilities
|
||||
|
||||
def get_platform_name(self) -> str:
|
||||
"""获取当前适配器的平台标识名称。"""
|
||||
"""Lấy tên định danh nền tảng của adapter hiện tại."""
|
||||
return self.capabilities.platform_name
|
||||
|
||||
@abstractmethod
|
||||
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||
"""
|
||||
将平台无关的统一消息列表转换回当前平台的原生字典格式。
|
||||
Chuyển tin nhắn thống nhất độc lập nền tảng về dict gốc của nền tảng.
|
||||
|
||||
此方法主要用于向后兼容,使新的统一接口能与依赖原生数据结构的旧版分析逻辑协同工作。
|
||||
Dùng để tương thích ngược với logic phân tích cũ phụ thuộc cấu trúc gốc.
|
||||
|
||||
Args:
|
||||
messages (list[UnifiedMessage]): 待转换的统一消息列表
|
||||
messages: Danh sách tin nhắn thống nhất cần chuyển.
|
||||
|
||||
Returns:
|
||||
list[dict]: 转换后的平台原生消息字典列表
|
||||
Danh sách dict tin nhắn gốc của nền tảng.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -113,13 +111,14 @@ class PlatformAdapter(
|
||||
nodes: list[dict],
|
||||
) -> bool:
|
||||
"""
|
||||
发送合并转发消息(基类默认实现:转换为格式化文本分段发送)。
|
||||
各适配器可覆盖此方法实现原生合并转发。
|
||||
Gửi tin nhắn chuyển tiếp gộp.
|
||||
|
||||
Mặc định chuyển thành văn bản có định dạng và chia đoạn; adapter có thể ghi đè.
|
||||
"""
|
||||
if not nodes:
|
||||
return True
|
||||
|
||||
# 万能回退:将节点重新组合成易读的长文本
|
||||
# Fallback chung: ghép node thành văn bản dài dễ đọc.
|
||||
lines = []
|
||||
for node in nodes:
|
||||
data = node.get("data", node)
|
||||
@@ -130,14 +129,14 @@ class PlatformAdapter(
|
||||
|
||||
full_text = "\n\n".join(lines)
|
||||
|
||||
# 处理超长文本分段(取大部分平台的安全阈值 1800 字符)
|
||||
# Chia văn bản dài ở ngưỡng an toàn 1.800 ký tự.
|
||||
max_chunk_size = 1800
|
||||
if len(full_text) > max_chunk_size:
|
||||
# 尝试在换行处拆分
|
||||
# Thử tách tại ký tự xuống dòng.
|
||||
chunks = []
|
||||
curr = full_text
|
||||
while len(curr) > max_chunk_size:
|
||||
# 寻找最近的换行符
|
||||
# Tìm ký tự xuống dòng gần nhất.
|
||||
split_idx = curr.rfind("\n", 0, max_chunk_size)
|
||||
if split_idx == -1:
|
||||
split_idx = max_chunk_size
|
||||
@@ -157,32 +156,33 @@ class PlatformAdapter(
|
||||
self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
对消息添加/移除表情回应。
|
||||
Thêm hoặc xoá reaction cho tin nhắn.
|
||||
|
||||
Args:
|
||||
group_id (str): 群组/频道 ID
|
||||
message_id (str): 消息 ID
|
||||
emoji (str | int): 表情代码或字符
|
||||
is_add (bool): True 为添加,False 为移除
|
||||
group_id: ID nhóm/kênh.
|
||||
message_id: ID tin nhắn.
|
||||
emoji: Mã hoặc ký tự emoji.
|
||||
is_add: True để thêm, False để xoá.
|
||||
|
||||
Returns:
|
||||
bool: 平台是否支持并成功执行
|
||||
True nếu nền tảng hỗ trợ và thực thi thành công.
|
||||
"""
|
||||
return False
|
||||
|
||||
async def send_text_report(self, group_id: str, content: str) -> bool:
|
||||
"""
|
||||
以最适合当前平台的方式发送长文本报告。
|
||||
默认逻辑:将长文本切分为多个节点,然后调用 send_forward_msg。
|
||||
各平台适配器通过实现 send_forward_msg 来决定最终呈现形式(合并转发、分段发送等)。
|
||||
Gửi báo cáo văn bản dài theo cách phù hợp nhất với nền tảng.
|
||||
|
||||
Mặc định chia thành node rồi gọi ``send_forward_msg``; adapter quyết định
|
||||
hình thức cuối như chuyển tiếp gộp hoặc gửi theo đoạn.
|
||||
"""
|
||||
import re
|
||||
|
||||
try:
|
||||
# 1. 准备节点基础信息
|
||||
# 1. Chuẩn bị thông tin node cơ bản.
|
||||
self_id = self.bot_self_ids[0] if self.bot_self_ids else "bot"
|
||||
self_name = "分析报告"
|
||||
# 2. 切分文本为逻辑段落(按标题、空行切分)
|
||||
self_name = "Báo cáo phân tích"
|
||||
# 2. Chia thành đoạn logic theo tiêu đề và dòng trống.
|
||||
raw_content = str(content)
|
||||
sections = re.split(r"\n+(?=[🎯📊💬🏆])|\n{2,}", raw_content.strip())
|
||||
nodes = []
|
||||
@@ -204,15 +204,16 @@ class PlatformAdapter(
|
||||
if not nodes:
|
||||
return await self.send_text(group_id, raw_content)
|
||||
|
||||
# 3. 尝试发送转发消息/长消息链
|
||||
# 3. Thử gửi chuyển tiếp hoặc chuỗi tin nhắn dài.
|
||||
return await self.send_forward_msg(group_id, nodes)
|
||||
except Exception:
|
||||
# 兜底:直接发送
|
||||
# Fallback: gửi trực tiếp.
|
||||
return await self.send_text(group_id, str(content))
|
||||
|
||||
async def is_group_muted(self, group_id: str) -> bool:
|
||||
"""
|
||||
检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)。
|
||||
默认返回 False。各平台适配器可以根据需要重写此方法。
|
||||
Kiểm tra nhóm có tắt chat toàn bộ hoặc tắt quyền bot hay không.
|
||||
|
||||
Mặc định trả về False; adapter có thể ghi đè khi cần.
|
||||
"""
|
||||
return False
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
Bot实例管理模块 - 基础设施层
|
||||
统一管理bot实例的获取、设置和使用
|
||||
"""
|
||||
"""Quản lý tập trung các bot instance ở tầng infrastructure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,50 +9,47 @@ from . import PlatformAdapter, PlatformAdapterFactory
|
||||
|
||||
|
||||
class BotManager:
|
||||
"""
|
||||
Bot实例管理器 - 统一管理所有bot相关操作
|
||||
|
||||
与 DDD 架构集成,为每个 bot 实例创建对应的 PlatformAdapter,
|
||||
实现跨平台支持。
|
||||
"""
|
||||
"""Quản lý bot và tạo PlatformAdapter tương ứng để hỗ trợ đa nền tảng."""
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self.config_manager = config_manager
|
||||
self._bot_instances: dict[str, object] = {} # {platform_id: bot_instance}
|
||||
self._adapters: dict[
|
||||
str, PlatformAdapter
|
||||
] = {} # {platform_id: PlatformAdapter} - DDD 集成
|
||||
self._platforms: dict[str, object] = {} # 存储平台对象以访问配置
|
||||
self._bot_self_ids: list[str] = [] # 支持多个机器人账号 ID (原 _bot_qq_ids)
|
||||
] = {} # {platform_id: PlatformAdapter} - tích hợp DDD.
|
||||
self._platforms: dict[str, object] = {} # Lưu platform để truy cập cấu hình.
|
||||
self._bot_self_ids: list[
|
||||
str
|
||||
] = [] # Hỗ trợ nhiều ID bot, trước đây là _bot_qq_ids.
|
||||
self._context: object | None = None
|
||||
self._is_initialized = False
|
||||
self._default_platform = "default" # 默认平台
|
||||
self._plugin_instance: object | None = None # 插件实例引用,用于适配器回调
|
||||
self._default_platform = "default" # Nền tảng mặc định.
|
||||
self._plugin_instance: object | None = None # Dùng cho callback adapter.
|
||||
|
||||
def set_context(self, context):
|
||||
"""设置AstrBot上下文,并传递给所有支持的适配器"""
|
||||
"""Thiết lập context AstrBot và truyền tới adapter có hỗ trợ."""
|
||||
self._context = context
|
||||
|
||||
# 将 context 传递给所有支持 set_context 的适配器
|
||||
# Truyền context tới mọi adapter hỗ trợ set_context.
|
||||
for adapter in self._adapters.values():
|
||||
if hasattr(adapter, "set_context"):
|
||||
adapter.set_context(context)
|
||||
|
||||
def set_plugin_instance(self, plugin_instance: object):
|
||||
"""设置插件实例引用"""
|
||||
"""Thiết lập tham chiếu plugin instance."""
|
||||
self._plugin_instance = plugin_instance
|
||||
|
||||
def set_bot_instance(self, bot_instance, platform_id=None, platform_name=None):
|
||||
"""
|
||||
设置bot实例,支持指定平台ID
|
||||
Thiết lập bot instance với platform ID tùy chọn.
|
||||
|
||||
同时会创建对应的 PlatformAdapter(如果平台被支持)。
|
||||
Đồng thời tạo PlatformAdapter nếu nền tảng được hỗ trợ.
|
||||
"""
|
||||
if not platform_id:
|
||||
platform_id = self._get_platform_id_from_instance(bot_instance)
|
||||
|
||||
if bot_instance and platform_id:
|
||||
# 如果 bot_instance 没变,且已经有适配器,跳过重新创建,防止丢失内部状态(如缓存等)
|
||||
# Không tạo lại adapter nếu instance không đổi để giữ trạng thái nội bộ.
|
||||
old_instance = self._bot_instances.get(platform_id)
|
||||
if bot_instance is old_instance and platform_id in self._adapters:
|
||||
bot_self_id = self._extract_bot_self_id(bot_instance)
|
||||
@@ -65,7 +59,7 @@ class BotManager:
|
||||
|
||||
self._bot_instances[platform_id] = bot_instance
|
||||
|
||||
# 为 DDD 集成创建 PlatformAdapter
|
||||
# Tạo PlatformAdapter để tích hợp DDD.
|
||||
if platform_name is None:
|
||||
platform_name = self._detect_platform_name(bot_instance)
|
||||
|
||||
@@ -84,91 +78,91 @@ class BotManager:
|
||||
platform_name, bot_instance, adapter_config
|
||||
)
|
||||
if adapter:
|
||||
# 如果有 context,传递给适配器
|
||||
# Truyền context tới adapter nếu có.
|
||||
if self._context is not None:
|
||||
adapter.set_context(self._context)
|
||||
self._adapters[platform_id] = adapter
|
||||
logger.debug(
|
||||
f"已为 {platform_id} ({platform_name}) 创建 PlatformAdapter"
|
||||
f"Đã tạo PlatformAdapter cho {platform_id} ({platform_name})"
|
||||
)
|
||||
|
||||
# 自动提取机器人 ID
|
||||
# Tự trích xuất ID bot.
|
||||
bot_self_id = self._extract_bot_self_id(bot_instance)
|
||||
if bot_self_id and bot_self_id not in self._bot_self_ids:
|
||||
self._bot_self_ids.append(str(bot_self_id))
|
||||
|
||||
def set_bot_self_ids(self, bot_self_ids):
|
||||
"""设置机器人 ID 列表(支持单个 ID 或 ID 列表)"""
|
||||
"""Thiết lập một ID bot hoặc danh sách ID bot."""
|
||||
if isinstance(bot_self_ids, list):
|
||||
self._bot_self_ids = [str(uid) for uid in bot_self_ids if uid]
|
||||
elif bot_self_ids:
|
||||
self._bot_self_ids = [str(bot_self_ids)]
|
||||
|
||||
# 同步更新所有现有适配器的 ID 列表
|
||||
# Đồng bộ danh sách ID tới mọi adapter hiện có.
|
||||
for adapter in self._adapters.values():
|
||||
if hasattr(adapter, "bot_self_ids"):
|
||||
adapter.bot_self_ids = self._bot_self_ids.copy()
|
||||
|
||||
def get_bot_instance(self, platform_id=None):
|
||||
"""获取指定平台的bot实例,如果不指定则返回第一个可用的实例"""
|
||||
"""Lấy bot instance theo platform hoặc instance khả dụng duy nhất."""
|
||||
if platform_id:
|
||||
# 如果指定了平台ID,尝试获取
|
||||
# Thử lấy theo platform ID được chỉ định.
|
||||
instance = self._bot_instances.get(platform_id)
|
||||
if not instance and platform_id in self._platforms:
|
||||
self._refresh_from_stored_platforms()
|
||||
instance = self._bot_instances.get(platform_id)
|
||||
return instance
|
||||
|
||||
# 没有指定平台ID
|
||||
# Không có platform ID.
|
||||
if not self._bot_instances and self._platforms:
|
||||
self._refresh_from_stored_platforms()
|
||||
|
||||
if self._bot_instances:
|
||||
# 如果只有一个实例,直接返回
|
||||
# Trả trực tiếp nếu chỉ có một instance.
|
||||
if len(self._bot_instances) == 1:
|
||||
return list(self._bot_instances.values())[0]
|
||||
|
||||
# 如果有多个实例,必须指定 platform_id
|
||||
# Bắt buộc chỉ định platform_id khi có nhiều instance.
|
||||
logger.error(
|
||||
f"存在多个Bot实例 {list(self._bot_instances.keys())} 但未指定 platform_id,"
|
||||
"无法确定使用哪个实例。请明确指定 platform_id。"
|
||||
f"Có nhiều bot instance {list(self._bot_instances.keys())} nhưng chưa chỉ định platform_id; "
|
||||
"không thể xác định instance cần dùng"
|
||||
)
|
||||
return None
|
||||
|
||||
# 没有任何平台可用
|
||||
logger.error("没有任何可用的bot实例")
|
||||
# Không có nền tảng khả dụng.
|
||||
logger.error("Không có bot instance khả dụng")
|
||||
return None
|
||||
|
||||
def _refresh_from_stored_platforms(self):
|
||||
"""尝试从已存储的平台对象中刷新 bot 实例 (Lazy Load)"""
|
||||
"""Thử làm mới bot instance từ platform đã lưu theo lazy load."""
|
||||
for platform_id, platform in self._platforms.items():
|
||||
bot_client = None
|
||||
# Lark 平台优先使用 API client,避免拿到仅支持长连接的 ws client
|
||||
# Lark ưu tiên API client thay vì ws client chỉ hỗ trợ kết nối dài.
|
||||
bot_client = getattr(platform, "lark_api", None)
|
||||
# 优先尝试 get_client()
|
||||
# Ưu tiên get_client().
|
||||
get_client = getattr(platform, "get_client", None)
|
||||
if not bot_client and callable(get_client):
|
||||
bot_client = get_client()
|
||||
|
||||
# 如果 get_client() 返回 None,尝试直接访问属性
|
||||
# Nếu get_client() trả None, thử truy cập thuộc tính trực tiếp.
|
||||
if not bot_client:
|
||||
bot_client = getattr(platform, "bot", None)
|
||||
if not bot_client:
|
||||
# AstrBot v4.14.4 DiscordPlatformAdapter 使用 'client' 属性
|
||||
# DiscordPlatformAdapter AstrBot v4.14.4 dùng thuộc tính client.
|
||||
bot_client = getattr(platform, "client", None)
|
||||
|
||||
if bot_client:
|
||||
# 检查是否已存在且是否发生变化(防止重复创建适配器)
|
||||
# Kiểm tra client có thay đổi để tránh tạo adapter lặp.
|
||||
old_client = self._bot_instances.get(platform_id)
|
||||
|
||||
# 如果 client 对象没变且已经有适配器,跳过
|
||||
# Bỏ qua nếu client không đổi và adapter đã tồn tại.
|
||||
if bot_client is old_client and platform_id in self._adapters:
|
||||
continue
|
||||
|
||||
platform_name = None
|
||||
metadata_obj = getattr(platform, "metadata", None)
|
||||
if metadata_obj is not None:
|
||||
# 优先使用 type
|
||||
# Ưu tiên type.
|
||||
type_val = getattr(metadata_obj, "type", None)
|
||||
if isinstance(type_val, str):
|
||||
platform_name = type_val
|
||||
@@ -177,7 +171,7 @@ class BotManager:
|
||||
if isinstance(name_val, str):
|
||||
platform_name = name_val
|
||||
|
||||
# 兼容不同版本的元数据获取
|
||||
# Tương thích cách lấy metadata ở các phiên bản.
|
||||
if not platform_name:
|
||||
meta = getattr(platform, "meta", None)
|
||||
if callable(meta):
|
||||
@@ -187,7 +181,7 @@ class BotManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 后备检测:如果不支持名称
|
||||
# Phát hiện fallback nếu tên không được hỗ trợ.
|
||||
if not platform_name or not PlatformAdapterFactory.is_supported(
|
||||
str(platform_name)
|
||||
):
|
||||
@@ -196,76 +190,77 @@ class BotManager:
|
||||
platform_name = detected
|
||||
|
||||
self.set_bot_instance(bot_client, platform_id, platform_name)
|
||||
logger.info(f"已刷新/发现平台 {platform_id} 的 bot 实例 (变动或懒加载)")
|
||||
logger.info(
|
||||
f"Đã làm mới/phát hiện bot instance của platform {platform_id}"
|
||||
)
|
||||
|
||||
def get_all_bot_instances(self) -> dict:
|
||||
"""获取所有已加载的bot实例 {platform_id: bot_instance}"""
|
||||
"""Lấy mọi bot instance đã tải theo platform_id."""
|
||||
return self._bot_instances.copy()
|
||||
|
||||
def get_platform_count(self) -> int:
|
||||
"""获取当前已加载的平台数量"""
|
||||
"""Lấy số nền tảng hiện đã tải."""
|
||||
return len(self._bot_instances)
|
||||
|
||||
def get_platform_ids(self) -> list[str]:
|
||||
"""获取所有已加载的平台 ID 列表"""
|
||||
"""Lấy danh sách platform ID đã tải."""
|
||||
return list(self._bot_instances.keys())
|
||||
|
||||
def has_bot_instance(self) -> bool:
|
||||
"""检查是否有可用的bot实例"""
|
||||
"""Kiểm tra có bot instance khả dụng hay không."""
|
||||
return bool(self._bot_instances)
|
||||
|
||||
def has_bot_self_id(self) -> bool:
|
||||
"""检查是否有配置的机器人 ID"""
|
||||
"""Kiểm tra có ID bot đã cấu hình hay không."""
|
||||
return bool(self._bot_self_ids)
|
||||
|
||||
def is_ready_for_auto_analysis(self) -> bool:
|
||||
"""检查是否准备好进行了自动分析"""
|
||||
"""Kiểm tra đã sẵn sàng phân tích tự động hay chưa."""
|
||||
if not self.has_bot_instance():
|
||||
logger.debug("[BotManager] 自动分析就绪检查失败:没有可用的 bot 实例。")
|
||||
logger.debug(
|
||||
"[BotManager] Chưa sẵn sàng phân tích tự động: không có bot instance"
|
||||
)
|
||||
return False
|
||||
|
||||
if not self.has_bot_self_id():
|
||||
# 允许在没有配置/自动提取到 ID 的情况下尝试,但记录调试信息
|
||||
# 这有助于诊断某些平台(如 Telegram)自动获取 ID 失败的情况
|
||||
# Vẫn cho phép thử khi chưa cấu hình/trích xuất được ID và ghi debug.
|
||||
logger.debug(
|
||||
"[BotManager] 自动分析就绪检查警告:bot_self_ids 列表为空。可能会影响消息过滤能力。"
|
||||
"[BotManager] Cảnh báo sẵn sàng tự động: bot_self_ids rỗng, có thể ảnh hưởng lọc tin nhắn"
|
||||
)
|
||||
# 为了解决 #128 及其后续反馈,我们将此检查放宽
|
||||
# 只要有 bot 实例就可以尝试运行
|
||||
# Nới lỏng kiểm tra theo #128: có bot instance là có thể thử chạy.
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def _get_platform_id_from_instance(self, bot_instance):
|
||||
"""从bot实例获取平台ID"""
|
||||
"""Lấy platform ID từ bot instance."""
|
||||
if hasattr(bot_instance, "platform") and isinstance(bot_instance.platform, str):
|
||||
return bot_instance.platform
|
||||
return self._default_platform
|
||||
|
||||
def _detect_platform_name(self, bot_instance) -> str | None:
|
||||
"""
|
||||
从 bot 实例检测平台名称,用于创建适配器。
|
||||
Phát hiện tên nền tảng từ bot instance để tạo adapter.
|
||||
|
||||
返回平台名称如 'aiocqhttp', 'discord' 等。
|
||||
Trả về tên như ``aiocqhttp`` hoặc ``discord``.
|
||||
"""
|
||||
# 优先使用 platform 属性
|
||||
# Ưu tiên thuộc tính platform.
|
||||
if hasattr(bot_instance, "platform"):
|
||||
platform = bot_instance.platform
|
||||
if isinstance(platform, str):
|
||||
return platform
|
||||
|
||||
# 检查已知的 API 特征(平台无关的方式)
|
||||
# OneBot/aiocqhttp 特征: 有 call_action 方法
|
||||
# Kiểm tra đặc trưng API đã biết; OneBot/aiocqhttp có call_action.
|
||||
if hasattr(bot_instance, "call_action"):
|
||||
return "aiocqhttp"
|
||||
|
||||
# 使用工厂的已注册平台列表进行类名匹配
|
||||
# Khớp tên class với nền tảng đã đăng ký trong factory.
|
||||
class_name = type(bot_instance).__name__.lower()
|
||||
for platform_name in PlatformAdapterFactory.get_supported_platforms():
|
||||
if platform_name in class_name:
|
||||
return platform_name
|
||||
|
||||
# 通用类名模式匹配(用于尚未注册的平台)
|
||||
# Khớp mẫu tên class chung cho nền tảng chưa đăng ký.
|
||||
known_patterns = {
|
||||
"cqhttp": "aiocqhttp",
|
||||
"onebot": "aiocqhttp",
|
||||
@@ -276,16 +271,16 @@ class BotManager:
|
||||
|
||||
return None
|
||||
|
||||
# ==================== DDD 集成方法 ====================
|
||||
# ==================== Phương thức tích hợp DDD ====================
|
||||
|
||||
def get_adapter(self, platform_id: str | None = None) -> PlatformAdapter | None:
|
||||
"""
|
||||
获取指定平台的 PlatformAdapter。
|
||||
Lấy PlatformAdapter của nền tảng được chỉ định.
|
||||
|
||||
这是 DDD 架构操作的主要方法。
|
||||
Đây là phương thức chính cho thao tác kiến trúc DDD.
|
||||
"""
|
||||
if platform_id:
|
||||
# 无论是否存在适配器,都尝试检测一次 client 是否有变(如重启后 session 变化)
|
||||
# Luôn kiểm tra client có đổi hay không, ví dụ sau khi restart phiên.
|
||||
if platform_id in self._platforms:
|
||||
self._refresh_from_stored_platforms()
|
||||
|
||||
@@ -296,11 +291,11 @@ class BotManager:
|
||||
return list(self._adapters.values())[0]
|
||||
|
||||
logger.warning(
|
||||
f"存在多个适配器 {list(self._adapters.keys())},但未指定 platform_id。"
|
||||
f"Có nhiều adapter {list(self._adapters.keys())} nhưng chưa chỉ định platform_id"
|
||||
)
|
||||
return None
|
||||
|
||||
# 如果没有任何适配器,尝试全局刷新一次
|
||||
# Nếu không có adapter, thử làm mới toàn cục một lần.
|
||||
self._refresh_from_stored_platforms()
|
||||
if self._adapters:
|
||||
if platform_id:
|
||||
@@ -311,17 +306,17 @@ class BotManager:
|
||||
return None
|
||||
|
||||
def get_all_adapters(self) -> dict:
|
||||
"""获取所有 PlatformAdapter 实例 {platform_id: adapter}"""
|
||||
"""Lấy mọi PlatformAdapter theo platform_id."""
|
||||
return self._adapters.copy()
|
||||
|
||||
def has_adapter(self, platform_id: str | None = None) -> bool:
|
||||
"""检查指定平台是否有适配器"""
|
||||
"""Kiểm tra nền tảng chỉ định có adapter hay không."""
|
||||
if platform_id:
|
||||
return platform_id in self._adapters
|
||||
return bool(self._adapters)
|
||||
|
||||
def can_analyze(self, platform_id: str | None = None) -> bool:
|
||||
"""使用 DDD 能力检查平台是否支持分析"""
|
||||
"""Dùng capability DDD để kiểm tra nền tảng có hỗ trợ phân tích hay không."""
|
||||
adapter = self.get_adapter(platform_id)
|
||||
if adapter:
|
||||
return adapter.get_capabilities().can_analyze()
|
||||
@@ -329,16 +324,16 @@ class BotManager:
|
||||
|
||||
async def auto_discover_bot_instances(self):
|
||||
"""
|
||||
自动发现所有可用的bot实例
|
||||
Tự phát hiện mọi bot instance khả dụng.
|
||||
|
||||
同时为每个发现的 bot 创建对应的 PlatformAdapter。
|
||||
Đồng thời tạo PlatformAdapter tương ứng cho mỗi bot.
|
||||
"""
|
||||
platform_manager = getattr(self._context, "platform_manager", None)
|
||||
get_insts = getattr(platform_manager, "get_insts", None)
|
||||
if self._context is None or not callable(get_insts):
|
||||
return {}
|
||||
|
||||
# 使用新版 API 获取所有平台实例
|
||||
# Dùng API mới để lấy mọi platform instance.
|
||||
raw_platforms = get_insts()
|
||||
if isinstance(raw_platforms, list):
|
||||
platforms: list[object] = raw_platforms
|
||||
@@ -352,11 +347,11 @@ class BotManager:
|
||||
discovered = {}
|
||||
|
||||
logger.info(
|
||||
f"auto_discover_bot_instances: 在管理器中发现 {len(platforms)} 个平台。"
|
||||
f"auto_discover_bot_instances: phát hiện {len(platforms)} nền tảng trong manager"
|
||||
)
|
||||
|
||||
for platform in platforms:
|
||||
# 获取bot实例
|
||||
# Lấy bot instance.
|
||||
bot_client = None
|
||||
bot_client = getattr(platform, "lark_api", None)
|
||||
platform_get_client = getattr(platform, "get_client", None)
|
||||
@@ -368,7 +363,7 @@ class BotManager:
|
||||
if not bot_client:
|
||||
bot_client = getattr(platform, "client", None)
|
||||
|
||||
# 健壮地获取元数据
|
||||
# Lấy metadata an toàn.
|
||||
metadata = getattr(platform, "metadata", None)
|
||||
platform_meta_method = getattr(platform, "meta", None)
|
||||
if not metadata and callable(platform_meta_method):
|
||||
@@ -377,7 +372,7 @@ class BotManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否有有效的元数据和ID
|
||||
# Kiểm tra metadata và ID hợp lệ.
|
||||
platform_id = None
|
||||
if metadata:
|
||||
metadata_id = getattr(metadata, "id", None)
|
||||
@@ -387,17 +382,17 @@ class BotManager:
|
||||
platform_id = metadata.get("id")
|
||||
|
||||
if platform_id:
|
||||
# 确保平台 ID 是 str
|
||||
# Chuẩn hoá platform ID thành str.
|
||||
platform_id = str(platform_id)
|
||||
|
||||
# 知识点发现: 记录元数据以调试自定义 ID
|
||||
# Ghi metadata để debug ID tuỳ chỉnh.
|
||||
logger.info(
|
||||
f"[群分析插件 BotManager]: Log metadata for debugging custom IDs ,Platform: {platform_id}, Metadata Type: {getattr(metadata, 'type', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('type', 'N/A')}, Metadata Name: {getattr(metadata, 'name', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('name', 'N/A')}"
|
||||
f"[Plugin phân tích nhóm BotManager] Metadata debug ID tuỳ chỉnh, platform: {platform_id}, type: {getattr(metadata, 'type', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('type', 'N/A')}, name: {getattr(metadata, 'name', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('name', 'N/A')}"
|
||||
)
|
||||
|
||||
# 从元数据检测平台名称
|
||||
# Phát hiện tên nền tảng từ metadata.
|
||||
platform_name = None
|
||||
# 优先使用 type
|
||||
# Ưu tiên type.
|
||||
type_val = getattr(metadata, "type", None)
|
||||
if isinstance(type_val, str):
|
||||
platform_name = type_val
|
||||
@@ -414,7 +409,7 @@ class BotManager:
|
||||
if isinstance(dict_name, str):
|
||||
platform_name = dict_name
|
||||
|
||||
# 验证此平台名称是否受支持,如果不支持,尝试从bot实例检测(如果可用)
|
||||
# Nếu tên chưa được hỗ trợ, thử phát hiện từ bot instance.
|
||||
if (
|
||||
not platform_name
|
||||
or not PlatformAdapterFactory.is_supported(str(platform_name))
|
||||
@@ -424,47 +419,47 @@ class BotManager:
|
||||
platform_name = detected
|
||||
|
||||
logger.debug(
|
||||
f"发现平台: {platform_id} ({platform_name}), 客户端就绪: {bool(bot_client)}"
|
||||
f"Phát hiện platform: {platform_id} ({platform_name}), client sẵn sàng: {bool(bot_client)}"
|
||||
)
|
||||
|
||||
# 无论bot客户端状态如何,都存储平台实例
|
||||
# Luôn lưu platform instance dù client đã sẵn sàng hay chưa.
|
||||
self._platforms[platform_id] = platform
|
||||
|
||||
if bot_client:
|
||||
logger.info(
|
||||
f"[BotManager] 为平台 {platform_id} ({platform_name}) 注册 bot 实例"
|
||||
f"[BotManager] Đăng ký bot instance cho {platform_id} ({platform_name})"
|
||||
)
|
||||
self.set_bot_instance(bot_client, platform_id, platform_name)
|
||||
discovered[platform_id] = bot_client
|
||||
else:
|
||||
logger.info(
|
||||
f"发现平台 {platform_id} 但客户端未就绪。将进行懒加载。"
|
||||
f"Phát hiện platform {platform_id} nhưng client chưa sẵn sàng; sẽ lazy load"
|
||||
)
|
||||
discovered[platform_id] = platform
|
||||
|
||||
if self._adapters:
|
||||
logger.info(
|
||||
f"已创建 {len(self._adapters)} 个 PlatformAdapter: "
|
||||
f"Đã tạo {len(self._adapters)} PlatformAdapter: "
|
||||
f"{list(self._adapters.keys())}"
|
||||
)
|
||||
|
||||
return discovered
|
||||
|
||||
async def initialize_from_config(self):
|
||||
"""从配置初始化bot管理器"""
|
||||
# 设置配置的bot ID 列表
|
||||
"""Khởi tạo bot manager từ cấu hình."""
|
||||
# Thiết lập danh sách ID bot trong cấu hình.
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
if bot_self_ids:
|
||||
self.set_bot_self_ids(bot_self_ids)
|
||||
|
||||
# 自动发现所有bot实例
|
||||
# Tự phát hiện mọi bot instance.
|
||||
discovered = await self.auto_discover_bot_instances()
|
||||
self._is_initialized = True
|
||||
|
||||
return discovered
|
||||
|
||||
def get_status_info(self) -> dict[str, object]:
|
||||
"""获取bot管理器状态信息"""
|
||||
"""Lấy thông tin trạng thái bot manager."""
|
||||
adapter_info = {}
|
||||
for pid, adapter in self._adapters.items():
|
||||
caps = adapter.get_capabilities()
|
||||
@@ -479,17 +474,17 @@ class BotManager:
|
||||
"bot_self_ids": self._bot_self_ids,
|
||||
"platform_count": len(self._bot_instances),
|
||||
"platforms": list(self._bot_instances.keys()),
|
||||
"adapters": adapter_info, # DDD 集成信息
|
||||
"adapters": adapter_info, # Thông tin tích hợp DDD.
|
||||
"ready_for_auto_analysis": self.is_ready_for_auto_analysis(),
|
||||
}
|
||||
|
||||
def update_from_event(self, event):
|
||||
"""从事件更新bot实例(用于手动命令)"""
|
||||
# 兼容不同平台的 bot 实例属性名 (OneBot 使用 bot, Discord 使用 client)
|
||||
"""Cập nhật bot instance từ event cho command thủ công."""
|
||||
# Tương thích tên thuộc tính giữa các nền tảng.
|
||||
bot_instance = getattr(event, "bot", None) or getattr(event, "client", None)
|
||||
|
||||
if bot_instance:
|
||||
# 从事件中获取平台ID
|
||||
# Lấy platform ID từ event.
|
||||
platform_id = None
|
||||
if hasattr(event, "get_platform_id"):
|
||||
platform_id = event.get_platform_id()
|
||||
@@ -500,7 +495,7 @@ class BotManager:
|
||||
|
||||
self.set_bot_instance(bot_instance, platform_id)
|
||||
|
||||
# 优先从事件中提取机器人自身 ID,避免获取到 functools.partial 等异常对象
|
||||
# Ưu tiên lấy ID bot từ event, tránh object bất thường như functools.partial.
|
||||
bot_self_id = None
|
||||
if hasattr(event, "get_self_id"):
|
||||
val = event.get_self_id()
|
||||
@@ -516,10 +511,10 @@ class BotManager:
|
||||
bot_self_id = self._extract_bot_self_id(bot_instance)
|
||||
|
||||
if bot_self_id:
|
||||
# 将单个ID转换为列表,保持统一处理
|
||||
# Chuyển một ID thành list để xử lý thống nhất.
|
||||
self.set_bot_self_ids([bot_self_id])
|
||||
else:
|
||||
# 如果bot实例没有ID,尝试使用配置的ID列表
|
||||
# Nếu instance không có ID, thử danh sách trong cấu hình.
|
||||
config_self_ids = self.config_manager.get_bot_self_ids()
|
||||
if config_self_ids:
|
||||
self.set_bot_self_ids(config_self_ids)
|
||||
@@ -527,12 +522,12 @@ class BotManager:
|
||||
return False
|
||||
|
||||
def _extract_bot_self_id(self, bot_instance):
|
||||
"""从bot实例中提取自身ID(单个)"""
|
||||
"""Trích xuất một self ID từ bot instance."""
|
||||
return self._extract_bot_self_id_impl(bot_instance)
|
||||
|
||||
def _extract_bot_self_id_impl(self, bot_instance):
|
||||
"""从bot实例中提取ID(通用实现)"""
|
||||
# 尝试多种方式获取bot ID,并严格限制类型为 str/int 且不可调用,防止 OneBot (aiocqhttp) 动态代理返回 functools.partial
|
||||
"""Trích xuất ID từ bot instance bằng triển khai chung."""
|
||||
# Chỉ nhận str/int không callable để tránh dynamic proxy trả functools.partial.
|
||||
if hasattr(bot_instance, "self_id") and bot_instance.self_id:
|
||||
val = bot_instance.self_id
|
||||
if isinstance(val, (str, int)) and not callable(val):
|
||||
@@ -554,20 +549,20 @@ class BotManager:
|
||||
return None
|
||||
|
||||
def validate_for_message_fetching(self, group_id: str) -> bool:
|
||||
"""验证是否可以进行消息获取"""
|
||||
"""Xác thực có thể lấy tin nhắn hay không."""
|
||||
return self.has_bot_instance() and bool(group_id)
|
||||
|
||||
def should_filter_bot_message(self, sender_id: str) -> bool:
|
||||
"""判断是否应该过滤bot自己的消息(支持多个ID)"""
|
||||
"""Kiểm tra có nên lọc tin nhắn của bot, hỗ trợ nhiều ID."""
|
||||
if not self._bot_self_ids:
|
||||
return False
|
||||
|
||||
sender_id_str = str(sender_id)
|
||||
# 检查是否在ID列表中
|
||||
# Kiểm tra sender có trong danh sách ID.
|
||||
return sender_id_str in self._bot_self_ids
|
||||
|
||||
def is_plugin_enabled(self, platform_id: str, plugin_name: str) -> bool:
|
||||
"""检查指定平台是否启用了该插件"""
|
||||
"""Kiểm tra plugin có được bật trên nền tảng chỉ định hay không."""
|
||||
if platform_id not in self._platforms:
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
平台适配器工厂
|
||||
"""
|
||||
"""Factory adapter nền tảng."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,17 +10,16 @@ from .base import PlatformAdapter
|
||||
|
||||
class PlatformAdapterFactory:
|
||||
"""
|
||||
平台适配器工厂
|
||||
Factory adapter nền tảng.
|
||||
|
||||
根据平台名称创建适配器实例。
|
||||
使用注册表模式便于扩展。
|
||||
Tạo instance adapter theo tên nền tảng bằng registry dễ mở rộng.
|
||||
"""
|
||||
|
||||
_adapters: dict[str, type[PlatformAdapter]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, platform_name: str, adapter_class: type[PlatformAdapter]):
|
||||
"""注册新适配器"""
|
||||
"""Đăng ký adapter mới."""
|
||||
cls._adapters[platform_name.lower()] = adapter_class
|
||||
|
||||
@classmethod
|
||||
@@ -33,15 +30,15 @@ class PlatformAdapterFactory:
|
||||
config: Mapping[str, object] | None = None,
|
||||
) -> PlatformAdapter | None:
|
||||
"""
|
||||
创建平台适配器
|
||||
Tạo adapter nền tảng.
|
||||
|
||||
参数:
|
||||
platform_name: 平台名称(如 "aiocqhttp"、"telegram")
|
||||
bot_instance: AstrBot 机器人实例
|
||||
config: 配置字典
|
||||
Args:
|
||||
platform_name: Tên nền tảng như ``aiocqhttp`` hoặc ``telegram``.
|
||||
bot_instance: Instance bot AstrBot.
|
||||
config: Dict cấu hình.
|
||||
|
||||
返回:
|
||||
平台适配器实例,如果不支持则返回 None
|
||||
Returns:
|
||||
Instance adapter hoặc None nếu không hỗ trợ.
|
||||
"""
|
||||
adapter_class = cls._adapters.get(platform_name.lower())
|
||||
|
||||
@@ -51,22 +48,22 @@ class PlatformAdapterFactory:
|
||||
try:
|
||||
return adapter_class(bot_instance, config)
|
||||
except Exception:
|
||||
# 记录异常,但不崩溃
|
||||
logger.error(f"为 {platform_name} 创建适配器时出错", exc_info=True)
|
||||
# Ghi lỗi nhưng không làm sập plugin.
|
||||
logger.error(f"Lỗi khi tạo adapter cho {platform_name}", exc_info=True)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_supported_platforms(cls) -> list[str]:
|
||||
"""获取所有支持的平台名称"""
|
||||
"""Lấy tên tất cả nền tảng được hỗ trợ."""
|
||||
return list(cls._adapters.keys())
|
||||
|
||||
@classmethod
|
||||
def is_supported(cls, platform_name: str) -> bool:
|
||||
"""检查平台是否被支持"""
|
||||
"""Kiểm tra nền tảng có được hỗ trợ hay không."""
|
||||
return platform_name.lower() in cls._adapters
|
||||
|
||||
|
||||
# 导入适配器以注册它们
|
||||
# Import các adapter để đăng ký.
|
||||
def _register_adapters():
|
||||
try:
|
||||
from .adapters.onebot_adapter import OneBotAdapter
|
||||
@@ -80,7 +77,7 @@ def _register_adapters():
|
||||
from .adapters.discord_adapter import DiscordAdapter
|
||||
|
||||
PlatformAdapterFactory.register("discord", DiscordAdapter)
|
||||
PlatformAdapterFactory.register("discord_bot", DiscordAdapter) # 添加别名
|
||||
PlatformAdapterFactory.register("discord_bot", DiscordAdapter) # Alias
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""平台模板预览交互能力。"""
|
||||
"""Khả năng tương tác xem trước template theo nền tảng."""
|
||||
|
||||
from .router import TemplatePreviewRouter
|
||||
from .telegram_preview_handler import TelegramTemplatePreviewHandler
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""模板预览平台路由。"""
|
||||
"""Router nền tảng cho chức năng xem trước template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,17 +6,17 @@ from typing import Any
|
||||
|
||||
|
||||
class TemplatePreviewRouter:
|
||||
"""统一分发不同平台的模板预览处理器。"""
|
||||
"""Điều phối handler xem trước template cho các nền tảng."""
|
||||
|
||||
def __init__(self, handlers: list[Any] | None = None):
|
||||
self._handlers: list[Any] = handlers or []
|
||||
|
||||
def add_handler(self, handler: Any) -> None:
|
||||
"""注册一个平台处理器。"""
|
||||
"""Đăng ký một handler nền tảng."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
async def ensure_handlers_registered(self, context: Any) -> None:
|
||||
"""让处理器完成初始化(如注册回调)。"""
|
||||
"""Cho phép handler khởi tạo, chẳng hạn đăng ký callback."""
|
||||
for handler in self._handlers:
|
||||
register_func = getattr(
|
||||
handler, "ensure_callback_handlers_registered", None
|
||||
@@ -25,7 +25,7 @@ class TemplatePreviewRouter:
|
||||
await register_func(context)
|
||||
|
||||
async def unregister_handlers(self) -> None:
|
||||
"""统一注销处理器资源。"""
|
||||
"""Huỷ đăng ký tài nguyên của các handler."""
|
||||
for handler in self._handlers:
|
||||
unregister_func = getattr(handler, "unregister_callback_handlers", None)
|
||||
if callable(unregister_func):
|
||||
@@ -38,11 +38,11 @@ class TemplatePreviewRouter:
|
||||
available_templates: list[str],
|
||||
) -> tuple[bool, list[Any]]:
|
||||
"""
|
||||
处理 /查看模板 交互。
|
||||
Xử lý tương tác xem template.
|
||||
|
||||
返回:
|
||||
- handled: 是否已由某个平台处理器接管
|
||||
- results: 需要回传给框架的消息结果列表
|
||||
Returns:
|
||||
Tuple gồm trạng thái đã được handler tiếp nhận và danh sách kết quả
|
||||
tin nhắn cần trả về framework.
|
||||
"""
|
||||
for handler in self._handlers:
|
||||
supports_func = getattr(handler, "supports", None)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Telegram 模板预览交互处理。"""
|
||||
"""Xử lý tương tác preview template trên Telegram."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,7 +58,7 @@ class _PreviewSession:
|
||||
|
||||
|
||||
class TelegramTemplatePreviewHandler:
|
||||
"""Telegram 按钮预览处理器(←/确定/→)。"""
|
||||
"""Trình xử lý nút preview Telegram (←/Xác nhận/→)."""
|
||||
|
||||
_SESSION_TTL_SECONDS = 2 * 60 * 60
|
||||
_MAX_SESSIONS = 200
|
||||
@@ -82,17 +82,17 @@ class TelegramTemplatePreviewHandler:
|
||||
|
||||
@staticmethod
|
||||
def supports(event: AstrMessageEvent) -> bool:
|
||||
"""判断是否 Telegram 事件。"""
|
||||
"""Kiểm tra có phải sự kiện Telegram hay không."""
|
||||
try:
|
||||
return (event.get_platform_name() or "").lower() == "telegram"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 向后兼容旧调用名
|
||||
# Tương thích ngược với tên gọi cũ.
|
||||
is_telegram_event = supports
|
||||
|
||||
async def ensure_callback_handlers_registered(self, context: Any) -> None:
|
||||
"""为所有 Telegram 平台注册按钮回调处理器。"""
|
||||
"""Đăng ký callback nút cho mọi nền tảng Telegram."""
|
||||
if not TELEGRAM_RUNTIME_AVAILABLE:
|
||||
return
|
||||
if not context or not hasattr(context, "platform_manager"):
|
||||
@@ -123,15 +123,15 @@ class TelegramTemplatePreviewHandler:
|
||||
self._registered_platform_ids.add(platform_id)
|
||||
continue
|
||||
|
||||
# 平台对象热替换:解绑旧 application 上的 handler 后重绑
|
||||
# Hot-swap nền tảng: gỡ handler application cũ rồi gắn lại.
|
||||
try:
|
||||
old_application.remove_handler(old_handler)
|
||||
logger.info(
|
||||
f"[TemplatePreview][Telegram] 检测到 application 变更,已解绑旧回调: platform_id={platform_id}"
|
||||
f"[TemplatePreview][Telegram] Phát hiện application thay đổi, đã gỡ callback cũ: platform_id={platform_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[TemplatePreview][Telegram] 解绑旧回调失败: platform_id={platform_id}, err={e}"
|
||||
f"[TemplatePreview][Telegram] Gỡ callback cũ thất bại: platform_id={platform_id}, err={e}"
|
||||
)
|
||||
self._handlers.pop(platform_id, None)
|
||||
self._registered_platform_ids.discard(platform_id)
|
||||
@@ -145,14 +145,14 @@ class TelegramTemplatePreviewHandler:
|
||||
self._registered_platform_ids.add(platform_id)
|
||||
self._handlers[platform_id] = (application, handler)
|
||||
logger.info(
|
||||
f"[TemplatePreview][Telegram] 已注册回调处理器: platform_id={platform_id}"
|
||||
f"[TemplatePreview][Telegram] Đã đăng ký callback: platform_id={platform_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[TemplatePreview][Telegram] 注册回调处理器失败: platform_id={platform_id}, err={e}"
|
||||
f"[TemplatePreview][Telegram] Đăng ký callback thất bại: platform_id={platform_id}, err={e}"
|
||||
)
|
||||
|
||||
# 兜底清理:平台下线后移除残留 handler,避免资源泄漏
|
||||
# Dọn handler sót lại khi nền tảng offline để tránh rò rỉ tài nguyên.
|
||||
stale_ids = [
|
||||
platform_id
|
||||
for platform_id in list(self._handlers.keys())
|
||||
@@ -163,17 +163,17 @@ class TelegramTemplatePreviewHandler:
|
||||
try:
|
||||
old_application.remove_handler(old_handler)
|
||||
logger.info(
|
||||
f"[TemplatePreview][Telegram] 已清理离线平台回调: platform_id={stale_platform_id}"
|
||||
f"[TemplatePreview][Telegram] Đã dọn callback nền tảng offline: platform_id={stale_platform_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[TemplatePreview][Telegram] 清理离线平台回调失败: platform_id={stale_platform_id}, err={e}"
|
||||
f"[TemplatePreview][Telegram] Dọn callback nền tảng offline thất bại: platform_id={stale_platform_id}, err={e}"
|
||||
)
|
||||
self._registered_platform_ids.discard(stale_platform_id)
|
||||
self._platform_clients.pop(stale_platform_id, None)
|
||||
|
||||
async def unregister_callback_handlers(self) -> None:
|
||||
"""卸载已注册的回调处理器(插件终止时调用)。"""
|
||||
"""Gỡ callback đã đăng ký khi plugin kết thúc."""
|
||||
if not TELEGRAM_RUNTIME_AVAILABLE:
|
||||
return
|
||||
|
||||
@@ -181,11 +181,11 @@ class TelegramTemplatePreviewHandler:
|
||||
try:
|
||||
application.remove_handler(handler)
|
||||
logger.info(
|
||||
f"[TemplatePreview][Telegram] 已移除回调处理器: platform_id={platform_id}"
|
||||
f"[TemplatePreview][Telegram] Đã gỡ callback: platform_id={platform_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[TemplatePreview][Telegram] 移除回调处理器失败: platform_id={platform_id}, err={e}"
|
||||
f"[TemplatePreview][Telegram] Gỡ callback thất bại: platform_id={platform_id}, err={e}"
|
||||
)
|
||||
self._handlers.clear()
|
||||
self._registered_platform_ids.clear()
|
||||
@@ -198,11 +198,9 @@ class TelegramTemplatePreviewHandler:
|
||||
available_templates: list[str],
|
||||
) -> bool:
|
||||
"""
|
||||
在 Telegram 中发送可交互模板预览消息。
|
||||
Gửi tin nhắn preview template tương tác trên Telegram.
|
||||
|
||||
返回:
|
||||
- True: 已由本处理器发送消息(调用方不应再走默认回复)
|
||||
- False: 无法处理,调用方应走原有降级路径
|
||||
Trả về True nếu handler đã gửi; False để caller dùng fallback mặc định.
|
||||
"""
|
||||
if not TELEGRAM_RUNTIME_AVAILABLE:
|
||||
return False
|
||||
@@ -211,7 +209,7 @@ class TelegramTemplatePreviewHandler:
|
||||
|
||||
client = self._get_event_client(event, platform_id)
|
||||
if client is None:
|
||||
logger.warning("[TemplatePreview][Telegram] 无法获取 Telegram client")
|
||||
logger.warning("[TemplatePreview][Telegram] Không thể lấy Telegram client")
|
||||
return False
|
||||
|
||||
target = self._resolve_chat_target(event)
|
||||
@@ -223,7 +221,7 @@ class TelegramTemplatePreviewHandler:
|
||||
requester_id = int(str(event.get_sender_id()))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[TemplatePreview][Telegram] sender_id 非法,无法创建交互会话"
|
||||
"[TemplatePreview][Telegram] sender_id không hợp lệ, không thể tạo phiên tương tác"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -289,7 +287,7 @@ class TelegramTemplatePreviewHandler:
|
||||
)
|
||||
self._cleanup_expired_sessions()
|
||||
logger.info(
|
||||
"[TemplatePreview][Telegram] 已发送交互预览: "
|
||||
"[TemplatePreview][Telegram] Đã gửi preview tương tác: "
|
||||
f"platform_id={platform_id} chat_id={chat_id} token={token} templates={len(available_templates)}"
|
||||
)
|
||||
return True
|
||||
@@ -300,7 +298,7 @@ class TelegramTemplatePreviewHandler:
|
||||
platform_id: str,
|
||||
template_name: str,
|
||||
) -> bool:
|
||||
"""TG 回退路径:直接发送单张预览图(不经过 event.image_result)。"""
|
||||
"""Fallback Telegram: gửi trực tiếp một ảnh preview."""
|
||||
if not TELEGRAM_RUNTIME_AVAILABLE:
|
||||
return False
|
||||
|
||||
@@ -310,7 +308,9 @@ class TelegramTemplatePreviewHandler:
|
||||
|
||||
client = self._get_event_client(event, platform_id)
|
||||
if client is None:
|
||||
logger.warning("[TemplatePreview][Telegram] 回退发图失败:无法获取 client")
|
||||
logger.warning(
|
||||
"[TemplatePreview][Telegram] Gửi ảnh fallback thất bại: không thể lấy client"
|
||||
)
|
||||
return False
|
||||
|
||||
target = self._resolve_chat_target(event)
|
||||
@@ -320,7 +320,7 @@ class TelegramTemplatePreviewHandler:
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"caption": f"🖼 当前模板预览: {template_name}",
|
||||
"caption": f"🖼 Preview template hiện tại: {template_name}",
|
||||
"connect_timeout": self._CONNECT_TIMEOUT,
|
||||
"read_timeout": self._READ_TIMEOUT,
|
||||
"write_timeout": self._WRITE_TIMEOUT,
|
||||
@@ -345,7 +345,7 @@ class TelegramTemplatePreviewHandler:
|
||||
platform_id: str,
|
||||
available_templates: list[str],
|
||||
) -> tuple[bool, list[Any]]:
|
||||
"""统一处理 Telegram 的 /查看模板 流程。"""
|
||||
"""Xử lý thống nhất quy trình /xemmau trên Telegram."""
|
||||
if not self.supports(event):
|
||||
return False, []
|
||||
|
||||
@@ -358,12 +358,12 @@ class TelegramTemplatePreviewHandler:
|
||||
)
|
||||
results.append(
|
||||
event.plain_result(
|
||||
f"""🎨 可用报告模板列表
|
||||
📌 当前使用: {current_template}
|
||||
f"""🎨 Danh sách template báo cáo khả dụng
|
||||
📌 Đang dùng: {current_template}
|
||||
|
||||
{template_list_str}
|
||||
|
||||
💡 使用 /设置模板 [序号] 切换"""
|
||||
💡 Dùng /maubc [số thứ tự] để chuyển"""
|
||||
)
|
||||
)
|
||||
|
||||
@@ -374,10 +374,18 @@ class TelegramTemplatePreviewHandler:
|
||||
template_name=current_template,
|
||||
)
|
||||
if not sent_preview:
|
||||
results.append(event.plain_result("⚠️ 当前模板预览图发送失败"))
|
||||
results.append(
|
||||
event.plain_result(
|
||||
"⚠️ Gửi ảnh preview template hiện tại thất bại"
|
||||
)
|
||||
)
|
||||
except Exception as image_err:
|
||||
logger.warning(f"[TemplatePreview][Telegram] 回退发图失败: {image_err}")
|
||||
results.append(event.plain_result("⚠️ 当前模板预览图发送失败"))
|
||||
logger.warning(
|
||||
f"[TemplatePreview][Telegram] Gửi ảnh fallback thất bại: {image_err}"
|
||||
)
|
||||
results.append(
|
||||
event.plain_result("⚠️ Gửi ảnh preview template hiện tại thất bại")
|
||||
)
|
||||
|
||||
try:
|
||||
sent = await self.send_preview_message(
|
||||
@@ -391,7 +399,7 @@ class TelegramTemplatePreviewHandler:
|
||||
return True, results
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[TemplatePreview][Telegram] 交互预览发送失败,回退普通模式: {e}"
|
||||
f"[TemplatePreview][Telegram] Gửi preview tương tác thất bại, chuyển sang chế độ thường: {e}"
|
||||
)
|
||||
await _append_fallback_results()
|
||||
return True, results
|
||||
@@ -410,34 +418,42 @@ class TelegramTemplatePreviewHandler:
|
||||
data = query.data
|
||||
parts = data.split(":")
|
||||
if len(parts) != 3:
|
||||
await query.answer("无效操作", show_alert=False)
|
||||
await query.answer("Thao tác không hợp lệ", show_alert=False)
|
||||
return
|
||||
|
||||
_, token, action = parts
|
||||
session = self._sessions.get(token)
|
||||
if not session:
|
||||
await query.answer("预览会话已过期,请重新发送 /查看模板", show_alert=True)
|
||||
await query.answer(
|
||||
"Phiên preview đã hết hạn, hãy gửi lại /xemmau", show_alert=True
|
||||
)
|
||||
return
|
||||
if time.time() - session.created_at > self._SESSION_TTL_SECONDS:
|
||||
self._sessions.pop(token, None)
|
||||
await query.answer("预览会话已过期,请重新发送 /查看模板", show_alert=True)
|
||||
await query.answer(
|
||||
"Phiên preview đã hết hạn, hãy gửi lại /xemmau", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if not query.from_user:
|
||||
await query.answer("无法识别操作者", show_alert=False)
|
||||
await query.answer("Không thể nhận diện người thao tác", show_alert=False)
|
||||
return
|
||||
if int(query.from_user.id) != session.requester_id:
|
||||
await query.answer("仅命令发起人可操作该预览", show_alert=True)
|
||||
await query.answer(
|
||||
"Chỉ người gọi lệnh mới có thể thao tác preview này", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if not query.message:
|
||||
await query.answer("消息已失效", show_alert=False)
|
||||
await query.answer("Tin nhắn không còn hiệu lực", show_alert=False)
|
||||
return
|
||||
|
||||
if query.message.message_id != session.message_id or str(
|
||||
query.message.chat_id
|
||||
) != str(session.chat_id):
|
||||
await query.answer("预览状态不一致,请重新发送 /查看模板", show_alert=True)
|
||||
await query.answer(
|
||||
"Trạng thái preview không khớp, hãy gửi lại /xemmau", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if action == "prev":
|
||||
@@ -456,14 +472,14 @@ class TelegramTemplatePreviewHandler:
|
||||
template_name = session.current_template
|
||||
self.config_manager.set_report_template(template_name)
|
||||
await self._edit_preview_message(query, session, applied=True)
|
||||
await query.answer(f"已设置模板: {template_name}", show_alert=False)
|
||||
await query.answer(f"Đã đặt template: {template_name}", show_alert=False)
|
||||
logger.info(
|
||||
"[TemplatePreview][Telegram] 已应用模板: "
|
||||
"[TemplatePreview][Telegram] Đã áp dụng template: "
|
||||
f"platform_id={session.platform_id} template={template_name} requester={session.requester_id}"
|
||||
)
|
||||
return
|
||||
|
||||
await query.answer("未知操作", show_alert=False)
|
||||
await query.answer("Thao tác không xác định", show_alert=False)
|
||||
|
||||
async def _edit_preview_message(
|
||||
self, query: Any, session: _PreviewSession, applied: bool = False
|
||||
@@ -526,7 +542,7 @@ class TelegramTemplatePreviewHandler:
|
||||
callback_data=f"{self._callback_prefix}:{token}:prev",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="确定",
|
||||
text="Xác nhận",
|
||||
callback_data=f"{self._callback_prefix}:{token}:apply",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
@@ -545,13 +561,15 @@ class TelegramTemplatePreviewHandler:
|
||||
applied: bool = False,
|
||||
) -> str:
|
||||
current_active = self.config_manager.get_report_template()
|
||||
active_mark = "✅ 当前生效" if template_name == current_active else "未生效"
|
||||
apply_mark = "\n\n✅ 已应用该模板" if applied else ""
|
||||
active_mark = (
|
||||
"✅ Đang áp dụng" if template_name == current_active else "Chưa áp dụng"
|
||||
)
|
||||
apply_mark = "\n\n✅ Đã áp dụng template này" if applied else ""
|
||||
return (
|
||||
f"🎨 模板预览 ({index + 1}/{total})\n"
|
||||
f"当前项: {template_name}\n"
|
||||
f"状态: {active_mark}\n\n"
|
||||
"操作: ← 上一个 / 确定应用 / → 下一个"
|
||||
f"🎨 Preview template ({index + 1}/{total})\n"
|
||||
f"Mục hiện tại: {template_name}\n"
|
||||
f"Trạng thái: {active_mark}\n\n"
|
||||
"Thao tác: ← Trước / Xác nhận áp dụng / → Sau"
|
||||
f"{apply_mark}"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
报告生成模块
|
||||
包含HTML、图片、文本报告生成功能
|
||||
"""
|
||||
"""Module tạo báo cáo HTML, ảnh và văn bản."""
|
||||
|
||||
from .generators import ReportGenerator
|
||||
from .templates import HTMLTemplates
|
||||
|
||||
@@ -12,8 +12,7 @@ from ...utils.logger import logger
|
||||
|
||||
class ReportDispatcher:
|
||||
"""
|
||||
报告分发器
|
||||
负责协调报告生成、格式选择、消息发送和失败重试
|
||||
Bộ phân phối báo cáo, điều phối tạo báo cáo, chọn định dạng, gửi và fallback.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -28,7 +27,7 @@ class ReportDispatcher:
|
||||
self._html_render_func: Callable | None = None
|
||||
|
||||
def set_html_render(self, render_func: Callable):
|
||||
"""设置 HTML 渲染函数 (运行时注入)"""
|
||||
"""Thiết lập hàm render HTML được inject lúc runtime."""
|
||||
self._html_render_func = render_func
|
||||
|
||||
def _is_qq_official(self, platform_id: str | None) -> bool:
|
||||
@@ -42,13 +41,13 @@ class ReportDispatcher:
|
||||
platform_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
分发分析报告
|
||||
Phân phối báo cáo phân tích.
|
||||
"""
|
||||
trace_id = TraceContext.get()
|
||||
output_formats = self.config_manager.get_output_format()
|
||||
|
||||
logger.info(
|
||||
f"[{trace_id}] 正在分发群 {group_id} 的报告 (格式: {', '.join(output_formats)})"
|
||||
f"[{trace_id}] Đang phân phối báo cáo cho nhóm {group_id} (định dạng: {', '.join(output_formats)})"
|
||||
)
|
||||
|
||||
dispatch_map = {
|
||||
@@ -61,22 +60,24 @@ class ReportDispatcher:
|
||||
if handler:
|
||||
await handler(group_id, analysis_result, platform_id)
|
||||
|
||||
logger.info(f"[{trace_id}] 群 {group_id} 的报告分发完成")
|
||||
logger.info(f"[{trace_id}] Hoàn tất phân phối báo cáo cho nhóm {group_id}")
|
||||
|
||||
async def _dispatch_image(
|
||||
self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None
|
||||
) -> bool:
|
||||
trace_id = TraceContext.get()
|
||||
# 1. 检查渲染函数
|
||||
# 1. Kiểm tra hàm render.
|
||||
if not self._html_render_func:
|
||||
logger.warning(f"[{trace_id}] 未设置 HTML 渲染函数,回退到文本模式。")
|
||||
logger.warning(
|
||||
f"[{trace_id}] Chưa thiết lập hàm render HTML, chuyển sang văn bản"
|
||||
)
|
||||
return await self._dispatch_text(group_id, analysis_result, platform_id)
|
||||
|
||||
# 2. 生成图片
|
||||
# 2. Tạo ảnh.
|
||||
image_url = None
|
||||
html_content = None
|
||||
try:
|
||||
# 定义头像获取回调,请求小尺寸头像以优化性能
|
||||
# Callback lấy avatar kích thước nhỏ để tối ưu hiệu năng.
|
||||
async def avatar_url_getter(user_id: str):
|
||||
if not platform_id:
|
||||
return None
|
||||
@@ -94,10 +95,10 @@ class ReportDispatcher:
|
||||
allow_alphanumeric_user_ids=self._is_qq_official(platform_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[{trace_id}] Failed to generate image report: {e}")
|
||||
logger.error(f"[{trace_id}] Tạo báo cáo ảnh thất bại: {e}")
|
||||
# image_url and html_content remain None
|
||||
|
||||
# 4. 发送图片
|
||||
# 4. Gửi ảnh.
|
||||
sent = False
|
||||
if image_url:
|
||||
caption = (
|
||||
@@ -109,16 +110,16 @@ class ReportDispatcher:
|
||||
group_id, image_url, caption, platform_id
|
||||
)
|
||||
|
||||
# 5. 尝试上传到群文件/群相册(静默处理)
|
||||
# 无论消息发送是否成功(如超时回退),只要图片生成了,就尝试备份到群文件
|
||||
# 5. Thử upload vào tệp/album nhóm và chỉ ghi log khi lỗi.
|
||||
# Nếu ảnh đã tạo thì luôn thử sao lưu dù gửi tin nhắn có thành công hay không.
|
||||
await self._try_upload_image(group_id, image_url, platform_id)
|
||||
|
||||
if sent:
|
||||
return True
|
||||
|
||||
# 6. 最终回退:如果图片发送失败(包括生成失败或发送接口报错),直接尝试发送文本报告
|
||||
# 6. Fallback cuối: gửi báo cáo văn bản nếu ảnh thất bại.
|
||||
logger.warning(
|
||||
f"[{trace_id}] Image dispatch failed, falling back to text report."
|
||||
f"[{trace_id}] Phân phối ảnh thất bại, chuyển sang báo cáo văn bản"
|
||||
)
|
||||
return await self._dispatch_text(group_id, analysis_result, platform_id)
|
||||
|
||||
@@ -146,7 +147,7 @@ class ReportDispatcher:
|
||||
allow_alphanumeric_user_ids=self._is_qq_official(platform_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
|
||||
logger.error(f"[{trace_id}] Tạo báo cáo HTML thất bại: {e}")
|
||||
|
||||
if html_path:
|
||||
is_only_url = self.config_manager.get_html_only_url()
|
||||
@@ -154,10 +155,10 @@ class ReportDispatcher:
|
||||
|
||||
if is_only_url:
|
||||
if base_url and base_url.strip():
|
||||
# 获取配置的目录
|
||||
# Lấy thư mục đã cấu hình.
|
||||
html_output_dir = self.config_manager.get_html_output_dir()
|
||||
|
||||
# 若用户配置为空,使用默认目录
|
||||
# Dùng thư mục mặc định nếu cấu hình rỗng.
|
||||
if not html_output_dir:
|
||||
from astrbot.api.star import StarTools
|
||||
|
||||
@@ -166,14 +167,14 @@ class ReportDispatcher:
|
||||
"self_hosted_html_reports",
|
||||
)
|
||||
|
||||
# 计算相对路径并转换为URL
|
||||
# Tính đường dẫn tương đối và chuyển thành URL.
|
||||
rel_path = os.path.relpath(html_path, html_output_dir)
|
||||
url_path = rel_path.replace(os.sep, "/")
|
||||
report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}"
|
||||
|
||||
sent = await self.message_sender.send_text(
|
||||
group_id,
|
||||
f"📊 今日群聊分析报告已生成:\n{report_url}",
|
||||
f"📊 Báo cáo phân tích nhóm hôm nay đã sẵn sàng:\n{report_url}",
|
||||
platform_id,
|
||||
)
|
||||
|
||||
@@ -181,7 +182,7 @@ class ReportDispatcher:
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"[{trace_id}] 群 {group_id} 开启了仅发送外链,但未配置 html_base_url,已进行降级,回退至发送 HTML 文件。"
|
||||
f"[{trace_id}] Nhóm {group_id} chỉ bật gửi liên kết nhưng chưa cấu hình html_base_url; chuyển sang gửi tệp HTML"
|
||||
)
|
||||
|
||||
caption = (
|
||||
@@ -200,15 +201,15 @@ class ReportDispatcher:
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
f"[{trace_id}] HTML dispatch failed, falling back to text report."
|
||||
f"[{trace_id}] Phân phối HTML thất bại, chuyển sang báo cáo văn bản"
|
||||
)
|
||||
return await self._dispatch_text(group_id, analysis_result, platform_id)
|
||||
|
||||
async def _dispatch_text(
|
||||
self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None
|
||||
) -> bool:
|
||||
"""分发文本报告"""
|
||||
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告")
|
||||
"""Phân phối báo cáo văn bản."""
|
||||
logger.info(f"[Bộ phân phối] Đang gửi báo cáo văn bản tới nhóm {group_id}")
|
||||
is_qq_official = self._is_qq_official(platform_id)
|
||||
fallback_report = None
|
||||
if is_qq_official:
|
||||
@@ -221,8 +222,10 @@ class ReportDispatcher:
|
||||
else:
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||
# 尝试通过适配器发送文本报告
|
||||
logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}")
|
||||
# Thử gửi báo cáo văn bản qua adapter.
|
||||
logger.info(
|
||||
f"[Bộ phân phối] Đang thử gửi báo cáo văn bản qua adapter, nhóm: {group_id}"
|
||||
)
|
||||
try:
|
||||
if adapter:
|
||||
if is_qq_official:
|
||||
@@ -235,14 +238,18 @@ class ReportDispatcher:
|
||||
elif await adapter.send_text_report(group_id, text_report):
|
||||
return True
|
||||
return await self.message_sender.send_text(
|
||||
group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id
|
||||
group_id,
|
||||
f"📊 Báo cáo phân tích nhóm hằng ngày:\n\n{text_report}",
|
||||
platform_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[分发器] 发送文本报告最终失败。群: {group_id}, 错误: {e}")
|
||||
logger.error(
|
||||
f"[Bộ phân phối] Gửi báo cáo văn bản thất bại, nhóm: {group_id}, lỗi: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
# ================================================================
|
||||
# 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式)
|
||||
# Upload báo cáo ảnh vào tệp/album nhóm, chỉ cho định dạng ảnh trên QQ.
|
||||
# ================================================================
|
||||
|
||||
async def _try_upload_image(
|
||||
@@ -252,31 +259,31 @@ class ReportDispatcher:
|
||||
platform_id: str | None,
|
||||
):
|
||||
"""
|
||||
尝试将图片报告上传到群文件和/或群相册。
|
||||
Thử upload báo cáo ảnh vào tệp và/hoặc album nhóm.
|
||||
|
||||
仅在配置启用且平台为 OneBot 时执行,失败静默处理。
|
||||
Chỉ thực hiện khi bật cấu hình và nền tảng là OneBot; lỗi chỉ ghi log.
|
||||
"""
|
||||
enable_file = self.config_manager.get_enable_group_file_upload()
|
||||
enable_album = self.config_manager.get_enable_group_album_upload()
|
||||
if not enable_file and not enable_album:
|
||||
return
|
||||
|
||||
# 仅 OneBot 平台支持
|
||||
# Chỉ OneBot hỗ trợ.
|
||||
adapter = self._get_onebot_adapter(platform_id)
|
||||
if not adapter:
|
||||
return
|
||||
|
||||
# 将图片保存为临时文件
|
||||
# Lưu ảnh thành tệp tạm.
|
||||
image_file = self._save_image_to_temp(image_url, group_id)
|
||||
if not image_file:
|
||||
return
|
||||
|
||||
try:
|
||||
# 上传到群文件
|
||||
# Upload vào tệp nhóm.
|
||||
if enable_file:
|
||||
await self._do_upload_group_file(adapter, group_id, image_file)
|
||||
|
||||
# 上传到群相册
|
||||
# Upload vào album nhóm.
|
||||
if enable_album:
|
||||
await self._do_upload_group_album(adapter, group_id, image_file)
|
||||
finally:
|
||||
@@ -286,7 +293,7 @@ class ReportDispatcher:
|
||||
pass
|
||||
|
||||
async def _do_upload_group_file(self, adapter, group_id: str, file_path: str):
|
||||
"""上传文件到群文件目录,失败静默"""
|
||||
"""Upload tệp vào thư mục nhóm; lỗi chỉ ghi log."""
|
||||
try:
|
||||
folder_name = self.config_manager.get_group_file_folder()
|
||||
folder_id = None
|
||||
@@ -298,10 +305,10 @@ class ReportDispatcher:
|
||||
folder_id=folder_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"群文件上传失败 (群 {group_id}): {e}")
|
||||
logger.warning(f"Upload tệp nhóm thất bại (nhóm {group_id}): {e}")
|
||||
|
||||
async def _do_upload_group_album(self, adapter, group_id: str, file_path: str):
|
||||
"""上传图片到群相册,失败静默"""
|
||||
"""Upload ảnh vào album nhóm; lỗi chỉ ghi log."""
|
||||
try:
|
||||
album_name = self.config_manager.get_group_album_name()
|
||||
strict_mode = self.config_manager.get_group_album_strict_mode()
|
||||
@@ -312,12 +319,12 @@ class ReportDispatcher:
|
||||
album_id = await adapter.find_album_id(group_id, album_name)
|
||||
if not album_id and strict_mode:
|
||||
logger.info(
|
||||
f"群相册严格模式开启:在群 {group_id} 中未找到名为 '{album_name}' 的相册,停止上传。"
|
||||
f"Đã bật chế độ album nghiêm ngặt: không tìm thấy album '{album_name}' trong nhóm {group_id}, dừng upload"
|
||||
)
|
||||
return
|
||||
elif strict_mode:
|
||||
logger.info(
|
||||
f"群相册严格模式开启:未设置目标相册名称,停止上传以防止操作群 {group_id} 的默认相册。"
|
||||
f"Đã bật chế độ album nghiêm ngặt nhưng chưa đặt tên album đích; dừng để tránh thao tác album mặc định của nhóm {group_id}"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -329,10 +336,10 @@ class ReportDispatcher:
|
||||
strict_mode=strict_mode,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"群相册上传失败 (群 {group_id}): {e}")
|
||||
logger.warning(f"Upload album nhóm thất bại (nhóm {group_id}): {e}")
|
||||
|
||||
def _save_image_to_temp(self, image_url: str, group_id: str) -> str | None:
|
||||
"""将 base64 图片保存为临时 PNG 文件,返回路径。失败返回 None。"""
|
||||
"""Lưu ảnh Base64 thành PNG tạm và trả về đường dẫn hoặc None."""
|
||||
try:
|
||||
image_data = None
|
||||
if image_url.startswith("base64://"):
|
||||
@@ -353,17 +360,18 @@ class ReportDispatcher:
|
||||
|
||||
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = os.path.join(
|
||||
tempfile.gettempdir(), f"群聊分析报告_{group_id}_{date_str}.png"
|
||||
tempfile.gettempdir(),
|
||||
f"bao_cao_phan_tich_nhom_{group_id}_{date_str}.png",
|
||||
)
|
||||
with open(path, "wb") as f:
|
||||
f.write(image_data)
|
||||
return path
|
||||
except Exception as e:
|
||||
logger.debug(f"保存图片到临时文件失败: {e}")
|
||||
logger.debug(f"Lưu ảnh vào tệp tạm thất bại: {e}")
|
||||
return None
|
||||
|
||||
def _get_onebot_adapter(self, platform_id: str | None):
|
||||
"""获取 OneBot 适配器,非 OneBot 平台返回 None。"""
|
||||
"""Lấy adapter OneBot hoặc None cho nền tảng khác."""
|
||||
if not platform_id:
|
||||
return None
|
||||
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
报告生成器模块
|
||||
负责生成各种格式的分析报告
|
||||
"""
|
||||
"""Tạo báo cáo phân tích ở nhiều định dạng."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
@@ -147,16 +144,15 @@ DEFAULT_PROFILE_NAME_TRANSLATIONS = {
|
||||
|
||||
|
||||
class ReportGenerator(IReportGenerator):
|
||||
"""报告生成器"""
|
||||
"""Trình tạo báo cáo phân tích."""
|
||||
|
||||
def __init__(self, config_manager, data_dir):
|
||||
self._avatar_session = None
|
||||
self.config_manager = config_manager
|
||||
self.data_dir = data_dir
|
||||
self.activity_visualizer = ActivityVisualizer()
|
||||
self.html_templates = HTMLTemplates(config_manager) # 实例化HTML模板管理器
|
||||
# 全局 T2I 渲染信号量,保护本地资源
|
||||
# 使用专用的 T2I 并发配置项
|
||||
self.html_templates = HTMLTemplates(config_manager)
|
||||
# Semaphore render T2I toàn cục bảo vệ tài nguyên local.
|
||||
max_concurrent = self.config_manager.get_t2i_max_concurrent()
|
||||
self._render_semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._qq_official_markdown_generator = QQOfficialMarkdownReportGenerator(
|
||||
@@ -165,7 +161,7 @@ class ReportGenerator(IReportGenerator):
|
||||
self._render_semaphore,
|
||||
)
|
||||
|
||||
# 运行时缓存,用于在一次分析任务中避免重复下载同一个头像
|
||||
# Cache runtime để tránh tải lặp avatar trong một tác vụ phân tích.
|
||||
self._avatar_cache = Cache(
|
||||
str(self.data_dir / "avatar")
|
||||
) # user_id -> base64_uri
|
||||
@@ -176,7 +172,7 @@ class ReportGenerator(IReportGenerator):
|
||||
self._profile_asset_manifest = self._load_profile_asset_manifest()
|
||||
|
||||
def _load_profile_asset_manifest(self) -> dict[str, dict]:
|
||||
"""加载人格资源清单。"""
|
||||
"""Tải manifest tài nguyên hồ sơ."""
|
||||
manifest_path = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "assets"
|
||||
@@ -184,13 +180,13 @@ class ReportGenerator(IReportGenerator):
|
||||
/ "manifest.json"
|
||||
)
|
||||
if not manifest_path.exists():
|
||||
logger.warning(f"人格资源清单不存在: {manifest_path}")
|
||||
logger.warning(f"Manifest tài nguyên hồ sơ không tồn tại: {manifest_path}")
|
||||
return {"sbti": {}, "acgti": {}}
|
||||
|
||||
try:
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e:
|
||||
logger.warning(f"加载人格资源清单失败: {e}")
|
||||
logger.warning(f"Tải manifest tài nguyên hồ sơ thất bại: {e}")
|
||||
return {"sbti": {}, "acgti": {}}
|
||||
|
||||
manifest: dict[str, dict] = {"sbti": {}, "acgti": {}}
|
||||
@@ -205,7 +201,7 @@ class ReportGenerator(IReportGenerator):
|
||||
return manifest
|
||||
|
||||
def _get_profile_mapping_overrides(self) -> dict[str, dict]:
|
||||
"""解析用户配置的人格映射覆盖项。"""
|
||||
"""Parse cấu hình override ánh xạ hồ sơ của người dùng."""
|
||||
raw = self.config_manager.get_profile_mapping_config()
|
||||
if not raw:
|
||||
return {}
|
||||
@@ -227,13 +223,15 @@ class ReportGenerator(IReportGenerator):
|
||||
profile["name_zh"] = translated_name
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"人格映射配置 JSON 解析失败,已回退到默认映射: {e}")
|
||||
logger.warning(
|
||||
f"Parse JSON ánh xạ hồ sơ thất bại, dùng ánh xạ mặc định: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
def _build_profile_image_from_manifest_pattern(
|
||||
self, profile_mode: str, asset_code: str
|
||||
) -> str:
|
||||
"""当 manifest 缺少具体 code 时,根据已有资源路径模式推导图片地址。"""
|
||||
"""Suy ra URL ảnh theo mẫu path khi manifest thiếu code cụ thể."""
|
||||
system_manifest = self._profile_asset_manifest.get(profile_mode, {})
|
||||
for item in system_manifest.values():
|
||||
if not isinstance(item, dict):
|
||||
@@ -251,7 +249,7 @@ class ReportGenerator(IReportGenerator):
|
||||
def _get_manifest_profile_item_by_mbti(
|
||||
self, profile_mode: str, mbti: str
|
||||
) -> dict | None:
|
||||
"""按 MBTI 从 manifest 中寻找可用资源。"""
|
||||
"""Tìm tài nguyên khả dụng trong manifest theo MBTI."""
|
||||
normalized_mbti = str(mbti or "").strip().upper()
|
||||
system_manifest = self._profile_asset_manifest.get(profile_mode, {})
|
||||
for item in system_manifest.values():
|
||||
@@ -268,14 +266,14 @@ class ReportGenerator(IReportGenerator):
|
||||
profile_mode: str,
|
||||
overrides: dict[str, dict],
|
||||
) -> dict[str, str | float]:
|
||||
"""根据当前展示模式解析人格标签展示信息。"""
|
||||
"""Phân giải thông tin nhãn hồ sơ theo chế độ hiển thị hiện tại."""
|
||||
normalized_mbti = str(mbti or "").strip().upper()
|
||||
|
||||
# 1. 基础信息获取:从默认映射或用户覆盖中获取核心属性
|
||||
# 1. Lấy thuộc tính cốt lõi từ mặc định hoặc override.
|
||||
profile_defaults = DEFAULT_PROFILE_MAPPING.get(profile_mode, {})
|
||||
base_info = dict(profile_defaults.get(normalized_mbti, {}))
|
||||
|
||||
# 用户覆盖优先级最高
|
||||
# Override người dùng có ưu tiên cao nhất.
|
||||
user_override = overrides.get(profile_mode, {}).get(normalized_mbti, {})
|
||||
if isinstance(user_override, dict):
|
||||
base_info.update(user_override)
|
||||
@@ -285,23 +283,23 @@ class ReportGenerator(IReportGenerator):
|
||||
asset_code = str(base_info.get("asset_code", code)).strip() or code
|
||||
image = str(base_info.get("image", "")).strip()
|
||||
|
||||
# 2. 图片与属性补全 (基于 manifest.json 可信源)
|
||||
# 2. Bổ sung ảnh và thuộc tính từ manifest.json.
|
||||
if not image:
|
||||
system_manifest = self._profile_asset_manifest.get(profile_mode, {})
|
||||
# A. 优先按 asset_code 索引
|
||||
# A. Ưu tiên index theo asset_code.
|
||||
asset_item = system_manifest.get(asset_code)
|
||||
if isinstance(asset_item, dict):
|
||||
image = str(asset_item.get("file", "")).strip()
|
||||
if not name_zh:
|
||||
name_zh = str(asset_item.get("name", "")).strip()
|
||||
|
||||
# B. 按照 asset_code 的资源规律推导图片地址 (尝试根据同目录下其他资源的规律猜测当前角色的 CDN 地址)
|
||||
# B. Suy ra URL ảnh theo mẫu tài nguyên asset_code.
|
||||
if not image:
|
||||
image = self._build_profile_image_from_manifest_pattern(
|
||||
profile_mode, asset_code
|
||||
)
|
||||
|
||||
# C. 对于 acgti 模式,如果没找到明确映射也没能推导出图片,尝试通过 MBTI 反查该类型下的第一个可用资源作为兜底
|
||||
# C. Với acgti, fallback về tài nguyên đầu tiên cùng MBTI.
|
||||
if not image and profile_mode == "acgti":
|
||||
fallback_item = self._get_manifest_profile_item_by_mbti(
|
||||
profile_mode, normalized_mbti
|
||||
@@ -313,7 +311,7 @@ class ReportGenerator(IReportGenerator):
|
||||
if not code or code == normalized_mbti:
|
||||
code = str(fallback_item.get("code", code)).strip()
|
||||
|
||||
# 3. 构造显示文本 (Code + 中文名)
|
||||
# 3. Dựng văn bản hiển thị gồm code và tên.
|
||||
display = str(base_info.get("display", "")).strip()
|
||||
if not display:
|
||||
display = f"{code}({name_zh})" if name_zh else code
|
||||
@@ -330,22 +328,22 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_path_component(name: str) -> str:
|
||||
"""消毒单个路径/文件名片段,禁止路径穿越和非法字符。"""
|
||||
# 禁止空组件、相对路径控制符:"."、".."
|
||||
"""Làm sạch một thành phần path/tên tệp, chặn traversal và ký tự lỗi."""
|
||||
# Chặn thành phần rỗng và ký hiệu path tương đối.
|
||||
if not name or name in {".", ".."}:
|
||||
raise ValueError(f"无效的路径片段: {name!r}")
|
||||
raise ValueError(f"Thành phần path không hợp lệ: {name!r}")
|
||||
|
||||
# 不允许包含路径分隔符
|
||||
# Không cho phép ký tự phân cách path.
|
||||
name = name.replace("/", "_")
|
||||
name = name.replace("\\", "_")
|
||||
|
||||
# 去除非打印字符和非法文件名字符
|
||||
# Loại ký tự không in được và ký tự tên tệp không hợp lệ.
|
||||
name = re.sub(r'[\x00-\x1f<>:"|?*]', "_", name)
|
||||
|
||||
# 保留中文、字母、数字、下划线、横线和点
|
||||
# Giữ nội dung hợp lệ sau khi làm sạch.
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("路径片段经过消毒后为空")
|
||||
raise ValueError("Thành phần path rỗng sau khi làm sạch")
|
||||
|
||||
return name
|
||||
|
||||
@@ -356,7 +354,7 @@ class ReportGenerator(IReportGenerator):
|
||||
group_id: str,
|
||||
date: str,
|
||||
) -> Path:
|
||||
"""根据格式构建安全输出路径,支持子目录和 {ulid}。"""
|
||||
"""Dựng path output an toàn theo format, hỗ trợ thư mục con và ulid."""
|
||||
generated_ulid = str(ulid.new())
|
||||
safe_context = {
|
||||
"group_id": group_id,
|
||||
@@ -367,16 +365,16 @@ class ReportGenerator(IReportGenerator):
|
||||
try:
|
||||
formatted = render_template(filename_format, strict=True, **safe_context)
|
||||
except Exception as e:
|
||||
raise ValueError(f"文件名模板渲染失败: {e}") from e
|
||||
raise ValueError(f"Render template tên tệp thất bại: {e}") from e
|
||||
|
||||
if os.path.isabs(formatted):
|
||||
raise ValueError("文件名格式不得为绝对路径")
|
||||
raise ValueError("Định dạng tên tệp không được là path tuyệt đối")
|
||||
|
||||
relative_path = Path(formatted)
|
||||
sanitized_parts = []
|
||||
for part in relative_path.parts:
|
||||
if part in {".", ".."}:
|
||||
raise ValueError("路径中不得包含 '.' 或 '..'。")
|
||||
raise ValueError("Path không được chứa '.' hoặc '..'")
|
||||
sanitized_parts.append(self._sanitize_path_component(part))
|
||||
|
||||
safe_relative = Path(*sanitized_parts)
|
||||
@@ -384,13 +382,15 @@ class ReportGenerator(IReportGenerator):
|
||||
output_dir_resolved = output_dir.resolve(strict=False)
|
||||
target_path = (output_dir_resolved / safe_relative).resolve(strict=False)
|
||||
|
||||
# 防止回退到上级目录(使用 Path.relative_to 进行目录包含校验)
|
||||
# Chặn traversal lên thư mục cha bằng Path.relative_to.
|
||||
try:
|
||||
target_path.relative_to(output_dir_resolved)
|
||||
except ValueError:
|
||||
raise ValueError("文件路径不在输出目录之内,可能包含路径穿越")
|
||||
raise ValueError(
|
||||
"Path tệp nằm ngoài thư mục output, có thể chứa path traversal"
|
||||
)
|
||||
|
||||
# 防止与已有文件覆盖(如果用户格式没有唯一标记),追加 ULID 后缀
|
||||
# Thêm hậu tố ULID để tránh ghi đè khi format không có định danh duy nhất.
|
||||
if target_path.exists():
|
||||
suffix = target_path.suffix
|
||||
stem = target_path.stem
|
||||
@@ -408,25 +408,25 @@ class ReportGenerator(IReportGenerator):
|
||||
nickname_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
hide_user_names: bool = False,
|
||||
# Also controls ID normalization and fallback display name ("群友").
|
||||
# Đồng thời kiểm soát chuẩn hoá ID và tên hiển thị fallback.
|
||||
allow_alphanumeric_user_ids: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
生成图片格式的分析报告
|
||||
Tạo báo cáo phân tích dạng ảnh.
|
||||
|
||||
Args:
|
||||
analysis_result: 分析结果字典
|
||||
group_id: 群组ID
|
||||
html_render_func: HTML渲染函数
|
||||
avatar_url_getter: 异步回调函数,接收 user_id 返回 avatar_url/data
|
||||
nickname_getter: 昵称获取函数
|
||||
analysis_result: Dict kết quả phân tích.
|
||||
group_id: ID nhóm.
|
||||
html_render_func: Hàm render HTML.
|
||||
avatar_url_getter: Callback async lấy avatar theo user_id.
|
||||
nickname_getter: Hàm lấy nickname.
|
||||
|
||||
Returns:
|
||||
tuple[str | None, str | None]: (image_url, html_content)
|
||||
"""
|
||||
html_content = None
|
||||
try:
|
||||
# 准备渲染数据
|
||||
# Chuẩn bị dữ liệu render.
|
||||
render_payload = await self._prepare_render_data(
|
||||
analysis_result,
|
||||
chart_template="activity_chart.html",
|
||||
@@ -437,7 +437,7 @@ class ReportGenerator(IReportGenerator):
|
||||
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
|
||||
)
|
||||
|
||||
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
||||
# Render template HTML bằng Jinja2.
|
||||
html_content = self.html_templates.render_template(
|
||||
"image_template.html", **render_payload
|
||||
)
|
||||
@@ -447,19 +447,21 @@ class ReportGenerator(IReportGenerator):
|
||||
render_payload.get("avatar_reuse_aliases", {}),
|
||||
)
|
||||
|
||||
# 检查HTML内容是否有效
|
||||
# Kiểm tra nội dung HTML hợp lệ.
|
||||
if not html_content:
|
||||
logger.error("图片报告HTML渲染失败:返回空内容")
|
||||
logger.error("Render HTML báo cáo ảnh thất bại: nội dung rỗng")
|
||||
return None, None
|
||||
|
||||
logger.info(f"图片报告HTML渲染完成,长度: {len(html_content)} 字符")
|
||||
logger.info(
|
||||
f"Render HTML báo cáo ảnh hoàn tất, độ dài: {len(html_content)} ký tự"
|
||||
)
|
||||
|
||||
# 从配置中获取两轮渲染策略
|
||||
# Lấy hai chiến lược render từ cấu hình.
|
||||
render_strategies = self.config_manager.get_t2i_rendering_strategies()
|
||||
|
||||
# 使用信号量控制并发进入渲染引擎
|
||||
# Dùng semaphore kiểm soát concurrency render.
|
||||
async with self._render_semaphore:
|
||||
logger.debug(f"[T2I] 已进入渲染队列 (群: {group_id})")
|
||||
logger.debug(f"[T2I] Đã vào hàng đợi render (nhóm: {group_id})")
|
||||
|
||||
last_exception = None
|
||||
|
||||
@@ -469,18 +471,20 @@ class ReportGenerator(IReportGenerator):
|
||||
if image_options.get("type") == "png":
|
||||
image_options.pop("quality", None)
|
||||
|
||||
logger.info(f"正在尝试第 {attempt} 轮渲染策略: {image_options}")
|
||||
logger.info(
|
||||
f"Đang thử chiến lược render lượt {attempt}: {image_options}"
|
||||
)
|
||||
|
||||
# 改为获取 bytes 数据,避免 OneBot 无法访问内部 URL
|
||||
# Lấy bytes để tránh OneBot không truy cập được URL nội bộ.
|
||||
image_data = await html_render_func(
|
||||
html_content, # 渲染后的HTML内容
|
||||
{}, # 空数据字典,因为数据已包含在HTML中
|
||||
False, # return_url=False,直接获取图片数据
|
||||
html_content,
|
||||
{},
|
||||
False,
|
||||
image_options,
|
||||
)
|
||||
|
||||
if image_data:
|
||||
# 校验是否为合法图片(防止 T2I 返回 500 错误 HTML 字符流)
|
||||
# Xác thực ảnh để tránh T2I trả trang lỗi HTML.
|
||||
is_valid = False
|
||||
actual_data_head = None
|
||||
|
||||
@@ -493,16 +497,16 @@ class ReportGenerator(IReportGenerator):
|
||||
with open(image_data, "rb") as f:
|
||||
actual_data_head = f.read(10)
|
||||
except Exception as e:
|
||||
logger.warning(f"读取图片临时文件失败: {e}")
|
||||
logger.warning(f"Đọc tệp ảnh tạm thất bại: {e}")
|
||||
|
||||
if actual_data_head:
|
||||
# 检查 magic numbers (JPEG: FF D8, PNG: 89 50 4E 47)
|
||||
# Kiểm tra magic number JPEG/PNG.
|
||||
if actual_data_head.startswith(
|
||||
b"\xff\xd8"
|
||||
) or actual_data_head.startswith(b"\x89PNG"):
|
||||
is_valid = True
|
||||
else:
|
||||
# 尝试解析 HTML 错误(如 502 Bad Gateway)
|
||||
# Thử parse lỗi HTML như 502 Bad Gateway.
|
||||
html_error = None
|
||||
if isinstance(image_data, bytes):
|
||||
html_error = self._extract_html_error_summary(
|
||||
@@ -513,7 +517,7 @@ class ReportGenerator(IReportGenerator):
|
||||
):
|
||||
try:
|
||||
with open(image_data, "rb") as f:
|
||||
# 读取前 4KB 即可识别 HTML 错误
|
||||
# 4 KB đầu đủ để nhận diện lỗi HTML.
|
||||
html_error = (
|
||||
self._extract_html_error_summary(
|
||||
f.read(4096)
|
||||
@@ -524,11 +528,11 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
if html_error:
|
||||
logger.warning(
|
||||
f"[T2I] 渲染引擎返回了错误页面而非图片: {html_error}"
|
||||
f"[T2I] Engine render trả trang lỗi thay vì ảnh: {html_error}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"渲染结果似乎不是有效的图片数据 (头部: {actual_data_head.hex()})"
|
||||
f"Kết quả render có vẻ không phải ảnh hợp lệ (header: {actual_data_head.hex()})"
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
@@ -536,35 +540,35 @@ class ReportGenerator(IReportGenerator):
|
||||
b64 = base64.b64encode(image_data).decode("utf-8")
|
||||
image_url = f"base64://{b64}"
|
||||
logger.info(
|
||||
f"图片生成成功 (轮次 {attempt}): [Base64 Data {len(image_data)} bytes]"
|
||||
f"Tạo ảnh thành công (lượt {attempt}): [Base64 Data {len(image_data)} bytes]"
|
||||
)
|
||||
return image_url, html_content
|
||||
elif isinstance(image_data, str):
|
||||
logger.info(
|
||||
f"图片生成成功 (轮次 {attempt}): {image_data}"
|
||||
f"Tạo ảnh thành công (lượt {attempt}): {image_data}"
|
||||
)
|
||||
return image_data, html_content
|
||||
|
||||
logger.warning(
|
||||
f"渲染轮次 {attempt} ({image_options['type']}) 返回了无效或空数据"
|
||||
f"Lượt render {attempt} ({image_options['type']}) trả dữ liệu rỗng hoặc không hợp lệ"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"渲染轮次 {attempt} 失败: {e}")
|
||||
logger.warning(f"Lượt render {attempt} thất bại: {e}")
|
||||
last_exception = e
|
||||
if attempt < len(render_strategies):
|
||||
logger.info("准备尝试下一轮回退策略")
|
||||
logger.info("Chuẩn bị thử chiến lược fallback tiếp theo")
|
||||
continue
|
||||
|
||||
# 如果所有策略都失败
|
||||
logger.error(f"所有渲染尝试都失败。最后一个错误: {last_exception}")
|
||||
# Mọi chiến lược đều thất bại.
|
||||
logger.error(f"Mọi lần render đều thất bại. Lỗi cuối: {last_exception}")
|
||||
return None, html_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成图片报告过程发生严重错误: {e}", exc_info=True)
|
||||
logger.error(f"Lỗi nghiêm trọng khi tạo báo cáo ảnh: {e}", exc_info=True)
|
||||
return None, html_content
|
||||
finally:
|
||||
# 清理本次运行的 session 和缓存
|
||||
# Dọn session và cache của lần chạy này.
|
||||
if self._avatar_session:
|
||||
await self._avatar_session.close()
|
||||
self._avatar_session = None
|
||||
@@ -580,25 +584,25 @@ class ReportGenerator(IReportGenerator):
|
||||
allow_alphanumeric_user_ids: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
生成HTML格式的分析报告,保存到指定目录
|
||||
Tạo báo cáo HTML và lưu vào thư mục chỉ định.
|
||||
|
||||
Args:
|
||||
analysis_result: 分析结果字典
|
||||
group_id: 群组ID
|
||||
avatar_url_getter: 异步回调函数,接收 user_id 返回 avatar_url/data
|
||||
nickname_getter: 昵称获取函数
|
||||
analysis_result: Dict kết quả phân tích.
|
||||
group_id: ID nhóm.
|
||||
avatar_url_getter: Callback async lấy avatar theo user_id.
|
||||
nickname_getter: Hàm lấy nickname.
|
||||
|
||||
Returns:
|
||||
tuple[str | None, str | None]: (html_path, json_path) - HTML文件路径和JSON文件路径
|
||||
Tuple path tệp HTML và JSON.
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
|
||||
# 确保输出目录存在(使用 asyncio.to_thread 避免阻塞)
|
||||
# Đảm bảo thư mục output tồn tại mà không block event loop.
|
||||
output_dir = Path(self.config_manager.get_html_output_dir())
|
||||
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
# 生成文件路径
|
||||
# Tạo path tệp.
|
||||
current_date = datetime.now().strftime("%Y%m%d")
|
||||
base_html_path = self._build_safe_report_path(
|
||||
output_dir,
|
||||
@@ -615,7 +619,7 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 准备渲染数据
|
||||
# Chuẩn bị dữ liệu render.
|
||||
render_data = await self._prepare_render_data(
|
||||
analysis_result,
|
||||
chart_template="activity_chart.html",
|
||||
@@ -625,9 +629,11 @@ class ReportGenerator(IReportGenerator):
|
||||
hide_user_names=hide_user_names,
|
||||
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
|
||||
)
|
||||
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
logger.info(
|
||||
f"Chuẩn bị dữ liệu render HTML hoàn tất, gồm {len(render_data)} trường"
|
||||
)
|
||||
|
||||
# 生成 HTML 内容(使用 Jinja2 渲染器,尝试 html_template.html,失败则回退到 image_template.html)
|
||||
# Render bằng html_template.html, fallback sang image_template.html.
|
||||
html_content = None
|
||||
try:
|
||||
html_content = self.html_templates.render_template(
|
||||
@@ -638,10 +644,10 @@ class ReportGenerator(IReportGenerator):
|
||||
render_data.get("avatar_reuse_registry", {}),
|
||||
render_data.get("avatar_reuse_aliases", {}),
|
||||
)
|
||||
logger.info("使用 html_template.html 渲染成功")
|
||||
logger.info("Render bằng html_template.html thành công")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"html_template.html 不存在或渲染失败,回退到 image_template.html: {e}"
|
||||
f"html_template.html không tồn tại hoặc render lỗi, fallback sang image_template.html: {e}"
|
||||
)
|
||||
html_content = self.html_templates.render_template(
|
||||
"image_template.html", **render_data
|
||||
@@ -651,20 +657,22 @@ class ReportGenerator(IReportGenerator):
|
||||
render_data.get("avatar_reuse_registry", {}),
|
||||
render_data.get("avatar_reuse_aliases", {}),
|
||||
)
|
||||
logger.info("使用 image_template.html 渲染成功")
|
||||
logger.info("Render bằng image_template.html thành công")
|
||||
|
||||
# 检查HTML内容是否有效
|
||||
# Kiểm tra nội dung HTML hợp lệ.
|
||||
if not html_content:
|
||||
logger.error("HTML报告渲染失败:返回空内容")
|
||||
logger.error("Render báo cáo HTML thất bại: nội dung rỗng")
|
||||
return None, None
|
||||
|
||||
logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符")
|
||||
logger.info(
|
||||
f"Tạo nội dung HTML hoàn tất, độ dài: {len(html_content)} ký tự"
|
||||
)
|
||||
|
||||
# 保存 HTML 文件
|
||||
# Lưu tệp HTML.
|
||||
await asyncio.to_thread(
|
||||
html_path.write_text, html_content, encoding="utf-8"
|
||||
)
|
||||
logger.info(f"HTML 报告已保存: {html_path}")
|
||||
logger.info(f"Đã lưu báo cáo HTML: {html_path}")
|
||||
|
||||
def json_default_encoder(obj):
|
||||
if hasattr(obj, "to_dict") and callable(obj.to_dict):
|
||||
@@ -681,7 +689,7 @@ class ReportGenerator(IReportGenerator):
|
||||
f"Object of type {type(obj).__name__} is not JSON serializable"
|
||||
)
|
||||
|
||||
# 保存原始 JSON 数据
|
||||
# Lưu dữ liệu JSON gốc.
|
||||
json_data = {
|
||||
"analysis_result": (
|
||||
self._sanitize_analysis_result_for_export(analysis_result)
|
||||
@@ -701,23 +709,23 @@ class ReportGenerator(IReportGenerator):
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info(f"JSON 数据已保存: {json_path}")
|
||||
logger.info(f"Đã lưu dữ liệu JSON: {json_path}")
|
||||
|
||||
return str(html_path.absolute()), str(json_path.absolute())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成 HTML 报告失败: {e}", exc_info=True)
|
||||
logger.error(f"Tạo báo cáo HTML thất bại: {e}", exc_info=True)
|
||||
return None, None
|
||||
|
||||
def build_html_caption(self, html_path: str) -> str:
|
||||
"""根据 html_base_url 生成 HTML 报告链接 caption。由调用方决定是否发送。"""
|
||||
"""Tạo caption liên kết báo cáo từ html_base_url."""
|
||||
|
||||
caption = "📊 Đã tạo báo cáo phân tích nhóm hàng ngày"
|
||||
base_url = self.config_manager.get_html_base_url()
|
||||
if not base_url or not html_path:
|
||||
return caption
|
||||
|
||||
# 支持 html_filename_format 中的子目录,保持相对路径
|
||||
# Giữ path tương đối để hỗ trợ thư mục con trong format tên tệp.
|
||||
output_dir = Path(self.config_manager.get_html_output_dir()).resolve(
|
||||
strict=False
|
||||
)
|
||||
@@ -733,7 +741,7 @@ class ReportGenerator(IReportGenerator):
|
||||
return caption + f"\n{base_url.rstrip('/')}/{encoded_relative_url}"
|
||||
|
||||
def generate_text_report(self, analysis_result: dict) -> str:
|
||||
"""生成文本格式的分析报告"""
|
||||
"""Tạo báo cáo phân tích dạng văn bản."""
|
||||
stats = analysis_result["statistics"]
|
||||
topics = analysis_result["topics"]
|
||||
user_titles = analysis_result["user_titles"]
|
||||
@@ -865,13 +873,13 @@ class ReportGenerator(IReportGenerator):
|
||||
hide_user_names: bool = False,
|
||||
allow_alphanumeric_user_ids: bool = False,
|
||||
) -> dict:
|
||||
"""准备渲染数据"""
|
||||
"""Chuẩn bị dữ liệu render."""
|
||||
stats = analysis_result["statistics"]
|
||||
topics = analysis_result["topics"]
|
||||
user_titles = analysis_result["user_titles"]
|
||||
activity_viz = stats.activity_visualization
|
||||
|
||||
# 使用Jinja2模板构建话题HTML(批量渲染)
|
||||
# Dựng HTML chủ đề hàng loạt bằng Jinja2.
|
||||
max_topics = self.config_manager.get_max_topics()
|
||||
topics_list = []
|
||||
user_analysis = analysis_result.get("user_analysis")
|
||||
@@ -879,7 +887,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_reuse_aliases: dict[str, str] = {}
|
||||
|
||||
for i, topic in enumerate(topics[:max_topics], 1):
|
||||
# 处理话题详情中的用户引用头像
|
||||
# Xử lý avatar trong tham chiếu người dùng của chi tiết chủ đề.
|
||||
processed_detail = await self._render_mentions(
|
||||
topic.detail,
|
||||
avatar_url_getter,
|
||||
@@ -900,7 +908,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_reuse_aliases,
|
||||
)
|
||||
else:
|
||||
contributors = "、".join(topic.contributors)
|
||||
contributors = ", ".join(topic.contributors)
|
||||
topics_list.append(
|
||||
{
|
||||
"index": i,
|
||||
@@ -914,7 +922,7 @@ class ReportGenerator(IReportGenerator):
|
||||
}
|
||||
)
|
||||
|
||||
# 通用模板上下文,包含可能被子模板引用的全局配置
|
||||
# Context chung gồm cấu hình toàn cục dùng bởi template con.
|
||||
common_context = {
|
||||
"hide_user_names": hide_user_names,
|
||||
"t2i_font_source": self.config_manager.get_t2i_font_source(),
|
||||
@@ -926,16 +934,16 @@ class ReportGenerator(IReportGenerator):
|
||||
topics_html = self.html_templates.render_template(
|
||||
"topic_item.html", topics=topics_list, **common_context
|
||||
)
|
||||
logger.info(f"话题HTML生成完成,长度: {len(topics_html)}")
|
||||
logger.info(f"Tạo HTML chủ đề hoàn tất, độ dài: {len(topics_html)}")
|
||||
|
||||
# 使用Jinja2模板构建用户称号HTML(批量渲染,包含头像)
|
||||
# Dựng HTML danh hiệu hàng loạt bằng Jinja2, gồm avatar.
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
titles_list = []
|
||||
profile_mode = self.config_manager.get_profile_display_mode()
|
||||
profile_mapping_overrides = self._get_profile_mapping_overrides()
|
||||
for title in user_titles[:max_user_titles]:
|
||||
user_id = str(title.user_id)
|
||||
# 获取用户头像
|
||||
# Lấy avatar người dùng.
|
||||
avatar_data = await self._get_user_avatar(
|
||||
user_id, avatar_url_getter, avatar_cache_namespace
|
||||
)
|
||||
@@ -974,9 +982,9 @@ class ReportGenerator(IReportGenerator):
|
||||
titles_html = self.html_templates.render_template(
|
||||
"user_title_item.html", titles=titles_list, **common_context
|
||||
)
|
||||
logger.info(f"用户称号HTML生成完成,长度: {len(titles_html)}")
|
||||
logger.info(f"Tạo HTML danh hiệu hoàn tất, độ dài: {len(titles_html)}")
|
||||
|
||||
# 使用Jinja2模板构建金句HTML(批量渲染)
|
||||
# Dựng HTML trích dẫn hàng loạt bằng Jinja2.
|
||||
max_golden_quotes = self.config_manager.get_max_golden_quotes()
|
||||
quotes_list = []
|
||||
for golden_quote in stats.golden_quotes[:max_golden_quotes]:
|
||||
@@ -999,7 +1007,7 @@ class ReportGenerator(IReportGenerator):
|
||||
quote_user_id, avatar_cache_namespace
|
||||
),
|
||||
)
|
||||
# 处理解析锐评中的用户引用头像
|
||||
# Xử lý avatar trong tham chiếu người dùng của nhận xét.
|
||||
processed_reason = await self._render_mentions(
|
||||
golden_quote.reason,
|
||||
avatar_url_getter,
|
||||
@@ -1025,25 +1033,27 @@ class ReportGenerator(IReportGenerator):
|
||||
quotes_html = self.html_templates.render_template(
|
||||
"quote_item.html", quotes=quotes_list, **common_context
|
||||
)
|
||||
logger.info(f"金句HTML生成完成,长度: {len(quotes_html)}")
|
||||
logger.info(f"Tạo HTML trích dẫn hoàn tất, độ dài: {len(quotes_html)}")
|
||||
|
||||
# 生成活跃度可视化HTML
|
||||
# Tạo HTML biểu đồ hoạt động.
|
||||
chart_data = self.activity_visualizer.get_hourly_chart_data(
|
||||
activity_viz.hourly_activity
|
||||
)
|
||||
hourly_chart_html = self.html_templates.render_template(
|
||||
chart_template, chart_data=chart_data, **common_context
|
||||
)
|
||||
logger.info(f"活跃度图表HTML生成完成,长度: {len(hourly_chart_html)}")
|
||||
logger.info(
|
||||
f"Tạo HTML biểu đồ hoạt động hoàn tất, độ dài: {len(hourly_chart_html)}"
|
||||
)
|
||||
|
||||
# 生成聊天质量锐评HTML
|
||||
# Tạo HTML đánh giá chất lượng trò chuyện.
|
||||
chat_quality_html = ""
|
||||
chat_quality_review = analysis_result.get("chat_quality_review")
|
||||
if not chat_quality_review and hasattr(stats, "chat_quality_review"):
|
||||
chat_quality_review = stats.chat_quality_review
|
||||
|
||||
if chat_quality_review:
|
||||
# 如果是对象,转为字典(为了统一渲染)
|
||||
# Chuyển object thành dict để render thống nhất.
|
||||
if hasattr(chat_quality_review, "dimensions"):
|
||||
review_data = {
|
||||
"title": chat_quality_review.title,
|
||||
@@ -1092,15 +1102,17 @@ class ReportGenerator(IReportGenerator):
|
||||
chat_quality_html = self.html_templates.render_template(
|
||||
"chat_quality_item.html", **review_data, **common_context
|
||||
)
|
||||
logger.info(f"聊天质量锐评HTML生成完成,长度: {len(chat_quality_html)}")
|
||||
logger.info(
|
||||
f"Tạo HTML chất lượng trò chuyện hoàn tất, độ dài: {len(chat_quality_html)}"
|
||||
)
|
||||
|
||||
# 准备最终渲染数据
|
||||
# Chuẩn bị dữ liệu render cuối.
|
||||
render_data = {
|
||||
"t2i_font_source": self.config_manager.get_t2i_font_source(),
|
||||
"t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(),
|
||||
"t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(),
|
||||
"t2i_atri_font_mirror": self.config_manager.get_t2i_atri_font_mirror(),
|
||||
"current_date": datetime.now().strftime("%Y年%m月%d日"),
|
||||
"current_date": datetime.now().strftime("%d/%m/%Y"),
|
||||
"current_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"message_count": stats.message_count,
|
||||
"participant_count": stats.participant_count,
|
||||
@@ -1125,7 +1137,7 @@ class ReportGenerator(IReportGenerator):
|
||||
"avatar_reuse_aliases": avatar_reuse_aliases,
|
||||
}
|
||||
|
||||
logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
logger.info(f"Chuẩn bị dữ liệu render hoàn tất, gồm {len(render_data)} trường")
|
||||
return render_data
|
||||
|
||||
async def _render_avatar_only_ids(
|
||||
@@ -1185,7 +1197,7 @@ class ReportGenerator(IReportGenerator):
|
||||
allow_alphanumeric_user_ids: bool = False,
|
||||
) -> Markup:
|
||||
"""
|
||||
处理文本,将 [用户ID] 格式的引用替换为头像胶囊。
|
||||
Thay tham chiếu dạng ``[user ID]`` trong văn bản bằng capsule avatar.
|
||||
"""
|
||||
if not text:
|
||||
return Markup("")
|
||||
@@ -1198,7 +1210,7 @@ class ReportGenerator(IReportGenerator):
|
||||
source_text = str(text)
|
||||
supports_extended_ids = hide_user_names or allow_alphanumeric_user_ids
|
||||
if supports_extended_ids:
|
||||
# LLM 偶尔会直接输出 ID;先标准化为引用,避免 OpenID 以明文形式显示。
|
||||
# Chuẩn hoá ID do LLM trả trực tiếp thành tham chiếu để ẩn OpenID.
|
||||
for user_id in sorted(known_ids, key=len, reverse=True):
|
||||
source_text = re.sub(
|
||||
rf"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}"
|
||||
@@ -1221,26 +1233,26 @@ class ReportGenerator(IReportGenerator):
|
||||
return Markup(html.escape(f"[{uid}]", quote=True))
|
||||
url = await self._get_user_avatar(
|
||||
uid, avatar_url_getter, avatar_cache_namespace
|
||||
) # 内部已有缓存,无需顶层并发获取
|
||||
) # Đã có cache nội bộ, không cần concurrency cấp trên.
|
||||
|
||||
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:
|
||||
stats = user_analysis[uid]
|
||||
name = stats.get("nickname") or stats.get("name")
|
||||
if self._is_placeholder_display_name(name, uid):
|
||||
name = None
|
||||
|
||||
# 2. 尝试通过回调获取实时昵称
|
||||
# 2. Thử lấy nickname thời gian thực qua callback.
|
||||
if not name and nickname_getter:
|
||||
try:
|
||||
name = await nickname_getter(uid)
|
||||
if self._is_placeholder_display_name(name, uid):
|
||||
name = None
|
||||
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 = (
|
||||
"display:inline-flex;align-items:center;background:rgba(0,0,0,0.05);"
|
||||
"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;"
|
||||
|
||||
# 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_name = (
|
||||
name
|
||||
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(
|
||||
@@ -1330,7 +1342,7 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
return True
|
||||
normalized = str(name).strip()
|
||||
@@ -1342,7 +1354,7 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
@staticmethod
|
||||
def _safe_url_for_log(url: str | None) -> str:
|
||||
"""对日志中的 URL 进行脱敏,避免泄露 token。"""
|
||||
"""Che token trong URL ghi log."""
|
||||
if not url:
|
||||
return ""
|
||||
# Telegram file URL: .../file/bot<token>/<file_path>
|
||||
@@ -1350,7 +1362,7 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
@staticmethod
|
||||
def _build_avatar_ref(avatar_key: str | None, avatar_url: str) -> str:
|
||||
"""根据稳定输入生成不暴露平台或用户 ID 的头像引用。"""
|
||||
"""Tạo tham chiếu avatar ổn định mà không lộ platform hay user ID."""
|
||||
if avatar_key:
|
||||
digest = hashlib.sha256(avatar_key.encode("utf-8")).hexdigest()[:24]
|
||||
return f"avatar-{digest}"
|
||||
@@ -1365,7 +1377,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_reuse_aliases: dict[str, str] | None = None,
|
||||
avatar_key: str | None = None,
|
||||
) -> str | None:
|
||||
"""将 Data URI 头像登记为可复用资源,并返回短引用 ID。"""
|
||||
"""Đăng ký avatar Data URI làm tài nguyên tái sử dụng và trả ID ngắn."""
|
||||
if not avatar_url or avatar_reuse_registry is None:
|
||||
return None
|
||||
if not avatar_url.startswith("data:image/"):
|
||||
@@ -1382,7 +1394,7 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
@staticmethod
|
||||
def _build_avatar_reuse_styles(avatar_reuse_registry: dict[str, str]) -> str:
|
||||
"""为头像生成一次性复用样式。"""
|
||||
"""Tạo style tái sử dụng một lần cho avatar."""
|
||||
if not avatar_reuse_registry:
|
||||
return ""
|
||||
|
||||
@@ -1406,7 +1418,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_reuse_registry: dict[str, str],
|
||||
avatar_reuse_aliases: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""将最终 HTML 中的内联 Data URI 头像 img 改为短引用。"""
|
||||
"""Đổi avatar Data URI inline trong HTML cuối thành tham chiếu ngắn."""
|
||||
if not html_content:
|
||||
return html_content
|
||||
|
||||
@@ -1440,7 +1452,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_reuse_registry: dict[str, str] | None,
|
||||
avatar_reuse_aliases: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""复用最终 HTML 中所有内联头像资源,并注入复用样式。"""
|
||||
"""Tái sử dụng avatar inline trong HTML cuối và inject style."""
|
||||
if not html_content:
|
||||
return html_content
|
||||
|
||||
@@ -1455,7 +1467,7 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
@staticmethod
|
||||
def _inject_avatar_reuse_styles(html_content: str, avatar_reuse_styles: str) -> str:
|
||||
"""将头像复用样式注入最终 HTML。"""
|
||||
"""Inject style tái sử dụng avatar vào HTML cuối."""
|
||||
if not html_content or not avatar_reuse_styles:
|
||||
return html_content
|
||||
|
||||
@@ -1472,7 +1484,7 @@ class ReportGenerator(IReportGenerator):
|
||||
def _get_avatar_cache_key(
|
||||
self, avatar_id: str, avatar_cache_namespace: str | None = None
|
||||
) -> str:
|
||||
"""生成头像缓存键,避免不同平台的同一数字 ID 互相污染。"""
|
||||
"""Tạo cache key avatar để tránh xung đột ID giữa các nền tảng."""
|
||||
namespace = str(avatar_cache_namespace or "legacy").strip() or "legacy"
|
||||
return f"{namespace}:{avatar_id}"
|
||||
|
||||
@@ -1483,39 +1495,42 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
获取用户头像的 Base64 Data URI。
|
||||
使用磁盘缓存,支持跨任务复用。获取失败时不缓存结果,以便后续请求重试。
|
||||
Lấy Data URI Base64 của avatar người dùng.
|
||||
|
||||
Dùng disk cache giữa các tác vụ; không cache thất bại để có thể retry.
|
||||
"""
|
||||
cache_key = self._get_avatar_cache_key(avatar_id, avatar_cache_namespace)
|
||||
# 1. 检查缓存 (仅包含成功的头像数据)
|
||||
# 1. Kiểm tra cache, chỉ chứa avatar thành công.
|
||||
if cache_key in self._avatar_cache:
|
||||
data = self._avatar_cache[cache_key]
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
return str(data)
|
||||
|
||||
# 2. 尝试获取头像字节流
|
||||
# 2. Thử lấy bytes avatar.
|
||||
avatar_bytes = await self._get_user_avatar_bytes(avatar_id, avatar_url_getter)
|
||||
|
||||
if not avatar_bytes:
|
||||
# 获取失败时返回默认头像,但不存入缓存,以便下次重试
|
||||
logger.warning(f"获取用户头像失败 {avatar_id},本次将使用回退头像")
|
||||
# Trả avatar mặc định nhưng không cache để lần sau có thể retry.
|
||||
logger.warning(
|
||||
f"Lấy avatar người dùng thất bại {avatar_id}; dùng avatar fallback"
|
||||
)
|
||||
return self._get_default_avatar_base64()
|
||||
|
||||
# 3. 获取成功:转换并缓存
|
||||
# 3. Chuyển đổi và cache khi thành công.
|
||||
avatar = self._b64_with_mime(avatar_bytes)
|
||||
if avatar:
|
||||
self._avatar_cache.set(cache_key, avatar, expire=AVATAR_CACHE_EXPIRE_TIME)
|
||||
return avatar
|
||||
|
||||
# 最终兜底
|
||||
# Fallback cuối.
|
||||
return self._get_default_avatar_base64()
|
||||
|
||||
def _b64_with_mime(self, _bytes: bytes) -> str | None:
|
||||
"""将字节数据转换为 Base64 Data URI,并自动识别 MIME 类型。"""
|
||||
"""Chuyển bytes thành Data URI Base64 và tự nhận diện MIME type."""
|
||||
try:
|
||||
b64 = base64.b64encode(_bytes).decode("utf-8")
|
||||
# 简单判断 mime type
|
||||
# Nhận diện MIME type đơn giản.
|
||||
mime = "image/jpeg"
|
||||
if _bytes.startswith(b"\x89PNG"):
|
||||
mime = "image/png"
|
||||
@@ -1528,13 +1543,13 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
return f"data:{mime};base64,{b64}"
|
||||
except Exception as e:
|
||||
logger.error(f"base64 转换失败: {e}", exc_info=True)
|
||||
logger.error(f"Chuyển Base64 thất bại: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _get_user_avatar_bytes(
|
||||
self, user_id: str, avatar_url_getter=None
|
||||
) -> bytes | None:
|
||||
"""核心头像获取逻辑"""
|
||||
"""Logic lõi lấy avatar."""
|
||||
file_content = None
|
||||
if not self._avatar_session:
|
||||
self._avatar_session = aiohttp.ClientSession(
|
||||
@@ -1544,7 +1559,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_url = None
|
||||
if avatar_url_getter:
|
||||
try:
|
||||
# avatar_url_getter 应该返回 URL
|
||||
# avatar_url_getter dự kiến trả URL.
|
||||
result = await avatar_url_getter(user_id)
|
||||
if result:
|
||||
if result.startswith("http"):
|
||||
@@ -1557,10 +1572,12 @@ class ReportGenerator(IReportGenerator):
|
||||
return base64.b64decode(parts[1])
|
||||
else:
|
||||
logger.warning(
|
||||
f"custom avatar_url_getter 返回了非 HTTP URL: {result[:50]}..."
|
||||
f"avatar_url_getter tuỳ chỉnh trả URL không phải HTTP: {result[:50]}..."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"使用 custom avatar_url_getter 获取头像失败: {e}")
|
||||
logger.warning(
|
||||
f"Lấy avatar bằng avatar_url_getter tuỳ chỉnh thất bại: {e}"
|
||||
)
|
||||
|
||||
if not avatar_url:
|
||||
if (
|
||||
@@ -1568,22 +1585,22 @@ class ReportGenerator(IReportGenerator):
|
||||
and user_id.isdigit()
|
||||
and 5 <= len(user_id) <= 12
|
||||
):
|
||||
# 强制使用 spec=40
|
||||
# Buộc dùng spec=40.
|
||||
avatar_url = (
|
||||
f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40"
|
||||
)
|
||||
else:
|
||||
# 其他平台若无 URL,无法获取头像
|
||||
# Nền tảng khác không thể lấy avatar nếu thiếu URL.
|
||||
return None
|
||||
|
||||
# 5. 下载并保存
|
||||
# 5. Tải và lưu.
|
||||
safe_avatar_url = self._safe_url_for_log(avatar_url)
|
||||
try:
|
||||
async with self._avatar_session.get(avatar_url) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
if content:
|
||||
# 校验文件头
|
||||
# Xác thực header tệp.
|
||||
is_valid_image = False
|
||||
if content.startswith(b"\xff\xd8"): # JPEG
|
||||
is_valid_image = True
|
||||
@@ -1600,26 +1617,26 @@ class ReportGenerator(IReportGenerator):
|
||||
file_content = content
|
||||
else:
|
||||
logger.warning(
|
||||
f"下载的头像数据格式无效 ({safe_avatar_url})"
|
||||
f"Dữ liệu avatar tải về không hợp lệ ({safe_avatar_url})"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"下载头像失败 {safe_avatar_url}: {response.status}"
|
||||
f"Tải avatar thất bại {safe_avatar_url}: {response.status}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"下载头像网络错误 {safe_avatar_url}: {e}")
|
||||
logger.warning(f"Lỗi mạng khi tải avatar {safe_avatar_url}: {e}")
|
||||
|
||||
return file_content
|
||||
|
||||
def _get_default_avatar_base64(self) -> str:
|
||||
"""返回默认头像 (灰色圆形占位符)"""
|
||||
# 一个简单的灰色圆圈 SVG 转 Base64
|
||||
"""Trả avatar mặc định là placeholder hình tròn màu xám."""
|
||||
# SVG hình tròn xám đơn giản dưới dạng Base64.
|
||||
svg = '<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")
|
||||
return f"data:image/svg+xml;base64,{b64}"
|
||||
|
||||
async def close(self):
|
||||
"""释放资源,关闭缓存和 session"""
|
||||
"""Giải phóng tài nguyên, đóng cache và session."""
|
||||
if self._avatar_session:
|
||||
await self._avatar_session.close()
|
||||
self._avatar_session = None
|
||||
@@ -1627,31 +1644,31 @@ class ReportGenerator(IReportGenerator):
|
||||
try:
|
||||
if self._avatar_cache:
|
||||
self._avatar_cache.close()
|
||||
logger.debug("头像缓存已关闭")
|
||||
logger.debug("Đã đóng cache avatar")
|
||||
except Exception as e:
|
||||
logger.warning(f"关闭头像缓存失败: {e}")
|
||||
logger.warning(f"Đóng cache avatar thất bại: {e}")
|
||||
|
||||
def _extract_html_error_summary(self, data: bytes) -> str | None:
|
||||
"""从返回的字节流中尝试提取 HTML 错误信息(如 <title>)"""
|
||||
"""Thử trích xuất lỗi HTML như title từ bytes phản hồi."""
|
||||
try:
|
||||
content = data.decode("utf-8", errors="ignore")
|
||||
content_lower = content.lower()
|
||||
if "<html" in content_lower or "<!doctype html" in content_lower:
|
||||
# 尝试提取标题
|
||||
# Thử trích xuất title.
|
||||
title_match = re.search(
|
||||
r"<title>(.*?)</title>", content, re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
if title_match:
|
||||
return f"HTML 错误页: {title_match.group(1).strip()}"
|
||||
return f"Trang lỗi HTML: {title_match.group(1).strip()}"
|
||||
|
||||
# 尝试提取 h1
|
||||
# Thử trích xuất h1.
|
||||
h1_match = re.search(
|
||||
r"<h1>(.*?)</h1>", content, re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
if h1_match:
|
||||
return f"HTML 错误页: {h1_match.group(1).strip()}"
|
||||
return f"Trang lỗi HTML: {h1_match.group(1).strip()}"
|
||||
|
||||
return f"HTML 响应 (前100字): {content[:100].strip()}..."
|
||||
return f"Phản hồi HTML (100 ký tự đầu): {content[:100].strip()}..."
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
HTML模板模块
|
||||
使用Jinja2加载外部HTML模板文件
|
||||
"""
|
||||
"""Module tải template HTML bên ngoài bằng Jinja2."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@@ -13,25 +10,25 @@ from ...utils.logger import logger
|
||||
|
||||
|
||||
class HTMLTemplates:
|
||||
"""HTML模板管理类"""
|
||||
"""Trình quản lý template HTML."""
|
||||
|
||||
def __init__(self, config_manager):
|
||||
"""初始化Jinja2环境"""
|
||||
"""Khởi tạo môi trường Jinja2."""
|
||||
self.config_manager = config_manager
|
||||
# 设置模板根目录
|
||||
# Thiết lập thư mục gốc của template.
|
||||
self.base_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
self.platform_base_dir = os.path.join(
|
||||
os.path.dirname(__file__), "platform_templates"
|
||||
)
|
||||
# 缓存不同模板的Jinja2环境(多线程安全)
|
||||
# Cache môi trường Jinja2 theo template, an toàn đa luồng.
|
||||
self._envs = {}
|
||||
self._env_lock = threading.Lock()
|
||||
|
||||
def _get_env_sync(self) -> Environment:
|
||||
"""获取当前配置的模板环境(同步版本,供 asyncio.to_thread 调用)"""
|
||||
"""Lấy đồng bộ môi trường template hiện tại cho asyncio.to_thread."""
|
||||
template_name = self.config_manager.get_report_template()
|
||||
|
||||
# 如果环境已缓存且配置未变(使用锁保证多线程安全)
|
||||
# Trả cache nếu có; dùng lock để đảm bảo an toàn đa luồng.
|
||||
with self._env_lock:
|
||||
env = self._envs.get(template_name)
|
||||
if env is not None:
|
||||
@@ -39,7 +36,9 @@ class HTMLTemplates:
|
||||
|
||||
template_dir = os.path.join(self.base_dir, template_name)
|
||||
if not os.path.exists(template_dir):
|
||||
logger.warning(f"模板目录不存在: {template_dir},回退到 scrapbook")
|
||||
logger.warning(
|
||||
f"Thư mục template không tồn tại: {template_dir}, chuyển sang scrapbook"
|
||||
)
|
||||
template_dir = os.path.join(self.base_dir, "scrapbook")
|
||||
|
||||
env = Environment(
|
||||
@@ -49,7 +48,7 @@ class HTMLTemplates:
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
# 使用双重检查锁定,避免在高并发下重复创建相同 template_name 的 env
|
||||
# Double-check locking để tránh tạo trùng môi trường khi tải cao.
|
||||
with self._env_lock:
|
||||
existing = self._envs.get(template_name)
|
||||
if existing is not None:
|
||||
@@ -59,69 +58,69 @@ class HTMLTemplates:
|
||||
return env
|
||||
|
||||
async def _get_env_async(self) -> Environment:
|
||||
"""获取当前配置的模板环境(异步版本)"""
|
||||
"""Lấy bất đồng bộ môi trường template hiện tại."""
|
||||
return await asyncio.to_thread(self._get_env_sync)
|
||||
|
||||
def _get_env(self) -> Environment:
|
||||
"""获取当前配置的模板环境(同步版本,向后兼容)"""
|
||||
"""Lấy đồng bộ môi trường template hiện tại để tương thích ngược."""
|
||||
return self._get_env_sync()
|
||||
|
||||
def _read_template_file_sync(self, filename: str) -> str:
|
||||
"""同步读取模板文件内容"""
|
||||
"""Đọc đồng bộ nội dung tệp template."""
|
||||
with open(filename, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
async def get_image_template_async(self) -> str:
|
||||
"""获取图片报告的HTML模板(异步版本,返回原始模板字符串)"""
|
||||
"""Lấy bất đồng bộ template HTML báo cáo ảnh dưới dạng chuỗi gốc."""
|
||||
try:
|
||||
env = await self._get_env_async()
|
||||
template = env.get_template("image_template.html")
|
||||
if template.filename is None:
|
||||
logger.error("图片模板路径为空")
|
||||
logger.error("Đường dẫn template ảnh rỗng")
|
||||
return ""
|
||||
return await asyncio.to_thread(
|
||||
self._read_template_file_sync, template.filename
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"加载图片模板失败: {e}")
|
||||
logger.error(f"Tải template ảnh thất bại: {e}")
|
||||
return ""
|
||||
|
||||
def get_image_template(self) -> str:
|
||||
"""获取图片报告的HTML模板(同步版本,向后兼容)"""
|
||||
"""Lấy đồng bộ template HTML báo cáo ảnh để tương thích ngược."""
|
||||
try:
|
||||
env = self._get_env()
|
||||
template = env.get_template("image_template.html")
|
||||
if template.filename is None:
|
||||
logger.error("图片模板路径为空")
|
||||
logger.error("Đường dẫn template ảnh rỗng")
|
||||
return ""
|
||||
with open(template.filename, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"加载图片模板失败: {e}")
|
||||
logger.error(f"Tải template ảnh thất bại: {e}")
|
||||
return ""
|
||||
|
||||
def render_template(self, template_name: str, **kwargs) -> str:
|
||||
"""渲染指定的模板文件
|
||||
"""Render tệp template được chỉ định.
|
||||
|
||||
Args:
|
||||
template_name: 模板文件名
|
||||
**kwargs: 传递给模板的变量
|
||||
template_name: Tên tệp template.
|
||||
**kwargs: Biến truyền cho template.
|
||||
|
||||
Returns:
|
||||
渲染后的HTML字符串
|
||||
Chuỗi HTML đã render.
|
||||
"""
|
||||
try:
|
||||
env = self._get_env()
|
||||
template = env.get_template(template_name)
|
||||
return template.render(**kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"渲染模板 {template_name} 失败: {e}")
|
||||
logger.error(f"Render template {template_name} thất bại: {e}")
|
||||
return ""
|
||||
|
||||
def render_platform_template(
|
||||
self, platform_name: str, template_name: str, **kwargs
|
||||
) -> str:
|
||||
"""渲染与报告主题解耦的平台专用模板。"""
|
||||
"""Render template riêng theo nền tảng, độc lập với theme báo cáo."""
|
||||
try:
|
||||
template_dir = os.path.join(self.platform_base_dir, platform_name)
|
||||
env = Environment(
|
||||
@@ -132,5 +131,7 @@ class HTMLTemplates:
|
||||
)
|
||||
return env.get_template(template_name).render(**kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"渲染平台模板 {platform_name}/{template_name} 失败: {e}")
|
||||
logger.error(
|
||||
f"Render template nền tảng {platform_name}/{template_name} thất bại: {e}"
|
||||
)
|
||||
return ""
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
min-height: 5px;
|
||||
box-shadow: 0 6px 14px rgba({{ bottom_red }}, {{ bottom_green }}, {{ bottom_blue }}, 0.14);
|
||||
border: none;
|
||||
" title="{{hour_data.hour}}时 - {{hour_data.count}}条消息"></div>
|
||||
" title="{{hour_data.hour}} giờ - {{hour_data.count}} tin nhắn"></div>
|
||||
</div>
|
||||
<span style="font-size: 10px; color: #9a8aa3; margin-top: 8px; line-height: 1;">{{hour_data.hour}}</span>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
.quality-card::before {
|
||||
content: "锐评";
|
||||
content: "Đánh giá";
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 18px;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
@font-face {
|
||||
font-family: 'LXGW WenKai';
|
||||
@@ -2090,7 +2090,7 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Animations Container */
|
||||
@keyframes floatUpDown {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
@@ -2116,7 +2116,7 @@
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
}
|
||||
|
||||
|
||||
@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); }
|
||||
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 */
|
||||
.float-sticker:nth-child(even) { animation: floatUpDown 4s 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-star, .sticker-sparkle, .sticker-cat { animation-delay: 0.5s !important; }
|
||||
.sticker-bow, .sticker-wand, .sticker-ribbon { animation-delay: 1s !important; }
|
||||
@@ -2289,7 +2289,7 @@
|
||||
.glass-card {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
|
||||
.html-slot .item:hover {
|
||||
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;
|
||||
@@ -2684,9 +2684,9 @@
|
||||
</div>
|
||||
<div class="header-copy">
|
||||
<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">
|
||||
主人,今天群里的大家也超级精神呢!身为高性能的亚托莉,已经把所有闪闪发光的聊天记忆,像打捞海底宝藏一样全都收集好啦!
|
||||
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 class="date-box">
|
||||
@@ -2708,10 +2708,10 @@
|
||||
<img class="hero-kawaii" src="{{ t2i_atri_font_mirror }}/file/1775132815504_1774881268554_可爱.gif"
|
||||
alt="cute icon">
|
||||
</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">
|
||||
报告主人!今天一共捕获了 <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>
|
||||
<img class="hero-character"
|
||||
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-time">{{ most_active_period }}</div>
|
||||
<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>
|
||||
@@ -2740,7 +2740,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<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 class="stat-card">
|
||||
@@ -2749,7 +2749,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<div class="stat-value">{{ participant_count }}</div>
|
||||
<div class="stat-label">参与人数</div>
|
||||
<div class="stat-label">Người tham gia</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -2758,7 +2758,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<div class="stat-value">{{ total_characters }}</div>
|
||||
<div class="stat-label">总字符数</div>
|
||||
<div class="stat-label">Tổng ký tự</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -2767,7 +2767,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<div class="stat-value">{{ emoji_count }}</div>
|
||||
<div class="stat-label">表情数量</div>
|
||||
<div class="stat-label">Số biểu cảm</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -2779,10 +2779,10 @@
|
||||
<use href="#icon-bar-chart-3"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">TIDE CHART <img class="badge-kawaii"
|
||||
@@ -2799,10 +2799,10 @@
|
||||
<use href="#icon-flame"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">TOPIC BOARD <img class="badge-kawaii"
|
||||
@@ -2819,10 +2819,10 @@
|
||||
<use href="#icon-badge-check"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">SPOTLIGHT <img class="badge-kawaii"
|
||||
@@ -2839,10 +2839,10 @@
|
||||
<use href="#icon-quote"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">PRECIOUS WORDS <img class="badge-kawaii"
|
||||
@@ -2860,10 +2860,10 @@
|
||||
<use href="#icon-flower-2"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">GROUP VIBE <img class="badge-kawaii"
|
||||
@@ -2881,10 +2881,10 @@
|
||||
<use href="#icon-sparkles"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">ENERGY COST <img class="badge-kawaii"
|
||||
@@ -2893,15 +2893,15 @@
|
||||
</div>
|
||||
<div class="token-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -2921,7 +2921,7 @@
|
||||
<span>SXP-Simon/astrbot_plugin_qq_group_daily_analysis</span>
|
||||
</a>
|
||||
</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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
@font-face {
|
||||
font-family: 'LXGW WenKai';
|
||||
@@ -1674,9 +1674,9 @@
|
||||
<header class="header">
|
||||
<div class="header-copy">
|
||||
<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">
|
||||
主人,今天群里的大家也超级精神呢!身为高性能的亚托莉,已经把所有闪闪发光的聊天记忆,像打捞海底宝藏一样全都收集好啦!
|
||||
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 class="date-box">
|
||||
@@ -1699,10 +1699,10 @@
|
||||
src="{{ t2i_atri_font_mirror }}/file/1775130600453_1774881268554_可爱.webp"
|
||||
alt="cute icon">
|
||||
</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">
|
||||
报告主人!今天一共捕获了 <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>
|
||||
<img class="hero-character"
|
||||
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-time">{{ most_active_period }}</div>
|
||||
<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>
|
||||
@@ -1733,7 +1733,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<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 class="stat-card">
|
||||
@@ -1742,7 +1742,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<div class="stat-value">{{ participant_count }}</div>
|
||||
<div class="stat-label">参与人数</div>
|
||||
<div class="stat-label">Người tham gia</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -1751,7 +1751,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<div class="stat-value">{{ total_characters }}</div>
|
||||
<div class="stat-label">总字符数</div>
|
||||
<div class="stat-label">Tổng ký tự</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -1760,7 +1760,7 @@
|
||||
</svg></div>
|
||||
<div class="stat-copy">
|
||||
<div class="stat-value">{{ emoji_count }}</div>
|
||||
<div class="stat-label">表情数量</div>
|
||||
<div class="stat-label">Số biểu cảm</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1772,10 +1772,10 @@
|
||||
<use href="#icon-bar-chart-3"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">TIDE CHART <img class="badge-kawaii"
|
||||
@@ -1792,10 +1792,10 @@
|
||||
<use href="#icon-flame"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">TOPIC BOARD <img class="badge-kawaii"
|
||||
@@ -1812,10 +1812,10 @@
|
||||
<use href="#icon-badge-check"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">SPOTLIGHT <img class="badge-kawaii"
|
||||
@@ -1832,10 +1832,10 @@
|
||||
<use href="#icon-quote"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">PRECIOUS WORDS <img class="badge-kawaii"
|
||||
@@ -1853,10 +1853,10 @@
|
||||
<use href="#icon-flower-2"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">GROUP VIBE <img class="badge-kawaii"
|
||||
@@ -1874,10 +1874,10 @@
|
||||
<use href="#icon-sparkles"></use>
|
||||
</svg></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"
|
||||
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 class="section-badge">ENERGY COST <img class="badge-kawaii"
|
||||
@@ -1886,15 +1886,15 @@
|
||||
</div>
|
||||
<div class="token-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -1914,7 +1914,7 @@
|
||||
<span>SXP-Simon/astrbot_plugin_qq_group_daily_analysis</span>
|
||||
</a>
|
||||
</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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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);">
|
||||
{% set chart_colors = [
|
||||
'#66D1C7', '#6FD3C6', '#79D5C4', '#85D8C3', '#93DAC1', '#A3DDBF',
|
||||
@@ -32,7 +32,7 @@
|
||||
{% endfor %}
|
||||
</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;">
|
||||
{% for item in chart_data %}
|
||||
<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>
|
||||
<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;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-page-base);
|
||||
|
||||
/* 背景图:多层叠加,保证通透和细节 */
|
||||
/* Ảnh nền nhiều lớp để giữ độ trong và chi tiết */
|
||||
background-image:
|
||||
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),
|
||||
@@ -77,12 +77,12 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-miku-light);
|
||||
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-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* --- 丰富的背景隐含文字装饰 (极低透明度,不影响阅读) --- */
|
||||
/* Chữ trang trí ẩn trên nền với độ mờ rất thấp */
|
||||
.deco-01 {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
@@ -142,7 +142,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 头部设计 */
|
||||
/* Thiết kế header */
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -182,7 +182,7 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 统计卡片层 */
|
||||
/* Lớp thẻ thống kê */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -227,21 +227,21 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* --- 活跃峰值时段块 (核心修改区) --- */
|
||||
/* Khối khung giờ hoạt động cao điểm */
|
||||
.active-peak-block {
|
||||
grid-column: span 12;
|
||||
background: linear-gradient(135deg, var(--miku-base) 0%, var(--miku-light) 100%);
|
||||
color: #fff;
|
||||
|
||||
/* 修改 1: 设定固定高度,防止被图片拉大 */
|
||||
/* Đặt chiều cao cố định để ảnh không kéo giãn */
|
||||
height: 120px;
|
||||
/* 修改 2: 移除垂直 padding,让内容占满高度 */
|
||||
/* Bỏ padding dọc để nội dung phủ hết chiều cao */
|
||||
padding: 0 35px;
|
||||
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 30px;
|
||||
|
||||
/* 修改 3: 使用水平 Flex 布局,文字居左,图片居右 */
|
||||
/* Flex ngang: chữ bên trái, ảnh bên phải */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -252,7 +252,7 @@
|
||||
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 {
|
||||
content: "MIKU";
|
||||
position: absolute;
|
||||
@@ -272,7 +272,7 @@
|
||||
letter-spacing: -3px;
|
||||
}
|
||||
|
||||
/* 文本区域样式 */
|
||||
/* Kiểu vùng văn bản */
|
||||
.peak-label {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
@@ -303,32 +303,32 @@
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
/* 图片容器样式:顶天立地,完全不拉伸 */
|
||||
/* Container ảnh phủ chiều cao nhưng không kéo giãn */
|
||||
.peak-image-container {
|
||||
height: 100%;
|
||||
/* 核心:容器高度占满 */
|
||||
/* Container phủ hết chiều cao */
|
||||
width: auto;
|
||||
max-width: 45%;
|
||||
/* 防止图片太宽挤扁文字 */
|
||||
/* Tránh ảnh quá rộng ép vùng chữ */
|
||||
flex: 0 1 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* 在水印上方,但在文字下方 */
|
||||
/* Trên watermark nhưng dưới chữ */
|
||||
}
|
||||
|
||||
.miku-peak-activity-image {
|
||||
height: 100%;
|
||||
/* 核心:图片高度占满容器 */
|
||||
/* Ảnh phủ hết chiều cao container */
|
||||
width: 150px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 模块标头公共样式 */
|
||||
/* Kiểu chung cho tiêu đề module */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -358,7 +358,7 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 全局通用卡片 */
|
||||
/* Thẻ dùng chung toàn cục */
|
||||
.card-common {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: var(--radius);
|
||||
@@ -376,13 +376,13 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* --- 完美对齐的透明马卡龙底部栏 --- */
|
||||
/* Thanh đáy macaron trong suốt được căn chỉnh */
|
||||
.footer {
|
||||
margin-top: 50px;
|
||||
/* 使用半透明的马卡龙蓝/葱绿作为底色,告别黑色沉闷 */
|
||||
/* Nền xanh macaron bán trong suốt thay cho màu đen */
|
||||
background: rgba(224, 242, 241, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
/* 毛玻璃效果 */
|
||||
/* Hiệu ứng kính mờ */
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(57, 197, 187, 0.3);
|
||||
border-radius: var(--radius);
|
||||
@@ -407,7 +407,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 字体颜色适配浅色背景 */
|
||||
/* Màu chữ phù hợp nền sáng */
|
||||
.footer-repo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-page-base);
|
||||
|
||||
/* 背景图:多层叠加,保证通透和细节 */
|
||||
/* Ảnh nền nhiều lớp để giữ độ trong và chi tiết */
|
||||
background-image:
|
||||
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),
|
||||
@@ -77,12 +77,12 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-miku-light);
|
||||
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-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* --- 丰富的背景隐含文字装饰 (极低透明度,不影响阅读) --- */
|
||||
/* Chữ trang trí ẩn trên nền với độ mờ rất thấp */
|
||||
.deco-01 {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
@@ -142,7 +142,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 头部设计 */
|
||||
/* Thiết kế header */
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -182,7 +182,7 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 统计卡片层 */
|
||||
/* Lớp thẻ thống kê */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -227,21 +227,21 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* --- 活跃峰值时段块 (核心修改区) --- */
|
||||
/* Khối khung giờ hoạt động cao điểm */
|
||||
.active-peak-block {
|
||||
grid-column: span 12;
|
||||
background: linear-gradient(135deg, var(--miku-base) 0%, var(--miku-light) 100%);
|
||||
color: #fff;
|
||||
|
||||
/* 修改 1: 设定固定高度,防止被图片拉大 */
|
||||
/* Đặt chiều cao cố định để ảnh không kéo giãn */
|
||||
height: 120px;
|
||||
/* 修改 2: 移除垂直 padding,让内容占满高度 */
|
||||
/* Bỏ padding dọc để nội dung phủ hết chiều cao */
|
||||
padding: 0 35px;
|
||||
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 30px;
|
||||
|
||||
/* 修改 3: 使用水平 Flex 布局,文字居左,图片居右 */
|
||||
/* Flex ngang: chữ bên trái, ảnh bên phải */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -252,7 +252,7 @@
|
||||
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 {
|
||||
content: "MIKU";
|
||||
position: absolute;
|
||||
@@ -272,7 +272,7 @@
|
||||
letter-spacing: -3px;
|
||||
}
|
||||
|
||||
/* 文本区域样式 */
|
||||
/* Kiểu vùng văn bản */
|
||||
.peak-label {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
@@ -303,32 +303,32 @@
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
/* 图片容器样式:顶天立地,完全不拉伸 */
|
||||
/* Container ảnh phủ chiều cao nhưng không kéo giãn */
|
||||
.peak-image-container {
|
||||
height: 100%;
|
||||
/* 核心:容器高度占满 */
|
||||
/* Container phủ hết chiều cao */
|
||||
width: auto;
|
||||
max-width: 45%;
|
||||
/* 防止图片太宽挤扁文字 */
|
||||
/* Tránh ảnh quá rộng ép vùng chữ */
|
||||
flex: 0 1 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* 在水印上方,但在文字下方 */
|
||||
/* Trên watermark nhưng dưới chữ */
|
||||
}
|
||||
|
||||
.miku-peak-activity-image {
|
||||
height: 100%;
|
||||
/* 核心:图片高度占满容器 */
|
||||
/* Ảnh phủ hết chiều cao container */
|
||||
width: 150px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 模块标头公共样式 */
|
||||
/* Kiểu chung cho tiêu đề module */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -358,7 +358,7 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 全局通用卡片 */
|
||||
/* Thẻ dùng chung toàn cục */
|
||||
.card-common {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: var(--radius);
|
||||
@@ -376,13 +376,13 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* --- 完美对齐的透明马卡龙底部栏 --- */
|
||||
/* Thanh đáy macaron trong suốt được căn chỉnh */
|
||||
.footer {
|
||||
margin-top: 50px;
|
||||
/* 使用半透明的马卡龙蓝/葱绿作为底色,告别黑色沉闷 */
|
||||
/* Nền xanh macaron bán trong suốt thay cho màu đen */
|
||||
background: rgba(224, 242, 241, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
/* 毛玻璃效果 */
|
||||
/* Hiệu ứng kính mờ */
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(57, 197, 187, 0.3);
|
||||
border-radius: var(--radius);
|
||||
@@ -407,7 +407,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 字体颜色适配浅色背景 */
|
||||
/* Màu chữ phù hợp nền sáng */
|
||||
.footer-repo {
|
||||
display: inline-flex;
|
||||
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>
|
||||
<link rel="preconnect" href="{{ t2i_google_fonts_mirror }}">
|
||||
<link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin>
|
||||
<!-- 引入手写字体和正文阅读字体 -->
|
||||
<!-- Tải font viết tay và font nội dung -->
|
||||
<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"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
/* 卡通手账调色板 */
|
||||
/* Bảng màu sổ tay hoạt hình */
|
||||
--bg-paper: #fdfbf7;
|
||||
--ink-primary: #5d4037;
|
||||
/* 像铅笔/墨水的深褐色 */
|
||||
/* Nâu đậm như bút chì hoặc mực */
|
||||
--ink-secondary: #8d6e63;
|
||||
|
||||
--color-yellow: #fff9c4;
|
||||
@@ -28,22 +28,22 @@
|
||||
|
||||
--accent-orange: #ff7043;
|
||||
|
||||
/* MBTI 专属高对比色 */
|
||||
/* Màu tương phản cao dành cho MBTI */
|
||||
--mbti-bg: #ede7f6;
|
||||
--mbti-border: #9575cd;
|
||||
--mbti-text: #512da8;
|
||||
|
||||
/* 称号专属色 */
|
||||
/* Màu riêng cho danh hiệu */
|
||||
--title-bg: #fffde7;
|
||||
--title-border: #ffb74d;
|
||||
--title-text: #bf360c;
|
||||
|
||||
--font-title: 'ZCOOL KuaiLe', cursive;
|
||||
/* 标题字体 */
|
||||
/* Font tiêu đề */
|
||||
--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 nội dung */
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -164,7 +164,7 @@
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-primary);
|
||||
background-color: var(--bg-paper);
|
||||
/* 点阵纸背景纹理 */
|
||||
/* Hoạ tiết nền giấy chấm bi */
|
||||
background-image: radial-gradient(#ddd 2px, transparent 2px);
|
||||
background-size: 20px 20px;
|
||||
min-height: 100vh;
|
||||
@@ -172,7 +172,7 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* SVG 图标通用样式 */
|
||||
/* Kiểu chung cho biểu tượng SVG */
|
||||
.doodle {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
@@ -210,7 +210,7 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ==================== 1. 头部设计 ==================== */
|
||||
/* ==================== 1. Thiết kế header ==================== */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
@@ -264,8 +264,8 @@
|
||||
mask-repeat: repeat-x;
|
||||
}
|
||||
|
||||
/* ==================== 2. 统计邮票 ==================== */
|
||||
/* ==================== 2. 统计区域 (新版) ==================== */
|
||||
/* ==================== 2. Tem thống kê ==================== */
|
||||
/* ==================== 2. Vùng thống kê mới ==================== */
|
||||
.stats-wrapper {
|
||||
display: flex;
|
||||
gap: 25px;
|
||||
@@ -296,7 +296,7 @@
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 装饰性背景纹理 */
|
||||
/* Hoạ tiết nền trang trí */
|
||||
.stamp::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -332,7 +332,7 @@
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
/* ==================== 布局容器 ==================== */
|
||||
/* ==================== Container bố cục ==================== */
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
@@ -353,7 +353,7 @@
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
/* ==================== 3. 图表区 ==================== */
|
||||
/* ==================== 3. Vùng biểu đồ ==================== */
|
||||
.chart-section {
|
||||
grid-column: span 7;
|
||||
background: #fff;
|
||||
@@ -374,7 +374,7 @@
|
||||
border-left: 2px solid #999;
|
||||
}
|
||||
|
||||
/* 手绘条形图模拟 */
|
||||
/* Mô phỏng biểu đồ cột vẽ tay */
|
||||
.hand-chart-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -450,8 +450,8 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 4. 活跃时段 ==================== */
|
||||
/* ==================== 4. 活跃时段 (新版) ==================== */
|
||||
/* ==================== 4. Khung giờ hoạt động ==================== */
|
||||
/* ==================== 4. Khung giờ hoạt động mới ==================== */
|
||||
.highlight-section {
|
||||
flex: 1;
|
||||
background: var(--color-yellow);
|
||||
@@ -491,7 +491,7 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* ==================== 5. 话题列表 ==================== */
|
||||
/* ==================== 5. Danh sách chủ đề ==================== */
|
||||
.topic-section {
|
||||
grid-column: span 12;
|
||||
background: #fff;
|
||||
@@ -522,7 +522,7 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -579,7 +579,7 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ==================== 6. 群友画像 ==================== */
|
||||
/* ==================== 6. Hồ sơ thành viên ==================== */
|
||||
.user-section {
|
||||
grid-column: span 12;
|
||||
margin-top: 20px;
|
||||
@@ -755,7 +755,7 @@
|
||||
/* Removed big quote mark to clean up */
|
||||
}
|
||||
|
||||
/* ==================== 7. 每日回响 ==================== */
|
||||
/* ==================== 7. Dư âm hằng ngày ==================== */
|
||||
.quotes-section {
|
||||
grid-column: span 12;
|
||||
display: flex;
|
||||
@@ -1023,7 +1023,7 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- 1. 头部 -->
|
||||
<!-- 1. Header -->
|
||||
<div class="header">
|
||||
<div class="title-sticker">
|
||||
<div class="tape"></div>
|
||||
@@ -1032,7 +1032,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 统计邮票和活跃时段 -->
|
||||
<!-- 2. Tem thống kê và khung giờ hoạt động -->
|
||||
<div class="stats-wrapper">
|
||||
<!-- Left: Stats Grid (2x2) -->
|
||||
<div class="stats-grid">
|
||||
@@ -1096,7 +1096,7 @@
|
||||
|
||||
<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="coil"></div>
|
||||
<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>
|
||||
<link rel="preconnect" href="{{ t2i_google_fonts_mirror }}">
|
||||
<link rel="preconnect" href="{{ t2i_gstatic_mirror }}" crossorigin>
|
||||
<!-- 引入手写字体和正文阅读字体 -->
|
||||
<!-- Tải font viết tay và font nội dung -->
|
||||
<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"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
/* 卡通手账调色板 */
|
||||
/* Bảng màu sổ tay hoạt hình */
|
||||
--bg-paper: #fdfbf7;
|
||||
--ink-primary: #5d4037;
|
||||
/* 像铅笔/墨水的深褐色 */
|
||||
/* Nâu đậm như bút chì hoặc mực */
|
||||
--ink-secondary: #8d6e63;
|
||||
|
||||
--color-yellow: #fff9c4;
|
||||
@@ -28,22 +28,22 @@
|
||||
|
||||
--accent-orange: #ff7043;
|
||||
|
||||
/* MBTI 专属高对比色 */
|
||||
/* Màu tương phản cao dành cho MBTI */
|
||||
--mbti-bg: #ede7f6;
|
||||
--mbti-border: #9575cd;
|
||||
--mbti-text: #512da8;
|
||||
|
||||
/* 称号专属色 */
|
||||
/* Màu riêng cho danh hiệu */
|
||||
--title-bg: #fffde7;
|
||||
--title-border: #ffb74d;
|
||||
--title-text: #bf360c;
|
||||
|
||||
--font-title: 'ZCOOL KuaiLe', cursive;
|
||||
/* 标题字体 */
|
||||
/* Font tiêu đề */
|
||||
--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 nội dung */
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -164,7 +164,7 @@
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-primary);
|
||||
background-color: var(--bg-paper);
|
||||
/* 点阵纸背景纹理 */
|
||||
/* Hoạ tiết nền giấy chấm bi */
|
||||
background-image: radial-gradient(#ddd 2px, transparent 2px);
|
||||
background-size: 20px 20px;
|
||||
min-height: 100vh;
|
||||
@@ -172,7 +172,7 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* SVG 图标通用样式 */
|
||||
/* Kiểu chung cho biểu tượng SVG */
|
||||
.doodle {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
@@ -210,7 +210,7 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ==================== 1. 头部设计 ==================== */
|
||||
/* ==================== 1. Thiết kế header ==================== */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
@@ -264,8 +264,8 @@
|
||||
mask-repeat: repeat-x;
|
||||
}
|
||||
|
||||
/* ==================== 2. 统计邮票 ==================== */
|
||||
/* ==================== 2. 统计区域 (新版) ==================== */
|
||||
/* ==================== 2. Tem thống kê ==================== */
|
||||
/* ==================== 2. Vùng thống kê mới ==================== */
|
||||
.stats-wrapper {
|
||||
display: flex;
|
||||
gap: 25px;
|
||||
@@ -296,7 +296,7 @@
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 装饰性背景纹理 */
|
||||
/* Hoạ tiết nền trang trí */
|
||||
.stamp::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -332,7 +332,7 @@
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
/* ==================== 布局容器 ==================== */
|
||||
/* ==================== Container bố cục ==================== */
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
@@ -353,7 +353,7 @@
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
/* ==================== 3. 图表区 ==================== */
|
||||
/* ==================== 3. Vùng biểu đồ ==================== */
|
||||
.chart-section {
|
||||
grid-column: span 7;
|
||||
background: #fff;
|
||||
@@ -374,7 +374,7 @@
|
||||
border-left: 2px solid #999;
|
||||
}
|
||||
|
||||
/* 手绘条形图模拟 */
|
||||
/* Mô phỏng biểu đồ cột vẽ tay */
|
||||
.hand-chart-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -450,8 +450,8 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 4. 活跃时段 ==================== */
|
||||
/* ==================== 4. 活跃时段 (新版) ==================== */
|
||||
/* ==================== 4. Khung giờ hoạt động ==================== */
|
||||
/* ==================== 4. Khung giờ hoạt động mới ==================== */
|
||||
.highlight-section {
|
||||
flex: 1;
|
||||
background: var(--color-yellow);
|
||||
@@ -491,7 +491,7 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* ==================== 5. 话题列表 ==================== */
|
||||
/* ==================== 5. Danh sách chủ đề ==================== */
|
||||
.topic-section {
|
||||
grid-column: span 12;
|
||||
background: #fff;
|
||||
@@ -522,7 +522,7 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -579,7 +579,7 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ==================== 6. 群友画像 ==================== */
|
||||
/* ==================== 6. Hồ sơ thành viên ==================== */
|
||||
.user-section {
|
||||
grid-column: span 12;
|
||||
margin-top: 20px;
|
||||
@@ -755,7 +755,7 @@
|
||||
/* Removed big quote mark to clean up */
|
||||
}
|
||||
|
||||
/* ==================== 7. 每日回响 ==================== */
|
||||
/* ==================== 7. Dư âm hằng ngày ==================== */
|
||||
.quotes-section {
|
||||
grid-column: span 12;
|
||||
display: flex;
|
||||
@@ -1023,7 +1023,7 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- 1. 头部 -->
|
||||
<!-- 1. Header -->
|
||||
<div class="header">
|
||||
<div class="title-sticker">
|
||||
<div class="tape"></div>
|
||||
@@ -1032,7 +1032,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 统计邮票和活跃时段 -->
|
||||
<!-- 2. Tem thống kê và khung giờ hoạt động -->
|
||||
<div class="stats-wrapper">
|
||||
<!-- Left: Stats Grid (2x2) -->
|
||||
<div class="stats-grid">
|
||||
@@ -1096,7 +1096,7 @@
|
||||
|
||||
<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="coil"></div>
|
||||
<div class="section-title">
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
{% for item in chart_data %}
|
||||
<div class="sf-chart-col"
|
||||
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"
|
||||
style="font-size: 10px; font-weight: bold; color: #b71c1c; margin-bottom: 4px; font-family: sans-serif;">
|
||||
{{ item.count if item.count > 0 else '' }}
|
||||
</div>
|
||||
|
||||
<!-- 图表柱体 -->
|
||||
<!-- Cột biểu đồ -->
|
||||
<div class="sf-chart-bar" style="
|
||||
width: 70%;
|
||||
height: {{ item.percentage }}%;
|
||||
@@ -21,7 +21,7 @@
|
||||
min-height: {{ '2px' if item.count > 0 else '0px' }};
|
||||
"></div>
|
||||
|
||||
<!-- 刻度文字 -->
|
||||
<!-- Nhãn thang đo -->
|
||||
<div class="sf-chart-tick" style="
|
||||
position: absolute;
|
||||
bottom: -25px;
|
||||
@@ -39,6 +39,6 @@
|
||||
{% else %}
|
||||
<div
|
||||
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>
|
||||
{% endif %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
|
||||
.sf-quality-item::before {
|
||||
content: '福';
|
||||
content: 'Phúc';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
right: -15px;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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_gstatic_mirror }}" crossorigin>
|
||||
<!-- 引入书法字体和节日字体 -->
|
||||
<!-- Tải font thư pháp và font lễ hội -->
|
||||
<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"
|
||||
rel="stylesheet">
|
||||
@@ -18,7 +18,7 @@
|
||||
--gold-dark: #f9a825;
|
||||
--wood-dark: #3e2723;
|
||||
--bg-paper: #fff9e6;
|
||||
/* 宣纸色 */
|
||||
/* Màu giấy tuyên */
|
||||
--text-main: #3e2723;
|
||||
--text-gold: #ffca28;
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 装饰角标 */
|
||||
/* Nhãn góc trang trí */
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
@@ -163,7 +163,7 @@
|
||||
}
|
||||
|
||||
.sf-hongbao::before {
|
||||
content: '福';
|
||||
content: 'Phúc';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
@@ -370,7 +370,7 @@
|
||||
}
|
||||
|
||||
.sf-user-card::before {
|
||||
content: '春';
|
||||
content: 'Xuân';
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
--gold-dark: #f9a825;
|
||||
--wood-dark: #3e2723;
|
||||
--bg-paper: #fff9e6;
|
||||
/* 宣纸色 */
|
||||
/* Màu giấy tuyên */
|
||||
--text-main: #3e2723;
|
||||
--text-gold: #ffca28;
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 装饰角标 */
|
||||
/* Nhãn góc trang trí */
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
.sf-hongbao::before {
|
||||
content: '福';
|
||||
content: 'Phúc';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
@@ -369,7 +369,7 @@
|
||||
}
|
||||
|
||||
.sf-user-card::before {
|
||||
content: '春';
|
||||
content: 'Xuân';
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<img src="{{ quote.avatar_data }}" alt="{{ quote.sender }}"
|
||||
style="width:100%; height:100%; object-fit:cover;">
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="sf-quote-bubble">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<img src="{{ title.avatar_data }}" alt="{{ title.name }}" class="sf-avatar">
|
||||
{% else %}
|
||||
<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 %}
|
||||
</div>
|
||||
<div class="sf-user-info">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 time as time_mod
|
||||
@@ -18,7 +15,7 @@ from ..reporting.dispatcher import ReportDispatcher
|
||||
|
||||
|
||||
class AutoScheduler:
|
||||
"""自动调度器,支持传统模式和增量模式"""
|
||||
"""Bộ lập lịch tự động hỗ trợ chế độ truyền thống và tăng dần."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -36,7 +33,7 @@ class AutoScheduler:
|
||||
self.html_render_func = html_render_func
|
||||
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.report_dispatcher = ReportDispatcher(
|
||||
config_manager, report_generator, self.message_sender
|
||||
@@ -44,82 +41,89 @@ class AutoScheduler:
|
||||
if html_render_func:
|
||||
self.report_dispatcher.set_html_render(html_render_func)
|
||||
|
||||
self.scheduler_job_ids = [] # 存储已注册的定时任务 ID
|
||||
self.last_executed_target = None # 记录上次执行的具体时间点,防止重复执行
|
||||
self.scheduler_job_ids = [] # Lưu ID của các tác vụ đã đăng ký.
|
||||
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)
|
||||
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):
|
||||
"""设置bot实例(保持向后兼容)"""
|
||||
"""Đặt bot instance để giữ tương thích ngược."""
|
||||
self.bot_manager.set_bot_instance(bot_instance)
|
||||
|
||||
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):
|
||||
self.bot_manager.set_bot_self_ids(bot_self_ids)
|
||||
elif bot_self_ids:
|
||||
self.bot_manager.set_bot_self_ids([bot_self_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)
|
||||
|
||||
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:
|
||||
# 首先检查已注册的bot实例
|
||||
# Kiểm tra các bot instance đã đăng ký trước.
|
||||
if (
|
||||
hasattr(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:
|
||||
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
|
||||
|
||||
# 如果有多个实例,尝试通过适配器检查群属于哪个平台
|
||||
logger.info(f"检测到多个适配器,正在验证群 {group_id} 属于哪个平台...")
|
||||
# Khi có nhiều instance, kiểm tra nhóm qua từng adapter.
|
||||
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():
|
||||
try:
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
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))
|
||||
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
|
||||
else:
|
||||
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:
|
||||
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
|
||||
|
||||
# 如果所有适配器都尝试失败,记录错误并返回 None
|
||||
# Không adapter nào xác định được nền tảng.
|
||||
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
|
||||
|
||||
# 没有任何bot实例,返回None
|
||||
logger.error("❌ 没有注册的bot实例")
|
||||
# Chưa có bot instance nào được đăng ký.
|
||||
logger.error("❌ Chưa đăng ký bot instance nào")
|
||||
return None
|
||||
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
|
||||
|
||||
async def _get_group_name_safe(
|
||||
self, group_id: str, platform_id: str | None = None
|
||||
) -> str:
|
||||
"""
|
||||
为 TraceID 生成解析可读的群名。
|
||||
使用内存缓存以避免重复的 API 调用。
|
||||
若名称不可用,则回退到 group_id。
|
||||
Lấy tên nhóm dễ đọc để tạo TraceID.
|
||||
|
||||
Dùng cache bộ nhớ để tránh gọi API lặp lại và fallback về group_id.
|
||||
"""
|
||||
if group_id in self._group_name_cache:
|
||||
return self._group_name_cache[group_id]
|
||||
@@ -139,40 +143,46 @@ class AutoScheduler:
|
||||
return group_id
|
||||
|
||||
# ================================================================
|
||||
# 任务注册与取消
|
||||
# Đăng ký và huỷ tác vụ
|
||||
# ================================================================
|
||||
|
||||
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)
|
||||
|
||||
# unschedule_jobs 会将 _terminating 设为 True (用于关闭场景),
|
||||
# 但 schedule_jobs 意味着插件仍在运行;因此需要重置此标志位
|
||||
# unschedule_jobs đặt _terminating=True cho luồng shutdown;
|
||||
# schedule_jobs nghĩa là plugin vẫn chạy nên cần đặt lại cờ.
|
||||
self._terminating = False
|
||||
|
||||
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
|
||||
|
||||
scheduler = context.cron_manager.scheduler
|
||||
|
||||
# 1. 注册核心报告生成任务(涵盖全量分析与增量总结报告)
|
||||
# 每个配置的时间点都会触发一次解析
|
||||
logger.info("注册定时分析报告任务...")
|
||||
# 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("Đang đăng ký tác vụ báo cáo phân tích định kỳ...")
|
||||
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():
|
||||
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)
|
||||
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):
|
||||
"""在配置的时间点注册报告生成任务。
|
||||
"""Đă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()
|
||||
if isinstance(time_config, str):
|
||||
@@ -194,23 +204,25 @@ class AutoScheduler:
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
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:
|
||||
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):
|
||||
"""
|
||||
在活跃时段注册增量分析定时任务。
|
||||
Đăng ký 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_end_hour = self.config_manager.get_incremental_active_end_hour()
|
||||
interval_minutes = self.config_manager.get_incremental_interval_minutes()
|
||||
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 = []
|
||||
current_minutes = active_start_hour * 60
|
||||
end_minutes = active_end_hour * 60
|
||||
@@ -221,7 +233,7 @@ class AutoScheduler:
|
||||
trigger_times.append((hour, minute))
|
||||
current_minutes += interval_minutes
|
||||
|
||||
# 注册增量分析任务
|
||||
# Đăng ký tác vụ phân tích tăng dần.
|
||||
for hour, minute in trigger_times:
|
||||
try:
|
||||
trigger = CronTrigger(hour=hour, minute=minute)
|
||||
@@ -236,15 +248,18 @@ class AutoScheduler:
|
||||
)
|
||||
self.scheduler_job_ids.append(job_id)
|
||||
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:
|
||||
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):
|
||||
"""取消定时任务"""
|
||||
"""Huỷ các tác vụ định kỳ."""
|
||||
self._terminating = True
|
||||
if (
|
||||
not context
|
||||
@@ -261,33 +276,33 @@ class AutoScheduler:
|
||||
try:
|
||||
if scheduler.get_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:
|
||||
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()
|
||||
|
||||
# ================================================================
|
||||
# 共享辅助方法:解析定时分析目标
|
||||
# Hàm dùng chung để phân giải mục tiêu phân tích định kỳ
|
||||
# ================================================================
|
||||
|
||||
async def _get_scheduled_targets(
|
||||
self, mode_filter: str | None = None
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""
|
||||
根据分层过滤逻辑判定所有应参与计划分析的目标群组及其分析策略。
|
||||
Xác định nhóm mục tiêu và chiến lược theo bộ lọc phân tầng.
|
||||
|
||||
判定过程:
|
||||
1. 准入层:群组必须在基础设置的允许名单内。
|
||||
2. 定时层:群组需通过定时分析名单的过滤。
|
||||
3. 模式层:如果群组在增量名单内,则使用增量模式,否则使用默认策略。
|
||||
Quy trình:
|
||||
1. Nhóm phải nằm trong danh sách được phép ở cấu hình cơ sở.
|
||||
2. Nhóm phải vượt qua bộ lọc danh sách phân tích định kỳ.
|
||||
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.
|
||||
|
||||
参数:
|
||||
mode_filter: 如果提供,则只返回匹配指定模式的目标 (traditional 或 incremental)。
|
||||
Args:
|
||||
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()
|
||||
|
||||
# 预加载所有配置名单和模式
|
||||
# Tải trước các danh sách và chế độ.
|
||||
sched_list = self.config_manager.get_scheduled_group_list()
|
||||
sched_list_mode = self.config_manager.get_scheduled_group_list_mode()
|
||||
|
||||
@@ -296,70 +311,70 @@ class AutoScheduler:
|
||||
|
||||
result = []
|
||||
|
||||
# 遍历所有平台上的群组
|
||||
# Duyệt nhóm trên mọi nền tảng.
|
||||
for platform_id, group_id_orig in all_groups:
|
||||
group_id = str(group_id_orig)
|
||||
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):
|
||||
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(
|
||||
umo, sched_list_mode, sched_list
|
||||
):
|
||||
continue
|
||||
|
||||
# 3. 模式层判定 (增量黑白名单)
|
||||
# 3. 模式层判定 (增量黑白名单)
|
||||
# 3. Lớp chế độ: danh sách đen/trắng tăng dần.
|
||||
if self.config_manager.is_group_in_filtered_list(
|
||||
umo, incr_list_mode, incr_list
|
||||
):
|
||||
# 如果在增量名单内,则执行增量模式
|
||||
# Trong danh sách tăng dần.
|
||||
effective_mode = "incremental"
|
||||
else:
|
||||
# 不在增量名单内,则执行普通模式
|
||||
# Không trong danh sách tăng dần.
|
||||
effective_mode = "traditional"
|
||||
|
||||
# 4. 模式过滤 (如果函数调用者要求过滤)
|
||||
# 4. Lọc chế độ nếu caller yêu cầu.
|
||||
if mode_filter and effective_mode != mode_filter:
|
||||
continue
|
||||
|
||||
result.append((group_id, platform_id, effective_mode))
|
||||
|
||||
logger.info(
|
||||
f"分层调度解析完成:符合条件的群组共 {len(result)} 个"
|
||||
+ (f" (模式过滤: {mode_filter})" if mode_filter else "")
|
||||
f"Phân giải lịch phân tầng hoàn tất: {len(result)} nhóm hợp lệ"
|
||||
+ (f" (bộ lọc chế độ: {mode_filter})" if mode_filter else "")
|
||||
)
|
||||
return result
|
||||
|
||||
# ================================================================
|
||||
# 统一报告调度入口
|
||||
# Điểm vào thống nhất cho lịch báo cáo
|
||||
# ================================================================
|
||||
|
||||
async def _run_scheduled_report(self):
|
||||
"""统一的定时分析入口。
|
||||
"""Điểm vào thống nhất cho phân tích định kỳ.
|
||||
|
||||
在配置的时间点触发,解析所有目标群并根据其分析模式分发任务:
|
||||
- traditional: 执行全量拉取分析并发送报告
|
||||
- incremental: 执行增量最终报告阶段(合并并汇报)
|
||||
Kích hoạt tại giờ cấu hình và phân phối theo chế độ:
|
||||
- traditional: lấy toàn bộ dữ liệu, phân tích và gửi báo cáo;
|
||||
- incremental: hợp nhất dữ liệu tăng dần và gửi báo cáo cuối.
|
||||
"""
|
||||
if self._terminating:
|
||||
return
|
||||
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()
|
||||
|
||||
if not all_targets:
|
||||
logger.info("没有配置的群聊需要定时分析")
|
||||
logger.info("Không có nhóm nào cần phân tích định kỳ")
|
||||
return
|
||||
|
||||
max_concurrent = self.config_manager.get_max_concurrent_tasks()
|
||||
sem = asyncio.Semaphore(max_concurrent)
|
||||
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):
|
||||
@@ -375,13 +390,13 @@ class AutoScheduler:
|
||||
|
||||
tasks = []
|
||||
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):
|
||||
if self._terminating:
|
||||
logger.info("检测到插件正在停止,取消后续任务创建")
|
||||
logger.info("Plugin đang dừng; huỷ tạo các tác vụ tiếp theo")
|
||||
break
|
||||
|
||||
# 为前几个任务添加微小的启动间隔,均匀分散 API 压力
|
||||
# Giãn thời điểm khởi động để phân tán tải API.
|
||||
if idx > 0 and stagger > 0:
|
||||
await asyncio.sleep(stagger)
|
||||
|
||||
@@ -393,7 +408,7 @@ class AutoScheduler:
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 统计结果
|
||||
# Thống kê kết quả.
|
||||
success_count = 0
|
||||
skip_count = 0
|
||||
error_count = 0
|
||||
@@ -403,7 +418,9 @@ class AutoScheduler:
|
||||
if isinstance(result, DuplicateGroupTaskError):
|
||||
skip_count += 1
|
||||
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
|
||||
elif isinstance(result, dict) and not result.get("success", True):
|
||||
skip_count += 1
|
||||
@@ -411,34 +428,35 @@ class AutoScheduler:
|
||||
success_count += 1
|
||||
|
||||
logger.info(
|
||||
f"定时报告完成 — 成功: {success_count}, 跳过: {skip_count}, "
|
||||
f"失败: {error_count}, 总计: {len(all_targets)}"
|
||||
f"Báo cáo định kỳ hoàn tất — thành công: {success_count}, "
|
||||
f"bỏ qua: {skip_count}, thất bại: {error_count}, "
|
||||
f"tổng: {len(all_targets)}"
|
||||
)
|
||||
|
||||
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(
|
||||
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:
|
||||
# 为每个群聊设置独立的超时时间,适当放宽到 30 分钟以支持大型批次
|
||||
# Mỗi nhóm có timeout 30 phút để hỗ trợ batch lớn.
|
||||
await asyncio.wait_for(
|
||||
self._perform_auto_analysis_for_group(group_id, target_platform_id),
|
||||
timeout=1800,
|
||||
)
|
||||
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:
|
||||
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(
|
||||
self, group_id: str, target_platform_id: str | None = None
|
||||
):
|
||||
"""为指定群执行自动分析(业务逻辑委派给 AnalysisApplicationService)"""
|
||||
"""Phân tích tự động một nhóm qua AnalysisApplicationService."""
|
||||
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)
|
||||
trace_id = TraceContext.generate(prefix="group", group_name=group_name)
|
||||
TraceContext.set(trace_id)
|
||||
@@ -447,30 +465,32 @@ class AutoScheduler:
|
||||
return
|
||||
|
||||
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():
|
||||
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
|
||||
|
||||
# 委派给应用层服务执行核心用例
|
||||
# AnalysisApplicationService 内部已处理群锁 (group_lock)
|
||||
# AnalysisApplicationService xử lý use case và khoá nhóm.
|
||||
result = await self.analysis_service.execute_daily_analysis(
|
||||
group_id=group_id, platform_id=target_platform_id, manual=False
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
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
|
||||
|
||||
# 获取分析结果及适配器
|
||||
# Lấy kết quả phân tích và adapter.
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
|
||||
# 调度导出并发送报告
|
||||
# Xuất và gửi báo cáo.
|
||||
await self.report_dispatcher.dispatch(
|
||||
group_id,
|
||||
analysis_result,
|
||||
@@ -479,33 +499,35 @@ class AutoScheduler:
|
||||
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:
|
||||
# group_lock 抛出的 DuplicateGroupTaskError 表示任务正在运行,优雅跳过
|
||||
logger.debug(f"群 {group_id} 任务因并发锁冲突而跳过(已在运行)")
|
||||
raise # 重新抛出,让上层知道任务并没真正执行而是跳过了
|
||||
# DuplicateGroupTaskError nghĩa là tác vụ đang chạy; bỏ qua an toàn.
|
||||
logger.debug(f"Bỏ qua nhóm {group_id} do xung đột khoá đồng thời")
|
||||
raise # Ném lại để caller biết tác vụ chưa thực sự chạy.
|
||||
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:
|
||||
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):
|
||||
"""为所有目标模式设定为 incremental 的群执行增量分析任务。"""
|
||||
"""Phân tích các nhóm có chế độ mục tiêu là incremental."""
|
||||
if self._terminating:
|
||||
return
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
target_list = incr_targets
|
||||
@@ -513,8 +535,8 @@ class AutoScheduler:
|
||||
max_concurrent = self.config_manager.get_max_concurrent_tasks()
|
||||
|
||||
logger.info(
|
||||
f"将为 {len(target_list)} 个群聊执行增量分析 "
|
||||
f"(并发限制: {max_concurrent}, 交错间隔: {stagger}秒)"
|
||||
f"Sẽ phân tích tăng dần {len(target_list)} nhóm "
|
||||
f"(giới hạn đồng thời: {max_concurrent}, giãn cách: {stagger} giây)"
|
||||
)
|
||||
|
||||
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 isinstance(result, dict) and result.get("success"):
|
||||
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(
|
||||
gid, pid
|
||||
@@ -545,7 +567,9 @@ class AutoScheduler:
|
||||
analysis_tasks = []
|
||||
for idx, (gid, pid, _mode) in enumerate(target_list):
|
||||
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
|
||||
task = asyncio.create_task(
|
||||
staggered_incremental(idx, gid, pid),
|
||||
@@ -564,7 +588,9 @@ class AutoScheduler:
|
||||
if isinstance(result, DuplicateGroupTaskError):
|
||||
skip_count += 1
|
||||
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
|
||||
elif isinstance(result, dict) and not result.get("success", True):
|
||||
skip_count += 1
|
||||
@@ -572,17 +598,18 @@ class AutoScheduler:
|
||||
success_count += 1
|
||||
|
||||
logger.info(
|
||||
f"增量分析完成 - 成功: {success_count}, 跳过: {skip_count}, "
|
||||
f"失败: {error_count}, 总计: {len(target_list)}"
|
||||
f"Phân tích tăng dần hoàn tất — thành công: {success_count}, "
|
||||
f"bỏ qua: {skip_count}, thất bại: {error_count}, "
|
||||
f"tổng: {len(target_list)}"
|
||||
)
|
||||
|
||||
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(
|
||||
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:
|
||||
result = await asyncio.wait_for(
|
||||
self._perform_incremental_analysis_for_group(
|
||||
@@ -592,18 +619,18 @@ class AutoScheduler:
|
||||
)
|
||||
return result
|
||||
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"}
|
||||
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)}
|
||||
|
||||
async def _perform_incremental_analysis_for_group(
|
||||
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:
|
||||
# 解析可读群名以生成语义化的 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)
|
||||
trace_id = TraceContext.generate(prefix="incr", group_name=group_name)
|
||||
TraceContext.set(trace_id)
|
||||
@@ -612,57 +639,60 @@ class AutoScheduler:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 执行增量分析 "
|
||||
f"(Platform: {target_platform_id or 'Auto'})"
|
||||
f"Bắt đầu phân tích tăng dần nhóm {group_id} "
|
||||
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():
|
||||
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"}
|
||||
|
||||
# 委派给应用层服务执行增量分析用例
|
||||
# AnalysisApplicationService 内部已处理群锁 (group_lock)
|
||||
# AnalysisApplicationService xử lý use case và khoá nhóm.
|
||||
result = await self.analysis_service.execute_incremental_analysis(
|
||||
group_id=group_id, platform_id=target_platform_id
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
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
|
||||
|
||||
# 增量分析只累积数据,不发送报告
|
||||
# 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", {})
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析完成: "
|
||||
f"消息数={result.get('messages_count', 0)}, "
|
||||
f"话题={batch_summary.get('topics_count', 0)}, "
|
||||
f"金句={batch_summary.get('quotes_count', 0)}"
|
||||
f"Phân tích tăng dần nhóm {group_id} hoàn tất: "
|
||||
f"tin nhắn={result.get('messages_count', 0)}, "
|
||||
f"chủ đề={batch_summary.get('topics_count', 0)}, "
|
||||
f"trích dẫn={batch_summary.get('quotes_count', 0)}"
|
||||
)
|
||||
return result
|
||||
|
||||
except DuplicateGroupTaskError:
|
||||
# group_lock 抛出的 DuplicateGroupTaskError 表示任务正在运行,优雅跳过
|
||||
logger.debug(f"群 {group_id} 增量分析因并发锁冲突而跳过(已在运行)")
|
||||
# Tác vụ đang chạy; bỏ qua an toàn.
|
||||
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"}
|
||||
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)}
|
||||
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(
|
||||
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 vì lý 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:
|
||||
result = await asyncio.wait_for(
|
||||
@@ -672,15 +702,15 @@ class AutoScheduler:
|
||||
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"):
|
||||
reason = result.get("reason", "")
|
||||
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():
|
||||
logger.warning(
|
||||
f"群 {group_id} 增量最终报告失败 (reason={reason}),"
|
||||
f"正在回退到传统全量分析..."
|
||||
f"Báo cáo tăng dần cuối nhóm {group_id} thất bại "
|
||||
f"(reason={reason}); đang fallback về phân tích đầy đủ..."
|
||||
)
|
||||
return await self._fallback_to_traditional(
|
||||
group_id, target_platform_id
|
||||
@@ -689,27 +719,31 @@ class AutoScheduler:
|
||||
return result
|
||||
|
||||
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():
|
||||
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 {"success": False, "reason": "timeout"}
|
||||
|
||||
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():
|
||||
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 {"success": False, "reason": str(e)}
|
||||
|
||||
async def _fallback_to_traditional(
|
||||
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:
|
||||
logger.info(
|
||||
f"⬆️ 群 {group_id} 回退到传统全量分析 "
|
||||
f"(Platform: {target_platform_id or 'Auto'})"
|
||||
f"⬆️ Nhóm {group_id} fallback về phân tích đầy đủ "
|
||||
f"(nền tảng: {target_platform_id or 'Tự động'})"
|
||||
)
|
||||
await self._perform_auto_analysis_for_group_with_timeout(
|
||||
group_id, target_platform_id
|
||||
@@ -717,7 +751,7 @@ class AutoScheduler:
|
||||
return {"success": True, "fallback": True}
|
||||
except Exception as fallback_err:
|
||||
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,
|
||||
)
|
||||
return {"success": False, "reason": f"fallback_failed: {fallback_err}"}
|
||||
@@ -725,9 +759,9 @@ class AutoScheduler:
|
||||
async def _perform_incremental_final_report_for_group(
|
||||
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:
|
||||
# 解析可读群名以生成语义化的 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)
|
||||
trace_id = TraceContext.generate(prefix="report", group_name=group_name)
|
||||
TraceContext.set(trace_id)
|
||||
@@ -736,27 +770,28 @@ class AutoScheduler:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 生成增量最终报告 "
|
||||
f"(Platform: {target_platform_id or 'Auto'})"
|
||||
f"Bắt đầu tạo báo cáo tăng dần cuối cho nhóm {group_id} "
|
||||
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():
|
||||
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"}
|
||||
|
||||
# 委派给应用层服务执行最终报告用例
|
||||
# AnalysisApplicationService 内部已处理群锁 (group_lock)
|
||||
# AnalysisApplicationService xử lý use case và khoá nhóm.
|
||||
result = await self.analysis_service.execute_incremental_final_report(
|
||||
group_id=group_id, platform_id=target_platform_id
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
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
|
||||
|
||||
# 获取分析结果及适配器,分发报告
|
||||
# Lấy kết quả, adapter và phân phối báo cáo.
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
|
||||
@@ -768,7 +803,7 @@ class AutoScheduler:
|
||||
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:
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
before_ts = time_mod.time() - (analysis_days * 2 * 24 * 3600)
|
||||
@@ -779,70 +814,77 @@ class AutoScheduler:
|
||||
)
|
||||
if cleaned > 0:
|
||||
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:
|
||||
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
|
||||
|
||||
except DuplicateGroupTaskError:
|
||||
# group_lock 抛出的 DuplicateGroupTaskError 表示任务正在运行,优雅跳过
|
||||
logger.debug(f"群 {group_id} 最终报告因并发锁冲突而跳过(已在运行)")
|
||||
# Tác vụ đang chạy; bỏ qua an toàn.
|
||||
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"}
|
||||
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)}
|
||||
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]]:
|
||||
"""
|
||||
获取所有bot实例所在的群列表(使用 PlatformAdapter)
|
||||
Lấy danh sách nhóm của mọi bot instance qua PlatformAdapter.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str]]: [(platform_id, group_id), ...]
|
||||
"""
|
||||
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"):
|
||||
try:
|
||||
await self.bot_manager.auto_discover_bot_instances()
|
||||
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())
|
||||
|
||||
if not bot_ids:
|
||||
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 []
|
||||
|
||||
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():
|
||||
# 检查该平台是否启用了此插件
|
||||
# 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(
|
||||
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
|
||||
|
||||
try:
|
||||
# 1. 优先从 BotManager 获取已创建的适配器
|
||||
# 1. Ưu tiên adapter đã được BotManager tạo.
|
||||
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
|
||||
if not adapter:
|
||||
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:
|
||||
try:
|
||||
groups = await adapter.get_group_list()
|
||||
@@ -866,7 +908,7 @@ class AutoScheduler:
|
||||
if str(group_id).strip()
|
||||
]
|
||||
|
||||
# 获取平台名称(仅用于日志)
|
||||
# Lấy tên nền tảng chỉ để ghi log.
|
||||
p_name = None
|
||||
if hasattr(adapter, "get_platform_name"):
|
||||
try:
|
||||
@@ -878,17 +920,24 @@ class AutoScheduler:
|
||||
all_groups.add((platform_id, str(group_id)))
|
||||
|
||||
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
|
||||
|
||||
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. 降级:无法通过适配器获取
|
||||
logger.debug(f"平台 {platform_id} 无法通过适配器获取群列表")
|
||||
# 4. Adapter không lấy được danh sách nhóm.
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""安全模板渲染工具(String Template 兼容)"""
|
||||
"""Công cụ render template an toàn, tương thích String Template."""
|
||||
|
||||
import re
|
||||
from string import Template
|
||||
|
||||
from ...utils.logger import logger
|
||||
|
||||
# 统一默认 placeholder
|
||||
# Placeholder mặc định dùng chung
|
||||
PLACEHOLDERS = {
|
||||
# 分析类核心变量
|
||||
# Biến cốt lõi của phân tích
|
||||
"messages_text": "${messages_text}",
|
||||
"reviews_text": "${reviews_text}",
|
||||
"max_topics": "${max_topics}",
|
||||
"users_text": "${users_text}",
|
||||
"max_golden_quotes": "${max_golden_quotes}",
|
||||
# 文件名渲染类变量
|
||||
# Biến dùng khi render tên tệp
|
||||
"group_id": "${group_id}",
|
||||
"date": "${date}",
|
||||
"ulid": "${ulid}",
|
||||
@@ -21,32 +21,30 @@ PLACEHOLDERS = {
|
||||
|
||||
|
||||
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.
|
||||
|
||||
只认为满足:
|
||||
1) 不包含 String Template `${var}` 或 `$var`
|
||||
2) 包含 str.format `{var}`(非 `{{...}}`)
|
||||
Chỉ coi là str.format khi không chứa placeholder String Template
|
||||
`${var}` hoặc `$var`, đồng thời có `{var}` nhưng không phải `{{...}}`.
|
||||
"""
|
||||
if not template:
|
||||
return False
|
||||
|
||||
# 1. 預先建立排除模式 (匹配 ${var} 或 $var)
|
||||
# 使用 set 去重並組合
|
||||
# 1. Tạo pattern loại trừ cho ${var} hoặc $var.
|
||||
dollar_patterns = [re.escape(v) for v in PLACEHOLDERS.values()] + [
|
||||
rf"\${re.escape(k)}" for k in PLACEHOLDERS.keys()
|
||||
]
|
||||
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):
|
||||
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():
|
||||
# (?<!\{) 前面不能有 {
|
||||
# \{{key}\} 匹配 {key}
|
||||
# (?!\}) 後面不能有 }
|
||||
# (?<!\$) 前面不能有 $
|
||||
# (?<!\{): phía trước không được là {
|
||||
# \{{key}\}: khớp {key}
|
||||
# (?!\}): phía sau không được là }
|
||||
# (?<!\$): phía trước không được là $
|
||||
pattern = rf"(?<![\{{\$])\{{{key}\}}(?!\}})"
|
||||
if re.search(pattern, template):
|
||||
return True
|
||||
@@ -54,9 +52,9 @@ def is_str_format_template(template: 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 và trạng thái đã nâng cấp.
|
||||
"""
|
||||
if template is None:
|
||||
return "", False
|
||||
@@ -64,31 +62,32 @@ def upgrade_str_format_template(template: str) -> tuple[str, bool]:
|
||||
if not is_str_format_template(template):
|
||||
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("$", "$$")
|
||||
|
||||
# 将 {var} 转为 ${var}
|
||||
# Chuyển {var} thành ${var}.
|
||||
safe_template = re.sub(
|
||||
r"(?<![\{\$])\{([_a-zA-Z][_a-zA-Z0-9]*)\}(?!\})",
|
||||
lambda m: f"${{{m.group(1)}}}",
|
||||
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("}}", "}")
|
||||
|
||||
return safe_template, True
|
||||
|
||||
|
||||
def render_template(template: str, strict: bool = False, **kwargs) -> str:
|
||||
"""渲染模板(String Template)。
|
||||
"""Render template bằng String Template.
|
||||
|
||||
Args:
|
||||
template: 模板字符串
|
||||
strict: 是否使用严格模式(变量缺失则抛出异常)
|
||||
**kwargs: 渲染变量
|
||||
template: Chuỗi template.
|
||||
strict: Có dùng strict mode hay không; thiếu biến sẽ phát sinh lỗi.
|
||||
**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:
|
||||
return ""
|
||||
@@ -100,7 +99,7 @@ def render_template(template: str, strict: bool = False, **kwargs) -> str:
|
||||
if strict:
|
||||
raise
|
||||
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,
|
||||
)
|
||||
return template
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
可视化模块
|
||||
"""
|
||||
"""Module trực quan hoá."""
|
||||
|
||||
from .activity_charts import ActivityVisualizer
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
群聊活跃度可视化模块
|
||||
参考 astrbot_plugin_github_analyzer 的实现方式
|
||||
"""
|
||||
"""Module trực quan hoá mức độ hoạt động của nhóm."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
@@ -11,7 +8,7 @@ from ...domain.repositories.visualization_repository import IActivityVisualizer
|
||||
|
||||
|
||||
class ActivityVisualizer(IActivityVisualizer):
|
||||
"""活跃度可视化器"""
|
||||
"""Trình trực quan hoá mức độ hoạt động."""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
@@ -19,32 +16,32 @@ class ActivityVisualizer(IActivityVisualizer):
|
||||
def generate_activity_visualization(
|
||||
self, messages: list[dict]
|
||||
) -> ActivityVisualization:
|
||||
"""生成活跃度可视化数据 - 专注于小时级别分析"""
|
||||
"""Tạo dữ liệu trực quan hoạt động theo giờ."""
|
||||
hourly_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:
|
||||
# 时间分析 - 只关注小时
|
||||
# Chỉ phân tích theo giờ.
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0))
|
||||
hour = msg_time.hour
|
||||
|
||||
# # 用户分析
|
||||
# # Phân tích thành viên.
|
||||
# sender = msg.get("sender", {})
|
||||
# user_id = str(sender.get("user_id", ""))
|
||||
# nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||
|
||||
# 统计每小时消息数
|
||||
# Đếm tin nhắn theo giờ.
|
||||
hourly_activity[hour] += 1
|
||||
|
||||
# # 统计用户活跃度
|
||||
# # Thống kê hoạt động thành viên.
|
||||
# user_activity[user_id] = {
|
||||
# "nickname": nickname,
|
||||
# "count": user_activity.get(user_id, {}).get("count", 0) + 1
|
||||
# }
|
||||
|
||||
# 统计每小时表情数
|
||||
# Đếm biểu cảm theo giờ.
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") in ["face", "mface", "bface", "sface"]:
|
||||
emoji_activity[hour] += 1
|
||||
@@ -54,7 +51,7 @@ class ActivityVisualizer(IActivityVisualizer):
|
||||
if "动画表情" in summary or "表情" in summary:
|
||||
emoji_activity[hour] += 1
|
||||
|
||||
# 生成用户活跃度排行
|
||||
# Tạo bảng xếp hạng hoạt động thành viên.
|
||||
user_ranking = []
|
||||
for user_id, data in user_activity.items():
|
||||
user_ranking.append(
|
||||
@@ -66,7 +63,7 @@ class ActivityVisualizer(IActivityVisualizer):
|
||||
)
|
||||
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)[
|
||||
:3
|
||||
]
|
||||
@@ -74,8 +71,8 @@ class ActivityVisualizer(IActivityVisualizer):
|
||||
|
||||
return ActivityVisualization(
|
||||
hourly_activity=dict(hourly_activity),
|
||||
daily_activity={}, # 不使用日期分析
|
||||
user_activity_ranking=user_ranking[:10], # 前10名
|
||||
daily_activity={}, # Không phân tích theo ngày.
|
||||
user_activity_ranking=user_ranking[:10], # Top 10.
|
||||
peak_hours=peak_hours,
|
||||
activity_heatmap_data=self._generate_hourly_heatmap_data(
|
||||
hourly_activity, emoji_activity
|
||||
@@ -85,8 +82,8 @@ class ActivityVisualizer(IActivityVisualizer):
|
||||
def _generate_hourly_heatmap_data(
|
||||
self, hourly_activity: dict, emoji_activity: 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_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:
|
||||
"""计算活跃度等级"""
|
||||
"""Tính cấp độ hoạt động."""
|
||||
if not hourly_activity:
|
||||
return {}
|
||||
|
||||
@@ -127,7 +124,7 @@ class ActivityVisualizer(IActivityVisualizer):
|
||||
return levels
|
||||
|
||||
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 = []
|
||||
max_activity = max(hourly_activity.values()) if hourly_activity else 1
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ class TraceContext:
|
||||
str: Chuỗi Caption của báo cáo.
|
||||
"""
|
||||
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
|
||||
def set(cls, trace_id: str) -> None:
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
"""
|
||||
工具函数模块
|
||||
包含通用工具函数
|
||||
"""
|
||||
"""Module chứa các hàm tiện ích dùng chung."""
|
||||
|
||||
__all__ = []
|
||||
|
||||
+6
-6
@@ -5,14 +5,14 @@ from ..shared.trace_context import TraceContext
|
||||
|
||||
class PluginLogger:
|
||||
"""
|
||||
日志代理类:插件级统一日志装饰器
|
||||
Proxy logger thống nhất ở cấp plugin.
|
||||
|
||||
自动向所有通过该实例输出的日志信息前缀添加 `[群分析插件]` 标签,
|
||||
以便用户在 AstrBot 混合日志流中快速定位属于本插件的输出。
|
||||
不直接继承 logging.LoggerAdapter 以符合框架规范。
|
||||
Tự động thêm tiền tố ``[Phân tích nhóm]`` để dễ nhận diện log của plugin
|
||||
trong luồng log hỗn hợp của AstrBot. Không kế thừa trực tiếp
|
||||
``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
|
||||
|
||||
def _format_msg(self, msg: str) -> str:
|
||||
@@ -40,5 +40,5 @@ class PluginLogger:
|
||||
astrbot_logger.exception(self._format_msg(msg), *args, **kwargs)
|
||||
|
||||
|
||||
# 导出带前缀的 logger
|
||||
# Export logger có tiền tố.
|
||||
logger = PluginLogger()
|
||||
|
||||
+33
-35
@@ -6,15 +6,15 @@ from .logger import logger
|
||||
|
||||
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 và tránh lỗi dây chuyền cho đến khi hồi phục.
|
||||
|
||||
States:
|
||||
CLOSED: 正常工作状态,允许请求
|
||||
OPEN: 熔断状态,拒绝请求
|
||||
HALF_OPEN: 尝试恢复状态,允许少量测试请求
|
||||
CLOSED: Hoạt động bình thường, cho phép yêu cầu.
|
||||
OPEN: Mạch mở, từ chối yêu cầu.
|
||||
HALF_OPEN: Thử hồi phục, cho phép một số yêu cầu kiểm tra.
|
||||
"""
|
||||
|
||||
STATE_CLOSED = "CLOSED"
|
||||
@@ -28,12 +28,12 @@ class CircuitBreaker:
|
||||
name: str = "default",
|
||||
):
|
||||
"""
|
||||
初始化熔断器。
|
||||
Khởi tạo circuit breaker.
|
||||
|
||||
Args:
|
||||
failure_threshold (int): 连续失败触发熔断的次数上限
|
||||
recovery_timeout (int): 熔断开启后尝试恢复之前的冷却时间(秒)
|
||||
name (str): 熔断器标识符(用于日志区分)
|
||||
failure_threshold: Số lỗi liên tiếp để mở mạch.
|
||||
recovery_timeout: Thời gian chờ trước khi thử hồi phục, tính bằng giây.
|
||||
name: Định danh circuit breaker dùng trong log.
|
||||
"""
|
||||
self.name = name
|
||||
self.failure_threshold = failure_threshold
|
||||
@@ -44,7 +44,7 @@ class CircuitBreaker:
|
||||
self.last_failure_time = 0.0
|
||||
|
||||
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
|
||||
if (
|
||||
self.state == self.STATE_CLOSED
|
||||
@@ -52,26 +52,26 @@ class CircuitBreaker:
|
||||
):
|
||||
self._open_circuit()
|
||||
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()
|
||||
|
||||
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:
|
||||
self._close_circuit()
|
||||
elif self.state == self.STATE_CLOSED:
|
||||
# 正常状态下的成功重置累积计数值
|
||||
# Thành công khi mạch đóng sẽ reset bộ đếm lỗi.
|
||||
self.failure_count = 0
|
||||
|
||||
def allow_request(self) -> bool:
|
||||
"""
|
||||
判断是否允许本次服务请求。
|
||||
Kiểm tra có cho phép yêu cầu dịch vụ này hay không.
|
||||
|
||||
Returns:
|
||||
bool: True 为允许,False 为拦截
|
||||
True nếu cho phép, False nếu chặn.
|
||||
"""
|
||||
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:
|
||||
self._half_open_circuit()
|
||||
return True
|
||||
@@ -79,33 +79,31 @@ class CircuitBreaker:
|
||||
return True
|
||||
|
||||
def _open_circuit(self) -> None:
|
||||
"""动作:开启熔断"""
|
||||
"""Mở circuit breaker."""
|
||||
self.state = self.STATE_OPEN
|
||||
self.last_failure_time = time.monotonic()
|
||||
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:
|
||||
"""动作:关闭熔断,恢复常态"""
|
||||
"""Đóng circuit breaker và trở về trạng thái bình thường."""
|
||||
self.state = self.STATE_CLOSED
|
||||
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:
|
||||
"""动作:进入半开状态"""
|
||||
"""Chuyển circuit breaker sang trạng thái half-open."""
|
||||
self.state = self.STATE_HALF_OPEN
|
||||
logger.info(
|
||||
f"熔断器 CircuitBreaker[{self.name}] 进入半开 (HALF_OPEN) 测试模式。"
|
||||
)
|
||||
logger.info(f"CircuitBreaker[{self.name}] đã vào chế độ kiểm tra HALF_OPEN.")
|
||||
|
||||
|
||||
class GlobalRateLimiter:
|
||||
"""
|
||||
韧性设计:全局并发动态限流器
|
||||
Bộ giới hạn đồng thời động toàn cục.
|
||||
|
||||
基于单例模式管理 asyncio.Semaphore,确保在插件内的异步任务
|
||||
不会超过设定的最大并发限制(如保护 LLM 账单或避免 API 拥塞)。
|
||||
Quản lý ``asyncio.Semaphore`` theo singleton để tác vụ bất đồng bộ không
|
||||
vượt giới hạn, giúp kiểm soát chi phí LLM và tránh nghẽn API.
|
||||
"""
|
||||
|
||||
_instance: "GlobalRateLimiter | None" = None
|
||||
@@ -119,24 +117,24 @@ class GlobalRateLimiter:
|
||||
@classmethod
|
||||
def get_instance(cls, max_concurrency: int | None = None) -> "GlobalRateLimiter":
|
||||
"""
|
||||
获取或创建限流器单例。
|
||||
Lấy hoặc tạo singleton rate limiter.
|
||||
|
||||
Args:
|
||||
max_concurrency (int, optional): 允许的最大并发数。如果提供且与当前不同,则重置信号量。
|
||||
max_concurrency: Số tác vụ đồng thời tối đa; thay đổi sẽ reset semaphore.
|
||||
|
||||
Returns:
|
||||
GlobalRateLimiter: 唯一实例
|
||||
Instance GlobalRateLimiter duy nhất.
|
||||
"""
|
||||
instance = cls()
|
||||
if max_concurrency is not None:
|
||||
instance.reconfigure(max_concurrency)
|
||||
elif cls._semaphore is None:
|
||||
# 默认兜底
|
||||
# Giá trị fallback mặc định.
|
||||
cls._semaphore = asyncio.Semaphore(3)
|
||||
return instance
|
||||
|
||||
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 (
|
||||
hasattr(self._semaphore, "_value")
|
||||
and self._semaphore._value != max_concurrency # type: ignore
|
||||
@@ -147,13 +145,13 @@ class GlobalRateLimiter:
|
||||
else "None"
|
||||
)
|
||||
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)
|
||||
|
||||
@property
|
||||
def semaphore(self) -> asyncio.Semaphore:
|
||||
"""返回核心的异步信号量对象。"""
|
||||
"""Trả về semaphore bất đồng bộ cốt lõi."""
|
||||
if self._semaphore is None:
|
||||
self.__class__._semaphore = asyncio.Semaphore(3)
|
||||
assert self._semaphore is not None
|
||||
|
||||
@@ -505,7 +505,7 @@ def test_qq_official_avatar_mentions_hide_placeholder_openid():
|
||||
|
||||
rendered_text = str(rendered)
|
||||
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():
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_new_qq_official_message_replaces_mentions_before_storage():
|
||||
|
||||
stored_parts = history_manager.calls[0]["content"]["message"]
|
||||
assert stored_parts == [
|
||||
{"type": "plain", "text": "请问 @随风潜入夜 和 @群友 怎么看"}
|
||||
{"type": "plain", "text": "请问 @随风潜入夜 和 @Thành viên 怎么看"}
|
||||
]
|
||||
assert "KNOWN_OPENID" not in stored_parts[0]["text"]
|
||||
assert "UNKNOWN_OPENID" not in stored_parts[0]["text"]
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_local_history_sender_name_uses_group_scoped_alias_for_placeholder():
|
||||
assert empty_name_message is not None
|
||||
assert openid_name_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 openid_name_message.sender_name == empty_name_message.sender_name
|
||||
assert other_group_message.sender_name != empty_name_message.sender_name
|
||||
|
||||
Reference in New Issue
Block a user