diff --git a/README.md b/README.md index b568892..ec6cb75 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # QQ群日常分析插件 -[![Plugin Version](https://img.shields.io/badge/Latest_Version-v1.6.0-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis) +[![Plugin Version](https://img.shields.io/badge/Latest_Version-v1.7.0-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis) [![AstrBot](https://img.shields.io/badge/AstrBot-Plugin-ff69b4?style=for-the-badge)](https://github.com/AstrBotDevs/AstrBot) [![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE) @@ -137,6 +137,10 @@ _✨ 一个基于AstrBot的智能群聊分析插件,能够生成精美的群 ### v1.4.0 - PDF 版本情况说明更新 +### v1.7.0 +- 修复提示 +- 解耦化 + ## 许可证 MIT License diff --git a/main.py b/main.py index c5893e7..1c6452b 100644 --- a/main.py +++ b/main.py @@ -1,142 +1,34 @@ -""" +""" QQ群日常分析插件 基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等 + +重构版本 - 使用模块化架构 """ -import json import asyncio -import base64 -import aiohttp -import subprocess -import sys -import os -from datetime import datetime, timedelta -from typing import List, Dict, Optional +from typing import Optional from pathlib import Path -from dataclasses import dataclass, field -from collections import defaultdict from astrbot.api.event import filter -from astrbot.api.star import Context, Star, register +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.message.components import File from astrbot.core.star.filter.permission import PermissionType -# PDF 生成相关导入 -PYPPETEER_AVAILABLE = False -PYPPETEER_VERSION = None - -def check_pyppeteer_availability(): - """检查 pyppeteer 可用性""" - global PYPPETEER_AVAILABLE, PYPPETEER_VERSION - try: - import pyppeteer - from pyppeteer import launch - PYPPETEER_AVAILABLE = True - - # 检查版本 - try: - PYPPETEER_VERSION = pyppeteer.__version__ - logger.info(f"使用 pyppeteer {PYPPETEER_VERSION} 作为 PDF 引擎") - except AttributeError: - PYPPETEER_VERSION = "unknown" - logger.info("使用 pyppeteer (版本未知) 作为 PDF 引擎") - - return True - except ImportError: - PYPPETEER_AVAILABLE = False - PYPPETEER_VERSION = None - logger.warning("pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2") - return False - -def reload_pyppeteer(): - """重新加载 pyppeteer 模块""" - global PYPPETEER_AVAILABLE, PYPPETEER_VERSION - import sys - import importlib - - try: - logger.info("开始重新加载 pyppeteer 模块...") - - # 移除所有 pyppeteer 相关模块 - modules_to_remove = [mod for mod in sys.modules.keys() if mod.startswith('pyppeteer')] - logger.info(f"移除模块: {modules_to_remove}") - for mod in modules_to_remove: - del sys.modules[mod] - - # 强制重新导入 - try: - import pyppeteer - from pyppeteer import launch - - # 更新全局变量 - PYPPETEER_AVAILABLE = True - try: - PYPPETEER_VERSION = pyppeteer.__version__ - logger.info(f"重新加载成功,pyppeteer 版本: {PYPPETEER_VERSION}") - except AttributeError: - PYPPETEER_VERSION = "unknown" - logger.info("重新加载成功,pyppeteer 版本未知") - - return True - - except ImportError as e: - logger.error(f"重新导入 pyppeteer 失败: {e}") - PYPPETEER_AVAILABLE = False - PYPPETEER_VERSION = None - return False - - except Exception as e: - logger.error(f"重新加载 pyppeteer 时出错: {e}") - return False - -# 初始检查 -check_pyppeteer_availability() +# 导入重构后的模块 +from .src.core.config import ConfigManager +from .src.reports.generators import ReportGenerator +from .src.scheduler.auto_scheduler import AutoScheduler +from .src.utils.pdf_utils import PDFInstaller +from .src.utils.helpers import MessageAnalyzer -@dataclass -class SummaryTopic: - """话题总结数据结构""" - topic: str - contributors: List[str] - detail: str - - -@dataclass -class UserTitle: - """用户称号数据结构""" - name: str - qq: int - title: str - mbti: str - reason: str - - -@dataclass -class GoldenQuote: - """群聊金句数据结构""" - content: str - sender: str - reason: str - -@dataclass -class TokenUsage: - """Token使用统计""" - prompt_tokens: int = 0 - completion_tokens: int = 0 - total_tokens: int = 0 - -@dataclass -class GroupStatistics: - """群聊统计数据结构""" - message_count: int - total_characters: int - participant_count: int - most_active_period: str - golden_quotes: List[GoldenQuote] - emoji_count: int - token_usage: TokenUsage = field(default_factory=TokenUsage) +# 全局变量 +config_manager = None +message_analyzer = None +report_generator = None +auto_scheduler = None class QQGroupDailyAnalysis(Star): @@ -144,37 +36,84 @@ class QQGroupDailyAnalysis(Star): super().__init__(context) self.config = config - # 直接从AstrBot配置系统读取配置 - self.enabled_groups = config.get("enabled_groups", []) - self.max_messages = config.get("max_messages", 1000) - self.analysis_days = config.get("analysis_days", 1) - self.auto_analysis_time = config.get("auto_analysis_time", "09:00") - self.enable_auto_analysis = config.get("enable_auto_analysis", False) - self.output_format = config.get("output_format", "image") + # 初始化模块化组件 + global config_manager, message_analyzer, report_generator, auto_scheduler - self.min_messages_threshold = config.get("min_messages_threshold", 50) - self.topic_analysis_enabled = config.get("topic_analysis_enabled", True) - self.user_title_analysis_enabled = config.get("user_title_analysis_enabled", True) - self.max_topics = config.get("max_topics", 5) - self.max_user_titles = config.get("max_user_titles", 8) - self.max_golden_quotes = config.get("max_golden_quotes", 5) - self.max_query_rounds = config.get("max_query_rounds", 35) + config_manager = ConfigManager(config) + message_analyzer = MessageAnalyzer(context, config_manager) + report_generator = ReportGenerator(config_manager) + auto_scheduler = AutoScheduler( + config_manager, + message_analyzer.message_handler, + message_analyzer, + report_generator, + self.html_render # 传入html_render函数 + ) - # PDF 相关配置 - self.pdf_output_dir = config.get("pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports") - - self.pdf_filename_format = config.get("pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf") - - # 启动定时任务 - self.scheduler_task = None - self.bot_instance = None # 保存bot实例用于自动分析 - self.bot_qq_id = None # 保存机器人QQ号,用于过滤机器人消息 - - # 延迟启动定时任务,给系统时间初始化 - if self.enable_auto_analysis: + # 延迟启动自动调度器,给系统时间初始化 + if config_manager.get_enable_auto_analysis(): asyncio.create_task(self._delayed_start_scheduler()) - - logger.info("QQ群日常分析插件已初始化") + + logger.info("QQ群日常分析插件已初始化(模块化版本)") + + async def _delayed_start_scheduler(self): + """延迟启动调度器,给系统时间初始化""" + try: + # 等待10秒让系统完全初始化 + await asyncio.sleep(10) + + # 尝试获取bot实例 + bot_instance = await self._get_bot_instance() + if bot_instance: + auto_scheduler.set_bot_instance(bot_instance) + logger.info("已为自动调度器设置bot实例") + else: + logger.info("暂时未获取到bot实例,定时任务仍会启动") + + # 启动调度器 + await auto_scheduler.start_scheduler() + + except Exception as e: + logger.error(f"延迟启动调度器失败: {e}") + + async def _get_bot_instance(self): + """从Context获取bot实例""" + try: + # 简化的获取逻辑,尝试常见的几种方式 + if hasattr(self.context, 'get_platforms') and callable(self.context.get_platforms): + platforms = self.context.get_platforms() + for platform in platforms: + if hasattr(platform, 'bot') and platform.bot: + logger.info(f"从平台获取到bot实例") + return platform.bot + + # 尝试从context的platforms属性获取 + if hasattr(self.context, 'platforms') and self.context.platforms: + for platform in self.context.platforms: + if hasattr(platform, 'bot') and platform.bot: + logger.info(f"从平台列表获取到bot实例") + return platform.bot + + logger.info("暂时无法获取bot实例") + return None + + except Exception as e: + logger.error(f"获取bot实例失败: {e}") + return None + + async def _reload_config_and_restart_scheduler(self): + """重新加载配置并重启调度器""" + try: + # 重新加载配置 + config_manager.reload_config() + logger.info(f"重新加载配置: 自动分析={config_manager.get_enable_auto_analysis()}") + + # 重启调度器 + await auto_scheduler.restart_scheduler() + logger.info("配置重载和调度器重启完成") + + except Exception as e: + logger.error(f"重新加载配置失败: {e}") @filter.command("群分析") @filter.permission_type(PermissionType.ADMIN) @@ -186,49 +125,47 @@ class QQGroupDailyAnalysis(Star): if not isinstance(event, AiocqhttpMessageEvent): yield event.plain_result("❌ 此功能仅支持QQ群聊") return - + group_id = event.get_group_id() if not group_id: yield event.plain_result("❌ 请在群聊中使用此命令") return - # 保存bot实例用于自动分析 - self.bot_instance = event.bot - - # 获取机器人QQ号 - await self._get_bot_qq_id() - + # 设置bot实例 + auto_scheduler.set_bot_instance(event.bot) + await message_analyzer.set_bot_instance(event.bot) + # 检查群组权限 - if self.enabled_groups and group_id not in self.enabled_groups: + enabled_groups = config_manager.get_enabled_groups() + if enabled_groups and group_id not in enabled_groups: yield event.plain_result("❌ 此群未启用日常分析功能") return - - # 设置分析天数 - analysis_days = days if days and 1 <= days <= 7 else self.analysis_days - + analysis_days = days if days and 1 <= days <= 7 else config_manager.get_analysis_days() + yield event.plain_result(f"🔍 开始分析群聊近{analysis_days}天的活动,请稍候...") # 调试:输出当前配置 - logger.info(f"当前输出格式配置: {self.output_format}") + logger.info(f"当前输出格式配置: {config_manager.get_output_format()}") try: # 获取群聊消息 - messages = await self._fetch_group_messages(event, analysis_days) + messages = await message_analyzer.message_handler.fetch_group_messages(event.bot, group_id, analysis_days) if not messages: yield event.plain_result("❌ 未找到足够的群聊记录,请确保群内有足够的消息历史") return # 检查消息数量是否足够分析 - if len(messages) < self.min_messages_threshold: - yield event.plain_result(f"❌ 消息数量不足({len(messages)}条),至少需要{self.min_messages_threshold}条消息才能进行有效分析") + min_threshold = config_manager.get_min_messages_threshold() + if len(messages) < min_threshold: + yield event.plain_result(f"❌ 消息数量不足({len(messages)}条),至少需要{min_threshold}条消息才能进行有效分析") return yield event.plain_result(f"📊 已获取{len(messages)}条消息,正在进行智能分析...") # 进行分析 - analysis_result = await self._analyze_messages(messages, group_id) + analysis_result = await message_analyzer.analyze_messages(messages, group_id) # 检查分析结果 if not analysis_result or not analysis_result.get("statistics"): @@ -236,31 +173,29 @@ class QQGroupDailyAnalysis(Star): return # 生成报告 - if self.output_format == "image": - image_url = await self._generate_image_report(analysis_result, group_id) + 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) if image_url: yield event.image_result(image_url) else: # 如果图片生成失败,回退到文本报告 logger.warning("图片报告生成失败,回退到文本报告") - text_report = await self._generate_text_report(analysis_result) + text_report = report_generator.generate_text_report(analysis_result) yield event.plain_result(f"⚠️ 图片报告生成失败,以下是文本版本:\n\n{text_report}") - elif self.output_format == "pdf": - if not PYPPETEER_AVAILABLE: + elif output_format == "pdf": + if not config_manager.pyppeteer_available: yield event.plain_result("❌ PDF 功能不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2") return - # yield event.plain_result("📄 正在生成 PDF 报告,请稍候...") - # yield event.plain_result("💡 首次使用可能需要下载 Chromium 浏览器,请耐心等待...") - - pdf_path = await self._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) yield result - # yield event.plain_result(f"✅ PDF 报告已生成并发送") else: # 如果 PDF 生成失败,提供详细的错误信息和解决方案 yield event.plain_result("❌ PDF 报告生成失败") @@ -271,10 +206,10 @@ class QQGroupDailyAnalysis(Star): # 回退到文本报告 logger.warning("PDF 报告生成失败,回退到文本报告") - text_report = await self._generate_text_report(analysis_result) + text_report = report_generator.generate_text_report(analysis_result) yield event.plain_result(f"\n📝 以下是文本版本的分析报告:\n\n{text_report}") else: - text_report = await self._generate_text_report(analysis_result) + text_report = report_generator.generate_text_report(analysis_result) yield event.plain_result(text_report) except Exception as e: @@ -298,12 +233,14 @@ class QQGroupDailyAnalysis(Star): return if not format_type: - yield event.plain_result(f"""📊 当前输出格式: {self.output_format} + current_format = config_manager.get_output_format() + pdf_status = '✅' if config_manager.pyppeteer_available else '❌ (需安装 pyppeteer)' + yield event.plain_result(f"""📊 当前输出格式: {current_format} 可用格式: • image - 图片格式 (默认) • text - 文本格式 -• pdf - PDF 格式 {'✅' if PYPPETEER_AVAILABLE else '❌ (需安装 pyppeteer)'} +• pdf - PDF 格式 {pdf_status} 用法: /设置格式 [格式名称]""") return @@ -313,13 +250,11 @@ class QQGroupDailyAnalysis(Star): yield event.plain_result("❌ 无效的格式类型,支持: image, text, pdf") return - if format_type == "pdf" and not PYPPETEER_AVAILABLE: + if format_type == "pdf" and not config_manager.pyppeteer_available: yield event.plain_result("❌ PDF 格式不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2") return - self.output_format = format_type - self.config["output_format"] = format_type - self.config.save_config() + config_manager.set_output_format(format_type) yield event.plain_result(f"✅ 输出格式已设置为: {format_type}") @filter.command("安装PDF") @@ -329,8 +264,6 @@ class QQGroupDailyAnalysis(Star): 安装 PDF 功能依赖 用法: /安装PDF """ - global PYPPETEER_AVAILABLE - if not isinstance(event, AiocqhttpMessageEvent): yield event.plain_result("❌ 此功能仅支持QQ群聊") return @@ -338,62 +271,9 @@ class QQGroupDailyAnalysis(Star): yield event.plain_result("🔄 开始安装 PDF 功能依赖,请稍候...") try: - # 检查是否已安装 - if PYPPETEER_AVAILABLE: - yield event.plain_result("✅ pyppeteer 已安装,正在检查 Chromium...") - - # 检查 Chromium - try: - import pyppeteer - # 尝试获取 Chromium 路径 - try: - chromium_path = pyppeteer.executablePath() - if Path(chromium_path).exists(): - yield event.plain_result("✅ PDF 功能已完全可用!") - return - except Exception: - # executablePath() 可能失败,说明 Chromium 未安装 - pass - - yield event.plain_result("🔄 Chromium 未安装,正在下载...") - success = await self._install_chromium() - if success: - yield event.plain_result("✅ PDF 功能安装完成!") - else: - yield event.plain_result("❌ Chromium 安装失败,请检查网络连接。\n💡 可尝试手动安装:在 Python 中运行 'import pyppeteer; await pyppeteer.launch()'") - return - except Exception as e: - yield event.plain_result(f"⚠️ 检查 Chromium 时出错: {e}") - - # 尝试安装更新版本的 pyppeteer - yield event.plain_result("📦 正在安装/更新 pyppeteer 库...") - - # 强制安装稳定版本的 pyppeteer (1.0.2) - yield event.plain_result("🔄 强制安装 pyppeteer 稳定版本 (1.0.2)...") - yield event.plain_result("� 使用 1.0.2 版本可避免 Chromium 下载问题") - success = await self._install_package("pyppeteer==1.0.2") - - if not success: - yield event.plain_result("❌ pyppeteer 安装失败") - yield event.plain_result("🔧 请尝试手动安装稳定版本:") - yield event.plain_result(" pip install pyppeteer==1.0.2") - yield event.plain_result("💡 如果仍然失败,请检查网络连接或使用代理") - return - - yield event.plain_result("✅ pyppeteer 安装成功!") - - # 重新检查可用性 - reload_success = reload_pyppeteer() - - if reload_success: - yield event.plain_result("🎉 PDF 功能安装完成!") - yield event.plain_result(f"✅ pyppeteer {PYPPETEER_VERSION} 已成功加载") - yield event.plain_result("💡 现在可以使用 /设置格式 pdf 启用 PDF 报告") - yield event.plain_result("📝 注意:首次生成 PDF 时会自动下载 Chromium") - else: - yield event.plain_result("⚠️ pyppeteer 重新加载失败") - yield event.plain_result("🔄 需要你手动去重启一次 AstrBot 程序") - yield event.plain_result("💡 pyppeteer 包已安装,但需要重启程序才能生效") + # 使用模块化的PDF安装器 + result = await PDFInstaller.install_pyppeteer(config_manager) + yield event.plain_result(result) except Exception as e: logger.error(f"安装 PDF 依赖失败: {e}", exc_info=True) @@ -414,2316 +294,79 @@ class QQGroupDailyAnalysis(Star): if not isinstance(event, AiocqhttpMessageEvent): yield event.plain_result("❌ 此功能仅支持QQ群聊") return - + group_id = event.get_group_id() if not group_id: yield event.plain_result("❌ 请在群聊中使用此命令") return - + if action == "enable": - if group_id not in self.enabled_groups: - self.enabled_groups.append(group_id) - self.config["enabled_groups"] = self.enabled_groups - self.config.save_config() + enabled_groups = config_manager.get_enabled_groups() + if group_id not in enabled_groups: + config_manager.add_enabled_group(group_id) yield event.plain_result("✅ 已为当前群启用日常分析功能") - # 重新加载配置并启动定时任务 - await self._reload_config_and_restart_scheduler() + # 重新启动定时任务 + await auto_scheduler.restart_scheduler() else: yield event.plain_result("ℹ️ 当前群已启用日常分析功能") elif action == "disable": - if group_id in self.enabled_groups: - self.enabled_groups.remove(group_id) - self.config["enabled_groups"] = self.enabled_groups - self.config.save_config() + enabled_groups = config_manager.get_enabled_groups() + if group_id in enabled_groups: + config_manager.remove_enabled_group(group_id) yield event.plain_result("✅ 已为当前群禁用日常分析功能") + + # 重新启动定时任务 + await auto_scheduler.restart_scheduler() else: yield event.plain_result("ℹ️ 当前群未启用日常分析功能") elif action == "reload": - # 重新加载配置 - await self._reload_config_and_restart_scheduler() + # 重新启动定时任务 + await auto_scheduler.restart_scheduler() yield event.plain_result("✅ 已重新加载配置并重启定时任务") elif action == "test": # 测试自动分析功能 - if group_id not in self.enabled_groups: + enabled_groups = config_manager.get_enabled_groups() + if group_id not in enabled_groups: yield event.plain_result("❌ 请先启用当前群的分析功能") return yield event.plain_result("🧪 开始测试自动分析功能...") - # 保存bot实例 - self.bot_instance = event.bot + # 设置bot实例 + auto_scheduler.set_bot_instance(event.bot) # 执行自动分析 try: - await self._perform_auto_analysis_for_group(group_id) + await auto_scheduler._perform_auto_analysis_for_group(group_id) yield event.plain_result("✅ 自动分析测试完成,请查看群消息") except Exception as e: yield event.plain_result(f"❌ 自动分析测试失败: {str(e)}") else: # status - status = "已启用" if group_id in self.enabled_groups else "未启用" - auto_status = "已启用" if self.enable_auto_analysis else "未启用" - scheduler_status = "运行中" if hasattr(self, 'scheduler_task') and self.scheduler_task and not self.scheduler_task.done() else "未运行" + 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_time = config_manager.get_auto_analysis_time() + + pdf_status = PDFInstaller.get_pdf_status(config_manager) + output_format = config_manager.get_output_format() + min_threshold = config_manager.get_min_messages_threshold() + max_rounds = config_manager.get_max_query_rounds() - if PYPPETEER_AVAILABLE: - pdf_status = f"可用 (pyppeteer {PYPPETEER_VERSION})" - else: - pdf_status = "不可用 (使用 /安装PDF 命令安装)" yield event.plain_result(f"""📊 当前群分析功能状态: • 群分析功能: {status} -• 自动分析: {auto_status} ({self.auto_analysis_time}) -• 定时任务: {scheduler_status} -• 输出格式: {self.output_format} +• 自动分析: {auto_status} ({auto_time}) +• 输出格式: {output_format} • PDF 功能: {pdf_status} -• 最小消息数: {self.min_messages_threshold} -• 最大查询轮数: {self.max_query_rounds} +• 最小消息数: {min_threshold} +• 最大查询轮数: {max_rounds} 💡 可用命令: enable, disable, status, reload, test 💡 支持的输出格式: image, text, pdf 💡 其他命令: /设置格式, /安装PDF""") - async def _get_bot_qq_id(self): - """获取机器人QQ号""" - try: - if self.bot_instance and not self.bot_qq_id: - login_info = await self.bot_instance.api.call_action("get_login_info") - self.bot_qq_id = str(login_info.get("user_id", "")) - logger.info(f"获取到机器人QQ号: {self.bot_qq_id}") - except Exception as e: - logger.error(f"获取机器人QQ号失败: {e}") - - async def _install_package(self, package_name: str) -> bool: - """安装 Python 包""" - try: - process = await asyncio.create_subprocess_exec( - sys.executable, "-m", "pip", "install", package_name, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - if process.returncode == 0: - logger.info(f"成功安装包: {package_name}") - return True - else: - logger.error(f"安装包 {package_name} 失败: {stderr.decode()}") - return False - - except Exception as e: - logger.error(f"安装包 {package_name} 时出错: {e}") - return False - - async def _install_chromium(self) -> bool: - """安装 Chromium 浏览器""" - try: - # 确保 pyppeteer 可用 - if not PYPPETEER_AVAILABLE: - logger.error("pyppeteer 不可用,无法安装 Chromium") - return False - - # 尝试直接启动浏览器,这会触发自动下载 - logger.info("尝试通过启动浏览器来触发 Chromium 下载") - - import pyppeteer - from pyppeteer import launch - browser = await launch(headless=True, args=['--no-sandbox']) - await browser.close() - - logger.info("成功安装并测试 Chromium") - return True - - except Exception as e: - logger.error(f"通过启动浏览器安装 Chromium 失败: {e}") - - # 备用方法:尝试命令行安装 - try: - logger.info("尝试命令行安装方法") - process = await asyncio.create_subprocess_exec( - sys.executable, "-c", - "import pyppeteer; import asyncio; asyncio.run(pyppeteer.launch())", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - if process.returncode == 0: - logger.info("成功通过命令行安装 Chromium") - return True - else: - logger.error(f"命令行安装失败: {stderr.decode()}") - return False - - except Exception as e2: - logger.error(f"命令行安装 Chromium 时出错: {e2}") - return False - - async def _fetch_group_messages_unified(self, client, group_id: str, days: int) -> List[Dict]: - """统一的群聊消息获取方法""" - try: - if not client or not group_id: - logger.error(f"群 {group_id} 无效的客户端或群组ID") - return [] - - # 计算时间范围 - end_time = datetime.now() - start_time = end_time - timedelta(days=days) - - messages = [] - message_seq = 0 - query_rounds = 0 - max_rounds = self.max_query_rounds # 从配置读取最大查询轮数 - consecutive_failures = 0 - max_failures = 3 # 最大连续失败次数 - - logger.info(f"开始获取群 {group_id} 近 {days} 天的消息记录") - logger.info(f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M:%S')} 到 {end_time.strftime('%Y-%m-%d %H:%M:%S')}") - - while len(messages) < self.max_messages and query_rounds < max_rounds: - try: - payloads = { - "group_id": group_id, - "message_seq": message_seq, - "count": 200, - "reverseOrder": True, - } - - result = await client.api.call_action("get_group_msg_history", **payloads) - - if not result or "messages" not in result: - logger.warning(f"群 {group_id} API返回无效结果: {result}") - consecutive_failures += 1 - if consecutive_failures >= max_failures: - break - continue - - round_messages = result.get("messages", []) - - if not round_messages: - logger.info(f"群 {group_id} 没有更多消息,结束获取") - break - - # 重置失败计数 - consecutive_failures = 0 - - # 过滤时间范围内的消息 - valid_messages_in_round = 0 - oldest_msg_time = None - - for msg in round_messages: - try: - msg_time = datetime.fromtimestamp(msg.get("time", 0)) - oldest_msg_time = msg_time # 记录最老的消息时间 - - # 过滤掉机器人自己的消息 - sender_id = str(msg.get("sender", {}).get("user_id", "")) - if self.bot_qq_id and sender_id == self.bot_qq_id: - continue - - if msg_time >= start_time and msg_time <= end_time: - messages.append(msg) - valid_messages_in_round += 1 - except Exception as msg_error: - logger.warning(f"群 {group_id} 处理单条消息失败: {msg_error}") - continue - - # 如果最老的消息时间已经超出范围,停止获取 - if oldest_msg_time and oldest_msg_time < start_time: - logger.info(f"群 {group_id} 已获取到时间范围外的消息,停止获取。共获取 {len(messages)} 条消息") - break - - if valid_messages_in_round == 0: - logger.warning(f"群 {group_id} 本轮未获取到有效消息") - break - - message_seq = round_messages[0]["message_id"] - query_rounds += 1 - - # 添加延迟避免请求过快 - if query_rounds % 5 == 0: - await asyncio.sleep(0.5) - - except Exception as e: - logger.error(f"群 {group_id} 获取消息失败 (第{query_rounds+1}轮): {e}") - consecutive_failures += 1 - if consecutive_failures >= max_failures: - logger.error(f"群 {group_id} 连续失败 {max_failures} 次,停止获取") - break - await asyncio.sleep(1) # 失败后等待1秒再重试 - - logger.info(f"群 {group_id} 消息获取完成,共获取 {len(messages)} 条消息,查询轮数: {query_rounds}") - return messages - - except Exception as e: - logger.error(f"群 {group_id} 获取群聊消息记录失败: {e}", exc_info=True) - return [] - - async def _fetch_group_messages(self, event: AiocqhttpMessageEvent, days: int) -> List[Dict]: - """获取群聊消息记录(手动分析)""" - return await self._fetch_group_messages_unified(event.bot, event.get_group_id(), days) - - async def _analyze_messages(self, messages: List[Dict], group_id: str) -> Dict: - """分析消息内容""" - # 基础统计 - stats = self._calculate_statistics(messages) - - # 用户活跃度分析 - user_analysis = self._analyze_users(messages) - - # 话题分析(根据配置决定是否启用) - topics = [] - topics_token_usage = TokenUsage() - if self.topic_analysis_enabled: - topics, topics_token_usage = await self._analyze_topics(messages) - - # 用户称号分析(根据配置决定是否启用) - user_titles = [] - titles_token_usage = TokenUsage() - if self.user_title_analysis_enabled: - user_titles, titles_token_usage = await self._analyze_user_titles(messages, user_analysis) - - # 群聊金句分析 - golden_quotes, quotes_token_usage = await self._analyze_golden_quotes(messages) - stats.golden_quotes = golden_quotes - - # 汇总token使用情况 - stats.token_usage.prompt_tokens = (topics_token_usage.prompt_tokens + - titles_token_usage.prompt_tokens + - quotes_token_usage.prompt_tokens) - stats.token_usage.completion_tokens = (topics_token_usage.completion_tokens + - titles_token_usage.completion_tokens + - quotes_token_usage.completion_tokens) - stats.token_usage.total_tokens = (topics_token_usage.total_tokens + - titles_token_usage.total_tokens + - quotes_token_usage.total_tokens) - - return { - "group_id": group_id, - "analysis_time": datetime.now().isoformat(), - "statistics": stats, - "user_analysis": user_analysis, - "topics": topics, - "user_titles": user_titles, - "message_count": len(messages) - } - - def _calculate_statistics(self, messages: List[Dict]) -> GroupStatistics: - """计算基础统计数据""" - total_chars = 0 - participants = set() - hour_counts = defaultdict(int) - emoji_count = 0 - - for msg in messages: - sender_id = str(msg.get("sender", {}).get("user_id", "")) - participants.add(sender_id) - - # 统计时间分布 - msg_time = datetime.fromtimestamp(msg.get("time", 0)) - hour_counts[msg_time.hour] += 1 - - # 处理消息内容 - for content in msg.get("message", []): - if content.get("type") == "text": - text = content.get("data", {}).get("text", "") - total_chars += len(text) - elif content.get("type") == "face": - emoji_count += 1 - - # 找出最活跃时段 - most_active_hour = max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0 - most_active_period = f"{most_active_hour:02d}:00-{(most_active_hour+1)%24:02d}:00" - - return GroupStatistics( - message_count=len(messages), - total_characters=total_chars, - participant_count=len(participants), - most_active_period=most_active_period, - golden_quotes=[], # 将在后续LLM分析中填充 - emoji_count=emoji_count, - token_usage=TokenUsage() # 初始化为空,将在LLM分析后填充 - ) - - def _analyze_users(self, messages: List[Dict]) -> Dict[str, Dict]: - """分析用户活跃度""" - user_stats = defaultdict(lambda: { - "message_count": 0, - "char_count": 0, - "emoji_count": 0, - "nickname": "", - "hours": defaultdict(int), - "reply_count": 0 - }) - - for msg in messages: - sender = msg.get("sender", {}) - user_id = str(sender.get("user_id", "")) - nickname = sender.get("nickname", "") or sender.get("card", "") - - user_stats[user_id]["message_count"] += 1 - user_stats[user_id]["nickname"] = nickname - - # 统计时间分布 - msg_time = datetime.fromtimestamp(msg.get("time", 0)) - user_stats[user_id]["hours"][msg_time.hour] += 1 - - # 处理消息内容 - for content in msg.get("message", []): - if content.get("type") == "text": - text = content.get("data", {}).get("text", "") - user_stats[user_id]["char_count"] += len(text) - elif content.get("type") == "face": - user_stats[user_id]["emoji_count"] += 1 - elif content.get("type") == "reply": - user_stats[user_id]["reply_count"] += 1 - - return dict(user_stats) - - def _render_html_template(self, template: str, data: Dict) -> str: - """简单的 HTML 模板渲染""" - result = template - - # 调试:记录渲染数据 - logger.info(f"渲染数据键: {list(data.keys())}") - - for key, value in data.items(): - placeholder = f"{{{key}}}" # 修正:使用单大括号 - # 调试:记录替换过程 - if placeholder in result: - logger.debug(f"替换 {placeholder} -> {str(value)[:100]}...") - result = result.replace(placeholder, str(value)) - - # 检查是否还有未替换的占位符 - import re - remaining_placeholders = re.findall(r'\{[^}]+\}', result) - if remaining_placeholders: - logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}") - - return result - - async def _get_user_avatar(self, user_id: str) -> Optional[str]: - """获取用户头像的base64编码""" - try: - avatar_url = f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=640" - async with aiohttp.ClientSession() as client: - response = await client.get(avatar_url) - response.raise_for_status() - avatar_data = await response.read() - # 转换为base64编码 - avatar_base64 = base64.b64encode(avatar_data).decode('utf-8') - return f"data:image/jpeg;base64,{avatar_base64}" - except Exception as e: - logger.error(f"获取用户头像失败 {user_id}: {e}") - return None - - async def _analyze_topics(self, messages: List[Dict]) -> tuple[List[SummaryTopic], TokenUsage]: - """使用LLM分析话题""" - try: - # 提取文本消息 - text_messages = [] - for msg in messages: - sender = msg.get("sender", {}) - nickname = sender.get("nickname", "") or sender.get("card", "") - msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") - - for content in msg.get("message", []): - if content.get("type") == "text": - text = content.get("data", {}).get("text", "").strip() - if text and len(text) > 2 and not text.startswith(("/")): # 过滤太短的消息和对机器人的命令 - text_messages.append({ - "sender": nickname, - "time": msg_time, - "content": text - }) - - if not text_messages: - return [], TokenUsage() - - # # 限制消息数量以避免token过多 - # if len(text_messages) > 100: - # # 均匀采样 - # step = len(text_messages) // 100 - # text_messages = text_messages[::step] - - # 构建LLM提示词,清理消息内容 - def clean_message_content(content): - """清理消息内容,移除可能影响JSON解析的字符""" - import re - # 替换中文引号 - content = content.replace('"', '"').replace('"', '"') - content = content.replace(''', "'").replace(''', "'") - # 移除或替换其他特殊字符 - content = content.replace('\n', ' ').replace('\r', ' ') - content = content.replace('\t', ' ') - # 移除可能的控制字符 - content = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', content) - return content.strip() - - messages_text = "\n".join([ - f"[{msg['time']}] {msg['sender']}: {clean_message_content(msg['content'])}" - for msg in text_messages - ]) - - prompt = f""" -你是一个帮我进行群聊信息总结的助手,生成总结内容时,你需要严格遵守下面的几个准则: -请分析接下来提供的群聊记录,提取出最多{self.max_topics}个主要话题。 - -对于每个话题,请提供: -1. 话题名称(突出主题内容,尽量简明扼要) -2. 主要参与者(最多5人) -3. 话题详细描述(包含关键信息和结论) - -注意: -- 对于比较有价值的点,稍微用一两句话详细讲讲,比如不要生成 “Nolan 和 SOV 讨论了 galgame 中关于性符号的衍生情况” 这种宽泛的内容,而是生成更加具体的讨论内容,让其他人只看这个消息就能知道讨论中有价值的,有营养的信息。 -- 对于其中的部分信息,你需要特意提到主题施加的主体是谁,是哪个群友做了什么事情,而不要直接生成和群友没有关系的语句。 -- 对于每一条总结,尽量讲清楚前因后果,以及话题的结论,是什么,为什么,怎么做,如果用户没有讲到细节,则可以不用这么做。 - -群聊记录: -{messages_text} - -重要:必须返回标准JSON格式,严格遵守以下规则: -1. 只使用英文双引号 " 不要使用中文引号 " " -2. 字符串内容中的引号必须转义为 \" -3. 多个对象之间用逗号分隔 -4. 数组元素之间用逗号分隔 -5. 不要在JSON外添加任何文字说明 -6. 描述内容避免使用特殊符号,用普通文字表达 - -请严格按照以下JSON格式返回,确保可以被标准JSON解析器解析: -[ - {{ - "topic": "话题名称", - "contributors": ["用户1", "用户2"], - "detail": "话题描述内容" - }}, - {{ - "topic": "另一个话题", - "contributors": ["用户3", "用户4"], - "detail": "另一个话题的描述" - }} -] - -注意:返回的内容必须是纯JSON,不要包含markdown代码块标记或其他格式 -""" - - # 调用LLM - provider = self.context.get_using_provider() - if not provider: - logger.warning("未配置LLM提供商,跳过话题分析") - return [], TokenUsage() - - response = await provider.text_chat( - prompt=prompt, - max_tokens=10000, # 增加token限制以避免响应被截断 - temperature=0.6 - ) - - # 提取token使用统计 - token_usage = TokenUsage() - if response.raw_completion and hasattr(response.raw_completion, 'usage'): - usage = response.raw_completion.usage - token_usage.prompt_tokens = usage.prompt_tokens - token_usage.completion_tokens = usage.completion_tokens - token_usage.total_tokens = usage.total_tokens - - # 解析响应 - if hasattr(response, 'completion_text'): - result_text = response.completion_text - else: - result_text = str(response) - - # 尝试解析JSON - try: - import re - # 提取JSON部分 - json_match = re.search(r'\[.*?\]', result_text, re.DOTALL) - if json_match: - json_text = json_match.group() - logger.debug(f"话题分析JSON原文: {json_text[:500]}...") - - # 强化JSON清理和修复 - def fix_json(text): - # 移除markdown代码块标记 - text = re.sub(r'```json\s*', '', text) - text = re.sub(r'```\s*$', '', text) - - # 基础清理 - text = text.replace('\n', ' ').replace('\r', ' ') - text = re.sub(r'\s+', ' ', text) - - # 替换中文引号为英文引号 - text = text.replace('"', '"').replace('"', '"') - text = text.replace(''', "'").replace(''', "'") - - # 处理字符串内容中的特殊字符 - # 转义字符串内的双引号 - def escape_quotes_in_strings(match): - content = match.group(1) - # 转义内部的双引号 - content = content.replace('"', '\\"') - return f'"{content}"' - - # 先处理字段值中的引号 - text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text) - - # 修复截断的JSON - if not text.endswith(']'): - last_complete = text.rfind('}') - if last_complete > 0: - text = text[:last_complete + 1] + ']' - - # 修复常见的JSON格式问题 - # 1. 修复缺失的逗号 - text = re.sub(r'}\s*{', '}, {', text) - - # 2. 确保字段名有引号 - text = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', text) - - # 3. 移除多余的逗号 - text = re.sub(r',\s*}', '}', text) - text = re.sub(r',\s*]', ']', text) - - return text - - json_text = fix_json(json_text) - logger.debug(f"修复后的JSON: {json_text[:300]}...") - - topics_data = json.loads(json_text) - topics = [SummaryTopic(**topic) for topic in topics_data[:self.max_topics]] - logger.info(f"话题分析成功,解析到 {len(topics)} 个话题") - return topics, token_usage - else: - logger.warning(f"话题分析响应中未找到JSON格式,响应内容: {result_text[:200]}...") - except json.JSONDecodeError as e: - logger.error(f"话题分析JSON解析失败: {e}") - logger.debug(f"修复后的JSON: {json_text if 'json_text' in locals() else 'N/A'}") - logger.debug(f"原始响应: {result_text}") - - # 如果JSON解析失败,尝试用正则表达式提取话题信息 - try: - logger.info("JSON解析失败,尝试正则表达式提取话题...") - topics = [] - - # 更强的正则表达式提取话题信息,处理转义字符 - # 匹配每个完整的话题对象 - topic_pattern = r'\{\s*"topic":\s*"([^"]+)"\s*,\s*"contributors":\s*\[([^\]]+)\]\s*,\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}' - matches = re.findall(topic_pattern, result_text, re.DOTALL) - - if not matches: - # 尝试更宽松的匹配 - topic_pattern = r'"topic":\s*"([^"]+)"[^}]*"contributors":\s*\[([^\]]+)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"' - matches = re.findall(topic_pattern, result_text, re.DOTALL) - - for match in matches[:self.max_topics]: - topic_name = match[0].strip() - contributors_str = match[1].strip() - detail = match[2].strip() - - # 清理detail中的转义字符 - detail = detail.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ') - - # 解析参与者列表 - contributors = [] - for contrib in re.findall(r'"([^"]+)"', contributors_str): - contributors.append(contrib.strip()) - - if not contributors: - contributors = ["群友"] - - topics.append(SummaryTopic( - topic=topic_name, - contributors=contributors[:5], # 最多5个参与者 - detail=detail # 限制长度 - )) - - if topics: - logger.info(f"正则表达式提取成功,获得 {len(topics)} 个话题") - return topics, token_usage - else: - # 最后的降级方案 - logger.info("正则表达式提取失败,使用默认话题...") - return [SummaryTopic( - topic="群聊讨论", - contributors=["群友"], - detail="今日群聊内容丰富,涵盖多个话题" - )] - except Exception as regex_e: - logger.error(f"正则表达式提取失败: {regex_e}") - # 最终降级方案 - return [SummaryTopic( - topic="群聊讨论", - contributors=["群友"], - detail="今日群聊内容丰富,涵盖多个话题" - )] - - except Exception as e: - logger.error(f"话题分析处理失败: {e}") - - return [], token_usage - - except Exception as e: - logger.error(f"话题分析失败: {e}") - return [], TokenUsage() - - async def _analyze_user_titles(self, messages: List[Dict], user_analysis: Dict) -> tuple[List[UserTitle], TokenUsage]: - """使用LLM分析用户称号""" - try: - # 准备用户数据 - user_summaries = [] - for user_id, stats in user_analysis.items(): - if stats["message_count"] < 5: # 过滤活跃度太低的用户 - continue - - # 分析用户特征 - night_messages = sum(stats["hours"][h] for h in range(0, 6)) - day_messages = stats["message_count"] - night_messages - avg_chars = stats["char_count"] / stats["message_count"] if stats["message_count"] > 0 else 0 - - user_summaries.append({ - "name": stats["nickname"], - "qq": int(user_id), - "message_count": stats["message_count"], - "avg_chars": round(avg_chars, 1), - "emoji_ratio": round(stats["emoji_count"] / stats["message_count"], 2), - "night_ratio": round(night_messages / stats["message_count"], 2), - "reply_ratio": round(stats["reply_count"] / stats["message_count"], 2) - }) - - if not user_summaries: - return [], TokenUsage() - - # 按消息数量排序,取前N名(根据配置) - user_summaries.sort(key=lambda x: x["message_count"], reverse=True) - user_summaries = user_summaries[:self.max_user_titles] - - # 构建LLM提示词 - users_text = "\n".join([ - f"- {user['name']} (QQ:{user['qq']}): " - f"发言{user['message_count']}条, 平均{user['avg_chars']}字, " - f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, " - f"回复比例{user['reply_ratio']}" - for user in user_summaries - ]) - - prompt = f""" -请为以下群友分配合适的称号和MBTI类型。每个人只能有一个称号,每个称号只能给一个人。 - -可选称号: -- 龙王: 发言频繁但内容轻松的人 -- 技术专家: 经常讨论技术话题的人 -- 夜猫子: 经常在深夜发言的人 -- 表情包军火库: 经常发表情的人 -- 沉默终结者: 经常开启话题的人 -- 评论家: 平均发言长度很长的人 -- 阳角: 在群里很有影响力的人 -- 互动达人: 经常回复别人的人 -- ... (你可以自行进行拓展添加) - -用户数据: -{users_text} - -请以JSON格式返回,格式如下: -[ - {{ - "name": "用户名", - "qq": 123456789, - "title": "称号", - "mbti": "MBTI类型", - "reason": "获得此称号的原因" - }} -] -""" - - # 调用LLM - provider = self.context.get_using_provider() - if not provider: - logger.warning("未配置LLM提供商,跳过用户称号分析") - return [], TokenUsage() - - response = await provider.text_chat( - prompt=prompt, - max_tokens=1500, - temperature=0.5 - ) - - # 提取token使用统计 - token_usage = TokenUsage() - if response.raw_completion and hasattr(response.raw_completion, 'usage'): - usage = response.raw_completion.usage - token_usage.prompt_tokens = usage.prompt_tokens - token_usage.completion_tokens = usage.completion_tokens - token_usage.total_tokens = usage.total_tokens - - # 解析响应 - if hasattr(response, 'completion_text'): - result_text = response.completion_text - else: - result_text = str(response) - - # 尝试解析JSON - try: - import re - json_match = re.search(r'\[.*\]', result_text, re.DOTALL) - if json_match: - titles_data = json.loads(json_match.group()) - return [UserTitle(**title) for title in titles_data], token_usage - except: - pass - - return [], token_usage - - except Exception as e: - logger.error(f"用户称号分析失败: {e}") - return [], TokenUsage() - - async def _analyze_golden_quotes(self, messages: List[Dict]) -> tuple[List[GoldenQuote], TokenUsage]: - """使用LLM分析群聊金句""" - try: - # 提取有趣的文本消息 - interesting_messages = [] - for msg in messages: - sender = msg.get("sender", {}) - nickname = sender.get("nickname", "") or sender.get("card", "") - msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") - - for content in msg.get("message", []): - if content.get("type") == "text": - text = content.get("data", {}).get("text", "").strip() - # 过滤长度适中、可能有趣的消息 - if 5 <= len(text) <= 100 and not text.startswith(("http", "www", "/")): - interesting_messages.append({ - "sender": nickname, - "time": msg_time, - "content": text - }) - - if not interesting_messages: - return [], TokenUsage() - - # # 限制消息数量以避免token过多 - # if len(interesting_messages) > 50: - # # 均匀采样 - # step = len(interesting_messages) // 50 - # interesting_messages = interesting_messages[::step] - - # 构建LLM提示词 - messages_text = "\n".join([ - f"[{msg['time']}] {msg['sender']}: {msg['content']}" - for msg in interesting_messages - ]) - - # 计算金句数量,默认5句,但可以根据配置调整 - self.max_golden_quotes - - prompt = f""" -请从以下群聊记录中挑选出{self.max_golden_quotes}句最具冲击力、最令人惊叹的"金句"。这些金句需满足: -- 核心标准:**逆天的神人发言**,即具备颠覆常识的脑洞、逻辑跳脱的表达或强烈反差感的原创内容 -- 典型特征:包含某些争议话题元素、夸张类比、反常规结论、一本正经的"胡说八道"或突破语境的清奇思路,并且具备一定的冲击力,让人印象深刻。 - -对于每个金句,请提供: -1. 原文内容(完整保留发言细节) -2. 发言人昵称 -3. 选择理由(具体说明其"逆天"之处,如逻辑颠覆点/脑洞角度/反差感/争议话题元素) - -此外,我将对你进行严格约束: -- 优先筛选 **逆天指数最高** 的内容:发情、性压抑话题 > 争议话题 > 元素级 > 颠覆认知级 > 逻辑跳脱级 > 趣味调侃级,剔除单纯玩梗或网络热词堆砌的普通发言 -- 重点标记包含极端类比、反常识论证或无厘头结论的内容,并且包含一定的争议话题元素。 - -群聊记录: -{messages_text} - -请以JSON格式返回,格式如下: -[ - {{ - "content": "金句原文", - "sender": "发言人昵称", - "reason": "选择这句话的理由(需明确说明逆天特质)" - }} -] -""" - - # 调用LLM - provider = self.context.get_using_provider() - if not provider: - logger.warning("未配置LLM提供商,跳过金句分析") - return [], TokenUsage() - - response = await provider.text_chat( - prompt=prompt, - max_tokens=1500, - temperature=0.7 - ) - - # 提取token使用统计 - token_usage = TokenUsage() - if response.raw_completion and hasattr(response.raw_completion, 'usage'): - usage = response.raw_completion.usage - token_usage.prompt_tokens = usage.prompt_tokens - token_usage.completion_tokens = usage.completion_tokens - token_usage.total_tokens = usage.total_tokens - - # 解析响应 - if hasattr(response, 'completion_text'): - result_text = response.completion_text - else: - result_text = str(response) - - # 尝试解析JSON - try: - import re - json_match = re.search(r'\[.*\]', result_text, re.DOTALL) - if json_match: - quotes_data = json.loads(json_match.group()) - return [GoldenQuote(**quote) for quote in quotes_data[:self.max_golden_quotes]], token_usage - except: - pass - - return [], token_usage - - except Exception as e: - logger.error(f"金句分析失败: {e}") - return [], TokenUsage() - - async def _html_to_pdf(self, html_content: str, output_path: str) -> bool: - """将 HTML 内容转换为 PDF 文件""" - try: - # 确保 pyppeteer 可用 - if not PYPPETEER_AVAILABLE: - logger.error("pyppeteer 不可用,无法生成 PDF") - return False - - # 动态导入 pyppeteer - import pyppeteer - from pyppeteer import launch - - # 尝试启动浏览器,如果 Chromium 不存在会自动下载 - logger.info("启动浏览器进行 PDF 转换") - - # 配置浏览器启动参数,避免 Chromium 下载问题 - launch_options = { - 'headless': True, - 'args': [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - '--no-first-run', - '--disable-extensions', - '--disable-default-apps' - ] - } - - # 如果是 Windows 系统,尝试使用系统 Chrome - if sys.platform.startswith('win'): - # 常见的 Chrome 安装路径 - chrome_paths = [ - r"C:\Program Files\Google\Chrome\Application\chrome.exe", - r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", - r"C:\Users\{}\AppData\Local\Google\Chrome\Application\chrome.exe".format(os.environ.get('USERNAME', '')), - ] - - for chrome_path in chrome_paths: - if Path(chrome_path).exists(): - launch_options['executablePath'] = chrome_path - logger.info(f"使用系统 Chrome: {chrome_path}") - break - - browser = await launch(**launch_options) - page = await browser.newPage() - - # 设置页面内容 (pyppeteer 1.0.2 版本的 API) - await page.setContent(html_content) - # 等待页面加载完成 - try: - await page.waitForSelector('body', {'timeout': 10000}) - except Exception: - # 如果等待失败,继续执行(可能页面已经加载完成) - pass - - # 导出 PDF - await page.pdf({ - 'path': output_path, - 'format': 'A4', - 'printBackground': True, - 'margin': { - 'top': '10mm', - 'right': '10mm', - 'bottom': '10mm', - 'left': '10mm' - }, - 'scale': 0.8 - }) - - await browser.close() - logger.info(f"PDF 生成成功: {output_path}") - return True - - except Exception as e: - error_msg = str(e) - if "Chromium downloadable not found" in error_msg: - logger.error("Chromium 下载失败,建议安装 pyppeteer2 或使用系统 Chrome") - elif "No usable sandbox" in error_msg: - logger.error("沙盒权限问题,已尝试禁用沙盒") - else: - logger.error(f"HTML 转 PDF 失败: {e}") - return False - - async def _generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]: - """生成 PDF 格式的分析报告""" - try: - #确保输出目录存在 - output_dir = Path(self.pdf_output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - # 生成文件名 - current_date = datetime.now().strftime('%Y%m%d') - filename = self.pdf_filename_format.format( - group_id=group_id, - date=current_date - ) - pdf_path = output_dir / filename - - # 准备渲染数据 - render_data = await self._prepare_render_data(analysis_result) - logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段") - - # 生成 HTML 内容 - html_content = self._render_html_template(self._get_pdf_html_template(), render_data) - logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符") - - # 转换为 PDF - success = await self._html_to_pdf(html_content, str(pdf_path)) - - if success: - return str(pdf_path.absolute()) - else: - return None - - except Exception as e: - logger.error(f"生成 PDF 报告失败: {e}") - return None - - async def _generate_image_report(self, analysis_result: Dict, group_id: str) -> Optional[str]: - """生成图片格式的分析报告""" - try: - # 准备渲染数据 - render_payload = await self._prepare_render_data(analysis_result) - - # 使用AstrBot内置的HTML渲染服务 - image_url = await self.html_render(self._get_html_template(), render_payload) - return image_url - - except Exception as e: - logger.error(f"生成图片报告失败: {e}") - return None - - - - async def _prepare_render_data(self, analysis_result: Dict) -> Dict: - """准备渲染数据""" - stats = analysis_result["statistics"] - topics = analysis_result["topics"] - user_titles = analysis_result["user_titles"] - - # 构建话题HTML - topics_html = "" - for i, topic in enumerate(topics[:self.max_topics], 1): - contributors_str = "、".join(topic.contributors) - topics_html += f""" -
-
- {i} - {topic.topic} -
-
参与者: {contributors_str}
-
{topic.detail}
-
- """ - - # 构建用户称号HTML(包含头像) - titles_html = "" - for title in user_titles[:self.max_user_titles]: - # 获取用户头像 - avatar_data = await self._get_user_avatar(str(title.qq)) - avatar_html = f'头像' if avatar_data else '
👤
' - - titles_html += f""" -
-
- {avatar_html} -
-
{title.name}
-
-
{title.title}
-
{title.mbti}
-
-
-
-
{title.reason}
-
- """ - - # 构建金句HTML - quotes_html = "" - for quote in stats.golden_quotes[:self.max_golden_quotes]: - quotes_html += f""" -
-
"{quote.content}"
-
—— {quote.sender}
-
{quote.reason}
-
- """ - - # 返回扁平化的渲染数据 - return { - "current_date": datetime.now().strftime('%Y年%m月%d日'), - "current_datetime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - "message_count": stats.message_count, - "participant_count": stats.participant_count, - "total_characters": stats.total_characters, - "emoji_count": stats.emoji_count, - "most_active_period": stats.most_active_period, - "topics_html": topics_html, - "titles_html": titles_html, - "quotes_html": quotes_html, - "total_tokens": stats.token_usage.total_tokens, - "prompt_tokens": stats.token_usage.prompt_tokens, - "completion_tokens": stats.token_usage.completion_tokens - } - - def _get_html_template(self) -> str: - """获取HTML模板""" - return """ - - - - - - 群聊日常分析报告 - - - - -
-
-

