diff --git a/_conf_schema.json b/_conf_schema.json index fc175b6..c048281 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -321,6 +321,37 @@ } } }, + "qq_group_upload": { + "description": "群文件/群相册上传设置", + "type": "object", + "hint": "图片报告生成后自动上传到群文件目录或群相册。群文件上传基于 OneBot upload_group_file API,群相册上传为 NapCat 扩展 API。仅对 QQ(OneBot)平台的图片格式报告生效", + "items": { + "enable_group_file_upload": { + "type": "bool", + "description": "启用群文件上传", + "default": false, + "hint": "图片报告生成后,自动保存为 PNG 文件并上传到群文件目录。需要 OneBot 实现支持 upload_group_file API" + }, + "group_file_folder": { + "type": "string", + "description": "群文件上传目录名", + "default": "", + "hint": "上传到群文件中的子目录名称。留空则上传到根目录。如果指定的目录不存在,插件将尝试自动创建" + }, + "enable_group_album_upload": { + "type": "bool", + "description": "启用群相册上传(仅 NapCat)", + "default": false, + "hint": "图片报告生成后,自动上传到群相册。此功能为 NapCat 扩展 API,其他 OneBot 实现不支持" + }, + "group_album_name": { + "type": "string", + "description": "目标群相册名称", + "default": "", + "hint": "指定上传到哪个群相册。留空则上传到群默认相册。如果指定的相册不存在,将回退到默认相册" + } + } + }, "prompts": { "description": "提示词模板", "type": "object", diff --git a/main.py b/main.py index e3edc59..3001c0d 100644 --- a/main.py +++ b/main.py @@ -44,8 +44,28 @@ from .src.infrastructure.scheduler.retry import RetryManager from .src.utils.pdf_utils import PDFInstaller -class QQGroupDailyAnalysis(Star): - """QQ群日常分析插件主类""" +class GroupDailyAnalysis(Star): + """群分析插件主类""" + + # ── 显式类型声明(消除 Pylance Optional 推断) ── + config: AstrBotConfig + config_manager: ConfigManager + bot_manager: BotManager + history_manager: HistoryManager + report_generator: ReportGenerator + telegram_group_registry: TelegramGroupRegistry + statistics_service: StatisticsService + analysis_domain_service: AnalysisDomainService + llm_analyzer: LLMAnalyzer + incremental_store: IncrementalStore + incremental_merge_service: IncrementalMergeService + analysis_service: AnalysisApplicationService + message_processing_service: MessageProcessingService + template_command_service: TemplateCommandService + telegram_template_preview_handler: TelegramTemplatePreviewHandler + template_preview_router: TemplatePreviewRouter + retry_manager: RetryManager + auto_scheduler: AutoScheduler def __init__(self, context: Context, config: AstrBotConfig): super().__init__(context) @@ -188,15 +208,15 @@ class QQGroupDailyAnalysis(Star): if self.template_preview_router: await self.template_preview_router.unregister_handlers() - # 重置实例属性 - self.auto_scheduler = None - self.bot_manager = None - self.report_generator = None - self.config_manager = None - self.message_processing_service = None - self.telegram_group_registry = None - self.template_preview_router = None - self.telegram_template_preview_handler = None + # 释放实例属性引用(插件卸载后不再使用) + del self.auto_scheduler + del self.bot_manager + del self.report_generator + del self.config_manager + del self.message_processing_service + del self.telegram_group_registry + del self.template_preview_router + del self.telegram_template_preview_handler logger.info("QQ群日常分析插件资源清理完成") @@ -250,6 +270,90 @@ class QQGroupDailyAnalysis(Star): return event.platform_meta.id return "default" + # ================================================================ + # 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式) + # ================================================================ + + async def _try_upload_image(self, group_id: str, image_url: str, platform_id: str): + """ + 尝试将图片报告上传到群文件和/或群相册(静默处理,失败仅日志提示)。 + """ + import base64 + import tempfile + from datetime import datetime + + enable_file = self.config_manager.get_enable_group_file_upload() + enable_album = self.config_manager.get_enable_group_album_upload() + if not enable_file and not enable_album: + return + + adapter = self.bot_manager.get_adapter(platform_id) + if not adapter or not hasattr(adapter, "upload_group_file_to_folder"): + return + + # 将图片保存为临时文件 + image_file = None + created_temp = False + try: + if image_url.startswith("base64://"): + data = base64.b64decode(image_url[len("base64://") :]) + elif image_url.startswith("data:"): + parts = image_url.split(",", 1) + data = base64.b64decode(parts[1]) if len(parts) == 2 else None + elif os.path.isfile(image_url): + image_file = os.path.abspath(image_url) + data = None + else: + return + + if data and not image_file: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + image_file = os.path.join( + tempfile.gettempdir(), f"群聊分析报告_{group_id}_{ts}.png" + ) + with open(image_file, "wb") as f: + f.write(data) + created_temp = True + + if not image_file: + return + + if enable_file: + try: + folder_name = self.config_manager.get_group_file_folder() + folder_id = None + if folder_name: + folder_id = await adapter.find_or_create_folder( # type: ignore[attr-defined] + group_id, folder_name + ) + await adapter.upload_group_file_to_folder( # type: ignore[attr-defined] + group_id=group_id, + file_path=image_file, + folder_id=folder_id, + ) + except Exception as e: + logger.warning(f"群文件上传失败 (群 {group_id}): {e}") + + if enable_album and hasattr(adapter, "upload_group_album"): + try: + album_name = self.config_manager.get_group_album_name() + album_id = None + if album_name and hasattr(adapter, "find_album_id"): + album_id = await adapter.find_album_id(group_id, album_name) # type: ignore[attr-defined] + await adapter.upload_group_album( # type: ignore[attr-defined] + group_id, image_file, album_id=album_id + ) + except Exception as e: + logger.warning(f"群相册上传失败 (群 {group_id}): {e}") + except Exception as e: + logger.warning(f"图片上传处理异常: {e}") + finally: + if created_temp and image_file and os.path.exists(image_file): + try: + os.remove(image_file) + except OSError: + pass + @filter.command("群分析", alias={"group_analysis"}) @filter.permission_type(PermissionType.ADMIN) async def analyze_group_daily( @@ -335,6 +439,8 @@ class QQGroupDailyAnalysis(Star): if image_url: if not await adapter.send_image(group_id, image_url): yield event.image_result(image_url) + # 上传到群文件/群相册 + await self._try_upload_image(group_id, image_url, platform_id) elif html_content: yield event.plain_result("⚠️ 图片生成暂不可用,已尝试加入队列。") await self.retry_manager.add_task( @@ -456,6 +562,7 @@ class QQGroupDailyAnalysis(Star): if parse_error: yield event.plain_result(parse_error) return + assert template_name is not None if not await self.template_command_service.template_exists(template_name): yield event.plain_result(f"❌ 模板 '{template_name}' 不存在") diff --git a/src/domain/repositories/analysis_repository.py b/src/domain/repositories/analysis_repository.py index 97999a7..0e5ee2b 100644 --- a/src/domain/repositories/analysis_repository.py +++ b/src/domain/repositories/analysis_repository.py @@ -15,7 +15,10 @@ class IAnalysisProvider(ABC): @abstractmethod async def analyze_topics( - self, messages: list[dict], umo: str = None, session_id: str = None + self, + messages: list[dict], + umo: str | None = None, + session_id: str | None = None, ) -> tuple[list[SummaryTopic], TokenUsage]: """分析话题""" pass @@ -25,16 +28,19 @@ class IAnalysisProvider(ABC): self, messages: list[dict], user_activity: dict, - umo: str = None, - top_users: list[dict] = None, - session_id: str = None, + umo: str | None = None, + top_users: list[dict] | None = None, + session_id: str | None = None, ) -> tuple[list[UserTitle], TokenUsage]: """分析用户称号""" pass @abstractmethod async def analyze_golden_quotes( - self, messages: list[dict], umo: str = None, session_id: str = None + self, + messages: list[dict], + umo: str | None = None, + session_id: str | None = None, ) -> tuple[list[GoldenQuote], TokenUsage]: """分析金句""" pass @@ -44,8 +50,8 @@ class IAnalysisProvider(ABC): self, messages: list[dict], user_activity: dict, - umo: str = None, - top_users: list[dict] = None, + umo: str | None = None, + top_users: list[dict] | None = None, ) -> tuple[list[SummaryTopic], list[UserTitle], list[GoldenQuote], TokenUsage]: """并发分析所有内容""" pass diff --git a/src/domain/repositories/report_repository.py b/src/domain/repositories/report_repository.py index 6278e46..3022fc0 100644 --- a/src/domain/repositories/report_repository.py +++ b/src/domain/repositories/report_repository.py @@ -19,13 +19,18 @@ class IReportGenerator(ABC): group_id: str, html_render_func: Any, avatar_getter: Any = None, + nickname_getter: Any = None, ) -> tuple[str | None, str | None]: """生成图片报告""" pass @abstractmethod async def generate_pdf_report( - self, analysis_result: dict, group_id: str, avatar_getter: Any = None + self, + analysis_result: dict, + group_id: str, + avatar_getter: Any = None, + nickname_getter: Any = None, ) -> str | None: """生成 PDF 报告""" pass diff --git a/src/infrastructure/analysis/analyzers/base_analyzer.py b/src/infrastructure/analysis/analyzers/base_analyzer.py index fa39060..3828778 100644 --- a/src/infrastructure/analysis/analyzers/base_analyzer.py +++ b/src/infrastructure/analysis/analyzers/base_analyzer.py @@ -35,7 +35,7 @@ class BaseAnalyzer(ABC): # 增量分析模式下的最大数量覆盖值,为 None 时使用配置默认值 self._incremental_max_count: int | None = None - def get_provider_id_key(self) -> str: + def get_provider_id_key(self) -> str | None: """ 获取 Provider ID 配置键名 子类可重写以指定特定的 provider,默认返回 None(使用主 LLM Provider) @@ -139,7 +139,7 @@ class BaseAnalyzer(ABC): logger.error(f"保存调试数据失败: {e}", exc_info=True) async def analyze( - self, data: Any, umo: str = None, session_id: str = None + self, data: Any, umo: str | None = None, session_id: str | None = None ) -> tuple[list[Any], TokenUsage]: """ 统一的分析流程 diff --git a/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py b/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py index 4bb1a2b..3afe530 100644 --- a/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py +++ b/src/infrastructure/analysis/analyzers/golden_quote_analyzer.py @@ -40,7 +40,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer): """获取温度参数""" return 0.7 - def build_prompt(self, messages: list[dict]) -> str: + def build_prompt(self, data: list[dict]) -> str: """ 构建金句分析提示词 @@ -50,12 +50,12 @@ class GoldenQuoteAnalyzer(BaseAnalyzer): Returns: 提示词字符串 """ - if not messages: + if not data: return "" # 构建消息文本 messages_text = "\n".join( - [f"[{msg['time']}] {msg['sender']}: {msg['content']}" for msg in messages] + [f"[{msg['time']}] {msg['sender']}: {msg['content']}" for msg in data] ) max_golden_quotes = self.get_max_count() @@ -92,7 +92,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer): """ return extract_golden_quotes_with_regex(result_text, max_count) - def create_data_objects(self, quotes_data: list[dict]) -> list[GoldenQuote]: + def create_data_objects(self, data_list: list[dict]) -> list[GoldenQuote]: """ 创建金句对象列表 @@ -106,7 +106,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer): quotes = [] max_quotes = self.get_max_count() - for quote_data in quotes_data[:max_quotes]: + for quote_data in data_list[:max_quotes]: # 确保数据格式正确 content = quote_data.get("content", "").strip() sender = quote_data.get("sender", "").strip() @@ -128,7 +128,10 @@ class GoldenQuoteAnalyzer(BaseAnalyzer): return [] async def analyze_golden_quotes( - self, messages: list[dict], umo: str = None, session_id: str = None + self, + messages: list[dict], + umo: str | None = None, + session_id: str | None = None, ) -> tuple[list[GoldenQuote], TokenUsage]: """ 分析群聊金句 diff --git a/src/infrastructure/analysis/analyzers/topic_analyzer.py b/src/infrastructure/analysis/analyzers/topic_analyzer.py index c5cf115..a9f9958 100644 --- a/src/infrastructure/analysis/analyzers/topic_analyzer.py +++ b/src/infrastructure/analysis/analyzers/topic_analyzer.py @@ -41,7 +41,7 @@ class TopicAnalyzer(BaseAnalyzer): """获取温度参数""" return 0.6 - def build_prompt(self, messages: list[dict]) -> str: + def build_prompt(self, data: list[dict]) -> str: """ 构建话题分析提示词 @@ -52,18 +52,18 @@ class TopicAnalyzer(BaseAnalyzer): 提示词字符串 """ # 验证输入数据格式 - if not isinstance(messages, list): - logger.error(f"build_prompt 期望列表,但收到: {type(messages)}") + if not isinstance(data, list): + logger.error(f"build_prompt 期望列表,但收到: {type(data)}") return "" # 检查消息列表是否为空 - if not messages: + if not data: logger.warning("build_prompt 收到空消息列表") return "" # 提取文本消息 text_messages = [] - for i, msg in enumerate(messages): + for i, msg in enumerate(data): # 确保msg是字典类型,避免'str' object has no attribute 'get'错误 if not isinstance(msg, dict): continue @@ -177,20 +177,20 @@ class TopicAnalyzer(BaseAnalyzer): logger.warning("未找到有效的话题分析提示词配置,请检查配置文件") return "" - def extract_with_regex(self, result_text: str, max_topics: int) -> list[dict]: + def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]: """ 使用正则表达式提取话题信息 Args: result_text: LLM响应文本 - max_topics: 最大话题数量 + max_count: 最大话题数量 Returns: 话题数据列表 """ - return extract_topics_with_regex(result_text, max_topics) + return extract_topics_with_regex(result_text, max_count) - def create_data_objects(self, topics_data: list[dict]) -> list[SummaryTopic]: + def create_data_objects(self, data_list: list[dict]) -> list[SummaryTopic]: """ 创建话题对象列表 @@ -201,9 +201,9 @@ class TopicAnalyzer(BaseAnalyzer): SummaryTopic对象列表 """ logger.debug( - f"create_data_objects 开始处理,输入数据数量: {len(topics_data) if topics_data else 0}" + f"create_data_objects 开始处理,输入数据数量: {len(data_list) if data_list else 0}" ) - logger.debug(f"输入数据类型: {type(topics_data)}") + logger.debug(f"输入数据类型: {type(data_list)}") try: topics = [] @@ -211,7 +211,7 @@ class TopicAnalyzer(BaseAnalyzer): logger.debug(f"处理前 {max_topics} 条话题数据") - for i, topic_data in enumerate(topics_data[:max_topics]): + for i, topic_data in enumerate(data_list[:max_topics]): logger.debug(f"处理第 {i + 1} 条话题数据,类型: {type(topic_data)}") # 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误 @@ -301,7 +301,10 @@ class TopicAnalyzer(BaseAnalyzer): return text_messages async def analyze_topics( - self, messages: list[dict], umo: str = None, session_id: str = None + self, + messages: list[dict], + umo: str | None = None, + session_id: str | None = None, ) -> tuple[list[SummaryTopic], TokenUsage]: """ 分析群聊话题 diff --git a/src/infrastructure/analysis/analyzers/user_title_analyzer.py b/src/infrastructure/analysis/analyzers/user_title_analyzer.py index 55fe45d..107d695 100644 --- a/src/infrastructure/analysis/analyzers/user_title_analyzer.py +++ b/src/infrastructure/analysis/analyzers/user_title_analyzer.py @@ -35,7 +35,7 @@ class UserTitleAnalyzer(BaseAnalyzer): """获取温度参数""" return 0.5 - def build_prompt(self, user_data: dict) -> str: + def build_prompt(self, data: dict) -> str: """ 构建用户称号分析提示词 @@ -45,7 +45,7 @@ class UserTitleAnalyzer(BaseAnalyzer): Returns: 提示词字符串 """ - user_summaries = user_data.get("user_summaries", []) + user_summaries = data.get("user_summaries", []) if not user_summaries: return "" @@ -91,7 +91,7 @@ class UserTitleAnalyzer(BaseAnalyzer): """ return extract_user_titles_with_regex(result_text, max_count) - def create_data_objects(self, titles_data: list[dict]) -> list[UserTitle]: + def create_data_objects(self, data_list: list[dict]) -> list[UserTitle]: """ 创建用户称号对象列表 @@ -105,7 +105,7 @@ class UserTitleAnalyzer(BaseAnalyzer): titles = [] max_titles = self.get_max_count() - for title_data in titles_data[:max_titles]: + for title_data in data_list[:max_titles]: # 确保数据格式正确 name = title_data.get("name", "").strip() user_id = title_data.get("user_id") @@ -142,7 +142,10 @@ class UserTitleAnalyzer(BaseAnalyzer): return [] def prepare_user_data( - self, messages: list[dict], user_analysis: dict, top_users: list[dict] = None + self, + messages: list[dict], + user_analysis: dict, + top_users: list[dict] | None = None, ) -> dict: """ 准备用户数据 @@ -235,10 +238,10 @@ class UserTitleAnalyzer(BaseAnalyzer): async def analyze_user_titles( self, messages: list[dict], - user_analysis: dict, - umo: str = None, - top_users: list[dict] = None, - session_id: str = None, + user_activity: dict, + umo: str | None = None, + top_users: list[dict] | None = None, + session_id: str | None = None, ) -> tuple[list[UserTitle], TokenUsage]: """ 分析用户称号 @@ -255,7 +258,7 @@ class UserTitleAnalyzer(BaseAnalyzer): """ try: # 准备用户数据,传入活跃用户列表 - user_data = self.prepare_user_data(messages, user_analysis, top_users) + user_data = self.prepare_user_data(messages, user_activity, top_users) if not user_data["user_summaries"]: logger.info("没有符合条件的用户,返回空结果") diff --git a/src/infrastructure/analysis/llm_analyzer.py b/src/infrastructure/analysis/llm_analyzer.py index f3fd4c6..1f39f7b 100644 --- a/src/infrastructure/analysis/llm_analyzer.py +++ b/src/infrastructure/analysis/llm_analyzer.py @@ -11,6 +11,7 @@ from ...domain.models.data_models import ( TokenUsage, UserTitle, ) +from ...domain.repositories.analysis_repository import IAnalysisProvider from ...utils.logger import logger from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer from .analyzers.topic_analyzer import TopicAnalyzer @@ -19,13 +20,17 @@ from .utils.json_utils import fix_json from .utils.llm_utils import call_provider_with_retry -class LLMAnalyzer: +class LLMAnalyzer(IAnalysisProvider): """ LLM分析器 作为统一入口,协调各个专门的分析器进行不同类型的分析 保持向后兼容性,提供原有的接口 """ + topic_analyzer: TopicAnalyzer + user_title_analyzer: UserTitleAnalyzer + golden_quote_analyzer: GoldenQuoteAnalyzer + def __init__(self, context, config_manager): """ 初始化LLM分析器 @@ -43,7 +48,10 @@ class LLMAnalyzer: self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager) async def analyze_topics( - self, messages: list[dict], umo: str = None, session_id: str = None + self, + messages: list[dict], + umo: str | None = None, + session_id: str | None = None, ) -> tuple[list[SummaryTopic], TokenUsage]: """ 使用LLM分析话题 @@ -78,10 +86,10 @@ class LLMAnalyzer: async def analyze_user_titles( self, messages: list[dict], - user_analysis: dict, - umo: str = None, - top_users: list[dict] = None, - session_id: str = None, + user_activity: dict, + umo: str | None = None, + top_users: list[dict] | None = None, + session_id: str | None = None, ) -> tuple[list[UserTitle], TokenUsage]: """ 使用LLM分析用户称号 @@ -89,7 +97,7 @@ class LLMAnalyzer: Args: messages: 群聊消息列表 - user_analysis: 用户分析统计 + user_activity: 用户分析统计 umo: 模型唯一标识符 top_users: 活跃用户列表(可选) session_id: 会话ID (用于调试模式) @@ -110,14 +118,17 @@ class LLMAnalyzer: logger.info(f"开始用户称号分析, session_id: {session_id}") return await self.user_title_analyzer.analyze_user_titles( - messages, user_analysis, umo, top_users, session_id + messages, user_activity, umo, top_users, session_id ) except Exception as e: logger.error(f"用户称号分析失败: {e}") return [], TokenUsage() async def analyze_golden_quotes( - self, messages: list[dict], umo: str = None, session_id: str = None + self, + messages: list[dict], + umo: str | None = None, + session_id: str | None = None, ) -> tuple[list[GoldenQuote], TokenUsage]: """ 使用LLM分析群聊金句 @@ -153,9 +164,9 @@ class LLMAnalyzer: async def analyze_all_concurrent( self, messages: list[dict], - user_analysis: dict, - umo: str = None, - top_users: list[dict] = None, + user_activity: dict, + umo: str | None = None, + top_users: list[dict] | None = None, topic_enabled: bool = True, user_title_enabled: bool = True, golden_quote_enabled: bool = True, @@ -165,7 +176,7 @@ class LLMAnalyzer: Args: messages: 群聊消息列表 - user_analysis: 用户分析统计 + user_activity: 用户分析统计 umo: 模型唯一标识符 top_users: 活跃用户列表(可选) topic_enabled: 是否启用话题分析 @@ -206,7 +217,7 @@ class LLMAnalyzer: if user_title_enabled: tasks.append( self.user_title_analyzer.analyze_user_titles( - messages, user_analysis, umo, top_users, session_id + messages, user_activity, umo, top_users, session_id ) ) task_names.append("user_title") @@ -235,11 +246,11 @@ class LLMAnalyzer: logger.error(f"分析任务 {name} 失败: {result}") continue - if name == "topic": + if name == "topic" and isinstance(result, tuple): topics, topic_usage = result - elif name == "user_title": + elif name == "user_title" and isinstance(result, tuple): user_titles, title_usage = result - elif name == "golden_quote": + elif name == "golden_quote" and isinstance(result, tuple): golden_quotes, quote_usage = result # 合并Token使用统计 @@ -267,7 +278,7 @@ class LLMAnalyzer: async def analyze_incremental_concurrent( self, messages: list[dict], - umo: str = None, + umo: str | None = None, topics_per_batch: int = 3, quotes_per_batch: int = 3, topic_enabled: bool = True, @@ -346,9 +357,9 @@ class LLMAnalyzer: logger.error(f"增量{name}分析失败: {result}") continue - if name == "topic": + if name == "topic" and isinstance(result, tuple): topics, topic_usage = result - elif name == "golden_quote": + elif name == "golden_quote" and isinstance(result, tuple): golden_quotes, quote_usage = result # 合并Token使用统计 @@ -411,8 +422,8 @@ class LLMAnalyzer: prompt: str, max_tokens: int, temperature: float, - umo: str = None, - provider_id_key: str = None, + umo: str | None = None, + provider_id_key: str | None = None, ): """ 向后兼容的LLM调用方法 diff --git a/src/infrastructure/analysis/utils/llm_utils.py b/src/infrastructure/analysis/utils/llm_utils.py index 93b3ed0..92c7d59 100644 --- a/src/infrastructure/analysis/utils/llm_utils.py +++ b/src/infrastructure/analysis/utils/llm_utils.py @@ -48,7 +48,7 @@ async def _try_get_provider_id_by_id( return None -async def _try_get_session_provider_id(context, umo: str) -> str | None: +async def _try_get_session_provider_id(context, umo: str | None) -> str | None: """ 尝试获取会话 Provider ID 的辅助函数 @@ -97,7 +97,10 @@ async def _try_get_first_available_provider_id(context) -> str | None: async def get_provider_id_with_fallback( - context, config_manager, provider_id_key: str, umo: str = None + context, + config_manager, + provider_id_key: str | None, + umo: str | None = None, ) -> str | None: """ 根据配置键获取 Provider ID,支持多级回退 @@ -183,8 +186,8 @@ async def call_provider_with_retry( prompt: str, max_tokens: int, temperature: float, - umo: str = None, - provider_id_key: str = None, + umo: str | None = None, + provider_id_key: str | None = None, ) -> Any | None: """ 调用LLM提供者,带超时、重试与退避。支持自定义服务商和配置化 Provider 选择。 diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 2afc39b..20e4c3b 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -4,6 +4,7 @@ """ import sys +from pathlib import Path from astrbot.api import AstrBotConfig, logger from astrbot.core.utils.astrbot_path import get_astrbot_data_path @@ -217,7 +218,7 @@ class ConfigManager: """获取PDF输出目录""" try: plugin_name = "astrbot_plugin_qq_group_daily_analysis" - data_path = get_astrbot_data_path() + data_path = Path(get_astrbot_data_path()) default_path = data_path / "plugin_data" / plugin_name / "reports" return self._get_group("pdf").get("pdf_output_dir", str(default_path)) except Exception: @@ -398,6 +399,26 @@ class ConfigManager: """获取是否使用用户群名片""" return self._get_group("basic").get("enable_user_card", False) + # ========== 群文件/群相册上传配置 ========== + + def get_enable_group_file_upload(self) -> bool: + """获取是否启用群文件上传""" + return self._get_group("qq_group_upload").get("enable_group_file_upload", False) + + def get_group_file_folder(self) -> str: + """获取群文件上传目录名,空字符串表示根目录""" + return self._get_group("qq_group_upload").get("group_file_folder", "") + + def get_enable_group_album_upload(self) -> bool: + """获取是否启用群相册上传(仅 NapCat)""" + return self._get_group("qq_group_upload").get( + "enable_group_album_upload", False + ) + + def get_group_album_name(self) -> str: + """获取目标群相册名称,空字符串表示默认相册""" + return self._get_group("qq_group_upload").get("group_album_name", "") + # ========== 增量分析配置 ========== def get_incremental_enabled(self) -> bool: diff --git a/src/infrastructure/platform/adapters/onebot_adapter.py b/src/infrastructure/platform/adapters/onebot_adapter.py index f6f4020..04cc4a9 100644 --- a/src/infrastructure/platform/adapters/onebot_adapter.py +++ b/src/infrastructure/platform/adapters/onebot_adapter.py @@ -604,3 +604,260 @@ class OneBotAdapter(PlatformAdapter): user_id: await self.get_user_avatar_url(user_id, size) for user_id in user_ids } + + # ================================================================ + # 群文件 / 群相册上传 + # ================================================================ + + async def upload_group_file_to_folder( + self, + group_id: str, + file_path: str, + filename: str | None = None, + folder_id: str | None = None, + ) -> bool: + """ + 上传文件到群文件目录的指定子文件夹。 + + Args: + group_id: 目标群号 + file_path: 本地文件绝对路径 + filename: 显示的文件名,默认为路径尾部 + folder_id: 目标文件夹 ID(由 get_group_file_root_folders 获取)。 + 为 None 或空字符串时上传到根目录。 + + Returns: + bool: 上传任务是否成功启动 + """ + try: + params = { + "group_id": int(group_id), + "file": file_path, + "name": filename or file_path.replace("\\", "/").split("/")[-1], + } + if folder_id: + params["folder"] = folder_id + + await self.bot.call_action("upload_group_file", **params) + logger.info( + f"OneBot 群文件上传成功: {params['name']} -> 群 {group_id}" + + (f" (目录: {folder_id})" if folder_id else " (根目录)") + ) + return True + except Exception as e: + logger.error(f"OneBot 群文件上传失败: {e}") + return False + + async def create_group_file_folder( + self, + group_id: str, + folder_name: str, + ) -> str | None: + """ + 在群文件根目录下创建子文件夹。 + + Args: + group_id: 目标群号 + folder_name: 文件夹名称 + + Returns: + str | None: 创建成功时返回 folder_id,失败返回 None + """ + try: + result = await self.bot.call_action( + "create_group_file_folder", + group_id=int(group_id), + name=folder_name, + parent_id="/", + ) + # go-cqhttp 等实现可能不返回 folder_id + folder_id = None + if isinstance(result, dict): + folder_id = result.get("folder_id") or result.get("id") + logger.info( + f"OneBot 群文件夹创建成功: {folder_name} (群 {group_id})" + + (f" [ID: {folder_id}]" if folder_id else "") + ) + return folder_id + except Exception as e: + error_msg = str(e).lower() + # 文件夹已存在的情况不视为错误 + if "exist" in error_msg or "已存在" in error_msg: + logger.info(f"OneBot 群文件夹已存在: {folder_name} (群 {group_id})") + return None # 需要通过 get_group_file_root_folders 获取 ID + logger.error(f"OneBot 群文件夹创建失败: {e}") + return None + + async def get_group_file_root_folders( + self, + group_id: str, + ) -> list[dict]: + """ + 获取群文件根目录下的文件夹列表。 + + Args: + group_id: 目标群号 + + Returns: + list[dict]: 文件夹列表,每项包含 folder_id/name 等字段。 + API 不可用时返回空列表。 + """ + try: + result = await self.bot.call_action( + "get_group_root_files", + group_id=int(group_id), + ) + if isinstance(result, dict): + return result.get("folders", []) or [] + return [] + except Exception as e: + logger.debug(f"OneBot 获取群文件夹列表失败: {e}") + return [] + + async def find_or_create_folder( + self, + group_id: str, + folder_name: str, + ) -> str | None: + """ + 查找或创建指定名称的群文件子文件夹,返回 folder_id。 + + 先尝试在现有根目录文件夹中查找匹配名称的文件夹, + 找不到则创建新文件夹。 + + Args: + group_id: 目标群号 + folder_name: 文件夹名称 + + Returns: + str | None: folder_id(成功时)或 None(失败时) + """ + if not folder_name: + return None + + # 1. 先尝试查找已有文件夹 + folders = await self.get_group_file_root_folders(group_id) + for folder in folders: + name = folder.get("folder_name") or folder.get("name", "") + fid = folder.get("folder_id") or folder.get("id", "") + if name == folder_name and fid: + logger.debug(f"找到已有群文件夹: {folder_name} [ID: {fid}]") + return fid + + # 2. 未找到,尝试创建 + created_id = await self.create_group_file_folder(group_id, folder_name) + if created_id: + return created_id + + # 3. 创建后再次查找(某些实现创建时不返回 ID) + folders = await self.get_group_file_root_folders(group_id) + for folder in folders: + name = folder.get("folder_name") or folder.get("name", "") + fid = folder.get("folder_id") or folder.get("id", "") + if name == folder_name and fid: + logger.debug(f"创建后找到群文件夹: {folder_name} [ID: {fid}]") + return fid + + logger.warning( + f"无法获取群文件夹 ID: {folder_name} (群 {group_id}),将上传到根目录" + ) + return None + + async def upload_group_album( + self, + group_id: str, + image_path: str, + album_id: str | None = None, + ) -> bool: + """ + 上传图片到群相册(NapCat 扩展 API)。 + + 注意:此 API 仅 NapCat 支持,go-cqhttp / Lagrange 等不支持。 + 调用失败时会静默降级,不影响正常发送。 + + Args: + group_id: 目标群号 + image_path: 本地图片文件的绝对路径 + album_id: 目标相册 ID,为 None 时上传到默认相册 + + Returns: + bool: 上传是否成功 + """ + try: + params = { + "group_id": int(group_id), + "file": image_path, + } + if album_id: + params["album_id"] = album_id + + await self.bot.call_action("upload_group_album", **params) + logger.info(f"NapCat 群相册上传成功: 群 {group_id}") + return True + except Exception as e: + error_msg = str(e).lower() + if "not found" in error_msg or "not support" in error_msg: + logger.warning( + "当前 OneBot 实现不支持群相册上传 API (upload_group_album)," + "此功能仅 NapCat 可用。" + ) + else: + logger.warning(f"NapCat 群相册上传失败: {e}") + return False + + async def get_group_album_list( + self, + group_id: str, + ) -> list[dict]: + """ + 获取群相册列表(NapCat 扩展 API)。 + + Args: + group_id: 目标群号 + + Returns: + list[dict]: 相册列表,每项可能包含 album_id / name 等字段。 + API 不可用时返回空列表。 + """ + try: + result = await self.bot.call_action( + "get_group_album_list", + group_id=int(group_id), + ) + if isinstance(result, list): + return result + if isinstance(result, dict): + return result.get("albums", []) or result.get("data", []) or [] + return [] + except Exception as e: + logger.debug(f"获取群相册列表失败: {e}") + return [] + + async def find_album_id( + self, + group_id: str, + album_name: str, + ) -> str | None: + """ + 根据相册名称查找 album_id。找不到返回 None(将回退到默认相册)。 + + Args: + group_id: 目标群号 + album_name: 目标相册名称 + + Returns: + str | None: 匹配的 album_id,未找到返回 None + """ + if not album_name: + return None + + albums = await self.get_group_album_list(group_id) + for album in albums: + name = album.get("name") or album.get("album_name", "") + aid = album.get("album_id") or album.get("id", "") + if name == album_name and aid: + logger.debug(f"找到群相册: {album_name} [ID: {aid}]") + return str(aid) + + logger.info(f"未找到群相册 '{album_name}' (群 {group_id}),将使用默认相册") + return None diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index 1be4294..174360b 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -1,4 +1,8 @@ +import base64 +import os +import tempfile from collections.abc import Callable +from datetime import datetime from typing import Any from ...utils.logger import logger @@ -94,22 +98,17 @@ class ReportDispatcher: group_id, image_url, "📊 每日群聊分析报告已生成:", platform_id ) if sent: + # 4. 发送成功后,尝试上传到群文件/群相册(静默处理) + await self._try_upload_image(group_id, image_url, platform_id) return True - # 4. 发送失败或生成失败的处理 -> 加入重试队列 + # 5. 发送失败或生成失败的处理 -> 加入重试队列 if html_content: logger.warning( f"[{trace_id}] Image dispatch failed, adding to retry queue..." ) # 尝试获取 platform_id 如果没有提供 if not platform_id: - # 这里假设 MessageSender 能帮忙或者我们需要自己查 - # 由于 Dispatcher 不直接持有 BotManager (除了通过 MessageSender 间接持有) - # 原有逻辑:AutoScheduler 调用 get_platform_id_for_group - # 我们这里暂时依赖传入的 platform_id,如果没有,RetryManager 可能处理不了? - # 实际上 RetryManager 需要 platform_id。 - # 让我们尝试通过 MessageSender 的 bot_manager 获取一个 - # 或者更简单:如果 platform_id 为空,我们尝试获取第一个可用的 (MessageSender._get_available_platforms Logic) platforms = self.message_sender._get_available_platforms(group_id) if platforms: platform_id = platforms[0][0] # use first available @@ -124,7 +123,7 @@ class ReportDispatcher: f"[{trace_id}] Cannot add to retry queue: No platform_id available." ) - # 5. 最终回退:文本报告 + # 6. 最终回退:文本报告 logger.warning(f"[{trace_id}] Falling back to text report.") return await self._dispatch_text(group_id, analysis_result, platform_id) @@ -173,3 +172,113 @@ class ReportDispatcher: except Exception as e: logger.error(f"[{TraceContext.get()}] Failed to dispatch text report: {e}") return False + + # ================================================================ + # 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式) + # ================================================================ + + async def _try_upload_image( + self, + group_id: str, + image_url: str, + platform_id: str | None, + ): + """ + 尝试将图片报告上传到群文件和/或群相册。 + + 仅在配置启用且平台为 OneBot 时执行,失败静默处理。 + """ + enable_file = self.config_manager.get_enable_group_file_upload() + enable_album = self.config_manager.get_enable_group_album_upload() + if not enable_file and not enable_album: + return + + # 仅 OneBot 平台支持 + adapter = self._get_onebot_adapter(platform_id) + if not adapter: + return + + # 将图片保存为临时文件 + image_file = self._save_image_to_temp(image_url, group_id) + if not image_file: + return + + try: + # 上传到群文件 + if enable_file: + await self._do_upload_group_file(adapter, group_id, image_file) + + # 上传到群相册 + if enable_album: + await self._do_upload_group_album(adapter, group_id, image_file) + finally: + try: + os.remove(image_file) + except OSError: + pass + + async def _do_upload_group_file(self, adapter, group_id: str, file_path: str): + """上传文件到群文件目录,失败静默""" + try: + folder_name = self.config_manager.get_group_file_folder() + folder_id = None + if folder_name: + folder_id = await adapter.find_or_create_folder(group_id, folder_name) + await adapter.upload_group_file_to_folder( + group_id=group_id, + file_path=file_path, + folder_id=folder_id, + ) + except Exception as e: + logger.warning(f"群文件上传失败 (群 {group_id}): {e}") + + async def _do_upload_group_album(self, adapter, group_id: str, file_path: str): + """上传图片到群相册,失败静默""" + try: + album_name = self.config_manager.get_group_album_name() + album_id = None + if album_name and hasattr(adapter, "find_album_id"): + album_id = await adapter.find_album_id(group_id, album_name) + await adapter.upload_group_album(group_id, file_path, album_id=album_id) + except Exception as e: + logger.warning(f"群相册上传失败 (群 {group_id}): {e}") + + def _save_image_to_temp(self, image_url: str, group_id: str) -> str | None: + """将 base64 图片保存为临时 PNG 文件,返回路径。失败返回 None。""" + try: + image_data = None + if image_url.startswith("base64://"): + image_data = base64.b64decode(image_url[len("base64://") :]) + elif image_url.startswith("data:"): + parts = image_url.split(",", 1) + if len(parts) == 2: + image_data = base64.b64decode(parts[1]) + elif os.path.isfile(image_url): + return os.path.abspath(image_url) + elif image_url.startswith("file:///"): + p = image_url[len("file:///") :] + if os.path.isfile(p): + return os.path.abspath(p) + + if not image_data: + return None + + date_str = datetime.now().strftime("%Y%m%d_%H%M%S") + path = os.path.join( + tempfile.gettempdir(), f"群聊分析报告_{group_id}_{date_str}.png" + ) + with open(path, "wb") as f: + f.write(image_data) + return path + except Exception as e: + logger.debug(f"保存图片到临时文件失败: {e}") + return None + + def _get_onebot_adapter(self, platform_id: str | None): + """获取 OneBot 适配器,非 OneBot 平台返回 None。""" + if not platform_id: + return None + adapter = self.message_sender.bot_manager.get_adapter(platform_id) + if adapter and hasattr(adapter, "upload_group_file_to_folder"): + return adapter + return None diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index 2cd3f4a..07cc1d7 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -149,7 +149,11 @@ class ReportGenerator(IReportGenerator): return None, html_content async def generate_pdf_report( - self, analysis_result: dict, group_id: str, avatar_getter=None + self, + analysis_result: dict, + group_id: str, + avatar_getter=None, + nickname_getter=None, ) -> str | None: """生成PDF格式的分析报告""" try: @@ -169,6 +173,7 @@ class ReportGenerator(IReportGenerator): analysis_result, chart_template="activity_chart_pdf.html", avatar_getter=avatar_getter, + nickname_getter=nickname_getter, ) logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段") @@ -357,7 +362,7 @@ class ReportGenerator(IReportGenerator): detail: str, avatar_getter, nickname_getter=None, - user_analysis: dict = None, + user_analysis: dict | None = None, ) -> str: """ 处理话题详情,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式 @@ -537,7 +542,9 @@ class ReportGenerator(IReportGenerator): safe_avatar_url = self._safe_url_for_log(avatar_url) async with aiohttp.ClientSession() as client: try: - async with client.get(avatar_url, timeout=5) as response: + async with client.get( + avatar_url, timeout=aiohttp.ClientTimeout(total=5) + ) as response: if response.status == 200: content = await response.read() if content: @@ -607,7 +614,7 @@ class ReportGenerator(IReportGenerator): try: # 动态导入 playwright try: - from playwright.async_api import async_playwright + from playwright.async_api import async_playwright # type: ignore except ImportError: logger.error("playwright 未安装,无法生成 PDF") logger.info("💡 请尝试运行: pip install playwright") diff --git a/src/utils/logger.py b/src/utils/logger.py index c31d761..76098c6 100644 --- a/src/utils/logger.py +++ b/src/utils/logger.py @@ -5,12 +5,12 @@ class PluginLogger: """ 日志代理类:插件级统一日志装饰器 - 自动向所有通过该实例输出的日志信息前缀添加 `[QQ群分析]` 标签, + 自动向所有通过该实例输出的日志信息前缀添加 `[群分析插件]` 标签, 以便用户在 AstrBot 混合日志流中快速定位属于本插件的输出。 不直接继承 logging.LoggerAdapter 以符合框架规范。 """ - def __init__(self, prefix: str = "[QQ群分析]"): + def __init__(self, prefix: str = "[群分析插件]"): self.prefix = prefix def _format_msg(self, msg: str) -> str: