diff --git a/README.md b/README.md index 790b6f1d..81f1861e 100644 --- a/README.md +++ b/README.md @@ -292,19 +292,28 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)** **启用方法:** - 通过 `incremental_group_list_mode + incremental_group_list` 指定哪些群走增量模式,并根据需要调整分析间隔 (`incremental_interval_minutes`) 和消息阈值。 -## HTML 报告与自建外链 +## HTML 报告、自建外链与图床上传 如果你希望在群里发送的不是图片,而是一个可以点击跳转的精美网页链接,可以使用 HTML 格式输出。 ### 1. 配置流程 1. **设置输出格式**:在 `basic` 设置中将 `output_format` 改为 `html`。 2. **指定储存目录 (`html_output_dir`)**:设置 HTML 文件在服务器上的保存路径。留空则默认保存在插件数据目录。 -3. **配置外链基址 (`html_base_url`)**:这是关键。如果你使用 Nginx/Apache 等 Web 服务器将上述目录映射到了公网,请在这里填写访问的前缀(如 `https://report.example.com`)。 +3. **配置外链基址 (`html_base_url`)**:这是关键。如果你使用 Nginx/Apache 等 Web 服务器将上述目录映射到了公网,请在这里填写访问的前缀(如 `https://report.example.com`)。如果开启图床上传,则这里应填写图床站点根地址(如 `https://your-imgbed.example.com`),不要带 `/upload` 或 `/file`。 +4. **按需开启图床上传 (`html_upload_enabled`)**:开启后,插件会把生成好的 HTML 上传到图床;成功时直接发送图床外链,失败时自动回退为发送本地 HTML 文件。 +5. **配置图床 Token / 渠道**:`html_upload_token` 为上传鉴权 token,`html_upload_channel` 为可选上传渠道;渠道选 `default` 时不显式指定,让站点走默认渠道。若手动指定渠道,请确保该渠道已在你的图床实例中正确配置并可用。 ### 2. 工作原理 -- 机器人生成 HTML 报告并保存到本地目录。 -- 机器人根据文件名和 `html_base_url` 拼接成完整链接发送到群里。 -- **注意**:本插件**不提供** Web 服务器功能,你需要自行使用 Nginx 或 AstrBot 所在的服务器环境来实现静态文件的公网访问。 +- `html_upload_enabled = false`:机器人生成 HTML 报告并保存到本地目录,再根据文件名和 `html_base_url` 拼接链接,或直接发送本地 HTML 文件。 +- `html_upload_enabled = true`:机器人生成本地 HTML 后上传到图床,成功时直接发送图床外链,失败时回退为发送本地 HTML 文件。 +- **注意**:关闭图床上传时,本插件**不提供** Web 服务器功能,你需要自行使用 Nginx 或 AstrBot 所在的服务器环境来实现静态文件的公网访问。 + +
+图床兼容说明 + +当前 README 中提到的 HTML 图床上传流程,已按 [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 的上传接口进行适配与验证。配置 `html_base_url` 时请填写站点根地址,插件会自行请求 `/upload` 接口。 + +
## 人格设定 (Persona) diff --git a/_conf_schema.json b/_conf_schema.json index 9ad46f19..0181f4f1 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -364,7 +364,33 @@ "type": "string", "description": "外鏈 Base URL", "default": "", - "hint": "用于生成外链的 Base URL(可留空)。如果设置了此项,发送报告时会在消息中提供超连結 (${BASE_URL}/${FILENAME})。留空则不提供外链。" + "hint": "用于生成外链的 Base URL(可留空)。如果设置了此项,发送报告时会在消息中提供超连結 (${BASE_URL}/${FILENAME})。留空则不提供外链。如果开启图床上传,此处同时作为图床站点根地址使用,应填写站点根 URL(如 https://your-imgbed.example.com),不要带 /upload 或 /file。" + }, + "html_upload_enabled": { + "type": "bool", + "description": "上传 HTML 到图床", + "default": false, + "hint": "开启后,HTML 报告生成后会先上传到图床,成功时直接发送外链;失败则回退为发送本地 HTML 文件。" + }, + "html_upload_token": { + "type": "string", + "description": "图床上传 Token", + "default": "", + "hint": "图床 API Token。仅在开启“上传 HTML 到图床”后生效,插件会使用 Authorization: Bearer 方式上传。" + }, + "html_upload_channel": { + "type": "string", + "description": "图床上传渠道", + "options": [ + "default", + "telegram", + "cfr2", + "s3", + "discord", + "huggingface" + ], + "default": "default", + "hint": "图床上传渠道。请确保所选渠道已在图床中可用;不确定时用 default。" }, "html_output_dir": { "type": "string", diff --git a/main.py b/main.py index e5a16ca9..75679a1b 100644 --- a/main.py +++ b/main.py @@ -33,6 +33,7 @@ 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.reporting.html_publisher import HtmlReportPublisher 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 auto_scheduler: AutoScheduler message_sender: MessageSender + html_report_publisher: HtmlReportPublisher def __init__(self, context: Context, config: AstrBotConfig): super().__init__(context) @@ -144,6 +146,9 @@ def __init__(self, context: Context, config: AstrBotConfig): # 调度与发送 self.message_sender = MessageSender(self.bot_manager, self.config_manager) + self.html_report_publisher = HtmlReportPublisher( + self.config_manager, self.message_sender + ) self.auto_scheduler = AutoScheduler( self.config_manager, self.analysis_service, @@ -628,23 +633,14 @@ async def nickname_getter(user_id: str) -> str | None: nickname_getter=nickname_getter, ) if html_path: - caption = self.report_generator.build_html_caption(html_path) - - # 发送 HTML 文件 - 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) + sent = await self.html_report_publisher.publish( + group_id, + html_path, + platform_id=platform_id, + ) if not sent: + caption = self.html_report_publisher.build_caption(html_path) yield event.chain_result( [File(name=Path(html_path).name, file=html_path)] ) diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index e38671ff..77e6dc75 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -266,6 +266,18 @@ def get_html_base_url(self) -> str: """获取HTML外链Base URL""" return self._get_group("html").get("html_base_url", "") + def get_html_upload_enabled(self) -> bool: + """获取是否启用 HTML 图床上传""" + return bool(self._get_group("html").get("html_upload_enabled", False)) + + def get_html_upload_token(self) -> str: + """获取 HTML 图床上传 Token""" + return self._get_group("html").get("html_upload_token", "") + + def get_html_upload_channel(self) -> str: + """获取 HTML 图床上传渠道""" + return self._get_group("html").get("html_upload_channel", "default") + def get_html_filename_format(self) -> str: """获取HTML文件名格式""" return self._get_group("html").get( diff --git a/src/infrastructure/reporting/__init__.py b/src/infrastructure/reporting/__init__.py index 52a016aa..a25ad3e0 100644 --- a/src/infrastructure/reporting/__init__.py +++ b/src/infrastructure/reporting/__init__.py @@ -4,6 +4,7 @@ """ from .generators import ReportGenerator +from .html_publisher import HtmlReportPublisher from .templates import HTMLTemplates -__all__ = ["ReportGenerator", "HTMLTemplates"] +__all__ = ["ReportGenerator", "HTMLTemplates", "HtmlReportPublisher"] diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index 0326b784..7a9b9bfb 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -7,6 +7,7 @@ from ...shared.trace_context import TraceContext from ...utils.logger import logger +from .html_publisher import HtmlReportPublisher class ReportDispatcher: @@ -24,6 +25,7 @@ def __init__( self.config_manager = config_manager self.report_generator = report_generator self.message_sender = message_sender + self.html_report_publisher = HtmlReportPublisher(config_manager, message_sender) self._html_render_func: Callable | None = None def set_html_render(self, render_func: Callable): @@ -125,12 +127,9 @@ async def _dispatch_html( 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( + sent = await self.html_report_publisher.publish( group_id, html_path, - caption=caption, platform_id=platform_id, ) if sent: diff --git a/src/infrastructure/reporting/html_publisher.py b/src/infrastructure/reporting/html_publisher.py new file mode 100644 index 00000000..b29a1721 --- /dev/null +++ b/src/infrastructure/reporting/html_publisher.py @@ -0,0 +1,221 @@ +""" +HTML 报告发布器 +负责上传 HTML 报告到图床,或按现有逻辑发送本地文件/外链。 +""" + +import json +import os +from pathlib import Path +from urllib.parse import quote, urlsplit + +import aiohttp + +from ...utils.logger import logger + + +class HtmlReportPublisher: + """HTML 报告发布器""" + + def __init__(self, config_manager, message_sender): + self.config_manager = config_manager + self.message_sender = message_sender + + def build_caption( + self, + html_path: str | None = None, + public_url: str | None = None, + ) -> str: + """构建 HTML 报告提示文本。""" + caption = "📊 每日群聊分析报告已生成" + final_url = public_url + if not final_url and not self.config_manager.get_html_upload_enabled(): + final_url = self._build_self_hosted_url(html_path) + if final_url: + return f"{caption}\n{final_url}" + return caption + + async def publish( + self, + group_id: str, + html_path: str, + platform_id: str | None = None, + ) -> bool: + """发布 HTML 报告。""" + if not html_path: + return False + + public_url = None + if self.config_manager.get_html_upload_enabled(): + public_url = await self.upload(html_path) + if public_url: + sent = await self.message_sender.send_text( + group_id, + self.build_caption(public_url=public_url), + platform_id, + ) + if sent: + return True + logger.warning("HTML 图床外链发送失败,回退为发送本地 HTML 文件。") + else: + logger.warning("HTML 图床上传失败,回退为发送本地 HTML 文件。") + + return await self.message_sender.send_file( + group_id, + html_path, + caption=self.build_caption(html_path=html_path, public_url=public_url), + platform_id=platform_id, + ) + + async def upload(self, html_path: str) -> str | None: + """上传 HTML 报告到图床并返回最终外链。""" + base_url = self._normalized_base_url() + token = str(self.config_manager.get_html_upload_token() or "").strip() + if not base_url: + logger.warning("HTML 图床上传已启用,但未配置 html_base_url。") + return None + if not token: + logger.warning("HTML 图床上传已启用,但未配置 html_upload_token。") + return None + + upload_url = f"{base_url}/upload" + params = { + "returnFormat": "default", + "uploadNameType": "origin", + } + channel = str( + self.config_manager.get_html_upload_channel() or "default" + ).strip() + if channel and channel != "default": + params["uploadChannel"] = channel + + timeout = aiohttp.ClientTimeout(total=60) + headers = {"Authorization": f"Bearer {token}"} + file_name = Path(html_path).name + + try: + async with aiohttp.ClientSession( + trust_env=True, timeout=timeout + ) as session: + with open(html_path, "rb") as file_obj: + form = aiohttp.FormData() + form.add_field( + "file", + file_obj, + filename=file_name, + content_type="text/html", + ) + async with session.post( + upload_url, + params=params, + headers=headers, + data=form, + ) as response: + response_text = await response.text() + if response.status >= 400: + logger.warning( + "HTML 图床上传失败: status=%s body=%s", + response.status, + response_text[:300], + ) + return None + + try: + payload = json.loads(response_text) + except json.JSONDecodeError: + logger.warning( + "HTML 图床上传返回了非 JSON 响应: %s", + response_text[:300], + ) + return None + + except FileNotFoundError: + logger.warning("HTML 图床上传失败,本地文件不存在: %s", html_path) + return None + except Exception: + logger.exception("HTML 图床上传异常") + return None + + src = self._extract_src(payload) + if not src: + logger.warning("HTML 图床上传响应中缺少 src 字段: %s", payload) + return None + + final_url = self._build_public_url(src) + if not final_url: + logger.warning("HTML 图床上传成功,但无法构建最终外链: %s", src) + return None + + return final_url + + def _normalized_base_url(self) -> str: + base_url = str(self.config_manager.get_html_base_url() or "").strip() + return base_url.rstrip("/") + + def _build_self_hosted_url(self, html_path: str | None) -> str | None: + if not html_path: + return None + + base_url = self._normalized_base_url() + if not base_url: + return None + + 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 f"{base_url}/{encoded_relative_url.lstrip('/')}" + + def _build_public_url(self, src: str) -> str | None: + base_url = self._normalized_base_url() + if not base_url or not src: + return None + + src = str(src).strip() + if src.startswith(("http://", "https://")): + parsed = urlsplit(src) + relative = parsed.path or "/" + if parsed.query: + relative = f"{relative}?{parsed.query}" + if parsed.fragment: + relative = f"{relative}#{parsed.fragment}" + return ( + f"{base_url}{relative if relative.startswith('/') else '/' + relative}" + ) + + return f"{base_url}/{src.lstrip('/')}" + + @staticmethod + def _extract_src(payload) -> str | None: + if isinstance(payload, list) and payload: + first = payload[0] + if isinstance(first, dict): + src = first.get("src") + if isinstance(src, str) and src.strip(): + return src.strip() + + if isinstance(payload, dict): + src = payload.get("src") + if isinstance(src, str) and src.strip(): + return src.strip() + + data = payload.get("data") + if isinstance(data, list) and data: + first = data[0] + if isinstance(first, dict): + src = first.get("src") + if isinstance(src, str) and src.strip(): + return src.strip() + elif isinstance(data, dict): + src = data.get("src") + if isinstance(src, str) and src.strip(): + return src.strip() + + return None