feat: 增强图片上传能力并完成核心架构类型重构

- 重构: 优化主类命名逻辑,提升多平台架构的可读性与通用性。
- 功能: 新增分析报告同步上传至群文件和群相册的功能,支持自动化文件管理与清理。
- 类型: 全面修正静态类型错误,确保各分析器模块与基类接口签名严格对齐。
- 优化: 改进内部类成员标注,解决可选参数与类型推断在透传过程中的不匹配问题。
- 稳健性: 完善网络请求超时处理、临时资源管理以及可选依赖项的动态加载逻辑。
- 配置: 扩展配置管理项,支持自定义上传开关、目标目录以及 PDF 渲染参数。
This commit is contained in:
SXP-Simon
2026-02-21 19:30:51 +08:00
committed by Helian Nuits
parent dd69a68b0f
commit de3c189d4f
15 changed files with 658 additions and 92 deletions
+31
View File
@@ -321,6 +321,37 @@
}
}
},
"qq_group_upload": {
"description": "群文件/群相册上传设置",
"type": "object",
"hint": "图片报告生成后自动上传到群文件目录或群相册。群文件上传基于 OneBot upload_group_file API,群相册上传为 NapCat 扩展 API。仅对 QQ(OneBot)平台的图片格式报告生效",
"items": {
"enable_group_file_upload": {
"type": "bool",
"description": "启用群文件上传",
"default": false,
"hint": "图片报告生成后,自动保存为 PNG 文件并上传到群文件目录。需要 OneBot 实现支持 upload_group_file API"
},
"group_file_folder": {
"type": "string",
"description": "群文件上传目录名",
"default": "",
"hint": "上传到群文件中的子目录名称。留空则上传到根目录。如果指定的目录不存在,插件将尝试自动创建"
},
"enable_group_album_upload": {
"type": "bool",
"description": "启用群相册上传(仅 NapCat",
"default": false,
"hint": "图片报告生成后,自动上传到群相册。此功能为 NapCat 扩展 API,其他 OneBot 实现不支持"
},
"group_album_name": {
"type": "string",
"description": "目标群相册名称",
"default": "",
"hint": "指定上传到哪个群相册。留空则上传到群默认相册。如果指定的相册不存在,将回退到默认相册"
}
}
},
"prompts": {
"description": "提示词模板",
"type": "object",
+118 -11
View File
@@ -44,8 +44,28 @@ from .src.infrastructure.scheduler.retry import RetryManager
from .src.utils.pdf_utils import PDFInstaller
class QQGroupDailyAnalysis(Star):
"""QQ群日常分析插件主类"""
class GroupDailyAnalysis(Star):
"""分析插件主类"""
# ── 显式类型声明(消除 Pylance Optional 推断) ──
config: AstrBotConfig
config_manager: ConfigManager
bot_manager: BotManager
history_manager: HistoryManager
report_generator: ReportGenerator
telegram_group_registry: TelegramGroupRegistry
statistics_service: StatisticsService
analysis_domain_service: AnalysisDomainService
llm_analyzer: LLMAnalyzer
incremental_store: IncrementalStore
incremental_merge_service: IncrementalMergeService
analysis_service: AnalysisApplicationService
message_processing_service: MessageProcessingService
template_command_service: TemplateCommandService
telegram_template_preview_handler: TelegramTemplatePreviewHandler
template_preview_router: TemplatePreviewRouter
retry_manager: RetryManager
auto_scheduler: AutoScheduler
def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context)
@@ -188,15 +208,15 @@ class QQGroupDailyAnalysis(Star):
if self.template_preview_router:
await self.template_preview_router.unregister_handlers()
# 重置实例属性
self.auto_scheduler = None
self.bot_manager = None
self.report_generator = None
self.config_manager = None
self.message_processing_service = None
self.telegram_group_registry = None
self.template_preview_router = None
self.telegram_template_preview_handler = None
# 释放实例属性引用(插件卸载后不再使用)
del self.auto_scheduler
del self.bot_manager
del self.report_generator
del self.config_manager
del self.message_processing_service
del self.telegram_group_registry
del self.template_preview_router
del self.telegram_template_preview_handler
logger.info("QQ群日常分析插件资源清理完成")
@@ -250,6 +270,90 @@ class QQGroupDailyAnalysis(Star):
return event.platform_meta.id
return "default"
# ================================================================
# 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式)
# ================================================================
async def _try_upload_image(self, group_id: str, image_url: str, platform_id: str):
"""
尝试将图片报告上传到群文件和/或群相册(静默处理,失败仅日志提示)。
"""
import base64
import tempfile
from datetime import datetime
enable_file = self.config_manager.get_enable_group_file_upload()
enable_album = self.config_manager.get_enable_group_album_upload()
if not enable_file and not enable_album:
return
adapter = self.bot_manager.get_adapter(platform_id)
if not adapter or not hasattr(adapter, "upload_group_file_to_folder"):
return
# 将图片保存为临时文件
image_file = None
created_temp = False
try:
if image_url.startswith("base64://"):
data = base64.b64decode(image_url[len("base64://") :])
elif image_url.startswith("data:"):
parts = image_url.split(",", 1)
data = base64.b64decode(parts[1]) if len(parts) == 2 else None
elif os.path.isfile(image_url):
image_file = os.path.abspath(image_url)
data = None
else:
return
if data and not image_file:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
image_file = os.path.join(
tempfile.gettempdir(), f"群聊分析报告_{group_id}_{ts}.png"
)
with open(image_file, "wb") as f:
f.write(data)
created_temp = True
if not image_file:
return
if enable_file:
try:
folder_name = self.config_manager.get_group_file_folder()
folder_id = None
if folder_name:
folder_id = await adapter.find_or_create_folder( # type: ignore[attr-defined]
group_id, folder_name
)
await adapter.upload_group_file_to_folder( # type: ignore[attr-defined]
group_id=group_id,
file_path=image_file,
folder_id=folder_id,
)
except Exception as e:
logger.warning(f"群文件上传失败 (群 {group_id}): {e}")
if enable_album and hasattr(adapter, "upload_group_album"):
try:
album_name = self.config_manager.get_group_album_name()
album_id = None
if album_name and hasattr(adapter, "find_album_id"):
album_id = await adapter.find_album_id(group_id, album_name) # type: ignore[attr-defined]
await adapter.upload_group_album( # type: ignore[attr-defined]
group_id, image_file, album_id=album_id
)
except Exception as e:
logger.warning(f"群相册上传失败 (群 {group_id}): {e}")
except Exception as e:
logger.warning(f"图片上传处理异常: {e}")
finally:
if created_temp and image_file and os.path.exists(image_file):
try:
os.remove(image_file)
except OSError:
pass
@filter.command("群分析", alias={"group_analysis"})
@filter.permission_type(PermissionType.ADMIN)
async def analyze_group_daily(
@@ -335,6 +439,8 @@ class QQGroupDailyAnalysis(Star):
if image_url:
if not await adapter.send_image(group_id, image_url):
yield event.image_result(image_url)
# 上传到群文件/群相册
await self._try_upload_image(group_id, image_url, platform_id)
elif html_content:
yield event.plain_result("⚠️ 图片生成暂不可用,已尝试加入队列。")
await self.retry_manager.add_task(
@@ -456,6 +562,7 @@ class QQGroupDailyAnalysis(Star):
if parse_error:
yield event.plain_result(parse_error)
return
assert template_name is not None
if not await self.template_command_service.template_exists(template_name):
yield event.plain_result(f"❌ 模板 '{template_name}' 不存在")
+13 -7
View File
@@ -15,7 +15,10 @@ class IAnalysisProvider(ABC):
@abstractmethod
async def analyze_topics(
self, messages: list[dict], umo: str = None, session_id: str = None
self,
messages: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[list[SummaryTopic], TokenUsage]:
"""分析话题"""
pass
@@ -25,16 +28,19 @@ class IAnalysisProvider(ABC):
self,
messages: list[dict],
user_activity: dict,
umo: str = None,
top_users: list[dict] = None,
session_id: str = None,
umo: str | None = None,
top_users: list[dict] | None = None,
session_id: str | None = None,
) -> tuple[list[UserTitle], TokenUsage]:
"""分析用户称号"""
pass
@abstractmethod
async def analyze_golden_quotes(
self, messages: list[dict], umo: str = None, session_id: str = None
self,
messages: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[list[GoldenQuote], TokenUsage]:
"""分析金句"""
pass
@@ -44,8 +50,8 @@ class IAnalysisProvider(ABC):
self,
messages: list[dict],
user_activity: dict,
umo: str = None,
top_users: list[dict] = None,
umo: str | None = None,
top_users: list[dict] | None = None,
) -> tuple[list[SummaryTopic], list[UserTitle], list[GoldenQuote], TokenUsage]:
"""并发分析所有内容"""
pass
+6 -1
View File
@@ -19,13 +19,18 @@ class IReportGenerator(ABC):
group_id: str,
html_render_func: Any,
avatar_getter: Any = None,
nickname_getter: Any = None,
) -> tuple[str | None, str | None]:
"""生成图片报告"""
pass
@abstractmethod
async def generate_pdf_report(
self, analysis_result: dict, group_id: str, avatar_getter: Any = None
self,
analysis_result: dict,
group_id: str,
avatar_getter: Any = None,
nickname_getter: Any = None,
) -> str | None:
"""生成 PDF 报告"""
pass
@@ -35,7 +35,7 @@ class BaseAnalyzer(ABC):
# 增量分析模式下的最大数量覆盖值,为 None 时使用配置默认值
self._incremental_max_count: int | None = None
def get_provider_id_key(self) -> str:
def get_provider_id_key(self) -> str | None:
"""
获取 Provider ID 配置键名
子类可重写以指定特定的 provider,默认返回 None(使用主 LLM Provider
@@ -139,7 +139,7 @@ class BaseAnalyzer(ABC):
logger.error(f"保存调试数据失败: {e}", exc_info=True)
async def analyze(
self, data: Any, umo: str = None, session_id: str = None
self, data: Any, umo: str | None = None, session_id: str | None = None
) -> tuple[list[Any], TokenUsage]:
"""
统一的分析流程
@@ -40,7 +40,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
"""获取温度参数"""
return 0.7
def build_prompt(self, messages: list[dict]) -> str:
def build_prompt(self, data: list[dict]) -> str:
"""
构建金句分析提示词
@@ -50,12 +50,12 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
Returns:
提示词字符串
"""
if not messages:
if not data:
return ""
# 构建消息文本
messages_text = "\n".join(
[f"[{msg['time']}] {msg['sender']}: {msg['content']}" for msg in messages]
[f"[{msg['time']}] {msg['sender']}: {msg['content']}" for msg in data]
)
max_golden_quotes = self.get_max_count()
@@ -92,7 +92,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
"""
return extract_golden_quotes_with_regex(result_text, max_count)
def create_data_objects(self, quotes_data: list[dict]) -> list[GoldenQuote]:
def create_data_objects(self, data_list: list[dict]) -> list[GoldenQuote]:
"""
创建金句对象列表
@@ -106,7 +106,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
quotes = []
max_quotes = self.get_max_count()
for quote_data in quotes_data[:max_quotes]:
for quote_data in data_list[:max_quotes]:
# 确保数据格式正确
content = quote_data.get("content", "").strip()
sender = quote_data.get("sender", "").strip()
@@ -128,7 +128,10 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
return []
async def analyze_golden_quotes(
self, messages: list[dict], umo: str = None, session_id: str = None
self,
messages: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[list[GoldenQuote], TokenUsage]:
"""
分析群聊金句
@@ -41,7 +41,7 @@ class TopicAnalyzer(BaseAnalyzer):
"""获取温度参数"""
return 0.6
def build_prompt(self, messages: list[dict]) -> str:
def build_prompt(self, data: list[dict]) -> str:
"""
构建话题分析提示词
@@ -52,18 +52,18 @@ class TopicAnalyzer(BaseAnalyzer):
提示词字符串
"""
# 验证输入数据格式
if not isinstance(messages, list):
logger.error(f"build_prompt 期望列表,但收到: {type(messages)}")
if not isinstance(data, list):
logger.error(f"build_prompt 期望列表,但收到: {type(data)}")
return ""
# 检查消息列表是否为空
if not messages:
if not data:
logger.warning("build_prompt 收到空消息列表")
return ""
# 提取文本消息
text_messages = []
for i, msg in enumerate(messages):
for i, msg in enumerate(data):
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误
if not isinstance(msg, dict):
continue
@@ -177,20 +177,20 @@ class TopicAnalyzer(BaseAnalyzer):
logger.warning("未找到有效的话题分析提示词配置,请检查配置文件")
return ""
def extract_with_regex(self, result_text: str, max_topics: int) -> list[dict]:
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
"""
使用正则表达式提取话题信息
Args:
result_text: LLM响应文本
max_topics: 最大话题数量
max_count: 最大话题数量
Returns:
话题数据列表
"""
return extract_topics_with_regex(result_text, max_topics)
return extract_topics_with_regex(result_text, max_count)
def create_data_objects(self, topics_data: list[dict]) -> list[SummaryTopic]:
def create_data_objects(self, data_list: list[dict]) -> list[SummaryTopic]:
"""
创建话题对象列表
@@ -201,9 +201,9 @@ class TopicAnalyzer(BaseAnalyzer):
SummaryTopic对象列表
"""
logger.debug(
f"create_data_objects 开始处理,输入数据数量: {len(topics_data) if topics_data else 0}"
f"create_data_objects 开始处理,输入数据数量: {len(data_list) if data_list else 0}"
)
logger.debug(f"输入数据类型: {type(topics_data)}")
logger.debug(f"输入数据类型: {type(data_list)}")
try:
topics = []
@@ -211,7 +211,7 @@ class TopicAnalyzer(BaseAnalyzer):
logger.debug(f"处理前 {max_topics} 条话题数据")
for i, topic_data in enumerate(topics_data[:max_topics]):
for i, topic_data in enumerate(data_list[:max_topics]):
logger.debug(f"处理第 {i + 1} 条话题数据,类型: {type(topic_data)}")
# 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误
@@ -301,7 +301,10 @@ class TopicAnalyzer(BaseAnalyzer):
return text_messages
async def analyze_topics(
self, messages: list[dict], umo: str = None, session_id: str = None
self,
messages: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[list[SummaryTopic], TokenUsage]:
"""
分析群聊话题
@@ -35,7 +35,7 @@ class UserTitleAnalyzer(BaseAnalyzer):
"""获取温度参数"""
return 0.5
def build_prompt(self, user_data: dict) -> str:
def build_prompt(self, data: dict) -> str:
"""
构建用户称号分析提示词
@@ -45,7 +45,7 @@ class UserTitleAnalyzer(BaseAnalyzer):
Returns:
提示词字符串
"""
user_summaries = user_data.get("user_summaries", [])
user_summaries = data.get("user_summaries", [])
if not user_summaries:
return ""
@@ -91,7 +91,7 @@ class UserTitleAnalyzer(BaseAnalyzer):
"""
return extract_user_titles_with_regex(result_text, max_count)
def create_data_objects(self, titles_data: list[dict]) -> list[UserTitle]:
def create_data_objects(self, data_list: list[dict]) -> list[UserTitle]:
"""
创建用户称号对象列表
@@ -105,7 +105,7 @@ class UserTitleAnalyzer(BaseAnalyzer):
titles = []
max_titles = self.get_max_count()
for title_data in titles_data[:max_titles]:
for title_data in data_list[:max_titles]:
# 确保数据格式正确
name = title_data.get("name", "").strip()
user_id = title_data.get("user_id")
@@ -142,7 +142,10 @@ class UserTitleAnalyzer(BaseAnalyzer):
return []
def prepare_user_data(
self, messages: list[dict], user_analysis: dict, top_users: list[dict] = None
self,
messages: list[dict],
user_analysis: dict,
top_users: list[dict] | None = None,
) -> dict:
"""
准备用户数据
@@ -235,10 +238,10 @@ class UserTitleAnalyzer(BaseAnalyzer):
async def analyze_user_titles(
self,
messages: list[dict],
user_analysis: dict,
umo: str = None,
top_users: list[dict] = None,
session_id: str = None,
user_activity: dict,
umo: str | None = None,
top_users: list[dict] | None = None,
session_id: str | None = None,
) -> tuple[list[UserTitle], TokenUsage]:
"""
分析用户称号
@@ -255,7 +258,7 @@ class UserTitleAnalyzer(BaseAnalyzer):
"""
try:
# 准备用户数据,传入活跃用户列表
user_data = self.prepare_user_data(messages, user_analysis, top_users)
user_data = self.prepare_user_data(messages, user_activity, top_users)
if not user_data["user_summaries"]:
logger.info("没有符合条件的用户,返回空结果")
+33 -22
View File
@@ -11,6 +11,7 @@ from ...domain.models.data_models import (
TokenUsage,
UserTitle,
)
from ...domain.repositories.analysis_repository import IAnalysisProvider
from ...utils.logger import logger
from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer
from .analyzers.topic_analyzer import TopicAnalyzer
@@ -19,13 +20,17 @@ from .utils.json_utils import fix_json
from .utils.llm_utils import call_provider_with_retry
class LLMAnalyzer:
class LLMAnalyzer(IAnalysisProvider):
"""
LLM分析器
作为统一入口,协调各个专门的分析器进行不同类型的分析
保持向后兼容性,提供原有的接口
"""
topic_analyzer: TopicAnalyzer
user_title_analyzer: UserTitleAnalyzer
golden_quote_analyzer: GoldenQuoteAnalyzer
def __init__(self, context, config_manager):
"""
初始化LLM分析器
@@ -43,7 +48,10 @@ class LLMAnalyzer:
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
async def analyze_topics(
self, messages: list[dict], umo: str = None, session_id: str = None
self,
messages: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[list[SummaryTopic], TokenUsage]:
"""
使用LLM分析话题
@@ -78,10 +86,10 @@ class LLMAnalyzer:
async def analyze_user_titles(
self,
messages: list[dict],
user_analysis: dict,
umo: str = None,
top_users: list[dict] = None,
session_id: str = None,
user_activity: dict,
umo: str | None = None,
top_users: list[dict] | None = None,
session_id: str | None = None,
) -> tuple[list[UserTitle], TokenUsage]:
"""
使用LLM分析用户称号
@@ -89,7 +97,7 @@ class LLMAnalyzer:
Args:
messages: 群聊消息列表
user_analysis: 用户分析统计
user_activity: 用户分析统计
umo: 模型唯一标识符
top_users: 活跃用户列表(可选)
session_id: 会话ID (用于调试模式)
@@ -110,14 +118,17 @@ class LLMAnalyzer:
logger.info(f"开始用户称号分析, session_id: {session_id}")
return await self.user_title_analyzer.analyze_user_titles(
messages, user_analysis, umo, top_users, session_id
messages, user_activity, umo, top_users, session_id
)
except Exception as e:
logger.error(f"用户称号分析失败: {e}")
return [], TokenUsage()
async def analyze_golden_quotes(
self, messages: list[dict], umo: str = None, session_id: str = None
self,
messages: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[list[GoldenQuote], TokenUsage]:
"""
使用LLM分析群聊金句
@@ -153,9 +164,9 @@ class LLMAnalyzer:
async def analyze_all_concurrent(
self,
messages: list[dict],
user_analysis: dict,
umo: str = None,
top_users: list[dict] = None,
user_activity: dict,
umo: str | None = None,
top_users: list[dict] | None = None,
topic_enabled: bool = True,
user_title_enabled: bool = True,
golden_quote_enabled: bool = True,
@@ -165,7 +176,7 @@ class LLMAnalyzer:
Args:
messages: 群聊消息列表
user_analysis: 用户分析统计
user_activity: 用户分析统计
umo: 模型唯一标识符
top_users: 活跃用户列表(可选)
topic_enabled: 是否启用话题分析
@@ -206,7 +217,7 @@ class LLMAnalyzer:
if user_title_enabled:
tasks.append(
self.user_title_analyzer.analyze_user_titles(
messages, user_analysis, umo, top_users, session_id
messages, user_activity, umo, top_users, session_id
)
)
task_names.append("user_title")
@@ -235,11 +246,11 @@ class LLMAnalyzer:
logger.error(f"分析任务 {name} 失败: {result}")
continue
if name == "topic":
if name == "topic" and isinstance(result, tuple):
topics, topic_usage = result
elif name == "user_title":
elif name == "user_title" and isinstance(result, tuple):
user_titles, title_usage = result
elif name == "golden_quote":
elif name == "golden_quote" and isinstance(result, tuple):
golden_quotes, quote_usage = result
# 合并Token使用统计
@@ -267,7 +278,7 @@ class LLMAnalyzer:
async def analyze_incremental_concurrent(
self,
messages: list[dict],
umo: str = None,
umo: str | None = None,
topics_per_batch: int = 3,
quotes_per_batch: int = 3,
topic_enabled: bool = True,
@@ -346,9 +357,9 @@ class LLMAnalyzer:
logger.error(f"增量{name}分析失败: {result}")
continue
if name == "topic":
if name == "topic" and isinstance(result, tuple):
topics, topic_usage = result
elif name == "golden_quote":
elif name == "golden_quote" and isinstance(result, tuple):
golden_quotes, quote_usage = result
# 合并Token使用统计
@@ -411,8 +422,8 @@ class LLMAnalyzer:
prompt: str,
max_tokens: int,
temperature: float,
umo: str = None,
provider_id_key: str = None,
umo: str | None = None,
provider_id_key: str | None = None,
):
"""
向后兼容的LLM调用方法
@@ -48,7 +48,7 @@ async def _try_get_provider_id_by_id(
return None
async def _try_get_session_provider_id(context, umo: str) -> str | None:
async def _try_get_session_provider_id(context, umo: str | None) -> str | None:
"""
尝试获取会话 Provider ID 的辅助函数
@@ -97,7 +97,10 @@ async def _try_get_first_available_provider_id(context) -> str | None:
async def get_provider_id_with_fallback(
context, config_manager, provider_id_key: str, umo: str = None
context,
config_manager,
provider_id_key: str | None,
umo: str | None = None,
) -> str | None:
"""
根据配置键获取 Provider ID,支持多级回退
@@ -183,8 +186,8 @@ async def call_provider_with_retry(
prompt: str,
max_tokens: int,
temperature: float,
umo: str = None,
provider_id_key: str = None,
umo: str | None = None,
provider_id_key: str | None = None,
) -> Any | None:
"""
调用LLM提供者,带超时、重试与退避。支持自定义服务商和配置化 Provider 选择。
+22 -1
View File
@@ -4,6 +4,7 @@
"""
import sys
from pathlib import Path
from astrbot.api import AstrBotConfig, logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -217,7 +218,7 @@ class ConfigManager:
"""获取PDF输出目录"""
try:
plugin_name = "astrbot_plugin_qq_group_daily_analysis"
data_path = get_astrbot_data_path()
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))
except Exception:
@@ -398,6 +399,26 @@ class ConfigManager:
"""获取是否使用用户群名片"""
return self._get_group("basic").get("enable_user_card", False)
# ========== 群文件/群相册上传配置 ==========
def get_enable_group_file_upload(self) -> bool:
"""获取是否启用群文件上传"""
return self._get_group("qq_group_upload").get("enable_group_file_upload", False)
def get_group_file_folder(self) -> str:
"""获取群文件上传目录名,空字符串表示根目录"""
return self._get_group("qq_group_upload").get("group_file_folder", "")
def get_enable_group_album_upload(self) -> bool:
"""获取是否启用群相册上传(仅 NapCat)"""
return self._get_group("qq_group_upload").get(
"enable_group_album_upload", False
)
def get_group_album_name(self) -> str:
"""获取目标群相册名称,空字符串表示默认相册"""
return self._get_group("qq_group_upload").get("group_album_name", "")
# ========== 增量分析配置 ==========
def get_incremental_enabled(self) -> bool:
@@ -604,3 +604,260 @@ class OneBotAdapter(PlatformAdapter):
user_id: await self.get_user_avatar_url(user_id, size)
for user_id in user_ids
}
# ================================================================
# 群文件 / 群相册上传
# ================================================================
async def upload_group_file_to_folder(
self,
group_id: str,
file_path: str,
filename: str | None = None,
folder_id: str | None = None,
) -> bool:
"""
上传文件到群文件目录的指定子文件夹。
Args:
group_id: 目标群号
file_path: 本地文件绝对路径
filename: 显示的文件名,默认为路径尾部
folder_id: 目标文件夹 ID(由 get_group_file_root_folders 获取)。
为 None 或空字符串时上传到根目录。
Returns:
bool: 上传任务是否成功启动
"""
try:
params = {
"group_id": int(group_id),
"file": file_path,
"name": filename or file_path.replace("\\", "/").split("/")[-1],
}
if folder_id:
params["folder"] = folder_id
await self.bot.call_action("upload_group_file", **params)
logger.info(
f"OneBot 群文件上传成功: {params['name']} -> 群 {group_id}"
+ (f" (目录: {folder_id})" if folder_id else " (根目录)")
)
return True
except Exception as e:
logger.error(f"OneBot 群文件上传失败: {e}")
return False
async def create_group_file_folder(
self,
group_id: str,
folder_name: str,
) -> str | None:
"""
在群文件根目录下创建子文件夹。
Args:
group_id: 目标群号
folder_name: 文件夹名称
Returns:
str | None: 创建成功时返回 folder_id,失败返回 None
"""
try:
result = await self.bot.call_action(
"create_group_file_folder",
group_id=int(group_id),
name=folder_name,
parent_id="/",
)
# go-cqhttp 等实现可能不返回 folder_id
folder_id = None
if isinstance(result, dict):
folder_id = result.get("folder_id") or result.get("id")
logger.info(
f"OneBot 群文件夹创建成功: {folder_name} (群 {group_id})"
+ (f" [ID: {folder_id}]" if folder_id else "")
)
return folder_id
except Exception as e:
error_msg = str(e).lower()
# 文件夹已存在的情况不视为错误
if "exist" in error_msg or "已存在" in error_msg:
logger.info(f"OneBot 群文件夹已存在: {folder_name} (群 {group_id})")
return None # 需要通过 get_group_file_root_folders 获取 ID
logger.error(f"OneBot 群文件夹创建失败: {e}")
return None
async def get_group_file_root_folders(
self,
group_id: str,
) -> list[dict]:
"""
获取群文件根目录下的文件夹列表。
Args:
group_id: 目标群号
Returns:
list[dict]: 文件夹列表,每项包含 folder_id/name 等字段。
API 不可用时返回空列表。
"""
try:
result = await self.bot.call_action(
"get_group_root_files",
group_id=int(group_id),
)
if isinstance(result, dict):
return result.get("folders", []) or []
return []
except Exception as e:
logger.debug(f"OneBot 获取群文件夹列表失败: {e}")
return []
async def find_or_create_folder(
self,
group_id: str,
folder_name: str,
) -> str | None:
"""
查找或创建指定名称的群文件子文件夹,返回 folder_id。
先尝试在现有根目录文件夹中查找匹配名称的文件夹,
找不到则创建新文件夹。
Args:
group_id: 目标群号
folder_name: 文件夹名称
Returns:
str | None: folder_id(成功时)或 None(失败时)
"""
if not folder_name:
return None
# 1. 先尝试查找已有文件夹
folders = await self.get_group_file_root_folders(group_id)
for folder in folders:
name = folder.get("folder_name") or folder.get("name", "")
fid = folder.get("folder_id") or folder.get("id", "")
if name == folder_name and fid:
logger.debug(f"找到已有群文件夹: {folder_name} [ID: {fid}]")
return fid
# 2. 未找到,尝试创建
created_id = await self.create_group_file_folder(group_id, folder_name)
if created_id:
return created_id
# 3. 创建后再次查找(某些实现创建时不返回 ID)
folders = await self.get_group_file_root_folders(group_id)
for folder in folders:
name = folder.get("folder_name") or folder.get("name", "")
fid = folder.get("folder_id") or folder.get("id", "")
if name == folder_name and fid:
logger.debug(f"创建后找到群文件夹: {folder_name} [ID: {fid}]")
return fid
logger.warning(
f"无法获取群文件夹 ID: {folder_name} (群 {group_id}),将上传到根目录"
)
return None
async def upload_group_album(
self,
group_id: str,
image_path: str,
album_id: str | None = None,
) -> bool:
"""
上传图片到群相册(NapCat 扩展 API)。
注意:此 API 仅 NapCat 支持,go-cqhttp / Lagrange 等不支持。
调用失败时会静默降级,不影响正常发送。
Args:
group_id: 目标群号
image_path: 本地图片文件的绝对路径
album_id: 目标相册 ID,为 None 时上传到默认相册
Returns:
bool: 上传是否成功
"""
try:
params = {
"group_id": int(group_id),
"file": image_path,
}
if album_id:
params["album_id"] = album_id
await self.bot.call_action("upload_group_album", **params)
logger.info(f"NapCat 群相册上传成功: 群 {group_id}")
return True
except Exception as e:
error_msg = str(e).lower()
if "not found" in error_msg or "not support" in error_msg:
logger.warning(
"当前 OneBot 实现不支持群相册上传 API (upload_group_album)"
"此功能仅 NapCat 可用。"
)
else:
logger.warning(f"NapCat 群相册上传失败: {e}")
return False
async def get_group_album_list(
self,
group_id: str,
) -> list[dict]:
"""
获取群相册列表(NapCat 扩展 API)。
Args:
group_id: 目标群号
Returns:
list[dict]: 相册列表,每项可能包含 album_id / name 等字段。
API 不可用时返回空列表。
"""
try:
result = await self.bot.call_action(
"get_group_album_list",
group_id=int(group_id),
)
if isinstance(result, list):
return result
if isinstance(result, dict):
return result.get("albums", []) or result.get("data", []) or []
return []
except Exception as e:
logger.debug(f"获取群相册列表失败: {e}")
return []
async def find_album_id(
self,
group_id: str,
album_name: str,
) -> str | None:
"""
根据相册名称查找 album_id。找不到返回 None(将回退到默认相册)。
Args:
group_id: 目标群号
album_name: 目标相册名称
Returns:
str | None: 匹配的 album_id,未找到返回 None
"""
if not album_name:
return None
albums = await self.get_group_album_list(group_id)
for album in albums:
name = album.get("name") or album.get("album_name", "")
aid = album.get("album_id") or album.get("id", "")
if name == album_name and aid:
logger.debug(f"找到群相册: {album_name} [ID: {aid}]")
return str(aid)
logger.info(f"未找到群相册 '{album_name}' (群 {group_id}),将使用默认相册")
return None
+118 -9
View File
@@ -1,4 +1,8 @@
import base64
import os
import tempfile
from collections.abc import Callable
from datetime import datetime
from typing import Any
from ...utils.logger import logger
@@ -94,22 +98,17 @@ class ReportDispatcher:
group_id, image_url, "📊 每日群聊分析报告已生成:", platform_id
)
if sent:
# 4. 发送成功后,尝试上传到群文件/群相册(静默处理)
await self._try_upload_image(group_id, image_url, platform_id)
return True
# 4. 发送失败或生成失败的处理 -> 加入重试队列
# 5. 发送失败或生成失败的处理 -> 加入重试队列
if html_content:
logger.warning(
f"[{trace_id}] Image dispatch failed, adding to retry queue..."
)
# 尝试获取 platform_id 如果没有提供
if not platform_id:
# 这里假设 MessageSender 能帮忙或者我们需要自己查
# 由于 Dispatcher 不直接持有 BotManager (除了通过 MessageSender 间接持有)
# 原有逻辑:AutoScheduler 调用 get_platform_id_for_group
# 我们这里暂时依赖传入的 platform_id,如果没有,RetryManager 可能处理不了?
# 实际上 RetryManager 需要 platform_id。
# 让我们尝试通过 MessageSender 的 bot_manager 获取一个
# 或者更简单:如果 platform_id 为空,我们尝试获取第一个可用的 (MessageSender._get_available_platforms Logic)
platforms = self.message_sender._get_available_platforms(group_id)
if platforms:
platform_id = platforms[0][0] # use first available
@@ -124,7 +123,7 @@ class ReportDispatcher:
f"[{trace_id}] Cannot add to retry queue: No platform_id available."
)
# 5. 最终回退:文本报告
# 6. 最终回退:文本报告
logger.warning(f"[{trace_id}] Falling back to text report.")
return await self._dispatch_text(group_id, analysis_result, platform_id)
@@ -173,3 +172,113 @@ class ReportDispatcher:
except Exception as e:
logger.error(f"[{TraceContext.get()}] Failed to dispatch text report: {e}")
return False
# ================================================================
# 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式)
# ================================================================
async def _try_upload_image(
self,
group_id: str,
image_url: str,
platform_id: str | None,
):
"""
尝试将图片报告上传到群文件和/或群相册。
仅在配置启用且平台为 OneBot 时执行,失败静默处理。
"""
enable_file = self.config_manager.get_enable_group_file_upload()
enable_album = self.config_manager.get_enable_group_album_upload()
if not enable_file and not enable_album:
return
# 仅 OneBot 平台支持
adapter = self._get_onebot_adapter(platform_id)
if not adapter:
return
# 将图片保存为临时文件
image_file = self._save_image_to_temp(image_url, group_id)
if not image_file:
return
try:
# 上传到群文件
if enable_file:
await self._do_upload_group_file(adapter, group_id, image_file)
# 上传到群相册
if enable_album:
await self._do_upload_group_album(adapter, group_id, image_file)
finally:
try:
os.remove(image_file)
except OSError:
pass
async def _do_upload_group_file(self, adapter, group_id: str, file_path: str):
"""上传文件到群文件目录,失败静默"""
try:
folder_name = self.config_manager.get_group_file_folder()
folder_id = None
if folder_name:
folder_id = await adapter.find_or_create_folder(group_id, folder_name)
await adapter.upload_group_file_to_folder(
group_id=group_id,
file_path=file_path,
folder_id=folder_id,
)
except Exception as e:
logger.warning(f"群文件上传失败 (群 {group_id}): {e}")
async def _do_upload_group_album(self, adapter, group_id: str, file_path: str):
"""上传图片到群相册,失败静默"""
try:
album_name = self.config_manager.get_group_album_name()
album_id = None
if album_name and hasattr(adapter, "find_album_id"):
album_id = await adapter.find_album_id(group_id, album_name)
await adapter.upload_group_album(group_id, file_path, album_id=album_id)
except Exception as e:
logger.warning(f"群相册上传失败 (群 {group_id}): {e}")
def _save_image_to_temp(self, image_url: str, group_id: str) -> str | None:
"""将 base64 图片保存为临时 PNG 文件,返回路径。失败返回 None。"""
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):
return os.path.abspath(image_url)
elif image_url.startswith("file:///"):
p = image_url[len("file:///") :]
if os.path.isfile(p):
return os.path.abspath(p)
if not image_data:
return None
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join(
tempfile.gettempdir(), f"群聊分析报告_{group_id}_{date_str}.png"
)
with open(path, "wb") as f:
f.write(image_data)
return path
except Exception as e:
logger.debug(f"保存图片到临时文件失败: {e}")
return None
def _get_onebot_adapter(self, platform_id: str | None):
"""获取 OneBot 适配器,非 OneBot 平台返回 None。"""
if not platform_id:
return None
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
if adapter and hasattr(adapter, "upload_group_file_to_folder"):
return adapter
return None
+11 -4
View File
@@ -149,7 +149,11 @@ class ReportGenerator(IReportGenerator):
return None, html_content
async def generate_pdf_report(
self, analysis_result: dict, group_id: str, avatar_getter=None
self,
analysis_result: dict,
group_id: str,
avatar_getter=None,
nickname_getter=None,
) -> str | None:
"""生成PDF格式的分析报告"""
try:
@@ -169,6 +173,7 @@ class ReportGenerator(IReportGenerator):
analysis_result,
chart_template="activity_chart_pdf.html",
avatar_getter=avatar_getter,
nickname_getter=nickname_getter,
)
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
@@ -357,7 +362,7 @@ class ReportGenerator(IReportGenerator):
detail: str,
avatar_getter,
nickname_getter=None,
user_analysis: dict = None,
user_analysis: dict | None = None,
) -> str:
"""
处理话题详情,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式
@@ -537,7 +542,9 @@ class ReportGenerator(IReportGenerator):
safe_avatar_url = self._safe_url_for_log(avatar_url)
async with aiohttp.ClientSession() as client:
try:
async with client.get(avatar_url, timeout=5) as response:
async with client.get(
avatar_url, timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
content = await response.read()
if content:
@@ -607,7 +614,7 @@ class ReportGenerator(IReportGenerator):
try:
# 动态导入 playwright
try:
from playwright.async_api import async_playwright
from playwright.async_api import async_playwright # type: ignore
except ImportError:
logger.error("playwright 未安装,无法生成 PDF")
logger.info("💡 请尝试运行: pip install playwright")
+2 -2
View File
@@ -5,12 +5,12 @@ class PluginLogger:
"""
日志代理类:插件级统一日志装饰器
自动向所有通过该实例输出的日志信息前缀添加 `[QQ群分析]` 标签,
自动向所有通过该实例输出的日志信息前缀添加 `[群分析插件]` 标签,
以便用户在 AstrBot 混合日志流中快速定位属于本插件的输出。
不直接继承 logging.LoggerAdapter 以符合框架规范。
"""
def __init__(self, prefix: str = "[QQ群分析]"):
def __init__(self, prefix: str = "[群分析插件]"):
self.prefix = prefix
def _format_msg(self, msg: str) -> str: