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