[v1.7.0] (解耦化) 优化项目结构,修复部分提示内容

This commit is contained in:
回归天空
2025-09-02 23:30:37 +08:00
parent ee2f08e658
commit 84c663df44
20 changed files with 2259 additions and 2517 deletions
+5
View File
@@ -0,0 +1,5 @@
"""
QQ群日常分析插件 - 源代码包
"""
__author__ = "SXP-Simon"
+12
View File
@@ -0,0 +1,12 @@
"""
分析模块
包含LLM分析和统计分析功能
"""
from .llm_analyzer import LLMAnalyzer
from .statistics import UserAnalyzer
__all__ = [
'LLMAnalyzer',
'UserAnalyzer'
]
+470
View File
@@ -0,0 +1,470 @@
"""
LLM分析器模块
负责使用LLM进行话题分析、用户称号分析和金句分析
"""
import json
import re
from datetime import datetime
from typing import List, Dict, Tuple
from astrbot.api import logger
from ...src.models.data_models import SummaryTopic, UserTitle, GoldenQuote, TokenUsage
class LLMAnalyzer:
"""LLM分析器"""
def __init__(self, context, config_manager):
self.context = context
self.config_manager = config_manager
async def analyze_topics(self, messages: List[Dict]) -> Tuple[List[SummaryTopic], TokenUsage]:
"""使用LLM分析话题"""
try:
# 提取文本消息
text_messages = []
for msg in messages:
sender = msg.get("sender", {})
nickname = sender.get("nickname", "") or sender.get("card", "")
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
for content in msg.get("message", []):
if content.get("type") == "text":
text = content.get("data", {}).get("text", "").strip()
if text and len(text) > 2 and not text.startswith(("/")):
text_messages.append({
"sender": nickname,
"time": msg_time,
"content": text
})
if not text_messages:
return [], TokenUsage()
# 构建LLM提示词,清理消息内容
def clean_message_content(content):
"""清理消息内容,移除可能影响JSON解析的字符"""
# 替换中文引号
content = content.replace('"', '"').replace('"', '"')
content = content.replace(''', "'").replace(''', "'")
# 移除或替换其他特殊字符
content = content.replace('\n', ' ').replace('\r', ' ')
content = content.replace('\t', ' ')
# 移除可能的控制字符
content = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', content)
return content.strip()
messages_text = "\n".join([
f"[{msg['time']}] {msg['sender']}: {clean_message_content(msg['content'])}"
for msg in text_messages
])
max_topics = self.config_manager.get_max_topics()
prompt = f"""
你是一个帮我进行群聊信息总结的助手,生成总结内容时,你需要严格遵守下面的几个准则:
请分析接下来提供的群聊记录,提取出最多{max_topics}个主要话题。
对于每个话题,请提供:
1. 话题名称(突出主题内容,尽量简明扼要)
2. 主要参与者(最多5人)
3. 话题详细描述(包含关键信息和结论)
注意:
- 对于比较有价值的点,稍微用一两句话详细讲讲,比如不要生成 "Nolan 和 SOV 讨论了 galgame 中关于性符号的衍生情况" 这种宽泛的内容,而是生成更加具体的讨论内容,让其他人只看这个消息就能知道讨论中有价值的,有营养的信息。
- 对于其中的部分信息,你需要特意提到主题施加的主体是谁,是哪个群友做了什么事情,而不要直接生成和群友没有关系的语句。
- 对于每一条总结,尽量讲清楚前因后果,以及话题的结论,是什么,为什么,怎么做,如果用户没有讲到细节,则可以不用这么做。
群聊记录:
{messages_text}
重要:必须返回标准JSON格式,严格遵守以下规则:
1. 只使用英文双引号 " 不要使用中文引号 " "
2. 字符串内容中的引号必须转义为 \"
3. 多个对象之间用逗号分隔
4. 数组元素之间用逗号分隔
5. 不要在JSON外添加任何文字说明
6. 描述内容避免使用特殊符号,用普通文字表达
请严格按照以下JSON格式返回,确保可以被标准JSON解析器解析:
[
{{
"topic": "话题名称",
"contributors": ["用户1", "用户2"],
"detail": "话题描述内容"
}},
{{
"topic": "另一个话题",
"contributors": ["用户3", "用户4"],
"detail": "另一个话题的描述"
}}
]
注意:返回的内容必须是纯JSON,不要包含markdown代码块标记或其他格式
"""
# 调用LLM
provider = self.context.get_using_provider()
if not provider:
logger.warning("未配置LLM提供商,跳过话题分析")
return [], TokenUsage()
response = await provider.text_chat(
prompt=prompt,
max_tokens=10000,
temperature=0.6
)
# 提取token使用统计
token_usage = TokenUsage()
if response.raw_completion and hasattr(response.raw_completion, 'usage'):
usage = response.raw_completion.usage
token_usage.prompt_tokens = usage.prompt_tokens
token_usage.completion_tokens = usage.completion_tokens
token_usage.total_tokens = usage.total_tokens
# 解析响应
if hasattr(response, 'completion_text'):
result_text = response.completion_text
else:
result_text = str(response)
# 尝试解析JSON
try:
# 提取JSON部分
json_match = re.search(r'\[.*?\]', result_text, re.DOTALL)
if json_match:
json_text = json_match.group()
logger.debug(f"话题分析JSON原文: {json_text[:500]}...")
# 强化JSON清理和修复
json_text = self._fix_json(json_text)
logger.debug(f"修复后的JSON: {json_text[:300]}...")
topics_data = json.loads(json_text)
topics = [SummaryTopic(**topic) for topic in topics_data[:max_topics]]
logger.info(f"话题分析成功,解析到 {len(topics)} 个话题")
return topics, token_usage
else:
logger.warning(f"话题分析响应中未找到JSON格式,响应内容: {result_text[:200]}...")
except json.JSONDecodeError as e:
logger.error(f"话题分析JSON解析失败: {e}")
logger.debug(f"修复后的JSON: {json_text if 'json_text' in locals() else 'N/A'}")
logger.debug(f"原始响应: {result_text}")
# 如果JSON解析失败,尝试用正则表达式提取话题信息
topics = self._extract_topics_with_regex(result_text, max_topics)
if topics:
logger.info(f"正则表达式提取成功,获得 {len(topics)} 个话题")
return topics, token_usage
else:
# 最后的降级方案
logger.info("正则表达式提取失败,使用默认话题...")
return [SummaryTopic(
topic="群聊讨论",
contributors=["群友"],
detail="今日群聊内容丰富,涵盖多个话题"
)], token_usage
return [], token_usage
except Exception as e:
logger.error(f"话题分析失败: {e}")
return [], TokenUsage()
def _fix_json(self, text: str) -> str:
"""修复JSON格式问题"""
# 移除markdown代码块标记
text = re.sub(r'```json\s*', '', text)
text = re.sub(r'```\s*$', '', text)
# 基础清理
text = text.replace('\n', ' ').replace('\r', ' ')
text = re.sub(r'\s+', ' ', text)
# 替换中文引号为英文引号
text = text.replace('"', '"').replace('"', '"')
text = text.replace(''', "'").replace(''', "'")
# 处理字符串内容中的特殊字符
# 转义字符串内的双引号
def escape_quotes_in_strings(match):
content = match.group(1)
# 转义内部的双引号
content = content.replace('"', '\\"')
return f'"{content}"'
# 先处理字段值中的引号
text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
# 修复截断的JSON
if not text.endswith(']'):
last_complete = text.rfind('}')
if last_complete > 0:
text = text[:last_complete + 1] + ']'
# 修复常见的JSON格式问题
# 1. 修复缺失的逗号
text = re.sub(r'}\s*{', '}, {', text)
# 2. 确保字段名有引号
text = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', text)
# 3. 移除多余的逗号
text = re.sub(r',\s*}', '}', text)
text = re.sub(r',\s*]', ']', text)
return text
def _extract_topics_with_regex(self, result_text: str, max_topics: int) -> List[SummaryTopic]:
"""使用正则表达式提取话题信息"""
try:
topics = []
# 更强的正则表达式提取话题信息,处理转义字符
# 匹配每个完整的话题对象
topic_pattern = r'\{\s*"topic":\s*"([^"]+)"\s*,\s*"contributors":\s*\[([^\]]+)\]\s*,\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
matches = re.findall(topic_pattern, result_text, re.DOTALL)
if not matches:
# 尝试更宽松的匹配
topic_pattern = r'"topic":\s*"([^"]+)"[^}]*"contributors":\s*\[([^\]]+)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"'
matches = re.findall(topic_pattern, result_text, re.DOTALL)
for match in matches[:max_topics]:
topic_name = match[0].strip()
contributors_str = match[1].strip()
detail = match[2].strip()
# 清理detail中的转义字符
detail = detail.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
# 解析参与者列表
contributors = []
for contrib in re.findall(r'"([^"]+)"', contributors_str):
contributors.append(contrib.strip())
if not contributors:
contributors = ["群友"]
topics.append(SummaryTopic(
topic=topic_name,
contributors=contributors[:5], # 最多5个参与者
detail=detail
))
return topics
except Exception as e:
logger.error(f"正则表达式提取失败: {e}")
return []
async def analyze_user_titles(self, messages: List[Dict], user_analysis: Dict) -> Tuple[List[UserTitle], TokenUsage]:
"""使用LLM分析用户称号"""
try:
# 准备用户数据
user_summaries = []
for user_id, stats in user_analysis.items():
if stats["message_count"] < 5: # 过滤活跃度太低的用户
continue
# 分析用户特征
night_messages = sum(stats["hours"][h] for h in range(0, 6))
day_messages = stats["message_count"] - night_messages
avg_chars = stats["char_count"] / stats["message_count"] if stats["message_count"] > 0 else 0
user_summaries.append({
"name": stats["nickname"],
"qq": int(user_id),
"message_count": stats["message_count"],
"avg_chars": round(avg_chars, 1),
"emoji_ratio": round(stats["emoji_count"] / stats["message_count"], 2),
"night_ratio": round(night_messages / stats["message_count"], 2),
"reply_ratio": round(stats["reply_count"] / stats["message_count"], 2)
})
if not user_summaries:
return [], TokenUsage()
# 按消息数量排序,取前N名
max_user_titles = self.config_manager.get_max_user_titles()
user_summaries.sort(key=lambda x: x["message_count"], reverse=True)
user_summaries = user_summaries[:max_user_titles]
# 构建LLM提示词
users_text = "\n".join([
f"- {user['name']} (QQ:{user['qq']}): "
f"发言{user['message_count']}条, 平均{user['avg_chars']}字, "
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, "
f"回复比例{user['reply_ratio']}"
for user in user_summaries
])
prompt = f"""
请为以下群友分配合适的称号和MBTI类型。每个人只能有一个称号,每个称号只能给一个人。
可选称号:
- 龙王: 发言频繁但内容轻松的人
- 技术专家: 经常讨论技术话题的人
- 夜猫子: 经常在深夜发言的人
- 表情包军火库: 经常发表情的人
- 沉默终结者: 经常开启话题的人
- 评论家: 平均发言长度很长的人
- 阳角: 在群里很有影响力的人
- 互动达人: 经常回复别人的人
- ... (你可以自行进行拓展添加)
用户数据:
{users_text}
请以JSON格式返回,格式如下:
[
{{
"name": "用户名",
"qq": 123456789,
"title": "称号",
"mbti": "MBTI类型",
"reason": "获得此称号的原因"
}}
]
"""
# 调用LLM
provider = self.context.get_using_provider()
if not provider:
logger.warning("未配置LLM提供商,跳过用户称号分析")
return [], TokenUsage()
response = await provider.text_chat(
prompt=prompt,
max_tokens=1500,
temperature=0.5
)
# 提取token使用统计
token_usage = TokenUsage()
if response.raw_completion and hasattr(response.raw_completion, 'usage'):
usage = response.raw_completion.usage
token_usage.prompt_tokens = usage.prompt_tokens
token_usage.completion_tokens = usage.completion_tokens
token_usage.total_tokens = usage.total_tokens
# 解析响应
if hasattr(response, 'completion_text'):
result_text = response.completion_text
else:
result_text = str(response)
# 尝试解析JSON
try:
json_match = re.search(r'\[.*\]', result_text, re.DOTALL)
if json_match:
titles_data = json.loads(json_match.group())
return [UserTitle(**title) for title in titles_data], token_usage
except:
pass
return [], token_usage
except Exception as e:
logger.error(f"用户称号分析失败: {e}")
return [], TokenUsage()
async def analyze_golden_quotes(self, messages: List[Dict]) -> Tuple[List[GoldenQuote], TokenUsage]:
"""使用LLM分析群聊金句"""
try:
# 提取有趣的文本消息
interesting_messages = []
for msg in messages:
sender = msg.get("sender", {})
nickname = sender.get("nickname", "") or sender.get("card", "")
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
for content in msg.get("message", []):
if content.get("type") == "text":
text = content.get("data", {}).get("text", "").strip()
# 过滤长度适中、可能有趣的消息
if 5 <= len(text) <= 100 and not text.startswith(("http", "www", "/")):
interesting_messages.append({
"sender": nickname,
"time": msg_time,
"content": text
})
if not interesting_messages:
return [], TokenUsage()
# 构建LLM提示词
messages_text = "\n".join([
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
for msg in interesting_messages
])
# 计算金句数量
max_golden_quotes = self.config_manager.get_max_golden_quotes()
prompt = f"""
请从以下群聊记录中挑选出{max_golden_quotes}句最具冲击力、最令人惊叹的"金句"。这些金句需满足:
- 核心标准:**逆天的神人发言**,即具备颠覆常识的脑洞、逻辑跳脱的表达或强烈反差感的原创内容
- 典型特征:包含某些争议话题元素、夸张类比、反常规结论、一本正经的"胡说八道"或突破语境的清奇思路,并且具备一定的冲击力,让人印象深刻。
对于每个金句,请提供:
1. 原文内容(完整保留发言细节)
2. 发言人昵称
3. 选择理由(具体说明其"逆天"之处,如逻辑颠覆点/脑洞角度/反差感/争议话题元素)
此外,我将对你进行严格约束:
- 优先筛选 **逆天指数最高** 的内容:发情、性压抑话题 > 争议话题 > 元素级 > 颠覆认知级 > 逻辑跳脱级 > 趣味调侃级,剔除单纯玩梗或网络热词堆砌的普通发言
- 重点标记包含极端类比、反常识论证或无厘头结论的内容,并且包含一定的争议话题元素。
群聊记录:
{messages_text}
请以JSON格式返回,格式如下:
[
{{
"content": "金句原文",
"sender": "发言人昵称",
"reason": "选择这句话的理由(需明确说明逆天特质)"
}}
]
"""
# 调用LLM
provider = self.context.get_using_provider()
if not provider:
logger.warning("未配置LLM提供商,跳过金句分析")
return [], TokenUsage()
response = await provider.text_chat(
prompt=prompt,
max_tokens=1500,
temperature=0.7
)
# 提取token使用统计
token_usage = TokenUsage()
if response.raw_completion and hasattr(response.raw_completion, 'usage'):
usage = response.raw_completion.usage
token_usage.prompt_tokens = usage.prompt_tokens
token_usage.completion_tokens = usage.completion_tokens
token_usage.total_tokens = usage.total_tokens
# 解析响应
if hasattr(response, 'completion_text'):
result_text = response.completion_text
else:
result_text = str(response)
# 尝试解析JSON
try:
json_match = re.search(r'\[.*\]', result_text, re.DOTALL)
if json_match:
quotes_data = json.loads(json_match.group())
return [GoldenQuote(**quote) for quote in quotes_data[:max_golden_quotes]], token_usage
except:
pass
return [], token_usage
except Exception as e:
logger.error(f"金句分析失败: {e}")
return [], TokenUsage()
+88
View File
@@ -0,0 +1,88 @@
"""
统计分析模块
负责用户活跃度分析和其他统计功能
"""
from datetime import datetime
from typing import List, Dict
from collections import defaultdict
class UserAnalyzer:
"""用户分析器"""
def __init__(self, config_manager):
self.config_manager = config_manager
def analyze_users(self, messages: List[Dict]) -> Dict[str, Dict]:
"""分析用户活跃度"""
user_stats = defaultdict(lambda: {
"message_count": 0,
"char_count": 0,
"emoji_count": 0,
"nickname": "",
"hours": defaultdict(int),
"reply_count": 0
})
for msg in messages:
sender = msg.get("sender", {})
user_id = str(sender.get("user_id", ""))
nickname = sender.get("nickname", "") or sender.get("card", "")
user_stats[user_id]["message_count"] += 1
user_stats[user_id]["nickname"] = nickname
# 统计时间分布
msg_time = datetime.fromtimestamp(msg.get("time", 0))
user_stats[user_id]["hours"][msg_time.hour] += 1
# 处理消息内容
for content in msg.get("message", []):
if content.get("type") == "text":
text = content.get("data", {}).get("text", "")
user_stats[user_id]["char_count"] += len(text)
elif content.get("type") == "face":
user_stats[user_id]["emoji_count"] += 1
elif content.get("type") == "reply":
user_stats[user_id]["reply_count"] += 1
return dict(user_stats)
def get_top_users(self, user_analysis: Dict[str, Dict], limit: int = 10) -> List[Dict]:
"""获取最活跃的用户"""
users = []
for user_id, stats in user_analysis.items():
users.append({
"user_id": user_id,
"nickname": stats["nickname"],
"message_count": stats["message_count"],
"char_count": stats["char_count"],
"emoji_count": stats["emoji_count"],
"reply_count": stats["reply_count"]
})
# 按消息数量排序
users.sort(key=lambda x: x["message_count"], reverse=True)
return users[:limit]
def get_user_activity_pattern(self, user_analysis: Dict[str, Dict], user_id: str) -> Dict:
"""获取用户活动模式"""
if user_id not in user_analysis:
return {}
stats = user_analysis[user_id]
hours = stats["hours"]
# 找出最活跃的时间段
most_active_hour = max(hours.items(), key=lambda x: x[1])[0] if hours else 0
# 计算夜间活跃度
night_messages = sum(hours[h] for h in range(0, 6))
night_ratio = night_messages / stats["message_count"] if stats["message_count"] > 0 else 0
return {
"most_active_hour": most_active_hour,
"night_ratio": night_ratio,
"hourly_distribution": dict(hours)
}
+11
View File
@@ -0,0 +1,11 @@
"""
核心功能模块
"""
from .config import ConfigManager
from .message_handler import MessageHandler
__all__ = [
'ConfigManager',
'MessageHandler'
]
+263
View File
@@ -0,0 +1,263 @@
"""
配置管理模块
负责处理插件配置和PDF依赖检查
"""
import sys
import importlib
from pathlib import Path
from typing import Optional, List
from astrbot.api import logger, AstrBotConfig
class ConfigManager:
"""配置管理器"""
def __init__(self, config: AstrBotConfig):
self.config = config
self._pyppeteer_available = False
self._pyppeteer_version = None
self._check_pyppeteer_availability()
def get_enabled_groups(self) -> List[str]:
"""获取启用的群组列表"""
return self.config.get("enabled_groups", [])
def get_max_messages(self) -> int:
"""获取最大消息数量"""
return self.config.get("max_messages", 1000)
def get_analysis_days(self) -> int:
"""获取分析天数"""
return self.config.get("analysis_days", 1)
def get_auto_analysis_time(self) -> str:
"""获取自动分析时间"""
return self.config.get("auto_analysis_time", "09:00")
def get_enable_auto_analysis(self) -> bool:
"""获取是否启用自动分析"""
return self.config.get("enable_auto_analysis", False)
def get_output_format(self) -> str:
"""获取输出格式"""
return self.config.get("output_format", "image")
def get_min_messages_threshold(self) -> int:
"""获取最小消息阈值"""
return self.config.get("min_messages_threshold", 50)
def get_topic_analysis_enabled(self) -> bool:
"""获取是否启用话题分析"""
return self.config.get("topic_analysis_enabled", True)
def get_user_title_analysis_enabled(self) -> bool:
"""获取是否启用用户称号分析"""
return self.config.get("user_title_analysis_enabled", True)
def get_max_topics(self) -> int:
"""获取最大话题数量"""
return self.config.get("max_topics", 5)
def get_max_user_titles(self) -> int:
"""获取最大用户称号数量"""
return self.config.get("max_user_titles", 8)
def get_max_golden_quotes(self) -> int:
"""获取最大金句数量"""
return self.config.get("max_golden_quotes", 5)
def get_max_query_rounds(self) -> int:
"""获取最大查询轮数"""
return self.config.get("max_query_rounds", 35)
def get_pdf_output_dir(self) -> str:
"""获取PDF输出目录"""
return self.config.get("pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports")
def get_pdf_filename_format(self) -> str:
"""获取PDF文件名格式"""
return self.config.get("pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf")
def set_output_format(self, format_type: str):
"""设置输出格式"""
self.config["output_format"] = format_type
self.config.save_config()
def set_enabled_groups(self, groups: List[str]):
"""设置启用的群组列表"""
self.config["enabled_groups"] = groups
self.config.save_config()
def set_max_messages(self, count: int):
"""设置最大消息数量"""
self.config["max_messages"] = count
self.config.save_config()
def set_analysis_days(self, days: int):
"""设置分析天数"""
self.config["analysis_days"] = days
self.config.save_config()
def set_auto_analysis_time(self, time_str: str):
"""设置自动分析时间"""
self.config["auto_analysis_time"] = time_str
self.config.save_config()
def set_enable_auto_analysis(self, enabled: bool):
"""设置是否启用自动分析"""
self.config["enable_auto_analysis"] = enabled
self.config.save_config()
def set_min_messages_threshold(self, threshold: int):
"""设置最小消息阈值"""
self.config["min_messages_threshold"] = threshold
self.config.save_config()
def set_topic_analysis_enabled(self, enabled: bool):
"""设置是否启用话题分析"""
self.config["topic_analysis_enabled"] = enabled
self.config.save_config()
def set_user_title_analysis_enabled(self, enabled: bool):
"""设置是否启用用户称号分析"""
self.config["user_title_analysis_enabled"] = enabled
self.config.save_config()
def set_max_topics(self, count: int):
"""设置最大话题数量"""
self.config["max_topics"] = count
self.config.save_config()
def set_max_user_titles(self, count: int):
"""设置最大用户称号数量"""
self.config["max_user_titles"] = count
self.config.save_config()
def set_max_golden_quotes(self, count: int):
"""设置最大金句数量"""
self.config["max_golden_quotes"] = count
self.config.save_config()
def set_max_query_rounds(self, rounds: int):
"""设置最大查询轮数"""
self.config["max_query_rounds"] = rounds
self.config.save_config()
def set_pdf_output_dir(self, directory: str):
"""设置PDF输出目录"""
self.config["pdf_output_dir"] = directory
self.config.save_config()
def set_pdf_filename_format(self, format_str: str):
"""设置PDF文件名格式"""
self.config["pdf_filename_format"] = format_str
self.config.save_config()
def add_enabled_group(self, group_id: str):
"""添加启用的群组"""
enabled_groups = self.get_enabled_groups()
if group_id not in enabled_groups:
enabled_groups.append(group_id)
self.config["enabled_groups"] = enabled_groups
self.config.save_config()
def remove_enabled_group(self, group_id: str):
"""移除启用的群组"""
enabled_groups = self.get_enabled_groups()
if group_id in enabled_groups:
enabled_groups.remove(group_id)
self.config["enabled_groups"] = enabled_groups
self.config.save_config()
@property
def pyppeteer_available(self) -> bool:
"""检查pyppeteer是否可用"""
return self._pyppeteer_available
@property
def pyppeteer_version(self) -> Optional[str]:
"""获取pyppeteer版本"""
return self._pyppeteer_version
def _check_pyppeteer_availability(self):
"""检查 pyppeteer 可用性"""
try:
import pyppeteer
from pyppeteer import launch
self._pyppeteer_available = True
# 检查版本
try:
self._pyppeteer_version = pyppeteer.__version__
logger.info(f"使用 pyppeteer {self._pyppeteer_version} 作为 PDF 引擎")
except AttributeError:
self._pyppeteer_version = "unknown"
logger.info("使用 pyppeteer (版本未知) 作为 PDF 引擎")
except ImportError:
self._pyppeteer_available = False
self._pyppeteer_version = None
logger.warning("pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2")
def reload_pyppeteer(self) -> bool:
"""重新加载 pyppeteer 模块"""
try:
logger.info("开始重新加载 pyppeteer 模块...")
# 移除所有 pyppeteer 相关模块
modules_to_remove = [mod for mod in sys.modules.keys() if mod.startswith('pyppeteer')]
logger.info(f"移除模块: {modules_to_remove}")
for mod in modules_to_remove:
del sys.modules[mod]
# 强制重新导入
try:
import pyppeteer
from pyppeteer import launch
# 更新全局变量
self._pyppeteer_available = True
try:
self._pyppeteer_version = pyppeteer.__version__
logger.info(f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}")
except AttributeError:
self._pyppeteer_version = "unknown"
logger.info("重新加载成功,pyppeteer 版本未知")
return True
except ImportError as e:
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
self._pyppeteer_available = False
self._pyppeteer_version = None
return False
except Exception as e:
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
self._pyppeteer_available = False
self._pyppeteer_version = None
return False
except Exception as e:
logger.error(f"重新加载 pyppeteer 时出错: {e}")
return False
def save_config(self):
"""保存配置到AstrBot配置系统"""
try:
self.config.save_config()
logger.info("配置已保存")
except Exception as e:
logger.error(f"保存配置失败: {e}")
def reload_config(self):
"""重新加载配置"""
try:
# 重新从AstrBot配置系统读取所有配置
logger.info("重新加载配置...")
# 配置会自动从self.config中重新读取
logger.info("配置重载完成")
except Exception as e:
logger.error(f"重新加载配置失败: {e}")
+167
View File
@@ -0,0 +1,167 @@
"""
消息处理模块
负责群聊消息的获取、过滤和预处理
"""
import asyncio
from datetime import datetime, timedelta
from typing import List, Dict, Optional
from collections import defaultdict
from astrbot.api import logger
from ...src.models.data_models import GroupStatistics, TokenUsage
class MessageHandler:
"""消息处理器"""
def __init__(self, config_manager):
self.config_manager = config_manager
self.bot_qq_id = None
async def set_bot_qq_id(self, bot_instance):
"""设置机器人QQ号"""
try:
if bot_instance and not self.bot_qq_id:
login_info = await bot_instance.api.call_action("get_login_info")
self.bot_qq_id = str(login_info.get("user_id", ""))
logger.info(f"获取到机器人QQ号: {self.bot_qq_id}")
except Exception as e:
logger.error(f"获取机器人QQ号失败: {e}")
async def fetch_group_messages(self, bot_instance, group_id: str, days: int) -> List[Dict]:
"""获取群聊消息记录"""
try:
if not bot_instance or not group_id:
logger.error(f"{group_id} 无效的客户端或群组ID")
return []
# 计算时间范围
end_time = datetime.now()
start_time = end_time - timedelta(days=days)
messages = []
message_seq = 0
query_rounds = 0
max_rounds = self.config_manager.get_max_query_rounds()
max_messages = self.config_manager.get_max_messages()
consecutive_failures = 0
max_failures = 3
logger.info(f"开始获取群 {group_id}{days} 天的消息记录")
logger.info(f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M:%S')}{end_time.strftime('%Y-%m-%d %H:%M:%S')}")
while len(messages) < max_messages and query_rounds < max_rounds:
try:
payloads = {
"group_id": group_id,
"message_seq": message_seq,
"count": 200,
"reverseOrder": True,
}
result = await bot_instance.api.call_action("get_group_msg_history", **payloads)
if not result or "messages" not in result:
logger.warning(f"{group_id} API返回无效结果: {result}")
consecutive_failures += 1
if consecutive_failures >= max_failures:
break
continue
round_messages = result.get("messages", [])
if not round_messages:
logger.info(f"{group_id} 没有更多消息,结束获取")
break
# 重置失败计数
consecutive_failures = 0
# 过滤时间范围内的消息
valid_messages_in_round = 0
oldest_msg_time = None
for msg in round_messages:
try:
msg_time = datetime.fromtimestamp(msg.get("time", 0))
oldest_msg_time = msg_time
# 过滤掉机器人自己的消息
sender_id = str(msg.get("sender", {}).get("user_id", ""))
if self.bot_qq_id and sender_id == self.bot_qq_id:
continue
if msg_time >= start_time and msg_time <= end_time:
messages.append(msg)
valid_messages_in_round += 1
except Exception as msg_error:
logger.warning(f"{group_id} 处理单条消息失败: {msg_error}")
continue
# 如果最老的消息时间已经超出范围,停止获取
if oldest_msg_time and oldest_msg_time < start_time:
logger.info(f"{group_id} 已获取到时间范围外的消息,停止获取。共获取 {len(messages)} 条消息")
break
if valid_messages_in_round == 0:
logger.warning(f"{group_id} 本轮未获取到有效消息")
break
message_seq = round_messages[0]["message_id"]
query_rounds += 1
# 添加延迟避免请求过快
if query_rounds % 5 == 0:
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"{group_id} 获取消息失败 (第{query_rounds+1}轮): {e}")
consecutive_failures += 1
if consecutive_failures >= max_failures:
logger.error(f"{group_id} 连续失败 {max_failures} 次,停止获取")
break
await asyncio.sleep(1)
logger.info(f"{group_id} 消息获取完成,共获取 {len(messages)} 条消息,查询轮数: {query_rounds}")
return messages
except Exception as e:
logger.error(f"{group_id} 获取群聊消息记录失败: {e}", exc_info=True)
return []
def calculate_statistics(self, messages: List[Dict]) -> GroupStatistics:
"""计算基础统计数据"""
total_chars = 0
participants = set()
hour_counts = defaultdict(int)
emoji_count = 0
for msg in messages:
sender_id = str(msg.get("sender", {}).get("user_id", ""))
participants.add(sender_id)
# 统计时间分布
msg_time = datetime.fromtimestamp(msg.get("time", 0))
hour_counts[msg_time.hour] += 1
# 处理消息内容
for content in msg.get("message", []):
if content.get("type") == "text":
text = content.get("data", {}).get("text", "")
total_chars += len(text)
elif content.get("type") == "face":
emoji_count += 1
# 找出最活跃时段
most_active_hour = max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0
most_active_period = f"{most_active_hour:02d}:00-{(most_active_hour+1)%24:02d}:00"
return GroupStatistics(
message_count=len(messages),
total_characters=total_chars,
participant_count=len(participants),
most_active_period=most_active_period,
golden_quotes=[],
emoji_count=emoji_count,
token_usage=TokenUsage()
)
+19
View File
@@ -0,0 +1,19 @@
"""
数据模型模块
"""
from .data_models import (
SummaryTopic,
UserTitle,
GoldenQuote,
TokenUsage,
GroupStatistics
)
__all__ = [
'SummaryTopic',
'UserTitle',
'GoldenQuote',
'TokenUsage',
'GroupStatistics'
]
+53
View File
@@ -0,0 +1,53 @@
"""
数据模型定义
包含所有分析相关的数据结构
"""
from dataclasses import dataclass, field
from typing import List
@dataclass
class SummaryTopic:
"""话题总结数据结构"""
topic: str
contributors: List[str]
detail: str
@dataclass
class UserTitle:
"""用户称号数据结构"""
name: str
qq: int
title: str
mbti: str
reason: str
@dataclass
class GoldenQuote:
"""群聊金句数据结构"""
content: str
sender: str
reason: str
@dataclass
class TokenUsage:
"""Token使用统计"""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
@dataclass
class GroupStatistics:
"""群聊统计数据结构"""
message_count: int
total_characters: int
participant_count: int
most_active_period: str
golden_quotes: List[GoldenQuote]
emoji_count: int
token_usage: TokenUsage = field(default_factory=TokenUsage)
+12
View File
@@ -0,0 +1,12 @@
"""
报告生成模块
包含HTML、PDF、文本报告生成功能
"""
from .generators import ReportGenerator
from .templates import HTMLTemplates
__all__ = [
'ReportGenerator',
'HTMLTemplates'
]
+323
View File
@@ -0,0 +1,323 @@
"""
报告生成器模块
负责生成各种格式的分析报告
"""
import base64
import aiohttp
from datetime import datetime
from typing import Dict, Optional
from pathlib import Path
from astrbot.api import logger
from .templates import HTMLTemplates
class ReportGenerator:
"""报告生成器"""
def __init__(self, config_manager):
self.config_manager = config_manager
async def generate_image_report(self, analysis_result: Dict, group_id: str, html_render_func) -> Optional[str]:
"""生成图片格式的分析报告"""
try:
# 准备渲染数据
render_payload = await self._prepare_render_data(analysis_result)
# 使用AstrBot内置的HTML渲染服务(直接传递模板和数据)
image_url = await html_render_func(HTMLTemplates.get_image_template(), render_payload)
return image_url
except Exception as e:
logger.error(f"生成图片报告失败: {e}")
return None
async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]:
"""生成PDF格式的分析报告"""
try:
# 确保输出目录存在
output_dir = Path(self.config_manager.get_pdf_output_dir())
output_dir.mkdir(parents=True, exist_ok=True)
# 生成文件名
current_date = datetime.now().strftime('%Y%m%d')
filename = self.config_manager.get_pdf_filename_format().format(
group_id=group_id,
date=current_date
)
pdf_path = output_dir / filename
# 准备渲染数据
render_data = await self._prepare_render_data(analysis_result)
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
# 生成 HTML 内容(PDF模板使用{}占位符)
html_content = self._render_html_template(HTMLTemplates.get_pdf_template(), render_data, use_jinja_style=False)
logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符")
# 转换为 PDF
success = await self._html_to_pdf(html_content, str(pdf_path))
if success:
return str(pdf_path.absolute())
else:
return None
except Exception as e:
logger.error(f"生成 PDF 报告失败: {e}")
return None
def generate_text_report(self, analysis_result: Dict) -> str:
"""生成文本格式的分析报告"""
stats = analysis_result["statistics"]
topics = analysis_result["topics"]
user_titles = analysis_result["user_titles"]
report = f"""
🎯 群聊日常分析报告
📅 {datetime.now().strftime('%Y年%m月%d')}
📊 基础统计
• 消息总数: {stats.message_count}
• 参与人数: {stats.participant_count}
• 总字符数: {stats.total_characters}
• 表情数量: {stats.emoji_count}
• 最活跃时段: {stats.most_active_period}
💬 热门话题
"""
max_topics = self.config_manager.get_max_topics()
for i, topic in enumerate(topics[:max_topics], 1):
contributors_str = "".join(topic.contributors)
report += f"{i}. {topic.topic}\n"
report += f" 参与者: {contributors_str}\n"
report += f" {topic.detail}\n\n"
report += "🏆 群友称号\n"
max_user_titles = self.config_manager.get_max_user_titles()
for title in user_titles[:max_user_titles]:
report += f"{title.name} - {title.title} ({title.mbti})\n"
report += f" {title.reason}\n\n"
report += "💬 群圣经\n"
max_golden_quotes = self.config_manager.get_max_golden_quotes()
for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1):
report += f"{i}. \"{quote.content}\" —— {quote.sender}\n"
report += f" {quote.reason}\n\n"
return report
async def _prepare_render_data(self, analysis_result: Dict) -> Dict:
"""准备渲染数据"""
stats = analysis_result["statistics"]
topics = analysis_result["topics"]
user_titles = analysis_result["user_titles"]
# 构建话题HTML
topics_html = ""
max_topics = self.config_manager.get_max_topics()
for i, topic in enumerate(topics[:max_topics], 1):
contributors_str = "".join(topic.contributors)
topics_html += f"""
<div class="topic-item">
<div class="topic-header">
<span class="topic-number">{i}</span>
<span class="topic-title">{topic.topic}</span>
</div>
<div class="topic-contributors">参与者: {contributors_str}</div>
<div class="topic-detail">{topic.detail}</div>
</div>
"""
# 构建用户称号HTML(包含头像)
titles_html = ""
max_user_titles = self.config_manager.get_max_user_titles()
for title in user_titles[:max_user_titles]:
# 获取用户头像
avatar_data = await self._get_user_avatar(str(title.qq))
avatar_html = f'<img src="{avatar_data}" class="user-avatar" alt="头像">' if avatar_data else '<div class="user-avatar-placeholder">👤</div>'
titles_html += f"""
<div class="user-title">
<div class="user-info">
{avatar_html}
<div class="user-details">
<div class="user-name">{title.name}</div>
<div class="user-badges">
<div class="user-title-badge">{title.title}</div>
<div class="user-mbti">{title.mbti}</div>
</div>
</div>
</div>
<div class="user-reason">{title.reason}</div>
</div>
"""
# 构建金句HTML
quotes_html = ""
max_golden_quotes = self.config_manager.get_max_golden_quotes()
for quote in stats.golden_quotes[:max_golden_quotes]:
quotes_html += f"""
<div class="quote-item">
<div class="quote-content">"{quote.content}"</div>
<div class="quote-author">—— {quote.sender}</div>
<div class="quote-reason">{quote.reason}</div>
</div>
"""
# 返回扁平化的渲染数据
return {
"current_date": datetime.now().strftime('%Y年%m月%d'),
"current_datetime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"message_count": stats.message_count,
"participant_count": stats.participant_count,
"total_characters": stats.total_characters,
"emoji_count": stats.emoji_count,
"most_active_period": stats.most_active_period,
"topics_html": topics_html,
"titles_html": titles_html,
"quotes_html": quotes_html,
"total_tokens": stats.token_usage.total_tokens,
"prompt_tokens": stats.token_usage.prompt_tokens,
"completion_tokens": stats.token_usage.completion_tokens
}
def _render_html_template(self, template: str, data: Dict, use_jinja_style: bool = False) -> str:
"""HTML模板渲染,支持两种占位符格式
Args:
template: HTML模板字符串
data: 渲染数据
use_jinja_style: 是否使用Jinja2风格的{{ }}占位符,否则使用{}占位符
"""
result = template
# 调试:记录渲染数据
logger.info(f"渲染数据键: {list(data.keys())}, 使用Jinja风格: {use_jinja_style}")
for key, value in data.items():
if use_jinja_style:
# 图片模板使用{{ }}占位符
placeholder = f"{{{{ {key} }}}}"
else:
# PDF模板使用{}占位符
placeholder = f"{{{key}}}"
# 调试:记录替换过程
if placeholder in result:
logger.debug(f"替换 {placeholder} -> {str(value)[:100]}...")
result = result.replace(placeholder, str(value))
# 检查是否还有未替换的占位符
import re
if use_jinja_style:
remaining_placeholders = re.findall(r'\{\{[^}]+\}\}', result)
else:
remaining_placeholders = re.findall(r'\{[^}]+\}', result)
if remaining_placeholders:
logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}")
return result
async def _get_user_avatar(self, user_id: str) -> Optional[str]:
"""获取用户头像的base64编码"""
try:
avatar_url = f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=640"
async with aiohttp.ClientSession() as client:
response = await client.get(avatar_url)
response.raise_for_status()
avatar_data = await response.read()
# 转换为base64编码
avatar_base64 = base64.b64encode(avatar_data).decode('utf-8')
return f"data:image/jpeg;base64,{avatar_base64}"
except Exception as e:
logger.error(f"获取用户头像失败 {user_id}: {e}")
return None
async def _html_to_pdf(self, html_content: str, output_path: str) -> bool:
"""将 HTML 内容转换为 PDF 文件"""
try:
# 确保 pyppeteer 可用
if not self.config_manager.pyppeteer_available:
logger.error("pyppeteer 不可用,无法生成 PDF")
return False
# 动态导入 pyppeteer
import pyppeteer
from pyppeteer import launch
import sys
import os
# 尝试启动浏览器,如果 Chromium 不存在会自动下载
logger.info("启动浏览器进行 PDF 转换")
# 配置浏览器启动参数,避免 Chromium 下载问题
launch_options = {
'headless': True,
'args': [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-first-run',
'--disable-extensions',
'--disable-default-apps'
]
}
# 如果是 Windows 系统,尝试使用系统 Chrome
if sys.platform.startswith('win'):
# 常见的 Chrome 安装路径
chrome_paths = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Users\{}\AppData\Local\Google\Chrome\Application\chrome.exe".format(os.environ.get('USERNAME', '')),
]
for chrome_path in chrome_paths:
if Path(chrome_path).exists():
launch_options['executablePath'] = chrome_path
logger.info(f"使用系统 Chrome: {chrome_path}")
break
browser = await launch(**launch_options)
page = await browser.newPage()
# 设置页面内容 (pyppeteer 1.0.2 版本的 API)
await page.setContent(html_content)
# 等待页面加载完成
try:
await page.waitForSelector('body', {'timeout': 10000})
except Exception:
# 如果等待失败,继续执行(可能页面已经加载完成)
pass
# 导出 PDF
await page.pdf({
'path': output_path,
'format': 'A4',
'printBackground': True,
'margin': {
'top': '10mm',
'right': '10mm',
'bottom': '10mm',
'left': '10mm'
},
'scale': 0.8
})
await browser.close()
logger.info(f"PDF 生成成功: {output_path}")
return True
except Exception as e:
error_msg = str(e)
if "Chromium downloadable not found" in error_msg:
logger.error("Chromium 下载失败,建议安装 pyppeteer2 或使用系统 Chrome")
elif "No usable sandbox" in error_msg:
logger.error("沙盒权限问题,已尝试禁用沙盒")
else:
logger.error(f"HTML 转 PDF 失败: {e}")
return False
+200
View File
@@ -0,0 +1,200 @@
"""
HTML模板模块
严格按照main-backup中的实现,包含图片报告和PDF报告的不同HTML模板
"""
class HTMLTemplates:
"""HTML模板管理类"""
@staticmethod
def get_image_template() -> str:
"""获取图片报告的HTML模板(使用{{ }}占位符)"""
return """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>群聊日常分析报告</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans SC', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh; padding: 20px; line-height: 1.6; color: #1a1a1a;
}
.container { max-width: 1200px; margin: 0 auto; background: #ffffff; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); overflow: hidden; }
.header { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 48px 40px; text-align: center; border-radius: 24px 24px 0 0; }
.header h1 { font-size: 2.5em; font-weight: 300; margin-bottom: 12px; letter-spacing: -1px; }
.header .date { font-size: 1em; opacity: 0.8; font-weight: 300; letter-spacing: 0.5px; }
.content { padding: 32px; }
.topics-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin-bottom: 32px; align-items: start; }
.users-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 32px; align-items: start; }
.section { margin-bottom: 0; }
.full-width-section { grid-column: 1 / -1; margin-bottom: 32px; }
.section-title { font-size: 1.3em; font-weight: 600; margin-bottom: 20px; color: #4a5568; letter-spacing: -0.3px; display: flex; align-items: center; gap: 8px; border-bottom: 2px solid #e2e8f0; padding-bottom: 8px; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 32px; }
.stat-card { background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); padding: 32px 24px; text-align: center; border-radius: 20px; border: 1px solid #e2e8f0; transition: all 0.3s ease; }
.stat-card:hover { background: linear-gradient(135deg, #ffffff 0%, #f7fafc 100%); transform: translateY(-4px); box-shadow: 0 12px 32px rgba(102, 126, 234, 0.15); }
.stat-number { font-size: 2.5em; font-weight: 300; color: #4299e1; margin-bottom: 8px; display: block; letter-spacing: -1px; }
.stat-label { font-size: 0.8em; color: #666666; font-weight: 400; text-transform: uppercase; letter-spacing: 1px; }
.active-period { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 32px; text-align: center; margin: 48px 0; border-radius: 20px; box-shadow: 0 8px 24px rgba(66, 153, 225, 0.3); }
.active-period .time { font-size: 2.5em; font-weight: 200; margin-bottom: 8px; letter-spacing: -1px; }
.active-period .label { font-size: 0.8em; opacity: 0.8; font-weight: 300; text-transform: uppercase; letter-spacing: 1px; }
.topic-item { background: #ffffff; padding: 20px; margin-bottom: 0; border-radius: 12px; border: 1px solid #e5e5e5; transition: all 0.3s ease; display: flex; flex-direction: column; }
.topic-item:hover { background: #f8f9fa; transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); }
.topic-header { display: flex; align-items: center; margin-bottom: 20px; }
.topic-number { background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); color: #ffffff; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 500; margin-right: 16px; font-size: 0.9em; box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3); }
.topic-title { font-weight: 600; color: #2d3748; font-size: 1.1em; letter-spacing: -0.3px; }
.topic-contributors { color: #666666; font-size: 0.8em; margin-bottom: 16px; text-transform: uppercase; letter-spacing: 0.5px; }
.topic-detail { color: #333333; line-height: 1.6; font-size: 0.9em; font-weight: 300; }
.user-title { background: #ffffff; padding: 16px; margin-bottom: 0; border-radius: 12px; border: 1px solid #e5e5e5; display: flex; align-items: flex-start; justify-content: space-between; transition: all 0.3s ease; min-height: 80px; }
.user-title:hover { background: #f8f9fa; transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); }
.user-info { display: flex; align-items: center; flex: 1; }
.user-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 16px; border: 2px solid #f0f0f0; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
.user-avatar-placeholder { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #f0f0f0 0%, #e2e8f0 100%); display: flex; align-items: center; justify-content: center; margin-right: 16px; font-size: 1em; color: #999999; border: 2px solid #e5e5e5; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
.user-details { flex: 1; }
.user-name { font-weight: 600; color: #2d3748; margin-bottom: 12px; font-size: 1em; letter-spacing: -0.2px; }
.user-badges { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.user-title-badge { background: linear-gradient(135deg, #4299e1 0%, #3182ce 100%); color: #ffffff; padding: 6px 16px; border-radius: 20px; font-size: 0.75em; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 2px 8px rgba(66, 153, 225, 0.3); }
.user-mbti { background: linear-gradient(135deg, #667eea 0%, #5a67d8 100%); color: #ffffff; padding: 6px 12px; border-radius: 16px; font-weight: 500; font-size: 0.75em; text-transform: uppercase; letter-spacing: 1px; box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); }
.user-reason { color: #666666; font-size: 0.8em; text-align: right; line-height: 1.4; font-weight: 300; margin-left: 16px; flex: 1; word-wrap: break-word; overflow-wrap: break-word; }
.quote-item { background: linear-gradient(135deg, #faf5ff 0%, #f7fafc 100%); padding: 16px; margin-bottom: 16px; border-radius: 12px; border: 1px solid #e2e8f0; position: relative; transition: all 0.3s ease; }
.quote-item:hover { background: linear-gradient(135deg, #ffffff 0%, #faf5ff 100%); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15); }
.quote-content { font-size: 1.1em; color: #2d3748; font-weight: 500; line-height: 1.6; margin-bottom: 12px; font-style: italic; letter-spacing: 0.2px; }
.quote-author { font-size: 0.9em; color: #4299e1; font-weight: 600; margin-bottom: 8px; text-align: right; }
.quote-reason { font-size: 0.8em; color: #666666; font-style: normal; background: rgba(66, 153, 225, 0.1); padding: 8px 12px; border-radius: 12px; border-left: 3px solid #4299e1; }
.footer { background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); color: #ffffff; text-align: center; padding: 32px; font-size: 0.8em; font-weight: 300; letter-spacing: 0.5px; opacity: 0.9; }
@media (min-width: 1400px) { .container { max-width: 1400px; } .topics-grid { grid-template-columns: repeat(3, 1fr); } .users-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { body { padding: 10px; } .container { margin: 0; max-width: 100%; } .header { padding: 24px 20px; } .header h1 { font-size: 1.8em; } .content { padding: 20px; } .topics-grid { grid-template-columns: 1fr; } .users-grid { grid-template-columns: 1fr; } .stats-grid { grid-template-columns: 1fr 1fr; gap: 12px; } .stat-card { padding: 20px 16px; } .topic-item { padding: 20px; } .user-title { flex-direction: column; align-items: flex-start; gap: 12px; padding: 16px; min-height: auto; } .user-info { width: 100%; } .user-reason { text-align: left; max-width: none; margin-left: 0; margin-top: 8px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 群聊日常分析报告</h1>
<div class="date">{{ current_date }}</div>
</div>
<div class="content">
<div class="section full-width-section">
<h2 class="section-title">📈 基础统计</h2>
<div class="stats-grid">
<div class="stat-card"><div class="stat-number">{{ message_count }}</div><div class="stat-label">消息总数</div></div>
<div class="stat-card"><div class="stat-number">{{ participant_count }}</div><div class="stat-label">参与人数</div></div>
<div class="stat-card"><div class="stat-number">{{ total_characters }}</div><div class="stat-label">总字符数</div></div>
<div class="stat-card"><div class="stat-number">{{ emoji_count }}</div><div class="stat-label">表情数量</div></div>
</div>
<div class="active-period">
<div class="time">{{ most_active_period }}</div>
<div class="label">最活跃时段</div>
</div>
</div>
<div class="section">
<h2 class="section-title">💬 热门话题</h2>
<div class="topics-grid">{{ topics_html | safe }}</div>
</div>
<div class="section">
<h2 class="section-title">🏆 群友称号</h2>
<div class="users-grid">{{ titles_html | safe }}</div>
</div>
<div class="section">
<h2 class="section-title">💬 群圣经</h2>
{{ quotes_html | safe }}
</div>
</div>
<div class="footer">
由 AstrBot QQ群日常分析插件 生成 | {{ current_datetime }} | SXP-Simon/astrbot-qq-group-daily-analysis<br>
<small style="opacity: 0.8; font-size: 0.9em;">🤖 AI分析消耗:{{ total_tokens }} tokens (输入: {{ prompt_tokens }}, 输出: {{ completion_tokens }})</small>
</div>
</div>
</body>
</html>"""
@staticmethod
def get_pdf_template() -> str:
"""获取PDF报告的HTML模板(使用{}占位符)"""
return """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>群聊日常分析报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Microsoft YaHei', 'SimHei', sans-serif; background: #ffffff; color: #1a1a1a; line-height: 1.6; font-size: 14px; }
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 30px; text-align: center; border-radius: 12px; margin-bottom: 30px; }
.header h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; }
.header .date { font-size: 16px; opacity: 0.9; }
.section { margin-bottom: 40px; page-break-inside: avoid; }
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #4a5568; border-bottom: 2px solid #4299e1; padding-bottom: 8px; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 30px; }
.stat-card { background: #f8f9ff; padding: 20px; text-align: center; border-radius: 8px; border: 1px solid #e2e8f0; }
.stat-number { font-size: 24px; font-weight: 600; color: #4299e1; margin-bottom: 5px; }
.stat-label { font-size: 12px; color: #666666; text-transform: uppercase; }
.active-period { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 25px; text-align: center; margin: 30px 0; border-radius: 8px; }
.active-period .time { font-size: 28px; font-weight: 300; margin-bottom: 5px; }
.active-period .label { font-size: 14px; opacity: 0.9; }
.topic-item { background: #ffffff; padding: 20px; margin-bottom: 15px; border-radius: 8px; border: 1px solid #e2e8f0; page-break-inside: avoid; }
.topic-header { display: flex; align-items: center; margin-bottom: 12px; }
.topic-number { background: #4299e1; color: #ffffff; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 600; margin-right: 12px; font-size: 12px; }
.topic-title { font-weight: 600; color: #2d3748; font-size: 16px; }
.topic-contributors { color: #666666; font-size: 12px; margin-bottom: 10px; }
.topic-detail { color: #333333; line-height: 1.6; font-size: 14px; }
.user-title { background: #ffffff; padding: 20px; margin-bottom: 15px; border-radius: 8px; border: 1px solid #e2e8f0; display: flex; align-items: flex-start; justify-content: space-between; page-break-inside: avoid; }
.user-info { display: flex; align-items: center; flex: 1; }
.user-details { flex: 1; }
.user-name { font-weight: 600; color: #2d3748; margin-bottom: 8px; font-size: 16px; }
.user-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.user-title-badge { background: #4299e1; color: #ffffff; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.user-mbti { background: #667eea; color: #ffffff; padding: 4px 8px; border-radius: 8px; font-weight: 500; font-size: 12px; }
.user-reason { color: #666666; font-size: 12px; max-width: 200px; text-align: right; line-height: 1.4; }
.user-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 15px; border: 2px solid #e2e8f0; object-fit: cover; flex-shrink: 0; }
.user-avatar-placeholder { width: 40px; height: 40px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; margin-right: 15px; font-size: 18px; color: #666666; flex-shrink: 0; }
.quote-item { background: #faf5ff; padding: 20px; margin-bottom: 15px; border-radius: 8px; border: 1px solid #e2e8f0; page-break-inside: avoid; }
.quote-content { font-size: 16px; color: #2d3748; font-weight: 500; line-height: 1.6; margin-bottom: 10px; font-style: italic; }
.quote-author { font-size: 14px; color: #4299e1; font-weight: 600; margin-bottom: 8px; text-align: right; }
.quote-reason { font-size: 12px; color: #666666; background: rgba(66, 153, 225, 0.1); padding: 8px 12px; border-radius: 6px; border-left: 3px solid #4299e1; }
.footer { background: #f8f9ff; color: #666666; text-align: center; padding: 20px; font-size: 12px; border-radius: 8px; margin-top: 40px; }
@media print { body { font-size: 12px; } .container { padding: 10px; } .header { padding: 20px; } .section { margin-bottom: 30px; } .stats-grid { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 群聊日常分析报告</h1>
<div class="date">{current_date}</div>
</div>
<div class="section">
<h2 class="section-title">📈 基础统计</h2>
<div class="stats-grid">
<div class="stat-card"><div class="stat-number">{message_count}</div><div class="stat-label">消息总数</div></div>
<div class="stat-card"><div class="stat-number">{participant_count}</div><div class="stat-label">参与人数</div></div>
<div class="stat-card"><div class="stat-number">{total_characters}</div><div class="stat-label">总字符数</div></div>
<div class="stat-card"><div class="stat-number">{emoji_count}</div><div class="stat-label">表情数量</div></div>
</div>
<div class="active-period">
<div class="time">{most_active_period}</div>
<div class="label">最活跃时段</div>
</div>
</div>
<div class="section">
<h2 class="section-title">💬 热门话题</h2>
{topics_html}
</div>
<div class="section">
<h2 class="section-title">🏆 群友称号</h2>
{titles_html}
</div>
<div class="section">
<h2 class="section-title">💬 群圣经</h2>
{quotes_html}
</div>
<div class="footer">
由 AstrBot QQ群日常分析插件 生成 | {current_datetime} | SXP-Simon/astrbot-qq-group-daily-analysis<br>
<small style="opacity: 0.8; font-size: 0.9em;">🤖 AI分析消耗:{total_tokens} tokens (输入: {prompt_tokens}, 输出: {completion_tokens})</small>
</div>
</div>
</body>
</html>"""
+10
View File
@@ -0,0 +1,10 @@
"""
调度和自动化模块
包含定时任务和自动分析功能
"""
from .auto_scheduler import AutoScheduler
__all__ = [
'AutoScheduler'
]
+317
View File
@@ -0,0 +1,317 @@
"""
自动调度器模块
负责定时任务和自动分析功能
"""
import asyncio
from datetime import datetime, timedelta
from typing import Optional
from astrbot.api import logger
class AutoScheduler:
"""自动调度器"""
def __init__(self, config_manager, message_handler, analyzer, report_generator, html_render_func=None):
self.config_manager = config_manager
self.message_handler = message_handler
self.analyzer = analyzer
self.report_generator = report_generator
self.html_render_func = html_render_func
self.scheduler_task = None
self.bot_instance = None
self.last_execution_date = None # 记录上次执行日期,防止重复执行
def set_bot_instance(self, bot_instance):
"""设置bot实例"""
self.bot_instance = bot_instance
# 同时设置消息处理器的bot实例
asyncio.create_task(self.message_handler.set_bot_qq_id(bot_instance))
async def start_scheduler(self):
"""启动定时任务调度器"""
if not self.config_manager.get_enable_auto_analysis():
logger.info("自动分析功能未启用")
return
# 延迟启动,给系统时间初始化
await asyncio.sleep(10)
logger.info(f"启动定时任务调度器,自动分析时间: {self.config_manager.get_auto_analysis_time()}")
self.scheduler_task = asyncio.create_task(self._scheduler_loop())
async def stop_scheduler(self):
"""停止定时任务调度器"""
if self.scheduler_task and not self.scheduler_task.done():
self.scheduler_task.cancel()
logger.info("已停止定时任务调度器")
async def restart_scheduler(self):
"""重启定时任务调度器"""
await self.stop_scheduler()
if self.config_manager.get_enable_auto_analysis():
await self.start_scheduler()
async def _scheduler_loop(self):
"""调度器主循环"""
while True:
try:
now = datetime.now()
target_time = datetime.strptime(self.config_manager.get_auto_analysis_time(), "%H:%M").replace(
year=now.year, month=now.month, day=now.day
)
# 如果今天的目标时间已过,设置为明天
if now >= target_time:
target_time += timedelta(days=1)
# 计算等待时间
wait_seconds = (target_time - now).total_seconds()
logger.info(f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f}")
# 等待到目标时间
await asyncio.sleep(wait_seconds)
# 执行自动分析
if self.config_manager.get_enable_auto_analysis():
# 检查是否今天已经执行过
today = now.date()
if self.last_execution_date == today:
logger.info(f"今天 {today} 已经执行过自动分析,跳过执行")
# 等待到明天再检查
await asyncio.sleep(3600) # 等待1小时后再检查
continue
logger.info("开始执行定时分析")
await self._run_auto_analysis()
self.last_execution_date = today # 记录执行日期
logger.info(f"定时分析执行完成,记录执行日期: {today}")
else:
logger.info("自动分析已禁用,跳过执行")
break
except Exception as e:
logger.error(f"定时任务调度器错误: {e}")
# 等待5分钟后重试
await asyncio.sleep(300)
async def _run_auto_analysis(self):
"""执行自动分析"""
try:
logger.info("开始执行自动群聊分析")
# 为每个启用的群执行分析
enabled_groups = self.config_manager.get_enabled_groups()
for group_id in enabled_groups:
try:
logger.info(f"为群 {group_id} 执行自动分析")
await self._perform_auto_analysis_for_group(group_id)
except Exception as e:
logger.error(f"{group_id} 自动分析失败: {e}")
except Exception as e:
logger.error(f"自动分析执行失败: {e}")
async def _perform_auto_analysis_for_group(self, group_id: str):
"""为指定群执行自动分析"""
try:
if not self.bot_instance:
logger.warning(f"{group_id} 自动分析跳过:未获取到bot实例")
return
logger.info(f"开始为群 {group_id} 执行自动分析")
# 获取群聊消息
analysis_days = self.config_manager.get_analysis_days()
messages = await self.message_handler.fetch_group_messages(self.bot_instance, group_id, analysis_days)
if not messages:
logger.warning(f"{group_id} 未获取到足够的消息记录")
return
# 检查消息数量
min_threshold = self.config_manager.get_min_messages_threshold()
if len(messages) < min_threshold:
logger.warning(f"{group_id} 消息数量不足({len(messages)}条),跳过分析")
return
logger.info(f"{group_id} 获取到 {len(messages)} 条消息,开始分析")
# 进行分析
analysis_result = await self.analyzer.analyze_messages(messages, group_id)
if not analysis_result:
logger.error(f"{group_id} 分析失败")
return
# 生成并发送报告
await self._send_analysis_report(group_id, analysis_result)
except Exception as e:
logger.error(f"{group_id} 自动分析执行失败: {e}", exc_info=True)
async def _send_analysis_report(self, group_id: str, analysis_result: dict):
"""发送分析报告到群"""
try:
output_format = self.config_manager.get_output_format()
if output_format == "image":
if self.html_render_func:
# 使用图片格式
logger.info(f"{group_id} 自动分析使用图片报告格式")
image_url = await self.report_generator.generate_image_report(analysis_result, group_id, self.html_render_func)
if image_url:
await self._send_image_message(group_id, image_url)
logger.info(f"{group_id} 图片报告发送成功")
else:
# 图片生成失败,回退到文本
logger.warning(f"{group_id} 图片报告生成失败,回退到文本报告")
text_report = self.report_generator.generate_text_report(analysis_result)
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
else:
# 没有html_render函数,回退到文本报告
logger.warning(f"{group_id} 缺少html_render函数,回退到文本报告")
text_report = self.report_generator.generate_text_report(analysis_result)
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
elif output_format == "pdf":
if not self.config_manager.pyppeteer_available:
logger.warning(f"{group_id} PDF功能不可用,回退到文本报告")
text_report = self.report_generator.generate_text_report(analysis_result)
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
else:
pdf_path = await self.report_generator.generate_pdf_report(analysis_result, group_id)
if pdf_path:
await self._send_pdf_file(group_id, pdf_path)
logger.info(f"{group_id} 自动分析完成,已发送PDF报告")
else:
logger.error(f"{group_id} PDF报告生成失败,回退到文本报告")
text_report = self.report_generator.generate_text_report(analysis_result)
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
else:
text_report = self.report_generator.generate_text_report(analysis_result)
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
logger.info(f"{group_id} 自动分析完成,已发送报告")
except Exception as e:
logger.error(f"发送分析报告到群 {group_id} 失败: {e}")
async def _send_image_message(self, group_id: str, image_url: str):
"""发送图片消息到群"""
try:
if not self.bot_instance:
logger.error(f"{group_id} 发送图片失败:缺少bot实例")
return
# 发送图片消息到群
await self.bot_instance.api.call_action(
"send_group_msg",
group_id=group_id,
message=[{
"type": "text",
"data": {"text": "📊 每日群聊分析报告已生成:"}
}, {
"type": "image",
"data": {"url": image_url}
}]
)
logger.info(f"{group_id} 图片消息发送成功")
except Exception as e:
logger.error(f"发送图片消息到群 {group_id} 失败: {e}")
async def _send_text_message(self, group_id: str, text_content: str):
"""发送文本消息到群"""
try:
if not self.bot_instance:
logger.error(f"{group_id} 发送文本失败:缺少bot实例")
return
# 发送文本消息到群
await self.bot_instance.api.call_action(
"send_group_msg",
group_id=group_id,
message=text_content
)
logger.info(f"{group_id} 文本消息发送成功")
except Exception as e:
logger.error(f"发送文本消息到群 {group_id} 失败: {e}")
async def _send_pdf_file(self, group_id: str, pdf_path: str):
"""发送PDF文件到群"""
try:
if not self.bot_instance:
logger.error(f"{group_id} 发送PDF失败:缺少bot实例")
return
# 发送PDF文件到群
await self.bot_instance.api.call_action(
"send_group_msg",
group_id=group_id,
message=[{
"type": "text",
"data": {"text": "📊 每日群聊分析报告已生成:"}
}, {
"type": "file",
"data": {"file": pdf_path}
}]
)
logger.info(f"{group_id} PDF文件发送成功")
except Exception as e:
logger.error(f"发送PDF文件到群 {group_id} 失败: {e}")
# 发送失败提示
try:
await self.bot_instance.api.call_action(
"send_group_msg",
group_id=group_id,
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}"
)
except Exception as e2:
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
async def _send_text_message(self, group_id: str, message: str):
"""发送文本消息到群"""
try:
if not self.bot_instance:
return
await self.bot_instance.api.call_action(
"send_group_msg",
group_id=group_id,
message=message
)
except Exception as e:
logger.error(f"发送文本消息到群 {group_id} 失败: {e}")
async def _send_pdf_file(self, group_id: str, pdf_path: str):
"""发送PDF文件到群"""
try:
if not self.bot_instance:
return
await self.bot_instance.api.call_action(
"send_group_msg",
group_id=group_id,
message=[{
"type": "text",
"data": {"text": "📊 每日群聊分析报告已生成:"}
}, {
"type": "file",
"data": {"file": pdf_path}
}]
)
except Exception as e:
logger.error(f"发送PDF文件到群 {group_id} 失败: {e}")
# 如果发送PDF失败,尝试发送提示信息
try:
await self.bot_instance.api.call_action(
"send_group_msg",
group_id=group_id,
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}"
)
except Exception as e2:
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
+12
View File
@@ -0,0 +1,12 @@
"""
工具函数模块
包含PDF处理和通用工具函数
"""
from .pdf_utils import PDFInstaller
from .helpers import MessageAnalyzer
__all__ = [
'PDFInstaller',
'MessageAnalyzer'
]
+76
View File
@@ -0,0 +1,76 @@
"""
通用工具函数模块
包含消息分析和其他通用功能
"""
from typing import List, Dict
from ...src.models.data_models import GroupStatistics, SummaryTopic, UserTitle, GoldenQuote, TokenUsage
from ...src.core.message_handler import MessageHandler
from ...src.analysis.llm_analyzer import LLMAnalyzer
from ...src.analysis.statistics import UserAnalyzer
class MessageAnalyzer:
"""消息分析器 - 整合所有分析功能"""
def __init__(self, context, config_manager):
self.context = context
self.config_manager = config_manager
self.message_handler = MessageHandler(config_manager)
self.llm_analyzer = LLMAnalyzer(context, config_manager)
self.user_analyzer = UserAnalyzer(config_manager)
async def set_bot_instance(self, bot_instance):
"""设置bot实例"""
await self.message_handler.set_bot_qq_id(bot_instance)
async def analyze_messages(self, messages: List[Dict], group_id: str) -> Dict:
"""完整的消息分析流程"""
try:
# 基础统计
statistics = self.message_handler.calculate_statistics(messages)
# 用户分析
user_analysis = self.user_analyzer.analyze_users(messages)
# LLM分析
topics = []
user_titles = []
golden_quotes = []
total_token_usage = TokenUsage()
# 话题分析
if self.config_manager.get_topic_analysis_enabled():
topics, topic_tokens = await self.llm_analyzer.analyze_topics(messages)
total_token_usage.prompt_tokens += topic_tokens.prompt_tokens
total_token_usage.completion_tokens += topic_tokens.completion_tokens
total_token_usage.total_tokens += topic_tokens.total_tokens
# 用户称号分析
if self.config_manager.get_user_title_analysis_enabled():
user_titles, title_tokens = await self.llm_analyzer.analyze_user_titles(messages, user_analysis)
total_token_usage.prompt_tokens += title_tokens.prompt_tokens
total_token_usage.completion_tokens += title_tokens.completion_tokens
total_token_usage.total_tokens += title_tokens.total_tokens
# 金句分析
golden_quotes, quote_tokens = await self.llm_analyzer.analyze_golden_quotes(messages)
total_token_usage.prompt_tokens += quote_tokens.prompt_tokens
total_token_usage.completion_tokens += quote_tokens.completion_tokens
total_token_usage.total_tokens += quote_tokens.total_tokens
# 更新统计数据
statistics.golden_quotes = golden_quotes
statistics.token_usage = total_token_usage
return {
"statistics": statistics,
"topics": topics,
"user_titles": user_titles,
"user_analysis": user_analysis
}
except Exception as e:
from astrbot.api import logger
logger.error(f"消息分析失败: {e}")
return None
+57
View File
@@ -0,0 +1,57 @@
"""
PDF工具模块
负责PDF相关的安装和管理功能
"""
import sys
import asyncio
from astrbot.api import logger
class PDFInstaller:
"""PDF功能安装器"""
@staticmethod
async def install_pyppeteer(config_manager):
"""安装pyppeteer依赖"""
try:
logger.info("开始安装 pyppeteer...")
# 使用asyncio安装pyppeteer和兼容的websockets版本
logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...")
process = await asyncio.create_subprocess_exec(
sys.executable, "-m", "pip", "install",
"pyppeteer==1.0.2", "websockets==10.4",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
logger.info("pyppeteer 安装成功")
logger.info(f"安装输出: {stdout.decode()}")
# 重新加载pyppeteer模块
success = config_manager.reload_pyppeteer()
if success:
return "✅ pyppeteer 安装成功!PDF 功能现已可用。"
else:
return "⚠️ pyppeteer 安装完成,但重新加载失败。请重启 AstrBot 以使用 PDF 功能。"
else:
error_msg = stderr.decode()
logger.error(f"pyppeteer 安装失败: {error_msg}")
return f"❌ pyppeteer 安装失败: {error_msg}"
except Exception as e:
logger.error(f"安装 pyppeteer 时出错: {e}")
return f"❌ 安装过程中出错: {str(e)}"
@staticmethod
def get_pdf_status(config_manager) -> str:
"""获取PDF功能状态"""
if config_manager.pyppeteer_available:
version = config_manager.pyppeteer_version or "未知版本"
return f"✅ PDF 功能可用 (pyppeteer {version})"
else:
return "❌ PDF 功能不可用 - 需要安装 pyppeteer"