📊 群聊日常分析报告

-
{{ current_date }}
-
- -
- -
-

📈 基础统计

-
-
-
{{ message_count }}
-
消息总数
-
-
-
{{ participant_count }}
-
参与人数
-
-
-
{{ total_characters }}
-
总字符数
-
-
-
{{ emoji_count }}
-
表情数量
-
-
- -
-
{{ most_active_period }}
-
最活跃时段
-
-
- - -
-

💬 热门话题

-
- {{ topics_html | safe }} -
-
- - -
-

🏆 群友称号

-
- {{ titles_html | safe }} -
-
- - -
-

💬 群圣经

- {{ quotes_html | safe }} -
-
- - -
- - - """ - - - - - async def _generate_text_report(self, analysis_result: Dict) -> str: - """生成文本格式的分析报告""" - stats = analysis_result["statistics"] - topics = analysis_result["topics"] - user_titles = analysis_result["user_titles"] - - report = f""" -🎯 群聊日常分析报告 -📅 {datetime.now().strftime('%Y年%m月%d日')} - -📊 基础统计 -• 消息总数: {stats.message_count} -• 参与人数: {stats.participant_count} -• 总字符数: {stats.total_characters} -• 表情数量: {stats.emoji_count} -• 最活跃时段: {stats.most_active_period} - -💬 热门话题 -""" - - for i, topic in enumerate(topics[:self.max_topics], 1): - contributors_str = "、".join(topic.contributors) - report += f"{i}. {topic.topic}\n" - report += f" 参与者: {contributors_str}\n" - report += f" {topic.detail}\n\n" - - report += "🏆 群友称号\n" - for title in user_titles[:self.max_user_titles]: - report += f"• {title.name} - {title.title} ({title.mbti})\n" - report += f" {title.reason}\n\n" - - report += "💬 群圣经\n" - for i, quote in enumerate(stats.golden_quotes[:self.max_golden_quotes], 1): - report += f"{i}. \"{quote.content}\" —— {quote.sender}\n" - report += f" {quote.reason}\n\n" - - return report - - - - async def _reload_config_and_restart_scheduler(self): - """重新加载配置并重启调度器""" - try: - # 重新从配置系统读取配置 - self.enabled_groups = self.config.get("enabled_groups", []) - self.enable_auto_analysis = self.config.get("enable_auto_analysis", False) - self.auto_analysis_time = self.config.get("auto_analysis_time", "09:00") - logger.info(f"重新加载配置: 自动分析={self.enable_auto_analysis}") - - # 停止现有的调度器 - if hasattr(self, 'scheduler_task') and self.scheduler_task and not self.scheduler_task.done(): - self.scheduler_task.cancel() - logger.info("已停止现有的定时任务") - - # 如果启用了自动分析,启动新的调度器 - if self.enable_auto_analysis: - self.scheduler_task = asyncio.create_task(self._start_scheduler()) - logger.info("已启动新的定时任务") - - except Exception as e: - logger.error(f"重新加载配置失败: {e}") - - async def _start_scheduler(self): - """启动定时任务调度器""" - logger.info(f"启动定时任务调度器,自动分析时间: {self.auto_analysis_time}") - - while True: - try: - now = datetime.now() - target_time = datetime.strptime(self.auto_analysis_time, "%H:%M").replace( - year=now.year, month=now.month, day=now.day - ) - - # 如果今天的目标时间已过,设置为明天 - if now >= target_time: - target_time += timedelta(days=1) - - # 计算等待时间 - wait_seconds = (target_time - now).total_seconds() - logger.info(f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f} 秒") - - # 等待到目标时间 - await asyncio.sleep(wait_seconds) - - # 执行自动分析 - if self.enable_auto_analysis: - logger.info("开始执行定时分析") - await self._run_auto_analysis() - else: - logger.info("自动分析已禁用,跳过执行") - break - - except Exception as e: - logger.error(f"定时任务调度器错误: {e}") - # 等待5分钟后重试 - await asyncio.sleep(300) - - async def _run_auto_analysis(self): - """执行自动分析""" - try: - logger.info("开始执行自动群聊分析") - - # 为每个启用的群执行分析 - for group_id in self.enabled_groups: - try: - logger.info(f"为群 {group_id} 执行自动分析") - - # 这里需要模拟一个事件对象来调用分析功能 - # 由于自动分析没有真实的用户事件,我们直接调用内部方法 - await self._perform_auto_analysis_for_group(group_id) - - except Exception as e: - logger.error(f"群 {group_id} 自动分析失败: {e}") - - except Exception as e: - logger.error(f"自动分析执行失败: {e}") - - async def _perform_auto_analysis_for_group(self, group_id: str): - """为指定群执行自动分析""" - try: - # 尝试获取bot实例 - if not self.bot_instance: - self.bot_instance = await self._get_bot_instance() - - if not self.bot_instance: - logger.warning(f"群 {group_id} 自动分析跳过:未获取到bot实例,请先手动触发一次分析") - return - - # 确保有机器人QQ号 - if not self.bot_qq_id: - await self._get_bot_qq_id() - - logger.info(f"开始为群 {group_id} 执行自动分析") - - # 获取群聊消息 - messages = await self._fetch_group_messages_for_auto(group_id) - if not messages: - logger.warning(f"群 {group_id} 未获取到足够的消息记录") - return - - # 检查消息数量 - if len(messages) < self.min_messages_threshold: - logger.warning(f"群 {group_id} 消息数量不足({len(messages)}条),跳过分析") - return - - logger.info(f"群 {group_id} 获取到 {len(messages)} 条消息,开始分析") - - # 进行分析 - analysis_result = await self._analyze_messages(messages, group_id) - if not analysis_result: - logger.error(f"群 {group_id} 分析失败") - return - - # 生成报告 - if self.output_format == "image": - image_url = await self._generate_image_report(analysis_result, group_id) - if image_url: - # 发送分析报告到群 - await self._send_auto_analysis_result(group_id, image_url) - logger.info(f"群 {group_id} 自动分析完成,已发送图片报告") - else: - logger.error(f"群 {group_id} 图片报告生成失败") - # 图片生成失败时回退到文本报告 - text_report = await self._generate_text_report(analysis_result) - await self._send_auto_analysis_text(group_id, text_report) - logger.info(f"群 {group_id} 图片报告生成失败,已发送文本报告") - elif self.output_format == "pdf": - if not PYPPETEER_AVAILABLE: - logger.warning(f"群 {group_id} PDF功能不可用,回退到文本报告") - text_report = await self._generate_text_report(analysis_result) - await self._send_auto_analysis_text(group_id, text_report) - logger.info(f"群 {group_id} PDF功能不可用,已发送文本报告") - else: - pdf_path = await self._generate_pdf_report(analysis_result, group_id) - if pdf_path: - # 发送PDF文件到群 - await self._send_auto_analysis_pdf(group_id, pdf_path) - logger.info(f"群 {group_id} 自动分析完成,已发送PDF报告") - else: - logger.error(f"群 {group_id} PDF报告生成失败,回退到文本报告") - text_report = await self._generate_text_report(analysis_result) - await self._send_auto_analysis_text(group_id, text_report) - logger.info(f"群 {group_id} PDF报告生成失败,已发送文本报告") - else: - text_report = await self._generate_text_report(analysis_result) - await self._send_auto_analysis_text(group_id, text_report) - logger.info(f"群 {group_id} 自动分析完成,已发送文本报告") - - except Exception as e: - logger.error(f"群 {group_id} 自动分析执行失败: {e}", exc_info=True) - - async def _fetch_group_messages_for_auto(self, group_id: str) -> List[Dict]: - """为自动分析获取群聊消息(使用统一方法)""" - if not self.bot_instance: - logger.error(f"群 {group_id} 获取消息失败:缺少bot实例") - return [] - - return await self._fetch_group_messages_unified(self.bot_instance, group_id, self.analysis_days) - - async def _send_auto_analysis_result(self, group_id: str, image_url: str): - """发送自动分析的图片结果到群""" - try: - if not self.bot_instance: - return - - # 发送图片消息到群 - await self.bot_instance.api.call_action( - "send_group_msg", - group_id=group_id, - message=[{ - "type": "text", - "data": {"text": "📊 每日群聊分析报告已生成:"} - }, { - "type": "image", - "data": {"url": image_url} - }] - ) - - except Exception as e: - logger.error(f"发送自动分析结果到群 {group_id} 失败: {e}") - - async def _send_auto_analysis_text(self, group_id: str, text_report: str): - """发送自动分析的文本结果到群""" - try: - if not self.bot_instance: - return - - # 发送文本消息到群 - await self.bot_instance.api.call_action( - "send_group_msg", - group_id=group_id, - message=f"📊 每日群聊分析报告:\n\n{text_report}" - ) - - except Exception as e: - logger.error(f"发送自动分析文本到群 {group_id} 失败: {e}") - - async def _send_auto_analysis_pdf(self, group_id: str, pdf_path: str): - """发送自动分析的PDF结果到群""" - try: - if not self.bot_instance: - return - - # 发送PDF文件到群 - await self.bot_instance.api.call_action( - "send_group_msg", - group_id=group_id, - message=[{ - "type": "text", - "data": {"text": "📊 每日群聊分析报告已生成:"} - }, { - "type": "file", - "data": {"file": pdf_path} - }] - ) - - except Exception as e: - logger.error(f"发送自动分析PDF到群 {group_id} 失败: {e}") - # 如果发送PDF失败,尝试发送提示信息 - try: - await self.bot_instance.api.call_action( - "send_group_msg", - group_id=group_id, - message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}" - ) - except Exception as e2: - logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}") - - async def _get_bot_instance(self): - """从Context获取bot实例""" - try: - # 如果已经有保存的实例,直接返回 - if self.bot_instance: - return self.bot_instance - - logger.info("尝试获取bot实例...") - - # 简化的获取逻辑,尝试常见的几种方式 - if hasattr(self.context, 'get_platforms') and callable(self.context.get_platforms): - platforms = self.context.get_platforms() - for platform in platforms: - if hasattr(platform, 'bot') and platform.bot: - logger.info(f"从平台获取到bot实例") - return platform.bot - - # 尝试从context的platforms属性获取 - if hasattr(self.context, 'platforms') and self.context.platforms: - for platform in self.context.platforms: - if hasattr(platform, 'bot') and platform.bot: - logger.info(f"从平台列表获取到bot实例") - return platform.bot - - logger.info("暂时无法获取bot实例,等待用户手动触发分析") - return None - - except Exception as e: - logger.error(f"获取bot实例失败: {e}") - return None - - async def _delayed_start_scheduler(self): - """延迟启动调度器,给系统时间初始化""" - try: - # 等待10秒让系统完全初始化 - await asyncio.sleep(10) - - # 尝试获取bot实例 - self.bot_instance = await self._get_bot_instance() - - if self.bot_instance: - logger.info("成功获取bot实例,启动定时任务") - # 获取机器人QQ号 - await self._get_bot_qq_id() - else: - logger.info("暂时未获取到bot实例,定时任务仍会启动。首次手动触发分析后将自动获取bot实例") - - # 启动调度器 - self.scheduler_task = asyncio.create_task(self._start_scheduler()) - - except Exception as e: - logger.error(f"延迟启动调度器失败: {e}") - - def _get_pdf_html_template(self) -> str: - """获取 PDF 专用的 HTML 模板""" - return """ - - - - - - 群聊日常分析报告 - - - -
-
-

