mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
fix(docs): 完善注释
This commit is contained in:
+1
-1
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)} 条有效用户称号")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+38
-21
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 "未知"
|
||||||
|
|
||||||
# 找到高峰时段
|
# 找到高峰时段
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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 客户端实例
|
# 路径 A:bot 本身就是 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 是 DiscordPlatformAdapter,client 在 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", []):
|
UnifiedMember(
|
||||||
members.append(
|
user_id=str(u.id),
|
||||||
UnifiedMember(
|
nickname=u.name,
|
||||||
user_id=str(user.id),
|
card=u.display_name,
|
||||||
nickname=user.display_name,
|
role="member",
|
||||||
card=None,
|
|
||||||
role="member",
|
|
||||||
join_time=None,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return members
|
for u in getattr(channel, "recipients", [])
|
||||||
|
]
|
||||||
|
|
||||||
# 公会频道
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user