From 059d6ea68d8a2cba1b444f7b0fd9bd259eb97585 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Wed, 11 Mar 2026 23:18:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E9=9D=A2=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BB=E6=8F=92=E4=BB=B6=E6=9E=B6=E6=9E=84=EF=BC=8C=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E5=B9=B6=E5=8F=91=E7=AB=9E=E6=80=81=E3=80=81=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=B3=84=E9=9C=B2=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=81=A5=E5=A3=AE=E6=80=A7=20=E3=80=90=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=8E=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96=E3=80=91=20-=20=E5=BC=95=E5=85=A5=20asyncio.?= =?UTF-8?q?Lock=20(=5Finit=5Flock)=20=E4=BF=9D=E6=8A=A4=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=B5=81=E7=A8=8B=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=A4=9A?= =?UTF-8?q?=E5=85=A5=E5=8F=A3(Reload/Init/Loaded)=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=97=B6=E7=BB=B4=E6=8C=81=E5=8D=95=E4=BE=8B=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E9=87=8D=E5=A4=8D=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E3=80=82=20-=20=E9=87=8D=E6=9E=84=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=AF=B9=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=8D=8F=E7=A8=8B=20Loop=20=E7=9A=84=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=EF=BC=8C=E9=A2=84=E9=98=B2=20Python=203.10+?= =?UTF-8?q?=20=E7=8E=AF=E5=A2=83=E4=B8=8B=E5=B8=B8=E8=A7=81=E7=9A=84?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=BC=82=E5=B8=B8=E3=80=82=20-=20?= =?UTF-8?q?=E5=BC=95=E5=85=A5=20=5Fbackground=5Ftasks=20=E9=9B=86=E5=90=88?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=BF=BD=E8=B8=AA=E5=BC=82=E6=AD=A5=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E9=80=8F=E6=98=8E=E7=9A=84=E4=BB=BB=E5=8A=A1=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=8E=E8=87=AA=E5=8A=A8=E7=A7=BB=E9=99=A4=E3=80=82?= =?UTF-8?q?=20=E3=80=90=E8=B5=84=E6=BA=90=E6=B8=85=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E3=80=91=20-=20=E5=A2=9E=E5=BC=BA=20terminat?= =?UTF-8?q?e()=20=E6=9E=90=E6=9E=84=E9=80=BB=E8=BE=91=EF=BC=9A=E5=9C=A8?= =?UTF-8?q?=E5=8D=B8=E8=BD=BD=E6=97=B6=E4=B8=BB=E5=8A=A8=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E6=89=80=E6=9C=89=E8=BF=BD=E8=B8=AA=E7=9A=84=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=EF=BC=88=E5=8C=85=E6=8B=AC=E6=AD=A3=E5=9C=A8?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E7=9A=84=E5=88=86=E6=9E=90=E3=80=81=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=88=96=E4=BE=9D=E8=B5=96=E5=AE=89=E8=A3=85=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BD=BF=E7=94=A8=20asyncio.gather=20?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E5=AE=89=E5=85=A8=E9=87=8A=E6=94=BE=E3=80=82?= =?UTF-8?q?=20-=20=E9=87=87=E7=94=A8=20type:=20ignore=20=E9=85=8D=E5=90=88?= =?UTF-8?q?=E9=9D=9E=20Optional=20=E7=B1=BB=E5=9E=8B=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=EF=BC=8C=E5=9C=A8=E4=BF=9D=E8=AF=81=20terminate()=20=E8=83=BD?= =?UTF-8?q?=E5=A4=9F=E7=BD=AE=E7=A9=BA=E5=BC=95=E7=94=A8=E7=9A=84=E5=89=8D?= =?UTF-8?q?=E6=8F=90=E4=B8=8B=EF=BC=8C=E6=B6=88=E9=99=A4=E4=BA=86=20Pylanc?= =?UTF-8?q?e=20=E5=AF=B9=E5=85=A8=E5=B1=80=2050+=20=E5=A4=84=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E8=AE=BF=E9=97=AE=E7=9A=84=E2=80=9C=E5=8F=AF=E8=83=BD?= =?UTF-8?q?=E4=B8=BA=20None=E2=80=9D=E8=AF=AF=E6=8A=A5=E3=80=82=20?= =?UTF-8?q?=E3=80=90=E4=B8=9A=E5=8A=A1=E6=B5=81=E7=A8=8B=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E6=80=A7=E3=80=91=20-=20=E6=8F=90=E5=8F=96=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=9A=E5=B0=86=E5=88=86=E6=9E=90=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E7=9A=84=E6=B8=B2=E6=9F=93=E4=B8=8E=E5=A4=9A=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=88=86=E5=8F=91=E9=80=BB=E8=BE=91=E6=8A=BD=E7=A6=BB?= =?UTF-8?q?=E8=87=B3=20=5Fsend=5Fanalysis=5Freport=20=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=99=A8=EF=BC=8C=E6=98=BE=E8=91=97=E9=99=8D?= =?UTF-8?q?=E4=BD=8E=E5=8D=95=E4=B8=80=E5=87=BD=E6=95=B0=E5=9C=88=E5=A4=8D?= =?UTF-8?q?=E6=9D=82=E5=BA=A6=E3=80=82=20-=20=E4=BC=98=E5=8C=96=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E6=9C=8D=E5=8A=A1=EF=BC=9A=20=20=20?= =?UTF-8?q?*=20=E5=BC=95=E5=85=A5=20MAX=5FPAYLOAD=5FSIZE=20(20MB)=20?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=EF=BC=8C=E6=8B=A6=E6=88=AA=E8=B6=85=E5=A4=A7?= =?UTF-8?q?=20Base64/=E6=95=B0=E6=8D=AE=EF=BC=8C=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E5=86=85=E5=AD=98=E9=A3=8E=E9=99=A9=E3=80=82=20=20=20*=20?= =?UTF-8?q?=E5=BC=83=E7=94=A8=E5=9B=BA=E5=AE=9A=E8=B7=AF=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20tempfile.mkstemp=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=AE=89=E5=85=A8=E7=9A=84=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E3=80=82=20=20=20*=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=20finally=20=E5=9D=97=E6=B8=85=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D=E7=89=A9=E7=90=86?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=9C=A8=E4=B8=8A=E4=BC=A0=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E6=88=96=E5=A4=B1=E8=B4=A5=E5=90=8E=E9=83=BD=E8=83=BD=E8=A2=AB?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E5=88=A0=E9=99=A4=E3=80=82=20=E3=80=90?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=E7=B3=BB=E7=BB=9F=E9=87=8D=E6=9E=84=E3=80=91?= =?UTF-8?q?=20-=20=E9=87=87=E7=94=A8=E5=AD=90=E5=91=BD=E4=BB=A4=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=BC=98=E5=8C=96=20/=E5=88=86=E6=9E=90=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=20(analysis=5Fsettings)=EF=BC=8C=E5=B0=86=E5=90=AF?= =?UTF-8?q?=E7=94=A8/=E7=A6=81=E7=94=A8=E9=80=BB=E8=BE=91=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=E8=87=B3=E7=A7=81=E6=9C=89=E5=A4=84=E7=90=86=E6=96=B9?= =?UTF-8?q?=E6=B3=95=EF=BC=8C=E6=8F=90=E5=8D=87=E4=BB=A3=E7=A0=81=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E6=80=A7=E3=80=82=20-=20=E4=BF=AE=E5=A4=8D=20/?= =?UTF-8?q?=E7=BE=A4=E5=88=86=E6=9E=90=20=E5=BC=82=E6=AD=A5=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3=E6=BC=8F=E6=B4=9E=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=88=86?= =?UTF-8?q?=E6=9E=90=E7=BB=93=E6=9E=9C=E6=B5=81=E8=83=BD=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E9=80=9A=E8=BF=87=20AsyncGenerator=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=9B=9E=E4=BC=A0=E3=80=82=20-=20=E7=A7=BB=E9=99=A4=E6=89=80?= =?UTF-8?q?=E6=9C=89=E4=B8=9A=E5=8A=A1=E6=A0=A1=E9=AA=8C=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=20assert=20=E6=96=AD=E8=A8=80=EF=BC=8C=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=E6=9B=B4=E5=AE=89=E5=85=A8=E7=9A=84=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=88=86=E6=94=AF=E4=B8=8E=E9=94=99=E8=AF=AF=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 501 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 256 insertions(+), 245 deletions(-) diff --git a/main.py b/main.py index 9c7d769..d409734 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,8 @@ QQ群日常分析插件 import asyncio import os +from collections.abc import AsyncGenerator +from pathlib import Path from astrbot.api import AstrBotConfig from astrbot.api import logger as astrbot_logger @@ -51,7 +53,7 @@ from .src.utils.trace_context import TraceContext, TraceLogFilter class GroupDailyAnalysis(Star): """群分析插件主类""" - # ── 显式类型声明(消除 Pylance Optional 推断) ── + # ── 显式类型声明 (由 __init__ 初始化) ── config: AstrBotConfig config_manager: ConfigManager bot_manager: BotManager @@ -141,10 +143,21 @@ class GroupDailyAnalysis(Star): self._initialized = False self._discovery_run = False # 是否已尝试过运行发现逻辑 + self._init_lock = asyncio.Lock() + self._background_tasks: set[asyncio.Task] = set() + # 异步注册任务,处理插件重载情况 - self._init_task = asyncio.create_task( - self._run_initialization("Plugin Reload/Init") - ) + try: + loop = asyncio.get_running_loop() + self._init_task = loop.create_task( + self._run_initialization("Plugin Reload/Init") + ) + self._background_tasks.add(self._init_task) + self._init_task.add_done_callback(self._background_tasks.discard) + except RuntimeError: + # 如果当前没有 running loop (例如在非异步初始化的环境中), + # 则依赖 on_platform_loaded 钩子执行初始化 + self._init_task = None # orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。 # 如果需要高性能缓存,后续可由 AnalysisApplicationService 内部维护。 @@ -156,100 +169,95 @@ class GroupDailyAnalysis(Star): async def _run_initialization(self, source: str): """统一初始化逻辑""" - # 如果已经成功发现过平台,且不是来自 Platform Loaded 的强制触发,则跳过 - if ( - self._initialized - and self.bot_manager - and self.bot_manager.get_platform_count() > 0 - and source != "Platform Loaded" - ): - return + async with self._init_lock: + # 如果已经成功发现过平台,且不是来自 Platform Loaded 的强制触发,则跳过 + if ( + self._initialized + and self.bot_manager + and self.bot_manager.get_platform_count() > 0 + and source != "Platform Loaded" + ): + return - # 稍微延迟,确保 context 和环境稳定 - # 针对极少数环境,2秒可能不足以让平台管理器就绪,增加到 5秒 - await asyncio.sleep(5) + # 稍微延迟,确保 context 和环境稳定 + # 针对极少数环境,2秒可能不足以让平台管理器就绪,增加到 5秒 + await asyncio.sleep(5) - # [加固] 如果在等待期间插件已被卸载(terminate),则直接退出 - if not self.bot_manager: - return + # [加固] 如果在等待期间插件已被卸载(terminate),则直接退出 + if not self.bot_manager: + return - try: - # 注册 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 日志追踪已启用") - - logger.info(f"正在执行插件初始化 (来源: {source})...") - # 检查插件是否被启用 (Fix for empty plugin_set issue) - if self.context: - config = self.context.get_config() - # ... 为空修正逻辑保持不变 ... - plugin_set = config.get("plugin_set", []) - if ( - isinstance(plugin_set, list) - and "astrbot_plugin_qq_group_daily_analysis" not in plugin_set + try: + # 注册 TraceID 过滤器 + trace_filter = TraceLogFilter() + if not any( + isinstance(f, TraceLogFilter) for f in astrbot_logger.filters ): - # 此时不强制修改 config,但可以记录日志 - pass + astrbot_logger.addFilter(trace_filter) + astrbot_logger.info("[Trace] TraceID 日志追踪已启用") - # 1. 尝试发现 bot 实例(即使暂时没有,后续任务触发时也会再扫一遍) - await self.bot_manager.initialize_from_config() + logger.info(f"正在执行插件初始化 (来源: {source})...") - # 2. 注册预览路由器 (WebUI 路由注册不依赖在线机器人) - if self.template_preview_router: - await self.template_preview_router.ensure_handlers_registered( - self.context - ) + # 1. 尝试发现 bot 实例 + await self.bot_manager.initialize_from_config() - # 3. 强制注册定时分析任务 (确保 APScheduler 即使在空载时也有任务占位) - if self.auto_scheduler: - self.auto_scheduler.schedule_jobs(self.context) + # 2. 注册预览路由器 + if self.template_preview_router: + await self.template_preview_router.ensure_handlers_registered( + self.context + ) - # 4. 始终启动重试管理器 - if self.retry_manager: - await self.retry_manager.start() + # 3. 强制注册定时分析任务 + if self.auto_scheduler: + self.auto_scheduler.schedule_jobs(self.context) - self._initialized = True - self._discovery_run = True - logger.info(f"插件任务注册完成 (来源: {source})") + # 4. 始终启动重试管理器 + if self.retry_manager: + await self.retry_manager.start() - except Exception as e: - logger.error(f"插件初始化失败: {e}", exc_info=True) + self._initialized = True + self._discovery_run = True + logger.info(f"插件任务注册完成 (来源: {source})") + + except Exception as e: + logger.error(f"插件初始化失败: {e}", exc_info=True) async def terminate(self): """插件被卸载/停用时调用,清理资源""" try: - # 取消正在进行的初始化任务 - if ( - hasattr(self, "_init_task") - and self._init_task - and not self._init_task.done() - ): - self._init_task.cancel() - logger.info("开始清理QQ群日常分析插件资源...") - # 停止自动调度器 + # 1. 停止所有后台任务 + if self._background_tasks: + logger.info(f"正在取消 {len(self._background_tasks)} 个后台任务...") + for task in self._background_tasks: + if not task.done(): + task.cancel() + + # 等待任务结束 + await asyncio.gather(*self._background_tasks, return_exceptions=True) + self._background_tasks.clear() + + # 2. 停止各个组件 if self.auto_scheduler: logger.info("正在停止自动调度器...") - self.auto_scheduler.unschedule_jobs(self.context) - logger.info("自动调度器已停止") + self.auto_scheduler.schedule_jobs(None) # type: ignore if self.retry_manager: await self.retry_manager.stop() + if self.template_preview_router: await self.template_preview_router.unregister_handlers() - # 释放实例属性引用(插件卸载后不再使用) - self.auto_scheduler = None - self.bot_manager = None - self.report_generator = None - self.config_manager = None - self.message_processing_service = None - self.telegram_group_registry = None - self.template_preview_router = None - self.telegram_template_preview_handler = None + # 3. 释放实例属性引用 (使用 type: ignore 允许 None 赋值) + self.auto_scheduler = None # type: ignore + self.bot_manager = None # type: ignore + self.report_generator = None # type: ignore + self.config_manager = None # type: ignore + self.message_processing_service = None # type: ignore + self.telegram_group_registry = None # type: ignore + self.template_preview_router = None # type: ignore + self.telegram_template_preview_handler = None # type: ignore logger.info("QQ群日常分析插件资源清理完成") @@ -354,27 +362,36 @@ class GroupDailyAnalysis(Star): # 2. 将内容准备为文件或数据 image_file = None created_temp = False + MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # 20MB 限制 + try: + data = None if image_url.startswith("base64://"): - data = base64.b64decode(image_url[len("base64://") :]) + base64_str = image_url[len("base64://") :] + if len(base64_str) * 3 / 4 > MAX_PAYLOAD_SIZE: + logger.warning("图片上传失败:Base64 负载过大") + return + data = base64.b64decode(base64_str) elif image_url.startswith("data:"): parts = image_url.split(",", 1) - data = base64.b64decode(parts[1]) if len(parts) == 2 else None + if len(parts) == 2: + if len(parts[1]) * 3 / 4 > MAX_PAYLOAD_SIZE: + logger.warning("图片上传失败:Data URI 负载过大") + return + data = base64.b64decode(parts[1]) elif os.path.isfile(image_url): - if os.path.isabs(image_url): - image_file = image_url - else: - image_file = os.path.abspath(image_url) - data = None - else: - return + image_file = os.path.abspath(image_url) if data and not image_file: - # 使用优化的文件名创建临时文件 - image_file = os.path.join(tempfile.gettempdir(), nice_filename) - with open(image_file, "wb") as f: - f.write(data) - created_temp = True + # 使用 tempfile 生成唯一后缀,防止并发冲突 + fd, image_file = tempfile.mkstemp(suffix=ext, prefix="group_report_") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + created_temp = True + except Exception: + os.close(fd) + raise if not image_file: return @@ -475,85 +492,8 @@ class GroupDailyAnalysis(Star): f"📊 已获取{result['messages_count']}条消息,正在生成渲染报告..." ) - analysis_result = result["analysis_result"] - adapter = result["adapter"] - output_format = self.config_manager.get_output_format() - - # 定义头像获取回调 (Infrastructure delegate) - async def avatar_getter(user_id: str) -> str | None: - return await adapter.get_user_avatar_url(user_id) - - # 定义昵称获取回调 - async def nickname_getter(user_id: str) -> str | None: - try: - member = await adapter.get_member_info(group_id, user_id) - if member: - return member.card or member.nickname - except Exception: - pass - return None - - if output_format == "image": - ( - image_url, - html_content, - ) = await self.report_generator.generate_image_report( - analysis_result, - group_id, - self.html_render, - avatar_getter=avatar_getter, - nickname_getter=nickname_getter, - ) - - if image_url: - caption = f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]" - # 优先使用适配器的 send_image (由插件适配器统一处理 Base64 转换和路径问题) - # 不再使用 yield event.image_result 回退,防止适配器超时回复导致重复发送图片 - await adapter.send_image(group_id, image_url, caption=caption) - - # 上传到群文件/群相册 (属于附加功能,不影响消息发送) - await self._try_upload_image(group_id, image_url, platform_id) - elif html_content: - yield event.plain_result("⚠️ 群分析报告图片发送失败,自动重试中。") - # 使用带提示词的重试任务,确保排队发送时视觉一致 - await self.retry_manager.add_task( - html_content, - analysis_result, - group_id, - platform_id, - caption=f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]", - ) - else: - text_report = self.report_generator.generate_text_report( - analysis_result - ) - yield event.plain_result( - f"⚠️ 图片生成失败,回退文本:\n\n{text_report}" - ) - - elif output_format == "pdf": - pdf_path = await self.report_generator.generate_pdf_report( - analysis_result, - group_id, - avatar_getter=avatar_getter, - nickname_getter=nickname_getter, - ) - if pdf_path: - if not await adapter.send_file(group_id, pdf_path): - from pathlib import Path - - yield event.chain_result( - [File(name=Path(pdf_path).name, file=pdf_path)] - ) - else: - yield event.plain_result("⚠️ PDF 生成失败。") - - else: - text_report = self.report_generator.generate_text_report( - analysis_result - ) - if not await adapter.send_text(group_id, text_report): - yield event.plain_result(text_report) + async for res in self._send_analysis_report(event, result, trace_id): + yield res except DuplicateGroupTaskError: yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~") @@ -563,6 +503,77 @@ class GroupDailyAnalysis(Star): f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员" ) + async def _send_analysis_report( + self, event: AstrMessageEvent, result: dict, trace_id: str + ) -> AsyncGenerator: + """处理分析结果的渲染和发送""" + group_id = result["group_id"] + platform_id = result["platform_id"] + analysis_result = result["analysis_result"] + adapter = result["adapter"] + output_format = self.config_manager.get_output_format() + + # 定义获取回调 + async def avatar_getter(user_id: str) -> str | None: + return await adapter.get_user_avatar_url(user_id) + + async def nickname_getter(user_id: str) -> str | None: + try: + member = await adapter.get_member_info(group_id, user_id) + if member: + return member.card or member.nickname + except Exception: + pass + return None + + if output_format == "image": + image_url, html_content = await self.report_generator.generate_image_report( + analysis_result, + group_id, + self.html_render, + avatar_getter=avatar_getter, + nickname_getter=nickname_getter, + ) + + if image_url: + caption = f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]" + await adapter.send_image(group_id, image_url, caption=caption) + await self._try_upload_image(group_id, image_url, platform_id) + elif html_content: + yield event.plain_result("⚠️ 群分析报告图片发送失败,自动重试中。") + await self.retry_manager.add_task( + html_content, + analysis_result, + group_id, + platform_id, + caption=f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]", + ) + else: + text_report = self.report_generator.generate_text_report( + analysis_result + ) + yield event.plain_result(f"⚠️ 图片生成失败,回退文本:\n\n{text_report}") + + elif output_format == "pdf": + pdf_path = await self.report_generator.generate_pdf_report( + analysis_result, + group_id, + avatar_getter=avatar_getter, + nickname_getter=nickname_getter, + ) + if pdf_path: + if not await adapter.send_file(group_id, pdf_path): + yield event.chain_result( + [File(name=Path(pdf_path).name, file=pdf_path)] + ) + else: + yield event.plain_result("⚠️ PDF 生成失败。") + + else: + text_report = self.report_generator.generate_text_report(analysis_result) + if not await adapter.send_text(group_id, text_report): + yield event.plain_result(text_report) + @filter.command("设置格式", alias={"set_format"}) @filter.permission_type(PermissionType.ADMIN) async def set_output_format(self, event: AstrMessageEvent, format_type: str = ""): @@ -641,7 +652,10 @@ class GroupDailyAnalysis(Star): if parse_error: yield event.plain_result(parse_error) return - assert template_name is not None + + if not template_name: + yield event.plain_result(f"❌ 无法解析模板输入: {template_input}") + return if not await self.template_command_service.template_exists(template_name): yield event.plain_result(f"❌ 模板 '{template_name}' 不存在") @@ -702,7 +716,9 @@ class GroupDailyAnalysis(Star): yield event.plain_result("🔄 开始安装 PDF 功能依赖,请稍候...") try: - result = await PDFInstaller.install_playwright(self.config_manager) + result = await PDFInstaller.install_playwright( + self.config_manager, task_registry=self._background_tasks + ) yield event.plain_result(result) except Exception as e: @@ -728,83 +744,12 @@ class GroupDailyAnalysis(Star): yield event.plain_result("❌ 请在群聊中使用此命令") return - elif action == "enable": - mode = self.config_manager.get_group_list_mode() - target_id = event.unified_msg_origin or group_id # 优先使用 UMO - - if mode == "whitelist": - glist = self.config_manager.get_group_list() - # 检查 UMO 或 Group ID 是否已在列表中 - if not self.config_manager.is_group_allowed(target_id): - glist.append(target_id) - self.config_manager.set_group_list(glist) - yield event.plain_result( - f"✅ 已将当前群加入白名单\nID: {target_id}" - ) - self.auto_scheduler.schedule_jobs(self.context) - else: - yield event.plain_result("ℹ️ 当前群已在白名单中") - elif mode == "blacklist": - glist = self.config_manager.get_group_list() - - # 尝试移除 UMO 和 Group ID - removed = False - if target_id in glist: - glist.remove(target_id) - removed = True - if group_id in glist: - glist.remove(group_id) - removed = True - - if removed: - self.config_manager.set_group_list(glist) - yield event.plain_result("✅ 已将当前群从黑名单移除") - self.auto_scheduler.schedule_jobs(self.context) - else: - yield event.plain_result("ℹ️ 当前群不在黑名单中") - else: - yield event.plain_result("ℹ️ 当前为无限制模式,所有群聊默认启用") - + if action == "enable": + async for result in self._handle_settings_enable(event, group_id): + yield result elif action == "disable": - mode = self.config_manager.get_group_list_mode() - target_id = event.unified_msg_origin or group_id # 优先使用 UMO - - if mode == "whitelist": - glist = self.config_manager.get_group_list() - - # 尝试移除 UMO 和 Group ID - removed = False - if target_id in glist: - glist.remove(target_id) - removed = True - if group_id in glist: - glist.remove(group_id) - removed = True - - if removed: - self.config_manager.set_group_list(glist) - yield event.plain_result("✅ 已将当前群从白名单移除") - self.auto_scheduler.schedule_jobs(self.context) - else: - yield event.plain_result("ℹ️ 当前群不在白名单中") - elif mode == "blacklist": - glist = self.config_manager.get_group_list() - # 检查 UMO 或 Group ID 是否已在列表中 - if self.config_manager.is_group_allowed( - target_id - ): # 如果允许,说明不在黑名单 - glist.append(target_id) - self.config_manager.set_group_list(glist) - yield event.plain_result( - f"✅ 已将当前群加入黑名单\nID: {target_id}" - ) - self.auto_scheduler.schedule_jobs(self.context) - else: - yield event.plain_result("ℹ️ 当前群已在黑名单中") - else: - yield event.plain_result( - "ℹ️ 当前为无限制模式,如需禁用请切换到黑名单模式" - ) + async for result in self._handle_settings_disable(event, group_id): + yield result elif action == "reload": self.auto_scheduler.schedule_jobs(self.context) @@ -940,3 +885,69 @@ class GroupDailyAnalysis(Star): f"• 参与者: {summary['participants']}\n" f"• 高峰时段: {summary['peak_hours']}" ) + + async def _handle_settings_enable(self, event: AstrMessageEvent, group_id: str): + """协助逻辑:处理启用设置的分支逻辑""" + mode = self.config_manager.get_group_list_mode() + target_id = event.unified_msg_origin or group_id + + if mode == "whitelist": + glist = self.config_manager.get_group_list() + if not self.config_manager.is_group_allowed(target_id): + glist.append(target_id) + self.config_manager.set_group_list(glist) + yield event.plain_result(f"✅ 已将当前群加入白名单\nID: {target_id}") + self.auto_scheduler.schedule_jobs(self.context) + else: + yield event.plain_result("ℹ️ 当前群已在白名单中") + elif mode == "blacklist": + glist = self.config_manager.get_group_list() + removed = False + if target_id in glist: + glist.remove(target_id) + removed = True + if group_id in glist: + glist.remove(group_id) + removed = True + + if removed: + self.config_manager.set_group_list(glist) + yield event.plain_result("✅ 已将当前群从黑名单移除") + self.auto_scheduler.schedule_jobs(self.context) + else: + yield event.plain_result("ℹ️ 当前群不在黑名单中") + else: + yield event.plain_result("ℹ️ 当前为无限制模式,所有群聊默认启用") + + async def _handle_settings_disable(self, event: AstrMessageEvent, group_id: str): + """协助逻辑:处理禁用设置的分支逻辑""" + mode = self.config_manager.get_group_list_mode() + target_id = event.unified_msg_origin or group_id + + if mode == "whitelist": + glist = self.config_manager.get_group_list() + removed = False + if target_id in glist: + glist.remove(target_id) + removed = True + if group_id in glist: + glist.remove(group_id) + removed = True + + if removed: + self.config_manager.set_group_list(glist) + yield event.plain_result("✅ 已将当前群从白名单移除") + self.auto_scheduler.schedule_jobs(self.context) + else: + yield event.plain_result("ℹ️ 当前群不在白名单中") + elif mode == "blacklist": + glist = self.config_manager.get_group_list() + if self.config_manager.is_group_allowed(target_id): + glist.append(target_id) + self.config_manager.set_group_list(glist) + yield event.plain_result(f"✅ 已将当前群加入黑名单\nID: {target_id}") + self.auto_scheduler.schedule_jobs(self.context) + else: + yield event.plain_result("ℹ️ 当前群已在黑名单中") + else: + yield event.plain_result("ℹ️ 当前为无限制模式,如需禁用请切换到黑名单模式")