fix(docs): 完善注释

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