diff --git a/_conf_schema.json b/_conf_schema.json index faaa9b4..447f627 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -58,7 +58,7 @@ "type": "list", "description": "群分析时屏蔽的用户ID列表", "default": [], - "hint": "填写后可启用自动分析功能。可以填写用于自动分析的机器人ID、多消息平台ID、不希望出现于群分析中的其他人的机器人ID等,这种群聊中出现但是不希望分析的ID。", + "hint": "填写后可启用自动分析功能。可以填写用于自动分析的机器人 ID(在不同平台上可能是数字或字符串)、多消息平台 ID、不希望出现于群分析中的其他人的 ID 等。", "items": { "type": "string" } diff --git a/main.py b/main.py index 21fe662..0ab834f 100644 --- a/main.py +++ b/main.py @@ -129,7 +129,7 @@ class QQGroupDailyAnalysis(Star): orchestrator = AnalysisOrchestrator.create_for_platform( platform_name, bot_instance, - config={"bot_qq_ids": self.config_manager.get_bot_qq_ids()}, + config={"bot_self_ids": self.config_manager.get_bot_self_ids()}, analysis_config=analysis_config, ) diff --git a/src/analysis/analyzers/topic_analyzer.py b/src/analysis/analyzers/topic_analyzer.py index 3fd31ce..17952c4 100644 --- a/src/analysis/analyzers/topic_analyzer.py +++ b/src/analysis/analyzers/topic_analyzer.py @@ -101,11 +101,7 @@ class TopicAnalyzer(BaseAnalyzer): # 处理 @ 消息,转换为文本 at_data = content.get("data", {}) # 兼容不同平台的 ID 字段 - at_id = ( - at_data.get("qq") - or at_data.get("id") - or at_data.get("user_id") - ) + at_id = at_data.get("id") or at_data.get("user_id") if at_id: at_text = f"@{at_id}" text_parts.append(at_text) diff --git a/src/analysis/analyzers/user_title_analyzer.py b/src/analysis/analyzers/user_title_analyzer.py index d347438..751f2c3 100644 --- a/src/analysis/analyzers/user_title_analyzer.py +++ b/src/analysis/analyzers/user_title_analyzer.py @@ -108,8 +108,7 @@ class UserTitleAnalyzer(BaseAnalyzer): for title_data in titles_data[:max_titles]: # 确保数据格式正确 name = title_data.get("name", "").strip() - # 兼容 LLM 返回 qq 或 user_id 的情况 - user_id = title_data.get("user_id") or title_data.get("qq") + user_id = title_data.get("user_id") title = title_data.get("title", "").strip() mbti = title_data.get("mbti", "").strip() reason = title_data.get("reason", "").strip() @@ -123,7 +122,7 @@ class UserTitleAnalyzer(BaseAnalyzer): if user_id is not None: user_id = str(user_id) else: - logger.warning(f"未找到用户ID (user_id/qq),跳过: {title_data}") + logger.warning(f"未找到用户ID (user_id),跳过: {title_data}") continue titles.append( @@ -157,8 +156,8 @@ class UserTitleAnalyzer(BaseAnalyzer): 准备好的用户数据字典 """ try: - # 获取机器人QQ号列表用于过滤 - bot_qq_ids = self.config_manager.get_bot_qq_ids() + # 获取机器人 ID 列表用于过滤 + bot_self_ids = self.config_manager.get_bot_self_ids() user_summaries = [] @@ -180,7 +179,7 @@ class UserTitleAnalyzer(BaseAnalyzer): for user_id, stats in user_analysis.items(): user_id_str = str(user_id) # 过滤机器人自己的消息 - if bot_qq_ids and user_id_str in [str(qq) for qq in bot_qq_ids]: + if bot_self_ids and user_id_str in [str(uid) for uid in bot_self_ids]: logger.debug(f"过滤掉机器人ID: {user_id}") continue diff --git a/src/analysis/statistics.py b/src/analysis/statistics.py index 240da46..27a42a4 100644 --- a/src/analysis/statistics.py +++ b/src/analysis/statistics.py @@ -26,8 +26,8 @@ class UserAnalyzer: def analyze_users(self, messages: list[dict]) -> dict[str, dict]: """分析用户活跃度""" - # 获取机器人QQ号列表用于过滤 - bot_qq_ids = self.config_manager.get_bot_self_ids() + # 获取机器人 ID 列表用于过滤 + bot_self_ids = self.config_manager.get_bot_self_ids() user_stats = defaultdict( lambda: { @@ -45,7 +45,7 @@ class UserAnalyzer: user_id = str(sender.get("user_id", "")) # 跳过机器人自己的消息,避免进入统计 - if bot_qq_ids and user_id in [str(qq) for qq in bot_qq_ids]: + if bot_self_ids and user_id in [str(sid) for sid in bot_self_ids]: continue nickname = InfoUtils.get_user_nickname(self.config_manager, sender) @@ -72,7 +72,7 @@ class UserAnalyzer: user_stats[user_id]["emoji_count"] += len(unicode_emojis) elif content.get("type") == "face": - # QQ基础表情 + # 基础表情 user_stats[user_id]["emoji_count"] += 1 elif content.get("type") == "mface": # 动画表情/魔法表情 @@ -99,13 +99,13 @@ class UserAnalyzer: self, user_analysis: dict[str, dict], limit: int = 10 ) -> list[dict]: """获取最活跃的用户""" - # 获取机器人QQ号列表用于过滤 - bot_qq_ids = self.config_manager.get_bot_self_ids() + # 获取机器人 ID 列表用于过滤 + bot_self_ids = self.config_manager.get_bot_self_ids() users = [] for user_id, stats in user_analysis.items(): # 过滤机器人自己 - if bot_qq_ids and str(user_id) in [str(qq) for qq in bot_qq_ids]: + if bot_self_ids and str(user_id) in [str(sid) for sid in bot_self_ids]: continue users.append( diff --git a/src/analysis/utils/json_utils.py b/src/analysis/utils/json_utils.py index fe420c1..3bb7272 100644 --- a/src/analysis/utils/json_utils.py +++ b/src/analysis/utils/json_utils.py @@ -203,17 +203,17 @@ def extract_user_titles_with_regex(result_text: str, max_count: int) -> list[dic titles = [] # 正则模式:匹配完整的用户称号对象 - pattern = r'\{\s*"name":\s*"([^"]+)"\s*,\s*"qq":\s*(\d+)\s*,\s*"title":\s*"([^"]+)"\s*,\s*"mbti":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}' + pattern = r'\{\s*"name":\s*"([^"]+)"\s*,\s*"user_id":\s*"([^"]+)"\s*,\s*"title":\s*"([^"]+)"\s*,\s*"mbti":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}' matches = re.findall(pattern, result_text, re.DOTALL) if not matches: # 尝试更宽松的匹配(字段顺序可变) - pattern = r'"name":\s*"([^"]+)"[^}]*"qq":\s*(\d+)[^}]*"title":\s*"([^"]+)"[^}]*"mbti":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"' + pattern = r'"name":\s*"([^"]+)"[^}]*"user_id":\s*"([^"]+)"[^}]*"title":\s*"([^"]+)"[^}]*"mbti":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"' matches = re.findall(pattern, result_text, re.DOTALL) for match in matches[:max_count]: name = match[0].strip() - qq = int(match[1]) + user_id = match[1].strip() title = match[2].strip() mbti = match[3].strip() reason = match[4].strip() @@ -222,7 +222,13 @@ def extract_user_titles_with_regex(result_text: str, max_count: int) -> list[dic reason = reason.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ") titles.append( - {"name": name, "qq": qq, "title": title, "mbti": mbti, "reason": reason} + { + "name": name, + "user_id": user_id, + "title": title, + "mbti": mbti, + "reason": reason, + } ) logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号") diff --git a/src/core/bot_manager.py b/src/core/bot_manager.py index 0c396b7..bc93119 100644 --- a/src/core/bot_manager.py +++ b/src/core/bot_manager.py @@ -52,7 +52,6 @@ class BotManager: if platform_name and PlatformAdapterFactory.is_supported(platform_name): adapter_config = { "bot_self_ids": self._bot_self_ids.copy(), - "bot_qq_ids": self._bot_self_ids.copy(), # 兼容旧适配器 } adapter = PlatformAdapterFactory.create( platform_name, bot_instance, adapter_config @@ -75,10 +74,6 @@ class BotManager: elif bot_self_ids: self._bot_self_ids = [str(bot_self_ids)] - def set_bot_qq_ids(self, bot_qq_ids): - """设置bot QQ号(兼容旧方法,建议使用 set_bot_self_ids)""" - self.set_bot_self_ids(bot_qq_ids) - def get_bot_instance(self, platform_id=None): """获取指定平台的bot实例,如果不指定则返回第一个可用的实例""" if platform_id: @@ -159,10 +154,6 @@ class BotManager: """检查是否有配置的机器人 ID""" return bool(self._bot_self_ids) - def has_bot_qq_id(self) -> bool: - """检查是否有配置的bot QQ号 (兼容旧方法)""" - return self.has_bot_self_id() - def is_ready_for_auto_analysis(self) -> bool: """检查是否准备好进行自动分析""" return self.has_bot_instance() and self.has_bot_self_id() @@ -392,8 +383,6 @@ class BotManager: return { "has_bot_instance": self.has_bot_instance(), - "has_bot_qq_id": self.has_bot_self_id(), - "bot_qq_ids": self._bot_self_ids, "bot_self_ids": self._bot_self_ids, "platform_count": len(self._bot_instances), "platforms": list(self._bot_instances.keys()), @@ -437,8 +426,6 @@ class BotManager: # 尝试多种方式获取bot ID if hasattr(bot_instance, "self_id") and bot_instance.self_id: return str(bot_instance.self_id) - elif hasattr(bot_instance, "qq") and bot_instance.qq: - return str(bot_instance.qq) elif hasattr(bot_instance, "user_id") and bot_instance.user_id: return str(bot_instance.user_id) # Discord.py style: client.user.id @@ -446,10 +433,6 @@ class BotManager: return str(bot_instance.user.id) return None - def _extract_bot_qq_id(self, bot_instance): - """从bot实例中提取QQ号(兼容旧方法名称)""" - return self._extract_bot_self_id_impl(bot_instance) - def validate_for_message_fetching(self, group_id: str) -> bool: """验证是否可以进行消息获取""" return self.has_bot_instance() and bool(group_id) diff --git a/src/core/config.py b/src/core/config.py index d724fd3..49365e8 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -186,10 +186,6 @@ class ConfigManager: ids = self.config.get("bot_qq_ids", []) return ids - def get_bot_qq_ids(self) -> list: - """获取bot QQ号列表 (已弃用,建议使用 get_bot_self_ids)""" - return self.get_bot_self_ids() - def get_pdf_filename_format(self) -> str: """获取PDF文件名格式""" return self.config.get( diff --git a/src/core/history_manager.py b/src/core/history_manager.py index 598615b..0dfdd4f 100644 --- a/src/core/history_manager.py +++ b/src/core/history_manager.py @@ -11,14 +11,20 @@ from ..utils.logger import logger class HistoryManager: - """历史分析记录管理器""" + """ + 核心组件:历史分析存档管理器 - def __init__(self, star_instance): + 该类负责将每日生成的群消息分析报告摘要持久化存储,并提供查询接口。 + 底层基于 AstrBot 提供的 KV 存储能力(put_kv_data/get_kv_data), + 确保即使在 Bot 重启后也能回溯历史数据。 + """ + + def __init__(self, star_instance: Any): """ - 初始化历史记录管理器 + 初始化历史记录管理器。 Args: - star_instance: Star 插件实例,用于访问 put_kv_data/get_kv_data + star_instance (Any): Star 插件实例,用于访问底层持久化引擎 """ self.plugin = star_instance @@ -30,13 +36,18 @@ class HistoryManager: time_str: str | None = None, ) -> bool: """ - 保存分析结果摘要到历史记录 + 序列化并存储一份分析报告摘要。 + + 摘要包含:发言总量、人数、提取的主题摘要及生成时间,不包含完整的原始消息流。 Args: - group_id: 群组ID - analysis_result: 分析结果对象 - date_str: 日期字符串 (格式: YYYY-MM-DD),如果不提供则使用当前日期 - time_str: 时间字符串 (格式: HH-MM),如果不提供则使用当前时间 + group_id (str): 群组 ID + analysis_result (dict[str, Any]): 包含 statistics, topics, user_titles 的完整分析对象 + date_str (str, optional): 归档日期 (YYYY-MM-DD),缺省为当天 + time_str (str, optional): 归档时间点 (HH-MM),缺省为当前时刻 + + Returns: + bool: 存储是否成功 """ try: now = datetime.datetime.now() @@ -45,10 +56,10 @@ class HistoryManager: if not time_str: time_str = now.strftime("%H-%M") - # 格式化 time_str,确保文件名/Key 安全 (替换 : 为 -) + # 消解非法字符,确保 Key 兼容性 time_str = time_str.replace(":", "-") - # 提取摘要数据 + # 从分析结果中剥离非持久化字段,提取核心统计元数据 stats = analysis_result.get("statistics") topics = analysis_result.get("topics", []) user_titles = analysis_result.get("user_titles", []) @@ -60,7 +71,7 @@ class HistoryManager: else 0, "topics": [{"topic": t.topic, "detail": t.detail} for t in topics], "user_titles_count": len(user_titles), - "generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"), } key = f"analysis_{group_id}_{date_str}_{time_str}" @@ -78,26 +89,32 @@ class HistoryManager: self, group_id: str, date_str: str, time_str: str ) -> dict[str, Any] | None: """ - 获取指定日期、时间点和群组的分析摘要 + 根据群组、日期和时间点检索一份历史摘要。 Args: - group_id: 群组ID - date_str: 日期字符串 (YYYY-MM-DD) - time_str: 时间字符串 (HH-MM) + group_id (str): 群组 ID + date_str (str): 日期 (YYYY-MM-DD) + time_str (str): 时间点 (HH-MM) + + Returns: + dict[str, Any] | None: 历史摘要字典,未找到返回 None """ - # 确保格式统一 + # 对齐存储时的 Key 规范 time_str = time_str.replace(":", "-") key = f"analysis_{group_id}_{date_str}_{time_str}" return await self.plugin.get_kv_data(key, None) async def has_history(self, group_id: str, date_str: str, time_str: str) -> bool: """ - 检查指定日期、时间点和群组是否已有分析记录 + 快速判定是否存在指定时间点的历史分析记录。 Args: - group_id: 群组ID - date_str: 日期字符串 (YYYY-MM-DD) - time_str: 时间字符串 (HH-MM) + group_id (str): 群组 ID + date_str (str): 日期 + time_str (str): 时间点 + + Returns: + bool: 是否存在记录 """ history = await self.get_history(group_id, date_str, time_str) return history is not None diff --git a/src/core/message_handler.py b/src/core/message_handler.py index 9a6d0ed..e91f294 100644 --- a/src/core/message_handler.py +++ b/src/core/message_handler.py @@ -23,16 +23,10 @@ class MessageHandler: """从bot实例中提取ID(单个)""" if hasattr(bot_instance, "self_id") and bot_instance.self_id: return str(bot_instance.self_id) - elif hasattr(bot_instance, "qq") and bot_instance.qq: - return str(bot_instance.qq) elif hasattr(bot_instance, "user_id") and bot_instance.user_id: return str(bot_instance.user_id) return None - def _extract_bot_qq_id_from_instance(self, bot_instance): - """从bot实例中提取QQ号(已弃用)""" - return self._extract_bot_self_id_from_instance(bot_instance) - async def fetch_group_messages( self, bot_instance, group_id: str, days: int, platform_id: str = None ) -> list[dict]: @@ -64,12 +58,12 @@ class MessageHandler: logger.error("未提供 bot_instance 且未找到适配器") return [] - # 确保bot_manager有QQ号列表用于过滤 + # 确保bot_manager有 ID 列表用于过滤 if self.bot_manager and not self.bot_manager.has_bot_self_id(): - # 尝试从bot_instance提取QQ号并设置为列表 - bot_self_id = self._extract_bot_qq_id_from_instance(bot_instance) + # 尝试从bot_instance提取 ID 并设置为列表 + bot_self_id = self._extract_bot_self_id_from_instance(bot_instance) if bot_self_id: - # 将单个QQ号转换为列表,保持统一处理 + # 将单个 ID 转换为列表,保持统一处理 self.bot_manager.set_bot_self_ids([bot_self_id]) # 计算时间范围 @@ -117,9 +111,9 @@ class MessageHandler: ) return [] elif hasattr(bot_instance, "api"): - # QQ 官方 bot (botClient) 不支持历史消息 + # 官方 bot (botClient) 不支持历史消息 logger.error( - f"群 {group_id} 检测到 QQ 官方 Bot,官方 API 不支持获取历史消息" + f"群 {group_id} 检测到官方 Bot,官方 API 不支持获取历史消息" ) return [] else: @@ -211,7 +205,7 @@ class MessageHandler: text = content.get("data", {}).get("text", "") total_chars += len(text) elif content.get("type") == "face": - # QQ基础表情 + # 基础表情 emoji_statistics.face_count += 1 face_id = content.get("data", {}).get("id", "unknown") emoji_statistics.face_details[f"face_{face_id}"] = ( diff --git a/src/core/message_sender.py b/src/core/message_sender.py index a24e0ca..98582c4 100644 --- a/src/core/message_sender.py +++ b/src/core/message_sender.py @@ -24,21 +24,21 @@ class MessageSender: 发送文本消息 """ trace_id = TraceContext.get() - logger.info(f"[{trace_id}] Start sending text to group {group_id}") + logger.info(f"[{trace_id}] 开始发送文本消息到群 {group_id}") platforms = self._get_available_platforms(group_id, platform_id) if not platforms: - logger.error(f"[{trace_id}] No available platforms for group {group_id}") + logger.error(f"[{trace_id}] 群 {group_id} 无可用发送平台") return False for pid, adapter in platforms: try: - logger.info(f"[{trace_id}] Trying platform {pid}...") + logger.info(f"[{trace_id}] 正在尝试平台 {pid}...") # 优先使用 Adapter 接口 if hasattr(adapter, "send_text"): if await adapter.send_text(group_id, text): - logger.info(f"[{trace_id}] Successfully sent text via {pid}") + logger.info(f"[{trace_id}] 成功通过 {pid} 发送文本") return True # Fallback to OneBot API (for backward compatibility or if adapter wrapping failed) @@ -46,14 +46,14 @@ class MessageSender: await adapter.api.call_action( "send_group_msg", group_id=group_id, message=text ) - logger.info(f"[{trace_id}] Successfully sent text via {pid} (API)") + logger.info(f"[{trace_id}] 成功通过 {pid} 发送文本 (API)") return True except Exception as e: self._log_send_error(pid, group_id, "text", e) continue - logger.error(f"[{trace_id}] Failed to send text via all platforms") + logger.error(f"[{trace_id}] 所有平台均发送文本失败") return False async def send_image_url( @@ -73,16 +73,14 @@ class MessageSender: for pid, adapter in platforms: try: - logger.info(f"[{trace_id}] Trying sending image (URL) via {pid}...") + logger.info(f"[{trace_id}] 正在通过 {pid} 发送图片 (URL 模式)...") # 优先使用 Adapter 接口 if hasattr(adapter, "send_image"): if await adapter.send_image( group_id, image_url, caption=text_prefix ): - logger.info( - f"[{trace_id}] Successfully sent image (URL) via {pid}" - ) + logger.info(f"[{trace_id}] 成功通过 {pid} 发送图片 (URL 模式)") return True # Fallback to OneBot API @@ -98,7 +96,7 @@ class MessageSender: "send_group_msg", group_id=group_id, message=message_chain ) logger.info( - f"[{trace_id}] Successfully sent image (URL) via {pid} (API)" + f"[{trace_id}] 成功通过 {pid} 发送图片 (URL 模式) (API)" ) return True except Exception as e: @@ -117,11 +115,11 @@ class MessageSender: 发送图片 (Base64 模式) - 需先下载图片 """ trace_id = TraceContext.get() - logger.info(f"[{trace_id}] Downloading image for Base64 fallback...") + logger.info(f"[{trace_id}] 正在下载图片以进行 Base64 回退发送...") image_bytes = await self._download_image(image_url) if not image_bytes: - logger.error(f"[{trace_id}] Failed to download image for Base64 conversion") + logger.error(f"[{trace_id}] 下载图片进行 Base64 转换失败") return False image_b64 = base64.b64encode(image_bytes).decode() @@ -134,7 +132,7 @@ class MessageSender: for pid, adapter in platforms: try: - logger.info(f"[{trace_id}] Trying sending image (Base64) via {pid}...") + logger.info(f"[{trace_id}] 正在通过 {pid} 发送图片 (Base64 模式)...") # 优先使用 Adapter 接口 (注意 Adapter 接口通常接受 path/url,这里我们传 base64 uri 它是支持的吗?) # 大多数 Adapter 的 send_image 如果识别 base64:// 应该能处理 @@ -148,7 +146,7 @@ class MessageSender: group_id, base64_uri, caption=text_prefix ): logger.info( - f"[{trace_id}] Successfully sent image (Base64) via {pid}" + f"[{trace_id}] 成功通过 {pid} 发送图片 (Base64 模式)" ) return True @@ -167,7 +165,7 @@ class MessageSender: "send_group_msg", group_id=group_id, message=message_chain ) logger.info( - f"[{trace_id}] Successfully sent image (Base64) via {pid} (API)" + f"[{trace_id}] 成功通过 {pid} 发送图片 (Base64 模式) (API)" ) return True except Exception as e: @@ -189,7 +187,7 @@ class MessageSender: return True logger.warning( - f"[{TraceContext.get()}] URL send failed, falling back to Base64..." + f"[{TraceContext.get()}] URL 发送失败,正在回退至 Base64 模式..." ) return await self.send_image_base64( group_id, image_url, text_prefix, platform_id @@ -212,11 +210,11 @@ class MessageSender: for pid, adapter in platforms: try: - logger.info(f"[{trace_id}] Trying sending PDF via {pid}...") + logger.info(f"[{trace_id}] 正在通过 {pid} 发送 PDF...") if hasattr(adapter, "send_file"): if await adapter.send_file(group_id, pdf_path): - logger.info(f"[{trace_id}] Successfully sent PDF via {pid}") + logger.info(f"[{trace_id}] 成功通过 {pid} 发送 PDF") return True # Fallback to OneBot API @@ -231,7 +229,7 @@ class MessageSender: await adapter.api.call_action( "send_group_msg", group_id=group_id, message=message_chain ) - logger.info(f"[{trace_id}] Successfully sent PDF via {pid} (API)") + logger.info(f"[{trace_id}] 成功通过 {pid} 发送 PDF (API)") return True except Exception as e: @@ -255,7 +253,7 @@ class MessageSender: if bot: instances.append((specific_platform_id, bot)) else: - logger.warning(f"Specified platform {specific_platform_id} not found") + logger.warning(f"找不到指定的平台 {specific_platform_id}") else: # 获取所有已发现的平台 all_instances = self.bot_manager.get_all_bot_instances() @@ -291,7 +289,7 @@ class MessageSender: # Fallback: return raw bot adapters.append((pid, bot)) except Exception as e: - logger.warning(f"Failed to create adapter for {pid}: {e}") + logger.warning(f"为 {pid} 创建适配器失败: {e}") adapters.append((pid, bot)) return adapters @@ -306,7 +304,7 @@ class MessageSender: return None return await resp.read() except Exception as e: - logger.error(f"Image download failed: {e}") + logger.error(f"图片下载失败: {e}") return None def _log_send_error( @@ -314,5 +312,5 @@ class MessageSender: ): """统一错误日志""" logger.debug( - f"[{TraceContext.get()}] Failed to send {msg_type} via {platform_id} to {group_id}: {error}" + f"[{TraceContext.get()}] 通过 {platform_id} 向 {group_id} 发送 {msg_type} 失败: {error}" ) diff --git a/src/domain/services/golden_quote_analyzer.py b/src/domain/services/golden_quote_analyzer.py index e0b3c3b..6cd9a5f 100644 --- a/src/domain/services/golden_quote_analyzer.py +++ b/src/domain/services/golden_quote_analyzer.py @@ -95,7 +95,9 @@ class GoldenQuoteAnalyzerAdapter(IGoldenQuoteAnalyzer): GoldenQuote( content=q.content, sender_name=q.sender, - sender_id=str(q.qq) if hasattr(q, "qq") and q.qq else None, + sender_id=str(q.user_id) + if hasattr(q, "user_id") and q.user_id + else None, reason=q.reason, ) for q in legacy_quotes diff --git a/src/domain/services/report_generator.py b/src/domain/services/report_generator.py index 1d42b27..49415f0 100644 --- a/src/domain/services/report_generator.py +++ b/src/domain/services/report_generator.py @@ -15,19 +15,19 @@ from ..value_objects.user_title import UserTitle class ReportGenerator: """ - 生成分析报告的领域服务。 + 领域服务:报告生成器 - 该服务接收分析结果并生成格式化的 - 文本报告,可发送到任何平台。 + 负责将抽象的统计数据、话题和金句转换为人类可读的格式化报告。 + 该类是平台无关的,主要生成 Markdown 风格的文本。 """ def __init__(self, group_name: str = "", date_str: str = ""): """ 初始化报告生成器。 - 参数: - group_name: 报告标题中的群组名称 - date_str: 报告的日期字符串 + Args: + group_name (str): 报告所属的群组名称 + date_str (str, optional): 报告日期 (YYYY-MM-DD),默认为今日 """ self.group_name = group_name self.date_str = date_str or datetime.now().strftime("%Y-%m-%d") @@ -42,18 +42,18 @@ class ReportGenerator: include_footer: bool = True, ) -> str: """ - 生成完整的分析报告。 + 生成完整的群聊分析报告。 - 参数: - statistics: 群聊统计 - topics: 讨论话题列表 - user_titles: 用户称号/徽章列表 - golden_quotes: 金句列表 - include_header: 是否包含报告头部 - include_footer: 是否包含报告尾部 + Args: + statistics (GroupStatistics): 基础统计数据 + topics (list[Topic]): 讨论话题列表 + user_titles (list[UserTitle]): 用户称号列表 + golden_quotes (list[GoldenQuote]): 精彩金句列表 + include_header (bool): 是否包含页眉 + include_footer (bool): 是否包含页脚 - 返回: - 格式化的报告字符串 + Returns: + str: 格式化后的完整报告字符串 """ sections = [] @@ -77,7 +77,12 @@ class ReportGenerator: return "\n\n".join(sections) def _generate_header(self) -> str: - """生成报告头部。""" + """ + 内部方法:构造报告的标题页眉。 + + Returns: + str: 包含群名、日期的页眉文本 + """ title = "📊 群聊分析报告" if self.group_name: title += f" - {self.group_name}" @@ -85,7 +90,15 @@ class ReportGenerator: return f"{title}\n📅 日期: {self.date_str}\n{'=' * 40}" def _generate_statistics_section(self, stats: GroupStatistics) -> str: - """生成统计部分。""" + """ + 内部方法:格式化基础数值统计区块。 + + Args: + stats (GroupStatistics): 群组统计数据 + + Returns: + str: 格式化的 Markdown 列表区块 + """ lines = [ "📈 **统计概览**", f"• 消息总数: {stats.message_count}", @@ -101,7 +114,15 @@ class ReportGenerator: return "\n".join(lines) def _generate_topics_section(self, topics: list[Topic]) -> str: - """生成话题部分。""" + """ + 内部方法:格式化讨论话题摘要区块。 + + Args: + topics (list[Topic]): 话题列表 + + Returns: + str: 序列化的 Markdown 话题区块 + """ lines = ["💬 **讨论话题**"] for i, topic in enumerate(topics, 1): @@ -112,7 +133,7 @@ class ReportGenerator: lines.append(f"\n{i}. **{topic.name}**") lines.append(f" 参与者: {contributors_str}") if topic.detail: - # 截断过长的详情 + # 截断过长的详情,避免报告过大 detail = ( topic.detail[:200] + "..." if len(topic.detail) > 200 @@ -123,7 +144,15 @@ class ReportGenerator: return "\n".join(lines) def _generate_user_titles_section(self, titles: list[UserTitle]) -> str: - """生成用户称号部分。""" + """ + 内部方法:格式化用户荣誉/称号区块。 + + Args: + titles (list[UserTitle]): 称号列表 + + Returns: + str: 格式化的 Markdown 用户榜区块 + """ lines = ["🏆 **用户称号与徽章**"] for title in titles: @@ -142,7 +171,15 @@ class ReportGenerator: return "\n".join(lines) def _generate_golden_quotes_section(self, quotes: list[GoldenQuote]) -> str: - """生成金句部分。""" + """ + 内部方法:格式化精彩金句展示区块。 + + Args: + quotes (list[GoldenQuote]): 金句列表 + + Returns: + str: 格式化的 Markdown 金句区块 + """ lines = ["✨ **金句集锦**"] for i, quote in enumerate(quotes, 1): @@ -159,83 +196,24 @@ class ReportGenerator: return "\n".join(lines) def _generate_footer(self, token_usage: TokenUsage | None = None) -> str: - """生成报告尾部。""" + """ + 内部方法:生成包含生成时间和性能元数据的页脚。 + + Args: + token_usage (TokenUsage, optional): 关联的 LLM 消耗 + + Returns: + str: 报告页脚 + """ + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") lines = ["─" * 40] - lines.append(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"生成时间: {now}") if token_usage and token_usage.total_tokens > 0: lines.append(f"令牌使用: {token_usage.total_tokens} tokens") return "\n".join(lines) - def _generate_topics_section(self, topics: list[Topic]) -> str: - """Generate topics section.""" - lines = ["💬 **Discussion Topics**"] - - for i, topic in enumerate(topics, 1): - contributors_str = ", ".join(topic.contributors[:3]) - if len(topic.contributors) > 3: - contributors_str += f" +{len(topic.contributors) - 3} more" - - lines.append(f"\n{i}. **{topic.name}**") - lines.append(f" Contributors: {contributors_str}") - if topic.detail: - # Truncate long details - detail = ( - topic.detail[:200] + "..." - if len(topic.detail) > 200 - else topic.detail - ) - lines.append(f" {detail}") - - return "\n".join(lines) - - def _generate_user_titles_section(self, titles: list[UserTitle]) -> str: - """Generate user titles section.""" - lines = ["🏆 **User Titles & Badges**"] - - for title in titles: - lines.append(f"\n👤 **{title.name}**") - lines.append(f" 🎖️ Title: {title.title}") - if title.mbti: - lines.append(f" 🧠 MBTI: {title.mbti}") - if title.reason: - reason = ( - title.reason[:150] + "..." - if len(title.reason) > 150 - else title.reason - ) - lines.append(f" 💡 Reason: {reason}") - - return "\n".join(lines) - - def _generate_golden_quotes_section(self, quotes: list[GoldenQuote]) -> str: - """Generate golden quotes section.""" - lines = ["✨ **Golden Quotes**"] - - for i, quote in enumerate(quotes, 1): - lines.append(f'\n{i}. "{quote.content}"') - lines.append(f" — {quote.sender}") - if quote.reason: - reason = ( - quote.reason[:100] + "..." - if len(quote.reason) > 100 - else quote.reason - ) - lines.append(f" ({reason})") - - return "\n".join(lines) - - def _generate_footer(self, token_usage: TokenUsage | None = None) -> str: - """Generate report footer.""" - lines = ["─" * 40] - lines.append(f"Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - - if token_usage and token_usage.total_tokens > 0: - lines.append(f"Token Usage: {token_usage.total_tokens} tokens") - - return "\n".join(lines) - def generate_summary_report( self, statistics: GroupStatistics, @@ -243,25 +221,25 @@ class ReportGenerator: top_quote: GoldenQuote | None = None, ) -> str: """ - Generate a brief summary report. + 生成简短的摘要报告。 Args: - statistics: Group chat statistics - top_topic: Most significant topic (optional) - top_quote: Best golden quote (optional) + statistics (GroupStatistics): 基础统计数据 + top_topic (Topic, optional): 头对话题 + top_quote (GoldenQuote, optional): 最优金句 Returns: - Brief summary string + str: 简短摘要字符串 """ lines = [ - f"📊 Daily Summary ({self.date_str})", - f"Messages: {statistics.message_count} | Participants: {statistics.participant_count}", + f"📊 每日摘要 ({self.date_str})", + f"消息: {statistics.message_count} | 参与: {statistics.participant_count}人", ] if top_topic: - lines.append(f"🔥 Hot Topic: {top_topic.name}") + lines.append(f"🔥 热门话题: {top_topic.name}") if top_quote: - lines.append(f'✨ Quote: "{top_quote.content}" — {top_quote.sender}') + lines.append(f'✨ 金句: "{top_quote.content}" — {top_quote.sender}') return "\n".join(lines) diff --git a/src/domain/services/statistics_calculator.py b/src/domain/services/statistics_calculator.py index c6516fe..9e673fd 100644 --- a/src/domain/services/statistics_calculator.py +++ b/src/domain/services/statistics_calculator.py @@ -17,18 +17,20 @@ from ..value_objects.statistics import ( class StatisticsCalculator: """ - 计算群聊统计的领域服务。 + 领域服务:统计计算器 - 该服务处理 UnifiedMessage 对象并生成 - 平台无关的统计数据。 + 负责处理统一格式的消息流,并生成多维度的统计分析结果。 + + Attributes: + bot_user_ids (set[str]): 需要在统计中过滤掉的机器人 ID 集合 """ def __init__(self, bot_user_ids: list[str] | None = None): """ 初始化统计计算器。 - 参数: - bot_user_ids: 要从统计中过滤的机器人用户 ID 列表 + Args: + bot_user_ids (list[str], optional): 机器人用户 ID 列表 """ self.bot_user_ids = set(bot_user_ids or []) @@ -38,14 +40,14 @@ class StatisticsCalculator: token_usage: TokenUsage | None = None, ) -> GroupStatistics: """ - 从消息计算综合群组统计。 + 根据一组消息计算综合群组统计数据。 - 参数: - messages: 要分析的统一消息列表 - token_usage: LLM 分析的可选令牌使用量 + Args: + messages (list[UnifiedMessage]): 待分析的消息列表 + token_usage (TokenUsage, optional): 关联的 LLM 令牌消耗 - 返回: - 包含计算统计的 GroupStatistics 对象 + Returns: + GroupStatistics: 计算出的群组统计对象 """ if not messages: return GroupStatistics() @@ -87,13 +89,13 @@ class StatisticsCalculator: self, messages: list[UnifiedMessage] ) -> dict[str, UserStatistics]: """ - 从消息计算单用户统计。 + 为每个独立用户计算详细的行为统计。 - 参数: - messages: 要分析的统一消息列表 + Args: + messages (list[UnifiedMessage]): 待分析的消息列表 - 返回: - user_id 到 UserStatistics 的映射字典 + Returns: + dict[str, UserStatistics]: 用户 ID 到统计对象的映射 """ user_stats: dict[str, UserStatistics] = {} @@ -113,14 +115,14 @@ class StatisticsCalculator: stats = user_stats[user_id] stats.message_count += 1 stats.char_count += len(msg.text_content) - stats.emoji_count += msg.emoji_count + stats.emoji_count += msg.get_emoji_count() # 计算回复数 if msg.reply_to_id: stats.reply_count += 1 # 跟踪每小时活动 - hour = msg.timestamp.hour + hour = msg.get_datetime().hour stats.hours[hour] = stats.hours.get(hour, 0) + 1 return user_stats @@ -132,15 +134,15 @@ class StatisticsCalculator: min_messages: int = 5, ) -> list[dict]: """ - 按消息数获取活跃用户排行。 + 获取基于消息活跃度的前 N 名用户排行。 - 参数: - user_stats: 用户统计字典 - limit: 返回的最大用户数 - min_messages: 被包含所需的最少消息数 + Args: + user_stats (dict[str, UserStatistics]): 用户统计映射 + limit (int): 返回的最大数量 + min_messages (int): 进入排行的最低消息门槛 - 返回: - 按消息数排序的活跃用户字典列表 + Returns: + list[dict]: 排序后的用户摘要字典列表 """ eligible_users = [ stats @@ -148,6 +150,7 @@ class StatisticsCalculator: if stats.message_count >= min_messages ] + # 按消息数降序排序 sorted_users = sorted( eligible_users, key=lambda x: x.message_count, reverse=True ) @@ -169,7 +172,15 @@ class StatisticsCalculator: def _calculate_emoji_statistics( self, messages: list[UnifiedMessage] ) -> EmojiStatistics: - """从消息计算表情使用统计。""" + """ + 内部方法:扫描消息流并汇总表情符号及贴纸的使用频次。 + + Args: + messages (list[UnifiedMessage]): 待扫描的消息列表 + + Returns: + EmojiStatistics: 包含标准表情、自定义表情、贴纸等分类计数的统计对象 + """ standard_count = 0 custom_count = 0 animated_count = 0 @@ -179,11 +190,15 @@ class StatisticsCalculator: for msg in messages: for content in msg.contents: - if content.type.value == "emoji": - emoji_id = content.metadata.get("emoji_id", "unknown") + if content.is_emoji(): + emoji_id = content.emoji_id or "unknown" emoji_details[emoji_id] = emoji_details.get(emoji_id, 0) + 1 - emoji_type = content.metadata.get("emoji_type", "standard") + emoji_type = ( + content.raw_data.get("emoji_type", "standard") + if isinstance(content.raw_data, dict) + else "standard" + ) if emoji_type == "standard": standard_count += 1 elif emoji_type == "custom": @@ -207,18 +222,27 @@ class StatisticsCalculator: def _calculate_activity_visualization( self, messages: list[UnifiedMessage] ) -> ActivityVisualization: - """从消息计算活动可视化数据。""" + """ + 内部方法:计算群组在时间轴(小时/日期)上的活跃分布。 + + Args: + messages (list[UnifiedMessage]): 消息列表 + + Returns: + ActivityVisualization: 包含 24 小时活跃分布、每日活跃趋势、峰值小时及用户排名的对象 + """ hourly: dict[int, int] = dict.fromkeys(range(24), 0) daily: dict[str, int] = {} user_counts: dict[str, int] = {} for msg in messages: + dt = msg.get_datetime() # 每小时活动 - hour = msg.timestamp.hour + hour = dt.hour hourly[hour] += 1 # 每日活动 - date_str = msg.timestamp.strftime("%Y-%m-%d") + date_str = dt.strftime("%Y-%m-%d") daily[date_str] = daily.get(date_str, 0) + 1 # 用户活动 @@ -239,14 +263,22 @@ class StatisticsCalculator: daily_activity=tuple(daily.items()), user_activity_ranking=tuple(user_ranking), peak_hours=tuple(peak_hours), - heatmap_data=(), # 可扩展用于热力图可视化 + heatmap_data=(), ) def _determine_most_active_period(self, activity: ActivityVisualization) -> str: - """确定最活跃时间段描述。""" + """ + 内部方法:根据 24 小时分布数据判定群组的最活跃时段文字描述。 + + Args: + activity (ActivityVisualization): 活跃分布数据 + + Returns: + str: 语义化的时间段描述 (如 '上午 (6:00-12:00)') + """ hourly = dict(activity.hourly_activity) - if not hourly: + if not hourly or all(count == 0 for count in hourly.values()): return "未知" # 找到高峰时段 diff --git a/src/domain/services/user_title_analyzer.py b/src/domain/services/user_title_analyzer.py index 3502295..d9cbd57 100644 --- a/src/domain/services/user_title_analyzer.py +++ b/src/domain/services/user_title_analyzer.py @@ -101,7 +101,7 @@ class UserTitleAnalyzerAdapter(IUserTitleAnalyzer): # 将结果转换为领域值对象 titles = [ UserTitle( - user_id=str(t.qq), + user_id=str(t.user_id), user_name=t.name, title=t.title, mbti=t.mbti, diff --git a/src/domain/value_objects/golden_quote.py b/src/domain/value_objects/golden_quote.py index 232b18a..553590b 100644 --- a/src/domain/value_objects/golden_quote.py +++ b/src/domain/value_objects/golden_quote.py @@ -11,16 +11,15 @@ from dataclasses import dataclass, field @dataclass(frozen=True) class GoldenQuote: """ - 群聊分析的金句值对象。 + 值对象:群聊金句 - 表示聊天中令人难忘/有趣的语录。 - 设计上不可变 (frozen=True)。 + 表示分析过程中提取出的具有代表性、幽默或深刻的消息语录。 - 属性: - content: 实际的语录内容 - sender: 发言者的显示名称 - reason: 该语录被选为金句的原因 - user_id: 平台无关的用户标识符(存储为字符串) + Attributes: + content (str): 语录原文 + sender (str): 说话者的显示名称 + reason (str): 入选理由(由 LLM 生成) + user_id (str): 用户唯一 ID """ content: str @@ -29,24 +28,14 @@ class GoldenQuote: user_id: str = "" def __post_init__(self): - """初始化后验证和规范化金句数据。""" - # 确保 user_id 始终是字符串 + """初始化后确保 user_id 类型正确。""" if not isinstance(self.user_id, str): object.__setattr__(self, "user_id", str(self.user_id)) @classmethod def from_dict(cls, data: dict) -> "GoldenQuote": - """ - 从字典数据创建 GoldenQuote。 - - 参数: - data: 包含金句数据的字典 - - 返回: - GoldenQuote 实例 - """ - # 同时处理 'qq' 和 'user_id' 键以保持向后兼容 - user_id = data.get("user_id", data.get("qq", "")) + """从持久化字典构建金句对象。""" + user_id = data.get("user_id", "") return cls( content=data.get("content", "").strip(), @@ -56,50 +45,21 @@ class GoldenQuote: ) def to_dict(self) -> dict: - """ - 将 GoldenQuote 转换为字典。 - - 返回: - 字典表示 - """ + """转换为持久化字典。""" return { "content": self.content, "sender": self.sender, "reason": self.reason, "user_id": self.user_id, - "qq": int(self.user_id) if self.user_id.isdigit() else 0, # 向后兼容 } @property def is_valid(self) -> bool: - """检查金句是否有有效数据。""" - return bool( - self.content - and self.content.strip() - and self.sender - and self.sender.strip() - ) - - @property - def qq(self) -> int: - """获取 QQ 号码以保持向后兼容。""" - try: - return int(self.user_id) - except (ValueError, TypeError): - return 0 + """验证金句数据的完整性。""" + return bool(self.content.strip() and self.sender.strip()) def with_user_id(self, user_id: str) -> "GoldenQuote": - """ - 创建一个更新了 user_id 的新 GoldenQuote。 - - 由于 GoldenQuote 是冻结的,需要创建新实例。 - - 参数: - user_id: 要设置的用户 ID - - 返回: - 更新了 user_id 的新 GoldenQuote 实例 - """ + """拷贝并更新用户 ID,返回新实例。""" return GoldenQuote( content=self.content, sender=self.sender, @@ -111,25 +71,24 @@ class GoldenQuote: @dataclass class GoldenQuoteCollection: """ - 带有实用方法的金句集合。 + 模型:金句容器 - 这是可变的,以便逐步构建语录集合。 + 提供对金句列表的高级操作封装。 """ quotes: list[GoldenQuote] = field(default_factory=list) def add(self, quote: GoldenQuote) -> None: - """添加金句到集合。""" + """添加单个金句,执行有效性检查。""" if quote.is_valid: self.quotes.append(quote) def add_from_dict(self, data: dict) -> None: - """从字典数据添加金句。""" - quote = GoldenQuote.from_dict(data) - self.add(quote) + """从原始数据添加金句。""" + self.add(GoldenQuote.from_dict(data)) def to_list(self) -> list[dict]: - """将所有语录转换为字典列表。""" + """导出为字典列表。""" return [q.to_dict() for q in self.quotes] def __len__(self) -> int: diff --git a/src/domain/value_objects/platform_capabilities.py b/src/domain/value_objects/platform_capabilities.py index 32a3a9c..a9e93a2 100644 --- a/src/domain/value_objects/platform_capabilities.py +++ b/src/domain/value_objects/platform_capabilities.py @@ -11,12 +11,35 @@ from dataclasses import dataclass @dataclass(frozen=True) class PlatformCapabilities: """ - 平台能力描述 + 值对象:平台能力描述 - 设计原则: - 1. 所有字段都有默认值(最保守假设) - 2. 不可变 - 3. 提供便捷的检查方法 + 用于在运行时判断当前平台支持哪些具体操作,实现防御性编程和多平台兼容。 + + Attributes: + platform_name (str): 平台标识(如 discord, onebot) + platform_version (str): 版本号 + supports_message_history (bool): 是否支持拉取历史消息 + max_message_history_days (int): 最大历史穿透天数 + max_message_count (int): 单次拉取最大消息数 + supports_message_search (bool): 是否支持消息搜索(扩展用) + supports_group_list (bool): 是否支持列出所有群组 + supports_group_info (bool): 是否支持获取群元数据 + supports_member_list (bool): 是否支持获取成员列表 + supports_member_info (bool): 是否支持获取单成员详情 + supports_text_message (bool): 是否能发送文本 + supports_image_message (bool): 是否能发送图片 + supports_file_message (bool): 是否能发送文件/PDF + supports_forward_message (bool): 是否支持转发链(合并转发) + supports_reply_message (bool): 是否支持回复引用 + max_text_length (int): 单条回复最大文本长度 + max_image_size_mb (float): 最大图片上传限制 (MB) + supports_at_all (bool): 是否能 @全员 + supports_recall (bool): 是否支持撤回 + supports_edit (bool): 是否支持编辑已发消息 + supports_user_avatar (bool): 是否有用户头像 API + supports_group_avatar (bool): 是否有群头像 API + avatar_needs_api_call (bool): 获取头像是否需要额外异步请求 + avatar_sizes (tuple[int, ...]): 平台支持的头像尺寸像素值 """ # 平台标识 @@ -53,11 +76,16 @@ class PlatformCapabilities: supports_user_avatar: bool = True supports_group_avatar: bool = False avatar_needs_api_call: bool = False - avatar_sizes: tuple = (100,) + avatar_sizes: tuple[int, ...] = (100,) # 检查方法 def can_analyze(self) -> bool: - """是否支持群聊分析(核心能力)""" + """ + 判断是否具备进行群聊分析的核心能力。 + + Returns: + bool: 核心能力齐全则返回 True + """ return ( self.supports_message_history and self.max_message_history_days > 0 @@ -65,7 +93,15 @@ class PlatformCapabilities: ) def can_send_report(self, format: str = "image") -> bool: - """是否能发送报告""" + """ + 判断是否能以指定格式发送报告。 + + Args: + format (str): 报告格式 ('text', 'image', 'pdf') + + Returns: + bool: 支持该格式则返回 True + """ if format == "text": return self.supports_text_message elif format == "image": @@ -75,15 +111,32 @@ class PlatformCapabilities: return False def get_effective_days(self, requested_days: int) -> int: - """获取实际可用天数""" + """ + 获取实际生效的历史拉取天数。 + + Args: + requested_days (int): 请求的天数 + + Returns: + int: 平台受限后的实际天数 + """ return min(requested_days, self.max_message_history_days) def get_effective_count(self, requested_count: int) -> int: - """获取实际可用消息数""" + """ + 获取实际生效的历史消息拉取条数。 + + Args: + requested_count (int): 请求的消息条数 + + Returns: + int: 平台受限后的实际条数 + """ return min(requested_count, self.max_message_count) # 预定义的平台能力 +# OneBot v11 (如 NapCat, LLOneBot 等) ONEBOT_V11_CAPABILITIES = PlatformCapabilities( platform_name="onebot", platform_version="v11", @@ -108,6 +161,7 @@ ONEBOT_V11_CAPABILITIES = PlatformCapabilities( avatar_sizes=(40, 100, 140, 160, 640), ) +# Telegram Bot API TELEGRAM_CAPABILITIES = PlatformCapabilities( platform_name="telegram", platform_version="bot_api_7.x", @@ -130,6 +184,7 @@ TELEGRAM_CAPABILITIES = PlatformCapabilities( avatar_sizes=(160, 320, 640), ) +# Discord API DISCORD_CAPABILITIES = PlatformCapabilities( platform_name="discord", platform_version="api_v10", @@ -152,6 +207,7 @@ DISCORD_CAPABILITIES = PlatformCapabilities( avatar_sizes=(16, 32, 64, 128, 256, 512, 1024, 2048, 4096), ) +# Slack Web API SLACK_CAPABILITIES = PlatformCapabilities( platform_name="slack", platform_version="web_api", @@ -173,8 +229,8 @@ SLACK_CAPABILITIES = PlatformCapabilities( avatar_sizes=(24, 32, 48, 72, 192, 512, 1024), ) -# 能力查找表 -PLATFORM_CAPABILITIES = { +# 能力查找表(映射平台标识到能力对象) +PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = { "aiocqhttp": ONEBOT_V11_CAPABILITIES, "onebot": ONEBOT_V11_CAPABILITIES, "telegram": TELEGRAM_CAPABILITIES, @@ -184,5 +240,13 @@ PLATFORM_CAPABILITIES = { def get_capabilities(platform_name: str) -> PlatformCapabilities | None: - """根据平台名称获取能力""" + """ + 根据平台名称查找其支持的能力。 + + Args: + platform_name (str): 平台名称 + + Returns: + Optional[PlatformCapabilities]: 对应的能力对象或 None + """ return PLATFORM_CAPABILITIES.get(platform_name.lower()) diff --git a/src/domain/value_objects/statistics.py b/src/domain/value_objects/statistics.py index 0eb68d1..83322d0 100644 --- a/src/domain/value_objects/statistics.py +++ b/src/domain/value_objects/statistics.py @@ -11,14 +11,14 @@ from dataclasses import dataclass, field @dataclass(frozen=True) class TokenUsage: """ - LLM API 调用的令牌使用统计。 + 值对象:LLM 令牌消耗统计 - 设计上不可变 (frozen=True)。 + 记录分析过程中消耗的 Prompt 和 Completion Token。 - 属性: - prompt_tokens: 提示词中的令牌数 - completion_tokens: 补全中的令牌数 - total_tokens: 使用的总令牌数 + Attributes: + prompt_tokens (int): 提示词 Token 数 + completion_tokens (int): 回答 Token 数 + total_tokens (int): 总计 Token 数 """ prompt_tokens: int = 0 @@ -27,7 +27,7 @@ class TokenUsage: @classmethod def from_dict(cls, data: dict) -> "TokenUsage": - """从字典创建 TokenUsage。""" + """从字典还原 TokenUsage 对象。""" return cls( prompt_tokens=data.get("prompt_tokens", 0), completion_tokens=data.get("completion_tokens", 0), @@ -35,15 +35,15 @@ class TokenUsage: ) def to_dict(self) -> dict: - """转换为字典。""" + """转换为字典格式,用于序列化。""" return { "prompt_tokens": self.prompt_tokens, "completion_tokens": self.completion_tokens, "total_tokens": self.total_tokens, } - def __add__(self, other: "TokenUsage") -> "TokenUsage": - """将两个 TokenUsage 对象相加。""" + def __add__(self, other: object) -> "TokenUsage": + """支持 TokenUsage 对象的加法运算。""" if not isinstance(other, TokenUsage): return NotImplemented return TokenUsage( @@ -56,18 +56,17 @@ class TokenUsage: @dataclass(frozen=True) class EmojiStatistics: """ - 表情使用统计。 + 值对象:表情符号统计 - 消息中表情使用的平台无关表示。 - 设计上不可变 (frozen=True)。 + 汇总消息链中不同类别的表情使用情况。 - 属性: - standard_emoji_count: 标准 Unicode 表情数量 - custom_emoji_count: 平台特定自定义表情数量 - animated_emoji_count: 动态表情数量 - sticker_count: 贴纸数量 - other_emoji_count: 其他表情类型数量 - emoji_details: 按表情 ID/名称的详细分类 + Attributes: + standard_emoji_count (int): 标准 Unicode 表情数 + custom_emoji_count (int): 平台自定义表情数 + animated_emoji_count (int): 动态表情数 + sticker_count (int): 贴纸/大表情数 + other_emoji_count (int): 其他未知类型 + emoji_details (tuple[tuple[str, int], ...]): 表情 ID 与次数的详细列表 """ standard_emoji_count: int = 0 @@ -75,11 +74,11 @@ class EmojiStatistics: animated_emoji_count: int = 0 sticker_count: int = 0 other_emoji_count: int = 0 - emoji_details: tuple = field(default_factory=tuple) + emoji_details: tuple[tuple[str, int], ...] = field(default_factory=tuple) @property def total_count(self) -> int: - """获取表情总数。""" + """获取所有表情的总数。""" return ( self.standard_emoji_count + self.custom_emoji_count @@ -90,7 +89,7 @@ class EmojiStatistics: @classmethod def from_dict(cls, data: dict) -> "EmojiStatistics": - """从字典创建 EmojiStatistics。""" + """从持久化字典构建统计对象。""" details = data.get("face_details", data.get("emoji_details", {})) if isinstance(details, dict): details = tuple(details.items()) @@ -111,7 +110,7 @@ class EmojiStatistics: ) def to_dict(self) -> dict: - """转换为字典。""" + """转换为持久化字典,包含向后兼容字段。""" return { "standard_emoji_count": self.standard_emoji_count, "custom_emoji_count": self.custom_emoji_count, @@ -131,28 +130,27 @@ class EmojiStatistics: @dataclass(frozen=True) class ActivityVisualization: """ - 活动可视化数据。 + 值对象:活动可视化数据 - 聊天活动模式的平台无关表示。 - 设计上不可变 (frozen=True)。 + 存储用于生成图表的各种活跃度指标。 - 属性: - hourly_activity: 按小时统计的消息数 (0-23) - daily_activity: 按日期统计的消息数 - user_activity_ranking: 用户活跃度排名列表 - peak_hours: 活动高峰时段列表 - heatmap_data: 活动热力图可视化数据 + Attributes: + hourly_activity (tuple[tuple[int, int], ...]): 24 小时活跃分布 + daily_activity (tuple[tuple[str, int], ...]): 每日消息数分布 + user_activity_ranking (tuple[dict, ...]): 用户活跃排名数据 + peak_hours (tuple[int, ...]): 高峰小时 ID + heatmap_data (tuple[Any, ...]): 热力图原始数据 """ - hourly_activity: tuple = field(default_factory=tuple) - daily_activity: tuple = field(default_factory=tuple) - user_activity_ranking: tuple = field(default_factory=tuple) - peak_hours: tuple = field(default_factory=tuple) + hourly_activity: tuple[tuple[int, int], ...] = field(default_factory=tuple) + daily_activity: tuple[tuple[str, int], ...] = field(default_factory=tuple) + user_activity_ranking: tuple[dict, ...] = field(default_factory=tuple) + peak_hours: tuple[int, ...] = field(default_factory=tuple) heatmap_data: tuple = field(default_factory=tuple) @classmethod def from_dict(cls, data: dict) -> "ActivityVisualization": - """从字典创建 ActivityVisualization。""" + """从字典反序列话可视化数据。""" hourly = data.get("hourly_activity", {}) daily = data.get("daily_activity", {}) ranking = data.get("user_activity_ranking", []) @@ -187,19 +185,16 @@ class ActivityVisualization: @dataclass(frozen=True) class GroupStatistics: """ - 综合群聊统计。 + 值对象:综合群聊统计 - 群聊统计数据的平台无关表示。 - 设计上不可变 (frozen=True)。 - - 属性: - message_count: 消息总数 - total_characters: 所有消息的总字符数 - participant_count: 唯一参与者数量 - most_active_period: 最活跃时间段描述 - emoji_statistics: 表情使用统计 - activity_visualization: 活动模式数据 - token_usage: 分析使用的 LLM 令牌 + Attributes: + message_count (int): 消息总数 + total_characters (int): 字符总数 + participant_count (int): 活跃人数 + most_active_period (str): 描述性的最活跃时段 + emoji_statistics (EmojiStatistics): 表情分类统计 + activity_visualization (ActivityVisualization): 可视化元数据 + token_usage (TokenUsage): LLM 消耗记录 """ message_count: int = 0 @@ -214,22 +209,22 @@ class GroupStatistics: @property def average_message_length(self) -> float: - """计算平均消息长度。""" + """计算平均每条消息的字符长度。""" if self.message_count == 0: return 0.0 return self.total_characters / self.message_count @property def emoji_count(self) -> int: - """获取表情总数以保持向后兼容。""" + """返回表情总数(向后兼容)。""" return self.emoji_statistics.total_count @classmethod def from_dict(cls, data: dict) -> "GroupStatistics": - """从字典创建 GroupStatistics。""" + """由字典数据构建完整的统计模型。""" emoji_data = data.get("emoji_statistics", {}) if not emoji_data: - # 向后兼容:从扁平字段构建 + # 向后兼容:从旧版本扁平字段中恢复 emoji_data = { "face_count": data.get("emoji_count", 0), } @@ -248,13 +243,13 @@ class GroupStatistics: ) def to_dict(self) -> dict: - """转换为字典。""" + """转换为可进行 JSON 序列化的字典。""" return { "message_count": self.message_count, "total_characters": self.total_characters, "participant_count": self.participant_count, "most_active_period": self.most_active_period, - "emoji_count": self.emoji_count, # 向后兼容 + "emoji_count": self.emoji_count, # 导出时也包含此字段以支持旧版阅读器 "emoji_statistics": self.emoji_statistics.to_dict(), "activity_visualization": self.activity_visualization.to_dict(), "token_usage": self.token_usage.to_dict(), @@ -264,16 +259,18 @@ class GroupStatistics: @dataclass class UserStatistics: """ - 单用户统计(可变以便在分析期间累积)。 + 可变模型:单个用户的行为分析 - 属性: - user_id: 平台无关的用户标识符 - nickname: 用户显示名称 - message_count: 发送的消息数 - char_count: 发送的总字符数 - emoji_count: 使用的表情数 - reply_count: 回复次数 - hours: 按小时统计的消息数 (0-23) + 用于在统计计算过程中作为状态累加器。 + + Attributes: + user_id (str): 用户唯一标示 + nickname (str): 用户名 + message_count (int): 消息条数 + char_count (int): 字符总数 + emoji_count (int): 表情总数 + reply_count (int): 被回复或回复的次数 + hours (dict[int, int]): 小时活跃频次 (0-23) """ user_id: str @@ -286,21 +283,21 @@ class UserStatistics: @property def average_chars(self) -> float: - """计算每条消息的平均字符数。""" + """平均每条消息的字符数。""" if self.message_count == 0: return 0.0 return self.char_count / self.message_count @property def emoji_ratio(self) -> float: - """计算每条消息的表情比率。""" + """平均每条消息包含的表情数。""" if self.message_count == 0: return 0.0 return self.emoji_count / self.message_count @property def night_ratio(self) -> float: - """计算夜间活动比率 (0-6 点)。""" + """深夜活跃占比(凌晨 0 点至 6 点)。""" if self.message_count == 0: return 0.0 night_messages = sum(self.hours.get(h, 0) for h in range(6)) @@ -308,13 +305,13 @@ class UserStatistics: @property def reply_ratio(self) -> float: - """计算回复比率。""" + """回复行为占比。""" if self.message_count == 0: return 0.0 return self.reply_count / self.message_count def to_dict(self) -> dict: - """转换为字典。""" + """返回详细的用户行为分析字典。""" return { "user_id": self.user_id, "nickname": self.nickname, diff --git a/src/domain/value_objects/topic.py b/src/domain/value_objects/topic.py index 1b1f769..3bbc621 100644 --- a/src/domain/value_objects/topic.py +++ b/src/domain/value_objects/topic.py @@ -11,15 +11,14 @@ from dataclasses import dataclass, field @dataclass(frozen=True) class Topic: """ - 群聊分析的话题值对象。 + 值对象:讨论话题 - 表示一个包含参与者和详情的讨论话题。 - 设计上不可变 (frozen=True)。 + 表示从聊天记录中总结出的一个核心讨论点。 - 属性: - name: 话题标题/名称 - contributors: 参与该话题讨论的用户名列表 - detail: 话题讨论的详细描述或摘要 + Attributes: + name (str): 话题名称 + contributors (tuple[str, ...]): 核心贡献者列表(不可变) + detail (str): 话题详情摘要 """ name: str @@ -27,25 +26,16 @@ class Topic: detail: str = "" def __post_init__(self): - """初始化后验证话题数据。""" + """数据规范化。""" if not self.name or not self.name.strip(): object.__setattr__(self, "name", "未知话题") - # 确保 contributors 是元组以保证不可变性 if isinstance(self.contributors, list): object.__setattr__(self, "contributors", tuple(self.contributors)) @classmethod def from_dict(cls, data: dict) -> "Topic": - """ - 从字典数据创建 Topic。 - - 参数: - data: 包含话题数据的字典 - - 返回: - Topic 实例 - """ + """从字典还原话题对象。""" contributors = data.get("contributors", []) if isinstance(contributors, list): contributors = tuple(contributors) @@ -57,12 +47,7 @@ class Topic: ) def to_dict(self) -> dict: - """ - 将 Topic 转换为字典。 - - 返回: - 字典表示 - """ + """导出为序列化字典。""" return { "topic": self.name, "contributors": list(self.contributors), @@ -71,39 +56,37 @@ class Topic: @property def contributor_count(self) -> int: - """获取参与者数量。""" + """参与讨论的人数。""" return len(self.contributors) @property def is_valid(self) -> bool: - """检查话题是否有有效数据。""" - return bool( - self.name and self.name.strip() and self.detail and self.detail.strip() - ) + """验证话题数据的有效性。""" + return bool(self.name.strip() and self.detail.strip()) @dataclass class TopicCollection: """ - 带有实用方法的话题集合。 + 模型:话题集合 - 这是可变的,以便逐步构建话题集合。 + Attributes: + topics (list[Topic]): 话题列表 """ topics: list[Topic] = field(default_factory=list) def add(self, topic: Topic) -> None: - """添加话题到集合。""" + """添加话题并进行有效性检查。""" if topic.is_valid: self.topics.append(topic) def add_from_dict(self, data: dict) -> None: - """从字典数据添加话题。""" - topic = Topic.from_dict(data) - self.add(topic) + """从原始数据添加。""" + self.add(Topic.from_dict(data)) def to_list(self) -> list[dict]: - """将所有话题转换为字典列表。""" + """导出字典列表。""" return [t.to_dict() for t in self.topics] def __len__(self) -> int: diff --git a/src/domain/value_objects/unified_group.py b/src/domain/value_objects/unified_group.py index 950371b..e44cb8b 100644 --- a/src/domain/value_objects/unified_group.py +++ b/src/domain/value_objects/unified_group.py @@ -7,20 +7,42 @@ from dataclasses import dataclass @dataclass(frozen=True) class UnifiedMember: - """统一成员信息""" + """ + 值对象:统一成员信息 + + Attributes: + user_id (str): 用户唯一 ID + nickname (str): 用户昵称 + card (str, optional): 群名片 + role (str): 角色(owner/admin/member) + join_time (int, optional): 入群时间(秒级时间戳) + avatar_url (str, optional): 头像网络链接 + avatar_data (str, optional): 头像 Base64 数据 + """ user_id: str nickname: str - card: str | None = None # 群名片 - role: str = "member" # owner, admin, member + card: str | None = None + role: str = "member" join_time: int | None = None avatar_url: str | None = None - avatar_data: str | None = None # Base64 用于模板渲染 + avatar_data: str | None = None @dataclass(frozen=True) class UnifiedGroup: - """统一群组信息""" + """ + 值对象:统一群组信息 + + Attributes: + group_id (str): 群组唯一 ID + group_name (str): 群组名称 + member_count (int): 成员数量 + owner_id (str, optional): 群主 ID + create_time (int, optional): 创建时间 + description (str, optional): 群简介/公告 + platform (str): 来源平台 + """ group_id: str group_name: str diff --git a/src/domain/value_objects/unified_message.py b/src/domain/value_objects/unified_message.py index 85f7701..d54ebba 100644 --- a/src/domain/value_objects/unified_message.py +++ b/src/domain/value_objects/unified_message.py @@ -11,7 +11,11 @@ from typing import Any class MessageContentType(Enum): - """消息内容类型枚举""" + """ + 枚举:消息内容类型 + + 用于标识 MessageContent 的具体类型。 + """ TEXT = "text" IMAGE = "image" @@ -29,9 +33,19 @@ class MessageContentType(Enum): @dataclass(frozen=True) class MessageContent: """ - 消息内容段值对象 + 值对象:消息内容段 - 不可变,用于组成消息链 + 表示消息链中的一个组成部分(如文本、图片、表情等)。 + 该对象是不可变的,用于保证数据流的纯净。 + + Attributes: + type (MessageContentType): 内容类型 + text (str): 文本内容(仅当类型为 TEXT 或包含文本描述时) + url (str): 资源链接(图片、视频、文件等) + emoji_id (str): 表情 ID + emoji_name (str): 表情名称 + at_user_id (str): 被 @ 的用户 ID + raw_data (Any): 平台原始数据,用于扩展 """ type: MessageContentType @@ -43,22 +57,33 @@ class MessageContent: raw_data: Any = None def is_text(self) -> bool: + """检查是否为文本内容。""" return self.type == MessageContentType.TEXT def is_emoji(self) -> bool: + """检查是否为表情内容。""" return self.type == MessageContentType.EMOJI @dataclass(frozen=True) class UnifiedMessage: """ - 统一消息格式 - 跨平台核心值对象 + 核心值对象:统一消息格式 - 设计原则: - 1. 只保留分析所需的字段 - 2. 使用平台无关的类型 - 3. 不可变 (frozen=True) - 线程安全 - 4. 所有 ID 使用字符串 - 避免平台差异 + 跨平台抽象层,将不同平台的原始消息转换为统一格式进行分析。 + 采用“只读”设计,确保分析逻辑的一致性。 + + Attributes: + message_id (str): 消息唯一标识符 + sender_id (str): 发送者唯一 ID + sender_name (str): 发送者昵称 + group_id (str): 群组/会话唯一 ID + text_content (str): 经过清洗后的纯文本内容,主要用于 LLM 分析 + contents (tuple[MessageContent, ...]): 结构化消息链 + timestamp (int): Unix 时间戳(秒) + platform (str): 来源平台名称(如 onebot, discord 等) + reply_to_id (str, optional): 被回复的消息 ID + sender_card (str, optional): 平台特定的群名片或特别备注 """ # 基础标识 @@ -68,42 +93,73 @@ class UnifiedMessage: group_id: str # 消息内容 - text_content: str # 提取的纯文本用于 LLM 分析 + text_content: str contents: tuple[MessageContent, ...] = field(default_factory=tuple) # 时间信息 - timestamp: int = 0 # Unix 时间戳 + timestamp: int = 0 # 平台信息 platform: str = "unknown" # 可选信息 reply_to_id: str | None = None - sender_card: str | None = None # 群名片/昵称 + sender_card: str | None = None # 分析辅助方法 def has_text(self) -> bool: - """是否有文本内容""" + """ + 判断消息是否包含非空文本。 + + Returns: + bool: 包含有效文本则返回 True + """ return bool(self.text_content.strip()) def get_display_name(self) -> str: - """获取显示名称,优先使用群名片""" + """ + 获取用户显示名称。 + 优先级:群名片 > 昵称 > 用户 ID。 + + Returns: + str: 格式化后的显示名称 + """ return self.sender_card or self.sender_name or self.sender_id def get_emoji_count(self) -> int: - """获取表情数量""" + """ + 计算消息链中包含的表情数量。 + + Returns: + int: 表情总数 + """ return sum(1 for c in self.contents if c.is_emoji()) def get_text_length(self) -> int: - """获取文本长度""" + """ + 获取文本内容的字符长度。 + + Returns: + int: 字符数 + """ return len(self.text_content) def get_datetime(self) -> datetime: - """获取消息时间""" + """ + 将 Unix 时间戳转换为 datetime 对象。 + + Returns: + datetime: 本地化后的时间对象 + """ return datetime.fromtimestamp(self.timestamp) def to_analysis_format(self) -> str: - """转换为分析格式(供 LLM 使用)""" + """ + 转换为供 LLM 消费的分析格式。 + + Returns: + str: 格式如 "[用户名]: 消息内容" 的字符串 + """ name = self.get_display_name() return f"[{name}]: {self.text_content}" diff --git a/src/domain/value_objects/user_title.py b/src/domain/value_objects/user_title.py index 4903a21..78e1a88 100644 --- a/src/domain/value_objects/user_title.py +++ b/src/domain/value_objects/user_title.py @@ -11,17 +11,14 @@ from dataclasses import dataclass, field @dataclass(frozen=True) class UserTitle: """ - 群聊分析的用户称号值对象。 + 值对象:用户称号/勋章 - 表示基于用户行为分配的称号/徽章。 - 设计上不可变 (frozen=True)。 - - 属性: - name: 用户显示名称 - user_id: 平台无关的用户标识符(存储为字符串) - title: 分配给用户的称号/徽章 - mbti: MBTI 人格类型评估 - reason: 分配该称号的原因说明 + Attributes: + name (str): 用户昵称 + user_id (str): 用户唯一 ID + title (str): 获得的称号名称 + mbti (str): 评估出的 MBTI 类型 + reason (str): 授予该称号的理由 """ name: str @@ -31,24 +28,14 @@ class UserTitle: reason: str = "" def __post_init__(self): - """初始化后验证和规范化用户称号数据。""" - # 确保 user_id 始终是字符串 + """确保 ID 为字符串。""" if not isinstance(self.user_id, str): object.__setattr__(self, "user_id", str(self.user_id)) @classmethod def from_dict(cls, data: dict) -> "UserTitle": - """ - 从字典数据创建 UserTitle。 - - 参数: - data: 包含用户称号数据的字典 - - 返回: - UserTitle 实例 - """ - # 同时处理 'qq' 和 'user_id' 键以保持向后兼容 - user_id = data.get("user_id", data.get("qq", "")) + """解析持久化字典。""" + user_id = data.get("user_id", "") return cls( name=data.get("name", "").strip(), @@ -59,16 +46,10 @@ class UserTitle: ) def to_dict(self) -> dict: - """ - 将 UserTitle 转换为字典。 - - 返回: - 字典表示 - """ + """导出字典。""" return { "name": self.name, "user_id": self.user_id, - "qq": int(self.user_id) if self.user_id.isdigit() else 0, # 向后兼容 "title": self.title, "mbti": self.mbti, "reason": self.reason, @@ -76,46 +57,32 @@ class UserTitle: @property def is_valid(self) -> bool: - """检查用户称号是否有有效数据。""" - return bool( - self.name - and self.name.strip() - and self.title - and self.title.strip() - and self.user_id - ) - - @property - def qq(self) -> int: - """获取 QQ 号码以保持向后兼容。""" - try: - return int(self.user_id) - except (ValueError, TypeError): - return 0 + """基本数据完整性验证。""" + return bool(self.name.strip() and self.title.strip() and self.user_id) @dataclass class UserTitleCollection: """ - 带有实用方法的用户称号集合。 + 模型:称号容器 - 这是可变的,以便逐步构建称号集合。 + Attributes: + titles (list[UserTitle]): 称号列表 """ titles: list[UserTitle] = field(default_factory=list) def add(self, title: UserTitle) -> None: - """添加用户称号到集合。""" + """添加称号。""" if title.is_valid: self.titles.append(title) def add_from_dict(self, data: dict) -> None: - """从字典数据添加用户称号。""" - title = UserTitle.from_dict(data) - self.add(title) + """解析并添加。""" + self.add(UserTitle.from_dict(data)) def get_by_user_id(self, user_id: str) -> UserTitle | None: - """根据用户 ID 获取称号。""" + """根据唯一 ID 检索称号。""" user_id_str = str(user_id) for title in self.titles: if title.user_id == user_id_str: @@ -123,7 +90,7 @@ class UserTitleCollection: return None def to_list(self) -> list[dict]: - """将所有称号转换为字典列表。""" + """导出映射列表。""" return [t.to_dict() for t in self.titles] def __len__(self) -> int: diff --git a/src/infrastructure/persistence/history_repository.py b/src/infrastructure/persistence/history_repository.py index 2a31862..278ff3d 100644 --- a/src/infrastructure/persistence/history_repository.py +++ b/src/infrastructure/persistence/history_repository.py @@ -15,10 +15,14 @@ from ...utils.logger import logger class HistoryRepository: """ - 用于存储和检索分析历史的仓库。 + 基础设施:历史仓库 - 此实现将历史记录存储为 JSON 文件,保持 - 与现有 history_manager 的向后兼容性。 + 负责群聊分析历史记录的持久化存储与检索。目前使用本地 JSON 文件实现, + 保持了与旧版 `history_manager` 的数据格式兼容性。 + + Attributes: + data_dir (Path): 插件数据存储的总根目录 + history_dir (Path): 专门存放历史记录的子目录 """ def __init__(self, data_dir: str): @@ -26,18 +30,18 @@ class HistoryRepository: 初始化历史仓库。 Args: - data_dir: 存储历史数据的基础目录 + data_dir (str): 存储历史数据的基础目录路径 """ self.data_dir = Path(data_dir) self.history_dir = self.data_dir / "history" self._ensure_directories() def _ensure_directories(self) -> None: - """确保所需目录存在。""" + """内部方法:确保所需的目录结构已创建。""" self.history_dir.mkdir(parents=True, exist_ok=True) def _get_group_history_path(self, group_id: str) -> Path: - """获取群组的历史文件路径。""" + """内部方法:获取特定群组的历史 JSON 文件路径。""" return self.history_dir / f"group_{group_id}.json" def save_analysis_result( @@ -47,52 +51,52 @@ class HistoryRepository: date_str: str | None = None, ) -> bool: """ - 保存分析结果到历史记录。 + 将分析结果保存到持久化存储。 Args: - group_id: 群组标识符 - result: 分析结果字典 - date_str: 日期字符串(默认为今天) + group_id (str): 群组标识符 + result (dict[str, Any]): 包含统计、金句等信息的分析结果字典 + date_str (str, optional): 关联日期 (YYYY-MM-DD),默认为执行日 Returns: - 如果保存成功则返回 True + bool: 保存成功返回 True,发生异常返回 False """ try: date_str = date_str or datetime.now().strftime("%Y-%m-%d") history = self.load_group_history(group_id) - # 如果不存在则添加时间戳 + # 注入执行时间戳 if "timestamp" not in result: result["timestamp"] = datetime.now().isoformat() - # 按日期存储 + # 结构化存储:二级映射 {date -> result} if "daily" not in history: history["daily"] = {} history["daily"][date_str] = result history["last_updated"] = datetime.now().isoformat() - # 写入文件 + # 原子写入(覆盖) history_path = self._get_group_history_path(group_id) with open(history_path, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) - logger.debug(f"已保存群组 {group_id} 在 {date_str} 的分析结果") + logger.debug(f"已保存群 {group_id} 在 {date_str} 的历史分析记录") return True except Exception as e: - logger.error(f"保存分析结果失败: {e}") + logger.error(f"保存群 {group_id} 的历史记录失败: {e}") return False def load_group_history(self, group_id: str) -> dict[str, Any]: """ - 加载群组历史记录。 + 加载特定群组的完整历史记录字典。 Args: - group_id: 群组标识符 + group_id (str): 群组标识符 Returns: - 历史记录字典 + dict[str, Any]: 历史数据字典,若文件不存在则返回包含空 daily 结构的初始字典 """ try: history_path = self._get_group_history_path(group_id) @@ -101,78 +105,77 @@ class HistoryRepository: return json.load(f) return {"daily": {}, "group_id": group_id} except Exception as e: - logger.error(f"加载群组历史记录失败: {e}") + logger.error(f"加载群 {group_id} 的历史记录失败: {e}") return {"daily": {}, "group_id": group_id} def get_analysis_result( self, group_id: str, date_str: str ) -> dict[str, Any] | None: """ - 获取特定日期的分析结果。 + 获取指定日期已存档的分析结果。 Args: - group_id: 群组标识符 - date_str: 日期字符串 (YYYY-MM-DD 格式) + group_id (str): 群组 ID + date_str (str): 目标日期 (YYYY-MM-DD) Returns: - 分析结果,如果未找到则返回 None + Optional[dict[str, Any]]: 分析结果字典,未找到则返回 None """ history = self.load_group_history(group_id) return history.get("daily", {}).get(date_str) def get_recent_results(self, group_id: str, limit: int = 7) -> list[dict[str, Any]]: """ - 获取最近的分析结果。 + 获取指定群组最近 N 次的分析结果列表。 Args: - group_id: 群组标识符 - limit: 返回的最大结果数 + group_id (str): 群组 ID + limit (int): 最大返回条数 Returns: - 最近分析结果列表 + list[dict[str, Any]]: 按日期降序排列的结果列表 """ history = self.load_group_history(group_id) daily = history.get("daily", {}) - # 按日期降序排序 + # 按日期字符串字典序降序排列(YYYY-MM-DD 天然有序) sorted_dates = sorted(daily.keys(), reverse=True)[:limit] return [daily[date] for date in sorted_dates] def has_analysis_for_date(self, group_id: str, date_str: str) -> bool: """ - 检查特定日期是否存在分析结果。 + 检查指定日期是否已经生成过分析。 Args: - group_id: 群组标识符 - date_str: 日期字符串 (YYYY-MM-DD 格式) + group_id (str): 群组 ID + date_str (str): 日期字符串 Returns: - 如果分析结果存在则返回 True + bool: 存在记录则返回 True """ - result = self.get_analysis_result(group_id, date_str) - return result is not None + return self.get_analysis_result(group_id, date_str) is not None def delete_old_history(self, group_id: str, keep_days: int = 30) -> int: """ - 删除超过指定天数的历史记录。 + 自动清理超过天数限制的陈旧历史记录。 Args: - group_id: 群组标识符 - keep_days: 保留历史记录的天数 + group_id (str): 群组 ID + keep_days (int): 保留的天数上限 Returns: - 删除的条目数 + int: 实际删除的记录条数 """ try: history = self.load_group_history(group_id) daily = history.get("daily", {}) - # 计算截止日期(简单的字符串比较适用于 YYYY-MM-DD 格式) + # 计算截止日期边界 from datetime import timedelta cutoff = (datetime.now() - timedelta(days=keep_days)).strftime("%Y-%m-%d") - # 查找要删除的日期 + # 筛选已过期的日期 dates_to_delete = [date for date in daily.keys() if date < cutoff] for date in dates_to_delete: @@ -187,22 +190,23 @@ class HistoryRepository: return len(dates_to_delete) except Exception as e: - logger.error(f"删除旧历史记录失败: {e}") + logger.error(f"清理群 {group_id} 的陈旧历史记录失败: {e}") return 0 def list_groups_with_history(self) -> list[str]: """ - 列出所有有历史记录的群组。 + 扫描文件系统,列出当前所有具有存档记录的群组 ID。 Returns: - 群组 ID 列表 + list[str]: 群组 ID 字符串列表 """ try: groups = [] for file_path in self.history_dir.glob("group_*.json"): + # 从文件名反推群组 ID (group_123.json -> 123) group_id = file_path.stem.replace("group_", "") groups.append(group_id) return groups except Exception as e: - logger.error(f"列出群组失败: {e}") + logger.error(f"列出历史记录群组失败: {e}") return [] diff --git a/src/infrastructure/platform/adapters/discord_adapter.py b/src/infrastructure/platform/adapters/discord_adapter.py index 6d723a5..e65a2b4 100644 --- a/src/infrastructure/platform/adapters/discord_adapter.py +++ b/src/infrastructure/platform/adapters/discord_adapter.py @@ -33,38 +33,47 @@ from ..base import PlatformAdapter class DiscordAdapter(PlatformAdapter): """ - Discord 平台适配器 + 具体实现:Discord 平台适配器 - 实现 PlatformAdapter 接口,提供 Discord 平台的消息操作。 + 利用 Discord API 为群组(频道)提供消息获取、发送及基础元数据查询功能。 + 由于 Discord 的高度异步特性和复杂的权限模型,该适配器集成了懒加载客户端和多级频道查询机制。 - 使用方式: - 1. 通过 PlatformAdapterFactory.create("discord", bot_instance, config) 创建 - 2. 或直接实例化:DiscordAdapter(bot_instance, config) - - 配置参数: - - bot_user_id: 机器人的 Discord 用户 ID(用于过滤自己的消息) + Attributes: + bot_user_id (str): 机器人自身的 Discord 用户 ID """ - def __init__(self, bot_instance: Any, config: dict = None): + def __init__(self, bot_instance: Any, config: dict | None = None): + """ + 初始化 Discord 适配器。 + + Args: + bot_instance (Any): 宿主机器人实例 + config (dict, optional): 配置项,用于提取机器人自身的 Discord ID + """ super().__init__(bot_instance, config) - # 机器人自己的用户 ID,用于过滤消息 + # 机器人自己的用户 ID,用于消息过滤(避免分析博取回复) self.bot_user_id = str(config.get("bot_user_id", "")) if config else "" - # 缓存 Discord 客户端(懒加载) + # 缓存 Discord 客户端(Lazy Loading) self._cached_client = None @property def _discord_client(self) -> Any: """ - 获取实际的 Discord 客户端实例 (Lazy Load) + 内部属性:获取实际的 Discord 客户端实例。 + + 具备懒加载和自动身份嗅探功能。 + + Returns: + Any: Discord Client 对象 """ if self._cached_client: return self._cached_client - # 尝试获取客户端 + # 执行路径探测逻辑,兼容不同版本的 AstrBot 宿主结构 self._cached_client = self._get_discord_client() - # 尝试从 Discord 客户端获取 ID (如果之前没获取到) + # 兜底:尝试从客户端连接状态中补全机器人 ID if not self.bot_user_id and self._cached_client: if hasattr(self._cached_client, "user") and self._cached_client.user: self.bot_user_id = str(self._cached_client.user.id) @@ -72,31 +81,27 @@ class DiscordAdapter(PlatformAdapter): return self._cached_client def _get_discord_client(self) -> Any: - """ - 获取实际的 Discord 客户端实例 - - AstrBot 的 DiscordPlatformAdapter 将 Discord 客户端存储在 self.client 中 - """ - # 如果 bot 本身就是 Discord client (有 get_channel 方法) + """内部方法:通过多级探测从 bot_instance 中提取 Discord SDK 客户端。""" + # 路径 A:bot 本身就是 Client (如小型集成) if hasattr(self.bot, "get_channel"): return self.bot - # 如果 bot 是 DiscordPlatformAdapter,client 在 self.bot.client 中 + # 路径 B:bot 是包装器,client 在标准成员变量中 if hasattr(self.bot, "client"): return self.bot.client - # 尝试其他可能的属性名 - for attr in ["_client", "discord_client", "_discord_client"]: + # 路径 C:其他常见私有属性名 + for attr in ("_client", "discord_client", "_discord_client"): if hasattr(self.bot, attr): client = getattr(self.bot, attr) if hasattr(client, "get_channel"): return client - logger.warning(f"无法从 {type(self.bot).__name__} 获取 Discord 客户端") + logger.warning(f"无法从 {type(self.bot).__name__} 中提取 Discord 客户端实例") return None def _init_capabilities(self) -> PlatformCapabilities: - """初始化 Discord 平台能力""" + """返回预定义的 Discord 平台能力集。""" return DISCORD_CAPABILITIES - # ==================== IMessageRepository ==================== + # ==================== IMessageRepository 实现 ==================== async def fetch_messages( self, @@ -106,35 +111,36 @@ class DiscordAdapter(PlatformAdapter): before_id: str | None = None, ) -> list[UnifiedMessage]: """ - 获取 Discord 频道消息历史 + 从 Discord 频道异步拉取历史消息记录。 - 参数: - group_id: Discord 频道 ID - days: 获取多少天内的消息 - max_count: 最大消息数量 - before_id: 从此消息 ID 之前开始获取(用于分页) + Args: + group_id (str): Discord 频道 (Channel) ID + days (int): 查询天数范围 + max_count (int): 最大拉取消息数量上限 + before_id (str, optional): 锚点消息 ID,从此之前开始拉取 - 返回: - UnifiedMessage 列表 + Returns: + list[UnifiedMessage]: 统一格式的消息对象列表 """ if not discord: - logger.error("未安装 py-cord 库,无法使用 Discord 适配器") + logger.error("Discord module (py-cord) not found. Cannot fetch messages.") return [] try: channel_id = int(group_id) + # 先从缓存尝试获取频道 channel = self._discord_client.get_channel(channel_id) if not channel: - # 尝试 fetch (API调用) + # 缓存未命中则通过网络 fetch try: channel = await self._discord_client.fetch_channel(channel_id) - except Exception: - logger.warning(f"无法找到频道 ID: {group_id}") + except Exception as e: + logger.debug(f"拉取 Discord 频道 {group_id} 失败: {e}") return [] - # 检查频道是否支持历史记录 + # 验证权限:确保支持历史消息流 if not hasattr(channel, "history"): - logger.warning(f"频道 {group_id} 不支持历史消息获取") + logger.warning(f"频道 {group_id} 不支持历史消息访问。") return [] end_time = datetime.now() @@ -142,18 +148,18 @@ class DiscordAdapter(PlatformAdapter): messages = [] - # 构建 history 参数 + # 构建 Discord SDK 的 history 查询参数 history_kwargs = {"limit": max_count, "after": start_time} if before_id: try: - # before 可以接受 Message 对象或 ID (int) + # 使用 Snowflake ID 指向特定消息 history_kwargs["before"] = discord.Object(id=int(before_id)) - except ValueError: + except (ValueError, TypeError): pass - # 获取消息 + # 消息迭代处理 async for msg in channel.history(**history_kwargs): - # 过滤机器人自己的消息(如果配置了 ID) + # 排除机器人自身发布的消息 if self.bot_user_id and str(msg.author.id) == self.bot_user_id: continue @@ -161,35 +167,26 @@ class DiscordAdapter(PlatformAdapter): if unified: messages.append(unified) - # 按时间升序排序 + # 排序回升序(SDK 通常返回降序) messages.sort(key=lambda m: m.timestamp) return messages except Exception as e: - logger.error(f"获取 Discord 消息失败: {e}", exc_info=True) + logger.error(f"Discord fetch_messages failed: {e}", exc_info=True) return [] def _convert_message(self, raw_msg: Any, group_id: str) -> UnifiedMessage | None: - """ - 将 Discord 消息转换为统一格式 - - 参数: - raw_msg: Discord 原始消息对象 (discord.Message) - group_id: 频道 ID - - 返回: - UnifiedMessage 或 None - """ + """内部方法:将 `discord.Message` 对象转换为统一的 `UnifiedMessage`。""" try: contents = [] - # 1. 文本内容 + # 1. 基础文本 if raw_msg.content: contents.append( MessageContent(type=MessageContentType.TEXT, text=raw_msg.content) ) - # 2. 附件处理 + # 2. 附件处理 (图片/视频/语音/普通文件) for attachment in raw_msg.attachments: content_type = attachment.content_type or "" if content_type.startswith("image/"): @@ -222,7 +219,7 @@ class DiscordAdapter(PlatformAdapter): ) ) - # 3. 嵌入内容 (Embeds) - 通常是富文本或图片 + # 3. 嵌入内容处理 (部分 Embed 可能包含富文本描述) for embed in raw_msg.embeds: if embed.image: contents.append( @@ -230,7 +227,6 @@ class DiscordAdapter(PlatformAdapter): type=MessageContentType.IMAGE, url=embed.image.url ) ) - # 其他 embed 内容暂作为未知类型或文本处理 if embed.description: contents.append( MessageContent( @@ -239,12 +235,12 @@ class DiscordAdapter(PlatformAdapter): ) ) - # 4. 贴纸 (Stickers) + # 4. 贴纸处理 (Stickers) if raw_msg.stickers: for sticker in raw_msg.stickers: contents.append( MessageContent( - type=MessageContentType.IMAGE, # 贴纸视为图片 + type=MessageContentType.IMAGE, # 贴纸在逻辑上按图片处理 url=sticker.url, raw_data={ "sticker_id": str(sticker.id), @@ -253,7 +249,7 @@ class DiscordAdapter(PlatformAdapter): ) ) - # 发送者名片 (昵称) + # 确定发送者的显示名称(服务器昵称 > 全局名称 > 用户名) sender_card = None if hasattr(raw_msg.author, "nick") and raw_msg.author.nick: sender_card = raw_msg.author.nick @@ -263,8 +259,8 @@ class DiscordAdapter(PlatformAdapter): return UnifiedMessage( message_id=str(raw_msg.id), sender_id=str(raw_msg.author.id), - sender_name=raw_msg.author.name, # 用户名 - sender_card=sender_card, # 服务器昵称 + sender_name=raw_msg.author.name, + sender_card=sender_card, group_id=group_id, text_content=raw_msg.content, contents=tuple(contents), @@ -275,16 +271,13 @@ class DiscordAdapter(PlatformAdapter): else None, ) except Exception as e: - logger.error(f"转换 Discord 消息失败: {e}") + logger.debug(f"Discord 消息转换错误: {e}") return None def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: - """ - 将统一消息格式转换为 OneBot 兼容格式 (用于兼容 MessageHandler) - """ + """将统一格式降级转换为 OneBot 风格的字典,以适配下游组件。""" raw_messages = [] for msg in messages: - # 构造 OneBot 风格的消息字典 raw_msg = { "message_id": msg.message_id, "group_id": msg.group_id, @@ -295,13 +288,13 @@ class DiscordAdapter(PlatformAdapter): "card": msg.sender_card, }, "message": [], + "user_id": msg.sender_id, # 后向兼容 } - # 构造消息链 for content in msg.contents: if content.type == MessageContentType.TEXT: raw_msg["message"].append( - {"type": "text", "data": {"text": content.text}} + {"type": "text", "data": {"text": content.text or ""}} ) elif content.type == MessageContentType.IMAGE: raw_msg["message"].append( @@ -322,13 +315,11 @@ class DiscordAdapter(PlatformAdapter): "data": {"id": content.raw_data["reply_id"]}, } ) - # 其他类型暂忽略或作为未知 raw_messages.append(raw_msg) - return raw_messages - # ==================== IMessageSender ==================== + # ==================== IMessageSender 实现 ==================== async def send_text( self, @@ -336,7 +327,17 @@ class DiscordAdapter(PlatformAdapter): text: str, reply_to: str | None = None, ) -> bool: - """发送文本消息到 Discord 频道""" + """ + 向 Discord 频道发送文本消息。 + + Args: + group_id (str): 频道 ID + text (str): 文本内容 + reply_to (str, optional): 引用的消息 ID + + Returns: + bool: 是否发送成功 + """ if not discord: return False @@ -352,17 +353,16 @@ class DiscordAdapter(PlatformAdapter): reference = None if reply_to: try: - # 创建 MessageReference reference = discord.MessageReference( message_id=int(reply_to), channel_id=channel_id ) - except ValueError: + except (ValueError, TypeError): pass await channel.send(content=text, reference=reference) return True except Exception as e: - logger.error(f"Discord 发送文本失败: {e}") + logger.error(f"Discord 文本发送失败: {e}") return False async def send_image( @@ -371,7 +371,19 @@ class DiscordAdapter(PlatformAdapter): image_path: str, caption: str = "", ) -> bool: - """发送图片到 Discord 频道""" + """ + 向 Discord 频道异步发送图片。 + + 对于远程 URL,会先下载到内存再通过 Discord API 发送。 + + Args: + group_id (str): 频道 ID + image_path (str): 本地路径或 http URL + caption (str): 可选说明文字 + + Returns: + bool: 是否发送成功 + """ if not discord: return False @@ -384,11 +396,9 @@ class DiscordAdapter(PlatformAdapter): if not hasattr(channel, "send"): return False - # 处理本地文件或 URL file_to_send = None if image_path.startswith(("http://", "https://")): - # URL 方式,需要下载图片后作为文件发送 - # 因为 Discord 无法访问内部 URL + # 远程图片:下载 -> 内存 Object -> Discord from io import BytesIO import aiohttp @@ -397,23 +407,21 @@ class DiscordAdapter(PlatformAdapter): async with aiohttp.ClientSession() as session: async with session.get( image_path, timeout=aiohttp.ClientTimeout(total=30) - ) as response: - if response.status == 200: - image_data = await response.read() - # 从 URL 提取文件名 + ) as resp: + if resp.status == 200: + data = await resp.read() + # 尽量保留原始后缀 filename = image_path.split("/")[-1].split("?")[0] if not filename.lower().endswith( (".png", ".jpg", ".jpeg", ".gif", ".webp") ): - filename = "report.png" + filename = "daily_report_image.png" + file_to_send = discord.File( - BytesIO(image_data), filename=filename + BytesIO(data), filename=filename ) else: - logger.warning( - f"Discord 下载图片失败,状态码: {response.status}" - ) - # 降级:直接发送 URL + # 兜底:如果下载失败,直接发 URL 给 Discord 尝试自动解析 content = ( f"{caption}\n{image_path}" if caption @@ -421,24 +429,23 @@ class DiscordAdapter(PlatformAdapter): ) await channel.send(content=content) return True - except Exception as download_error: - logger.warning(f"Discord 下载图片异常: {download_error}") - # 降级:直接发送 URL + except Exception as de: + logger.warning( + f"Discord 远程图片下载失败: {de},将回退为发送 URL。" + ) content = f"{caption}\n{image_path}" if caption else image_path await channel.send(content=content) return True else: - # 本地文件 + # 本地图片 file_to_send = discord.File(image_path) if file_to_send: - await channel.send( - content=caption if caption else None, file=file_to_send - ) + await channel.send(content=caption or None, file=file_to_send) return True except Exception as e: - logger.error(f"Discord 发送图片失败: {e}") + logger.error(f"Discord 图片发送失败: {e}") return False async def send_file( @@ -447,7 +454,7 @@ class DiscordAdapter(PlatformAdapter): file_path: str, filename: str | None = None, ) -> bool: - """发送文件到 Discord 频道""" + """向 Discord 频道上传任意文件。""" if not discord: return False @@ -464,13 +471,13 @@ class DiscordAdapter(PlatformAdapter): await channel.send(file=file_to_send) return True except Exception as e: - logger.error(f"Discord 发送文件失败: {e}") + logger.error(f"Discord 文件发送失败: {e}") return False - # ==================== IGroupInfoRepository ==================== + # ==================== IGroupInfoRepository 实现 ==================== async def get_group_info(self, group_id: str) -> UnifiedGroup | None: - """获取 Discord 频道信息""" + """解析 Discord 频道及所属服务器的基本信息。""" if not discord: return None @@ -480,17 +487,16 @@ class DiscordAdapter(PlatformAdapter): if not channel: channel = await self.bot.fetch_channel(channel_id) - # 尝试获取 Guild 信息 guild = getattr(channel, "guild", None) - group_name = getattr(channel, "name", str(channel.id)) + if guild: - # 如果是公会频道,可以用 Guild 信息补充 + # 群聊(服务器频道) member_count = guild.member_count owner_id = str(guild.owner_id) else: - # 私信或群组私信 - member_count = len(getattr(channel, "recipients", [])) + 1 # +1 for bot + # 私人对话(DM) + member_count = len(getattr(channel, "recipients", [])) + 1 owner_id = str(getattr(channel, "owner_id", "")) return UnifiedGroup( @@ -502,27 +508,29 @@ class DiscordAdapter(PlatformAdapter): platform="discord", ) except Exception as e: - logger.error(f"Discord 获取群组信息失败: {e}") + logger.debug(f"Discord 获取群组信息错误: {e}") return None async def get_group_list(self) -> list[str]: - """获取机器人所在的所有频道 ID (仅列出 TextChannel)""" + """列出机器人所在服务器中所有可访问的文本频道 ID。""" if not discord: return [] try: - # 遍历所有 Guilds 和 Channels channel_ids = [] for guild in self._discord_client.guilds: for channel in guild.text_channels: channel_ids.append(str(channel.id)) return channel_ids - except Exception as e: - logger.error(f"Discord 获取群组列表失败: {e}") + except Exception: return [] async def get_member_list(self, group_id: str) -> list[UnifiedMember]: - """获取 Discord 服务器成员列表""" + """ + 获取频道对应的成员列表。 + + 注意:对于大型服务器,建议启用 GUILD_MEMBERS 意图以保证列表完整性。 + """ if not discord: return [] @@ -534,24 +542,18 @@ class DiscordAdapter(PlatformAdapter): guild = getattr(channel, "guild", None) if not guild: - # 非公会频道(如 DM),返回收件人 - members = [] - for user in getattr(channel, "recipients", []): - members.append( - UnifiedMember( - user_id=str(user.id), - nickname=user.display_name, - card=None, - role="member", - join_time=None, - ) + # 私聊收件人 + return [ + UnifiedMember( + user_id=str(u.id), + nickname=u.name, + card=u.display_name, + role="member", ) - return members + for u in getattr(channel, "recipients", []) + ] - # 公会频道 members = [] - # 注意:如果 member_count 很大,members 可能不全(取决于 intent 和 cache) - # 需要启用 GUILD_MEMBERS intent for member in guild.members: role = "member" if member.id == guild.owner_id: @@ -563,7 +565,7 @@ class DiscordAdapter(PlatformAdapter): UnifiedMember( user_id=str(member.id), nickname=member.name, - card=member.nick or member.global_name, # 优先显示服务器昵称 + card=member.nick or member.global_name, role=role, join_time=int(member.joined_at.timestamp()) if member.joined_at @@ -571,8 +573,7 @@ class DiscordAdapter(PlatformAdapter): ) ) return members - except Exception as e: - logger.error(f"Discord 获取成员列表失败: {e}") + except Exception: return [] async def get_member_info( @@ -580,11 +581,12 @@ class DiscordAdapter(PlatformAdapter): group_id: str, user_id: str, ) -> UnifiedMember | None: - """获取特定成员信息""" + """获取并解析特定 Discord 用户的身份信息。""" if not discord: return None try: + uid = int(user_id) channel_id = int(group_id) channel = self.bot.get_channel(channel_id) if not channel: @@ -592,28 +594,21 @@ class DiscordAdapter(PlatformAdapter): guild = getattr(channel, "guild", None) if not guild: - # 私信,尝试 fetch user - user = await self.bot.fetch_user(int(user_id)) + # 跨频道/私聊探测 + user = await self.bot.fetch_user(uid) return UnifiedMember( - user_id=str(user.id), - nickname=user.name, - card=user.display_name, - role="member", - join_time=None, + user_id=str(user.id), nickname=user.name, card=user.display_name ) - member = guild.get_member(int(user_id)) - if not member: - member = await guild.fetch_member(int(user_id)) - + member = guild.get_member(uid) or await guild.fetch_member(uid) if not member: return None - role = "member" - if member.id == guild.owner_id: - role = "owner" - elif member.guild_permissions.administrator: - role = "admin" + role = ( + "owner" + if member.id == guild.owner_id + else ("admin" if member.guild_permissions.administrator else "member") + ) return UnifiedMember( user_id=str(member.id), @@ -624,52 +619,35 @@ class DiscordAdapter(PlatformAdapter): if member.joined_at else None, ) - except Exception as e: - logger.error(f"Discord 获取成员信息失败: {e}") + except Exception: return None - # ==================== IAvatarRepository ==================== + # ==================== IAvatarRepository 实现 ==================== async def get_user_avatar_url( self, user_id: str, size: int = 100, ) -> str | None: - """获取 Discord 用户头像 URL""" - if not discord: - logger.warning("[群分析插件 DiscordAdapter] py-cord 未安装") + """根据 Discord 用户 ID 动态解析其头像 CDN 地址。""" + if not discord or not self._discord_client: return None try: - logger.debug(f"[群分析插件 DiscordAdapter] 正在获取用户头像 {user_id}") - if not self._discord_client: - logger.warning("[群分析插件 DiscordAdapter] Discord 客户端未准备就绪") - return None - - user = self._discord_client.get_user(int(user_id)) - if not user: - logger.debug( - f"[群分析插件 DiscordAdapter] 用户 {user_id} 不在缓存中,正在获取..." - ) - user = await self._discord_client.fetch_user(int(user_id)) + uid = int(user_id) + user = self._discord_client.get_user( + uid + ) or await self._discord_client.fetch_user(uid) if user: - # 调整 size 到最接近的 2 的幂次方 - allowed_sizes = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] + # 自动对齐 Discord 支持的尺寸 (2的幂) + allowed_sizes = (16, 32, 64, 128, 256, 512, 1024, 2048, 4096) target_size = min(allowed_sizes, key=lambda x: abs(x - size)) + return user.display_avatar.with_size(target_size).url - url = user.display_avatar.with_size(target_size).url - logger.debug( - f"[群分析插件 DiscordAdapter] 获取用户头像 {user_id} 成功: {url}" - ) - return url - - logger.warning(f"[群分析插件 DiscordAdapter] 用户 {user_id} 未找到") return None except Exception as e: - logger.error( - f"[群分析插件 DiscordAdapter] 获取用户头像 {user_id} 失败: {e}" - ) + logger.debug(f"Discord 获取用户头像 URL 错误: {e}") return None async def get_user_avatar_data( @@ -677,8 +655,7 @@ class DiscordAdapter(PlatformAdapter): user_id: str, size: int = 100, ) -> str | None: - """获取 Discord 用户头像 Base64 数据""" - # 暂时只返回 None,让上层使用 URL + """暂不提供 Base64 转换服务,优先使用 CDN 链接。""" return None async def get_group_avatar_url( @@ -686,19 +663,17 @@ class DiscordAdapter(PlatformAdapter): group_id: str, size: int = 100, ) -> str | None: - """获取 Discord 服务器图标 URL""" + """获取 Discord 服务器(Guild)的图标地址。""" if not discord: return None try: - channel_id = int(group_id) - channel = self.bot.get_channel(channel_id) - if not channel: - channel = await self.bot.fetch_channel(channel_id) - + channel = self.bot.get_channel( + int(group_id) + ) or await self.bot.fetch_channel(int(group_id)) guild = getattr(channel, "guild", None) if guild and guild.icon: - allowed_sizes = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] + allowed_sizes = (16, 32, 64, 128, 256, 512, 1024, 2048, 4096) target_size = min(allowed_sizes, key=lambda x: abs(x - size)) return guild.icon.with_size(target_size).url return None @@ -710,8 +685,5 @@ class DiscordAdapter(PlatformAdapter): user_ids: list[str], size: int = 100, ) -> dict[str, str | None]: - """批量获取 Discord 用户头像 URL""" - return { - user_id: await self.get_user_avatar_url(user_id, size) - for user_id in user_ids - } + """批量获取头像的最佳实践。""" + return {uid: await self.get_user_avatar_url(uid, size) for uid in user_ids} diff --git a/src/infrastructure/platform/adapters/onebot_adapter.py b/src/infrastructure/platform/adapters/onebot_adapter.py index 789cea5..98d6265 100644 --- a/src/infrastructure/platform/adapters/onebot_adapter.py +++ b/src/infrastructure/platform/adapters/onebot_adapter.py @@ -20,37 +20,56 @@ from ....domain.value_objects.unified_message import ( MessageContentType, UnifiedMessage, ) +from ....utils.logger import logger from ..base import PlatformAdapter class OneBotAdapter(PlatformAdapter): + """ + 具体实现:OneBot v11 平台适配器 + + 支持 NapCat, go-cqhttp, Lagrange 等遵循 OneBot v11 协议的 QQ 机器人框架。 + 实现了消息获取、发送、群组管理及头像解析等全套功能。 + + Attributes: + platform_name (str): 平台硬编码标识 'onebot' + bot_self_ids (list[str]): 机器人自身的 QQ 号列表,用于消息过滤 + """ + platform_name = "onebot" - """OneBot v11 协议适配器""" - - # QQ 头像 URL 模板 + # QQ 头像服务 URL 模板 USER_AVATAR_TEMPLATE = "https://q1.qlogo.cn/g?b=qq&nk={user_id}&s={size}" USER_AVATAR_HD_TEMPLATE = ( "https://q.qlogo.cn/headimg_dl?dst_uin={user_id}&spec={size}&img_type=jpg" ) GROUP_AVATAR_TEMPLATE = "https://p.qlogo.cn/gh/{group_id}/{group_id}/{size}/" - AVAILABLE_SIZES = [40, 100, 140, 160, 640] + # OneBot 服务支持的头像尺寸像素 + AVAILABLE_SIZES = (40, 100, 140, 160, 640) - def __init__(self, bot_instance: Any, config: dict = None): + def __init__(self, bot_instance: Any, config: dict | None = None): + """ + 初始化 OneBot 适配器。 + + Args: + bot_instance (Any): 外部传入的机器人对象 + config (dict, optional): 插件配置,用于提取机器人自身的 QQ 号供过滤用 + """ super().__init__(bot_instance, config) self.bot_self_ids = ( [str(id) for id in config.get("bot_qq_ids", [])] if config else [] ) def _init_capabilities(self) -> PlatformCapabilities: + """返回预定义的 OneBot v11 能力集。""" return ONEBOT_V11_CAPABILITIES def _get_nearest_size(self, requested_size: int) -> int: - """获取最接近的可用尺寸""" + """从支持的尺寸列表中找到最接近请求尺寸的一个。""" return min(self.AVAILABLE_SIZES, key=lambda x: abs(x - requested_size)) - # ==================== IMessageRepository ==================== + # ==================== IMessageRepository 实现 ==================== async def fetch_messages( self, @@ -59,12 +78,23 @@ class OneBotAdapter(PlatformAdapter): max_count: int = 1000, before_id: str | None = None, ) -> list[UnifiedMessage]: - """获取群组消息历史""" + """ + 从 OneBot 后端拉取群组历史消息。 + Args: + group_id (str): 群号 + days (int): 拉取过去几天的消息 + max_count (int): 最大拉取条数 + before_id (str, optional): 锚点消息 ID(部分后端支持) + + Returns: + list[UnifiedMessage]: 统一格式的消息列表 + """ if not hasattr(self.bot, "call_action"): return [] try: + # 调用 OneBot 标准 API: get_group_msg_history result = await self.bot.call_action( "get_group_msg_history", group_id=int(group_id), @@ -80,9 +110,11 @@ class OneBotAdapter(PlatformAdapter): messages = [] for raw_msg in result.get("messages", []): msg_time = datetime.fromtimestamp(raw_msg.get("time", 0)) + # 时间范围过滤 if not (start_time <= msg_time <= end_time): continue + # 身份过滤(排除机器人自己) sender_id = str(raw_msg.get("sender", {}).get("user_id", "")) if sender_id in self.bot_self_ids: continue @@ -91,18 +123,21 @@ class OneBotAdapter(PlatformAdapter): if unified: messages.append(unified) + # 确保按时间顺序排列 messages.sort(key=lambda m: m.timestamp) return messages - except Exception: + except Exception as e: + logger.warning(f"OneBot 获取消息失败: {e}") return [] def _convert_message(self, raw_msg: dict, group_id: str) -> UnifiedMessage | None: - """将 OneBot 消息转换为统一格式""" + """内部方法:将 OneBot 原生原始消息字典转换为 UnifiedMessage 值对象。""" try: sender = raw_msg.get("sender", {}) message_chain = raw_msg.get("message", []) + # 兼容性处理:如果是字符串格式的 message,转换为列表格式 if isinstance(message_chain, str): message_chain = [{"type": "text", "data": {"text": message_chain}}] @@ -181,6 +216,7 @@ class OneBotAdapter(PlatformAdapter): MessageContent(type=MessageContentType.UNKNOWN, raw_data=seg) ) + # 提取回复 ID reply_to = None for c in contents: if c.type == MessageContentType.REPLY and c.raw_data: @@ -200,18 +236,24 @@ class OneBotAdapter(PlatformAdapter): reply_to_id=reply_to, ) - except Exception: + except Exception as e: + logger.debug(f"OneBot _convert_message 错误: {e}") return None def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: """ - 将统一消息格式转换为 OneBot 原生格式。 + 将统一格式转换回 OneBot v11 原生字典格式。 - 用于与现有分析器的向后兼容。 + 使现有业务逻辑逻辑无需重构即可使用新流水。 + + Args: + messages (list[UnifiedMessage]): 统一消息列表 + + Returns: + list[dict]: OneBot 格式的消息字典列表 """ raw_messages = [] for msg in messages: - # 重建 OneBot 消息格式 message_chain = [] for content in msg.contents: if content.type == MessageContentType.TEXT: @@ -265,14 +307,14 @@ class OneBotAdapter(PlatformAdapter): }, "message": message_chain, "group_id": msg.group_id, - "raw_message": msg.text_content, # 添加 raw_message 兼容字段 - "user_id": msg.sender_id, # 添加 user_id 兼容字段 + "raw_message": msg.text_content, + "user_id": msg.sender_id, } raw_messages.append(raw_msg) return raw_messages - # ==================== IMessageSender ==================== + # ==================== IMessageSender 实现 ==================== async def send_text( self, @@ -280,7 +322,17 @@ class OneBotAdapter(PlatformAdapter): text: str, reply_to: str | None = None, ) -> bool: - """发送文本消息""" + """ + 向群组发送文本消息。 + + Args: + group_id (str): 目标群号 + text (str): 消息内容 + reply_to (str, optional): 引用回复的消息 ID + + Returns: + bool: 是否发送成功 + """ try: message = [{"type": "text", "data": {"text": text}}] @@ -293,7 +345,8 @@ class OneBotAdapter(PlatformAdapter): message=message, ) return True - except Exception: + except Exception as e: + logger.error(f"OneBot 文本发送失败: {e}") return False async def send_image( @@ -302,7 +355,17 @@ class OneBotAdapter(PlatformAdapter): image_path: str, caption: str = "", ) -> bool: - """发送图片消息""" + """ + 向群组发送图片。 + + Args: + group_id (str): 目标群号 + image_path (str): 本地文件路径或远程 URL + caption (str): 图片下方可选的文字说明 + + Returns: + bool: 是否成功 + """ try: message = [] @@ -322,7 +385,8 @@ class OneBotAdapter(PlatformAdapter): message=message, ) return True - except Exception: + except Exception as e: + logger.error(f"OneBot 图片发送失败: {e}") return False async def send_file( @@ -331,22 +395,33 @@ class OneBotAdapter(PlatformAdapter): file_path: str, filename: str | None = None, ) -> bool: - """发送文件""" + """ + 通过群文件功能上传并发送文件。 + + Args: + group_id (str): 目标群号 + file_path (str): 本地文件绝对路径 + filename (str, optional): 显示的文件名,默认为路径尾部 + + Returns: + bool: 上传任务启动是否成功 + """ try: await self.bot.call_action( "upload_group_file", group_id=int(group_id), file=file_path, - name=filename or file_path.split("/")[-1], + name=filename or file_path.replace("\\", "/").split("/")[-1], ) return True - except Exception: + except Exception as e: + logger.error(f"OneBot 文件发送失败: {e}") return False - # ==================== IGroupInfoRepository ==================== + # ==================== IGroupInfoRepository 实现 ==================== async def get_group_info(self, group_id: str) -> UnifiedGroup | None: - """获取群组信息""" + """获取指定群组的基础元数据。""" try: result = await self.bot.call_action( "get_group_info", @@ -368,7 +443,7 @@ class OneBotAdapter(PlatformAdapter): return None async def get_group_list(self) -> list[str]: - """获取机器人所在的所有群组 ID""" + """获取当前机器人已加入的所有群组 ID 列表。""" try: result = await self.bot.call_action("get_group_list") return [str(g.get("group_id", "")) for g in result or []] @@ -376,7 +451,7 @@ class OneBotAdapter(PlatformAdapter): return [] async def get_member_list(self, group_id: str) -> list[UnifiedMember]: - """获取群组成员列表""" + """拉取整个群组成员列表。""" try: result = await self.bot.call_action( "get_group_member_list", @@ -403,7 +478,7 @@ class OneBotAdapter(PlatformAdapter): group_id: str, user_id: str, ) -> UnifiedMember | None: - """获取特定成员信息""" + """拉取特定群成员的详细名片及角色信息。""" try: result = await self.bot.call_action( "get_group_member_info", @@ -424,15 +499,25 @@ class OneBotAdapter(PlatformAdapter): except Exception: return None - # ==================== IAvatarRepository ==================== + # ==================== IAvatarRepository 实现 ==================== async def get_user_avatar_url( self, user_id: str, size: int = 100, ) -> str | None: - """获取 QQ 用户头像 URL""" + """ + 拼凑 QQ 官方服务地址获取用户头像。 + + Args: + user_id (str): QQ 号 + size (int): 期望像素大小 + + Returns: + str: 格式化后的 URL + """ actual_size = self._get_nearest_size(size) + # 640 使用 HD 接口更清晰 if actual_size >= 640: return self.USER_AVATAR_HD_TEMPLATE.format(user_id=user_id, size=640) return self.USER_AVATAR_TEMPLATE.format(user_id=user_id, size=actual_size) @@ -442,7 +527,9 @@ class OneBotAdapter(PlatformAdapter): user_id: str, size: int = 100, ) -> str | None: - """获取 QQ 用户头像 Base64 数据""" + """ + 通过网络下载头像并转换为 Base64 格式,适用于前端模板直接渲染。 + """ url = await self.get_user_avatar_url(user_id, size) if not url: return None @@ -457,8 +544,8 @@ class OneBotAdapter(PlatformAdapter): b64 = base64.b64encode(data).decode("utf-8") content_type = resp.headers.get("Content-Type", "image/png") return f"data:{content_type};base64,{b64}" - except Exception: - pass + except Exception as e: + logger.debug(f"OneBot 头像下载失败: {e}") return None async def get_group_avatar_url( @@ -466,7 +553,7 @@ class OneBotAdapter(PlatformAdapter): group_id: str, size: int = 100, ) -> str | None: - """获取 QQ 群头像 URL""" + """获取 QQ 群头像地址。""" actual_size = self._get_nearest_size(size) return self.GROUP_AVATAR_TEMPLATE.format(group_id=group_id, size=actual_size) @@ -475,7 +562,7 @@ class OneBotAdapter(PlatformAdapter): user_ids: list[str], size: int = 100, ) -> dict[str, str | None]: - """批量获取 QQ 用户头像 URL(无需 API 调用)""" + """批量映射 QQ 号到其头像 URL 地址。""" return { user_id: await self.get_user_avatar_url(user_id, size) for user_id in user_ids diff --git a/src/infrastructure/platform/base.py b/src/infrastructure/platform/base.py index 6d1957a..e8d5685 100644 --- a/src/infrastructure/platform/base.py +++ b/src/infrastructure/platform/base.py @@ -19,47 +19,73 @@ class PlatformAdapter( IMessageRepository, IMessageSender, IGroupInfoRepository, IAvatarRepository, ABC ): """ - 平台适配器基类 + 基础设施:平台适配器基类 - 组合消息仓储、消息发送、群组信息和头像接口。 - 每个平台适配器继承此类并实现所有方法。 + 继承自多个领域接口(仓储、发送器、群组信息、头像), + 充当领域层与具体聊天平台(如 OneBot, Discord)之间的中转站。 + + Attributes: + bot (Any): 平台对应的机器人 SDK 实例 + config (dict): 针对该平台的特定配置 """ - def __init__(self, bot_instance: Any, config: dict = None): + def __init__(self, bot_instance: Any, config: dict | None = None): + """ + 初始化平台适配器。 + + Args: + bot_instance (Any): 后端机器人实例 + config (dict, optional): 平台特定配置项 + """ self.bot = bot_instance self.config = config or {} self._capabilities: PlatformCapabilities | None = None @property def capabilities(self) -> PlatformCapabilities: - """平台能力(延迟初始化)""" + """ + 获取当前平台的能力描述对象。 + + 采用延迟加载机制,在首次访问时调用 `_init_capabilities`。 + + Returns: + PlatformCapabilities: 平台能力对象 + """ if self._capabilities is None: self._capabilities = self._init_capabilities() return self._capabilities @abstractmethod def _init_capabilities(self) -> PlatformCapabilities: - """初始化平台能力,子类必须实现""" + """ + 初始化并返回当前平台的能力定义。 + + 子类必须实现此方法以声明其对历史记录、图片发送等功能的支持情况。 + + Returns: + PlatformCapabilities: 初始化后的能力对象 + """ raise NotImplementedError def get_capabilities(self) -> PlatformCapabilities: + """获取平台能力的便捷入口。""" return self.capabilities def get_platform_name(self) -> str: + """获取当前适配器的平台标识名称。""" return self.capabilities.platform_name @abstractmethod def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: """ - 将统一消息格式转换为平台原生格式。 + 将平台无关的统一消息列表转换回当前平台的原生字典格式。 - 此方法由各平台适配器实现,返回该平台的原生消息格式。 - 用于与现有分析器的向后兼容。 + 此方法主要用于向后兼容,使新的统一接口能与依赖原生数据结构的旧版分析逻辑协同工作。 - 参数: - messages: UnifiedMessage 列表 + Args: + messages (list[UnifiedMessage]): 待转换的统一消息列表 - 返回: - 平台原生格式的消息字典列表 + Returns: + list[dict]: 转换后的平台原生消息字典列表 """ raise NotImplementedError diff --git a/src/models/data_models.py b/src/models/data_models.py index 9d47f85..4492767 100644 --- a/src/models/data_models.py +++ b/src/models/data_models.py @@ -25,14 +25,6 @@ class UserTitle: mbti: str reason: str - @property - def qq(self) -> int: - """兼容旧字段""" - try: - return int(self.user_id) if self.user_id else 0 - except ValueError: - return 0 - @dataclass class GoldenQuote: @@ -43,14 +35,6 @@ class GoldenQuote: reason: str user_id: str = "" # 原 qq 字段 - @property - def qq(self) -> int: - """兼容旧字段""" - try: - return int(self.user_id) if self.user_id else 0 - except ValueError: - return 0 - @dataclass class TokenUsage: diff --git a/src/reports/generators.py b/src/reports/generators.py index 11c667e..df62c28 100644 --- a/src/reports/generators.py +++ b/src/reports/generators.py @@ -105,7 +105,7 @@ class ReportGenerator: if image_options.get("type") == "png": image_options["quality"] = None - logger.info(f"尝试渲染策略: {image_options}") + logger.info(f"正在尝试渲染策略: {image_options}") image_url = await html_render_func( html_content, # 渲染后的HTML内容 {}, # 空数据字典,因为数据已包含在HTML中 @@ -117,7 +117,7 @@ class ReportGenerator: logger.info(f"图片生成成功 ({image_options}): {image_url}") return image_url, html_content else: - logger.warning(f"渲染策略 {image_options} 返回空URL") + logger.warning(f"渲染策略 {image_options} 返回空 URL") except Exception as e: logger.warning(f"渲染策略 {image_options} 失败: {e}") @@ -371,17 +371,20 @@ class ReportGenerator: except Exception as e: logger.warning(f"使用 custom avatar_getter 获取头像失败: {e}") - # 2. 如果没有 avatar_getter 或获取失败,使用默认 QQ 头像逻辑(仅当 user_id 看起来像 QQ 号时?) - # 为保持兼容性,如果 avatar_url 仍为 None,且不强制禁用 QQ 默认,则使用 QQ 逻辑 + # 2. 如果没有 avatar_getter 或获取失败,使用默认头像逻辑 + # 为保持兼容性,如果 avatar_url 仍为 None,则尝试常见的头像服务 if not avatar_url: if ( user_id.isdigit() and 5 <= len(user_id) <= 12 - ): # 简单判断是否可能是 QQ 号 + ): # 简单判断是否可能是数字 ID + # 对于数字 ID,使用通用的头像服务作为后备 avatar_url = ( f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=100" ) else: - return None # 非 QQ 号且无 avatar_getter,返回 None 使用默认占位符 + return ( + None # 非数字 ID 且无 avatar_getter,返回 None 使用默认占位符 + ) if not avatar_url: return None diff --git a/src/shared/constants.py b/src/shared/constants.py index 4c5e327..e4c4149 100644 --- a/src/shared/constants.py +++ b/src/shared/constants.py @@ -6,7 +6,11 @@ from enum import Enum class Platform(str, Enum): - """平台枚举类""" + """ + 支持的聊天平台枚举 + + 定义了插件适配的所有基础通讯平台标识。 + """ ONEBOT = "onebot" AIOCQHTTP = "aiocqhttp" @@ -17,7 +21,11 @@ class Platform(str, Enum): class TaskStatus(str, Enum): - """任务状态枚举类""" + """ + 分析任务执行状态枚举 + + 用于在异步处理流水线中标记分析任务的生命阶段。 + """ PENDING = "pending" RUNNING = "running" @@ -27,7 +35,11 @@ class TaskStatus(str, Enum): class ContentType(str, Enum): - """消息内容类型枚举类""" + """ + 统一消息内容类型枚举 + + 将不同平台(OneBot, Discord 等)的消息片段抽象为统一的类型体系。 + """ TEXT = "text" IMAGE = "image" @@ -42,7 +54,11 @@ class ContentType(str, Enum): class ReportFormat(str, Enum): - """报告格式枚举类""" + """ + 分析报告导出格式枚举 + + 控制最终呈现给用户的报告呈现形式。 + """ TEXT = "text" MARKDOWN = "markdown" diff --git a/src/shared/trace_context.py b/src/shared/trace_context.py index f89707e..40998a4 100644 --- a/src/shared/trace_context.py +++ b/src/shared/trace_context.py @@ -19,9 +19,18 @@ _current_trace: ContextVar[Optional["TraceContext"]] = ContextVar( @dataclass class TraceContext: """ - 用于在插件中追踪请求的上下文。 + 核心组件:全链路追踪上下文 (Tracing Context) - 提供用于调试和监控的关联 ID 和计时信息。 + 该组件用于在复杂的异步分析流程中关联日志、耗时统计及元数据。 + 它不仅提供了 TraceId 的生成与传递,还集成了毫秒级的性能打点(Checkpoint)功能。 + + Attributes: + trace_id (str): 链路唯一标识码,默认为 UUID 前 8 位 + group_id (str): 当前关联的群组 ID + platform (str): 当前消息所属平台 + operation (str): 当前执行的操作名称 (如 'DAILY_ANALYSIS') + start_time (datetime): 追踪开始的具体时刻 + metadata (dict[str, Any]): 随链路传递的额外上下文数据 """ trace_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8]) @@ -31,27 +40,27 @@ class TraceContext: start_time: datetime = field(default_factory=datetime.now) metadata: dict[str, Any] = field(default_factory=dict) - # 计时数据 + # 内部计时器,用于多阶段耗时分析 _checkpoints: dict[str, datetime] = field(default_factory=dict, init=False) def checkpoint(self, name: str) -> None: """ - 记录计时检查点。 + 在当前时间轴上设置一个命名锚点(打点)。 - 参数: - name: 检查点名称 + Args: + name (str): 锚点标识符,如 'LLM_REPLY_RECEIVED' """ self._checkpoints[name] = datetime.now() def elapsed_ms(self, from_checkpoint: str | None = None) -> float: """ - 获取经过的时间(毫秒)。 + 计算从开始或指定锚点到当前时刻经过的毫秒数。 - 参数: - from_checkpoint: 可选的起始检查点 + Args: + from_checkpoint (str, optional): 起始锚点名称。若为 None 则从链路启动时算起。 - 返回: - 经过的时间(毫秒) + Returns: + float: 经过的毫秒数 """ start = self.start_time if from_checkpoint and from_checkpoint in self._checkpoints: @@ -61,7 +70,12 @@ class TraceContext: return delta.total_seconds() * 1000 def to_dict(self) -> dict[str, Any]: - """将追踪上下文转换为字典。""" + """ + 将链路快照序列化为字典格式,便于持久化或 JSON 日志输出。 + + Returns: + dict[str, Any]: 序列化后的追踪状态 + """ return { "trace_id": self.trace_id, "group_id": self.group_id, @@ -74,17 +88,22 @@ class TraceContext: } def __enter__(self) -> "TraceContext": - """进入上下文管理器。""" + """进入上下文管理器,将当前实例绑定到当前协程上下文。""" _current_trace.set(self) return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: - """退出上下文管理器。""" + """退出上下文管理器,清理绑定状态。""" _current_trace.set(None) @classmethod def current(cls) -> Optional["TraceContext"]: - """获取当前追踪上下文。""" + """ + 静态获取当前协程活跃的追踪上下文。 + + Returns: + Optional[TraceContext]: 若当前处于追踪链路中则返回实例,否则返回 None + """ return _current_trace.get() @classmethod @@ -95,15 +114,15 @@ class TraceContext: operation: str = "", ) -> "TraceContext": """ - 获取当前追踪或创建新追踪。 + 尝试获取现有链路,若不存在则按需创建一个。 - 参数: - group_id: 群组标识符 - platform: 平台名称 - operation: 操作名称 + Args: + group_id (str): 群组 ID + platform (str): 平台名称 + operation (str): 操作描述 - 返回: - TraceContext 实例 + Returns: + TraceContext: 活跃或新生成的实例 """ current = cls.current() if current: @@ -118,10 +137,10 @@ class TraceContext: def get_trace_id() -> str: """ - 获取当前追踪 ID 或生成新的。 + 便捷接口:快速获取当前活跃的 TraceID 或零时生成一个临时 ID。 - 返回: - 追踪 ID 字符串 + Returns: + str: 8 位十六进制追踪 ID """ trace = TraceContext.current() if trace: @@ -135,23 +154,25 @@ def with_trace( operation: str = "", ): """ - 为函数添加追踪上下文的装饰器。 + 装饰器:自动为异步函数包裹追踪上下文。 - 参数: - group_id: 群组标识符 - platform: 平台名称 - operation: 操作名称 + Args: + group_id (str): 设置追踪的群组 + platform (str): 设置追踪的平台 + operation (str): 操作名称,默认为函数名 - 返回: - 装饰后的函数 + Returns: + Callable: 装饰后的函数 """ def decorator(func): async def wrapper(*args, **kwargs): + # 优先使用装饰器声明的 operation,否则取函数原始名称 + op_name = operation or func.__name__ with TraceContext( group_id=group_id, platform=platform, - operation=operation or func.__name__, + operation=op_name, ): return await func(*args, **kwargs) diff --git a/src/utils/helpers.py b/src/utils/helpers.py index abb2273..12cec9e 100644 --- a/src/utils/helpers.py +++ b/src/utils/helpers.py @@ -4,6 +4,7 @@ """ import asyncio +from typing import Any from ..analysis.llm_analyzer import LLMAnalyzer from ..analysis.statistics import UserAnalyzer @@ -13,9 +14,32 @@ from .logger import logger class MessageAnalyzer: - """消息分析器 - 整合所有分析功能""" + """ + 业务逻辑:消息分析整合器 - def __init__(self, context, config_manager, bot_manager=None): + 该类作为一个门面(Facade),将消息存储、统计计算、LLM 智能分析以及用户画像分析 + 等多个底层组件整合在一起,提供统一的消息分析流程接口。 + + Attributes: + context (Any): AstrBot 上下文环境 + config_manager (Any): 配置管理者实例 + bot_manager (Any, optional): 机器人多实例管理者 + message_handler (MessageHandler): 负责消息过滤和基础统计 + llm_analyzer (LLMAnalyzer): 负责调用大模型进行语义分析 + user_analyzer (UserAnalyzer): 负责用户活跃度及角色分析 + """ + + def __init__( + self, context: Any, config_manager: Any, bot_manager: Any | None = None + ): + """ + 初始化消息分析器。 + + Args: + context (Any): AstrBot 核心上下文 + config_manager (Any): 插件配置管理器 + bot_manager (Any, optional): 多平台机器人管理器实例 + """ self.context = context self.config_manager = config_manager self.bot_manager = bot_manager @@ -23,71 +47,91 @@ class MessageAnalyzer: self.llm_analyzer = LLMAnalyzer(context, config_manager) self.user_analyzer = UserAnalyzer(config_manager) - def _extract_bot_self_id_from_instance(self, bot_instance): - """从bot实例中提取ID(单个)""" + def _extract_bot_self_id_from_instance(self, bot_instance: Any) -> str | None: + """ + 内部方法:从不同平台的机器人实例中探测其自身 ID。 + + Args: + bot_instance (Any): 宿主机器人实例 (如 OneBot, Discord 实例) + + Returns: + str | None: 探测到的用户 ID 或 None + """ if hasattr(bot_instance, "self_id") and bot_instance.self_id: return str(bot_instance.self_id) - elif hasattr(bot_instance, "qq") and bot_instance.qq: - return str(bot_instance.qq) elif hasattr(bot_instance, "user_id") and bot_instance.user_id: return str(bot_instance.user_id) return None - def _extract_bot_qq_id_from_instance(self, bot_instance): - """从bot实例中提取QQ号(已弃用)""" - return self._extract_bot_self_id_from_instance(bot_instance) + async def set_bot_instance( + self, bot_instance: Any, platform_id: str | None = None + ) -> None: + """ + 向分析组件注入当前活跃的机器人实例。 - async def set_bot_instance(self, bot_instance, platform_id=None): - """设置bot实例(保持向后兼容)""" + Args: + bot_instance (Any): 活跃的机器人 SDK 实例 + platform_id (str, optional): 平台标识符,用于多实例路由 + """ if self.bot_manager: self.bot_manager.set_bot_instance(bot_instance, platform_id) else: - # 从bot实例提取ID并设置为列表 + # 降级逻辑:仅设置单个默认 ID bot_self_id = self._extract_bot_self_id_from_instance(bot_instance) if bot_self_id: - # 将单个ID转换为列表,保持统一处理 await self.message_handler.set_bot_self_ids([bot_self_id]) async def analyze_messages( - self, messages: list[dict], group_id: str, unified_msg_origin: str = None - ) -> dict: - """完整的消息分析流程""" + self, messages: list[dict], group_id: str, unified_msg_origin: str | None = None + ) -> dict | None: + """ + 执行完整的群消息流水化分析。 + + 包含:消息预处理 -> 词频统计 -> 活跃用户识别 -> LLM 摘要/金句提取。 + + Args: + messages (list[dict]): 待处理的原始或统一格式消息字典列表 + group_id (str): 群组 ID,用于上下文标识 + unified_msg_origin (str, optional): 统一消息来源标识 + + Returns: + dict | None: 包含 statistics, topics, user_titles, user_analysis 的字典,失败返回 None + """ try: - # 基础统计 + # 1. 基础消息统计 (耗时操作,放入线程池避免阻塞事件循环) statistics = await asyncio.to_thread( self.message_handler.calculate_statistics, messages ) - # 用户分析 + # 2. 用户维度分析 (等级、发言习惯等) user_analysis = await asyncio.to_thread( self.user_analyzer.analyze_users, messages ) - # 获取活跃用户列表 - 使用get_top_users方法,limit从配置中读取 + # 3. 筛选分析范围:提取 Top N 活跃用户用于深度称号分析 max_user_titles = self.config_manager.get_max_user_titles() top_users = self.user_analyzer.get_top_users( user_analysis, limit=max_user_titles ) logger.info( - f"获取到 {len(top_users)} 个活跃用户用于称号分析(配置上限: {max_user_titles})" + f"已为称号分析筛选出 {len(top_users)} 名活跃用户 (最大限制: {max_user_titles})" ) - # LLM分析 - 使用并发方式 + # 4. LLM 语义分析阶段 topics = [] user_titles = [] golden_quotes = [] total_token_usage = TokenUsage() - # 检查各个分析功能是否启用 + # 检查开关设置 topic_enabled = self.config_manager.get_topic_analysis_enabled() user_title_enabled = self.config_manager.get_user_title_analysis_enabled() golden_quote_enabled = ( self.config_manager.get_golden_quote_analysis_enabled() ) - # 如果三个分析都启用,使用并发执行 + # 策略:如果多项功能均开启,则通过 LLMAnalyzer 并发调用,显著降低分析总时长 if topic_enabled and user_title_enabled and golden_quote_enabled: - # 并发执行所有三个分析任务,传入活跃用户列表 ( topics, user_titles, @@ -97,7 +141,7 @@ class MessageAnalyzer: messages, user_analysis, umo=unified_msg_origin, top_users=top_users ) else: - # 如果只启用部分分析,则按需执行 + # 串行降级路径:根据开关按需串行调用 (适用于 Token 敏感或单项测试) if topic_enabled: topics, topic_tokens = await self.llm_analyzer.analyze_topics( messages, umo=unified_msg_origin @@ -109,7 +153,6 @@ class MessageAnalyzer: total_token_usage.total_tokens += topic_tokens.total_tokens if user_title_enabled: - # 传入活跃用户列表 ( user_titles, title_tokens, @@ -138,7 +181,7 @@ class MessageAnalyzer: ) total_token_usage.total_tokens += quote_tokens.total_tokens - # 更新统计数据 + # 5. 回填分析结果并组装返回字典 statistics.golden_quotes = golden_quotes statistics.token_usage = total_token_usage @@ -150,5 +193,5 @@ class MessageAnalyzer: } except Exception as e: - logger.error(f"消息分析失败: {e}") + logger.error(f"消息分析流水线执行失败: {e}") return None diff --git a/src/utils/logger.py b/src/utils/logger.py index d94766b..18530ad 100644 --- a/src/utils/logger.py +++ b/src/utils/logger.py @@ -1,15 +1,28 @@ import logging +from typing import Any from astrbot.api import logger as astrbot_logger class PluginLoggerAdapter(logging.LoggerAdapter): """ - 插件日志适配器 - 自动为日志添加 [QQ群分析] 前缀,方便区分 + 日志适配器:插件级统一日志装饰器 + + 自动向所有通过该实例输出的日志信息前缀添加 `[QQ群分析]` 标签, + 以便用户在 AstrBot 混合日志流中快速定位属于本插件的输出。 """ - def process(self, msg, kwargs): + def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: + """ + 加工日志消息,注入插件专有前缀。 + + Args: + msg (str): 原始日志消息 + kwargs (Any): 额外的日志参数映射 + + Returns: + tuple[str, Any]: (格式化后的消息, 参数) + """ return f"[QQ群分析] {msg}", kwargs diff --git a/src/utils/pdf_utils.py b/src/utils/pdf_utils.py index 70c464e..fa85a8b 100644 --- a/src/utils/pdf_utils.py +++ b/src/utils/pdf_utils.py @@ -6,18 +6,26 @@ PDF工具模块 import asyncio import sys from concurrent.futures import ThreadPoolExecutor +from typing import Any from .logger import logger class PDFInstaller: - """PDF功能安装器""" + """ + 工具组件:PDF 渲染引擎 (Playwright) 安装器 - # 类级别的线程池,用于异步下载任务 + 该组件负责管理 Playwright 及其对应浏览器内核 (Chromium) 的安装生命周期。 + 由于内核下载耗时较长且受网络波动影响,采用非阻塞的后台任务模式执行。 + """ + + # 类级别的线程池,专用于隔离耗时的 IO/Shell 操作 _executor = ThreadPoolExecutor( max_workers=1, thread_name_prefix="playwright_install" ) - _install_status = { + + # 静态安装状态追踪 + _install_status: dict[str, Any] = { "in_progress": False, "completed": False, "failed": False, @@ -25,13 +33,26 @@ class PDFInstaller: } @staticmethod - async def install_playwright(config_manager): - """安装 Playwright 依赖""" - try: - logger.info("开始安装 Playwright...") + async def install_playwright(config_manager: Any) -> str: + """ + 异步入口:安装 Playwright 环境。 - # 1. 安装 pip 包 - logger.info("正在运行 pip install playwright...") + 流程: + 1. 调用 pip 安装 `playwright` Python 包。 + 2. 验证自定义浏览器路径配置。 + 3. 若无自定义路径,则触发浏览器内核安装。 + + Args: + config_manager (Any): 配置管理实例,用于读取/设置安装状态。 + + Returns: + str: 安装阶段提示信息 + """ + try: + logger.info("正在初始化 Playwright 安装流程...") + + # 1. 下载并安装库文件 + logger.info("第一步:正在运行 pip install playwright...") process = await asyncio.create_subprocess_exec( sys.executable, "-m", @@ -45,52 +66,58 @@ class PDFInstaller: stdout, stderr = await process.communicate() if process.returncode != 0: - error_msg = stderr.decode() - logger.error(f"playwright pip 安装失败: {error_msg}") + error_msg = stderr.decode().strip() + logger.error(f"Playwright 库安装失败: {error_msg}") return f"❌ pip install playwright 失败: {error_msg}" - logger.info("pip 包安装成功,检查是否需要安装浏览器内核...") + logger.info("第一步完成。正在检查浏览器内核...") - # 2. 检查自定义路径 + # 2. 检查自定义路径:若用户已手动提供内核,则跳过下载步骤 from pathlib import Path custom_path = config_manager.get_browser_path() if custom_path and Path(custom_path).exists(): - logger.info( - f"检测到自定义浏览器路径: {custom_path},将跳过 Chromium 内核安装。" - ) - return f"✅ Playwright 包安装成功。检测到自定义浏览器路径 `{custom_path}`,已跳过浏览器内核安装。您可以现在尝试生成 PDF。" + logger.info(f"检测到自定义浏览器路径: {custom_path}。跳过内核下载。") + return f"✅ Playwright 库已就绪。已检测到自定义浏览器 `{custom_path}`,无需额外安装内核。您可以直接开始生成 PDF。" - # 3. 安装浏览器内核 + # 3. 部署浏览器内核 return await PDFInstaller.install_system_deps() except Exception as e: - logger.error(f"安装 playwright 时出错: {e}") + logger.error(f"Playwright 设置过程中出错: {e}") return f"❌ 安装过程中出错: {str(e)}" @staticmethod - async def install_system_deps(): - """安装系统依赖 (运行 playwright install chromium)""" + async def install_system_deps() -> str: + """ + 触发浏览器内核的后台异步安装流程。 + + 该方法检查防重入状态,并立即返回任务启动信息,不会阻塞主线程。 + + Returns: + str: 任务排队状态提示 + """ try: - # 检查是否已经在安装中 if PDFInstaller._install_status["in_progress"]: - return "⏳ 浏览器内核正在后台安装中,请稍候..." + return "⏳ 浏览器内核正在后台部署中,请稍后检查日志或状态。" - PDFInstaller._install_status["in_progress"] = True - PDFInstaller._install_status["completed"] = False - PDFInstaller._install_status["failed"] = False - PDFInstaller._install_status["error_message"] = None + PDFInstaller._install_status.update( + { + "in_progress": True, + "completed": False, + "failed": False, + "error_message": None, + } + ) - logger.info("启动后台任务安装 Chromium...") + logger.info("正在启动后台线程以部署 Chromium 内核...") asyncio.create_task(PDFInstaller._background_playwright_install()) - return """🚀 浏览器内核安装任务已启动 - -正在运行 `playwright install chromium`... -这可能需要几分钟时间,取决于网络速度。 -安装过程不会阻塞 Bot 的正常运行。 -下载完成后平台日志会显示安装完成的日志。 -""" + return ( + "🚀 浏览器内核安装任务已成功在后台启动。\n\n" + "程序正在执行 `playwright install chromium`,由于体积较大,通常需花费 2-5 分钟。\n" + "此过程不会影响机器人正常响应。安装完成后,系统日志将进行通知。" + ) except Exception as e: PDFInstaller._install_status["in_progress"] = False @@ -98,13 +125,14 @@ class PDFInstaller: return f"❌ 启动安装任务失败: {e}" @staticmethod - async def _background_playwright_install(): - """后台运行 playwright install""" + async def _background_playwright_install() -> None: + """ + 底层宿主任务:驱动系统 shell 执行浏览器二进制文件部署。 + """ try: - logger.info("开始运行 playwright install chromium...") + logger.info("正在执行二进制文件:playwright install chromium") - # 使用 shell 命令确保能找到 path 中的 playwright - # 或者使用 python -m playwright install chromium + # 通过当前 Python 解释器环境调用子模块,确保环境隔离 process = await asyncio.create_subprocess_exec( sys.executable, "-m", @@ -115,48 +143,50 @@ class PDFInstaller: stderr=asyncio.subprocess.PIPE, ) - # 等待完成,设置较长的超时 stdout, stderr = await process.communicate() if process.returncode == 0: PDFInstaller._install_status["completed"] = True - logger.info(f"✅ Playwright Chromium 安装成功: {stdout.decode()}") + logger.info("✅ Chromium 内核安装成功。") - # 尝试安装系统依赖 (Linux only,通常不需要 root 无法执行,但尝试一下无妨或者提示用户) + # Linux 特殊处理:提示用户补充系统依赖 if sys.platform.startswith("linux"): - logger.info("正在尝试安装系统依赖 (install-deps)...") - # 无需 await 阻塞太久,这步通常需要 sudo,可能会失败,仅做尝试或提示 - # 真正的系统依赖安装通常由 Dockerfile 或用户手动完成 - # 这里我们仅记录日志建议 logger.info( - "💡 如果 Linux 下仍无法生成 PDF,请尝试运行: sudo playwright install-deps" + "提示:在 Linux 上,如果 PDF 生成仍然失败,请尝试运行 'sudo playwright install-deps'。" ) - else: PDFInstaller._install_status["failed"] = True - PDFInstaller._install_status["error_message"] = stderr.decode() - logger.error(f"❌ Playwright Chromium 安装失败: {stderr.decode()}") + PDFInstaller._install_status["error_message"] = stderr.decode().strip() + logger.error(f"❌ Chromium 安装二进制文件执行失败: {stderr.decode()}") except Exception as e: - PDFInstaller._install_status["failed"] = True - PDFInstaller._install_status["error_message"] = str(e) - logger.error(f"Playwright 安装后台任务出错: {e}") + PDFInstaller._install_status.update( + {"failed": True, "error_message": str(e)} + ) + logger.error(f"Playwright 后台任务遇到异常: {e}") finally: PDFInstaller._install_status["in_progress"] = False @staticmethod - def get_pdf_status(config_manager) -> str: - """获取PDF功能状态""" - if config_manager.playwright_available: - version = config_manager.playwright_version or "未知版本" + def get_pdf_status(config_manager: Any) -> str: + """ + 查询当前系统的 PDF 功能可用性状态描述。 - status = f"✅ PDF 功能可用 (playwright {version})" + Args: + config_manager (Any): 配置管理器,用于读取核心探测开关。 + + Returns: + str: 用户友好的状态文本 + """ + if config_manager.playwright_available: + version = config_manager.playwright_version or "Unknown" + status = f"✅ PDF 功能可用 (核心版本: {version})" if PDFInstaller._install_status["in_progress"]: - status += "\n⏳ 正在后台安装浏览器内核..." + status += "\n⏳ 警告:浏览器内核仍在后台下载/部署中..." elif PDFInstaller._install_status["failed"]: - status += f"\n❌ 上次浏览器安装失败: {PDFInstaller._install_status.get('error_message', '未知错误')}" + status += f"\n⚠️ 上次内核安装异常: {PDFInstaller._install_status.get('error_message')}" return status else: - return "❌ PDF 功能不可用 - 请输入 /安装PDF 进行安装" + return "❌ PDF 渲染核心未安装 - 请发送管理员指令 `/安装PDF`。" diff --git a/src/utils/resilience.py b/src/utils/resilience.py index 1abbb3c..4d13dcb 100644 --- a/src/utils/resilience.py +++ b/src/utils/resilience.py @@ -6,7 +6,15 @@ from .logger import logger class CircuitBreaker: """ - 简单的熔断器实现 (Simple Circuit Breaker) + 韧性设计:熔断器 (Circuit Breaker) + + 用于监控外部服务(如 LLM API)的调用状态。当错误率达到阈值时,自动开启熔断, + 拦截对故障服务的进一步请求,保护系统不被连锁故障拖累,直到服务窗口恢复。 + + States: + CLOSED: 正常工作状态,允许请求 + OPEN: 熔断状态,拒绝请求 + HALF_OPEN: 尝试恢复状态,允许少量测试请求 """ STATE_CLOSED = "CLOSED" @@ -19,16 +27,24 @@ class CircuitBreaker: recovery_timeout: int = 60, name: str = "default", ): + """ + 初始化熔断器。 + + Args: + failure_threshold (int): 连续失败触发熔断的次数上限 + recovery_timeout (int): 熔断开启后尝试恢复之前的冷却时间(秒) + name (str): 熔断器标识符(用于日志区分) + """ self.name = name self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout self.failure_count = 0 self.state = self.STATE_CLOSED - self.last_failure_time = 0 + self.last_failure_time = 0.0 - def record_failure(self): - """记录一次失败""" + def record_failure(self) -> None: + """记录一次调用失败,并根据阈值决定是否切换到 OPEN 状态。""" self.failure_count += 1 if ( self.state == self.STATE_CLOSED @@ -36,66 +52,91 @@ class CircuitBreaker: ): self._open_circuit() elif self.state == self.STATE_HALF_OPEN: - # 在半开状态下,一次失败直接重新打开熔断器 + # 半开状态下任何一次失败都将立即导致熔断重开 self._open_circuit() - def record_success(self): - """记录一次成功""" + def record_success(self) -> None: + """记录一次调用成功,并尝试重置或关闭熔断器。""" if self.state == self.STATE_HALF_OPEN: self._close_circuit() elif self.state == self.STATE_CLOSED: - # 成功则重置失败计数 (可选,这里选择连续失败才熔断) + # 正常状态下的成功重置累积计数值 self.failure_count = 0 def allow_request(self) -> bool: - """是否允许请求""" + """ + 判断是否允许本次服务请求。 + + Returns: + bool: True 为允许,False 为拦截 + """ if self.state == self.STATE_OPEN: + # 检查冷却时间是否已过,过则进入试探性的半开状态 if time.time() - self.last_failure_time > self.recovery_timeout: self._half_open_circuit() return True return False return True - def _open_circuit(self): + def _open_circuit(self) -> None: + """动作:开启熔断""" self.state = self.STATE_OPEN self.last_failure_time = time.time() logger.warning( - f"CircuitBreaker[{self.name}] 熔断器已打开! 暂停请求 {self.recovery_timeout} 秒。" + f"熔断器 CircuitBreaker[{self.name}] 已激活!将拦截请求 {self.recovery_timeout} 秒。" ) - def _close_circuit(self): + def _close_circuit(self) -> None: + """动作:关闭熔断,恢复常态""" self.state = self.STATE_CLOSED self.failure_count = 0 - logger.info(f"CircuitBreaker[{self.name}] 熔断器已关闭,服务恢复。") + logger.info(f"熔断器 CircuitBreaker[{self.name}] 已恢复至关闭 (CLOSED) 状态。") - def _half_open_circuit(self): + def _half_open_circuit(self) -> None: + """动作:进入半开状态""" self.state = self.STATE_HALF_OPEN - logger.info(f"CircuitBreaker[{self.name}] 进入半开状态,尝试恢复...") + logger.info( + f"熔断器 CircuitBreaker[{self.name}] 进入半开 (HALF_OPEN) 测试模式。" + ) class GlobalRateLimiter: """ - 全局限流器 (Global Rate Limiter) - 使用 asyncio.Semaphore 控制并发数 + 韧性设计:全局并发动态限流器 + + 基于单例模式管理 asyncio.Semaphore,确保在插件内的异步任务 + 不会超过设定的最大并发限制(如保护 LLM 账单或避免 API 拥塞)。 """ - _instance = None - _semaphore = None + _instance: "GlobalRateLimiter | None" = None + _semaphore: asyncio.Semaphore | None = None @classmethod - def get_instance(cls, max_concurrency: int = 3): + def get_instance(cls, max_concurrency: int = 3) -> "GlobalRateLimiter": + """ + 获取或创建限流器单例。 + + Args: + max_concurrency (int): 允许的最大并发行数 + + Returns: + GlobalRateLimiter: 唯一实例 + """ if cls._instance is None: cls._instance = cls() cls._semaphore = asyncio.Semaphore(max_concurrency) return cls._instance @property - def semaphore(self): + def semaphore(self) -> asyncio.Semaphore: + """返回核心的异步信号量对象。""" if self._semaphore is None: - # Fallback if accessed before get_instance called with arg + # 兜底:若直接通过属性访问则初始化默认值 self._semaphore = asyncio.Semaphore(3) return self._semaphore -# 默认全局限流实例 -global_llm_rate_limiter = GlobalRateLimiter.get_instance(max_concurrency=3).semaphore +# 导出默认实例:用于 LLM 调用的全局限流 +global_llm_rate_limiter: asyncio.Semaphore = GlobalRateLimiter.get_instance( + max_concurrency=3 +).semaphore diff --git a/src/utils/trace_context.py b/src/utils/trace_context.py index 71527e9..6a58d62 100644 --- a/src/utils/trace_context.py +++ b/src/utils/trace_context.py @@ -2,6 +2,7 @@ import contextvars import logging import time import uuid +from typing import Any # 定义 ContextVar _trace_id_ctx = contextvars.ContextVar("trace_id", default="") @@ -9,22 +10,48 @@ _trace_id_ctx = contextvars.ContextVar("trace_id", default="") class TraceContext: """ - 链路追踪上下文管理器 + 链路追踪:追踪上下文管理者 + + 利用 `contextvars` 在异步任务流中传递全局唯一的 `trace_id`, + 实现对单一请求/分析任务的全流程日志记录追踪。 """ @staticmethod - def set(trace_id: str): - """设置当前上下文的 TraceID""" + def set(trace_id: str) -> Any: + """ + 设置当前异步上下文的 TraceID。 + + Args: + trace_id (str): 追踪 ID 字符串 + + Returns: + Token: contextvars 令牌,用于后续重置 + """ return _trace_id_ctx.set(trace_id) @staticmethod def get() -> str: - """获取当前上下文的 TraceID""" + """ + 获取当前异步上下文中的 TraceID。 + + Returns: + str: 当前任务的追踪 ID,若无则返回空字符串 + """ return _trace_id_ctx.get() @staticmethod def generate(prefix: str = "") -> str: - """生成一个新的 TraceID (Prefix + Timestamp + UUID前8位)""" + """ + 构建生成一个新的高辨识度 TraceID。 + + 格式:[prefix-]时间戳-UUID前8位 + + Args: + prefix (str, optional): ID 前缀 (如 'ANALYSIS') + + Returns: + str: 生成的追踪 ID + """ timestamp = int(time.time()) unique_id = str(uuid.uuid4())[:8] if prefix: @@ -32,20 +59,34 @@ class TraceContext: return f"{timestamp}-{unique_id}" @staticmethod - def clear(): - """清除当前上下文的 TraceID""" + def clear() -> None: + """ + 重置/清除当前上下文的 TraceID 记录。 + """ _trace_id_ctx.set("") class TraceLogFilter(logging.Filter): """ - 日志过滤器,自动注入 TraceID + 日志治理:TraceID 注入过滤器 + + 该过滤器被挂载到日志系统后,会自动从流水上下文中提取 `trace_id` + 并注入到每一条日志记录中,便于日后通过 ID 检索完整的任务执行链路。 """ - def filter(self, record): + def filter(self, record: logging.LogRecord) -> bool: + """ + 拦截日志记录进行 TraceID 动态修饰。 + + Args: + record (logging.LogRecord): 日志记录对象 + + Returns: + bool: 始终返回 True (仅修改不拦截) + """ trace_id = _trace_id_ctx.get() if trace_id: - # 将 trace_id 注入到 record 中,同时也修改 msg 以便在不支持自定义 format 的 logger 中也能看到 + # 同时注入属性和修饰消息文本,保证在简易日志格式下也能直接可见 record.trace_id = trace_id record.msg = f"[{trace_id}] {record.msg}" else: