feat: 檔案名稱模板支援 ULID 和資料夾路徑 (#146 @lekoOwO)

* 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 <sxp20061207@163.com>
This commit is contained in:
leko
2026-04-01 20:38:57 +08:00
committed by GitHub
co-authored by SXP-Simon
parent 1fa1ebe5ca
commit 8ba5f69788
6 changed files with 225 additions and 45 deletions
+4 -4
View File
@@ -356,8 +356,8 @@
"pdf_filename_format": { "pdf_filename_format": {
"type": "string", "type": "string",
"description": "PDF文件名格式", "description": "PDF文件名格式",
"default": "群聊分析报告_{group_id}_{date}.pdf", "default": "群聊分析报告_{group_id}_{date}_{ulid}.pdf",
"hint": "PDF文件名格式,支持变量:{group_id}(群号)、{date}(日期)" "hint": "PDF文件名格式,支持变量:{group_id}(群号)、{date}(日期)、{ulid}(时间排序ID),支持子目录/层级。"
} }
} }
}, },
@@ -381,8 +381,8 @@
"html_filename_format": { "html_filename_format": {
"type": "string", "type": "string",
"description": "HTML文件名格式", "description": "HTML文件名格式",
"default": "群聊分析报告_{group_id}_{date}.html", "default": "群聊分析报告_{group_id}_{date}_{ulid}.html",
"hint": "HTML文件名格式,支持变量:{group_id}(群号)、{date}(日期)" "hint": "HTML文件名格式,支持变量:{group_id}(群号)、{date}(日期)、{ulid}(时间排序ID),支持子目录/层级。"
} }
} }
}, },
+23 -9
View File
@@ -32,6 +32,7 @@ from .src.domain.services.incremental_merge_service import IncrementalMergeServi
from .src.domain.services.statistics_service import StatisticsService from .src.domain.services.statistics_service import StatisticsService
from .src.infrastructure.analysis.llm_analyzer import LLMAnalyzer from .src.infrastructure.analysis.llm_analyzer import LLMAnalyzer
from .src.infrastructure.config.config_manager import ConfigManager 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.history_manager import HistoryManager
from .src.infrastructure.persistence.incremental_store import IncrementalStore from .src.infrastructure.persistence.incremental_store import IncrementalStore
from .src.infrastructure.persistence.telegram_group_registry import ( from .src.infrastructure.persistence.telegram_group_registry import (
@@ -72,6 +73,7 @@ class GroupDailyAnalysis(Star):
template_preview_router: TemplatePreviewRouter template_preview_router: TemplatePreviewRouter
retry_manager: RetryManager retry_manager: RetryManager
auto_scheduler: AutoScheduler auto_scheduler: AutoScheduler
message_sender: MessageSender
def __init__(self, context: Context, config: AstrBotConfig): def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context) super().__init__(context)
@@ -133,6 +135,9 @@ class GroupDailyAnalysis(Star):
self.retry_manager = RetryManager( self.retry_manager = RetryManager(
self.bot_manager, self.html_render, self.report_generator 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.auto_scheduler = AutoScheduler(
self.config_manager, self.config_manager,
self.analysis_service, self.analysis_service,
@@ -638,20 +643,29 @@ class GroupDailyAnalysis(Star):
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
) )
if html_path: if html_path:
caption = self.report_generator.build_html_caption(html_path)
# 发送 HTML 文件 # 发送 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( yield event.chain_result(
[File(name=Path(html_path).name, file=html_path)] [File(name=Path(html_path).name, file=html_path)]
) )
# 如果配置了外链 Base URL,则也发送超链接 if caption:
base_url = self.config_manager.get_html_base_url() yield event.plain_result(caption)
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)
else: else:
yield event.plain_result("⚠️ HTML 生成失败。") yield event.plain_result("⚠️ HTML 生成失败。")
+2 -1
View File
@@ -1,2 +1,3 @@
playwright>=1.40.0 playwright>=1.40.0
diskcache diskcache
ulid-py
+33 -6
View File
@@ -18,7 +18,7 @@ class MessageSender:
self.retry_manager = retry_manager self.retry_manager = retry_manager
async def send_text( 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: ) -> bool:
"""发送文本消息""" """发送文本消息"""
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
@@ -28,7 +28,11 @@ class MessageSender:
return await adapter.send_text(group_id, text) return await adapter.send_text(group_id, text)
async def send_image_smart( 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: ) -> bool:
"""智能发送图片,支持自动选择适配器""" """智能发送图片,支持自动选择适配器"""
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
@@ -37,15 +41,38 @@ class MessageSender:
return False return False
return await adapter.send_image(group_id, image_url, caption) return await adapter.send_image(group_id, image_url, caption)
async def send_pdf( async def send_file(
self, group_id: str, pdf_path: str, caption: str = "", platform_id: str = None self,
group_id: str,
file_path: str,
caption: str = "",
platform_id: str | None = None,
) -> bool: ) -> bool:
"""发送 PDF 文件""" """发送文件(HTML/PDF/其它文件)。支持可选 caption。"""
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
if not adapter: if not adapter:
logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器") logger.error(f"[MessageSender] 未找到平台 {platform_id} 的适配器")
return False 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): def _get_available_platforms(self, group_id: str):
"""获取可用的平台列表 (Helper for Dispatcher)""" """获取可用的平台列表 (Helper for Dispatcher)"""
+37 -2
View File
@@ -46,6 +46,8 @@ class ReportDispatcher:
success = await self._dispatch_image(group_id, analysis_result, platform_id) success = await self._dispatch_image(group_id, analysis_result, platform_id)
elif output_format == "pdf": 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)
elif output_format == "html":
success = await self._dispatch_html(group_id, analysis_result, platform_id)
else: else:
success = await self._dispatch_text(group_id, analysis_result, platform_id) success = await self._dispatch_text(group_id, analysis_result, platform_id)
@@ -148,8 +150,11 @@ class ReportDispatcher:
# 3. 发送 PDF # 3. 发送 PDF
if pdf_path: if pdf_path:
sent = await self.message_sender.send_pdf( sent = await self.message_sender.send_file(
group_id, pdf_path, "📊 每日群聊分析报告已生成:", platform_id group_id,
pdf_path,
caption="📊 每日群聊分析报告已生成:",
platform_id=platform_id,
) )
if sent: if sent:
return True return True
@@ -160,6 +165,36 @@ class ReportDispatcher:
) )
return await self._dispatch_text(group_id, analysis_result, platform_id) return await self._dispatch_text(group_id, analysis_result, platform_id)
async def _dispatch_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( async def _dispatch_text(
self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None self, group_id: str, analysis_result: dict[str, Any], platform_id: str | None
) -> bool: ) -> bool:
+126 -23
View File
@@ -12,8 +12,10 @@ from dataclasses import asdict, is_dataclass
from datetime import date, datetime from datetime import date, datetime
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import aiohttp import aiohttp
import ulid
from diskcache import Cache from diskcache import Cache
from markupsafe import Markup from markupsafe import Markup
@@ -49,6 +51,77 @@ class ReportGenerator(IReportGenerator):
) )
self._avatar_session = None 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( async def generate_image_report(
self, self,
analysis_result: dict, analysis_result: dict,
@@ -221,12 +294,14 @@ class ReportGenerator(IReportGenerator):
output_dir = Path(self.config_manager.get_pdf_output_dir()) output_dir = Path(self.config_manager.get_pdf_output_dir())
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
# 生成文件 # 生成文件路径,支持 {group_id}/{date}/{ulid} 自定义子目录
current_date = datetime.now().strftime("%Y%m%d") current_date = datetime.now().strftime("%Y%m%d")
filename = self.config_manager.get_pdf_filename_format().format( pdf_path = self._build_safe_report_path(
group_id=group_id, date=current_date 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( render_data = await self._prepare_render_data(
@@ -287,19 +362,22 @@ class ReportGenerator(IReportGenerator):
output_dir = Path(self.config_manager.get_html_output_dir()) output_dir = Path(self.config_manager.get_html_output_dir())
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
# 生成文件 # 生成文件路径
current_date = datetime.now().strftime("%Y%m%d") current_date = datetime.now().strftime("%Y%m%d")
current_time = datetime.now().strftime("%H%M%S") base_html_path = self._build_safe_report_path(
html_filename = self.config_manager.get_html_filename_format().format( output_dir,
group_id=group_id, date=current_date 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 html_path = base_html_path
json_path = output_dir / json_filename 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( render_data = await self._prepare_render_data(
@@ -378,6 +456,29 @@ class ReportGenerator(IReportGenerator):
logger.error(f"生成 HTML 报告失败: {e}", exc_info=True) logger.error(f"生成 HTML 报告失败: {e}", exc_info=True)
return None, None 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: def generate_text_report(self, analysis_result: dict) -> str:
"""生成文本格式的分析报告""" """生成文本格式的分析报告"""
stats = analysis_result["statistics"] stats = analysis_result["statistics"]
@@ -413,9 +514,9 @@ class ReportGenerator(IReportGenerator):
report += "💬 群圣经\n" report += "💬 群圣经\n"
max_golden_quotes = self.config_manager.get_max_golden_quotes() max_golden_quotes = self.config_manager.get_max_golden_quotes()
for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1): for i, golden_quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1):
report += f'{i}. "{quote.content}" —— {quote.sender}\n' report += f'{i}. "{golden_quote.content}" —— {golden_quote.sender}\n'
report += f" {quote.reason}\n\n" report += f" {golden_quote.reason}\n\n"
return report return report
@@ -481,20 +582,22 @@ class ReportGenerator(IReportGenerator):
# 使用Jinja2模板构建金句HTML(批量渲染) # 使用Jinja2模板构建金句HTML(批量渲染)
max_golden_quotes = self.config_manager.get_max_golden_quotes() max_golden_quotes = self.config_manager.get_max_golden_quotes()
quotes_list = [] quotes_list = []
for quote in stats.golden_quotes[:max_golden_quotes]: for golden_quote in stats.golden_quotes[:max_golden_quotes]:
avatar_url = ( avatar_url = (
await self._get_user_avatar(str(quote.user_id), avatar_url_getter) await self._get_user_avatar(
if quote.user_id str(golden_quote.user_id), avatar_url_getter
)
if golden_quote.user_id
else None else None
) )
# 处理解析锐评中的用户引用头像 # 处理解析锐评中的用户引用头像
processed_reason = await self._render_mentions( 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( quotes_list.append(
{ {
"content": quote.content, "content": golden_quote.content,
"sender": quote.sender, "sender": golden_quote.sender,
"reason": processed_reason, "reason": processed_reason,
"avatar_url": avatar_url, "avatar_url": avatar_url,
} }