From 8ba5f69788d0a02ff7443f92c6dcd9ce0b11044a Mon Sep 17 00:00:00 2001 From: leko Date: Wed, 1 Apr 2026 20:38:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=AA=94=E6=A1=88=E5=90=8D=E7=A8=B1?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=94=AF=E6=8F=B4=20ULID=20=E5=92=8C?= =?UTF-8?q?=E8=B3=87=E6=96=99=E5=A4=BE=E8=B7=AF=E5=BE=91=20(#146=20@lekoOw?= =?UTF-8?q?O)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Enhance HTML report generation and dispatching with captions * chore: apply ruff fixes * fix(ReportGenerator): update HTML caption for report generation * fix(ReportGenerator): encode filename in HTML report caption URL * fix(ReportGenerator): refactor golden quotes handling for clarity and consistency * feat(ReportGenerator): enhance filename formatting with ulid support and path sanitization * fix(ReportGenerator): enhance path validation and filename uniqueness in report generation * fix: pylance type error and ruff * fix: 路径默认使用 ulid 变量 --------- Co-authored-by: SXP-Simon --- _conf_schema.json | 8 +- main.py | 32 ++-- requirements.txt | 3 +- .../messaging/message_sender.py | 39 ++++- src/infrastructure/reporting/dispatcher.py | 39 ++++- src/infrastructure/reporting/generators.py | 149 +++++++++++++++--- 6 files changed, 225 insertions(+), 45 deletions(-) diff --git a/_conf_schema.json b/_conf_schema.json index 12913c4..2a3ab1d 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -356,8 +356,8 @@ "pdf_filename_format": { "type": "string", "description": "PDF文件名格式", - "default": "群聊分析报告_{group_id}_{date}.pdf", - "hint": "PDF文件名格式,支持变量:{group_id}(群号)、{date}(日期)" + "default": "群聊分析报告_{group_id}_{date}_{ulid}.pdf", + "hint": "PDF文件名格式,支持变量:{group_id}(群号)、{date}(日期)、{ulid}(时间排序ID),支持子目录/层级。" } } }, @@ -381,8 +381,8 @@ "html_filename_format": { "type": "string", "description": "HTML文件名格式", - "default": "群聊分析报告_{group_id}_{date}.html", - "hint": "HTML文件名格式,支持变量:{group_id}(群号)、{date}(日期)" + "default": "群聊分析报告_{group_id}_{date}_{ulid}.html", + "hint": "HTML文件名格式,支持变量:{group_id}(群号)、{date}(日期)、{ulid}(时间排序ID),支持子目录/层级。" } } }, diff --git a/main.py b/main.py index f44745d..4bb77e7 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ from .src.domain.services.incremental_merge_service import IncrementalMergeServi from .src.domain.services.statistics_service import StatisticsService from .src.infrastructure.analysis.llm_analyzer import LLMAnalyzer from .src.infrastructure.config.config_manager import ConfigManager +from .src.infrastructure.messaging.message_sender import MessageSender from .src.infrastructure.persistence.history_manager import HistoryManager from .src.infrastructure.persistence.incremental_store import IncrementalStore from .src.infrastructure.persistence.telegram_group_registry import ( @@ -72,6 +73,7 @@ class GroupDailyAnalysis(Star): template_preview_router: TemplatePreviewRouter retry_manager: RetryManager auto_scheduler: AutoScheduler + message_sender: MessageSender def __init__(self, context: Context, config: AstrBotConfig): super().__init__(context) @@ -133,6 +135,9 @@ class GroupDailyAnalysis(Star): self.retry_manager = RetryManager( self.bot_manager, self.html_render, self.report_generator ) + self.message_sender = MessageSender( + self.bot_manager, self.config_manager, self.retry_manager + ) self.auto_scheduler = AutoScheduler( self.config_manager, self.analysis_service, @@ -638,20 +643,29 @@ class GroupDailyAnalysis(Star): nickname_getter=nickname_getter, ) if html_path: + caption = self.report_generator.build_html_caption(html_path) + # 发送 HTML 文件 - if not await adapter.send_file(group_id, html_path): + sender = getattr(self, "message_sender", None) + if sender: + sent = await sender.send_file( + group_id, + html_path, + caption=caption, + platform_id=platform_id, + ) + else: + sent = await adapter.send_file(group_id, html_path) + if sent and caption: + await adapter.send_text(group_id, caption) + + if not sent: yield event.chain_result( [File(name=Path(html_path).name, file=html_path)] ) - # 如果配置了外链 Base URL,则也发送超链接 - base_url = self.config_manager.get_html_base_url() - if base_url: - filename = Path(html_path).name - url = f"{base_url.rstrip('/')}/{filename}" - link_message = f"报告已生成: {url}" - if not await adapter.send_text(group_id, link_message): - yield event.plain_result(link_message) + if caption: + yield event.plain_result(caption) else: yield event.plain_result("⚠️ HTML 生成失败。") diff --git a/requirements.txt b/requirements.txt index 27db176..2d64e91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ playwright>=1.40.0 -diskcache \ No newline at end of file +diskcache +ulid-py \ No newline at end of file diff --git a/src/infrastructure/messaging/message_sender.py b/src/infrastructure/messaging/message_sender.py index d1fa793..1ed0817 100644 --- a/src/infrastructure/messaging/message_sender.py +++ b/src/infrastructure/messaging/message_sender.py @@ -18,7 +18,7 @@ class MessageSender: self.retry_manager = retry_manager async def send_text( - self, group_id: str, text: str, platform_id: str = None + self, group_id: str, text: str, platform_id: str | None = None ) -> bool: """发送文本消息""" adapter = self.bot_manager.get_adapter(platform_id) @@ -28,7 +28,11 @@ class MessageSender: return await adapter.send_text(group_id, text) async def send_image_smart( - self, group_id: str, image_url: str, caption: str = "", platform_id: str = None + self, + group_id: str, + image_url: str, + caption: str = "", + platform_id: str | None = None, ) -> bool: """智能发送图片,支持自动选择适配器""" adapter = self.bot_manager.get_adapter(platform_id) @@ -37,15 +41,38 @@ class MessageSender: return False return await adapter.send_image(group_id, image_url, caption) - async def send_pdf( - self, group_id: str, pdf_path: str, caption: str = "", platform_id: str = None + async def send_file( + self, + group_id: str, + file_path: str, + caption: str = "", + platform_id: str | None = None, ) -> bool: - """发送 PDF 文件""" + """发送文件(HTML/PDF/其它文件)。支持可选 caption。""" adapter = self.bot_manager.get_adapter(platform_id) if not adapter: logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器") return False - return await adapter.send_file(group_id, pdf_path) + + # 首先发送文件,本方法的返回值只代表文件是否发送成功。 + file_sent = await adapter.send_file(group_id, file_path) + + if not file_sent: + # 适配器返回 False,表示文件未成功发送 + return False + + # 文件已成功发送,下面的 caption 发送为尽力而为,不影响整体成功与否。 + if caption: + try: + caption_sent = await adapter.send_text(group_id, f"{caption}") + if not caption_sent: + logger.warning( + "[MessageSender] 文件已发送,但 caption 发送失败(适配器返回 False)" + ) + except Exception as e: + logger.warning(f"[MessageSender] 文件已发送,但 caption 发送异常: {e}") + + return True def _get_available_platforms(self, group_id: str): """获取可用的平台列表 (Helper for Dispatcher)""" diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index 549cb48..fb82b4c 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -46,6 +46,8 @@ class ReportDispatcher: success = await self._dispatch_image(group_id, analysis_result, platform_id) elif output_format == "pdf": success = await self._dispatch_pdf(group_id, analysis_result, platform_id) + elif output_format == "html": + success = await self._dispatch_html(group_id, analysis_result, platform_id) else: success = await self._dispatch_text(group_id, analysis_result, platform_id) @@ -148,8 +150,11 @@ class ReportDispatcher: # 3. 发送 PDF if pdf_path: - sent = await self.message_sender.send_pdf( - group_id, pdf_path, "📊 每日群聊分析报告已生成:", platform_id + sent = await self.message_sender.send_file( + group_id, + pdf_path, + caption="📊 每日群聊分析报告已生成:", + platform_id=platform_id, ) if sent: return True @@ -160,6 +165,36 @@ class ReportDispatcher: ) return await self._dispatch_text(group_id, analysis_result, platform_id) + async def _dispatch_html( + self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None + ) -> bool: + trace_id = TraceContext.get() + + html_path = None + try: + html_path, json_path = await self.report_generator.generate_html_report( + analysis_result, group_id + ) + except Exception as e: + logger.error(f"[{trace_id}] Failed to generate HTML report: {e}") + + if html_path: + caption = self.report_generator.build_html_caption(html_path) + + sent = await self.message_sender.send_file( + group_id, + html_path, + caption=caption, + platform_id=platform_id, + ) + if sent: + return True + + logger.warning( + f"[{trace_id}] HTML dispatch failed, falling back to text report." + ) + 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: diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index ae85696..faa9ad9 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -12,8 +12,10 @@ from dataclasses import asdict, is_dataclass from datetime import date, datetime from enum import Enum from pathlib import Path +from urllib.parse import quote import aiohttp +import ulid from diskcache import Cache from markupsafe import Markup @@ -49,6 +51,77 @@ class ReportGenerator(IReportGenerator): ) self._avatar_session = None + @staticmethod + def _sanitize_path_component(name: str) -> str: + """消毒单个路径/文件名片段,禁止路径穿越和非法字符。""" + # 禁止空组件、相对路径控制符:"."、".." + if not name or name in {".", ".."}: + raise ValueError(f"无效的路径片段: {name!r}") + + # 不允许包含路径分隔符 + name = name.replace("/", "_") + name = name.replace("\\", "_") + + # 去除非打印字符和非法文件名字符 + name = re.sub(r'[\x00-\x1f<>:"|?*]', "_", name) + + # 保留中文、字母、数字、下划线、横线和点 + name = name.strip() + if not name: + raise ValueError("路径片段经过消毒后为空") + + return name + + def _build_safe_report_path( + self, + output_dir: Path, + filename_format: str, + group_id: str, + date: str, + ) -> Path: + """根据格式构建安全输出路径,支持子目录和 {ulid}。""" + generated_ulid = str(ulid.new()) + safe_context = { + "group_id": group_id, + "date": date, + "ulid": generated_ulid, + } + + try: + formatted = filename_format.format(**safe_context) + except Exception as e: + raise ValueError(f"文件名格式化失败: {e}") from e + + if os.path.isabs(formatted): + raise ValueError("文件名格式不得为绝对路径") + + relative_path = Path(formatted) + sanitized_parts = [] + for part in relative_path.parts: + if part in {".", ".."}: + raise ValueError("路径中不得包含 '.' 或 '..'。") + sanitized_parts.append(self._sanitize_path_component(part)) + + safe_relative = Path(*sanitized_parts) + + output_dir_resolved = output_dir.resolve(strict=False) + target_path = (output_dir_resolved / safe_relative).resolve(strict=False) + + # 防止回退到上级目录(使用 Path.relative_to 进行目录包含校验) + try: + target_path.relative_to(output_dir_resolved) + except ValueError: + raise ValueError("文件路径不在输出目录之内,可能包含路径穿越") + + # 防止与已有文件覆盖(如果用户格式没有唯一标记),追加 ULID 后缀 + if target_path.exists(): + suffix = target_path.suffix + stem = target_path.stem + target_path = target_path.with_name(f"{stem}_{generated_ulid}{suffix}") + + target_path.parent.mkdir(parents=True, exist_ok=True) + return target_path + async def generate_image_report( self, analysis_result: dict, @@ -221,12 +294,14 @@ class ReportGenerator(IReportGenerator): output_dir = Path(self.config_manager.get_pdf_output_dir()) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) - # 生成文件名 + # 生成文件路径,支持 {group_id}/{date}/{ulid} 自定义子目录 current_date = datetime.now().strftime("%Y%m%d") - filename = self.config_manager.get_pdf_filename_format().format( - group_id=group_id, date=current_date + pdf_path = self._build_safe_report_path( + output_dir, + self.config_manager.get_pdf_filename_format(), + group_id=group_id, + date=current_date, ) - pdf_path = output_dir / filename # 准备渲染数据 render_data = await self._prepare_render_data( @@ -287,19 +362,22 @@ class ReportGenerator(IReportGenerator): output_dir = Path(self.config_manager.get_html_output_dir()) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) - # 生成文件名 + # 生成文件路径 current_date = datetime.now().strftime("%Y%m%d") - current_time = datetime.now().strftime("%H%M%S") - html_filename = self.config_manager.get_html_filename_format().format( - group_id=group_id, date=current_date + base_html_path = self._build_safe_report_path( + output_dir, + self.config_manager.get_html_filename_format(), + group_id=group_id, + date=current_date, ) - # 为避免同一天多次分析覆盖,添加时间戳 - html_filename_base = html_filename.rsplit(".", 1)[0] - html_filename = f"{html_filename_base}_{current_time}.html" - json_filename = f"{html_filename_base}_{current_time}.json" - html_path = output_dir / html_filename - json_path = output_dir / json_filename + html_path = base_html_path + if not html_path.suffix: + html_path = html_path.with_suffix(".html") + + json_path = html_path.with_suffix(".json") + + html_path.parent.mkdir(parents=True, exist_ok=True) # 准备渲染数据 render_data = await self._prepare_render_data( @@ -378,6 +456,29 @@ class ReportGenerator(IReportGenerator): logger.error(f"生成 HTML 报告失败: {e}", exc_info=True) return None, None + def build_html_caption(self, html_path: str) -> str: + """根据 html_base_url 生成 HTML 报告链接 caption""" + + caption = "📊 每日群聊分析报告已生成" + base_url = self.config_manager.get_html_base_url() + if not base_url or not html_path: + return caption + + # 支持 html_filename_format 中的子目录,保持相对路径 + output_dir = Path(self.config_manager.get_html_output_dir()).resolve( + strict=False + ) + try: + relative_path = ( + Path(html_path).resolve(strict=False).relative_to(output_dir) + ) + relative_url = str(relative_path).replace(os.sep, "/") + except Exception: + relative_url = Path(html_path).name + + encoded_relative_url = quote(relative_url, safe="/") + return caption + f"\n{base_url.rstrip('/')}/{encoded_relative_url}" + def generate_text_report(self, analysis_result: dict) -> str: """生成文本格式的分析报告""" stats = analysis_result["statistics"] @@ -413,9 +514,9 @@ class ReportGenerator(IReportGenerator): report += "💬 群圣经\n" max_golden_quotes = self.config_manager.get_max_golden_quotes() - for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1): - report += f'{i}. "{quote.content}" —— {quote.sender}\n' - report += f" {quote.reason}\n\n" + for i, golden_quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1): + report += f'{i}. "{golden_quote.content}" —— {golden_quote.sender}\n' + report += f" {golden_quote.reason}\n\n" return report @@ -481,20 +582,22 @@ class ReportGenerator(IReportGenerator): # 使用Jinja2模板构建金句HTML(批量渲染) max_golden_quotes = self.config_manager.get_max_golden_quotes() quotes_list = [] - for quote in stats.golden_quotes[:max_golden_quotes]: + for golden_quote in stats.golden_quotes[:max_golden_quotes]: avatar_url = ( - await self._get_user_avatar(str(quote.user_id), avatar_url_getter) - if quote.user_id + await self._get_user_avatar( + str(golden_quote.user_id), avatar_url_getter + ) + if golden_quote.user_id else None ) # 处理解析锐评中的用户引用头像 processed_reason = await self._render_mentions( - quote.reason, avatar_url_getter, nickname_getter, user_analysis + golden_quote.reason, avatar_url_getter, nickname_getter, user_analysis ) quotes_list.append( { - "content": quote.content, - "sender": quote.sender, + "content": golden_quote.content, + "sender": golden_quote.sender, "reason": processed_reason, "avatar_url": avatar_url, }