fix(docs): 完善注释

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