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