From 914a57a6266bc8ce3821e22da8715de06d7ebd6c Mon Sep 17 00:00:00 2001 From: jkfujr Date: Tue, 24 Mar 2026 01:14:33 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=88=86=E6=9E=90=E4=B8=8E=E5=85=A8=E5=B1=80=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E8=90=BD=E7=9B=98=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 配置层:新增 `auto_analysis_send_report` 控制静默分析模式。 2. 配置层:废弃独立的 `pdf` 专属配置,并升级为统一的 `report_storage`(向下兼容读取老配置),负责所有分析格式的落盘参数。 3. 调度层:向派发方法注入 `silent_mode` 标识,触发静默分析时不推送给群聊。 4. 分发层:统筹 dispatcher 逻辑扩展,新增 `_save_to_local_binary` 及 `_save_to_local_text`,实现 Image 和 Text 生成报告的同时向本地持久化归档目录保存 `.png` 取代临时文件以及保存 `.md` 文件。 5. 分发层:处理拦截逻辑,当开启静默模式时截断向 message_sender 的下发动作。 --- _conf_schema.json | 32 ++++--- src/infrastructure/config/config_manager.py | 61 ++++++++---- src/infrastructure/reporting/dispatcher.py | 96 ++++++++++++++++--- src/infrastructure/reporting/generators.py | 8 +- .../scheduler/auto_scheduler.py | 2 + 5 files changed, 152 insertions(+), 47 deletions(-) diff --git a/_conf_schema.json b/_conf_schema.json index 1aa564c1..fa36a67d 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -112,6 +112,12 @@ "items": { "type": "string" } + }, + "auto_analysis_send_report": { + "type": "bool", + "description": "分析后自动发送报告", + "default": true, + "hint": "开启后,定时自动分析完成后会像以往一样将报告推送到群聊;关闭后,仅在后台完成数据归档和本地报告文件生成(静默分析),不会打扰群聊。" } } }, @@ -321,28 +327,28 @@ } } }, - "pdf": { - "description": "PDF 设置", + "report_storage": { + "description": "分析报告本地存储设置", "type": "object", - "hint": "PDF 报告输出相关配置,包括输出目录、浏览器路径和文件名格式", + "hint": "统一管理所有格式(图片、文本、PDF)分析报告的本地存档行为", "items": { - "pdf_output_dir": { + "report_output_dir": { "type": "string", - "description": "PDF输出目录", + "description": "报告统一输出目录", "default": "data/plugins/astrbot_plugin_qq_group_daily_analysis/reports", - "hint": "PDF报告文件的保存目录" + "hint": "无论是png图片、文本md文档还是pdf,生成后均会存放在此文件夹中长久归档" + }, + "report_filename_format": { + "type": "string", + "description": "报告文件名格式", + "default": "群聊分析报告_{group_id}_{date}", + "hint": "报告文件名格式,无需加后缀。支持变量:{group_id}(群号)、{date}(日期)。插件会自动根据生成格式补齐 .png / .md / .pdf 后缀" }, "browser_path": { "type": "string", - "description": "自定义浏览器路径", + "description": "自定义浏览器路径 (生成 PDF 时使用)", "default": "", "hint": "要填写的话请你清楚自己在干什么。自定义浏览器的可执行文件路径(如 Chrome 或 Edge 的 .exe 文件)。提示:如果是在网页后台设置,则直接输入普通路径即可(如 C:\\Program Files\\...);如果是手动编辑 config.json 文件,请务必使用双反斜杠 '\\\\' 分隔路径。" - }, - "pdf_filename_format": { - "type": "string", - "description": "PDF文件名格式", - "default": "群聊分析报告_{group_id}_{date}.pdf", - "hint": "PDF文件名格式,支持变量:{group_id}(群号)、{date}(日期)" } } }, diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 1c5c9593..a28cab96 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -140,6 +140,10 @@ def get_enable_auto_analysis(self) -> bool: """获取是否启用自动分析""" return self._get_group("auto_analysis").get("enable_auto_analysis", False) + def get_auto_analysis_send_report(self) -> bool: + """获取分析完成后是否自动发送报告""" + return self._get_group("auto_analysis").get("auto_analysis_send_report", True) + def get_output_format(self) -> str: """获取输出格式""" return self._get_group("basic").get("output_format", "image") @@ -234,18 +238,23 @@ def get_keep_original_persona(self) -> bool: """获取是否保持原始人格设定""" return self._get_group("analysis_features").get("keep_original_persona", False) - def get_pdf_output_dir(self) -> str: - """获取PDF输出目录""" + def get_report_output_dir(self) -> str: + """获取报告统一输出目录""" try: plugin_name = "astrbot_plugin_qq_group_daily_analysis" data_path = Path(get_astrbot_data_path()) default_path = data_path / "plugin_data" / plugin_name / "reports" - return self._get_group("pdf").get("pdf_output_dir", str(default_path)) + + # 优先读新版配置 + report_storage = self._get_group("report_storage") + if "report_output_dir" in report_storage: + return report_storage.get("report_output_dir", str(default_path)) + + # 兼容读取旧版配置 pdf_output_dir + pdf_group = self._get_group("pdf") + return pdf_group.get("pdf_output_dir", str(default_path)) except Exception: - return self._get_group("pdf").get( - "pdf_output_dir", - "data/plugins/astrbot_plugin_qq_group_daily_analysis/reports", - ) + return "data/plugins/astrbot_plugin_qq_group_daily_analysis/reports" def get_bot_self_ids(self) -> list: """获取机器人自身的 ID 列表 (兼容 bot_qq_ids)""" @@ -255,11 +264,17 @@ def get_bot_self_ids(self) -> list: ids = basic.get("bot_qq_ids", []) return ids - def get_pdf_filename_format(self) -> str: - """获取PDF文件名格式""" - return self._get_group("pdf").get( - "pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf" - ) + def get_report_filename_format(self) -> str: + """获取报告文件名格式 (无后缀)""" + + report_storage = self._get_group("report_storage") + if "report_filename_format" in report_storage: + return report_storage.get("report_filename_format", "群聊分析报告_{group_id}_{date}") + + old_pdf_format = self._get_group("pdf").get("pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf") + if old_pdf_format.endswith(".pdf"): + return old_pdf_format[:-4] + return old_pdf_format def get_topic_analysis_prompt(self, style: str = "topic_prompt") -> str: """获取话题分析提示词模板""" @@ -399,6 +414,11 @@ def set_enable_auto_analysis(self, enabled: bool): self._ensure_group("auto_analysis")["enable_auto_analysis"] = enabled self.config.save_config() + def set_auto_analysis_send_report(self, enabled: bool): + """设置是否在分析后自动发送报告""" + self._ensure_group("auto_analysis")["auto_analysis_send_report"] = enabled + self.config.save_config() + def set_min_messages_threshold(self, threshold: int): """设置最小消息阈值""" self._ensure_group("basic")["min_messages_threshold"] = threshold @@ -443,14 +463,14 @@ def set_max_golden_quotes(self, count: int): self._ensure_group("analysis_features")["max_golden_quotes"] = count self.config.save_config() - def set_pdf_output_dir(self, directory: str): - """设置PDF输出目录""" - self._ensure_group("pdf")["pdf_output_dir"] = directory + def set_report_output_dir(self, directory: str): + """设置报告产出目录""" + self._ensure_group("report_storage")["report_output_dir"] = directory self.config.save_config() - def set_pdf_filename_format(self, format_str: str): - """设置PDF文件名格式""" - self._ensure_group("pdf")["pdf_filename_format"] = format_str + def set_report_filename_format(self, format_str: str): + """设置报告文件名格式""" + self._ensure_group("report_storage")["report_filename_format"] = format_str self.config.save_config() def get_report_template(self) -> str: @@ -589,11 +609,14 @@ def _check_playwright_availability(self): def get_browser_path(self) -> str: """获取自定义浏览器路径""" + report_storage = self._get_group("report_storage") + if "browser_path" in report_storage: + return report_storage.get("browser_path", "") return self._get_group("pdf").get("browser_path", "") def set_browser_path(self, path: str): """设置自定义浏览器路径""" - self._ensure_group("pdf")["browser_path"] = path + self._ensure_group("report_storage")["browser_path"] = path self.config.save_config() def reload_playwright(self) -> bool: diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index 549cb48c..69f534d3 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -3,6 +3,7 @@ import tempfile from collections.abc import Callable from datetime import datetime +from pathlib import Path from typing import Any from ...shared.trace_context import TraceContext @@ -31,6 +32,7 @@ async def dispatch( group_id: str, analysis_result: dict[str, Any], platform_id: str | None = None, + silent_mode: bool = False, ): """ 分发分析报告 @@ -38,16 +40,16 @@ async def dispatch( trace_id = TraceContext.get() output_format = self.config_manager.get_output_format() logger.info( - f"[{trace_id}] 正在分发群 {group_id} 的报告 (格式: {output_format})" + f"[{trace_id}] 正在分发群 {group_id} 的报告 (格式: {output_format}, 静默: {silent_mode})" ) success = False if output_format == "image": - success = await self._dispatch_image(group_id, analysis_result, platform_id) + success = await self._dispatch_image(group_id, analysis_result, platform_id, silent_mode) elif output_format == "pdf": - success = await self._dispatch_pdf(group_id, analysis_result, platform_id) + success = await self._dispatch_pdf(group_id, analysis_result, platform_id, silent_mode) else: - success = await self._dispatch_text(group_id, analysis_result, platform_id) + success = await self._dispatch_text(group_id, analysis_result, platform_id, silent_mode) if success: logger.info(f"[{trace_id}] 群 {group_id} 的报告分发成功") @@ -55,7 +57,7 @@ async def dispatch( logger.warning(f"[{trace_id}] 群 {group_id} 的报告分发失败") async def _dispatch_image( - self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None + self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None, silent_mode: bool = False ) -> bool: trace_id = TraceContext.get() # 1. 检查渲染函数 @@ -86,8 +88,14 @@ async def avatar_url_getter(user_id: str): logger.error(f"[{trace_id}] Failed to generate image report: {e}") # image_url and html_content remain None - # 3. 发送图片 + # 3. 发送图片 (或静默拦截) if image_url: + # 执行统一本地存档 (PNG) + self._save_to_local_binary(group_id, image_url, ".png") + if silent_mode: + logger.info(f"[{trace_id}] 群 {group_id} 图片报告已归档,静默模式不推送。") + return True + caption = TraceContext.make_report_caption() sent = await self.message_sender.send_image_smart( group_id, image_url, caption, platform_id @@ -127,7 +135,7 @@ async def avatar_url_getter(user_id: str): return await self._dispatch_text(group_id, analysis_result, platform_id) async def _dispatch_pdf( - self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None + self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None, silent_mode: bool = False ) -> bool: trace_id = TraceContext.get() # 1. 检查 Playwright @@ -135,7 +143,7 @@ async def _dispatch_pdf( logger.warning( f"[{trace_id}] Playwright not available, falling back to text." ) - return await self._dispatch_text(group_id, analysis_result, platform_id) + return await self._dispatch_text(group_id, analysis_result, platform_id, silent_mode) # 2. 生成 PDF pdf_path = None @@ -146,8 +154,13 @@ async def _dispatch_pdf( except Exception as e: logger.error(f"[{trace_id}] Failed to generate PDF report: {e}") - # 3. 发送 PDF + # 3. 发送 PDF (或静默拦截) + # 注意: PDF 已经在 generate 时由 Generator 持久化了,无需再次存档 if pdf_path: + if silent_mode: + logger.info(f"[{trace_id}] 群 {group_id} PDF报告已生成留存,静默模式不推送。") + return True + sent = await self.message_sender.send_pdf( group_id, pdf_path, "📊 每日群聊分析报告已生成:", platform_id ) @@ -158,13 +171,22 @@ async def _dispatch_pdf( logger.warning( f"[{trace_id}] PDF dispatch failed, falling back to text report." ) - return await self._dispatch_text(group_id, analysis_result, platform_id) + return await self._dispatch_text(group_id, analysis_result, platform_id, silent_mode) async def _dispatch_text( - self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None + self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None, silent_mode: bool = False ) -> bool: try: + trace_id = TraceContext.get() text_report = self.report_generator.generate_text_report(analysis_result) + + # 执行本地存档 (Markdown) + self._save_to_local_text(group_id, text_report, ".md") + + if silent_mode: + logger.info(f"[{trace_id}] 群 {group_id} 文本报告已归档,静默模式不推送。") + return True + return await self.message_sender.send_text( group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id ) @@ -301,3 +323,55 @@ def _get_onebot_adapter(self, platform_id: str | None): if adapter and hasattr(adapter, "upload_group_file_to_folder"): return adapter return None + + # ================================================================ + # 全局报告本地持久化存储(统一归档) + # ================================================================ + + def _get_archive_path(self, group_id: str, extension: str) -> str: + """获取并创建报告的统一输出归档路径""" + output_dir = Path(self.config_manager.get_report_output_dir()) + output_dir.mkdir(parents=True, exist_ok=True) + current_date = datetime.now().strftime("%Y%m%d") + + base_name = self.config_manager.get_report_filename_format().format( + group_id=group_id, date=current_date + ) + return str(output_dir / f"{base_name}{extension}") + + def _save_to_local_binary(self, group_id: str, image_url: str, ext: str): + """将二进制报告(如图片Base64)存留本地硬盘永久归档""" + try: + image_data = None + if image_url.startswith("base64://"): + image_data = base64.b64decode(image_url[len("base64://") :]) + elif image_url.startswith("data:"): + parts = image_url.split(",", 1) + if len(parts) == 2: + image_data = base64.b64decode(parts[1]) + elif os.path.isfile(image_url): + with open(image_url, "rb") as f: + image_data = f.read() + elif image_url.startswith("file:///"): + p = image_url[len("file:///") :] + if os.path.isfile(p): + with open(p, "rb") as f: + image_data = f.read() + + if image_data: + save_path = self._get_archive_path(group_id, ext) + with open(save_path, "wb") as f: + f.write(image_data) + logger.debug(f"已持久化归档报告二进制文件至: {save_path}") + except Exception as e: + logger.warning(f"持久化归档生成文件失败: {e}") + + def _save_to_local_text(self, group_id: str, text: str, ext: str): + """将纯文本报告存留本地硬盘永久归档""" + try: + save_path = self._get_archive_path(group_id, ext) + with open(save_path, "w", encoding="utf-8") as f: + f.write(text) + logger.debug(f"已持久化归档纯文本报告至: {save_path}") + except Exception as e: + logger.warning(f"持久化归档生成文件失败: {e}") diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index e45965fb..012b236b 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -211,14 +211,14 @@ async def generate_pdf_report( """生成PDF格式的分析报告""" try: # 确保输出目录存在(使用 asyncio.to_thread 避免阻塞) - output_dir = Path(self.config_manager.get_pdf_output_dir()) + output_dir = Path(self.config_manager.get_report_output_dir()) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) - # 生成文件名 + # 生成文件名 (配置返回无后缀,手动加 .pdf) current_date = datetime.now().strftime("%Y%m%d") - filename = self.config_manager.get_pdf_filename_format().format( + filename = self.config_manager.get_report_filename_format().format( group_id=group_id, date=current_date - ) + ) + ".pdf" pdf_path = output_dir / filename # 准备渲染数据 diff --git a/src/infrastructure/scheduler/auto_scheduler.py b/src/infrastructure/scheduler/auto_scheduler.py index ce336468..6e4d7c9b 100644 --- a/src/infrastructure/scheduler/auto_scheduler.py +++ b/src/infrastructure/scheduler/auto_scheduler.py @@ -455,6 +455,7 @@ async def _perform_auto_analysis_for_group( adapter.platform_id if hasattr(adapter, "platform_id") else target_platform_id, + silent_mode=not self.config_manager.get_auto_analysis_send_report() ) logger.info(f"群 {group_id} 自动分析任务执行成功") @@ -759,6 +760,7 @@ async def _perform_incremental_final_report_for_group( adapter.platform_id if hasattr(adapter, "platform_id") else target_platform_id, + silent_mode=not self.config_manager.get_auto_analysis_send_report() ) # 清理过期批次(保留 2 倍窗口范围的数据作为缓冲) From b740f124328a03edb5fe3f94501b8099d604807c Mon Sep 17 00:00:00 2001 From: jkfujr Date: Tue, 24 Mar 2026 01:35:25 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E5=BD=92=E6=A1=A3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 添加配置项说明:由于原有外部报告 HTML 模板仅适用图片与 PDF 的 UI 渲染,在配置界面增加适用范围说明,防止文本报告时引发歧义。 2. 添加统一开关:在 `report_storage` 下增加 `enable_local_storage` 归档留存开关。 3. 在分发器中结合开关状态,支持静音生成或阅后即扫把临时文件清理,防止垃圾堆叠。 --- _conf_schema.json | 8 +++++- src/infrastructure/config/config_manager.py | 9 +++++++ src/infrastructure/reporting/dispatcher.py | 28 ++++++++++++++++----- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/_conf_schema.json b/_conf_schema.json index 24f81ff6..94c8500d 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -75,7 +75,7 @@ "format" ], "default": "scrapbook", - "hint": "分析报告使用的HTML模板名称,QQ 端可以使用 `/设置模板` 查看使用指南,使用`/查看模板` 命令查看模板样式效果。其他平台不支持合并转发,可以到 [模板](https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis/tree/main/assets) 文件夹中查看模板样式效果" + "hint": "分析报告使用的HTML模板名称,注意:该外部主题模板仅适用于 image(图片) 和 pdf(PDF文件) 格式,text(文本) 格式无效不会生效。QQ 端可以使用 `/设置模板` 查看使用指南,使用`/查看模板` 命令查看模板样式效果。其他平台不支持合并转发,可以到 [模板](https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis/tree/main/assets) 文件夹中查看模板样式效果" }, "debug_mode": { "type": "bool", @@ -338,6 +338,12 @@ "type": "object", "hint": "统一管理所有格式(图片、文本、PDF)分析报告的本地存档行为", "items": { + "enable_local_storage": { + "type": "bool", + "description": "启用本地存储归档", + "default": true, + "hint": "开启后所有的报告(图片、文本、PDF)生成后都会被长久保存在统一输出目录中;关闭后报告将直接推送至群聊不再留底浪费空间(对纯净群管理员有帮助)。" + }, "report_output_dir": { "type": "string", "description": "报告统一输出目录", diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 78391e66..351a29d7 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -238,6 +238,10 @@ def get_keep_original_persona(self) -> bool: """获取是否保持原始人格设定""" return self._get_group("analysis_features").get("keep_original_persona", False) + def get_enable_local_storage(self) -> bool: + """获取是否启用本地存储归档""" + return self._get_group("report_storage").get("enable_local_storage", True) + def get_report_output_dir(self) -> str: """获取报告统一输出目录""" try: @@ -463,6 +467,11 @@ def set_max_golden_quotes(self, count: int): self._ensure_group("analysis_features")["max_golden_quotes"] = count self.config.save_config() + def set_enable_local_storage(self, enabled: bool): + """设置是否启用本地存储归档""" + self._ensure_group("report_storage")["enable_local_storage"] = enabled + self.config.save_config() + def set_report_output_dir(self, directory: str): """设置报告产出目录""" self._ensure_group("report_storage")["report_output_dir"] = directory diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index 69f534d3..04367bbc 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -157,13 +157,23 @@ async def _dispatch_pdf( # 3. 发送 PDF (或静默拦截) # 注意: PDF 已经在 generate 时由 Generator 持久化了,无需再次存档 if pdf_path: + sent = False if silent_mode: - logger.info(f"[{trace_id}] 群 {group_id} PDF报告已生成留存,静默模式不推送。") - return True - - sent = await self.message_sender.send_pdf( - group_id, pdf_path, "📊 每日群聊分析报告已生成:", platform_id - ) + logger.info(f"[{trace_id}] 群 {group_id} PDF报告静默模式不推送。") + sent = True + else: + sent = await self.message_sender.send_pdf( + group_id, pdf_path, "📊 每日群聊分析报告已生成:", platform_id + ) + + # 如果不启用本地存储,且发信结束(或静默阻断),应当清理 Generator 生成出来的临时文件 + if not self.config_manager.get_enable_local_storage(): + try: + Path(pdf_path).unlink(missing_ok=True) + logger.debug(f"[{trace_id}] 本地存储归档未启用,清理PDF缓存({pdf_path})") + except Exception as e: + pass + if sent: return True @@ -341,6 +351,9 @@ def _get_archive_path(self, group_id: str, extension: str) -> str: def _save_to_local_binary(self, group_id: str, image_url: str, ext: str): """将二进制报告(如图片Base64)存留本地硬盘永久归档""" + if not self.config_manager.get_enable_local_storage(): + return + try: image_data = None if image_url.startswith("base64://"): @@ -368,6 +381,9 @@ def _save_to_local_binary(self, group_id: str, image_url: str, ext: str): def _save_to_local_text(self, group_id: str, text: str, ext: str): """将纯文本报告存留本地硬盘永久归档""" + if not self.config_manager.get_enable_local_storage(): + return + try: save_path = self._get_archive_path(group_id, ext) with open(save_path, "w", encoding="utf-8") as f: From f9ee4ac89e1fe288918ec0678e0316525fee58de Mon Sep 17 00:00:00 2001 From: jkfujr Date: Tue, 24 Mar 2026 01:42:40 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E5=8F=91=E4=BF=A1=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E5=A2=9E=E5=8A=A0=E7=BE=A4=E8=81=8A=E9=BB=91=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E6=9D=83=E9=99=90=E6=A8=A1=E5=BC=8F=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 配置新增:在 `auto_analysis` 下增加 `send_report_mode` 模式选择和 `send_report_list` 控制名单。 2. 配置加载:在管理类补充 `is_group_allowed_to_send_report` 提供鉴权。 3. 调度扩展:修改 `auto_scheduler` 定时任务,计算 `silent_mode` 从单一的总开关升级为 `总开关 && 白名单鉴权通过`,阻断不需要被推送信件的群进行打扰。 --- _conf_schema.json | 22 ++++++- src/infrastructure/config/config_manager.py | 65 +++++++++++++++++++ .../scheduler/auto_scheduler.py | 12 +++- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/_conf_schema.json b/_conf_schema.json index 94c8500d..a01c0b44 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -123,7 +123,27 @@ "type": "bool", "description": "分析后自动发送报告", "default": true, - "hint": "开启后,定时自动分析完成后会像以往一样将报告推送到群聊;关闭后,仅在后台完成数据归档和本地报告文件生成(静默分析),不会打扰群聊。" + "hint": "总开关。开启后,分析定时完成后会尝试将报告推送到群聊;关闭后,仅在后台完成数据归档和本地报告生成,绝对不会发送到任何群聊。" + }, + "send_report_mode": { + "description": "发送报告组限制模式", + "type": "string", + "options": [ + "whitelist", + "blacklist", + "none" + ], + "default": "none", + "hint": "搭配发信开关使用。whitelist: 仅允许向列表内群聊发送报告;blacklist: 拒绝向列表内群聊发送;none: 不设限制(全发)" + }, + "send_report_list": { + "type": "list", + "description": "发送群聊白/黑名单列表", + "default": [], + "hint": "黑白名单模式下使用的群组列表。支持填写 AstrBot UMO (如 xxxxx:GroupMessage:123456) 或纯群号 (如 123456 将尝试匹配)。", + "items": { + "type": "string" + } } } }, diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 351a29d7..61e64cca 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -144,6 +144,61 @@ def get_auto_analysis_send_report(self) -> bool: """获取分析完成后是否自动发送报告""" return self._get_group("auto_analysis").get("auto_analysis_send_report", True) + def get_send_report_mode(self) -> str: + """获取发送报告限制模式 (whitelist/blacklist/none)""" + return self._get_group("auto_analysis").get("send_report_mode", "none") + + def get_send_report_list(self) -> list[str]: + """获取发送报告群组列表(用于黑白名单)""" + return self._get_group("auto_analysis").get("send_report_list", []) + + def is_group_allowed_to_send_report(self, group_id_or_umo: str) -> bool: + """根据配置的白/黑名单判断是否允许向该群发送自动分析报告""" + mode = self.get_send_report_mode().lower() + if mode not in ("whitelist", "blacklist", "none"): + mode = "none" + + if mode == "none": + return True + + glist = [str(g) for g in self.get_send_report_list()] + target = str(group_id_or_umo) + + target_simple_id = target.split(":")[-1] if ":" in target else target + target_parent_id = ( + target_simple_id.split("#", 1)[0] + if "#" in target_simple_id + else target_simple_id + ) + + def _is_match( + item: str, + target: str, + target_simple_id: str, + target_parent_id: str, + ) -> bool: + if ":" in item: + if item == target: + return True + if "#" in target_simple_id: + if ":" not in target: + return False + item_prefix, item_tail = item.rsplit(":", 1) + target_prefix, _ = target.rsplit(":", 1) + return ( + item_prefix == target_prefix and item_tail == target_parent_id + ) + return False + if item == target_simple_id: + return True + return "#" in target_simple_id and item == target_parent_id + + matched = any( + _is_match(item, target, target_simple_id, target_parent_id) + for item in glist + ) + return matched if mode == "whitelist" else not matched + def get_output_format(self) -> str: """获取输出格式""" return self._get_group("basic").get("output_format", "image") @@ -423,6 +478,16 @@ def set_auto_analysis_send_report(self, enabled: bool): self._ensure_group("auto_analysis")["auto_analysis_send_report"] = enabled self.config.save_config() + def set_send_report_mode(self, mode: str): + """设置发送报告权限模式""" + self._ensure_group("auto_analysis")["send_report_mode"] = mode + self.config.save_config() + + def set_send_report_list(self, group_list: list[str]): + """设置发送报告黑白名单""" + self._ensure_group("auto_analysis")["send_report_list"] = group_list + self.config.save_config() + def set_min_messages_threshold(self, threshold: int): """设置最小消息阈值""" self._ensure_group("basic")["min_messages_threshold"] = threshold diff --git a/src/infrastructure/scheduler/auto_scheduler.py b/src/infrastructure/scheduler/auto_scheduler.py index 6e4d7c9b..e706bc2b 100644 --- a/src/infrastructure/scheduler/auto_scheduler.py +++ b/src/infrastructure/scheduler/auto_scheduler.py @@ -448,6 +448,10 @@ async def _perform_auto_analysis_for_group( analysis_result = result["analysis_result"] adapter = result["adapter"] + # 计算是否应该发送报告 (总开关 && 黑白名单允许) + should_send = self.config_manager.get_auto_analysis_send_report() and \ + self.config_manager.is_group_allowed_to_send_report(group_id) + # 调度导出并发送报告 await self.report_dispatcher.dispatch( group_id, @@ -455,7 +459,7 @@ async def _perform_auto_analysis_for_group( adapter.platform_id if hasattr(adapter, "platform_id") else target_platform_id, - silent_mode=not self.config_manager.get_auto_analysis_send_report() + silent_mode=not should_send ) logger.info(f"群 {group_id} 自动分析任务执行成功") @@ -754,13 +758,17 @@ async def _perform_incremental_final_report_for_group( analysis_result = result["analysis_result"] adapter = result["adapter"] + # 计算是否应该发送报告 (总开关 && 黑白名单允许) + should_send = self.config_manager.get_auto_analysis_send_report() and \ + self.config_manager.is_group_allowed_to_send_report(group_id) + await self.report_dispatcher.dispatch( group_id, analysis_result, adapter.platform_id if hasattr(adapter, "platform_id") else target_platform_id, - silent_mode=not self.config_manager.get_auto_analysis_send_report() + silent_mode=not should_send ) # 清理过期批次(保留 2 倍窗口范围的数据作为缓冲) From 72f17009658c73105f375940b6010fe9ac827a92 Mon Sep 17 00:00:00 2001 From: jkfujr Date: Tue, 24 Mar 2026 02:30:34 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=E4=BF=AE=E5=A4=8D=E5=8F=91?= =?UTF-8?q?=E9=80=81=E9=99=8D=E7=BA=A7=E5=88=A4=E5=AE=9A=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E7=BE=A4=E7=BB=84=E9=85=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(dispatcher): 单独处理 `_dispatch_image` 降级发送丢失 `silent_mode` 参数的问题,防止静默模式被意外打破 - fix(dispatcher): 更换 `_dispatch_pdf` 清理临时文件的报错吞咽方法,捕获具体的 OSError 并留下警告日志,帮助追溯环境文件权限或路径错误 - refactor(config_manager): 封装提取 `_match_umo_rule`,简化 `is_group_allowed` 与 `is_group_allowed_to_send_report` 校验底层机制,分离 `:` 和 `#` 复杂的验证体系,使校验系统更具健壮性 --- src/infrastructure/config/config_manager.py | 112 +++++++------------- src/infrastructure/reporting/dispatcher.py | 11 +- 2 files changed, 48 insertions(+), 75 deletions(-) diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 61e64cca..e2a90f6f 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -49,6 +49,45 @@ def get_group_list(self) -> list[str]: """获取群组列表(用于黑白名单)""" return self._get_group("basic").get("group_list", []) + @staticmethod + def _match_umo_rule(rule: str, target: str) -> bool: + """ + 匹配目标源(target)是否符合指定规则(rule) + 支持 UMO 前缀和包含的话题会话的后段(#)提权匹配。 + """ + if rule == target: + return True + + # 分解目标 UMO + target_has_prefix = ":" in target + target_simple_id = target.split(":")[-1] if target_has_prefix else target + target_parent_id = target_simple_id.split("#", 1)[0] if "#" in target_simple_id else target_simple_id + target_has_topic = "#" in target_simple_id + target_prefix = target.rsplit(":", 1)[0] if target_has_prefix else "" + + # 分解规则 + rule_has_prefix = ":" in rule + rule_simple_id = rule.split(":")[-1] if rule_has_prefix else rule + rule_prefix = rule.rsplit(":", 1)[0] if rule_has_prefix else "" + + if rule_has_prefix: + # 规则也带有平台前缀,则双方前缀必须完全一致 + if not target_has_prefix or rule_prefix != target_prefix: + return False + # 允许 Telegram 等带后缀的话题会话通过“父 UMO”被包含命中 + if target_has_topic and rule_simple_id == target_parent_id: + return True + return False + + # 规则只是一个单独的不带前缀的纯标识 + if rule == target_simple_id: + return True + # 允许单独通过群号父 ID 来命中(如 rule="123", target="telegram2:Msg:123#456") + if target_has_topic and rule == target_parent_id: + return True + + return False + def is_group_allowed(self, group_id_or_umo: str) -> bool: """ 根据配置的白/黑名单判断是否允许在该群聊中使用 @@ -64,44 +103,7 @@ def is_group_allowed(self, group_id_or_umo: str) -> bool: glist = [str(g) for g in self.get_group_list()] target = str(group_id_or_umo) - target_simple_id = target.split(":")[-1] if ":" in target else target - target_parent_id = ( - target_simple_id.split("#", 1)[0] - if "#" in target_simple_id - else target_simple_id - ) - - def _is_match( - item: str, - target: str, - target_simple_id: str, - target_parent_id: str, - ) -> bool: - if ":" in item: - if item == target: - return True - - # 允许 Telegram 话题会话通过“父 UMO”命中, - # 例如: item=telegram2:GroupMessage:-1001 - # target=telegram2:GroupMessage:-1001#2264 - if "#" in target_simple_id: - if ":" not in target: - return False - item_prefix, item_tail = item.rsplit(":", 1) - target_prefix, _ = target.rsplit(":", 1) - return ( - item_prefix == target_prefix and item_tail == target_parent_id - ) - return False - if item == target_simple_id: - return True - # 允许 Telegram 话题会话通过父群 ID 命中简单群号白/黑名单 - return "#" in target_simple_id and item == target_parent_id - - is_in_list = any( - _is_match(item, target, target_simple_id, target_parent_id) - for item in glist - ) + is_in_list = any(self._match_umo_rule(item, target) for item in glist) if mode == "whitelist": return is_in_list @@ -164,39 +166,7 @@ def is_group_allowed_to_send_report(self, group_id_or_umo: str) -> bool: glist = [str(g) for g in self.get_send_report_list()] target = str(group_id_or_umo) - target_simple_id = target.split(":")[-1] if ":" in target else target - target_parent_id = ( - target_simple_id.split("#", 1)[0] - if "#" in target_simple_id - else target_simple_id - ) - - def _is_match( - item: str, - target: str, - target_simple_id: str, - target_parent_id: str, - ) -> bool: - if ":" in item: - if item == target: - return True - if "#" in target_simple_id: - if ":" not in target: - return False - item_prefix, item_tail = item.rsplit(":", 1) - target_prefix, _ = target.rsplit(":", 1) - return ( - item_prefix == target_prefix and item_tail == target_parent_id - ) - return False - if item == target_simple_id: - return True - return "#" in target_simple_id and item == target_parent_id - - matched = any( - _is_match(item, target, target_simple_id, target_parent_id) - for item in glist - ) + matched = any(self._match_umo_rule(item, target) for item in glist) return matched if mode == "whitelist" else not matched def get_output_format(self) -> str: diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index 04367bbc..9f7c287f 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -63,7 +63,7 @@ async def _dispatch_image( # 1. 检查渲染函数 if not self._html_render_func: logger.warning(f"[{trace_id}] 未设置 HTML 渲染函数,回退到文本模式。") - return await self._dispatch_text(group_id, analysis_result, platform_id) + return await self._dispatch_text(group_id, analysis_result, platform_id, silent_mode) # 2. 生成图片 image_url = None @@ -132,7 +132,7 @@ async def avatar_url_getter(user_id: str): # 6. 最终回退:文本报告 logger.warning(f"[{trace_id}] Falling back to text report.") - return await self._dispatch_text(group_id, analysis_result, platform_id) + return await self._dispatch_text(group_id, analysis_result, platform_id, silent_mode) async def _dispatch_pdf( self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None, silent_mode: bool = False @@ -171,8 +171,11 @@ async def _dispatch_pdf( try: Path(pdf_path).unlink(missing_ok=True) logger.debug(f"[{trace_id}] 本地存储归档未启用,清理PDF缓存({pdf_path})") - except Exception as e: - pass + except OSError as e: + # missing_ok=True 已经忽略文件不存在的情况;其他 OSError 需要记录,避免静默失败 + logger.warning( + f"[{trace_id}] 本地存储归档未启用,清理PDF缓存失败({pdf_path}): {e!r}" + ) if sent: return True