📊 群聊日常分析报告

-
{current_date}
-
- -
-

📈 基础统计

-
-
-
{message_count}
-
消息总数
-
-
-
{participant_count}
-
参与人数
-
-
-
{total_characters}
-
总字符数
-
-
-
{emoji_count}
-
表情数量
-
-
- -
-
{most_active_period}
-
最活跃时段
-
-
- -
-

💬 热门话题

- {topics_html} -
- -
-

🏆 群友称号

- {titles_html} -
- -
-

💬 群圣经

- {quotes_html} -
- - -
- - - """ diff --git a/metadata.yaml b/metadata.yaml index 81eac2e..923eadd 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -13,6 +13,6 @@ help: | # 插件的帮助信息 命令: /群分析 [天数] - 分析群聊活动 /分析设置 [操作] - 管理设置(enable/disable/status/test) -version: v1.6.0 # 插件版本号。格式:v1.1.1 或者 v1.1 +version: v1.7.0 # 插件版本号。格式:v1.1.1 或者 v1.1 author: SXP-Simon # 作者 repo: https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis # 插件的仓库地址 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..33c66e7 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,5 @@ +""" +QQ群日常分析插件 - 源代码包 +""" + +__author__ = "SXP-Simon" \ No newline at end of file diff --git a/src/analysis/__init__.py b/src/analysis/__init__.py new file mode 100644 index 0000000..d1d48ee --- /dev/null +++ b/src/analysis/__init__.py @@ -0,0 +1,12 @@ +""" +分析模块 +包含LLM分析和统计分析功能 +""" + +from .llm_analyzer import LLMAnalyzer +from .statistics import UserAnalyzer + +__all__ = [ + 'LLMAnalyzer', + 'UserAnalyzer' +] \ No newline at end of file diff --git a/src/analysis/llm_analyzer.py b/src/analysis/llm_analyzer.py new file mode 100644 index 0000000..45f92b4 --- /dev/null +++ b/src/analysis/llm_analyzer.py @@ -0,0 +1,470 @@ +""" +LLM分析器模块 +负责使用LLM进行话题分析、用户称号分析和金句分析 +""" + +import json +import re +from datetime import datetime +from typing import List, Dict, Tuple +from astrbot.api import logger +from ...src.models.data_models import SummaryTopic, UserTitle, GoldenQuote, TokenUsage + + +class LLMAnalyzer: + """LLM分析器""" + + def __init__(self, context, config_manager): + self.context = context + self.config_manager = config_manager + + async def analyze_topics(self, messages: List[Dict]) -> Tuple[List[SummaryTopic], TokenUsage]: + """使用LLM分析话题""" + try: + # 提取文本消息 + text_messages = [] + for msg in messages: + sender = msg.get("sender", {}) + nickname = sender.get("nickname", "") or sender.get("card", "") + msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") + + for content in msg.get("message", []): + if content.get("type") == "text": + text = content.get("data", {}).get("text", "").strip() + if text and len(text) > 2 and not text.startswith(("/")): + text_messages.append({ + "sender": nickname, + "time": msg_time, + "content": text + }) + + if not text_messages: + return [], TokenUsage() + + # 构建LLM提示词,清理消息内容 + def clean_message_content(content): + """清理消息内容,移除可能影响JSON解析的字符""" + # 替换中文引号 + content = content.replace('"', '"').replace('"', '"') + content = content.replace(''', "'").replace(''', "'") + # 移除或替换其他特殊字符 + content = content.replace('\n', ' ').replace('\r', ' ') + content = content.replace('\t', ' ') + # 移除可能的控制字符 + content = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', content) + return content.strip() + + messages_text = "\n".join([ + f"[{msg['time']}] {msg['sender']}: {clean_message_content(msg['content'])}" + for msg in text_messages + ]) + + max_topics = self.config_manager.get_max_topics() + prompt = f""" +你是一个帮我进行群聊信息总结的助手,生成总结内容时,你需要严格遵守下面的几个准则: +请分析接下来提供的群聊记录,提取出最多{max_topics}个主要话题。 + +对于每个话题,请提供: +1. 话题名称(突出主题内容,尽量简明扼要) +2. 主要参与者(最多5人) +3. 话题详细描述(包含关键信息和结论) + +注意: +- 对于比较有价值的点,稍微用一两句话详细讲讲,比如不要生成 "Nolan 和 SOV 讨论了 galgame 中关于性符号的衍生情况" 这种宽泛的内容,而是生成更加具体的讨论内容,让其他人只看这个消息就能知道讨论中有价值的,有营养的信息。 +- 对于其中的部分信息,你需要特意提到主题施加的主体是谁,是哪个群友做了什么事情,而不要直接生成和群友没有关系的语句。 +- 对于每一条总结,尽量讲清楚前因后果,以及话题的结论,是什么,为什么,怎么做,如果用户没有讲到细节,则可以不用这么做。 + +群聊记录: +{messages_text} + +重要:必须返回标准JSON格式,严格遵守以下规则: +1. 只使用英文双引号 " 不要使用中文引号 " " +2. 字符串内容中的引号必须转义为 \" +3. 多个对象之间用逗号分隔 +4. 数组元素之间用逗号分隔 +5. 不要在JSON外添加任何文字说明 +6. 描述内容避免使用特殊符号,用普通文字表达 + +请严格按照以下JSON格式返回,确保可以被标准JSON解析器解析: +[ + {{ + "topic": "话题名称", + "contributors": ["用户1", "用户2"], + "detail": "话题描述内容" + }}, + {{ + "topic": "另一个话题", + "contributors": ["用户3", "用户4"], + "detail": "另一个话题的描述" + }} +] + +注意:返回的内容必须是纯JSON,不要包含markdown代码块标记或其他格式 +""" + + # 调用LLM + provider = self.context.get_using_provider() + if not provider: + logger.warning("未配置LLM提供商,跳过话题分析") + return [], TokenUsage() + + response = await provider.text_chat( + prompt=prompt, + max_tokens=10000, + temperature=0.6 + ) + + # 提取token使用统计 + token_usage = TokenUsage() + if response.raw_completion and hasattr(response.raw_completion, 'usage'): + usage = response.raw_completion.usage + token_usage.prompt_tokens = usage.prompt_tokens + token_usage.completion_tokens = usage.completion_tokens + token_usage.total_tokens = usage.total_tokens + + # 解析响应 + if hasattr(response, 'completion_text'): + result_text = response.completion_text + else: + result_text = str(response) + + # 尝试解析JSON + try: + # 提取JSON部分 + json_match = re.search(r'\[.*?\]', result_text, re.DOTALL) + if json_match: + json_text = json_match.group() + logger.debug(f"话题分析JSON原文: {json_text[:500]}...") + + # 强化JSON清理和修复 + json_text = self._fix_json(json_text) + logger.debug(f"修复后的JSON: {json_text[:300]}...") + + topics_data = json.loads(json_text) + topics = [SummaryTopic(**topic) for topic in topics_data[:max_topics]] + logger.info(f"话题分析成功,解析到 {len(topics)} 个话题") + return topics, token_usage + else: + logger.warning(f"话题分析响应中未找到JSON格式,响应内容: {result_text[:200]}...") + except json.JSONDecodeError as e: + logger.error(f"话题分析JSON解析失败: {e}") + logger.debug(f"修复后的JSON: {json_text if 'json_text' in locals() else 'N/A'}") + logger.debug(f"原始响应: {result_text}") + + # 如果JSON解析失败,尝试用正则表达式提取话题信息 + topics = self._extract_topics_with_regex(result_text, max_topics) + if topics: + logger.info(f"正则表达式提取成功,获得 {len(topics)} 个话题") + return topics, token_usage + else: + # 最后的降级方案 + logger.info("正则表达式提取失败,使用默认话题...") + return [SummaryTopic( + topic="群聊讨论", + contributors=["群友"], + detail="今日群聊内容丰富,涵盖多个话题" + )], token_usage + + return [], token_usage + + except Exception as e: + logger.error(f"话题分析失败: {e}") + return [], TokenUsage() + + def _fix_json(self, text: str) -> str: + """修复JSON格式问题""" + # 移除markdown代码块标记 + text = re.sub(r'```json\s*', '', text) + text = re.sub(r'```\s*$', '', text) + + # 基础清理 + text = text.replace('\n', ' ').replace('\r', ' ') + text = re.sub(r'\s+', ' ', text) + + # 替换中文引号为英文引号 + text = text.replace('"', '"').replace('"', '"') + text = text.replace(''', "'").replace(''', "'") + + # 处理字符串内容中的特殊字符 + # 转义字符串内的双引号 + def escape_quotes_in_strings(match): + content = match.group(1) + # 转义内部的双引号 + content = content.replace('"', '\\"') + return f'"{content}"' + + # 先处理字段值中的引号 + text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text) + + # 修复截断的JSON + if not text.endswith(']'): + last_complete = text.rfind('}') + if last_complete > 0: + text = text[:last_complete + 1] + ']' + + # 修复常见的JSON格式问题 + # 1. 修复缺失的逗号 + text = re.sub(r'}\s*{', '}, {', text) + + # 2. 确保字段名有引号 + text = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', text) + + # 3. 移除多余的逗号 + text = re.sub(r',\s*}', '}', text) + text = re.sub(r',\s*]', ']', text) + + return text + + def _extract_topics_with_regex(self, result_text: str, max_topics: int) -> List[SummaryTopic]: + """使用正则表达式提取话题信息""" + try: + topics = [] + + # 更强的正则表达式提取话题信息,处理转义字符 + # 匹配每个完整的话题对象 + topic_pattern = r'\{\s*"topic":\s*"([^"]+)"\s*,\s*"contributors":\s*\[([^\]]+)\]\s*,\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}' + matches = re.findall(topic_pattern, result_text, re.DOTALL) + + if not matches: + # 尝试更宽松的匹配 + topic_pattern = r'"topic":\s*"([^"]+)"[^}]*"contributors":\s*\[([^\]]+)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"' + matches = re.findall(topic_pattern, result_text, re.DOTALL) + + for match in matches[:max_topics]: + topic_name = match[0].strip() + contributors_str = match[1].strip() + detail = match[2].strip() + + # 清理detail中的转义字符 + detail = detail.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ') + + # 解析参与者列表 + contributors = [] + for contrib in re.findall(r'"([^"]+)"', contributors_str): + contributors.append(contrib.strip()) + + if not contributors: + contributors = ["群友"] + + topics.append(SummaryTopic( + topic=topic_name, + contributors=contributors[:5], # 最多5个参与者 + detail=detail + )) + + return topics + except Exception as e: + logger.error(f"正则表达式提取失败: {e}") + return [] + + async def analyze_user_titles(self, messages: List[Dict], user_analysis: Dict) -> Tuple[List[UserTitle], TokenUsage]: + """使用LLM分析用户称号""" + try: + # 准备用户数据 + user_summaries = [] + for user_id, stats in user_analysis.items(): + if stats["message_count"] < 5: # 过滤活跃度太低的用户 + continue + + # 分析用户特征 + night_messages = sum(stats["hours"][h] for h in range(0, 6)) + day_messages = stats["message_count"] - night_messages + avg_chars = stats["char_count"] / stats["message_count"] if stats["message_count"] > 0 else 0 + + user_summaries.append({ + "name": stats["nickname"], + "qq": int(user_id), + "message_count": stats["message_count"], + "avg_chars": round(avg_chars, 1), + "emoji_ratio": round(stats["emoji_count"] / stats["message_count"], 2), + "night_ratio": round(night_messages / stats["message_count"], 2), + "reply_ratio": round(stats["reply_count"] / stats["message_count"], 2) + }) + + if not user_summaries: + return [], TokenUsage() + + # 按消息数量排序,取前N名 + max_user_titles = self.config_manager.get_max_user_titles() + user_summaries.sort(key=lambda x: x["message_count"], reverse=True) + user_summaries = user_summaries[:max_user_titles] + + # 构建LLM提示词 + users_text = "\n".join([ + f"- {user['name']} (QQ:{user['qq']}): " + f"发言{user['message_count']}条, 平均{user['avg_chars']}字, " + f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, " + f"回复比例{user['reply_ratio']}" + for user in user_summaries + ]) + + prompt = f""" +请为以下群友分配合适的称号和MBTI类型。每个人只能有一个称号,每个称号只能给一个人。 + +可选称号: +- 龙王: 发言频繁但内容轻松的人 +- 技术专家: 经常讨论技术话题的人 +- 夜猫子: 经常在深夜发言的人 +- 表情包军火库: 经常发表情的人 +- 沉默终结者: 经常开启话题的人 +- 评论家: 平均发言长度很长的人 +- 阳角: 在群里很有影响力的人 +- 互动达人: 经常回复别人的人 +- ... (你可以自行进行拓展添加) + +用户数据: +{users_text} + +请以JSON格式返回,格式如下: +[ + {{ + "name": "用户名", + "qq": 123456789, + "title": "称号", + "mbti": "MBTI类型", + "reason": "获得此称号的原因" + }} +] +""" + + # 调用LLM + provider = self.context.get_using_provider() + if not provider: + logger.warning("未配置LLM提供商,跳过用户称号分析") + return [], TokenUsage() + + response = await provider.text_chat( + prompt=prompt, + max_tokens=1500, + temperature=0.5 + ) + + # 提取token使用统计 + token_usage = TokenUsage() + if response.raw_completion and hasattr(response.raw_completion, 'usage'): + usage = response.raw_completion.usage + token_usage.prompt_tokens = usage.prompt_tokens + token_usage.completion_tokens = usage.completion_tokens + token_usage.total_tokens = usage.total_tokens + + # 解析响应 + if hasattr(response, 'completion_text'): + result_text = response.completion_text + else: + result_text = str(response) + + # 尝试解析JSON + try: + json_match = re.search(r'\[.*\]', result_text, re.DOTALL) + if json_match: + titles_data = json.loads(json_match.group()) + return [UserTitle(**title) for title in titles_data], token_usage + except: + pass + + return [], token_usage + + except Exception as e: + logger.error(f"用户称号分析失败: {e}") + return [], TokenUsage() + + async def analyze_golden_quotes(self, messages: List[Dict]) -> Tuple[List[GoldenQuote], TokenUsage]: + """使用LLM分析群聊金句""" + try: + # 提取有趣的文本消息 + interesting_messages = [] + for msg in messages: + sender = msg.get("sender", {}) + nickname = sender.get("nickname", "") or sender.get("card", "") + msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M") + + for content in msg.get("message", []): + if content.get("type") == "text": + text = content.get("data", {}).get("text", "").strip() + # 过滤长度适中、可能有趣的消息 + if 5 <= len(text) <= 100 and not text.startswith(("http", "www", "/")): + interesting_messages.append({ + "sender": nickname, + "time": msg_time, + "content": text + }) + + if not interesting_messages: + return [], TokenUsage() + + # 构建LLM提示词 + messages_text = "\n".join([ + f"[{msg['time']}] {msg['sender']}: {msg['content']}" + for msg in interesting_messages + ]) + + # 计算金句数量 + max_golden_quotes = self.config_manager.get_max_golden_quotes() + + prompt = f""" +请从以下群聊记录中挑选出{max_golden_quotes}句最具冲击力、最令人惊叹的"金句"。这些金句需满足: +- 核心标准:**逆天的神人发言**,即具备颠覆常识的脑洞、逻辑跳脱的表达或强烈反差感的原创内容 +- 典型特征:包含某些争议话题元素、夸张类比、反常规结论、一本正经的"胡说八道"或突破语境的清奇思路,并且具备一定的冲击力,让人印象深刻。 + +对于每个金句,请提供: +1. 原文内容(完整保留发言细节) +2. 发言人昵称 +3. 选择理由(具体说明其"逆天"之处,如逻辑颠覆点/脑洞角度/反差感/争议话题元素) + +此外,我将对你进行严格约束: +- 优先筛选 **逆天指数最高** 的内容:发情、性压抑话题 > 争议话题 > 元素级 > 颠覆认知级 > 逻辑跳脱级 > 趣味调侃级,剔除单纯玩梗或网络热词堆砌的普通发言 +- 重点标记包含极端类比、反常识论证或无厘头结论的内容,并且包含一定的争议话题元素。 + +群聊记录: +{messages_text} + +请以JSON格式返回,格式如下: +[ + {{ + "content": "金句原文", + "sender": "发言人昵称", + "reason": "选择这句话的理由(需明确说明逆天特质)" + }} +] +""" + + # 调用LLM + provider = self.context.get_using_provider() + if not provider: + logger.warning("未配置LLM提供商,跳过金句分析") + return [], TokenUsage() + + response = await provider.text_chat( + prompt=prompt, + max_tokens=1500, + temperature=0.7 + ) + + # 提取token使用统计 + token_usage = TokenUsage() + if response.raw_completion and hasattr(response.raw_completion, 'usage'): + usage = response.raw_completion.usage + token_usage.prompt_tokens = usage.prompt_tokens + token_usage.completion_tokens = usage.completion_tokens + token_usage.total_tokens = usage.total_tokens + + # 解析响应 + if hasattr(response, 'completion_text'): + result_text = response.completion_text + else: + result_text = str(response) + + # 尝试解析JSON + try: + json_match = re.search(r'\[.*\]', result_text, re.DOTALL) + if json_match: + quotes_data = json.loads(json_match.group()) + return [GoldenQuote(**quote) for quote in quotes_data[:max_golden_quotes]], token_usage + except: + pass + + return [], token_usage + + except Exception as e: + logger.error(f"金句分析失败: {e}") + return [], TokenUsage() \ No newline at end of file diff --git a/src/analysis/statistics.py b/src/analysis/statistics.py new file mode 100644 index 0000000..c53db24 --- /dev/null +++ b/src/analysis/statistics.py @@ -0,0 +1,88 @@ +""" +统计分析模块 +负责用户活跃度分析和其他统计功能 +""" + +from datetime import datetime +from typing import List, Dict +from collections import defaultdict + + +class UserAnalyzer: + """用户分析器""" + + def __init__(self, config_manager): + self.config_manager = config_manager + + def analyze_users(self, messages: List[Dict]) -> Dict[str, Dict]: + """分析用户活跃度""" + user_stats = defaultdict(lambda: { + "message_count": 0, + "char_count": 0, + "emoji_count": 0, + "nickname": "", + "hours": defaultdict(int), + "reply_count": 0 + }) + + for msg in messages: + sender = msg.get("sender", {}) + user_id = str(sender.get("user_id", "")) + nickname = sender.get("nickname", "") or sender.get("card", "") + + user_stats[user_id]["message_count"] += 1 + user_stats[user_id]["nickname"] = nickname + + # 统计时间分布 + msg_time = datetime.fromtimestamp(msg.get("time", 0)) + user_stats[user_id]["hours"][msg_time.hour] += 1 + + # 处理消息内容 + for content in msg.get("message", []): + if content.get("type") == "text": + text = content.get("data", {}).get("text", "") + user_stats[user_id]["char_count"] += len(text) + elif content.get("type") == "face": + user_stats[user_id]["emoji_count"] += 1 + elif content.get("type") == "reply": + user_stats[user_id]["reply_count"] += 1 + + return dict(user_stats) + + def get_top_users(self, user_analysis: Dict[str, Dict], limit: int = 10) -> List[Dict]: + """获取最活跃的用户""" + users = [] + for user_id, stats in user_analysis.items(): + users.append({ + "user_id": user_id, + "nickname": stats["nickname"], + "message_count": stats["message_count"], + "char_count": stats["char_count"], + "emoji_count": stats["emoji_count"], + "reply_count": stats["reply_count"] + }) + + # 按消息数量排序 + users.sort(key=lambda x: x["message_count"], reverse=True) + return users[:limit] + + def get_user_activity_pattern(self, user_analysis: Dict[str, Dict], user_id: str) -> Dict: + """获取用户活动模式""" + if user_id not in user_analysis: + return {} + + stats = user_analysis[user_id] + hours = stats["hours"] + + # 找出最活跃的时间段 + most_active_hour = max(hours.items(), key=lambda x: x[1])[0] if hours else 0 + + # 计算夜间活跃度 + night_messages = sum(hours[h] for h in range(0, 6)) + night_ratio = night_messages / stats["message_count"] if stats["message_count"] > 0 else 0 + + return { + "most_active_hour": most_active_hour, + "night_ratio": night_ratio, + "hourly_distribution": dict(hours) + } \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..9e7dda8 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,11 @@ +""" +核心功能模块 +""" + +from .config import ConfigManager +from .message_handler import MessageHandler + +__all__ = [ + 'ConfigManager', + 'MessageHandler' +] \ No newline at end of file diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..7bb908b --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,263 @@ +""" +配置管理模块 +负责处理插件配置和PDF依赖检查 +""" + +import sys +import importlib +from pathlib import Path +from typing import Optional, List +from astrbot.api import logger, AstrBotConfig + + +class ConfigManager: + """配置管理器""" + + def __init__(self, config: AstrBotConfig): + self.config = config + self._pyppeteer_available = False + self._pyppeteer_version = None + self._check_pyppeteer_availability() + + def get_enabled_groups(self) -> List[str]: + """获取启用的群组列表""" + return self.config.get("enabled_groups", []) + + def get_max_messages(self) -> int: + """获取最大消息数量""" + return self.config.get("max_messages", 1000) + + def get_analysis_days(self) -> int: + """获取分析天数""" + return self.config.get("analysis_days", 1) + + def get_auto_analysis_time(self) -> str: + """获取自动分析时间""" + return self.config.get("auto_analysis_time", "09:00") + + def get_enable_auto_analysis(self) -> bool: + """获取是否启用自动分析""" + return self.config.get("enable_auto_analysis", False) + + def get_output_format(self) -> str: + """获取输出格式""" + return self.config.get("output_format", "image") + + def get_min_messages_threshold(self) -> int: + """获取最小消息阈值""" + return self.config.get("min_messages_threshold", 50) + + def get_topic_analysis_enabled(self) -> bool: + """获取是否启用话题分析""" + return self.config.get("topic_analysis_enabled", True) + + def get_user_title_analysis_enabled(self) -> bool: + """获取是否启用用户称号分析""" + return self.config.get("user_title_analysis_enabled", True) + + def get_max_topics(self) -> int: + """获取最大话题数量""" + return self.config.get("max_topics", 5) + + def get_max_user_titles(self) -> int: + """获取最大用户称号数量""" + return self.config.get("max_user_titles", 8) + + def get_max_golden_quotes(self) -> int: + """获取最大金句数量""" + return self.config.get("max_golden_quotes", 5) + + def get_max_query_rounds(self) -> int: + """获取最大查询轮数""" + return self.config.get("max_query_rounds", 35) + + def get_pdf_output_dir(self) -> str: + """获取PDF输出目录""" + return self.config.get("pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports") + + def get_pdf_filename_format(self) -> str: + """获取PDF文件名格式""" + return self.config.get("pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf") + + def set_output_format(self, format_type: str): + """设置输出格式""" + self.config["output_format"] = format_type + self.config.save_config() + + def set_enabled_groups(self, groups: List[str]): + """设置启用的群组列表""" + self.config["enabled_groups"] = groups + self.config.save_config() + + def set_max_messages(self, count: int): + """设置最大消息数量""" + self.config["max_messages"] = count + self.config.save_config() + + def set_analysis_days(self, days: int): + """设置分析天数""" + self.config["analysis_days"] = days + self.config.save_config() + + def set_auto_analysis_time(self, time_str: str): + """设置自动分析时间""" + self.config["auto_analysis_time"] = time_str + self.config.save_config() + + def set_enable_auto_analysis(self, enabled: bool): + """设置是否启用自动分析""" + self.config["enable_auto_analysis"] = enabled + self.config.save_config() + + def set_min_messages_threshold(self, threshold: int): + """设置最小消息阈值""" + self.config["min_messages_threshold"] = threshold + self.config.save_config() + + def set_topic_analysis_enabled(self, enabled: bool): + """设置是否启用话题分析""" + self.config["topic_analysis_enabled"] = enabled + self.config.save_config() + + def set_user_title_analysis_enabled(self, enabled: bool): + """设置是否启用用户称号分析""" + self.config["user_title_analysis_enabled"] = enabled + self.config.save_config() + + def set_max_topics(self, count: int): + """设置最大话题数量""" + self.config["max_topics"] = count + self.config.save_config() + + def set_max_user_titles(self, count: int): + """设置最大用户称号数量""" + self.config["max_user_titles"] = count + self.config.save_config() + + def set_max_golden_quotes(self, count: int): + """设置最大金句数量""" + self.config["max_golden_quotes"] = count + self.config.save_config() + + def set_max_query_rounds(self, rounds: int): + """设置最大查询轮数""" + self.config["max_query_rounds"] = rounds + self.config.save_config() + + def set_pdf_output_dir(self, directory: str): + """设置PDF输出目录""" + self.config["pdf_output_dir"] = directory + self.config.save_config() + + def set_pdf_filename_format(self, format_str: str): + """设置PDF文件名格式""" + self.config["pdf_filename_format"] = format_str + self.config.save_config() + + def add_enabled_group(self, group_id: str): + """添加启用的群组""" + enabled_groups = self.get_enabled_groups() + if group_id not in enabled_groups: + enabled_groups.append(group_id) + self.config["enabled_groups"] = enabled_groups + self.config.save_config() + + def remove_enabled_group(self, group_id: str): + """移除启用的群组""" + enabled_groups = self.get_enabled_groups() + if group_id in enabled_groups: + enabled_groups.remove(group_id) + self.config["enabled_groups"] = enabled_groups + self.config.save_config() + + @property + def pyppeteer_available(self) -> bool: + """检查pyppeteer是否可用""" + return self._pyppeteer_available + + @property + def pyppeteer_version(self) -> Optional[str]: + """获取pyppeteer版本""" + return self._pyppeteer_version + + def _check_pyppeteer_availability(self): + """检查 pyppeteer 可用性""" + try: + import pyppeteer + from pyppeteer import launch + self._pyppeteer_available = True + + # 检查版本 + try: + self._pyppeteer_version = pyppeteer.__version__ + logger.info(f"使用 pyppeteer {self._pyppeteer_version} 作为 PDF 引擎") + except AttributeError: + self._pyppeteer_version = "unknown" + logger.info("使用 pyppeteer (版本未知) 作为 PDF 引擎") + + except ImportError: + self._pyppeteer_available = False + self._pyppeteer_version = None + logger.warning("pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2") + + def reload_pyppeteer(self) -> bool: + """重新加载 pyppeteer 模块""" + try: + logger.info("开始重新加载 pyppeteer 模块...") + + # 移除所有 pyppeteer 相关模块 + modules_to_remove = [mod for mod in sys.modules.keys() if mod.startswith('pyppeteer')] + logger.info(f"移除模块: {modules_to_remove}") + for mod in modules_to_remove: + del sys.modules[mod] + + # 强制重新导入 + try: + import pyppeteer + from pyppeteer import launch + + # 更新全局变量 + self._pyppeteer_available = True + try: + self._pyppeteer_version = pyppeteer.__version__ + logger.info(f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}") + except AttributeError: + self._pyppeteer_version = "unknown" + logger.info("重新加载成功,pyppeteer 版本未知") + + return True + + except ImportError as e: + logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效") + logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能") + self._pyppeteer_available = False + self._pyppeteer_version = None + return False + except Exception as e: + logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效") + logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能") + self._pyppeteer_available = False + self._pyppeteer_version = None + return False + + except Exception as e: + logger.error(f"重新加载 pyppeteer 时出错: {e}") + return False + + def save_config(self): + """保存配置到AstrBot配置系统""" + try: + self.config.save_config() + logger.info("配置已保存") + except Exception as e: + logger.error(f"保存配置失败: {e}") + + def reload_config(self): + """重新加载配置""" + try: + # 重新从AstrBot配置系统读取所有配置 + logger.info("重新加载配置...") + # 配置会自动从self.config中重新读取 + logger.info("配置重载完成") + except Exception as e: + logger.error(f"重新加载配置失败: {e}") \ No newline at end of file diff --git a/src/core/message_handler.py b/src/core/message_handler.py new file mode 100644 index 0000000..00376c4 --- /dev/null +++ b/src/core/message_handler.py @@ -0,0 +1,167 @@ +""" +消息处理模块 +负责群聊消息的获取、过滤和预处理 +""" + +import asyncio +from datetime import datetime, timedelta +from typing import List, Dict, Optional +from collections import defaultdict +from astrbot.api import logger +from ...src.models.data_models import GroupStatistics, TokenUsage + + +class MessageHandler: + """消息处理器""" + + def __init__(self, config_manager): + self.config_manager = config_manager + self.bot_qq_id = None + + async def set_bot_qq_id(self, bot_instance): + """设置机器人QQ号""" + try: + if bot_instance and not self.bot_qq_id: + login_info = await bot_instance.api.call_action("get_login_info") + self.bot_qq_id = str(login_info.get("user_id", "")) + logger.info(f"获取到机器人QQ号: {self.bot_qq_id}") + except Exception as e: + logger.error(f"获取机器人QQ号失败: {e}") + + async def fetch_group_messages(self, bot_instance, group_id: str, days: int) -> List[Dict]: + """获取群聊消息记录""" + try: + if not bot_instance or not group_id: + logger.error(f"群 {group_id} 无效的客户端或群组ID") + return [] + + # 计算时间范围 + end_time = datetime.now() + start_time = end_time - timedelta(days=days) + + messages = [] + message_seq = 0 + query_rounds = 0 + max_rounds = self.config_manager.get_max_query_rounds() + max_messages = self.config_manager.get_max_messages() + consecutive_failures = 0 + max_failures = 3 + + logger.info(f"开始获取群 {group_id} 近 {days} 天的消息记录") + logger.info(f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M:%S')} 到 {end_time.strftime('%Y-%m-%d %H:%M:%S')}") + + while len(messages) < max_messages and query_rounds < max_rounds: + try: + payloads = { + "group_id": group_id, + "message_seq": message_seq, + "count": 200, + "reverseOrder": True, + } + + result = await bot_instance.api.call_action("get_group_msg_history", **payloads) + + if not result or "messages" not in result: + logger.warning(f"群 {group_id} API返回无效结果: {result}") + consecutive_failures += 1 + if consecutive_failures >= max_failures: + break + continue + + round_messages = result.get("messages", []) + + if not round_messages: + logger.info(f"群 {group_id} 没有更多消息,结束获取") + break + + # 重置失败计数 + consecutive_failures = 0 + + # 过滤时间范围内的消息 + valid_messages_in_round = 0 + oldest_msg_time = None + + for msg in round_messages: + try: + msg_time = datetime.fromtimestamp(msg.get("time", 0)) + oldest_msg_time = msg_time + + # 过滤掉机器人自己的消息 + sender_id = str(msg.get("sender", {}).get("user_id", "")) + if self.bot_qq_id and sender_id == self.bot_qq_id: + continue + + if msg_time >= start_time and msg_time <= end_time: + messages.append(msg) + valid_messages_in_round += 1 + except Exception as msg_error: + logger.warning(f"群 {group_id} 处理单条消息失败: {msg_error}") + continue + + # 如果最老的消息时间已经超出范围,停止获取 + if oldest_msg_time and oldest_msg_time < start_time: + logger.info(f"群 {group_id} 已获取到时间范围外的消息,停止获取。共获取 {len(messages)} 条消息") + break + + if valid_messages_in_round == 0: + logger.warning(f"群 {group_id} 本轮未获取到有效消息") + break + + message_seq = round_messages[0]["message_id"] + query_rounds += 1 + + # 添加延迟避免请求过快 + if query_rounds % 5 == 0: + await asyncio.sleep(0.5) + + except Exception as e: + logger.error(f"群 {group_id} 获取消息失败 (第{query_rounds+1}轮): {e}") + consecutive_failures += 1 + if consecutive_failures >= max_failures: + logger.error(f"群 {group_id} 连续失败 {max_failures} 次,停止获取") + break + await asyncio.sleep(1) + + logger.info(f"群 {group_id} 消息获取完成,共获取 {len(messages)} 条消息,查询轮数: {query_rounds}") + return messages + + except Exception as e: + logger.error(f"群 {group_id} 获取群聊消息记录失败: {e}", exc_info=True) + return [] + + def calculate_statistics(self, messages: List[Dict]) -> GroupStatistics: + """计算基础统计数据""" + total_chars = 0 + participants = set() + hour_counts = defaultdict(int) + emoji_count = 0 + + for msg in messages: + sender_id = str(msg.get("sender", {}).get("user_id", "")) + participants.add(sender_id) + + # 统计时间分布 + msg_time = datetime.fromtimestamp(msg.get("time", 0)) + hour_counts[msg_time.hour] += 1 + + # 处理消息内容 + for content in msg.get("message", []): + if content.get("type") == "text": + text = content.get("data", {}).get("text", "") + total_chars += len(text) + elif content.get("type") == "face": + emoji_count += 1 + + # 找出最活跃时段 + most_active_hour = max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0 + most_active_period = f"{most_active_hour:02d}:00-{(most_active_hour+1)%24:02d}:00" + + return GroupStatistics( + message_count=len(messages), + total_characters=total_chars, + participant_count=len(participants), + most_active_period=most_active_period, + golden_quotes=[], + emoji_count=emoji_count, + token_usage=TokenUsage() + ) \ No newline at end of file diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..5a42962 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,19 @@ +""" +数据模型模块 +""" + +from .data_models import ( + SummaryTopic, + UserTitle, + GoldenQuote, + TokenUsage, + GroupStatistics +) + +__all__ = [ + 'SummaryTopic', + 'UserTitle', + 'GoldenQuote', + 'TokenUsage', + 'GroupStatistics' +] \ No newline at end of file diff --git a/src/models/data_models.py b/src/models/data_models.py new file mode 100644 index 0000000..1c71c2e --- /dev/null +++ b/src/models/data_models.py @@ -0,0 +1,53 @@ +""" +数据模型定义 +包含所有分析相关的数据结构 +""" + +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class SummaryTopic: + """话题总结数据结构""" + topic: str + contributors: List[str] + detail: str + + +@dataclass +class UserTitle: + """用户称号数据结构""" + name: str + qq: int + title: str + mbti: str + reason: str + + +@dataclass +class GoldenQuote: + """群聊金句数据结构""" + content: str + sender: str + reason: str + + +@dataclass +class TokenUsage: + """Token使用统计""" + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + +@dataclass +class GroupStatistics: + """群聊统计数据结构""" + message_count: int + total_characters: int + participant_count: int + most_active_period: str + golden_quotes: List[GoldenQuote] + emoji_count: int + token_usage: TokenUsage = field(default_factory=TokenUsage) \ No newline at end of file diff --git a/src/reports/__init__.py b/src/reports/__init__.py new file mode 100644 index 0000000..267df1e --- /dev/null +++ b/src/reports/__init__.py @@ -0,0 +1,12 @@ +""" +报告生成模块 +包含HTML、PDF、文本报告生成功能 +""" + +from .generators import ReportGenerator +from .templates import HTMLTemplates + +__all__ = [ + 'ReportGenerator', + 'HTMLTemplates' +] \ No newline at end of file diff --git a/src/reports/generators.py b/src/reports/generators.py new file mode 100644 index 0000000..86fd3b4 --- /dev/null +++ b/src/reports/generators.py @@ -0,0 +1,323 @@ +""" +报告生成器模块 +负责生成各种格式的分析报告 +""" + +import base64 +import aiohttp +from datetime import datetime +from typing import Dict, Optional +from pathlib import Path +from astrbot.api import logger +from .templates import HTMLTemplates + + +class ReportGenerator: + """报告生成器""" + + def __init__(self, config_manager): + self.config_manager = config_manager + + async def generate_image_report(self, analysis_result: Dict, group_id: str, html_render_func) -> Optional[str]: + """生成图片格式的分析报告""" + try: + # 准备渲染数据 + render_payload = await self._prepare_render_data(analysis_result) + + # 使用AstrBot内置的HTML渲染服务(直接传递模板和数据) + image_url = await html_render_func(HTMLTemplates.get_image_template(), render_payload) + return image_url + + except Exception as e: + logger.error(f"生成图片报告失败: {e}") + return None + + async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]: + """生成PDF格式的分析报告""" + try: + # 确保输出目录存在 + output_dir = Path(self.config_manager.get_pdf_output_dir()) + output_dir.mkdir(parents=True, exist_ok=True) + + # 生成文件名 + current_date = datetime.now().strftime('%Y%m%d') + filename = self.config_manager.get_pdf_filename_format().format( + group_id=group_id, + date=current_date + ) + pdf_path = output_dir / filename + + # 准备渲染数据 + render_data = await self._prepare_render_data(analysis_result) + logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段") + + # 生成 HTML 内容(PDF模板使用{}占位符) + html_content = self._render_html_template(HTMLTemplates.get_pdf_template(), render_data, use_jinja_style=False) + logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符") + + # 转换为 PDF + success = await self._html_to_pdf(html_content, str(pdf_path)) + + if success: + return str(pdf_path.absolute()) + else: + return None + + except Exception as e: + logger.error(f"生成 PDF 报告失败: {e}") + return None + + def generate_text_report(self, analysis_result: Dict) -> str: + """生成文本格式的分析报告""" + stats = analysis_result["statistics"] + topics = analysis_result["topics"] + user_titles = analysis_result["user_titles"] + + report = f""" +🎯 群聊日常分析报告 +📅 {datetime.now().strftime('%Y年%m月%d日')} + +📊 基础统计 +• 消息总数: {stats.message_count} +• 参与人数: {stats.participant_count} +• 总字符数: {stats.total_characters} +• 表情数量: {stats.emoji_count} +• 最活跃时段: {stats.most_active_period} + +💬 热门话题 +""" + + max_topics = self.config_manager.get_max_topics() + for i, topic in enumerate(topics[:max_topics], 1): + contributors_str = "、".join(topic.contributors) + report += f"{i}. {topic.topic}\n" + report += f" 参与者: {contributors_str}\n" + report += f" {topic.detail}\n\n" + + report += "🏆 群友称号\n" + max_user_titles = self.config_manager.get_max_user_titles() + for title in user_titles[:max_user_titles]: + report += f"• {title.name} - {title.title} ({title.mbti})\n" + report += f" {title.reason}\n\n" + + report += "💬 群圣经\n" + max_golden_quotes = self.config_manager.get_max_golden_quotes() + for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1): + report += f"{i}. \"{quote.content}\" —— {quote.sender}\n" + report += f" {quote.reason}\n\n" + + return report + + async def _prepare_render_data(self, analysis_result: Dict) -> Dict: + """准备渲染数据""" + stats = analysis_result["statistics"] + topics = analysis_result["topics"] + user_titles = analysis_result["user_titles"] + + # 构建话题HTML + topics_html = "" + max_topics = self.config_manager.get_max_topics() + for i, topic in enumerate(topics[:max_topics], 1): + contributors_str = "、".join(topic.contributors) + topics_html += f""" +
+
+ {i} + {topic.topic} +
+
参与者: {contributors_str}
+
{topic.detail}
+
+ """ + + # 构建用户称号HTML(包含头像) + titles_html = "" + max_user_titles = self.config_manager.get_max_user_titles() + for title in user_titles[:max_user_titles]: + # 获取用户头像 + avatar_data = await self._get_user_avatar(str(title.qq)) + avatar_html = f'头像' if avatar_data else '
👤
' + + titles_html += f""" +
+
+ {avatar_html} +
+
{title.name}
+
+
{title.title}
+
{title.mbti}
+
+
+
+
{title.reason}
+
+ """ + + # 构建金句HTML + quotes_html = "" + max_golden_quotes = self.config_manager.get_max_golden_quotes() + for quote in stats.golden_quotes[:max_golden_quotes]: + quotes_html += f""" +
+
"{quote.content}"
+
—— {quote.sender}
+
{quote.reason}
+
+ """ + + # 返回扁平化的渲染数据 + return { + "current_date": datetime.now().strftime('%Y年%m月%d日'), + "current_datetime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "message_count": stats.message_count, + "participant_count": stats.participant_count, + "total_characters": stats.total_characters, + "emoji_count": stats.emoji_count, + "most_active_period": stats.most_active_period, + "topics_html": topics_html, + "titles_html": titles_html, + "quotes_html": quotes_html, + "total_tokens": stats.token_usage.total_tokens, + "prompt_tokens": stats.token_usage.prompt_tokens, + "completion_tokens": stats.token_usage.completion_tokens + } + + def _render_html_template(self, template: str, data: Dict, use_jinja_style: bool = False) -> str: + """HTML模板渲染,支持两种占位符格式 + + Args: + template: HTML模板字符串 + data: 渲染数据 + use_jinja_style: 是否使用Jinja2风格的{{ }}占位符,否则使用{}占位符 + """ + result = template + + # 调试:记录渲染数据 + logger.info(f"渲染数据键: {list(data.keys())}, 使用Jinja风格: {use_jinja_style}") + + for key, value in data.items(): + if use_jinja_style: + # 图片模板使用{{ }}占位符 + placeholder = f"{{{{ {key} }}}}" + else: + # PDF模板使用{}占位符 + placeholder = f"{{{key}}}" + + # 调试:记录替换过程 + if placeholder in result: + logger.debug(f"替换 {placeholder} -> {str(value)[:100]}...") + result = result.replace(placeholder, str(value)) + + # 检查是否还有未替换的占位符 + import re + if use_jinja_style: + remaining_placeholders = re.findall(r'\{\{[^}]+\}\}', result) + else: + remaining_placeholders = re.findall(r'\{[^}]+\}', result) + + if remaining_placeholders: + logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}") + + return result + + async def _get_user_avatar(self, user_id: str) -> Optional[str]: + """获取用户头像的base64编码""" + try: + avatar_url = f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=640" + async with aiohttp.ClientSession() as client: + response = await client.get(avatar_url) + response.raise_for_status() + avatar_data = await response.read() + # 转换为base64编码 + avatar_base64 = base64.b64encode(avatar_data).decode('utf-8') + return f"data:image/jpeg;base64,{avatar_base64}" + except Exception as e: + logger.error(f"获取用户头像失败 {user_id}: {e}") + return None + + async def _html_to_pdf(self, html_content: str, output_path: str) -> bool: + """将 HTML 内容转换为 PDF 文件""" + try: + # 确保 pyppeteer 可用 + if not self.config_manager.pyppeteer_available: + logger.error("pyppeteer 不可用,无法生成 PDF") + return False + + # 动态导入 pyppeteer + import pyppeteer + from pyppeteer import launch + import sys + import os + + # 尝试启动浏览器,如果 Chromium 不存在会自动下载 + logger.info("启动浏览器进行 PDF 转换") + + # 配置浏览器启动参数,避免 Chromium 下载问题 + launch_options = { + 'headless': True, + 'args': [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--no-first-run', + '--disable-extensions', + '--disable-default-apps' + ] + } + + # 如果是 Windows 系统,尝试使用系统 Chrome + if sys.platform.startswith('win'): + # 常见的 Chrome 安装路径 + chrome_paths = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + r"C:\Users\{}\AppData\Local\Google\Chrome\Application\chrome.exe".format(os.environ.get('USERNAME', '')), + ] + + for chrome_path in chrome_paths: + if Path(chrome_path).exists(): + launch_options['executablePath'] = chrome_path + logger.info(f"使用系统 Chrome: {chrome_path}") + break + + browser = await launch(**launch_options) + page = await browser.newPage() + + # 设置页面内容 (pyppeteer 1.0.2 版本的 API) + await page.setContent(html_content) + # 等待页面加载完成 + try: + await page.waitForSelector('body', {'timeout': 10000}) + except Exception: + # 如果等待失败,继续执行(可能页面已经加载完成) + pass + + # 导出 PDF + await page.pdf({ + 'path': output_path, + 'format': 'A4', + 'printBackground': True, + 'margin': { + 'top': '10mm', + 'right': '10mm', + 'bottom': '10mm', + 'left': '10mm' + }, + 'scale': 0.8 + }) + + await browser.close() + logger.info(f"PDF 生成成功: {output_path}") + return True + + except Exception as e: + error_msg = str(e) + if "Chromium downloadable not found" in error_msg: + logger.error("Chromium 下载失败,建议安装 pyppeteer2 或使用系统 Chrome") + elif "No usable sandbox" in error_msg: + logger.error("沙盒权限问题,已尝试禁用沙盒") + else: + logger.error(f"HTML 转 PDF 失败: {e}") + return False \ No newline at end of file diff --git a/src/reports/templates.py b/src/reports/templates.py new file mode 100644 index 0000000..4077f46 --- /dev/null +++ b/src/reports/templates.py @@ -0,0 +1,200 @@ +""" +HTML模板模块 +严格按照main-backup中的实现,包含图片报告和PDF报告的不同HTML模板 +""" + + +class HTMLTemplates: + """HTML模板管理类""" + + @staticmethod + def get_image_template() -> str: + """获取图片报告的HTML模板(使用{{ }}占位符)""" + return """ + + + + + 群聊日常分析报告 + + + + +
+
+

