fix(message_handler): 取消分页拉取、Ruff 自动修复

Merge pull request #39 from exynos967/master
This commit is contained in:
Helian Nuits
2025-11-09 21:56:55 +08:00
committed by GitHub
29 changed files with 1433 additions and 1091 deletions
+72 -28
View File
@@ -1,4 +1,4 @@
""" """
QQ群日常分析插件 QQ群日常分析插件
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等 基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
@@ -7,12 +7,13 @@ QQ群日常分析插件
import asyncio import asyncio
from typing import Optional from typing import Optional
from pathlib import Path
from astrbot.api.event import filter from astrbot.api.event import filter
from astrbot.api.star import Context, Star from astrbot.api.star import Context, Star
from astrbot.api import logger, AstrBotConfig from astrbot.api import logger, AstrBotConfig
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
AiocqhttpMessageEvent,
)
from astrbot.core.message.components import File from astrbot.core.message.components import File
from astrbot.core.star.filter.permission import PermissionType from astrbot.core.star.filter.permission import PermissionType
@@ -39,7 +40,12 @@ class QQGroupDailyAnalysis(Star):
self.config = config self.config = config
# 初始化模块化组件 # 初始化模块化组件
global config_manager, bot_manager, message_analyzer, report_generator, auto_scheduler global \
config_manager, \
bot_manager, \
message_analyzer, \
report_generator, \
auto_scheduler
config_manager = ConfigManager(config) config_manager = ConfigManager(config)
bot_manager = BotManager(config_manager) bot_manager = BotManager(config_manager)
@@ -52,7 +58,7 @@ class QQGroupDailyAnalysis(Star):
message_analyzer, message_analyzer,
report_generator, report_generator,
bot_manager, bot_manager,
self.html_render # 传入html_render函数 self.html_render, # 传入html_render函数
) )
# 延迟启动自动调度器,给系统时间初始化 # 延迟启动自动调度器,给系统时间初始化
@@ -86,7 +92,12 @@ class QQGroupDailyAnalysis(Star):
try: try:
logger.info("开始清理QQ群日常分析插件资源...") logger.info("开始清理QQ群日常分析插件资源...")
global auto_scheduler, bot_manager, message_analyzer, report_generator, config_manager global \
auto_scheduler, \
bot_manager, \
message_analyzer, \
report_generator, \
config_manager
# 停止自动调度器 # 停止自动调度器
if auto_scheduler: if auto_scheduler:
@@ -123,7 +134,9 @@ class QQGroupDailyAnalysis(Star):
@filter.command("群分析") @filter.command("群分析")
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
async def analyze_group_daily(self, event: AiocqhttpMessageEvent, days: Optional[int] = None): async def analyze_group_daily(
self, event: AiocqhttpMessageEvent, days: Optional[int] = None
):
""" """
分析群聊日常活动 分析群聊日常活动
用法: /群分析 [天数] 用法: /群分析 [天数]
@@ -147,7 +160,9 @@ class QQGroupDailyAnalysis(Star):
return return
# 设置分析天数 # 设置分析天数
analysis_days = days if days and 1 <= days <= 7 else config_manager.get_analysis_days() analysis_days = (
days if days and 1 <= days <= 7 else config_manager.get_analysis_days()
)
yield event.plain_result(f"🔍 开始分析群聊近{analysis_days}天的活动,请稍候...") yield event.plain_result(f"🔍 开始分析群聊近{analysis_days}天的活动,请稍候...")
@@ -156,21 +171,31 @@ class QQGroupDailyAnalysis(Star):
try: try:
# 获取群聊消息 # 获取群聊消息
messages = await message_analyzer.message_handler.fetch_group_messages(bot_manager.get_bot_instance(), group_id, analysis_days) messages = await message_analyzer.message_handler.fetch_group_messages(
bot_manager.get_bot_instance(), group_id, analysis_days
)
if not messages: if not messages:
yield event.plain_result("❌ 未找到足够的群聊记录,请确保群内有足够的消息历史") yield event.plain_result(
"❌ 未找到足够的群聊记录,请确保群内有足够的消息历史"
)
return return
# 检查消息数量是否足够分析 # 检查消息数量是否足够分析
min_threshold = config_manager.get_min_messages_threshold() min_threshold = config_manager.get_min_messages_threshold()
if len(messages) < min_threshold: if len(messages) < min_threshold:
yield event.plain_result(f"❌ 消息数量不足({len(messages)}条),至少需要{min_threshold}条消息才能进行有效分析") yield event.plain_result(
f"❌ 消息数量不足({len(messages)}条),至少需要{min_threshold}条消息才能进行有效分析"
)
return return
yield event.plain_result(f"📊 已获取{len(messages)}条消息,正在进行智能分析...") yield event.plain_result(
f"📊 已获取{len(messages)}条消息,正在进行智能分析..."
)
# 进行分析 - 传递 unified_msg_origin 以获取正确的 LLM 提供商 # 进行分析 - 传递 unified_msg_origin 以获取正确的 LLM 提供商
analysis_result = await message_analyzer.analyze_messages(messages, group_id, event.unified_msg_origin) analysis_result = await message_analyzer.analyze_messages(
messages, group_id, event.unified_msg_origin
)
# 检查分析结果 # 检查分析结果
if not analysis_result or not analysis_result.get("statistics"): if not analysis_result or not analysis_result.get("statistics"):
@@ -180,23 +205,32 @@ class QQGroupDailyAnalysis(Star):
# 生成报告 # 生成报告
output_format = config_manager.get_output_format() output_format = config_manager.get_output_format()
if output_format == "image": if output_format == "image":
image_url = await report_generator.generate_image_report(analysis_result, group_id, self.html_render) image_url = await report_generator.generate_image_report(
analysis_result, group_id, self.html_render
)
if image_url: if image_url:
yield event.image_result(image_url) yield event.image_result(image_url)
else: else:
# 如果图片生成失败,回退到文本报告 # 如果图片生成失败,回退到文本报告
logger.warning("图片报告生成失败,回退到文本报告") logger.warning("图片报告生成失败,回退到文本报告")
text_report = report_generator.generate_text_report(analysis_result) text_report = report_generator.generate_text_report(analysis_result)
yield event.plain_result(f"⚠️ 图片报告生成失败,以下是文本版本:\n\n{text_report}") yield event.plain_result(
f"⚠️ 图片报告生成失败,以下是文本版本:\n\n{text_report}"
)
elif output_format == "pdf": elif output_format == "pdf":
if not config_manager.pyppeteer_available: if not config_manager.pyppeteer_available:
yield event.plain_result("❌ PDF 功能不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2") yield event.plain_result(
"❌ PDF 功能不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2"
)
return return
pdf_path = await report_generator.generate_pdf_report(analysis_result, group_id) pdf_path = await report_generator.generate_pdf_report(
analysis_result, group_id
)
if pdf_path: if pdf_path:
# 发送 PDF 文件 # 发送 PDF 文件
from pathlib import Path from pathlib import Path
pdf_file = File(name=Path(pdf_path).name, file=pdf_path) pdf_file = File(name=Path(pdf_path).name, file=pdf_path)
result = event.make_result() result = event.make_result()
result.chain.append(pdf_file) result.chain.append(pdf_file)
@@ -212,20 +246,24 @@ class QQGroupDailyAnalysis(Star):
# 回退到文本报告 # 回退到文本报告
logger.warning("PDF 报告生成失败,回退到文本报告") logger.warning("PDF 报告生成失败,回退到文本报告")
text_report = report_generator.generate_text_report(analysis_result) text_report = report_generator.generate_text_report(analysis_result)
yield event.plain_result(f"\n📝 以下是文本版本的分析报告:\n\n{text_report}") yield event.plain_result(
f"\n📝 以下是文本版本的分析报告:\n\n{text_report}"
)
else: else:
text_report = report_generator.generate_text_report(analysis_result) text_report = report_generator.generate_text_report(analysis_result)
yield event.plain_result(text_report) yield event.plain_result(text_report)
except Exception as e: except Exception as e:
logger.error(f"群分析失败: {e}", exc_info=True) logger.error(f"群分析失败: {e}", exc_info=True)
yield event.plain_result(f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员") yield event.plain_result(
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
)
@filter.command("设置格式") @filter.command("设置格式")
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
async def set_output_format(self, event: AiocqhttpMessageEvent, format_type: str = ""): async def set_output_format(
self, event: AiocqhttpMessageEvent, format_type: str = ""
):
""" """
设置分析报告输出格式 设置分析报告输出格式
用法: /设置格式 [image|text|pdf] 用法: /设置格式 [image|text|pdf]
@@ -241,7 +279,9 @@ class QQGroupDailyAnalysis(Star):
if not format_type: if not format_type:
current_format = config_manager.get_output_format() current_format = config_manager.get_output_format()
pdf_status = '' if config_manager.pyppeteer_available else '❌ (需安装 pyppeteer)' pdf_status = (
"" if config_manager.pyppeteer_available else "❌ (需安装 pyppeteer)"
)
yield event.plain_result(f"""📊 当前输出格式: {current_format} yield event.plain_result(f"""📊 当前输出格式: {current_format}
可用格式: 可用格式:
@@ -258,7 +298,9 @@ class QQGroupDailyAnalysis(Star):
return return
if format_type == "pdf" and not config_manager.pyppeteer_available: if format_type == "pdf" and not config_manager.pyppeteer_available:
yield event.plain_result("❌ PDF 格式不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2") yield event.plain_result(
"❌ PDF 格式不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2"
)
return return
config_manager.set_output_format(format_type) config_manager.set_output_format(format_type)
@@ -292,7 +334,9 @@ class QQGroupDailyAnalysis(Star):
@filter.command("分析设置") @filter.command("分析设置")
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
async def analysis_settings(self, event: AiocqhttpMessageEvent, action: str = "status"): async def analysis_settings(
self, event: AiocqhttpMessageEvent, action: str = "status"
):
""" """
管理分析设置 管理分析设置
用法: /分析设置 [enable|disable|status|reload|test] 用法: /分析设置 [enable|disable|status|reload|test]
@@ -360,7 +404,9 @@ class QQGroupDailyAnalysis(Star):
else: # status else: # status
enabled_groups = config_manager.get_enabled_groups() enabled_groups = config_manager.get_enabled_groups()
status = "已启用" if group_id in enabled_groups else "未启用" status = "已启用" if group_id in enabled_groups else "未启用"
auto_status = "已启用" if config_manager.get_enable_auto_analysis() else "未启用" auto_status = (
"已启用" if config_manager.get_enable_auto_analysis() else "未启用"
)
auto_time = config_manager.get_auto_analysis_time() auto_time = config_manager.get_auto_analysis_time()
pdf_status = PDFInstaller.get_pdf_status(config_manager) pdf_status = PDFInstaller.get_pdf_status(config_manager)
@@ -379,5 +425,3 @@ class QQGroupDailyAnalysis(Star):
💡 可用命令: enable, disable, status, reload, test 💡 可用命令: enable, disable, status, reload, test
💡 支持的输出格式: image, text, pdf (图片和PDF包含活跃度可视化) 💡 支持的输出格式: image, text, pdf (图片和PDF包含活跃度可视化)
💡 其他命令: /设置格式, /安装PDF""") 💡 其他命令: /设置格式, /安装PDF""")
+1 -4
View File
@@ -6,7 +6,4 @@
from .llm_analyzer import LLMAnalyzer from .llm_analyzer import LLMAnalyzer
from .statistics import UserAnalyzer from .statistics import UserAnalyzer
__all__ = [ __all__ = ["LLMAnalyzer", "UserAnalyzer"]
'LLMAnalyzer',
'UserAnalyzer'
]
+1 -6
View File
@@ -8,9 +8,4 @@ from .topic_analyzer import TopicAnalyzer
from .user_title_analyzer import UserTitleAnalyzer from .user_title_analyzer import UserTitleAnalyzer
from .golden_quote_analyzer import GoldenQuoteAnalyzer from .golden_quote_analyzer import GoldenQuoteAnalyzer
__all__ = [ __all__ = ["BaseAnalyzer", "TopicAnalyzer", "UserTitleAnalyzer", "GoldenQuoteAnalyzer"]
'BaseAnalyzer',
'TopicAnalyzer',
'UserTitleAnalyzer',
'GoldenQuoteAnalyzer'
]
+42 -20
View File
@@ -4,13 +4,16 @@
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Dict, Tuple, Any, Optional from typing import List, Dict, Tuple, Any
from datetime import datetime
from astrbot.api import logger from astrbot.api import logger
from ...models.data_models import TokenUsage from ...models.data_models import TokenUsage
from ..utils.json_utils import parse_json_response from ..utils.json_utils import parse_json_response
from ..utils.llm_utils import call_provider_with_retry, extract_token_usage, extract_response_text from ..utils.llm_utils import (
import re call_provider_with_retry,
extract_token_usage,
extract_response_text,
)
class BaseAnalyzer(ABC): class BaseAnalyzer(ABC):
""" """
@@ -102,17 +105,27 @@ class BaseAnalyzer(ABC):
""" """
try: try:
# 1. 构建提示词 # 1. 构建提示词
logger.debug(f"{self.get_data_type()}分析开始构建prompt,输入数据类型: {type(data)}") logger.debug(
logger.debug(f"{self.get_data_type()}分析输入数据长度: {len(data) if hasattr(data, '__len__') else 'N/A'}") f"{self.get_data_type()}分析开始构建prompt输入数据类型: {type(data)}"
)
logger.debug(
f"{self.get_data_type()}分析输入数据长度: {len(data) if hasattr(data, '__len__') else 'N/A'}"
)
prompt = self.build_prompt(data) prompt = self.build_prompt(data)
logger.info(f"开始{self.get_data_type()}分析,构建提示词完成") logger.info(f"开始{self.get_data_type()}分析,构建提示词完成")
logger.debug(f"{self.get_data_type()}分析prompt长度: {len(prompt) if prompt else 0}") logger.debug(
logger.debug(f"{self.get_data_type()}分析prompt前100字符: {prompt[:100] if prompt else 'None'}...") f"{self.get_data_type()}分析prompt长度: {len(prompt) if prompt else 0}"
)
logger.debug(
f"{self.get_data_type()}分析prompt前100字符: {prompt[:100] if prompt else 'None'}..."
)
# 检查 prompt 是否为空 # 检查 prompt 是否为空
if not prompt or not prompt.strip(): if not prompt or not prompt.strip():
logger.warning(f"{self.get_data_type()}分析: prompt 为空或只包含空白字符,跳过LLM调用") logger.warning(
f"{self.get_data_type()}分析: prompt 为空或只包含空白字符,跳过LLM调用"
)
return [], TokenUsage() return [], TokenUsage()
# 2. 调用LLM # 2. 调用LLM
@@ -120,12 +133,13 @@ class BaseAnalyzer(ABC):
temperature = self.get_temperature() temperature = self.get_temperature()
response = await call_provider_with_retry( response = await call_provider_with_retry(
self.context, self.config_manager, prompt, self.context, self.config_manager, prompt, max_tokens, temperature, umo
max_tokens, temperature, umo
) )
if response is None: if response is None:
logger.error(f"{self.get_data_type()}分析调用LLM失败: provider返回None(重试失败)") logger.error(
f"{self.get_data_type()}分析调用LLM失败: provider返回None(重试失败)"
)
return [], TokenUsage() return [], TokenUsage()
# 3. 提取token使用统计 # 3. 提取token使用统计
@@ -133,7 +147,7 @@ class BaseAnalyzer(ABC):
token_usage = TokenUsage( token_usage = TokenUsage(
prompt_tokens=token_usage_dict["prompt_tokens"], prompt_tokens=token_usage_dict["prompt_tokens"],
completion_tokens=token_usage_dict["completion_tokens"], completion_tokens=token_usage_dict["completion_tokens"],
total_tokens=token_usage_dict["total_tokens"] total_tokens=token_usage_dict["total_tokens"],
) )
# 4. 提取响应文本 # 4. 提取响应文本
@@ -141,25 +155,35 @@ class BaseAnalyzer(ABC):
logger.debug(f"{self.get_data_type()}分析原始响应: {result_text[:500]}...") logger.debug(f"{self.get_data_type()}分析原始响应: {result_text[:500]}...")
# 5. 尝试JSON解析 # 5. 尝试JSON解析
success, parsed_data, error_msg = parse_json_response(result_text, self.get_data_type()) success, parsed_data, error_msg = parse_json_response(
result_text, self.get_data_type()
)
if success and parsed_data: if success and parsed_data:
# JSON解析成功,创建数据对象 # JSON解析成功,创建数据对象
data_objects = self.create_data_objects(parsed_data) data_objects = self.create_data_objects(parsed_data)
logger.info(f"{self.get_data_type()}分析成功,解析到 {len(data_objects)} 条数据") logger.info(
f"{self.get_data_type()}分析成功,解析到 {len(data_objects)} 条数据"
)
return data_objects, token_usage return data_objects, token_usage
# 6. JSON解析失败,使用正则表达式降级 # 6. JSON解析失败,使用正则表达式降级
logger.warning(f"{self.get_data_type()}JSON解析失败,尝试正则表达式提取: {error_msg}") logger.warning(
f"{self.get_data_type()}JSON解析失败,尝试正则表达式提取: {error_msg}"
)
regex_data = self.extract_with_regex(result_text, self.get_max_count()) regex_data = self.extract_with_regex(result_text, self.get_max_count())
if regex_data: if regex_data:
logger.info(f"{self.get_data_type()}正则表达式提取成功,获得 {len(regex_data)} 条数据") logger.info(
f"{self.get_data_type()}正则表达式提取成功,获得 {len(regex_data)} 条数据"
)
data_objects = self.create_data_objects(regex_data) data_objects = self.create_data_objects(regex_data)
return data_objects, token_usage return data_objects, token_usage
else: else:
# 最后的降级方案 - 两种方法都失败 # 最后的降级方案 - 两种方法都失败
logger.error(f"{self.get_data_type()}分析失败: JSON解析和正则表达式提取均未成功,返回空列表") logger.error(
f"{self.get_data_type()}分析失败: JSON解析和正则表达式提取均未成功,返回空列表"
)
return [], token_usage return [], token_usage
except Exception as e: except Exception as e:
@@ -183,5 +207,3 @@ class BaseAnalyzer(ABC):
温度参数 温度参数
""" """
return 0.6 return 0.6
+16 -19
View File
@@ -12,7 +12,6 @@ from ..utils.json_utils import extract_golden_quotes_with_regex
from ..utils import InfoUtils from ..utils import InfoUtils
class GoldenQuoteAnalyzer(BaseAnalyzer): class GoldenQuoteAnalyzer(BaseAnalyzer):
""" """
金句分析器 金句分析器
@@ -49,10 +48,9 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
return "" return ""
# 构建消息文本 # 构建消息文本
messages_text = "\n".join([ messages_text = "\n".join(
f"[{msg['time']}] {msg['sender']}: {msg['content']}" [f"[{msg['time']}] {msg['sender']}: {msg['content']}" for msg in messages]
for msg in messages )
])
max_golden_quotes = self.get_max_count() max_golden_quotes = self.get_max_count()
@@ -63,8 +61,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
# 使用配置中的 prompt 并替换变量 # 使用配置中的 prompt 并替换变量
try: try:
prompt = prompt_template.format( prompt = prompt_template.format(
max_golden_quotes=max_golden_quotes, max_golden_quotes=max_golden_quotes, messages_text=messages_text
messages_text=messages_text
) )
logger.info("使用配置中的金句分析提示词") logger.info("使用配置中的金句分析提示词")
return prompt return prompt
@@ -114,11 +111,9 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
logger.warning(f"金句数据格式不完整,跳过: {quote_data}") logger.warning(f"金句数据格式不完整,跳过: {quote_data}")
continue continue
quotes.append(GoldenQuote( quotes.append(
content=content, GoldenQuote(content=content, sender=sender, reason=reason)
sender=sender, )
reason=reason
))
return quotes return quotes
@@ -148,12 +143,12 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
if content.get("type") == "text": if content.get("type") == "text":
text = content.get("data", {}).get("text", "").strip() text = content.get("data", {}).get("text", "").strip()
# 过滤长度适中、可能圣经的消息 # 过滤长度适中、可能圣经的消息
if 5 <= len(text) <= 100 and not text.startswith(("http", "www", "/")): if 5 <= len(text) <= 100 and not text.startswith(
interesting_messages.append({ ("http", "www", "/")
"sender": nickname, ):
"time": msg_time, interesting_messages.append(
"content": text {"sender": nickname, "time": msg_time, "content": text}
}) )
return interesting_messages return interesting_messages
@@ -161,7 +156,9 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
logger.error(f"提取圣经消息失败: {e}") logger.error(f"提取圣经消息失败: {e}")
return [] return []
async def analyze_golden_quotes(self, messages: List[Dict], umo: str = None) -> Tuple[List[GoldenQuote], TokenUsage]: async def analyze_golden_quotes(
self, messages: List[Dict], umo: str = None
) -> Tuple[List[GoldenQuote], TokenUsage]:
""" """
分析群聊金句 分析群聊金句
+100 -55
View File
@@ -58,32 +58,42 @@ class TopicAnalyzer(BaseAnalyzer):
# 提取文本消息 # 提取文本消息
text_messages = [] text_messages = []
for i, msg in enumerate(messages): for i, msg in enumerate(messages):
logger.debug(f"build_prompt 处理第 {i+1} 条消息,类型: {type(msg)}") logger.debug(f"build_prompt 处理第 {i + 1} 条消息,类型: {type(msg)}")
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误 # 确保msg是字典类型,避免'str' object has no attribute 'get'错误
if not isinstance(msg, dict): if not isinstance(msg, dict):
logger.warning(f"build_prompt 跳过非字典类型的消息: {type(msg)} - {msg}") logger.warning(
f"build_prompt 跳过非字典类型的消息: {type(msg)} - {msg}"
)
continue continue
try: try:
sender = msg.get("sender", {}) sender = msg.get("sender", {})
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误 # 确保sender是字典类型,避免'str' object has no attribute 'get'错误
if not isinstance(sender, dict): if not isinstance(sender, dict):
logger.warning(f"build_prompt 跳过sender非字典类型的消息: {type(sender)} - {sender}") logger.warning(
f"build_prompt 跳过sender非字典类型的消息: {type(sender)} - {sender}"
)
continue continue
nickname = InfoUtils.get_user_nickname(self.config_manager, sender) nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
message_list = msg.get("message", []) message_list = msg.get("message", [])
logger.debug(f"build_prompt 消息 {i+1} 的 message 字段类型: {type(message_list)}, 长度: {len(message_list) if hasattr(message_list, '__len__') else 'N/A'}") logger.debug(
f"build_prompt 消息 {i + 1} 的 message 字段类型: {type(message_list)}, 长度: {len(message_list) if hasattr(message_list, '__len__') else 'N/A'}"
)
# 提取文本内容,可能分布在多个 content 中 # 提取文本内容,可能分布在多个 content 中
text_parts = [] text_parts = []
for j, content in enumerate(message_list): for j, content in enumerate(message_list):
logger.debug(f"build_prompt 处理消息 {i+1} 的内容 {j+1}, 类型: {type(content)}") logger.debug(
f"build_prompt 处理消息 {i + 1} 的内容 {j + 1}, 类型: {type(content)}"
)
if not isinstance(content, dict): if not isinstance(content, dict):
logger.warning(f"build_prompt 跳过非字典类型的内容: {type(content)} - {content}") logger.warning(
f"build_prompt 跳过非字典类型的内容: {type(content)} - {content}"
)
continue continue
content_type = content.get("type", "") content_type = content.get("type", "")
@@ -91,7 +101,9 @@ class TopicAnalyzer(BaseAnalyzer):
if content_type == "text": if content_type == "text":
text = content.get("data", {}).get("text", "").strip() text = content.get("data", {}).get("text", "").strip()
logger.debug(f"build_prompt 提取到的文本: '{text}' (长度: {len(text)})") logger.debug(
f"build_prompt 提取到的文本: '{text}' (长度: {len(text)})"
)
if text: if text:
text_parts.append(text) text_parts.append(text)
elif content_type == "at": elif content_type == "at":
@@ -111,27 +123,35 @@ class TopicAnalyzer(BaseAnalyzer):
# 合并所有文本部分 # 合并所有文本部分
combined_text = "".join(text_parts).strip() combined_text = "".join(text_parts).strip()
logger.debug(f"build_prompt 合并后的文本: '{combined_text}' (长度: {len(combined_text)})") logger.debug(
f"build_prompt 合并后的文本: '{combined_text}' (长度: {len(combined_text)})"
)
if combined_text and len(combined_text) > 2 and not combined_text.startswith("/"): if (
combined_text
and len(combined_text) > 2
and not combined_text.startswith("/")
):
# 清理消息内容 # 清理消息内容
cleaned_text = combined_text.replace('', '"').replace('', '"') cleaned_text = combined_text.replace("", '"').replace("", '"')
cleaned_text = cleaned_text.replace('', "'").replace('', "'") cleaned_text = cleaned_text.replace("", "'").replace("", "'")
cleaned_text = cleaned_text.replace('\n', ' ').replace('\r', ' ') cleaned_text = cleaned_text.replace("\n", " ").replace("\r", " ")
cleaned_text = cleaned_text.replace('\t', ' ') cleaned_text = cleaned_text.replace("\t", " ")
cleaned_text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', cleaned_text) cleaned_text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned_text)
logger.debug(f"build_prompt 清理后的文本: '{cleaned_text}'") logger.debug(f"build_prompt 清理后的文本: '{cleaned_text}'")
text_messages.append({ text_messages.append(
"sender": nickname, {"sender": nickname, "time": msg_time, "content": cleaned_text}
"time": msg_time, )
"content": cleaned_text
})
else: else:
logger.debug(f"build_prompt 跳过文本: '{combined_text}' (长度不足或以/开头)") logger.debug(
f"build_prompt 跳过文本: '{combined_text}' (长度不足或以/开头)"
)
except Exception as e: except Exception as e:
logger.error(f"build_prompt 处理第 {i+1} 条消息时出错: {e}", exc_info=True) logger.error(
f"build_prompt 处理第 {i + 1} 条消息时出错: {e}", exc_info=True
)
continue continue
if not text_messages: if not text_messages:
@@ -139,10 +159,12 @@ class TopicAnalyzer(BaseAnalyzer):
return "" return ""
# 构建消息文本 # 构建消息文本
messages_text = "\n".join([ messages_text = "\n".join(
f"[{msg['time']}] {msg['sender']}: {msg['content']}" [
for msg in text_messages f"[{msg['time']}] {msg['sender']}: {msg['content']}"
]) for msg in text_messages
]
)
max_topics = self.get_max_count() max_topics = self.get_max_count()
@@ -153,8 +175,7 @@ class TopicAnalyzer(BaseAnalyzer):
# 使用配置中的 prompt 并替换变量 # 使用配置中的 prompt 并替换变量
try: try:
prompt = prompt_template.format( prompt = prompt_template.format(
max_topics=max_topics, max_topics=max_topics, messages_text=messages_text
messages_text=messages_text
) )
logger.info("使用配置中的话题分析提示词") logger.info("使用配置中的话题分析提示词")
return prompt return prompt
@@ -189,7 +210,9 @@ class TopicAnalyzer(BaseAnalyzer):
Returns: Returns:
SummaryTopic对象列表 SummaryTopic对象列表
""" """
logger.debug(f"create_data_objects 开始处理,输入数据数量: {len(topics_data) if topics_data else 0}") logger.debug(
f"create_data_objects 开始处理,输入数据数量: {len(topics_data) if topics_data else 0}"
)
logger.debug(f"输入数据类型: {type(topics_data)}") logger.debug(f"输入数据类型: {type(topics_data)}")
try: try:
@@ -199,11 +222,13 @@ class TopicAnalyzer(BaseAnalyzer):
logger.debug(f"处理前 {max_topics} 条话题数据") logger.debug(f"处理前 {max_topics} 条话题数据")
for i, topic_data in enumerate(topics_data[:max_topics]): for i, topic_data in enumerate(topics_data[:max_topics]):
logger.debug(f"处理第 {i+1} 条话题数据,类型: {type(topic_data)}") logger.debug(f"处理第 {i + 1} 条话题数据,类型: {type(topic_data)}")
# 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误 # 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误
if not isinstance(topic_data, dict): if not isinstance(topic_data, dict):
logger.warning(f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}") logger.warning(
f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}"
)
continue continue
try: try:
@@ -212,7 +237,9 @@ class TopicAnalyzer(BaseAnalyzer):
contributors = topic_data.get("contributors", []) contributors = topic_data.get("contributors", [])
detail = topic_data.get("detail", "").strip() detail = topic_data.get("detail", "").strip()
logger.debug(f"话题数据 - 名称: {topic_name}, 参与者: {contributors}, 详情: {detail[:50]}...") logger.debug(
f"话题数据 - 名称: {topic_name}, 参与者: {contributors}, 详情: {detail[:50]}..."
)
# 验证必要字段 # 验证必要字段
if not topic_name or not detail: if not topic_name or not detail:
@@ -224,15 +251,19 @@ class TopicAnalyzer(BaseAnalyzer):
contributors = ["群友"] contributors = ["群友"]
else: else:
# 清理参与者名称 # 清理参与者名称
contributors = [str(c).strip() for c in contributors if c and str(c).strip()] or ["群友"] contributors = [
str(c).strip() for c in contributors if c and str(c).strip()
] or ["群友"]
topics.append(SummaryTopic( topics.append(
topic=topic_name, SummaryTopic(
contributors=contributors[:5], # 最多5个参与者 topic=topic_name,
detail=detail contributors=contributors[:5], # 最多5个参与者
)) detail=detail,
)
)
except Exception as e: except Exception as e:
logger.error(f"处理第 {i+1} 条话题数据时出错: {e}", exc_info=True) logger.error(f"处理第 {i + 1} 条话题数据时出错: {e}", exc_info=True)
continue continue
logger.debug(f"create_data_objects 完成,创建了 {len(topics)} 个话题对象") logger.debug(f"create_data_objects 完成,创建了 {len(topics)} 个话题对象")
@@ -252,7 +283,9 @@ class TopicAnalyzer(BaseAnalyzer):
Returns: Returns:
提取的文本消息列表 提取的文本消息列表
""" """
logger.debug(f"extract_text_messages 开始处理,输入消息数量: {len(messages) if messages else 0}") logger.debug(
f"extract_text_messages 开始处理,输入消息数量: {len(messages) if messages else 0}"
)
logger.debug(f"extract_text_messages 输入消息类型: {type(messages)}") logger.debug(f"extract_text_messages 输入消息类型: {type(messages)}")
if not messages: if not messages:
@@ -262,7 +295,7 @@ class TopicAnalyzer(BaseAnalyzer):
text_messages = [] text_messages = []
for i, msg in enumerate(messages): for i, msg in enumerate(messages):
logger.debug(f"处理第 {i+1} 条消息,类型: {type(msg)}") logger.debug(f"处理第 {i + 1} 条消息,类型: {type(msg)}")
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误 # 确保msg是字典类型,避免'str' object has no attribute 'get'错误
if not isinstance(msg, dict): if not isinstance(msg, dict):
logger.warning(f"跳过非字典类型的消息: {type(msg)} - {msg}") logger.warning(f"跳过非字典类型的消息: {type(msg)} - {msg}")
@@ -272,7 +305,9 @@ class TopicAnalyzer(BaseAnalyzer):
sender = msg.get("sender", {}) sender = msg.get("sender", {})
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误 # 确保sender是字典类型,避免'str' object has no attribute 'get'错误
if not isinstance(sender, dict): if not isinstance(sender, dict):
logger.warning(f"extract_text_messages 跳过sender非字典类型的消息: {type(sender)} - {sender}") logger.warning(
f"extract_text_messages 跳过sender非字典类型的消息: {type(sender)} - {sender}"
)
continue continue
nickname = InfoUtils.get_user_nickname(self.config_manager, sender) nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
@@ -284,25 +319,31 @@ class TopicAnalyzer(BaseAnalyzer):
if text and len(text) > 2 and not text.startswith("/"): if text and len(text) > 2 and not text.startswith("/"):
# 清理消息内容 # 清理消息内容
text = text.replace('""', '"').replace('""', '"') text = text.replace('""', '"').replace('""', '"')
text = text.replace(''', "'").replace(''', "'") text = text.replace(""", "'").replace(""", "'")
text = text.replace('\n', ' ').replace('\r', ' ') text = text.replace("\n", " ").replace("\r", " ")
text = text.replace('\t', ' ') text = text.replace("\t", " ")
text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', text) text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", text)
text_messages.append({ text_messages.append(
"sender": nickname, {
"time": msg_time, "sender": nickname,
"content": text.strip() "time": msg_time,
}) "content": text.strip(),
}
)
except Exception as e: except Exception as e:
logger.error(f"处理第 {i+1} 条消息时出错: {e}", exc_info=True) logger.error(f"处理第 {i + 1} 条消息时出错: {e}", exc_info=True)
continue continue
logger.debug(f"extract_text_messages 完成,提取到 {len(text_messages)} 条文本消息") logger.debug(
f"extract_text_messages 完成,提取到 {len(text_messages)} 条文本消息"
)
if text_messages: if text_messages:
logger.debug(f"extract_text_messages 第一条文本消息: {text_messages[0]}") logger.debug(f"extract_text_messages 第一条文本消息: {text_messages[0]}")
return text_messages return text_messages
async def analyze_topics(self, messages: List[Dict], umo: str = None) -> Tuple[List[SummaryTopic], TokenUsage]: async def analyze_topics(
self, messages: List[Dict], umo: str = None
) -> Tuple[List[SummaryTopic], TokenUsage]:
""" """
分析群聊话题 分析群聊话题
@@ -314,10 +355,14 @@ class TopicAnalyzer(BaseAnalyzer):
(话题列表, Token使用统计) (话题列表, Token使用统计)
""" """
try: try:
logger.debug(f"analyze_topics 开始处理,消息数量: {len(messages) if messages else 0}") logger.debug(
f"analyze_topics 开始处理,消息数量: {len(messages) if messages else 0}"
)
logger.debug(f"消息类型: {type(messages)}") logger.debug(f"消息类型: {type(messages)}")
if messages: if messages:
logger.debug(f"第一条消息类型: {type(messages[0]) if messages else ''}") logger.debug(
f"第一条消息类型: {type(messages[0]) if messages else ''}"
)
logger.debug(f"第一条消息内容: {messages[0] if messages else ''}") logger.debug(f"第一条消息内容: {messages[0] if messages else ''}")
# 检查是否有有效的文本消息 # 检查是否有有效的文本消息
+58 -34
View File
@@ -48,13 +48,15 @@ class UserTitleAnalyzer(BaseAnalyzer):
return "" return ""
# 构建用户数据文本 # 构建用户数据文本
users_text = "\n".join([ users_text = "\n".join(
f"- {user['name']} (QQ:{user['qq']}): " [
f"发言{user['message_count']}条, 平均{user['avg_chars']}字, " f"- {user['name']} (QQ:{user['qq']}): "
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, " f"发言{user['message_count']}, 平均{user['avg_chars']}, "
f"回复比例{user['reply_ratio']}" f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, "
for user in user_summaries f"回复比例{user['reply_ratio']}"
]) for user in user_summaries
]
)
# 从配置读取 prompt 模板(默认使用 "default" 风格) # 从配置读取 prompt 模板(默认使用 "default" 风格)
prompt_template = self.config_manager.get_user_title_analysis_prompt() prompt_template = self.config_manager.get_user_title_analysis_prompt()
@@ -62,9 +64,7 @@ class UserTitleAnalyzer(BaseAnalyzer):
if prompt_template: if prompt_template:
# 使用配置中的 prompt 并替换变量 # 使用配置中的 prompt 并替换变量
try: try:
prompt = prompt_template.format( prompt = prompt_template.format(users_text=users_text)
users_text=users_text
)
logger.info("使用配置中的用户称号分析提示词") logger.info("使用配置中的用户称号分析提示词")
return prompt return prompt
except KeyError as e: except KeyError as e:
@@ -122,13 +122,9 @@ class UserTitleAnalyzer(BaseAnalyzer):
logger.warning(f"QQ号格式无效,跳过: {qq}") logger.warning(f"QQ号格式无效,跳过: {qq}")
continue continue
titles.append(UserTitle( titles.append(
name=name, UserTitle(name=name, qq=qq, title=title, mbti=mbti, reason=reason)
qq=qq, )
title=title,
mbti=mbti,
reason=reason
))
return titles return titles
@@ -136,7 +132,9 @@ class UserTitleAnalyzer(BaseAnalyzer):
logger.error(f"创建用户称号对象失败: {e}") logger.error(f"创建用户称号对象失败: {e}")
return [] return []
def prepare_user_data(self, messages: List[Dict], user_analysis: Dict, top_users: List[Dict] = None) -> Dict: def prepare_user_data(
self, messages: List[Dict], user_analysis: Dict, top_users: List[Dict] = None
) -> Dict:
""" """
准备用户数据 准备用户数据
@@ -156,13 +154,18 @@ class UserTitleAnalyzer(BaseAnalyzer):
# 如果提供了top_users列表,只分析这些活跃用户 # 如果提供了top_users列表,只分析这些活跃用户
if top_users: if top_users:
logger.info(f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析") logger.info(
target_user_ids = {str(user['user_id']) for user in top_users} f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析"
)
target_user_ids = {str(user["user_id"]) for user in top_users}
else: else:
# 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户 # 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户
logger.info("未提供活跃用户列表,使用消息数>=5的用户") logger.info("未提供活跃用户列表,使用消息数>=5的用户")
target_user_ids = {user_id for user_id, stats in user_analysis.items() target_user_ids = {
if stats["message_count"] >= 5} user_id
for user_id, stats in user_analysis.items()
if stats["message_count"] >= 5
}
for user_id, stats in user_analysis.items(): for user_id, stats in user_analysis.items():
# 过滤机器人自己的消息 # 过滤机器人自己的消息
@@ -176,18 +179,33 @@ class UserTitleAnalyzer(BaseAnalyzer):
# 分析用户特征 # 分析用户特征
night_messages = sum(stats["hours"][h] for h in range(6)) night_messages = sum(stats["hours"][h] for h in range(6))
day_messages = stats["message_count"] - night_messages avg_chars = (
avg_chars = stats["char_count"] / stats["message_count"] if stats["message_count"] > 0 else 0 stats["char_count"] / stats["message_count"]
if stats["message_count"] > 0
else 0
)
user_summaries.append({ user_summaries.append(
"name": stats["nickname"], {
"qq": int(user_id), "name": stats["nickname"],
"message_count": stats["message_count"], "qq": int(user_id),
"avg_chars": round(avg_chars, 1), "message_count": stats["message_count"],
"emoji_ratio": round(stats["emoji_count"] / stats["message_count"], 2) if stats["message_count"] > 0 else 0, "avg_chars": round(avg_chars, 1),
"night_ratio": round(night_messages / stats["message_count"], 2) if stats["message_count"] > 0 else 0, "emoji_ratio": round(
"reply_ratio": round(stats["reply_count"] / stats["message_count"], 2) if stats["message_count"] > 0 else 0 stats["emoji_count"] / stats["message_count"], 2
}) )
if stats["message_count"] > 0
else 0,
"night_ratio": round(night_messages / stats["message_count"], 2)
if stats["message_count"] > 0
else 0,
"reply_ratio": round(
stats["reply_count"] / stats["message_count"], 2
)
if stats["message_count"] > 0
else 0,
}
)
if not user_summaries: if not user_summaries:
return {"user_summaries": []} return {"user_summaries": []}
@@ -201,7 +219,13 @@ class UserTitleAnalyzer(BaseAnalyzer):
logger.error(f"准备用户数据失败: {e}") logger.error(f"准备用户数据失败: {e}")
return {"user_summaries": []} return {"user_summaries": []}
async def analyze_user_titles(self, messages: List[Dict], user_analysis: Dict, umo: str = None, top_users: List[Dict] = None) -> Tuple[List[UserTitle], TokenUsage]: async def analyze_user_titles(
self,
messages: List[Dict],
user_analysis: Dict,
umo: str = None,
top_users: List[Dict] = None,
) -> Tuple[List[UserTitle], TokenUsage]:
""" """
分析用户称号 分析用户称号
+50 -17
View File
@@ -12,7 +12,6 @@ from .analyzers.user_title_analyzer import UserTitleAnalyzer
from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer
from .utils.llm_utils import call_provider_with_retry from .utils.llm_utils import call_provider_with_retry
from .utils.json_utils import fix_json from .utils.json_utils import fix_json
from .utils.json_utils import extract_topics_with_regex, extract_user_titles_with_regex, extract_golden_quotes_with_regex
class LLMAnalyzer: class LLMAnalyzer:
@@ -38,7 +37,9 @@ class LLMAnalyzer:
self.user_title_analyzer = UserTitleAnalyzer(context, config_manager) self.user_title_analyzer = UserTitleAnalyzer(context, config_manager)
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager) self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
async def analyze_topics(self, messages: List[Dict], umo: str = None) -> Tuple[List[SummaryTopic], TokenUsage]: async def analyze_topics(
self, messages: List[Dict], umo: str = None
) -> Tuple[List[SummaryTopic], TokenUsage]:
""" """
使用LLM分析话题 使用LLM分析话题
保持原有接口,委托给专门的TopicAnalyzer处理 保持原有接口,委托给专门的TopicAnalyzer处理
@@ -57,7 +58,13 @@ class LLMAnalyzer:
logger.error(f"话题分析失败: {e}") logger.error(f"话题分析失败: {e}")
return [], TokenUsage() return [], TokenUsage()
async def analyze_user_titles(self, messages: List[Dict], user_analysis: Dict, umo: str = None, top_users: List[Dict] = None) -> Tuple[List[UserTitle], TokenUsage]: async def analyze_user_titles(
self,
messages: List[Dict],
user_analysis: Dict,
umo: str = None,
top_users: List[Dict] = None,
) -> Tuple[List[UserTitle], TokenUsage]:
""" """
使用LLM分析用户称号 使用LLM分析用户称号
保持原有接口,委托给专门的UserTitleAnalyzer处理 保持原有接口,委托给专门的UserTitleAnalyzer处理
@@ -73,12 +80,16 @@ class LLMAnalyzer:
""" """
try: try:
logger.info("开始用户称号分析") logger.info("开始用户称号分析")
return await self.user_title_analyzer.analyze_user_titles(messages, user_analysis, umo, top_users) return await self.user_title_analyzer.analyze_user_titles(
messages, user_analysis, umo, top_users
)
except Exception as e: except Exception as e:
logger.error(f"用户称号分析失败: {e}") logger.error(f"用户称号分析失败: {e}")
return [], TokenUsage() return [], TokenUsage()
async def analyze_golden_quotes(self, messages: List[Dict], umo: str = None) -> Tuple[List[GoldenQuote], TokenUsage]: async def analyze_golden_quotes(
self, messages: List[Dict], umo: str = None
) -> Tuple[List[GoldenQuote], TokenUsage]:
""" """
使用LLM分析群聊金句 使用LLM分析群聊金句
保持原有接口,委托给专门的GoldenQuoteAnalyzer处理 保持原有接口,委托给专门的GoldenQuoteAnalyzer处理
@@ -97,7 +108,13 @@ class LLMAnalyzer:
logger.error(f"金句分析失败: {e}") logger.error(f"金句分析失败: {e}")
return [], TokenUsage() return [], TokenUsage()
async def analyze_all_concurrent(self, messages: List[Dict], user_analysis: Dict, umo: str = None, top_users: List[Dict] = None) -> Tuple[List[SummaryTopic], List[UserTitle], List[GoldenQuote], TokenUsage]: async def analyze_all_concurrent(
self,
messages: List[Dict],
user_analysis: Dict,
umo: str = None,
top_users: List[Dict] = None,
) -> Tuple[List[SummaryTopic], List[UserTitle], List[GoldenQuote], TokenUsage]:
""" """
并发执行所有分析任务(话题、用户称号、金句) 并发执行所有分析任务(话题、用户称号、金句)
@@ -116,9 +133,11 @@ class LLMAnalyzer:
# 并发执行三个分析任务 # 并发执行三个分析任务
results = await asyncio.gather( results = await asyncio.gather(
self.topic_analyzer.analyze_topics(messages, umo), self.topic_analyzer.analyze_topics(messages, umo),
self.user_title_analyzer.analyze_user_titles(messages, user_analysis, umo, top_users), self.user_title_analyzer.analyze_user_titles(
messages, user_analysis, umo, top_users
),
self.golden_quote_analyzer.analyze_golden_quotes(messages, umo), self.golden_quote_analyzer.analyze_golden_quotes(messages, umo),
return_exceptions=True return_exceptions=True,
) )
# 处理结果 # 处理结果
@@ -146,12 +165,20 @@ class LLMAnalyzer:
# 合并Token使用统计 # 合并Token使用统计
total_usage = TokenUsage( total_usage = TokenUsage(
prompt_tokens=topic_usage.prompt_tokens + title_usage.prompt_tokens + quote_usage.prompt_tokens, prompt_tokens=topic_usage.prompt_tokens
completion_tokens=topic_usage.completion_tokens + title_usage.completion_tokens + quote_usage.completion_tokens, + title_usage.prompt_tokens
total_tokens=topic_usage.total_tokens + title_usage.total_tokens + quote_usage.total_tokens + quote_usage.prompt_tokens,
completion_tokens=topic_usage.completion_tokens
+ title_usage.completion_tokens
+ quote_usage.completion_tokens,
total_tokens=topic_usage.total_tokens
+ title_usage.total_tokens
+ quote_usage.total_tokens,
) )
logger.info(f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}") logger.info(
f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}"
)
return topics, user_titles, golden_quotes, total_usage return topics, user_titles, golden_quotes, total_usage
except Exception as e: except Exception as e:
@@ -159,8 +186,14 @@ class LLMAnalyzer:
return [], [], [], TokenUsage() return [], [], [], TokenUsage()
# 向后兼容的方法,保持原有调用方式 # 向后兼容的方法,保持原有调用方式
async def _call_provider_with_retry(self, provider, prompt: str, max_tokens: int, async def _call_provider_with_retry(
temperature: float, umo: str = None): self,
provider,
prompt: str,
max_tokens: int,
temperature: float,
umo: str = None,
):
""" """
向后兼容的LLM调用方法 向后兼容的LLM调用方法
现在委托给llm_utils模块处理 现在委托给llm_utils模块处理
@@ -175,8 +208,9 @@ class LLMAnalyzer:
Returns: Returns:
LLM生成的结果 LLM生成的结果
""" """
return await call_provider_with_retry(self.context, self.config_manager, return await call_provider_with_retry(
prompt, max_tokens, temperature, umo) self.context, self.config_manager, prompt, max_tokens, temperature, umo
)
def _fix_json(self, text: str) -> str: def _fix_json(self, text: str) -> str:
""" """
@@ -190,4 +224,3 @@ class LLMAnalyzer:
修复后的JSON文本 修复后的JSON文本
""" """
return fix_json(text) return fix_json(text)
+30 -21
View File
@@ -20,14 +20,16 @@ class UserAnalyzer:
# 获取机器人QQ号用于过滤 # 获取机器人QQ号用于过滤
bot_qq_id = self.config_manager.get_bot_qq_id() bot_qq_id = self.config_manager.get_bot_qq_id()
user_stats = defaultdict(lambda: { user_stats = defaultdict(
"message_count": 0, lambda: {
"char_count": 0, "message_count": 0,
"emoji_count": 0, "char_count": 0,
"nickname": "", "emoji_count": 0,
"hours": defaultdict(int), "nickname": "",
"reply_count": 0 "hours": defaultdict(int),
}) "reply_count": 0,
}
)
for msg in messages: for msg in messages:
sender = msg.get("sender", {}) sender = msg.get("sender", {})
@@ -75,7 +77,9 @@ class UserAnalyzer:
return dict(user_stats) return dict(user_stats)
def get_top_users(self, user_analysis: Dict[str, Dict], limit: int = 10) -> List[Dict]: def get_top_users(
self, user_analysis: Dict[str, Dict], limit: int = 10
) -> List[Dict]:
"""获取最活跃的用户""" """获取最活跃的用户"""
# 获取机器人QQ号用于过滤 # 获取机器人QQ号用于过滤
bot_qq_id = self.config_manager.get_bot_qq_id() bot_qq_id = self.config_manager.get_bot_qq_id()
@@ -86,20 +90,24 @@ class UserAnalyzer:
if bot_qq_id and str(user_id) == str(bot_qq_id): if bot_qq_id and str(user_id) == str(bot_qq_id):
continue continue
users.append({ users.append(
"user_id": user_id, {
"nickname": stats["nickname"], "user_id": user_id,
"message_count": stats["message_count"], "nickname": stats["nickname"],
"char_count": stats["char_count"], "message_count": stats["message_count"],
"emoji_count": stats["emoji_count"], "char_count": stats["char_count"],
"reply_count": stats["reply_count"] "emoji_count": stats["emoji_count"],
}) "reply_count": stats["reply_count"],
}
)
# 按消息数量排序 # 按消息数量排序
users.sort(key=lambda x: x["message_count"], reverse=True) users.sort(key=lambda x: x["message_count"], reverse=True)
return users[:limit] return users[:limit]
def get_user_activity_pattern(self, user_analysis: Dict[str, Dict], user_id: str) -> Dict: def get_user_activity_pattern(
self, user_analysis: Dict[str, Dict], user_id: str
) -> Dict:
"""获取用户活动模式""" """获取用户活动模式"""
if user_id not in user_analysis: if user_id not in user_analysis:
return {} return {}
@@ -112,11 +120,12 @@ class UserAnalyzer:
# 计算夜间活跃度 # 计算夜间活跃度
night_messages = sum(hours[h] for h in range(0, 6)) 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 night_ratio = (
night_messages / stats["message_count"] if stats["message_count"] > 0 else 0
)
return { return {
"most_active_hour": most_active_hour, "most_active_hour": most_active_hour,
"night_ratio": night_ratio, "night_ratio": night_ratio,
"hourly_distribution": dict(hours) "hourly_distribution": dict(hours),
} }
+11 -13
View File
@@ -8,30 +8,28 @@ from .json_utils import (
parse_json_response, parse_json_response,
extract_topics_with_regex, extract_topics_with_regex,
extract_user_titles_with_regex, extract_user_titles_with_regex,
extract_golden_quotes_with_regex extract_golden_quotes_with_regex,
) )
from .llm_utils import ( from .llm_utils import (
call_provider_with_retry, call_provider_with_retry,
extract_token_usage, extract_token_usage,
extract_response_text extract_response_text,
) )
from .info_utils import InfoUtils from .info_utils import InfoUtils
__all__ = [ __all__ = [
# JSON处理工具 # JSON处理工具
'fix_json', "fix_json",
'parse_json_response', "parse_json_response",
'extract_topics_with_regex', "extract_topics_with_regex",
'extract_user_titles_with_regex', "extract_user_titles_with_regex",
'extract_golden_quotes_with_regex', "extract_golden_quotes_with_regex",
# LLM工具 # LLM工具
'call_provider_with_retry', "call_provider_with_retry",
'extract_token_usage', "extract_token_usage",
'extract_response_text', "extract_response_text",
# 信息工具 # 信息工具
'InfoUtils' "InfoUtils",
] ]
+43 -43
View File
@@ -1,4 +1,3 @@
""" """
JSON处理工具模块 JSON处理工具模块
提供JSON解析、修复和正则提取功能 提供JSON解析、修复和正则提取功能
@@ -6,7 +5,7 @@ JSON处理工具模块
import json import json
import re import re
from typing import List, Dict, Tuple, Any, Optional from typing import List, Dict, Tuple, Optional
from astrbot.api import logger from astrbot.api import logger
@@ -22,24 +21,24 @@ def fix_json(text: str) -> str:
""" """
try: try:
# 1. 移除markdown代码块标记 # 1. 移除markdown代码块标记
text = re.sub(r'```json\s*', '', text) text = re.sub(r"```json\s*", "", text)
text = re.sub(r'```\s*$', '', text) text = re.sub(r"```\s*$", "", text)
# 2. 基础清理 # 2. 基础清理
text = text.replace('\n', ' ').replace('\r', ' ') text = text.replace("\n", " ").replace("\r", " ")
text = re.sub(r'\s+', ' ', text) text = re.sub(r"\s+", " ", text)
# 3. 替换中文符号为英文符号(修复) # 3. 替换中文符号为英文符号(修复)
# 中文引号 -> 英文引号 # 中文引号 -> 英文引号
text = text.replace('', '"').replace('', '"') text = text.replace("", '"').replace("", '"')
text = text.replace('', "'").replace('', "'") text = text.replace("", "'").replace("", "'")
# 中文逗号 -> 英文逗号 # 中文逗号 -> 英文逗号
text = text.replace('', ',') text = text.replace("", ",")
# 中文冒号 -> 英文冒号 # 中文冒号 -> 英文冒号
text = text.replace('', ':') text = text.replace("", ":")
# 中文括号 -> 英文括号 # 中文括号 -> 英文括号
text = text.replace('', '(').replace('', ')') text = text.replace("", "(").replace("", ")")
text = text.replace('', '[').replace('', ']') text = text.replace("", "[").replace("", "]")
# 4. 处理字符串内容中的特殊字符 # 4. 处理字符串内容中的特殊字符
# 转义字符串内的双引号 # 转义字符串内的双引号
@@ -53,14 +52,14 @@ def fix_json(text: str) -> str:
text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text) text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
# 5. 修复截断的JSON # 5. 修复截断的JSON
if not text.endswith(']'): if not text.endswith("]"):
last_complete = text.rfind('}') last_complete = text.rfind("}")
if last_complete > 0: if last_complete > 0:
text = text[:last_complete + 1] + ']' text = text[: last_complete + 1] + "]"
# 6. 修复常见的JSON格式问题 # 6. 修复常见的JSON格式问题
# 1. 修复缺失的逗号 # 1. 修复缺失的逗号
text = re.sub(r'}\s*{', '}, {', text) text = re.sub(r"}\s*{", "}, {", text)
# 2. 确保字段名有引号(仅在对象开始或逗号后,避免破坏字符串值) # 2. 确保字段名有引号(仅在对象开始或逗号后,避免破坏字符串值)
def quote_field_names(match): def quote_field_names(match):
@@ -69,11 +68,11 @@ def fix_json(text: str) -> str:
return f'{prefix}"{key}":' return f'{prefix}"{key}":'
# 只在 { 或 , 后面匹配字段名,避免在字符串值中误匹配 # 只在 { 或 , 后面匹配字段名,避免在字符串值中误匹配
text = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', quote_field_names, text) text = re.sub(r"([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:", quote_field_names, text)
# 3. 移除多余的逗号 # 3. 移除多余的逗号
text = re.sub(r',\s*}', '}', text) text = re.sub(r",\s*}", "}", text)
text = re.sub(r',\s*]', ']', text) text = re.sub(r",\s*]", "]", text)
return text.strip() return text.strip()
@@ -82,7 +81,9 @@ def fix_json(text: str) -> str:
return text return text
def parse_json_response(result_text: str, data_type: str) -> Tuple[bool, Optional[List[Dict]], Optional[str]]: def parse_json_response(
result_text: str, data_type: str
) -> Tuple[bool, Optional[List[Dict]], Optional[str]]:
""" """
统一的JSON解析方法 统一的JSON解析方法
@@ -95,7 +96,7 @@ def parse_json_response(result_text: str, data_type: str) -> Tuple[bool, Optiona
""" """
try: try:
# 1. 提取JSON部分 # 1. 提取JSON部分
json_match = re.search(r'\[.*?\]', result_text, re.DOTALL) json_match = re.search(r"\[.*?\]", result_text, re.DOTALL)
if not json_match: if not json_match:
error_msg = f"{data_type}响应中未找到JSON格式" error_msg = f"{data_type}响应中未找到JSON格式"
logger.warning(error_msg) logger.warning(error_msg)
@@ -153,16 +154,21 @@ def extract_topics_with_regex(result_text: str, max_topics: int) -> List[Dict]:
detail = match[2].strip() detail = match[2].strip()
# 清理detail中的转义字符 # 清理detail中的转义字符
detail = detail.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ') detail = detail.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
# 解析参与者列表 # 解析参与者列表
contributors = [contrib.strip() for contrib in re.findall(r'"([^"]+)"', contributors_str)] or ["群友"] contributors = [
contrib.strip()
for contrib in re.findall(r'"([^"]+)"', contributors_str)
] or ["群友"]
topics.append({ topics.append(
"topic": topic_name, {
"contributors": contributors[:5], # 最多5个参与者 "topic": topic_name,
"detail": detail "contributors": contributors[:5], # 最多5个参与者
}) "detail": detail,
}
)
logger.info(f"话题正则表达式提取成功,提取到 {len(topics)} 条有效话题内容") logger.info(f"话题正则表达式提取成功,提取到 {len(topics)} 条有效话题内容")
return topics return topics
@@ -203,15 +209,11 @@ def extract_user_titles_with_regex(result_text: str, max_count: int) -> List[Dic
reason = match[4].strip() reason = match[4].strip()
# 清理转义字符 # 清理转义字符
reason = reason.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ') reason = reason.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
titles.append({ titles.append(
"name": name, {"name": name, "qq": qq, "title": title, "mbti": mbti, "reason": reason}
"qq": qq, )
"title": title,
"mbti": mbti,
"reason": reason
})
logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号") logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号")
return titles return titles
@@ -250,14 +252,12 @@ def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> List[D
reason = match[2].strip() reason = match[2].strip()
# 清理转义字符 # 清理转义字符
content = content.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ') content = (
reason = reason.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ') content.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
)
reason = reason.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
quotes.append({ quotes.append({"content": content, "sender": sender, "reason": reason})
"content": content,
"sender": sender,
"reason": reason
})
logger.info(f"金句正则表达式提取成功,提取到 {len(quotes)} 条有效金句") logger.info(f"金句正则表达式提取成功,提取到 {len(quotes)} 条有效金句")
return quotes return quotes
+71 -36
View File
@@ -9,8 +9,14 @@ from astrbot.api import logger
import aiohttp import aiohttp
async def call_provider_with_retry(context, config_manager, prompt: str, max_tokens: int, async def call_provider_with_retry(
temperature: float, umo: str = None) -> Optional[Any]: context,
config_manager,
prompt: str,
max_tokens: int,
temperature: float,
umo: str = None,
) -> Optional[Any]:
""" """
调用LLM提供者,带超时、重试与退避。支持自定义服务商。 调用LLM提供者,带超时、重试与退避。支持自定义服务商。
@@ -38,42 +44,63 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
for attempt in range(1, retries + 1): for attempt in range(1, retries + 1):
try: try:
if custom_api_key and custom_api_base and custom_model: if custom_api_key and custom_api_base and custom_model:
logger.info(f"使用自定义LLM提供商: {custom_api_base} model={custom_model}, max_tokens={max_tokens}, temperature={temperature}") logger.info(
logger.debug(f"自定义LLM提供商 prompt 长度: {len(prompt) if prompt else 0}") f"使用自定义LLM提供商: {custom_api_base} model={custom_model}, max_tokens={max_tokens}, temperature={temperature}"
logger.debug(f"自定义LLM提供商 prompt 前100字符: {prompt[:100] if prompt else 'None'}...") )
logger.debug(
f"自定义LLM提供商 prompt 长度: {len(prompt) if prompt else 0}"
)
logger.debug(
f"自定义LLM提供商 prompt 前100字符: {prompt[:100] if prompt else 'None'}..."
)
# 检查 prompt 是否为空 # 检查 prompt 是否为空
if not prompt or not prompt.strip(): if not prompt or not prompt.strip():
logger.error("自定义LLM提供商: prompt 为空或只包含空白字符,无法发送请求") logger.error(
"自定义LLM提供商: prompt 为空或只包含空白字符,无法发送请求"
)
return None return None
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
headers = { headers = {
"Authorization": f"Bearer {custom_api_key}", "Authorization": f"Bearer {custom_api_key}",
"Content-Type": "application/json" "Content-Type": "application/json",
} }
payload = { payload = {
"model": custom_model, "model": custom_model,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "max_tokens": max_tokens,
"temperature": temperature "temperature": temperature,
} }
aio_timeout = aiohttp.ClientTimeout(total=timeout) aio_timeout = aiohttp.ClientTimeout(total=timeout)
async with session.post(custom_api_base, json=payload, headers=headers, timeout=aio_timeout) as resp: async with session.post(
custom_api_base,
json=payload,
headers=headers,
timeout=aio_timeout,
) as resp:
if resp.status != 200: if resp.status != 200:
error_text = await resp.text() error_text = await resp.text()
logger.error(f"自定义LLM服务商请求失败: HTTP {resp.status}, 内容: {error_text}") logger.error(
f"自定义LLM服务商请求失败: HTTP {resp.status}, 内容: {error_text}"
)
try: try:
response_json = await resp.json() response_json = await resp.json()
except Exception as json_err: except Exception as json_err:
error_text = await resp.text() error_text = await resp.text()
logger.error(f"自定义LLM服务商响应JSON解析失败: {json_err}, 内容: {error_text}") logger.error(
f"自定义LLM服务商响应JSON解析失败: {json_err}, 内容: {error_text}"
)
return None return None
# 兼容 OpenAI 格式,安全访问嵌套字段 # 兼容 OpenAI 格式,安全访问嵌套字段
content = None content = None
try: try:
choices = response_json.get("choices") choices = response_json.get("choices")
if choices and isinstance(choices, list) and len(choices) > 0: if (
choices
and isinstance(choices, list)
and len(choices) > 0
):
message = choices[0].get("message") message = choices[0].get("message")
if message and isinstance(message, dict): if message and isinstance(message, dict):
content = message.get("content") content = message.get("content")
@@ -81,17 +108,21 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
logger.error(f"自定义LLM响应格式异常: {response_json}") logger.error(f"自定义LLM响应格式异常: {response_json}")
return None return None
except Exception as key_err: except Exception as key_err:
logger.error(f"自定义LLM响应结构解析失败: {key_err}, 响应内容: {response_json}") logger.error(
f"自定义LLM响应结构解析失败: {key_err}, 响应内容: {response_json}"
)
return None return None
# 构造一个兼容原有逻辑的对象 # 构造一个兼容原有逻辑的对象
class CustomResponse: class CustomResponse:
completion_text = content completion_text = content
raw_completion = response_json raw_completion = response_json
return CustomResponse() return CustomResponse()
else: else:
# 确保使用当前指定的模型 # 确保使用当前指定的模型
provider = context.get_using_provider(umo=umo) provider = context.get_using_provider(umo=umo)
provider_id = 'unknown' provider_id = "unknown"
if provider: if provider:
try: try:
meta = provider.meta() meta = provider.meta()
@@ -99,23 +130,33 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
except Exception as e: except Exception as e:
logger.debug(f"获取提供商ID失败: {e}") logger.debug(f"获取提供商ID失败: {e}")
logger.info(f"获取到的 provider ID: {provider_id}") logger.info(f"获取到的 provider ID: {provider_id}")
if not provider or provider_id == 'unknown': if not provider or provider_id == "unknown":
logger.warning(f"获取的提供商不正确 (Provider ID: {provider_id})") logger.warning(f"获取的提供商不正确 (Provider ID: {provider_id})")
logger.info(f"使用LLM provider: {provider}, max_tokens={max_tokens}, temperature={temperature}") logger.info(
f"使用LLM provider: {provider}, max_tokens={max_tokens}, temperature={temperature}"
)
if not provider: if not provider:
logger.error("provider 为空,无法调用 text_chat,直接返回 None") logger.error("provider 为空,无法调用 text_chat,直接返回 None")
return None return None
logger.debug(f"LLM provider prompt 长度: {len(prompt) if prompt else 0}") logger.debug(
logger.debug(f"LLM provider prompt 前100字符: {prompt[:100] if prompt else 'None'}...") f"LLM provider prompt 长度: {len(prompt) if prompt else 0}"
)
logger.debug(
f"LLM provider prompt 前100字符: {prompt[:100] if prompt else 'None'}..."
)
# 检查 prompt 是否为空 # 检查 prompt 是否为空
if not prompt or not prompt.strip(): if not prompt or not prompt.strip():
logger.error("LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat") logger.error(
"LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat"
)
return None return None
coro = provider.text_chat(prompt=prompt, max_tokens=max_tokens, temperature=temperature) coro = provider.text_chat(
prompt=prompt, max_tokens=max_tokens, temperature=temperature
)
return await asyncio.wait_for(coro, timeout=timeout) return await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError as e: except asyncio.TimeoutError as e:
last_exc = e last_exc = e
@@ -143,30 +184,24 @@ def extract_token_usage(response) -> Optional[dict]:
Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens
""" """
try: try:
token_usage = { token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
# 安全地提取 usage,避免 response.raw_completion.usage 为 None 导致的 AttributeError # 安全地提取 usage,避免 response.raw_completion.usage 为 None 导致的 AttributeError
usage = None usage = None
if getattr(response, 'raw_completion', None) is not None: if getattr(response, "raw_completion", None) is not None:
usage = getattr(response.raw_completion, 'usage', None) usage = getattr(response.raw_completion, "usage", None)
if usage: if usage:
token_usage["prompt_tokens"] = getattr(usage, 'prompt_tokens', 0) or 0 token_usage["prompt_tokens"] = getattr(usage, "prompt_tokens", 0) or 0
token_usage["completion_tokens"] = getattr(usage, 'completion_tokens', 0) or 0 token_usage["completion_tokens"] = (
token_usage["total_tokens"] = getattr(usage, 'total_tokens', 0) or 0 getattr(usage, "completion_tokens", 0) or 0
)
token_usage["total_tokens"] = getattr(usage, "total_tokens", 0) or 0
return token_usage return token_usage
except Exception as e: except Exception as e:
logger.error(f"提取token使用统计失败: {e}") logger.error(f"提取token使用统计失败: {e}")
return { return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
def extract_response_text(response) -> str: def extract_response_text(response) -> str:
@@ -180,7 +215,7 @@ def extract_response_text(response) -> str:
响应文本内容 响应文本内容
""" """
try: try:
if hasattr(response, 'completion_text'): if hasattr(response, "completion_text"):
return response.completion_text return response.completion_text
else: else:
return str(response) return str(response)
+1 -4
View File
@@ -5,7 +5,4 @@
from .config import ConfigManager from .config import ConfigManager
from .message_handler import MessageHandler from .message_handler import MessageHandler
__all__ = [ __all__ = ["ConfigManager", "MessageHandler"]
'ConfigManager',
'MessageHandler'
]
+10 -11
View File
@@ -5,6 +5,7 @@ Bot实例管理模块
from typing import Dict, Any from typing import Dict, Any
class BotManager: class BotManager:
"""Bot实例管理器 - 统一管理所有bot相关操作""" """Bot实例管理器 - 统一管理所有bot相关操作"""
@@ -29,13 +30,11 @@ class BotManager:
if bot_qq_id: if bot_qq_id:
self._bot_qq_id = str(bot_qq_id) self._bot_qq_id = str(bot_qq_id)
def set_bot_qq_id(self, bot_qq_id: str): def set_bot_qq_id(self, bot_qq_id: str):
"""设置bot QQ号""" """设置bot QQ号"""
if bot_qq_id: if bot_qq_id:
self._bot_qq_id = str(bot_qq_id) self._bot_qq_id = str(bot_qq_id)
def get_bot_instance(self): def get_bot_instance(self):
"""获取当前bot实例""" """获取当前bot实例"""
return self._bot_instance return self._bot_instance
@@ -54,16 +53,16 @@ class BotManager:
async def auto_discover_bot_instance(self): async def auto_discover_bot_instance(self):
"""自动发现可用的bot实例""" """自动发现可用的bot实例"""
if not self._context or not hasattr(self._context, 'platform_manager'): if not self._context or not hasattr(self._context, "platform_manager"):
return None return None
platforms = getattr(self._context.platform_manager, 'platform_insts', []) platforms = getattr(self._context.platform_manager, "platform_insts", [])
for platform in platforms: for platform in platforms:
# 获取bot实例 # 获取bot实例
bot_client = None bot_client = None
if hasattr(platform, 'get_client'): if hasattr(platform, "get_client"):
bot_client = platform.get_client() bot_client = platform.get_client()
elif hasattr(platform, 'bot'): elif hasattr(platform, "bot"):
bot_client = platform.bot bot_client = platform.bot
if bot_client: if bot_client:
@@ -91,12 +90,12 @@ class BotManager:
"has_bot_instance": self.has_bot_instance(), "has_bot_instance": self.has_bot_instance(),
"has_bot_qq_id": self.has_bot_qq_id(), "has_bot_qq_id": self.has_bot_qq_id(),
"bot_qq_id": self._bot_qq_id, "bot_qq_id": self._bot_qq_id,
"ready_for_auto_analysis": self.is_ready_for_auto_analysis() "ready_for_auto_analysis": self.is_ready_for_auto_analysis(),
} }
def update_from_event(self, event): def update_from_event(self, event):
"""从事件更新bot实例(用于手动命令)""" """从事件更新bot实例(用于手动命令)"""
if hasattr(event, 'bot') and event.bot: if hasattr(event, "bot") and event.bot:
self.set_bot_instance(event.bot) self.set_bot_instance(event.bot)
# 每次都尝试从bot实例提取QQ号 # 每次都尝试从bot实例提取QQ号
bot_qq_id = self._extract_bot_qq_id(event.bot) bot_qq_id = self._extract_bot_qq_id(event.bot)
@@ -113,11 +112,11 @@ class BotManager:
def _extract_bot_qq_id(self, bot_instance): def _extract_bot_qq_id(self, bot_instance):
"""从bot实例中提取QQ号""" """从bot实例中提取QQ号"""
# 尝试多种方式获取bot QQ号 # 尝试多种方式获取bot QQ号
if hasattr(bot_instance, 'self_id') and bot_instance.self_id: if hasattr(bot_instance, "self_id") and bot_instance.self_id:
return str(bot_instance.self_id) return str(bot_instance.self_id)
elif hasattr(bot_instance, 'qq') and bot_instance.qq: elif hasattr(bot_instance, "qq") and bot_instance.qq:
return str(bot_instance.qq) return str(bot_instance.qq)
elif hasattr(bot_instance, 'user_id') and bot_instance.user_id: elif hasattr(bot_instance, "user_id") and bot_instance.user_id:
return str(bot_instance.user_id) return str(bot_instance.user_id)
return None return None
+30 -16
View File
@@ -4,8 +4,6 @@
""" """
import sys import sys
import importlib
from pathlib import Path
from typing import Optional, List from typing import Optional, List
from astrbot.api import logger, AstrBotConfig from astrbot.api import logger, AstrBotConfig
@@ -110,9 +108,12 @@ class ConfigManager:
def get_custom_model_name(self) -> str: def get_custom_model_name(self) -> str:
"""获取自定义 LLM 服务的模型名称""" """获取自定义 LLM 服务的模型名称"""
return self.config.get("custom_model_name", "") return self.config.get("custom_model_name", "")
def get_pdf_output_dir(self) -> str: def get_pdf_output_dir(self) -> str:
"""获取PDF输出目录""" """获取PDF输出目录"""
return self.config.get("pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports") return self.config.get(
"pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports"
)
def get_bot_qq_id(self) -> str: def get_bot_qq_id(self) -> str:
"""获取bot QQ号""" """获取bot QQ号"""
@@ -120,7 +121,9 @@ class ConfigManager:
def get_pdf_filename_format(self) -> str: def get_pdf_filename_format(self) -> str:
"""获取PDF文件名格式""" """获取PDF文件名格式"""
return self.config.get("pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf") return self.config.get(
"pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf"
)
def get_topic_analysis_prompt(self, style: str = "topic_prompt") -> str: def get_topic_analysis_prompt(self, style: str = "topic_prompt") -> str:
""" """
@@ -160,7 +163,9 @@ class ConfigManager:
# 兼容旧配置 # 兼容旧配置
return self.config.get("user_title_analysis_prompt", "") return self.config.get("user_title_analysis_prompt", "")
def get_golden_quote_analysis_prompt(self, style: str = "golden_quote_prompt") -> str: def get_golden_quote_analysis_prompt(
self, style: str = "golden_quote_prompt"
) -> str:
""" """
获取金句分析提示词模板 获取金句分析提示词模板
@@ -308,7 +313,7 @@ class ConfigManager:
"""检查 pyppeteer 可用性""" """检查 pyppeteer 可用性"""
try: try:
import pyppeteer import pyppeteer
from pyppeteer import launch
self._pyppeteer_available = True self._pyppeteer_available = True
# 检查版本 # 检查版本
@@ -322,7 +327,9 @@ class ConfigManager:
except ImportError: except ImportError:
self._pyppeteer_available = False self._pyppeteer_available = False
self._pyppeteer_version = None self._pyppeteer_version = None
logger.warning("pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2") logger.warning(
"pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2"
)
def reload_pyppeteer(self) -> bool: def reload_pyppeteer(self) -> bool:
"""重新加载 pyppeteer 模块""" """重新加载 pyppeteer 模块"""
@@ -330,7 +337,9 @@ class ConfigManager:
logger.info("开始重新加载 pyppeteer 模块...") logger.info("开始重新加载 pyppeteer 模块...")
# 移除所有 pyppeteer 相关模块 # 移除所有 pyppeteer 相关模块
modules_to_remove = [mod for mod in sys.modules.keys() if mod.startswith('pyppeteer')] modules_to_remove = [
mod for mod in sys.modules.keys() if mod.startswith("pyppeteer")
]
logger.info(f"移除模块: {modules_to_remove}") logger.info(f"移除模块: {modules_to_remove}")
for mod in modules_to_remove: for mod in modules_to_remove:
del sys.modules[mod] del sys.modules[mod]
@@ -338,28 +347,33 @@ class ConfigManager:
# 强制重新导入 # 强制重新导入
try: try:
import pyppeteer import pyppeteer
from pyppeteer import launch
# 更新全局变量 # 更新全局变量
self._pyppeteer_available = True self._pyppeteer_available = True
try: try:
self._pyppeteer_version = pyppeteer.__version__ self._pyppeteer_version = pyppeteer.__version__
logger.info(f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}") logger.info(
f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}"
)
except AttributeError: except AttributeError:
self._pyppeteer_version = "unknown" self._pyppeteer_version = "unknown"
logger.info("重新加载成功,pyppeteer 版本未知") logger.info("重新加载成功,pyppeteer 版本未知")
return True return True
except ImportError as e: except ImportError:
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效") logger.info("pyppeteer 重新导入需要重启 AstrBot 才能生效")
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能") logger.info(
"💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能"
)
self._pyppeteer_available = False self._pyppeteer_available = False
self._pyppeteer_version = None self._pyppeteer_version = None
return False return False
except Exception as e: except Exception:
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效") logger.info("pyppeteer 重新导入需要重启 AstrBot 才能生效")
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能") logger.info(
"💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能"
)
self._pyppeteer_available = False self._pyppeteer_available = False
self._pyppeteer_version = None self._pyppeteer_version = None
return False return False
+110 -134
View File
@@ -3,12 +3,11 @@
负责群聊消息的获取、过滤和预处理 负责群聊消息的获取、过滤和预处理
""" """
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Dict, Optional from typing import List, Dict
from collections import defaultdict from collections import defaultdict
from astrbot.api import logger from astrbot.api import logger
from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics, ActivityVisualization from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics
from ...src.visualization.activity_charts import ActivityVisualizer from ...src.visualization.activity_charts import ActivityVisualizer
@@ -35,15 +34,17 @@ class MessageHandler:
def _extract_bot_qq_id_from_instance(self, bot_instance): def _extract_bot_qq_id_from_instance(self, bot_instance):
"""从bot实例中提取QQ号""" """从bot实例中提取QQ号"""
if hasattr(bot_instance, 'self_id') and bot_instance.self_id: if hasattr(bot_instance, "self_id") and bot_instance.self_id:
return str(bot_instance.self_id) return str(bot_instance.self_id)
elif hasattr(bot_instance, 'qq') and bot_instance.qq: elif hasattr(bot_instance, "qq") and bot_instance.qq:
return str(bot_instance.qq) return str(bot_instance.qq)
elif hasattr(bot_instance, 'user_id') and bot_instance.user_id: elif hasattr(bot_instance, "user_id") and bot_instance.user_id:
return str(bot_instance.user_id) return str(bot_instance.user_id)
return None return None
async def fetch_group_messages(self, bot_instance, group_id: str, days: int) -> List[Dict]: async def fetch_group_messages(
self, bot_instance, group_id: str, days: int
) -> List[Dict]:
"""获取群聊消息记录""" """获取群聊消息记录"""
try: try:
# 验证参数 # 验证参数
@@ -63,136 +64,83 @@ class MessageHandler:
start_time = end_time - timedelta(days=days) start_time = end_time - timedelta(days=days)
messages = [] messages = []
message_seq = 0 # 一次性获取,移除分页与多轮查询
query_rounds = 0
max_rounds = self.config_manager.get_max_query_rounds()
max_messages = self.config_manager.get_max_messages() max_messages = self.config_manager.get_max_messages()
consecutive_failures = 0 query_rounds = 0
max_failures = 3
logger.info(f"开始获取群 {group_id}{days} 天的消息记录") 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')}") logger.info(
f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M:%S')}{end_time.strftime('%Y-%m-%d %H:%M:%S')}"
)
while query_rounds < max_rounds: # 单次请求,按配置的 max_messages 作为 count
try: try:
# 构造请求参数 payloads = {
payloads = { "group_id": int(group_id) if group_id.isdigit() else group_id,
"group_id": int(group_id) if group_id.isdigit() else group_id, "count": int(max_messages),
"count": 200, }
}
# 添加 message_seq(如果不是第一轮) result = None
if message_seq: if hasattr(bot_instance, "call_action"):
payloads["message_seq"] = message_seq try:
# aiocqhttp (CQHttp) 方式
# 尝试调用 API result = await bot_instance.call_action(
result = None "get_group_msg_history", **payloads
api_error = None )
query_rounds = 1
if hasattr(bot_instance, 'call_action'): except Exception as api_err:
try: logger.error(f"{group_id} API 调用失败: {api_err}")
# aiocqhttp (CQHttp) 方式 logger.error(
result = await bot_instance.call_action("get_group_msg_history", **payloads) f"{group_id} 当前 OneBot 实现可能不支持 get_group_msg_history API"
except Exception as api_err: )
api_error = api_err
logger.error(f"{group_id} API 调用失败: {api_err}")
# 第一次失败就放弃(该 API 不支持)
if query_rounds == 0:
logger.error(f"{group_id} 当前 OneBot 实现不支持 get_group_msg_history API")
return []
elif hasattr(bot_instance, 'api'):
# QQ官方 bot (botClient) 方式 - 官方API不支持历史消息
logger.error(f"{group_id} 检测到 QQ 官方 Bot,官方 API 不支持获取历史消息")
return []
else:
logger.error(f"{group_id} 未知的 bot_instance 类型,无法调用 API")
logger.error(f"bot_instance 类型: {type(bot_instance)}")
return [] return []
elif hasattr(bot_instance, "api"):
# QQ 官方 bot (botClient) 不支持历史消息
logger.error(
f"{group_id} 检测到 QQ 官方 Bot,官方 API 不支持获取历史消息"
)
return []
else:
logger.error(
f"{group_id} 未知的 bot_instance 类型,无法调用 API,类型: {type(bot_instance)}"
)
return []
if not result or "messages" not in result: if not result or "messages" not in result:
logger.warning(f"{group_id} API返回无效结果: {result}") logger.warning(f"{group_id} API返回无效结果: {result}")
consecutive_failures += 1 return []
if consecutive_failures >= max_failures:
break
continue
round_messages = result.get("messages", []) round_messages = result.get("messages", [])
if not round_messages:
logger.info(f"{group_id} 未获取到消息")
if not round_messages: # 过滤时间范围内的消息并过滤机器人自身消息
logger.info(f"{group_id} 没有更多消息,结束获取") for msg in round_messages:
break try:
msg_time = datetime.fromtimestamp(msg.get("time", 0))
# 重置失败计数 if not (start_time <= msg_time <= end_time):
consecutive_failures = 0
# 过滤时间范围内的消息
valid_messages_in_round = 0
oldest_msg_time = None
has_out_of_range_message = False
for msg in round_messages:
try:
msg_time = datetime.fromtimestamp(msg.get("time", 0))
# 记录本轮最老的消息时间
if oldest_msg_time is None or msg_time < oldest_msg_time:
oldest_msg_time = msg_time
# 检查是否已经超出时间范围
if msg_time < start_time:
has_out_of_range_message = True
# 继续处理本轮剩余消息,但不再查询下一轮
continue
# 过滤掉机器人自己的消息
sender_id = str(msg.get("sender", {}).get("user_id", ""))
if self.bot_manager and self.bot_manager.should_filter_bot_message(sender_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 continue
sender_id = str(msg.get("sender", {}).get("user_id", ""))
# 如果本轮有消息已经超出时间范围,立即停止获取 if (
if has_out_of_range_message: self.bot_manager
logger.info(f"{group_id} 已获取到时间范围外的消息(最老消息时间: {oldest_msg_time.strftime('%Y-%m-%d %H:%M:%S')}),停止获取。共获取 {len(messages)} 条消息") and self.bot_manager.should_filter_bot_message(sender_id)
break ):
continue
# 如果本轮没有获取到任何有效消息,停止获取 messages.append(msg)
if valid_messages_in_round == 0: except Exception as msg_error:
logger.warning(f"{group_id} 本轮未获取到有效消息,停止获取") logger.warning(f"{group_id} 处理单条消息失败: {msg_error}")
break continue
except Exception as e:
# 如果已经获取到足够的消息(达到 max_messages),停止获取 logger.error(f"{group_id} 获取消息失败: {e}")
if len(messages) >= max_messages: return []
logger.info(f"{group_id} 已达到消息数量限制({len(messages)} 条,限制 {max_messages} 条),停止获取")
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)
# ========== 最终清理步骤:严格过滤和限制 ========== # ========== 最终清理步骤:严格过滤和限制 ==========
original_count = len(messages) original_count = len(messages)
# 1. 严格过滤时间范围外的消息 # 1. 严格过滤时间范围外的消息
messages = [ messages = [
msg for msg in messages msg
for msg in messages
if start_time <= datetime.fromtimestamp(msg.get("time", 0)) <= end_time if start_time <= datetime.fromtimestamp(msg.get("time", 0)) <= end_time
] ]
time_filtered_count = len(messages) time_filtered_count = len(messages)
@@ -201,13 +149,19 @@ class MessageHandler:
if len(messages) > max_messages: if len(messages) > max_messages:
# 保留最新的消息(假设messages已按时间排序,从新到旧) # 保留最新的消息(假设messages已按时间排序,从新到旧)
messages = messages[:max_messages] messages = messages[:max_messages]
logger.info(f"{group_id} 消息数量超过限制,已截断: {time_filtered_count} -> {max_messages}") logger.info(
f"{group_id} 消息数量超过限制,已截断: {time_filtered_count} -> {max_messages}"
)
# 记录清理结果 # 记录清理结果
if original_count != len(messages): if original_count != len(messages):
logger.info(f"{group_id} 最终清理: 原始 {original_count} 条 -> 时间过滤 {time_filtered_count} 条 -> 最终 {len(messages)}") logger.info(
f"{group_id} 最终清理: 原始 {original_count} 条 -> 时间过滤 {time_filtered_count} 条 -> 最终 {len(messages)}"
)
logger.info(f"{group_id} 消息获取完成,共获取到 {len(messages)} 条有效消息(时间范围: 近{days}天),查询轮数: {query_rounds}") logger.info(
f"{group_id} 消息获取完成,共获取到 {len(messages)} 条有效消息(时间范围: 近{days}天),查询轮数: {query_rounds}"
)
return messages return messages
except Exception as e: except Exception as e:
@@ -238,22 +192,30 @@ class MessageHandler:
# QQ基础表情 # QQ基础表情
emoji_statistics.face_count += 1 emoji_statistics.face_count += 1
face_id = content.get("data", {}).get("id", "unknown") face_id = content.get("data", {}).get("id", "unknown")
emoji_statistics.face_details[f"face_{face_id}"] = emoji_statistics.face_details.get(f"face_{face_id}", 0) + 1 emoji_statistics.face_details[f"face_{face_id}"] = (
emoji_statistics.face_details.get(f"face_{face_id}", 0) + 1
)
elif content.get("type") == "mface": elif content.get("type") == "mface":
# 动画表情/魔法表情 # 动画表情/魔法表情
emoji_statistics.mface_count += 1 emoji_statistics.mface_count += 1
emoji_id = content.get("data", {}).get("emoji_id", "unknown") emoji_id = content.get("data", {}).get("emoji_id", "unknown")
emoji_statistics.face_details[f"mface_{emoji_id}"] = emoji_statistics.face_details.get(f"mface_{emoji_id}", 0) + 1 emoji_statistics.face_details[f"mface_{emoji_id}"] = (
emoji_statistics.face_details.get(f"mface_{emoji_id}", 0) + 1
)
elif content.get("type") == "bface": elif content.get("type") == "bface":
# 超级表情 # 超级表情
emoji_statistics.bface_count += 1 emoji_statistics.bface_count += 1
emoji_id = content.get("data", {}).get("p", "unknown") emoji_id = content.get("data", {}).get("p", "unknown")
emoji_statistics.face_details[f"bface_{emoji_id}"] = emoji_statistics.face_details.get(f"bface_{emoji_id}", 0) + 1 emoji_statistics.face_details[f"bface_{emoji_id}"] = (
emoji_statistics.face_details.get(f"bface_{emoji_id}", 0) + 1
)
elif content.get("type") == "sface": elif content.get("type") == "sface":
# 小表情 # 小表情
emoji_statistics.sface_count += 1 emoji_statistics.sface_count += 1
emoji_id = content.get("data", {}).get("id", "unknown") emoji_id = content.get("data", {}).get("id", "unknown")
emoji_statistics.face_details[f"sface_{emoji_id}"] = emoji_statistics.face_details.get(f"sface_{emoji_id}", 0) + 1 emoji_statistics.face_details[f"sface_{emoji_id}"] = (
emoji_statistics.face_details.get(f"sface_{emoji_id}", 0) + 1
)
elif content.get("type") == "image": elif content.get("type") == "image":
# 检查是否是动画表情(通过summary字段判断) # 检查是否是动画表情(通过summary字段判断)
data = content.get("data", {}) data = content.get("data", {})
@@ -262,20 +224,34 @@ class MessageHandler:
# 动画表情(以image形式发送) # 动画表情(以image形式发送)
emoji_statistics.mface_count += 1 emoji_statistics.mface_count += 1
file_name = data.get("file", "unknown") file_name = data.get("file", "unknown")
emoji_statistics.face_details[f"animated_{file_name}"] = emoji_statistics.face_details.get(f"animated_{file_name}", 0) + 1 emoji_statistics.face_details[f"animated_{file_name}"] = (
emoji_statistics.face_details.get(
f"animated_{file_name}", 0
)
+ 1
)
else: else:
# 普通图片,不计入表情统计 # 普通图片,不计入表情统计
pass pass
elif content.get("type") in ["record", "video"] and "emoji" in str(content.get("data", {})).lower(): elif (
content.get("type") in ["record", "video"]
and "emoji" in str(content.get("data", {})).lower()
):
# 其他可能的表情类型 # 其他可能的表情类型
emoji_statistics.other_emoji_count += 1 emoji_statistics.other_emoji_count += 1
# 找出最活跃时段 # 找出最活跃时段
most_active_hour = max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0 most_active_hour = (
most_active_period = f"{most_active_hour:02d}:00-{(most_active_hour+1)%24:02d}:00" 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"
)
# 生成活跃度可视化数据 # 生成活跃度可视化数据
activity_visualization = self.activity_visualizer.generate_activity_visualization(messages) activity_visualization = (
self.activity_visualizer.generate_activity_visualization(messages)
)
return GroupStatistics( return GroupStatistics(
message_count=len(messages), message_count=len(messages),
@@ -286,5 +262,5 @@ class MessageHandler:
emoji_count=emoji_statistics.total_emoji_count, # 保持向后兼容 emoji_count=emoji_statistics.total_emoji_count, # 保持向后兼容
emoji_statistics=emoji_statistics, emoji_statistics=emoji_statistics,
activity_visualization=activity_visualization, activity_visualization=activity_visualization,
token_usage=TokenUsage() token_usage=TokenUsage(),
) )
+2 -8
View File
@@ -7,13 +7,7 @@ from .data_models import (
UserTitle, UserTitle,
GoldenQuote, GoldenQuote,
TokenUsage, TokenUsage,
GroupStatistics GroupStatistics,
) )
__all__ = [ __all__ = ["SummaryTopic", "UserTitle", "GoldenQuote", "TokenUsage", "GroupStatistics"]
'SummaryTopic',
'UserTitle',
'GoldenQuote',
'TokenUsage',
'GroupStatistics'
]
+18 -3
View File
@@ -10,6 +10,7 @@ from typing import List
@dataclass @dataclass
class SummaryTopic: class SummaryTopic:
"""话题总结数据结构""" """话题总结数据结构"""
topic: str topic: str
contributors: List[str] contributors: List[str]
detail: str detail: str
@@ -18,6 +19,7 @@ class SummaryTopic:
@dataclass @dataclass
class UserTitle: class UserTitle:
"""用户称号数据结构""" """用户称号数据结构"""
name: str name: str
qq: int qq: int
title: str title: str
@@ -28,6 +30,7 @@ class UserTitle:
@dataclass @dataclass
class GoldenQuote: class GoldenQuote:
"""群聊金句数据结构""" """群聊金句数据结构"""
content: str content: str
sender: str sender: str
reason: str reason: str
@@ -36,6 +39,7 @@ class GoldenQuote:
@dataclass @dataclass
class TokenUsage: class TokenUsage:
"""Token使用统计""" """Token使用统计"""
prompt_tokens: int = 0 prompt_tokens: int = 0
completion_tokens: int = 0 completion_tokens: int = 0
total_tokens: int = 0 total_tokens: int = 0
@@ -44,6 +48,7 @@ class TokenUsage:
@dataclass @dataclass
class EmojiStatistics: class EmojiStatistics:
"""表情统计数据结构""" """表情统计数据结构"""
face_count: int = 0 # QQ基础表情数量 face_count: int = 0 # QQ基础表情数量
mface_count: int = 0 # 动画表情数量 mface_count: int = 0 # 动画表情数量
bface_count: int = 0 # 超级表情数量 bface_count: int = 0 # 超级表情数量
@@ -54,14 +59,21 @@ class EmojiStatistics:
@property @property
def total_emoji_count(self) -> int: def total_emoji_count(self) -> int:
"""总表情数量""" """总表情数量"""
return self.face_count + self.mface_count + self.bface_count + self.sface_count + self.other_emoji_count return (
self.face_count
+ self.mface_count
+ self.bface_count
+ self.sface_count
+ self.other_emoji_count
)
@dataclass @dataclass
class ActivityVisualization: class ActivityVisualization:
"""活跃度可视化数据结构""" """活跃度可视化数据结构"""
hourly_activity: dict = field(default_factory=dict) # {hour: count} hourly_activity: dict = field(default_factory=dict) # {hour: count}
daily_activity: dict = field(default_factory=dict) # {date: count} daily_activity: dict = field(default_factory=dict) # {date: count}
user_activity_ranking: list = field(default_factory=list) # 用户活跃度排行 user_activity_ranking: list = field(default_factory=list) # 用户活跃度排行
peak_hours: list = field(default_factory=list) # 高峰时段 peak_hours: list = field(default_factory=list) # 高峰时段
activity_heatmap_data: dict = field(default_factory=dict) # 热力图数据 activity_heatmap_data: dict = field(default_factory=dict) # 热力图数据
@@ -70,6 +82,7 @@ class ActivityVisualization:
@dataclass @dataclass
class GroupStatistics: class GroupStatistics:
"""群聊统计数据结构""" """群聊统计数据结构"""
message_count: int message_count: int
total_characters: int total_characters: int
participant_count: int participant_count: int
@@ -77,5 +90,7 @@ class GroupStatistics:
golden_quotes: List[GoldenQuote] golden_quotes: List[GoldenQuote]
emoji_count: int # 保持向后兼容 emoji_count: int # 保持向后兼容
emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics) emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics)
activity_visualization: ActivityVisualization = field(default_factory=ActivityVisualization) activity_visualization: ActivityVisualization = field(
default_factory=ActivityVisualization
)
token_usage: TokenUsage = field(default_factory=TokenUsage) token_usage: TokenUsage = field(default_factory=TokenUsage)
+1 -4
View File
@@ -6,7 +6,4 @@
from .generators import ReportGenerator from .generators import ReportGenerator
from .templates import HTMLTemplates from .templates import HTMLTemplates
__all__ = [ __all__ = ["ReportGenerator", "HTMLTemplates"]
'ReportGenerator',
'HTMLTemplates'
]
+135 -103
View File
@@ -21,7 +21,9 @@ class ReportGenerator:
self.config_manager = config_manager self.config_manager = config_manager
self.activity_visualizer = ActivityVisualizer() self.activity_visualizer = ActivityVisualizer()
async def generate_image_report(self, analysis_result: Dict, group_id: str, html_render_func) -> Optional[str]: async def generate_image_report(
self, analysis_result: Dict, group_id: str, html_render_func
) -> Optional[str]:
"""生成图片格式的分析报告""" """生成图片格式的分析报告"""
try: try:
# 准备渲染数据 # 准备渲染数据
@@ -31,13 +33,13 @@ class ReportGenerator:
image_options = { image_options = {
"full_page": True, "full_page": True,
"type": "jpeg", # 使用默认的jpeg格式提高兼容性 "type": "jpeg", # 使用默认的jpeg格式提高兼容性
"quality": 95, # 设置合理的质量 "quality": 95, # 设置合理的质量
} }
image_url = await html_render_func( image_url = await html_render_func(
HTMLTemplates.get_image_template(), HTMLTemplates.get_image_template(),
render_payload, render_payload,
True, # return_url=True,返回URL而不是下载文件 True, # return_url=True,返回URL而不是下载文件
image_options image_options,
) )
logger.info(f"图片生成成功: {image_url}") logger.info(f"图片生成成功: {image_url}")
@@ -51,13 +53,13 @@ class ReportGenerator:
simple_options = { simple_options = {
"full_page": True, "full_page": True,
"type": "jpeg", "type": "jpeg",
"quality": 70 # 降低质量以提高兼容性 "quality": 70, # 降低质量以提高兼容性
} }
image_url = await html_render_func( image_url = await html_render_func(
HTMLTemplates.get_image_template(), HTMLTemplates.get_image_template(),
render_payload, render_payload,
True, True,
simple_options simple_options,
) )
logger.info(f"使用低质量选项生成成功: {image_url}") logger.info(f"使用低质量选项生成成功: {image_url}")
return image_url return image_url
@@ -65,9 +67,9 @@ class ReportGenerator:
logger.error(f"后备低质量方案也失败: {fallback_e}") logger.error(f"后备低质量方案也失败: {fallback_e}")
return None return None
async def generate_pdf_report(
self, analysis_result: Dict, group_id: str
async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]: ) -> Optional[str]:
"""生成PDF格式的分析报告""" """生成PDF格式的分析报告"""
try: try:
# 确保输出目录存在 # 确保输出目录存在
@@ -75,10 +77,9 @@ class ReportGenerator:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
# 生成文件名 # 生成文件名
current_date = datetime.now().strftime('%Y%m%d') current_date = datetime.now().strftime("%Y%m%d")
filename = self.config_manager.get_pdf_filename_format().format( filename = self.config_manager.get_pdf_filename_format().format(
group_id=group_id, group_id=group_id, date=current_date
date=current_date
) )
pdf_path = output_dir / filename pdf_path = output_dir / filename
@@ -87,7 +88,9 @@ class ReportGenerator:
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段") logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
# 生成 HTML 内容(PDF模板使用{}占位符) # 生成 HTML 内容(PDF模板使用{}占位符)
html_content = self._render_html_template(HTMLTemplates.get_pdf_template(), render_data, use_jinja_style=False) html_content = self._render_html_template(
HTMLTemplates.get_pdf_template(), render_data, use_jinja_style=False
)
logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符") logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符")
# 转换为 PDF # 转换为 PDF
@@ -110,7 +113,7 @@ class ReportGenerator:
report = f""" report = f"""
🎯 群聊日常分析报告 🎯 群聊日常分析报告
📅 {datetime.now().strftime('%Y年%m月%d')} 📅 {datetime.now().strftime("%Y年%m月%d")}
📊 基础统计 📊 基础统计
• 消息总数: {stats.message_count} • 消息总数: {stats.message_count}
@@ -138,7 +141,7 @@ class ReportGenerator:
report += "💬 群圣经\n" report += "💬 群圣经\n"
max_golden_quotes = self.config_manager.get_max_golden_quotes() max_golden_quotes = self.config_manager.get_max_golden_quotes()
for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1): for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1):
report += f"{i}. \"{quote.content}\" —— {quote.sender}\n" report += f'{i}. "{quote.content}" —— {quote.sender}\n'
report += f" {quote.reason}\n\n" report += f" {quote.reason}\n\n"
return report return report
@@ -172,7 +175,11 @@ class ReportGenerator:
for title in user_titles[:max_user_titles]: for title in user_titles[:max_user_titles]:
# 获取用户头像 # 获取用户头像
avatar_data = await self._get_user_avatar(str(title.qq)) 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>' avatar_html = (
f'<img src="{avatar_data}" class="user-avatar" alt="头像">'
if avatar_data
else '<div class="user-avatar-placeholder">👤</div>'
)
titles_html += f""" titles_html += f"""
<div class="user-title"> <div class="user-title">
@@ -209,8 +216,8 @@ class ReportGenerator:
# 返回扁平化的渲染数据 # 返回扁平化的渲染数据
return { return {
"current_date": datetime.now().strftime('%Y年%m月%d'), "current_date": datetime.now().strftime("%Y年%m月%d"),
"current_datetime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), "current_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"message_count": stats.message_count, "message_count": stats.message_count,
"participant_count": stats.participant_count, "participant_count": stats.participant_count,
"total_characters": stats.total_characters, "total_characters": stats.total_characters,
@@ -220,15 +227,20 @@ class ReportGenerator:
"titles_html": titles_html, "titles_html": titles_html,
"quotes_html": quotes_html, "quotes_html": quotes_html,
"hourly_chart_html": hourly_chart_html, "hourly_chart_html": hourly_chart_html,
"total_tokens": stats.token_usage.total_tokens if stats.token_usage.total_tokens else 0, "total_tokens": stats.token_usage.total_tokens
"prompt_tokens": stats.token_usage.prompt_tokens if stats.token_usage.prompt_tokens else 0, if stats.token_usage.total_tokens
"completion_tokens": stats.token_usage.completion_tokens if stats.token_usage.completion_tokens else 0 else 0,
"prompt_tokens": stats.token_usage.prompt_tokens
if stats.token_usage.prompt_tokens
else 0,
"completion_tokens": stats.token_usage.completion_tokens
if stats.token_usage.completion_tokens
else 0,
} }
def _render_html_template(
self, template: str, data: Dict, use_jinja_style: bool = False
) -> str:
def _render_html_template(self, template: str, data: Dict, use_jinja_style: bool = False) -> str:
"""HTML模板渲染,支持两种占位符格式 """HTML模板渲染,支持两种占位符格式
Args: Args:
@@ -239,7 +251,9 @@ class ReportGenerator:
result = template result = template
# 调试:记录渲染数据 # 调试:记录渲染数据
logger.info(f"渲染数据键: {list(data.keys())}, 使用Jinja风格: {use_jinja_style}") logger.info(
f"渲染数据键: {list(data.keys())}, 使用Jinja风格: {use_jinja_style}"
)
for key, value in data.items(): for key, value in data.items():
if use_jinja_style: if use_jinja_style:
@@ -256,10 +270,11 @@ class ReportGenerator:
# 检查是否还有未替换的占位符 # 检查是否还有未替换的占位符
import re import re
if use_jinja_style: if use_jinja_style:
remaining_placeholders = re.findall(r'\{\{[^}]+\}\}', result) remaining_placeholders = re.findall(r"\{\{[^}]+\}\}", result)
else: else:
remaining_placeholders = re.findall(r'\{[^}]+\}', result) remaining_placeholders = re.findall(r"\{[^}]+\}", result)
if remaining_placeholders: if remaining_placeholders:
logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}") logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}")
@@ -275,7 +290,7 @@ class ReportGenerator:
response.raise_for_status() response.raise_for_status()
avatar_data = await response.read() avatar_data = await response.read()
# 转换为base64编码 # 转换为base64编码
avatar_base64 = base64.b64encode(avatar_data).decode('utf-8') avatar_base64 = base64.b64encode(avatar_data).decode("utf-8")
return f"data:image/jpeg;base64,{avatar_base64}" return f"data:image/jpeg;base64,{avatar_base64}"
except Exception as e: except Exception as e:
logger.error(f"获取用户头像失败 {user_id}: {e}") logger.error(f"获取用户头像失败 {user_id}: {e}")
@@ -300,71 +315,71 @@ class ReportGenerator:
# 配置浏览器启动参数,解决Docker环境中的沙盒问题 # 配置浏览器启动参数,解决Docker环境中的沙盒问题
launch_options = { launch_options = {
'headless': True, "headless": True,
'args': [ "args": [
'--no-sandbox', # Docker环境必需 - 禁用沙盒 "--no-sandbox", # Docker环境必需 - 禁用沙盒
'--disable-setuid-sandbox', # Docker环境必需 - 禁用setuid沙盒 "--disable-setuid-sandbox", # Docker环境必需 - 禁用setuid沙盒
'--disable-dev-shm-usage', # 避免共享内存问题 "--disable-dev-shm-usage", # 避免共享内存问题
'--disable-gpu', # 禁用GPU加速 "--disable-gpu", # 禁用GPU加速
'--no-first-run', "--no-first-run",
'--disable-extensions', "--disable-extensions",
'--disable-default-apps', "--disable-default-apps",
'--disable-background-timer-throttling', "--disable-background-timer-throttling",
'--disable-backgrounding-occluded-windows', "--disable-backgrounding-occluded-windows",
'--disable-renderer-backgrounding', "--disable-renderer-backgrounding",
'--disable-features=TranslateUI', "--disable-features=TranslateUI",
'--disable-ipc-flooding-protection', "--disable-ipc-flooding-protection",
'--disable-background-networking', "--disable-background-networking",
'--enable-features=NetworkService,NetworkServiceInProcess', "--enable-features=NetworkService,NetworkServiceInProcess",
'--force-color-profile=srgb', "--force-color-profile=srgb",
'--metrics-recording-only', "--metrics-recording-only",
'--disable-breakpad', "--disable-breakpad",
'--disable-component-extensions-with-background-pages', "--disable-component-extensions-with-background-pages",
'--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync', "--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync",
'--enable-automation', "--enable-automation",
'--password-store=basic', "--password-store=basic",
'--use-mock-keychain', "--use-mock-keychain",
'--export-tagged-pdf', "--export-tagged-pdf",
'--disable-web-security', "--disable-web-security",
'--disable-features=VizDisplayCompositor', "--disable-features=VizDisplayCompositor",
'--disable-blink-features=AutomationControlled', # 隐藏自动化特征 "--disable-blink-features=AutomationControlled", # 隐藏自动化特征
] ],
} }
# 检测系统 Chrome/Chromium 路径 # 检测系统 Chrome/Chromium 路径
chrome_paths = [] chrome_paths = []
if sys.platform.startswith('win'): if sys.platform.startswith("win"):
# Windows 系统 Chrome 安装路径 # Windows 系统 Chrome 安装路径
username = os.environ.get('USERNAME', '') username = os.environ.get("USERNAME", "")
chrome_paths = [ chrome_paths = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
rf"C:\Users\{username}\AppData\Local\Google\Chrome\Application\chrome.exe", rf"C:\Users\{username}\AppData\Local\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Chromium\Application\chrome.exe", r"C:\Program Files\Chromium\Application\chrome.exe",
] ]
elif sys.platform.startswith('linux'): elif sys.platform.startswith("linux"):
# Linux 系统 Chrome/Chromium 路径 # Linux 系统 Chrome/Chromium 路径
chrome_paths = [ chrome_paths = [
'/usr/bin/google-chrome', "/usr/bin/google-chrome",
'/usr/bin/google-chrome-stable', "/usr/bin/google-chrome-stable",
'/usr/bin/chromium', "/usr/bin/chromium",
'/usr/bin/chromium-browser', "/usr/bin/chromium-browser",
'/snap/bin/chromium', "/snap/bin/chromium",
'/usr/bin/chromium-freeworld', "/usr/bin/chromium-freeworld",
] ]
elif sys.platform.startswith('darwin'): elif sys.platform.startswith("darwin"):
# macOS 系统 Chrome 路径 # macOS 系统 Chrome 路径
chrome_paths = [ chrome_paths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
'/Applications/Chromium.app/Contents/MacOS/Chromium', "/Applications/Chromium.app/Contents/MacOS/Chromium",
] ]
# 查找可用的浏览器 # 查找可用的浏览器
found_browser = False found_browser = False
for chrome_path in chrome_paths: for chrome_path in chrome_paths:
if Path(chrome_path).exists(): if Path(chrome_path).exists():
launch_options['executablePath'] = chrome_path launch_options["executablePath"] = chrome_path
logger.info(f"使用系统浏览器: {chrome_path}") logger.info(f"使用系统浏览器: {chrome_path}")
found_browser = True found_browser = True
break break
@@ -373,18 +388,25 @@ class ReportGenerator:
logger.info("未找到系统浏览器,将使用 pyppeteer 默认下载的 Chromium") logger.info("未找到系统浏览器,将使用 pyppeteer 默认下载的 Chromium")
# 先尝试确保 Chromium 已下载 # 先尝试确保 Chromium 已下载
try: try:
from pyppeteer import connection, browser, launcher from pyppeteer import browser, launcher
launcher_instance = launcher.Launcher( launcher_instance = launcher.Launcher(
headless=True, headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] args=[
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
],
) )
await launcher_instance._get_chromium_revision() await launcher_instance._get_chromium_revision()
await launcher_instance._download_chromium() await launcher_instance._download_chromium()
chromium_path = pyppeteer.executablePath() chromium_path = pyppeteer.executablePath()
launch_options['executablePath'] = chromium_path launch_options["executablePath"] = chromium_path
logger.info(f"使用 pyppeteer 下载的 Chromium: {chromium_path}") logger.info(f"使用 pyppeteer 下载的 Chromium: {chromium_path}")
except Exception as pre_download_err: except Exception as pre_download_err:
logger.warning(f"预下载 Chromium 失败,继续尝试直接启动: {pre_download_err}") logger.warning(
f"预下载 Chromium 失败,继续尝试直接启动: {pre_download_err}"
)
# 尝试启动浏览器 # 尝试启动浏览器
try: try:
@@ -400,22 +422,26 @@ class ReportGenerator:
page = await browser.newPage() page = await browser.newPage()
# 设置页面视口,减少内存占用 # 设置页面视口,减少内存占用
await page.setViewport({ await page.setViewport(
'width': 1024, {
'height': 768, "width": 1024,
'deviceScaleFactor': 1, "height": 768,
'isMobile': False, "deviceScaleFactor": 1,
'hasTouch': False, "isMobile": False,
'isLandscape': False "hasTouch": False,
}) "isLandscape": False,
}
)
# 设置页面内容,使用更安全的加载方式 # 设置页面内容,使用更安全的加载方式
logger.info("开始设置页面内容...") logger.info("开始设置页面内容...")
await page.setContent(html_content, {'waitUntil': 'domcontentloaded', 'timeout': 30000}) await page.setContent(
html_content, {"waitUntil": "domcontentloaded", "timeout": 30000}
)
# 等待页面基本加载完成,但不要太长时间 # 等待页面基本加载完成,但不要太长时间
try: try:
await page.waitForSelector('body', {'timeout': 5000}) await page.waitForSelector("body", {"timeout": 5000})
logger.info("页面基本加载完成") logger.info("页面基本加载完成")
except Exception: except Exception:
logger.warning("等待页面加载超时,继续执行") logger.warning("等待页面加载超时,继续执行")
@@ -426,19 +452,19 @@ class ReportGenerator:
# 导出 PDF,使用更保守的设置 # 导出 PDF,使用更保守的设置
logger.info("开始生成PDF...") logger.info("开始生成PDF...")
pdf_options = { pdf_options = {
'path': output_path, "path": output_path,
'format': 'A4', "format": "A4",
'printBackground': True, "printBackground": True,
'margin': { "margin": {
'top': '10mm', "top": "10mm",
'right': '10mm', "right": "10mm",
'bottom': '10mm', "bottom": "10mm",
'left': '10mm' "left": "10mm",
}, },
'scale': 0.8, "scale": 0.8,
'displayHeaderFooter': False, "displayHeaderFooter": False,
'preferCSSPageSize': True, "preferCSSPageSize": True,
'timeout': 60000 # 增加PDF生成超时时间到60秒 "timeout": 60000, # 增加PDF生成超时时间到60秒
} }
await page.pdf(pdf_options) await page.pdf(pdf_options)
@@ -459,8 +485,8 @@ class ReportGenerator:
for page in pages: for page in pages:
try: try:
await page.close() await page.close()
except: except Exception as close_err:
pass logger.debug(f"关闭页面时忽略的异常: {close_err}")
# 等待一小段时间让资源释放 # 等待一小段时间让资源释放
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
@@ -473,21 +499,25 @@ class ReportGenerator:
# 强制清理 # 强制清理
try: try:
await browser.disconnect() await browser.disconnect()
except: except Exception as disc_err:
pass logger.debug(f"断开浏览器连接时忽略的异常: {disc_err}")
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
if "Chromium downloadable not found" in error_msg: if "Chromium downloadable not found" in error_msg:
logger.error("Chromium 下载失败,建议安装系统 Chrome/Chromium") logger.error("Chromium 下载失败,建议安装系统 Chrome/Chromium")
logger.info("💡 Linux 系统建议: sudo apt-get install chromium-browser 或 sudo yum install chromium") logger.info(
"💡 Linux 系统建议: sudo apt-get install chromium-browser 或 sudo yum install chromium"
)
elif "No usable sandbox" in error_msg: elif "No usable sandbox" in error_msg:
logger.error("沙盒权限问题,已尝试禁用沙盒") logger.error("沙盒权限问题,已尝试禁用沙盒")
elif "Connection refused" in error_msg or "connect" in error_msg.lower(): elif "Connection refused" in error_msg or "connect" in error_msg.lower():
logger.error("浏览器连接失败,请检查系统资源或尝试重启") logger.error("浏览器连接失败,请检查系统资源或尝试重启")
elif "executablePath" in error_msg and "not found" in error_msg: elif "executablePath" in error_msg and "not found" in error_msg:
logger.error("未找到系统浏览器,请安装 Chrome 或 Chromium") logger.error("未找到系统浏览器,请安装 Chrome 或 Chromium")
logger.info("💡 安装建议: sudo apt-get install chromium-browser (Ubuntu/Debian) 或 sudo yum install chromium (CentOS/RHEL)") logger.info(
"💡 安装建议: sudo apt-get install chromium-browser (Ubuntu/Debian) 或 sudo yum install chromium (CentOS/RHEL)"
)
elif "Browser closed unexpectedly" in error_msg: elif "Browser closed unexpectedly" in error_msg:
logger.error("浏览器意外关闭,可能是由于内存不足或系统资源限制") logger.error("浏览器意外关闭,可能是由于内存不足或系统资源限制")
logger.info("💡 建议: 检查系统内存,或重启 AstrBot 后重试") logger.info("💡 建议: 检查系统内存,或重启 AstrBot 后重试")
@@ -497,5 +527,7 @@ class ReportGenerator:
logger.info(" 3. 考虑使用其他 PDF 生成方案") logger.info(" 3. 考虑使用其他 PDF 生成方案")
else: else:
logger.error(f"HTML 转 PDF 失败: {e}") logger.error(f"HTML 转 PDF 失败: {e}")
logger.info("💡 可以尝试使用 /安装PDF 命令重新安装依赖,或检查系统日志获取更多信息") logger.info(
"💡 可以尝试使用 /安装PDF 命令重新安装依赖,或检查系统日志获取更多信息"
)
return False return False
+1 -3
View File
@@ -5,6 +5,4 @@
from .auto_scheduler import AutoScheduler from .auto_scheduler import AutoScheduler
__all__ = [ __all__ = ["AutoScheduler"]
'AutoScheduler'
]
+126 -61
View File
@@ -5,14 +5,21 @@
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
from astrbot.api import logger from astrbot.api import logger
class AutoScheduler: class AutoScheduler:
"""自动调度器""" """自动调度器"""
def __init__(self, config_manager, message_handler, analyzer, report_generator, bot_manager, html_render_func=None): def __init__(
self,
config_manager,
message_handler,
analyzer,
report_generator,
bot_manager,
html_render_func=None,
):
self.config_manager = config_manager self.config_manager = config_manager
self.message_handler = message_handler self.message_handler = message_handler
self.analyzer = analyzer self.analyzer = analyzer
@@ -33,16 +40,20 @@ class AutoScheduler:
def _get_platform_id(self): def _get_platform_id(self):
"""获取平台ID""" """获取平台ID"""
try: try:
if hasattr(self.bot_manager, '_context') and self.bot_manager._context: if hasattr(self.bot_manager, "_context") and self.bot_manager._context:
context = self.bot_manager._context context = self.bot_manager._context
if hasattr(context, 'platform_manager') and hasattr(context.platform_manager, 'platform_insts'): if hasattr(context, "platform_manager") and hasattr(
context.platform_manager, "platform_insts"
):
platforms = context.platform_manager.platform_insts platforms = context.platform_manager.platform_insts
for platform in platforms: for platform in platforms:
if hasattr(platform, 'metadata') and hasattr(platform.metadata, 'id'): if hasattr(platform, "metadata") and hasattr(
platform.metadata, "id"
):
platform_id = platform.metadata.id platform_id = platform.metadata.id
return platform_id return platform_id
return "aiocqhttp" # 默认值 return "aiocqhttp" # 默认值
except Exception as e: except Exception:
return "aiocqhttp" # 默认值 return "aiocqhttp" # 默认值
async def start_scheduler(self): async def start_scheduler(self):
@@ -54,7 +65,9 @@ class AutoScheduler:
# 延迟启动,给系统时间初始化 # 延迟启动,给系统时间初始化
await asyncio.sleep(10) await asyncio.sleep(10)
logger.info(f"启动定时任务调度器,自动分析时间: {self.config_manager.get_auto_analysis_time()}") logger.info(
f"启动定时任务调度器,自动分析时间: {self.config_manager.get_auto_analysis_time()}"
)
self.scheduler_task = asyncio.create_task(self._scheduler_loop()) self.scheduler_task = asyncio.create_task(self._scheduler_loop())
@@ -75,9 +88,9 @@ class AutoScheduler:
while True: while True:
try: try:
now = datetime.now() now = datetime.now()
target_time = datetime.strptime(self.config_manager.get_auto_analysis_time(), "%H:%M").replace( target_time = datetime.strptime(
year=now.year, month=now.month, day=now.day self.config_manager.get_auto_analysis_time(), "%H:%M"
) ).replace(year=now.year, month=now.month, day=now.day)
# 如果今天的目标时间已过,设置为明天 # 如果今天的目标时间已过,设置为明天
if now >= target_time: if now >= target_time:
@@ -85,7 +98,9 @@ class AutoScheduler:
# 计算等待时间 # 计算等待时间
wait_seconds = (target_time - now).total_seconds() wait_seconds = (target_time - now).total_seconds()
logger.info(f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f}") logger.info(
f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f}"
)
# 等待到目标时间 # 等待到目标时间
await asyncio.sleep(wait_seconds) await asyncio.sleep(wait_seconds)
@@ -94,7 +109,9 @@ class AutoScheduler:
if self.config_manager.get_enable_auto_analysis(): if self.config_manager.get_enable_auto_analysis():
# 检查今天是否已经执行过,防止重复执行 # 检查今天是否已经执行过,防止重复执行
if self.last_execution_date == target_time.date(): if self.last_execution_date == target_time.date():
logger.info(f"今天 {target_time.date()} 已经执行过自动分析,跳过执行") logger.info(
f"今天 {target_time.date()} 已经执行过自动分析,跳过执行"
)
# 等待到明天再检查 # 等待到明天再检查
await asyncio.sleep(3600) # 等待1小时后再检查 await asyncio.sleep(3600) # 等待1小时后再检查
continue continue
@@ -102,7 +119,9 @@ class AutoScheduler:
logger.info("开始执行定时分析") logger.info("开始执行定时分析")
await self._run_auto_analysis() await self._run_auto_analysis()
self.last_execution_date = target_time.date() # 记录执行日期 self.last_execution_date = target_time.date() # 记录执行日期
logger.info(f"定时分析执行完成,记录执行日期: {self.last_execution_date}") logger.info(
f"定时分析执行完成,记录执行日期: {self.last_execution_date}"
)
else: else:
logger.info("自动分析已禁用,跳过执行") logger.info("自动分析已禁用,跳过执行")
break break
@@ -122,14 +141,16 @@ class AutoScheduler:
logger.info("没有启用的群聊需要分析") logger.info("没有启用的群聊需要分析")
return return
logger.info(f"将为 {len(enabled_groups)} 个群聊并发执行分析: {enabled_groups}") logger.info(
f"将为 {len(enabled_groups)} 个群聊并发执行分析: {enabled_groups}"
)
# 创建并发任务 - 为每个群聊创建独立的分析任务 # 创建并发任务 - 为每个群聊创建独立的分析任务
analysis_tasks = [] analysis_tasks = []
for group_id in enabled_groups: for group_id in enabled_groups:
task = asyncio.create_task( task = asyncio.create_task(
self._perform_auto_analysis_for_group_with_timeout(group_id), self._perform_auto_analysis_for_group_with_timeout(group_id),
name=f"analysis_group_{group_id}" name=f"analysis_group_{group_id}",
) )
analysis_tasks.append(task) analysis_tasks.append(task)
@@ -148,7 +169,9 @@ class AutoScheduler:
else: else:
success_count += 1 success_count += 1
logger.info(f"并发分析完成 - 成功: {success_count}, 失败: {error_count}, 总计: {len(enabled_groups)}") logger.info(
f"并发分析完成 - 成功: {success_count}, 失败: {error_count}, 总计: {len(enabled_groups)}"
)
except Exception as e: except Exception as e:
logger.error(f"自动分析执行失败: {e}", exc_info=True) logger.error(f"自动分析执行失败: {e}", exc_info=True)
@@ -157,7 +180,9 @@ class AutoScheduler:
"""为指定群执行自动分析(带超时控制)""" """为指定群执行自动分析(带超时控制)"""
try: try:
# 为每个群聊设置独立的超时时间(20分钟)- 使用 asyncio.wait_for 兼容所有 Python 版本 # 为每个群聊设置独立的超时时间(20分钟)- 使用 asyncio.wait_for 兼容所有 Python 版本
await asyncio.wait_for(self._perform_auto_analysis_for_group(group_id), timeout=1200) await asyncio.wait_for(
self._perform_auto_analysis_for_group(group_id), timeout=1200
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error(f"{group_id} 分析超时(20分钟),跳过该群分析") logger.error(f"{group_id} 分析超时(20分钟),跳过该群分析")
except Exception as e: except Exception as e:
@@ -167,7 +192,7 @@ class AutoScheduler:
"""为指定群执行自动分析(核心逻辑)""" """为指定群执行自动分析(核心逻辑)"""
# 为每个群聊使用独立的锁,避免全局锁导致串行化 # 为每个群聊使用独立的锁,避免全局锁导致串行化
group_lock_key = f"analysis_{group_id}" group_lock_key = f"analysis_{group_id}"
if not hasattr(self, '_group_locks'): if not hasattr(self, "_group_locks"):
self._group_locks = {} self._group_locks = {}
if group_lock_key not in self._group_locks: if group_lock_key not in self._group_locks:
@@ -180,7 +205,9 @@ class AutoScheduler:
# 检查bot管理器状态 # 检查bot管理器状态
if not self.bot_manager.is_ready_for_auto_analysis(): if not self.bot_manager.is_ready_for_auto_analysis():
status = self.bot_manager.get_status_info() status = self.bot_manager.get_status_info()
logger.warning(f"{group_id} 自动分析跳过:bot管理器未就绪 - {status}") logger.warning(
f"{group_id} 自动分析跳过:bot管理器未就绪 - {status}"
)
return return
logger.info(f"开始为群 {group_id} 执行自动分析(并发任务)") logger.info(f"开始为群 {group_id} 执行自动分析(并发任务)")
@@ -189,7 +216,9 @@ class AutoScheduler:
analysis_days = self.config_manager.get_analysis_days() analysis_days = self.config_manager.get_analysis_days()
bot_instance = self.bot_manager.get_bot_instance() bot_instance = self.bot_manager.get_bot_instance()
messages = await self.message_handler.fetch_group_messages(bot_instance, group_id, analysis_days) messages = await self.message_handler.fetch_group_messages(
bot_instance, group_id, analysis_days
)
if not messages: if not messages:
logger.warning(f"{group_id} 未获取到足够的消息记录") logger.warning(f"{group_id} 未获取到足够的消息记录")
@@ -198,7 +227,9 @@ class AutoScheduler:
# 检查消息数量 # 检查消息数量
min_threshold = self.config_manager.get_min_messages_threshold() min_threshold = self.config_manager.get_min_messages_threshold()
if len(messages) < min_threshold: if len(messages) < min_threshold:
logger.warning(f"{group_id} 消息数量不足({len(messages)}条),跳过分析") logger.warning(
f"{group_id} 消息数量不足({len(messages)}条),跳过分析"
)
return return
logger.info(f"{group_id} 获取到 {len(messages)} 条消息,开始分析") logger.info(f"{group_id} 获取到 {len(messages)} 条消息,开始分析")
@@ -206,7 +237,9 @@ class AutoScheduler:
# 进行分析 - 构造正确的 unified_msg_origin # 进行分析 - 构造正确的 unified_msg_origin
platform_id = self._get_platform_id() platform_id = self._get_platform_id()
umo = f"{platform_id}:GroupMessage:{group_id}" if platform_id else None umo = f"{platform_id}:GroupMessage:{group_id}" if platform_id else None
analysis_result = await self.analyzer.analyze_messages(messages, group_id, umo) analysis_result = await self.analyzer.analyze_messages(
messages, group_id, umo
)
if not analysis_result: if not analysis_result:
logger.error(f"{group_id} 分析失败") logger.error(f"{group_id} 分析失败")
return return
@@ -224,7 +257,7 @@ class AutoScheduler:
finally: finally:
# 清理群聊锁资源(可选,防止内存泄漏) # 清理群聊锁资源(可选,防止内存泄漏)
if hasattr(self, '_group_locks') and len(self._group_locks) > 50: if hasattr(self, "_group_locks") and len(self._group_locks) > 50:
old_locks = list(self._group_locks.keys())[:10] old_locks = list(self._group_locks.keys())[:10]
for lock_key in old_locks: for lock_key in old_locks:
if not self._group_locks[lock_key].locked(): if not self._group_locks[lock_key].locked():
@@ -240,47 +273,87 @@ class AutoScheduler:
# 使用图片格式 # 使用图片格式
logger.info(f"{group_id} 自动分析使用图片报告格式") logger.info(f"{group_id} 自动分析使用图片报告格式")
try: try:
image_url = await self.report_generator.generate_image_report(analysis_result, group_id, self.html_render_func) image_url = await self.report_generator.generate_image_report(
analysis_result, group_id, self.html_render_func
)
if image_url: if image_url:
await self._send_image_message(group_id, image_url) await self._send_image_message(group_id, image_url)
logger.info(f"{group_id} 图片报告发送成功") logger.info(f"{group_id} 图片报告发送成功")
else: else:
# 图片生成失败,回退到文本 # 图片生成失败,回退到文本
logger.warning(f"{group_id} 图片报告生成失败(返回None),回退到文本报告") logger.warning(
text_report = self.report_generator.generate_text_report(analysis_result) f"{group_id} 图片报告生成失败(返回None),回退到文本报告"
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") )
text_report = self.report_generator.generate_text_report(
analysis_result
)
await self._send_text_message(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
)
except Exception as img_e: except Exception as img_e:
logger.error(f"{group_id} 图片报告生成异常: {img_e},回退到文本报告") logger.error(
text_report = self.report_generator.generate_text_report(analysis_result) f"{group_id} 图片报告生成异常: {img_e},回退到文本报告"
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") )
text_report = self.report_generator.generate_text_report(
analysis_result
)
await self._send_text_message(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
)
else: else:
# 没有html_render函数,回退到文本报告 # 没有html_render函数,回退到文本报告
logger.warning(f"{group_id} 缺少html_render函数,回退到文本报告") logger.warning(f"{group_id} 缺少html_render函数,回退到文本报告")
text_report = self.report_generator.generate_text_report(analysis_result) text_report = self.report_generator.generate_text_report(
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") analysis_result
)
await self._send_text_message(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
)
elif output_format == "pdf": elif output_format == "pdf":
if not self.config_manager.pyppeteer_available: if not self.config_manager.pyppeteer_available:
logger.warning(f"{group_id} PDF功能不可用,回退到文本报告") logger.warning(f"{group_id} PDF功能不可用,回退到文本报告")
text_report = self.report_generator.generate_text_report(analysis_result) text_report = self.report_generator.generate_text_report(
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") analysis_result
)
await self._send_text_message(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
)
else: else:
try: try:
pdf_path = await self.report_generator.generate_pdf_report(analysis_result, group_id) pdf_path = await self.report_generator.generate_pdf_report(
analysis_result, group_id
)
if pdf_path: if pdf_path:
await self._send_pdf_file(group_id, pdf_path) await self._send_pdf_file(group_id, pdf_path)
logger.info(f"{group_id} 自动分析完成,已发送PDF报告") logger.info(f"{group_id} 自动分析完成,已发送PDF报告")
else: else:
logger.error(f"{group_id} PDF报告生成失败(返回None),回退到文本报告") logger.error(
text_report = self.report_generator.generate_text_report(analysis_result) f"{group_id} PDF报告生成失败(返回None),回退到文本报告"
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") )
text_report = self.report_generator.generate_text_report(
analysis_result
)
await self._send_text_message(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
)
except Exception as pdf_e: except Exception as pdf_e:
logger.error(f"{group_id} PDF报告生成异常: {pdf_e},回退到文本报告") logger.error(
text_report = self.report_generator.generate_text_report(analysis_result) f"{group_id} PDF报告生成异常: {pdf_e},回退到文本报告"
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") )
text_report = self.report_generator.generate_text_report(
analysis_result
)
await self._send_text_message(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
)
else: else:
text_report = self.report_generator.generate_text_report(analysis_result) text_report = self.report_generator.generate_text_report(
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") analysis_result
)
await self._send_text_message(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
)
logger.info(f"{group_id} 自动分析完成,已发送报告") logger.info(f"{group_id} 自动分析完成,已发送报告")
@@ -299,13 +372,10 @@ class AutoScheduler:
await bot_instance.api.call_action( await bot_instance.api.call_action(
"send_group_msg", "send_group_msg",
group_id=group_id, group_id=group_id,
message=[{ message=[
"type": "text", {"type": "text", "data": {"text": "📊 每日群聊分析报告已生成:"}},
"data": {"text": "📊 每日群聊分析报告已生成:"} {"type": "image", "data": {"url": image_url}},
}, { ],
"type": "image",
"data": {"url": image_url}
}]
) )
logger.info(f"{group_id} 图片消息发送成功") logger.info(f"{group_id} 图片消息发送成功")
@@ -322,9 +392,7 @@ class AutoScheduler:
# 发送文本消息到群 # 发送文本消息到群
await bot_instance.api.call_action( await bot_instance.api.call_action(
"send_group_msg", "send_group_msg", group_id=group_id, message=text_content
group_id=group_id,
message=text_content
) )
logger.info(f"{group_id} 文本消息发送成功") logger.info(f"{group_id} 文本消息发送成功")
@@ -343,13 +411,10 @@ class AutoScheduler:
await bot_instance.api.call_action( await bot_instance.api.call_action(
"send_group_msg", "send_group_msg",
group_id=group_id, group_id=group_id,
message=[{ message=[
"type": "text", {"type": "text", "data": {"text": "📊 每日群聊分析报告已生成:"}},
"data": {"text": "📊 每日群聊分析报告已生成:"} {"type": "file", "data": {"file": pdf_path}},
}, { ],
"type": "file",
"data": {"file": pdf_path}
}]
) )
logger.info(f"{group_id} PDF文件发送成功") logger.info(f"{group_id} PDF文件发送成功")
@@ -360,7 +425,7 @@ class AutoScheduler:
await bot_instance.api.call_action( await bot_instance.api.call_action(
"send_group_msg", "send_group_msg",
group_id=group_id, group_id=group_id,
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}" message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}",
) )
except Exception as e2: except Exception as e2:
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}") logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
+1 -4
View File
@@ -6,7 +6,4 @@
from .pdf_utils import PDFInstaller from .pdf_utils import PDFInstaller
from .helpers import MessageAnalyzer from .helpers import MessageAnalyzer
__all__ = [ __all__ = ["PDFInstaller", "MessageAnalyzer"]
'PDFInstaller',
'MessageAnalyzer'
]
+47 -14
View File
@@ -4,12 +4,13 @@
""" """
from typing import List, Dict from typing import List, Dict
from ...src.models.data_models import GroupStatistics, SummaryTopic, UserTitle, GoldenQuote, TokenUsage from ...src.models.data_models import TokenUsage
from ...src.core.message_handler import MessageHandler from ...src.core.message_handler import MessageHandler
from ...src.analysis.llm_analyzer import LLMAnalyzer from ...src.analysis.llm_analyzer import LLMAnalyzer
from ...src.analysis.statistics import UserAnalyzer from ...src.analysis.statistics import UserAnalyzer
from astrbot.api import logger from astrbot.api import logger
class MessageAnalyzer: class MessageAnalyzer:
"""消息分析器 - 整合所有分析功能""" """消息分析器 - 整合所有分析功能"""
@@ -28,7 +29,9 @@ class MessageAnalyzer:
else: else:
await self.message_handler.set_bot_qq_id(bot_instance) await self.message_handler.set_bot_qq_id(bot_instance)
async def analyze_messages(self, messages: List[Dict], group_id: str, unified_msg_origin: str = None) -> Dict: async def analyze_messages(
self, messages: List[Dict], group_id: str, unified_msg_origin: str = None
) -> Dict:
"""完整的消息分析流程""" """完整的消息分析流程"""
try: try:
# 基础统计 # 基础统计
@@ -39,8 +42,12 @@ class MessageAnalyzer:
# 获取活跃用户列表 - 使用get_top_users方法,limit从配置中读取 # 获取活跃用户列表 - 使用get_top_users方法,limit从配置中读取
max_user_titles = self.config_manager.get_max_user_titles() max_user_titles = self.config_manager.get_max_user_titles()
top_users = self.user_analyzer.get_top_users(user_analysis, limit=max_user_titles) top_users = self.user_analyzer.get_top_users(
logger.info(f"获取到 {len(top_users)} 个活跃用户用于称号分析(配置上限: {max_user_titles})") user_analysis, limit=max_user_titles
)
logger.info(
f"获取到 {len(top_users)} 个活跃用户用于称号分析(配置上限: {max_user_titles})"
)
# LLM分析 - 使用并发方式 # LLM分析 - 使用并发方式
topics = [] topics = []
@@ -51,35 +58,61 @@ class MessageAnalyzer:
# 检查各个分析功能是否启用 # 检查各个分析功能是否启用
topic_enabled = self.config_manager.get_topic_analysis_enabled() topic_enabled = self.config_manager.get_topic_analysis_enabled()
user_title_enabled = self.config_manager.get_user_title_analysis_enabled() user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
golden_quote_enabled = self.config_manager.get_golden_quote_analysis_enabled() golden_quote_enabled = (
self.config_manager.get_golden_quote_analysis_enabled()
)
# 如果三个分析都启用,使用并发执行 # 如果三个分析都启用,使用并发执行
if topic_enabled and user_title_enabled and golden_quote_enabled: if topic_enabled and user_title_enabled and golden_quote_enabled:
# 并发执行所有三个分析任务,传入活跃用户列表 # 并发执行所有三个分析任务,传入活跃用户列表
topics, user_titles, golden_quotes, total_token_usage = await self.llm_analyzer.analyze_all_concurrent( (
topics,
user_titles,
golden_quotes,
total_token_usage,
) = await self.llm_analyzer.analyze_all_concurrent(
messages, user_analysis, umo=unified_msg_origin, top_users=top_users messages, user_analysis, umo=unified_msg_origin, top_users=top_users
) )
else: else:
# 如果只启用部分分析,则按需执行 # 如果只启用部分分析,则按需执行
if topic_enabled: if topic_enabled:
topics, topic_tokens = await self.llm_analyzer.analyze_topics(messages, umo=unified_msg_origin) topics, topic_tokens = await self.llm_analyzer.analyze_topics(
messages, umo=unified_msg_origin
)
total_token_usage.prompt_tokens += topic_tokens.prompt_tokens total_token_usage.prompt_tokens += topic_tokens.prompt_tokens
total_token_usage.completion_tokens += topic_tokens.completion_tokens total_token_usage.completion_tokens += (
topic_tokens.completion_tokens
)
total_token_usage.total_tokens += topic_tokens.total_tokens total_token_usage.total_tokens += topic_tokens.total_tokens
if user_title_enabled: if user_title_enabled:
# 传入活跃用户列表 # 传入活跃用户列表
user_titles, title_tokens = await self.llm_analyzer.analyze_user_titles( (
messages, user_analysis, umo=unified_msg_origin, top_users=top_users user_titles,
title_tokens,
) = await self.llm_analyzer.analyze_user_titles(
messages,
user_analysis,
umo=unified_msg_origin,
top_users=top_users,
) )
total_token_usage.prompt_tokens += title_tokens.prompt_tokens total_token_usage.prompt_tokens += title_tokens.prompt_tokens
total_token_usage.completion_tokens += title_tokens.completion_tokens total_token_usage.completion_tokens += (
title_tokens.completion_tokens
)
total_token_usage.total_tokens += title_tokens.total_tokens total_token_usage.total_tokens += title_tokens.total_tokens
if golden_quote_enabled: if golden_quote_enabled:
golden_quotes, quote_tokens = await self.llm_analyzer.analyze_golden_quotes(messages, umo=unified_msg_origin) (
golden_quotes,
quote_tokens,
) = await self.llm_analyzer.analyze_golden_quotes(
messages, umo=unified_msg_origin
)
total_token_usage.prompt_tokens += quote_tokens.prompt_tokens total_token_usage.prompt_tokens += quote_tokens.prompt_tokens
total_token_usage.completion_tokens += quote_tokens.completion_tokens total_token_usage.completion_tokens += (
quote_tokens.completion_tokens
)
total_token_usage.total_tokens += quote_tokens.total_tokens total_token_usage.total_tokens += quote_tokens.total_tokens
# 更新统计数据 # 更新统计数据
@@ -90,7 +123,7 @@ class MessageAnalyzer:
"statistics": statistics, "statistics": statistics,
"topics": topics, "topics": topics,
"user_titles": user_titles, "user_titles": user_titles,
"user_analysis": user_analysis "user_analysis": user_analysis,
} }
except Exception as e: except Exception as e:
+52 -34
View File
@@ -5,7 +5,6 @@ PDF工具模块
import sys import sys
import asyncio import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from astrbot.api import logger from astrbot.api import logger
@@ -14,12 +13,14 @@ class PDFInstaller:
"""PDF功能安装器""" """PDF功能安装器"""
# 类级别的线程池,用于异步下载任务 # 类级别的线程池,用于异步下载任务
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="chromium_download") _executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="chromium_download"
)
_download_status = { _download_status = {
"in_progress": False, "in_progress": False,
"completed": False, "completed": False,
"failed": False, "failed": False,
"error_message": None "error_message": None,
} }
@staticmethod @staticmethod
@@ -31,10 +32,14 @@ class PDFInstaller:
# 使用asyncio安装pyppeteer和兼容的websockets版本 # 使用asyncio安装pyppeteer和兼容的websockets版本
logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...") logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...")
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
sys.executable, "-m", "pip", "install", sys.executable,
"pyppeteer==1.0.2", "websockets==10.4", "-m",
"pip",
"install",
"pyppeteer==1.0.2",
"websockets==10.4",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
@@ -104,7 +109,7 @@ class PDFInstaller:
# 使用 asyncio.wait_for 实现超时控制 # 使用 asyncio.wait_for 实现超时控制
success = await asyncio.wait_for( success = await asyncio.wait_for(
PDFInstaller._download_chromium_via_pyppeteer(), PDFInstaller._download_chromium_via_pyppeteer(),
timeout=timeout_seconds timeout=timeout_seconds,
) )
if success: if success:
@@ -113,12 +118,16 @@ class PDFInstaller:
logger.info("✅ Chromium 后台下载完成!") logger.info("✅ Chromium 后台下载完成!")
else: else:
PDFInstaller._download_status["failed"] = True PDFInstaller._download_status["failed"] = True
PDFInstaller._download_status["error_message"] = "下载失败,请检查网络连接" PDFInstaller._download_status["error_message"] = (
"下载失败,请检查网络连接"
)
logger.error("❌ Chromium 下载失败") logger.error("❌ Chromium 下载失败")
except asyncio.TimeoutError: except asyncio.TimeoutError:
PDFInstaller._download_status["failed"] = True PDFInstaller._download_status["failed"] = True
PDFInstaller._download_status["error_message"] = f"下载超时({timeout_seconds}秒)" PDFInstaller._download_status["error_message"] = (
f"下载超时({timeout_seconds}秒)"
)
logger.error(f"❌ Chromium 下载超时({timeout_seconds}秒)") logger.error(f"❌ Chromium 下载超时({timeout_seconds}秒)")
except Exception as e: except Exception as e:
@@ -137,7 +146,9 @@ class PDFInstaller:
while retry_count <= max_retries: while retry_count <= max_retries:
try: try:
if retry_count > 0: if retry_count > 0:
logger.info(f"正在重试下载 Chromium(第 {retry_count}/{max_retries} 次)...") logger.info(
f"正在重试下载 Chromium(第 {retry_count}/{max_retries} 次)..."
)
else: else:
logger.info("通过 pyppeteer 自动下载 Chromium...") logger.info("通过 pyppeteer 自动下载 Chromium...")
@@ -155,11 +166,11 @@ class PDFInstaller:
# 创建 Launcher 实例但不启动浏览器 # 创建 Launcher 实例但不启动浏览器
launcher = Launcher( launcher = Launcher(
headless=True, headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox'] args=["--no-sandbox", "--disable-setuid-sandbox"],
) )
# 只下载 Chromium # 只下载 Chromium
chromium_revision = launcher._get_chromium_revision() launcher._get_chromium_revision()
await launcher._download_chromium() await launcher._download_chromium()
logger.info("✅ Chromium 下载完成") logger.info("✅ Chromium 下载完成")
@@ -171,28 +182,29 @@ class PDFInstaller:
# 方法2: 通过启动浏览器触发自动下载 # 方法2: 通过启动浏览器触发自动下载
import platform import platform
system = platform.system().lower() system = platform.system().lower()
if system == "linux": if system == "linux":
browser_args = [ browser_args = [
'--no-sandbox', "--no-sandbox",
'--disable-setuid-sandbox', "--disable-setuid-sandbox",
'--disable-dev-shm-usage', "--disable-dev-shm-usage",
'--disable-accelerated-2d-canvas', "--disable-accelerated-2d-canvas",
'--no-first-run', "--no-first-run",
'--no-zygote', "--no-zygote",
'--disable-gpu', "--disable-gpu",
'--disable-background-timer-throttling', "--disable-background-timer-throttling",
'--disable-backgrounding-occluded-windows', "--disable-backgrounding-occluded-windows",
'--disable-renderer-backgrounding', "--disable-renderer-backgrounding",
'--disable-features=TranslateUI', "--disable-features=TranslateUI",
'--disable-ipc-flooding-protection' "--disable-ipc-flooding-protection",
] ]
else: else:
browser_args = [ browser_args = [
'--no-sandbox', "--no-sandbox",
'--disable-setuid-sandbox', "--disable-setuid-sandbox",
'--disable-gpu' "--disable-gpu",
] ]
logger.info("启动 pyppeteer 浏览器以触发 Chromium 自动下载...") logger.info("启动 pyppeteer 浏览器以触发 Chromium 自动下载...")
@@ -200,7 +212,7 @@ class PDFInstaller:
headless=True, headless=True,
args=browser_args, args=browser_args,
ignoreHTTPSErrors=True, ignoreHTTPSErrors=True,
dumpio=False # 关闭浏览器日志输出以减少干扰 dumpio=False, # 关闭浏览器日志输出以减少干扰
) )
# 获取 Chromium 路径 # 获取 Chromium 路径
@@ -218,11 +230,13 @@ class PDFInstaller:
logger.info("尝试使用命令行触发 Chromium 自动下载...") logger.info("尝试使用命令行触发 Chromium 自动下载...")
import platform import platform
system = platform.system().lower() system = platform.system().lower()
if system == "linux": if system == "linux":
cmd = [ cmd = [
sys.executable, "-c", sys.executable,
"-c",
""" """
import pyppeteer import pyppeteer
import asyncio import asyncio
@@ -246,18 +260,19 @@ async def download_chrome():
raise raise
asyncio.run(download_chrome()) asyncio.run(download_chrome())
""" """,
] ]
else: else:
cmd = [ cmd = [
sys.executable, "-c", sys.executable,
"import pyppeteer; import asyncio; asyncio.run(pyppeteer.launch())" "-c",
"import pyppeteer; import asyncio; asyncio.run(pyppeteer.launch())",
] ]
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
@@ -280,7 +295,10 @@ asyncio.run(download_chrome())
logger.warning(f"下载失败,{wait_time}秒后重试... 错误: {e}") logger.warning(f"下载失败,{wait_time}秒后重试... 错误: {e}")
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
else: else:
logger.error(f"通过 pyppeteer 自动下载 Chromium 失败(已重试{max_retries}次): {e}", exc_info=True) logger.error(
f"通过 pyppeteer 自动下载 Chromium 失败(已重试{max_retries}次): {e}",
exc_info=True,
)
return False return False
return False return False
+1 -1
View File
@@ -4,4 +4,4 @@
from .activity_charts import ActivityVisualizer from .activity_charts import ActivityVisualizer
__all__ = ['ActivityVisualizer'] __all__ = ["ActivityVisualizer"]
+22 -14
View File
@@ -3,9 +3,9 @@
参考 astrbot_plugin_github_analyzer 的实现方式 参考 astrbot_plugin_github_analyzer 的实现方式
""" """
from datetime import datetime, timedelta from datetime import datetime
from collections import defaultdict from collections import defaultdict
from typing import Dict, List, Any from typing import Dict, List
from ..models.data_models import ActivityVisualization from ..models.data_models import ActivityVisualization
@@ -15,7 +15,9 @@ class ActivityVisualizer:
def __init__(self): def __init__(self):
pass pass
def generate_activity_visualization(self, messages: List[Dict]) -> ActivityVisualization: def generate_activity_visualization(
self, messages: List[Dict]
) -> ActivityVisualization:
"""生成活跃度可视化数据 - 专注于小时级别分析""" """生成活跃度可视化数据 - 专注于小时级别分析"""
hourly_activity = defaultdict(int) hourly_activity = defaultdict(int)
user_activity = defaultdict(int) user_activity = defaultdict(int)
@@ -54,15 +56,19 @@ class ActivityVisualizer:
# 生成用户活跃度排行 # 生成用户活跃度排行
user_ranking = [] user_ranking = []
for user_id, data in user_activity.items(): for user_id, data in user_activity.items():
user_ranking.append({ user_ranking.append(
"user_id": user_id, {
"nickname": data["nickname"], "user_id": user_id,
"message_count": data["count"] "nickname": data["nickname"],
}) "message_count": data["count"],
}
)
user_ranking.sort(key=lambda x: x["message_count"], reverse=True) user_ranking.sort(key=lambda x: x["message_count"], reverse=True)
# 找出高峰时段(活跃度最高的3个小时) # 找出高峰时段(活跃度最高的3个小时)
peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[:3] peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[
:3
]
peak_hours = [{"hour": hour, "count": count} for hour, count in peak_hours] peak_hours = [{"hour": hour, "count": count} for hour, count in peak_hours]
return ActivityVisualization( return ActivityVisualization(
@@ -70,10 +76,14 @@ class ActivityVisualizer:
daily_activity={}, # 不使用日期分析 daily_activity={}, # 不使用日期分析
user_activity_ranking=user_ranking[:10], # 前10名 user_activity_ranking=user_ranking[:10], # 前10名
peak_hours=peak_hours, peak_hours=peak_hours,
activity_heatmap_data=self._generate_hourly_heatmap_data(hourly_activity, emoji_activity) activity_heatmap_data=self._generate_hourly_heatmap_data(
hourly_activity, emoji_activity
),
) )
def _generate_hourly_heatmap_data(self, hourly_activity: dict, emoji_activity: dict) -> dict: def _generate_hourly_heatmap_data(
self, hourly_activity: dict, emoji_activity: dict
) -> dict:
"""生成小时级热力图数据""" """生成小时级热力图数据"""
# 计算活跃度等级 # 计算活跃度等级
max_hourly = max(hourly_activity.values()) if hourly_activity else 1 max_hourly = max(hourly_activity.values()) if hourly_activity else 1
@@ -90,7 +100,7 @@ class ActivityVisualizer:
hour: (emoji_activity.get(hour, 0) / max_emoji) * 100 hour: (emoji_activity.get(hour, 0) / max_emoji) * 100
for hour in range(24) for hour in range(24)
}, },
"activity_levels": self._calculate_activity_levels(hourly_activity) "activity_levels": self._calculate_activity_levels(hourly_activity),
} }
def _calculate_activity_levels(self, hourly_activity: dict) -> dict: def _calculate_activity_levels(self, hourly_activity: dict) -> dict:
@@ -161,5 +171,3 @@ class ActivityVisualizer:
html_parts.append(html_segment) html_parts.append(html_segment)
return "".join(html_parts) return "".join(html_parts)