mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 20:01:04 +00:00
feat: 增强图片上传能力并完成核心架构类型重构
- 重构: 优化主类命名逻辑,提升多平台架构的可读性与通用性。 - 功能: 新增分析报告同步上传至群文件和群相册的功能,支持自动化文件管理与清理。 - 类型: 全面修正静态类型错误,确保各分析器模块与基类接口签名严格对齐。 - 优化: 改进内部类成员标注,解决可选参数与类型推断在透传过程中的不匹配问题。 - 稳健性: 完善网络请求超时处理、临时资源管理以及可选依赖项的动态加载逻辑。 - 配置: 扩展配置管理项,支持自定义上传开关、目标目录以及 PDF 渲染参数。
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}' 不存在")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("没有符合条件的用户,返回空结果")
|
||||
|
||||
@@ -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 选择。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user