📊 群聊日常分析报告

+
{{ current_date }}
+
+
+
+

📈 基础统计

+
+
{{ message_count }}
消息总数
+
{{ participant_count }}
参与人数
+
{{ total_characters }}
总字符数
+
{{ emoji_count }}
表情数量
+
+
+
{{ most_active_period }}
+
最活跃时段
+
+
+
+

💬 热门话题

+
{{ topics_html | safe }}
+
+
+

🏆 群友称号

+
{{ titles_html | safe }}
+
+
+

💬 群圣经

+ {{ quotes_html | safe }} +
+
+ +
+ +""" + + @staticmethod + def get_pdf_template() -> str: + """获取PDF报告的HTML模板(使用{}占位符)""" + return """ + + + + + 群聊日常分析报告 + + + +
+
+

📊 群聊日常分析报告

+
{current_date}
+
+
+

📈 基础统计

+
+
{message_count}
消息总数
+
{participant_count}
参与人数
+
{total_characters}
总字符数
+
{emoji_count}
表情数量
+
+
+
{most_active_period}
+
最活跃时段
+
+
+
+

💬 热门话题

+ {topics_html} +
+
+

🏆 群友称号

+ {titles_html} +
+
+

💬 群圣经

+ {quotes_html} +
+ +
+ +""" diff --git a/src/scheduler/__init__.py b/src/scheduler/__init__.py new file mode 100644 index 0000000..67a693f --- /dev/null +++ b/src/scheduler/__init__.py @@ -0,0 +1,10 @@ +""" +调度和自动化模块 +包含定时任务和自动分析功能 +""" + +from .auto_scheduler import AutoScheduler + +__all__ = [ + 'AutoScheduler' +] \ No newline at end of file diff --git a/src/scheduler/auto_scheduler.py b/src/scheduler/auto_scheduler.py new file mode 100644 index 0000000..3593174 --- /dev/null +++ b/src/scheduler/auto_scheduler.py @@ -0,0 +1,317 @@ +""" +自动调度器模块 +负责定时任务和自动分析功能 +""" + +import asyncio +from datetime import datetime, timedelta +from typing import Optional +from astrbot.api import logger + + +class AutoScheduler: + """自动调度器""" + + def __init__(self, config_manager, message_handler, analyzer, report_generator, html_render_func=None): + self.config_manager = config_manager + self.message_handler = message_handler + self.analyzer = analyzer + self.report_generator = report_generator + self.html_render_func = html_render_func + self.scheduler_task = None + self.bot_instance = None + self.last_execution_date = None # 记录上次执行日期,防止重复执行 + + def set_bot_instance(self, bot_instance): + """设置bot实例""" + self.bot_instance = bot_instance + # 同时设置消息处理器的bot实例 + asyncio.create_task(self.message_handler.set_bot_qq_id(bot_instance)) + + async def start_scheduler(self): + """启动定时任务调度器""" + if not self.config_manager.get_enable_auto_analysis(): + logger.info("自动分析功能未启用") + return + + # 延迟启动,给系统时间初始化 + await asyncio.sleep(10) + + logger.info(f"启动定时任务调度器,自动分析时间: {self.config_manager.get_auto_analysis_time()}") + + self.scheduler_task = asyncio.create_task(self._scheduler_loop()) + + async def stop_scheduler(self): + """停止定时任务调度器""" + if self.scheduler_task and not self.scheduler_task.done(): + self.scheduler_task.cancel() + logger.info("已停止定时任务调度器") + + async def restart_scheduler(self): + """重启定时任务调度器""" + await self.stop_scheduler() + if self.config_manager.get_enable_auto_analysis(): + await self.start_scheduler() + + async def _scheduler_loop(self): + """调度器主循环""" + while True: + try: + now = datetime.now() + target_time = datetime.strptime(self.config_manager.get_auto_analysis_time(), "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + + # 如果今天的目标时间已过,设置为明天 + if now >= target_time: + target_time += timedelta(days=1) + + # 计算等待时间 + wait_seconds = (target_time - now).total_seconds() + logger.info(f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f} 秒") + + # 等待到目标时间 + await asyncio.sleep(wait_seconds) + + # 执行自动分析 + if self.config_manager.get_enable_auto_analysis(): + # 检查是否今天已经执行过 + today = now.date() + if self.last_execution_date == today: + logger.info(f"今天 {today} 已经执行过自动分析,跳过执行") + # 等待到明天再检查 + await asyncio.sleep(3600) # 等待1小时后再检查 + continue + + logger.info("开始执行定时分析") + await self._run_auto_analysis() + self.last_execution_date = today # 记录执行日期 + logger.info(f"定时分析执行完成,记录执行日期: {today}") + else: + logger.info("自动分析已禁用,跳过执行") + break + + except Exception as e: + logger.error(f"定时任务调度器错误: {e}") + # 等待5分钟后重试 + await asyncio.sleep(300) + + async def _run_auto_analysis(self): + """执行自动分析""" + try: + logger.info("开始执行自动群聊分析") + + # 为每个启用的群执行分析 + enabled_groups = self.config_manager.get_enabled_groups() + for group_id in enabled_groups: + try: + logger.info(f"为群 {group_id} 执行自动分析") + await self._perform_auto_analysis_for_group(group_id) + except Exception as e: + logger.error(f"群 {group_id} 自动分析失败: {e}") + + except Exception as e: + logger.error(f"自动分析执行失败: {e}") + + async def _perform_auto_analysis_for_group(self, group_id: str): + """为指定群执行自动分析""" + try: + if not self.bot_instance: + logger.warning(f"群 {group_id} 自动分析跳过:未获取到bot实例") + return + + logger.info(f"开始为群 {group_id} 执行自动分析") + + # 获取群聊消息 + analysis_days = self.config_manager.get_analysis_days() + messages = await self.message_handler.fetch_group_messages(self.bot_instance, group_id, analysis_days) + if not messages: + logger.warning(f"群 {group_id} 未获取到足够的消息记录") + return + + # 检查消息数量 + min_threshold = self.config_manager.get_min_messages_threshold() + if len(messages) < min_threshold: + logger.warning(f"群 {group_id} 消息数量不足({len(messages)}条),跳过分析") + return + + logger.info(f"群 {group_id} 获取到 {len(messages)} 条消息,开始分析") + + # 进行分析 + analysis_result = await self.analyzer.analyze_messages(messages, group_id) + if not analysis_result: + logger.error(f"群 {group_id} 分析失败") + return + + # 生成并发送报告 + await self._send_analysis_report(group_id, analysis_result) + + except Exception as e: + logger.error(f"群 {group_id} 自动分析执行失败: {e}", exc_info=True) + + async def _send_analysis_report(self, group_id: str, analysis_result: dict): + """发送分析报告到群""" + try: + output_format = self.config_manager.get_output_format() + + if output_format == "image": + if self.html_render_func: + # 使用图片格式 + logger.info(f"群 {group_id} 自动分析使用图片报告格式") + image_url = await self.report_generator.generate_image_report(analysis_result, group_id, self.html_render_func) + if image_url: + await self._send_image_message(group_id, image_url) + logger.info(f"群 {group_id} 图片报告发送成功") + else: + # 图片生成失败,回退到文本 + logger.warning(f"群 {group_id} 图片报告生成失败,回退到文本报告") + text_report = self.report_generator.generate_text_report(analysis_result) + await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") + else: + # 没有html_render函数,回退到文本报告 + logger.warning(f"群 {group_id} 缺少html_render函数,回退到文本报告") + text_report = self.report_generator.generate_text_report(analysis_result) + await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") + + elif output_format == "pdf": + if not self.config_manager.pyppeteer_available: + logger.warning(f"群 {group_id} PDF功能不可用,回退到文本报告") + text_report = self.report_generator.generate_text_report(analysis_result) + await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") + else: + pdf_path = await self.report_generator.generate_pdf_report(analysis_result, group_id) + if pdf_path: + await self._send_pdf_file(group_id, pdf_path) + logger.info(f"群 {group_id} 自动分析完成,已发送PDF报告") + else: + logger.error(f"群 {group_id} PDF报告生成失败,回退到文本报告") + text_report = self.report_generator.generate_text_report(analysis_result) + await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") + else: + text_report = self.report_generator.generate_text_report(analysis_result) + await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}") + + logger.info(f"群 {group_id} 自动分析完成,已发送报告") + + except Exception as e: + logger.error(f"发送分析报告到群 {group_id} 失败: {e}") + + async def _send_image_message(self, group_id: str, image_url: str): + """发送图片消息到群""" + try: + if not self.bot_instance: + logger.error(f"群 {group_id} 发送图片失败:缺少bot实例") + return + + # 发送图片消息到群 + await self.bot_instance.api.call_action( + "send_group_msg", + group_id=group_id, + message=[{ + "type": "text", + "data": {"text": "📊 每日群聊分析报告已生成:"} + }, { + "type": "image", + "data": {"url": image_url} + }] + ) + logger.info(f"群 {group_id} 图片消息发送成功") + + except Exception as e: + logger.error(f"发送图片消息到群 {group_id} 失败: {e}") + + async def _send_text_message(self, group_id: str, text_content: str): + """发送文本消息到群""" + try: + if not self.bot_instance: + logger.error(f"群 {group_id} 发送文本失败:缺少bot实例") + return + + # 发送文本消息到群 + await self.bot_instance.api.call_action( + "send_group_msg", + group_id=group_id, + message=text_content + ) + logger.info(f"群 {group_id} 文本消息发送成功") + + except Exception as e: + logger.error(f"发送文本消息到群 {group_id} 失败: {e}") + + async def _send_pdf_file(self, group_id: str, pdf_path: str): + """发送PDF文件到群""" + try: + if not self.bot_instance: + logger.error(f"群 {group_id} 发送PDF失败:缺少bot实例") + return + + # 发送PDF文件到群 + await self.bot_instance.api.call_action( + "send_group_msg", + group_id=group_id, + message=[{ + "type": "text", + "data": {"text": "📊 每日群聊分析报告已生成:"} + }, { + "type": "file", + "data": {"file": pdf_path} + }] + ) + logger.info(f"群 {group_id} PDF文件发送成功") + + except Exception as e: + logger.error(f"发送PDF文件到群 {group_id} 失败: {e}") + # 发送失败提示 + try: + await self.bot_instance.api.call_action( + "send_group_msg", + group_id=group_id, + message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}" + ) + except Exception as e2: + logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}") + + async def _send_text_message(self, group_id: str, message: str): + """发送文本消息到群""" + try: + if not self.bot_instance: + return + + await self.bot_instance.api.call_action( + "send_group_msg", + group_id=group_id, + message=message + ) + + except Exception as e: + logger.error(f"发送文本消息到群 {group_id} 失败: {e}") + + async def _send_pdf_file(self, group_id: str, pdf_path: str): + """发送PDF文件到群""" + try: + if not self.bot_instance: + return + + await self.bot_instance.api.call_action( + "send_group_msg", + group_id=group_id, + message=[{ + "type": "text", + "data": {"text": "📊 每日群聊分析报告已生成:"} + }, { + "type": "file", + "data": {"file": pdf_path} + }] + ) + + except Exception as e: + logger.error(f"发送PDF文件到群 {group_id} 失败: {e}") + # 如果发送PDF失败,尝试发送提示信息 + try: + await self.bot_instance.api.call_action( + "send_group_msg", + group_id=group_id, + message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}" + ) + except Exception as e2: + logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}") \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..c70fe49 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,12 @@ +""" +工具函数模块 +包含PDF处理和通用工具函数 +""" + +from .pdf_utils import PDFInstaller +from .helpers import MessageAnalyzer + +__all__ = [ + 'PDFInstaller', + 'MessageAnalyzer' +] \ No newline at end of file diff --git a/src/utils/helpers.py b/src/utils/helpers.py new file mode 100644 index 0000000..4901a55 --- /dev/null +++ b/src/utils/helpers.py @@ -0,0 +1,76 @@ +""" +通用工具函数模块 +包含消息分析和其他通用功能 +""" + +from typing import List, Dict +from ...src.models.data_models import GroupStatistics, SummaryTopic, UserTitle, GoldenQuote, TokenUsage +from ...src.core.message_handler import MessageHandler +from ...src.analysis.llm_analyzer import LLMAnalyzer +from ...src.analysis.statistics import UserAnalyzer + + +class MessageAnalyzer: + """消息分析器 - 整合所有分析功能""" + + def __init__(self, context, config_manager): + self.context = context + self.config_manager = config_manager + self.message_handler = MessageHandler(config_manager) + self.llm_analyzer = LLMAnalyzer(context, config_manager) + self.user_analyzer = UserAnalyzer(config_manager) + + async def set_bot_instance(self, bot_instance): + """设置bot实例""" + await self.message_handler.set_bot_qq_id(bot_instance) + + async def analyze_messages(self, messages: List[Dict], group_id: str) -> Dict: + """完整的消息分析流程""" + try: + # 基础统计 + statistics = self.message_handler.calculate_statistics(messages) + + # 用户分析 + user_analysis = self.user_analyzer.analyze_users(messages) + + # LLM分析 + topics = [] + user_titles = [] + golden_quotes = [] + total_token_usage = TokenUsage() + + # 话题分析 + if self.config_manager.get_topic_analysis_enabled(): + topics, topic_tokens = await self.llm_analyzer.analyze_topics(messages) + total_token_usage.prompt_tokens += topic_tokens.prompt_tokens + total_token_usage.completion_tokens += topic_tokens.completion_tokens + total_token_usage.total_tokens += topic_tokens.total_tokens + + # 用户称号分析 + if self.config_manager.get_user_title_analysis_enabled(): + user_titles, title_tokens = await self.llm_analyzer.analyze_user_titles(messages, user_analysis) + total_token_usage.prompt_tokens += title_tokens.prompt_tokens + total_token_usage.completion_tokens += title_tokens.completion_tokens + total_token_usage.total_tokens += title_tokens.total_tokens + + # 金句分析 + golden_quotes, quote_tokens = await self.llm_analyzer.analyze_golden_quotes(messages) + total_token_usage.prompt_tokens += quote_tokens.prompt_tokens + total_token_usage.completion_tokens += quote_tokens.completion_tokens + total_token_usage.total_tokens += quote_tokens.total_tokens + + # 更新统计数据 + statistics.golden_quotes = golden_quotes + statistics.token_usage = total_token_usage + + return { + "statistics": statistics, + "topics": topics, + "user_titles": user_titles, + "user_analysis": user_analysis + } + + except Exception as e: + from astrbot.api import logger + logger.error(f"消息分析失败: {e}") + return None \ No newline at end of file diff --git a/src/utils/pdf_utils.py b/src/utils/pdf_utils.py new file mode 100644 index 0000000..c633696 --- /dev/null +++ b/src/utils/pdf_utils.py @@ -0,0 +1,57 @@ +""" +PDF工具模块 +负责PDF相关的安装和管理功能 +""" + +import sys +import asyncio +from astrbot.api import logger + + +class PDFInstaller: + """PDF功能安装器""" + + @staticmethod + async def install_pyppeteer(config_manager): + """安装pyppeteer依赖""" + try: + logger.info("开始安装 pyppeteer...") + + # 使用asyncio安装pyppeteer和兼容的websockets版本 + logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...") + process = await asyncio.create_subprocess_exec( + sys.executable, "-m", "pip", "install", + "pyppeteer==1.0.2", "websockets==10.4", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode == 0: + logger.info("pyppeteer 安装成功") + logger.info(f"安装输出: {stdout.decode()}") + + # 重新加载pyppeteer模块 + success = config_manager.reload_pyppeteer() + if success: + return "✅ pyppeteer 安装成功!PDF 功能现已可用。" + else: + return "⚠️ pyppeteer 安装完成,但重新加载失败。请重启 AstrBot 以使用 PDF 功能。" + else: + error_msg = stderr.decode() + logger.error(f"pyppeteer 安装失败: {error_msg}") + return f"❌ pyppeteer 安装失败: {error_msg}" + + except Exception as e: + logger.error(f"安装 pyppeteer 时出错: {e}") + return f"❌ 安装过程中出错: {str(e)}" + + @staticmethod + def get_pdf_status(config_manager) -> str: + """获取PDF功能状态""" + if config_manager.pyppeteer_available: + version = config_manager.pyppeteer_version or "未知版本" + return f"✅ PDF 功能可用 (pyppeteer {version})" + else: + return "❌ PDF 功能不可用 - 需要安装 pyppeteer" \ No newline at end of file