From 692df4b0c943374356ec81406512cf00f14396b0 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Mon, 30 Mar 2026 17:43:14 +0800 Subject: [PATCH] chore: docs --- .gitignore | 3 +- docs/01_requirements_analysis.md | 45 - docs/02_architecture_design.md | 96 - docs/03_domain_model.md | 65 - docs/04_infrastructure_layer.md | 105 - docs/05_refactoring_roadmap.md | 61 - docs/06_review.md | 361 --- docs/07_ddd_refactoring_analysis.md | 698 ------ docs/08_cross_platform_decoupling_analysis.md | 968 -------- docs/09_ddd_cross_platform_complete_guide.md | 796 ------- docs/10_platform_abstraction_layer.md | 2044 ----------------- docs/11_ddd_implementation_status.md | 317 --- docs/12_platform_integration_guide.md | 658 ------ docs/LOG_VIEWING_RESEARCH.md | 677 ------ docs/MULTI_PLATFORM_INTEGRATION_GUIDE.md | 1049 --------- docs/TRACEID_IMPLEMENTATION.md | 332 --- docs/group_memory_feasibility_review.md | 461 ++++ docs/group_memory_phase1_proposal.md | 653 ++++++ docs/group_memory_prd.md | 811 +++++++ docs/group_memory_proposal.md | 908 ++++++++ 20 files changed, 2835 insertions(+), 8273 deletions(-) delete mode 100644 docs/01_requirements_analysis.md delete mode 100644 docs/02_architecture_design.md delete mode 100644 docs/03_domain_model.md delete mode 100644 docs/04_infrastructure_layer.md delete mode 100644 docs/05_refactoring_roadmap.md delete mode 100644 docs/06_review.md delete mode 100644 docs/07_ddd_refactoring_analysis.md delete mode 100644 docs/08_cross_platform_decoupling_analysis.md delete mode 100644 docs/09_ddd_cross_platform_complete_guide.md delete mode 100644 docs/10_platform_abstraction_layer.md delete mode 100644 docs/11_ddd_implementation_status.md delete mode 100644 docs/12_platform_integration_guide.md delete mode 100644 docs/LOG_VIEWING_RESEARCH.md delete mode 100644 docs/MULTI_PLATFORM_INTEGRATION_GUIDE.md delete mode 100644 docs/TRACEID_IMPLEMENTATION.md create mode 100644 docs/group_memory_feasibility_review.md create mode 100644 docs/group_memory_phase1_proposal.md create mode 100644 docs/group_memory_prd.md create mode 100644 docs/group_memory_proposal.md diff --git a/.gitignore b/.gitignore index a45d80a..9885c04 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ scripts/data/avatar/cache.db debug_miku.html scripts/output_all/debug_HatsuneMiku_test_group_*.pdf scripts/output/mock_report_test_group_mock_*.pdf -astrbot-lark-group-daily-analysis-main/** \ No newline at end of file +astrbot-lark-group-daily-analysis-main/** +.ace-tool/ diff --git a/docs/01_requirements_analysis.md b/docs/01_requirements_analysis.md deleted file mode 100644 index 4fce0e9..0000000 --- a/docs/01_requirements_analysis.md +++ /dev/null @@ -1,45 +0,0 @@ -# 01. 需求分析与现状评估 (Pragmatic Refactoring Edition) - -> **注**: 本文档基于 `06_review.md` 的反馈进行了大幅修正,从"理想化DDD重构"转向"务实框架对齐重构"。 - -## 1. 当前现状与实际问题 - -经过对代码和 AstrBot 框架的深度对比分析,我们重新定义了核心问题: - -### 1.1 核心架构冲突 -- **重复造轮子**: 插件内部实现了简陋的定时循环 (`_scheduler_loop`),而忽视了框架提供的 `Context.task_scheduler` (APScheduler)。 -- **绕过抽象层**: 大量直接调用 `bot.api.call_action`,导致代码与 OneBot v11 协议强耦合,未利用 AstrBot 的 `Context.send_message` 和 `PlatformManager` 抽象。 -- **上帝类 (God Class)**: `AutoScheduler` (1000+行) 确实职责过重,但问题不在于缺乏 EventBus,而在于缺乏合理的模块拆分(如 `MessageSender`, `ReportDispatcher`)。 - -### 1.2 稳定性痛点 (Confirmed) -- **多群并发冲击**: 确实存在,但通过 `asyncio.Semaphore` 已有基础控制,缺的是**全局速率限制**。 -- **LLM 可靠性**: 现有代码已实现多 Provider 和重试,但缺乏**熔断机制 (Circuit Breaker)**,导致单点故障可能拖累整体。 -- **发送可靠性**: 图片发送失败是高频问题,虽然有 URL/Base64 降级,但逻辑重复散落在各处。 - -### 1.3 可观测性缺失 -- **日志混乱**: 多群并发分析时,日志交织在一起,无法通过 TraceID 串联单次分析的全过程。 - -## 2. 重构目标 (Pragmatic Goals) - -本次重构的核心原则是:**回归框架,做减法,补短板**。 - -### 2.1 架构对齐 (Alignment) -- **废弃**自建的定时循环,转用 `Context.task_scheduler`。 -- **废弃**直接的 API 调用,尽可能使用 `Context.send_message` 和 `StarTools`。 -- **利用**框架生命周期钩子 (`OnPlatformLoaded`) 替代硬编码的 `sleep(30)`。 - -### 2.2 职责拆分 (Refactoring) -不是引入新架构,而是将 `AutoScheduler` 的代码剥离到独立模块: -- **`MessageSender`**: 统一处理文本、图片、PDF、合并转发发送,封装 URL->Base64 降级逻辑。 -- **`ReportDispatcher`**: 负责协调 分析 -> 生成 -> 发送 的流程。 -- **`BotManager`**: 增强群组发现和 Session 管理能力。 - -### 2.3 稳定性增强 (Robustness) -- **TraceID**: 使用 `contextvars` 实现零侵入的链路追踪。 -- **Circuit Breaker**: 在 LLM 调用层增加简单的熔断器(失败计数+冷却)。 -- **Global Rate Limit**: 全局控制并发请求数。 - -## 3. 预期收益 -- **代码量减少**: 预计减少 ~30% 冗余代码 (主要是 Scheduler 和 Message 发送逻辑)。 -- **稳定性提升**: 消除 API 超频风险,提升网络抖动时的恢复能力。 -- **维护性提升**: 遵循框架规范,降低后续 AstrBot 升级带来的兼容性风险。 diff --git a/docs/02_architecture_design.md b/docs/02_architecture_design.md deleted file mode 100644 index 9ec278b..0000000 --- a/docs/02_architecture_design.md +++ /dev/null @@ -1,96 +0,0 @@ -# 02. 架构设计 (Architecture Design - Framework Aligned) - -## 1. 总体架构图 (Pragmatic Architecture) - -本架构旨在最大限度复用 AstrBot 框架能力,通过职责拆分(而非分层解耦)来降低 `AutoScheduler` 的复杂度。 - -```mermaid -graph TD - subgraph "AstrBot Framework (宿主环境)" - TaskScheduler["Context.task_scheduler\n(APScheduler)"] - Context["Context\n(Session/Event/PlatformManager)"] - StarTools["StarTools\n(Message/Image)"] - end - - subgraph "Plugin Core (核心逻辑)" - Bootstrap["Plugin Bootstrap\n(main.py)"] - SchedulerJob["Scheduler Job\n(定时任务回调)"] - - AnalysisOrchestrator["Analysis Orchestrator\n(原有逻辑拆分)"] - - subgraph "Extracted Modules (提取模块)" - MessageSender["Message Sender\n(统一发送+降级)"] - ReportDispatcher["Report Dispatcher\n(报告生成+分发)"] - BotManagerEnh["Bot Manager\n(Session管理+群发现)"] - end - end - - subgraph "Infrastructure Enhancements (基建增强)" - TraceContext["Trace Context\n(contextvars)"] - LLMClient["LLM Client\n(CircuitBreaker + RateLimit)"] - end - - %% Flow - Bootstrap -->|Register Job| TaskScheduler - TaskScheduler -->|Trigger| SchedulerJob - - SchedulerJob -->|Set TraceID| TraceContext - SchedulerJob -->|Invoke| AnalysisOrchestrator - - AnalysisOrchestrator -->|Get Groups| BotManagerEnh - BotManagerEnh -->|Query| Context - - AnalysisOrchestrator -->|Analyze| LLMClient - - AnalysisOrchestrator -->|Generate Report| ReportDispatcher - ReportDispatcher -->|Send Report| MessageSender - - MessageSender -->|Use| StarTools - MessageSender -->|Fallback| Context -``` - -## 2. 核心组件详解 - -### 2.1 任务调度 (Scheduler) -**不再自建循环**。直接使用 AstrBot 提供的 `Context.task_scheduler` (APScheduler 实例)。 -- **注册**: 在 `__init__` 或 `OnPlatformLoaded` 中注册 cron job。 -- **优势**: 自动处理时区、任务持久化(如果配置)、优雅关闭。 - -### 2.2 消息发送 (MessageSender) -**统一发送入口**。将散落在各处的发送逻辑收敛到 `src/core/message_sender.py`。 -- **职责**: - 1. **协议适配**: 优先构建 `AstrMessageEvent` (即使是主动发送,也可构造虚拟 Event),调用 `Context.send_message`。 - 2. **降级策略**: URL发送失败 -> 下载转Base64发送 -> 纯文本回退。 - 3. **合并转发**: 封装 OneBot v11 的 Forward Message 构建细节。 -- **依赖**: 依赖 `Context` 和 `StarTools`,而非直接依赖 `bot.api`。 - -### 2.3 分析编排 (AnalysisOrchestrator / ReportDispatcher) -**逻辑拆分**。 -- `ReportDispatcher`: 接收 `AnalysisResult`,决定调用哪个渲染器(HTML/Text/PDF),并调用 `MessageSender` 发送。 -- `AnalysisOrchestrator`: 负责并发控制(Semaphore)和错误处理(Partial Failure)。 - -### 2.4 可观测性 (TraceContext) -**零侵入追踪**。 -- 使用 `contextvars.ContextVar` 存储 `trace_id`。 -- 实现 `TraceLogFilter` 自动注入 logging record。 -- 效果:在 `SchedulerJob` 入口设置一次 ID,后续深层调用的所有 `logger.info` 自动带上 `[trace_id: xxx]`。 - -### 2.5 LLM 客户端增强 -**原地增强**。不重写 `LLMAnalyzer`,而是在 `call_provider_with_retry` 层面增加: -- **CircuitBreaker**: 简单的失败计数器 (Windowed Counter)。 -- **RateLimiter**: `asyncio.Semaphore` 全局控制并发数。 - -## 3. 发送流程演进 - -### 旧流程 (Current) -`AutoScheduler` -> `_send_image_message` -> `bot.api.call_action("send_group_msg")` -> (失败) -> `RetryManager` queue -> `RetryManager` worker -> `bot.api` - -### 新流程 (Proposed) -`SchedulerJob` -> `ReportDispatcher` -> `MessageSender.send_image(url)` --> **Attempt 1**: `Context.send_message(image(url))` --> **Fail**: Catch generic exception --> **Attempt 2**: Download -> `Context.send_message(image(base64))` --> **Fail**: Catch exception --> **Fallback**: `MessageSender.send_text(report.text)` (Instant Fallback, no complex queue) - -> **注**: 如果确实需要异步低优先级的重试队列,可以将 `MessageSender` 的失败任务推送到 `Context.task_scheduler` 的一次性延时任务中,复用框架能力。 diff --git a/docs/03_domain_model.md b/docs/03_domain_model.md deleted file mode 100644 index 24b144a..0000000 --- a/docs/03_domain_model.md +++ /dev/null @@ -1,65 +0,0 @@ -# 03. 领域模型设计 (Domain Model - Pragmatic Edition) - -> **注**: 本文档已根据 Review 意见简化,移除了复杂的 DDD 聚合根,保留轻量级的数据结构和必要的配置管理。 - -## 1. 核心数据结构 (Data Structures) - -保持现有 `src/models/data_models.py` 的精简风格,按需增强。 - -### 1.1 `AnalysisContext` (New) -用于在一次分析流程中传递上下文信息,替代之前的 `AnalysisTask` 聚合根。 -* `trace_id: str`: 链路追踪 ID。 -* `group_id: str`: 目标群号。 -* `start_time: float`: 开始时间。 -* `is_manual: bool`: 是否为手动触发。 - -### 1.2 `GroupStatistics` (Enhanced) -增强现有的统计模型,支持追踪和部分失败记录。 -* `trace_id: str`: **[New]** 关联的 TraceID。 -* `partial_failures: List[str]`: **[New]** 记录分析过程中失败的模块 (e.g., ["golden_quote"])。 -* `...` (Existing fields: message_count, emoji_stats, etc.) - -### 1.3 `LLMRequest` (Value Object) -用于规范化 LLM 请求,支持模块化配置。 -* `module_tag: str`: 业务模块标签 (e.g., "topic", "summary")。 -* `prompt: str`: 提示词。 -* `system_prompt: str`: 系统提示词。 -* `trace_id: str`: 追踪 ID。 - -## 2. 配置管理 (Configuration) - -不引入新的 Entity,直接使用增强后的 `ConfigManager`。 - -### 2.1 `ConfigManager` (Enhanced) -* **LLM Configuration**: - * `get_provider_config(module_tag: str) -> dict`: 获取特定模块的 Provider 配置 (Platform, Model, Token)。 - * 支持回退策略: Module config -> Global config -> Default config。 -* **Feature Flags**: - * `is_module_enabled(module_tag: str) -> bool`: 检查模块开关。 - -## 3. 基础设施抽象 (Infrastructure Abstractions) - -仅保留必要的接口定义,避免过度抽象。 - -### 3.1 `IMessageSender` (Interface) -* `send_msg(group_id: str, message: list | str)`: 统一发送接口。 - -### 3.2 `IReportRenderer` (Interface) -* `render(data: AnalysisResult, template: str) -> bytes | str`: 渲染接口。 - -## 4. 链路追踪 (Traceability) - -使用 Python 标准库 `contextvars` 实现。 - -```python -# src/utils/trace_context.py -import contextvars - -trace_id_var = contextvars.ContextVar("trace_id", default="N/A") - -def get_trace_id() -> str: - return trace_id_var.get() - -def set_trace_id(trace_id: str): - trace_id_var.set(trace_id) -``` diff --git a/docs/04_infrastructure_layer.md b/docs/04_infrastructure_layer.md deleted file mode 100644 index 4ba14ad..0000000 --- a/docs/04_infrastructure_layer.md +++ /dev/null @@ -1,105 +0,0 @@ -# 04. 基础设施层设计 (Infrastructure Layer - Improved) - -## 1. 链路追踪 (TraceContext) - -使用 `contextvars` 实现零侵入的链路追踪,确保所有日志都能关联到具体的分析任务。 - -### 1.1 实现方案 - -```python -import contextvars -from astrbot.api import logger - -_trace_id_ctx = contextvars.ContextVar("trace_id", default="") - -class TraceContext: - @staticmethod - def set(trace_id: str): - return _trace_id_ctx.set(trace_id) - - @staticmethod - def get() -> str: - return _trace_id_ctx.get() - -class TraceLogFilter(logging.Filter): - def filter(self, record): - trace_id = _trace_id_ctx.get() - if trace_id: - record.msg = f"[{trace_id}] {record.msg}" - return True - -# 在插件初始化时挂载 Filter -logger.addFilter(TraceLogFilter()) -``` - -## 2. LLM 客户端增强 (Resilient LLM) - -在现有的 `call_provider_with_retry` 基础上,增加 **熔断 (Circuit Breaker)** 和 **限流 (Rate Limiter)**。 - -### 2.1 熔断器 (Circuit Breaker) - -防止单点故障拖垮整个流程。 - -```python -class CircuitBreaker: - def __init__(self, failure_threshold=5, recovery_timeout=60): - self.failure_count = 0 - self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN - self.last_failure_time = 0 - - def record_failure(self): - self.failure_count += 1 - if self.failure_count >= self.failure_threshold: - self.state = "OPEN" - self.last_failure_time = time.time() - - def allow_request(self) -> bool: - if self.state == "OPEN": - if time.time() - self.last_failure_time > self.recovery_timeout: - self.state = "HALF_OPEN" - return True - return False - return True -``` - -### 2.2 全局限流 (Global Rate Limiter) - -使用 `asyncio.Semaphore` 控制并发 LLM 请求数。 - -```python -# src/core/llm/limiter.py -global_llm_semaphore = asyncio.Semaphore(3) # 最大并发 3 - -async def call_llm_with_limit(...): - async with global_llm_semaphore: - return await call_provider_with_retry(...) -``` - -## 3. 消息发送增强 (MessageSender) - -### 3.1 统一发送接口 - -```python -class MessageSender: - def __init__(self, context: Context): - self.context = context - - async def send_image(self, group_id: str, url: str): - # 1. 尝试 URL 发送 - # 2. 失败 -> 尝试 Base64 发送 - # 3. 失败 -> 发送文本回退 - pass -``` - -### 3.2 离线任务支持 - -对于定时任务触发的场景,此时没有 `AstrMessageEvent`。需要手动构建 Session 或使用 `PlatformManager` 获取 Bot 实例直接发送。 - -```python -# 获取 Bot 实例 -bot = self.context.platform_manager.get_inst(platform_id) -# 构造虚拟 Session -session = Session(bot, group_id=group_id) -# 发送 -await self.context.send_message(session, chain) -``` diff --git a/docs/05_refactoring_roadmap.md b/docs/05_refactoring_roadmap.md deleted file mode 100644 index 7b24c2f..0000000 --- a/docs/05_refactoring_roadmap.md +++ /dev/null @@ -1,61 +0,0 @@ -# 05. 重构路线图 (Refactoring Roadmap - Pragmatic Edition) - -> **注**: 本路线图采用渐进式重构策略,优先解决稳定性问题,逐步对齐框架。 - -## Phase 1: 轻量级增强 (Lightweight Enhancements) -**目标**: 不动架构,仅通过装饰器和 ContextVar 增强系统的可观测性和稳定性。 -**预估工期**: 1 周 - -1. **TraceID 注入**: - - 实现 `TraceContext` (contextvars)。 - - 在 `main.py` 和 `auto_scheduler` 入口处埋点。 - - 配置 `logging.Filter`。 - -2. **LLM 熔断与限流**: - - 实现 `CircuitBreaker` 类。 - - 在 `src/utils/llm_utils.py` 的 `call_provider_with_retry` 中集成熔断器和全局 `Semaphore`。 - -3. **修复已知 Bug**: - - 修复 `_send_image_message` 中的双重 `return False` 问题。 - -**验收标准**: -- 日志中包含 `[trace_id]`。 -- 模拟 LLM 故障时,系统能快速失败并恢复。 - -## Phase 2: 核心职责提取 (Core Extraction) -**目标**: 将 `AutoScheduler` 的核心逻辑剥离为独立模块。 -**预估工期**: 1.5 周 - -1. **提取 `MessageSender`**: - - 创建 `src/core/message_sender.py`。 - - 迁移图片/文本发送逻辑,实现 URL->Base64 降级。 - - 在 `main.py` 中替换原有发送逻辑。 - -2. **提取 `ReportDispatcher`**: - - 创建 `src/reports/dispatcher.py`。 - - 迁移报告生成和分发逻辑。 - -3. **增强 `BotManager`**: - - 合并群组发现 (`_get_all_groups`) 逻辑。 - -**验收标准**: -- `AutoScheduler` 代码行数减少 40% 以上。 -- 发送文本和图片功能在各种网络环境下依然稳定。 - -## Phase 3: 框架完全对齐 (Framework Alignment) -**目标**: 移除自定义调度循环,完全复用 AstrBot 能力。 -**预估工期**: 1 周 - -1. **对接 `Context.task_scheduler`**: - - 移除 `_scheduler_loop`。 - - 使用 `context.task_scheduler.add_job` 注册定时任务。 - -2. **生命周期钩子**: - - 使用 `OnPlatformLoaded` 事件替代冷启动 sleep。 - -3. **配置清理**: - - 确保所有配置变更向后兼容。 - -**验收标准**: -- 插件启动无硬编码等待。 -- 定时任务准确触发。 diff --git a/docs/06_review.md b/docs/06_review.md deleted file mode 100644 index b120370..0000000 --- a/docs/06_review.md +++ /dev/null @@ -1,361 +0,0 @@ -# 06. 重构文档审查报告 (Architecture Review) - -> **审查日期**: 2026-02-07 -> **审查范围**: `docs/01~05` 全部重构文档 -> **参照基准**: 插件现有代码 (v4.6.9) + AstrBot-master 框架实际 API - ---- - -## 0. 审查总评 - -重构文档整体展现了较高的架构设计水平,对现有代码问题的诊断基本准确,DDD + 事件驱动的方向也是合理的工程演进路径。但文档在**与宿主框架的适配**、**实际可行性**、**复杂度收益比**上存在若干需要重新审视的关键问题。 - -### 评分概览 - -| 维度 | 评分 | 说明 | -|------|------|------| -| 问题诊断准确度 | ⭐⭐⭐⭐☆ | AutoScheduler 上帝类问题判断精准,LLM 脆弱性分析到位 | -| 架构方向合理性 | ⭐⭐⭐☆☆ | 方向正确但严重过度设计,未充分利用宿主框架能力 | -| 与 AstrBot 框架适配 | ⭐⭐☆☆☆ | 几乎未考虑 AstrBot 已有的事件系统和 API,存在大量重复建设 | -| 落地可行性 | ⭐⭐☆☆☆ | 四阶段路线图工期估计不足,缺乏增量验证策略 | -| 配置兼容性 | ⭐⭐⭐⭐☆ | 明确提出了配置向后兼容需求,这是正确的 | -| 模型设计质量 | ⭐⭐⭐☆☆ | 领域模型合理但偏理想化,与现有数据结构差距大 | - ---- - -## 1. 关键架构问题批注 - -### 1.1 🔴 [严重] 自建 EventBus 与 AstrBot 框架能力重复 - -**文档立场** (02_architecture_design): -> 引入事件总线 (EventBus) 作为核心通信机制,解耦各业务模块。 - -**实际情况**: -AstrBot 框架**已经内置了完整的事件系统**,包括: - -- **EventBus** (`astrbot/core/event_bus.py`) — 基于 `asyncio.Queue` 的事件分发器 -- **Pipeline 管道** — 洋葱模型的 9 阶段消息处理流水线 -- **StarHandlerRegistry** — 按 `EventType` + `priority` 分发到插件处理器 -- **丰富的 EventType 枚举**: - - `OnAfterAstrBotLoaded` — 启动后钩子 - - `OnPlatformLoaded` — 平台加载钩子 - - `OnAfterMessageSent` — 消息发送后钩子 - - 以及 LLM 请求/响应拦截等 - -**建议**: -> ❌ **不应在插件内部自建 AsyncEventBus**。应利用 AstrBot 已有的事件钩子实现解耦。 -> ✅ 对于插件内部的业务流转(分析→报告→发送),使用**简单的 async 回调链**或**协程编排**即可,无需引入一个完整的发布/订阅系统。 -> ✅ 如果确实需要插件级别的内部事件(如扩展点),使用轻量的 `Dict[str, List[Callable]]` 注册表即可,不必实现完整的 `DomainEvent` 体系。 - -**风险**: 自建 EventBus 会与 AstrBot 的 Pipeline 产生两套事件流,增加调试复杂度,且 `asyncio.create_task` 包裹的 handler 异常容易丢失。 - ---- - -### 1.2 🔴 [严重] 防腐层 (ACL) 层设计与 AstrBot 平台抽象冲突 - -**文档立场** (02, 04): -> NapCat ACL (防腐层) — 隔离 Bot 平台差异。 - -**实际情况**: -AstrBot 框架已经提供了完善的**平台抽象层**: - -- `Context.send_message(session, chain)` — 统一消息发送接口 -- `PlatformManager.get_insts()` — 获取所有平台实例 -- `AstrMessageEvent` — 统一消息事件对象,提供 `plain_result()`、`image_result()` 等 -- `Star.html_render()` — 内置 HTML→图片渲染 -- `StarTools.send_message()` — 主动消息发送 - -**当前插件的问题**: -插件绕过了 AstrBot 的抽象层,直接通过 `bot_instance.api.call_action()` 调用 OneBot v11 原始 API。这才是真正应该修复的"防腐层"问题——但修复方向应该是**回归 AstrBot 抽象接口**,而非再建一层 ACL。 - -**建议**: -> ✅ 自动分析的消息发送应使用 `StarTools.send_message(unified_msg_origin, chain)` 或 `Context.send_message(session, chain)`,而非直接调用 `call_action`。 -> ✅ `BotManager` 中大量的平台发现、实例缓存逻辑,应尽量复用 `Context.platform_manager`。 -> ⚠️ 但要注意: 定时任务主动推送消息时没有 `AstrMessageEvent` 上下文,此时需要手动构建 session,这是需要特殊处理的边界场景。建议在 `_delayed_start_scheduler` 中用 `@filter.on_decorating_result` 或 `OnPlatformLoaded` 钩子缓存 session 信息。 - ---- - -### 1.3 🟡 [中等] DDD 领域模型过度设计 - -**文档立场** (03_domain_model): -> 引入 `AnalysisTask` (聚合根)、`GroupConfig` (实体)、`LLMRequest` (值对象)、`RetryPolicy` (值对象) 等完整 DDD 体系。 - -**实际情况**: -当前插件的数据模型 (`src/models/data_models.py`) 使用简洁的 `@dataclass`: -`SummaryTopic`、`UserTitle`、`GoldenQuote`、`TokenUsage`、`GroupStatistics` 等,共 ~100 行代码。 - -这些模型**已经够用**,且与报告生成、LLM 分析紧密配合。引入完整的 DDD 聚合根 + 领域事件体系,对于一个**插件级别**的代码量来说: - -**成本远大于收益**。 - -**建议**: -> ✅ 保留现有 `@dataclass` 模型,按需增强: -> - 给 `GroupStatistics` 增加 `trace_id` 字段(支持日志追踪) -> - 给分析结果增加 `partial_failures: List[str]` 字段(支持部分成功) -> ❌ 不建议引入 `AnalysisTask` 作为聚合根并承载完整的状态机。对于插件场景,一个 `@dataclass AnalysisContext` 保存本次分析的元信息即可。 -> ❌ `GroupConfig` 作为独立实体没有必要,`ConfigManager` 已经很好地封装了配置读写。 - ---- - -### 1.4 🟡 [中等] LLM 服务增强方案忽视了 AstrBot Provider 体系 - -**文档立场** (03, 04): -> 多供应商支持:允许为不同模块(Topic, UserTitle, GoldenQuote)配置不同的 LLM 配置。 -> 实现 ResilientLLMClient 带 Rate Limiter + Circuit Breaker。 - -**实际情况**: -**好消息是——插件已经实现了这些功能的大部分!** - -- `ConfigManager` 已有 `get_topic_provider_id()`、`get_user_title_provider_id()`、`get_golden_quote_provider_id()` — **多 Provider 支持已存在** -- `llm_utils.py` 中的 `get_provider_id_with_fallback()` 实现了 4 级回退策略 — **Provider 回退已存在** -- `llm_utils.py` 中的 `call_provider_with_retry()` 已实现重试 — **重试已存在** -- `LLMAnalyzer.analyze_all_concurrent()` 已实现并发分析 + 部分失败隔离 — **部分成功已存在** - -文档似乎是基于**更早版本**的代码做的分析,没有充分反映当前已有的改进。 - -**建议**: -> ✅ 文档应先做 **现状盘点**,明确哪些能力已具备、哪些还缺失,避免重复建设。 -> ✅ 真正缺失的是: -> - **熔断器 (Circuit Breaker)** — 当前没有,可以用简单的计数器实现,不需要完整的状态机 -> - **全局 LLM 速率限制** — 当前单次请求有重试,但无全局 QPS 限制 -> - **结构化超时** — 当前只有 `get_llm_timeout()`,建议按模块区分 -> ❌ 不需要新建 `ResilientLLMClient` 类。在现有的 `call_provider_with_retry()` 上增强即可。 - ---- - -### 1.5 🟡 [中等] TraceID 方案可行但需简化 - -**文档立场** (02, 03): -> TraceID 格式: `{group_id}-{timestamp}-{uuid}`,所有领域事件都必须携带 TraceID。 - -**实际情况**: -TraceID 的理念是**正确的**,当前代码在多群并发分析时,日志确实难以区分。但实现不必绑定到 DomainEvent 体系。 - -**建议**: -> ✅ 使用 Python 标准库的 `contextvars` + `logging.Filter` 实现零侵入的 TraceID 注入: -> ```python -> import contextvars, uuid -> _trace_id: contextvars.ContextVar[str] = contextvars.ContextVar('trace_id', default='') -> -> class TraceFilter(logging.Filter): -> def filter(self, record): -> record.trace_id = _trace_id.get('') -> return True -> ``` -> 在每次群分析开始时 `_trace_id.set(f"{group_id}-{int(time.time())}")`,所有子协程自动继承。这比在每个函数签名中传递 `trace_id` 参数更优雅。 - ---- - -### 1.6 🟢 [建议] AutoScheduler 拆分策略 - -**文档诊断准确**: `AutoScheduler` 确实承担了过多职责 (1003 行代码),包含: -1. 定时循环逻辑 (`_scheduler_loop`) -2. 群组发现 (`_get_all_groups`) -3. 并发编排 (`_run_auto_analysis`) -4. 单群分析核心流程 (`_perform_auto_analysis_for_group`) -5. 报告发送 (`_send_analysis_report`, `_send_image_message`, `_send_text_message`, `_send_pdf_file`) -6. 平台路由 (`get_platform_id_for_group`) - -**但拆分方案应更务实**: - -> ✅ **推荐拆分方案**(非 EventBus 驱动,而是职责提取): -> -> | 提取目标 | 来源方法 | 新模块 | -> |----------|----------|--------| -> | `MessageSender` | `_send_image_message`, `_send_text_message`, `_send_pdf_file` | `src/core/message_sender.py` | -> | `GroupDiscovery` | `_get_all_groups`, `get_platform_id_for_group` | 合并到 `BotManager` | -> | `ReportDispatcher` | `_send_analysis_report` | `src/reports/dispatcher.py` | -> -> `AutoScheduler` 最终只保留: 定时循环 + 并发编排 + 调用各模块。 -> 预计从 1003 行缩减到 ~250 行。 - ---- - -## 2. 文档间一致性问题 - -### 2.1 01 与 02 之间的概念跳跃 -- 01 提出了"上帝类"和"异常处理过度"的问题 -- 02 直接跳到了完整的 EventBus + DDD 架构 -- **缺少过渡**: 没有评估"在不引入 EventBus 的前提下,仅通过职责提取能解决多少问题" - -### 2.2 03 领域模型与现有代码断层严重 -- 文档定义了 `AnalysisTask` 聚合根带 `TaskStatus` 状态机 -- 现有代码没有任何 `TaskStatus` 枚举或任务状态管理 -- **迁移成本被低估**: Phase 2 说"将 AutoScheduler 的逻辑拆解为事件处理器",但现有 1003 行的 AutoScheduler 与新设计几乎是**重写**而非渐进迁移 - -### 2.3 04 基础设施代码示例存在缺陷 -- `AsyncEventBus.publish()` 使用 `asyncio.create_task` 且仅在 `_safe_execute` 中 `logger.error` - - 问题: `create_task` 的异常如果没有被 `await`,在 Python 3.12+ 不会触发 `unraisable hook`,可能导致**静默丢失错误** - - 建议: 至少维护一个 `_pending_tasks: set` 并注册 `task.add_done_callback()` 进行异常日志记录 - -### 2.4 05 路线图时间估计缺失 -- 四个 Phase 都没有时间估计 -- Phase 2 的"事件驱动迁移"实质上是重写核心流程,至少需要 2-3 周集中开发 + 1 周回归测试 -- **建议增加**: 每个 Phase 的预估人天、验收标准和回退方案 - ---- - -## 3. 现有代码中被忽视的优点 - -文档以问题为导向,但忽视了当前代码中已有的若干良好实践,重构时**不应丢失**: - -| 现有优点 | 所在位置 | 说明 | -|----------|----------|------| -| 并发控制 + Semaphore | `auto_scheduler.py` L256 | 使用 `asyncio.Semaphore(max_concurrent)` 控制并发数 | -| 细粒度 LLM Provider 配置 | `config.py` | 已支持 topic/user_title/golden_quote 独立 Provider | -| 4 级 Provider 回退 | `llm_utils.py` | 专用→主→会话→首个可用 | -| BaseAnalyzer 模板方法模式 | `base_analyzer.py` | 分析器抽象类设计合理 | -| 图片发送三级降级 | `auto_scheduler.py` | URL → Base64 → 文本回退 | -| 死信队列 (DLQ) | `retry.py` | RetryManager 已有死信队列 + 文本回退 | -| 多平台适配器遍历 | `bot_manager.py` | 自动发现所有 aiocqhttp 实例 | -| 配置向后兼容 | `config.py` | get/set 方法带默认值,旧配置平滑迁移 | - ---- - -## 4. 推荐的替代重构策略 - -鉴于上述分析,建议采用**渐进式务实重构**替代文档中的"大设计上前 (Big Design Up Front)"方案: - -### Phase 1: 轻量增强 (1-2 周) -**零架构改动,仅增强现有模块** - -1. **TraceID 注入** — 使用 `contextvars` 全局注入 trace_id 到日志 -2. **LLM 熔断器** — 在 `call_provider_with_retry()` 中增加简单的失败计数 + 冷却期 -3. **LLM 全局限流** — 使用 `asyncio.Semaphore` 控制同时发起的 LLM 请求数 -4. **部分成功增强** — `analyze_all_concurrent` 已支持隔离失败,增加结果标记 - -### Phase 2: 职责提取 (1-2 周) -**从 AutoScheduler 提取独立模块,不引入 EventBus** - -1. **提取 `MessageSender`** — 统一文本/图片/PDF/合并转发发送逻辑 - - 优先使用 `StarTools.send_message()` / `Context.send_message()` - - 仅在必须使用 OneBot 专有 API 时保留 `call_action` 调用 -2. **提取 `ReportDispatcher`** — 从分析结果到报告生成到发送的编排逻辑 -3. **合并群发现逻辑到 `BotManager`** - -### Phase 3: 框架对齐 (1 周) -**复用 AstrBot 框架能力** - -1. **使用 AstrBot 定时器** — `Context.task_scheduler` 是 APScheduler 实例,用它替代手写的 `_scheduler_loop` -2. **使用 `OnPlatformLoaded` 钩子** — 替代 `_delayed_start_scheduler` 中的 30 秒 sleep -3. **使用 `Star.html_render`** — 已经内置,确认当前是否正确使用 - -### Phase 4: 可选增强 (按需) -1. **插件级事件扩展点** — 如果社区有需求(如 Webhook 推送),用简单的回调注册表实现 -2. **历史报告存储** — 利用 `Star.put_kv()` 存储分析结果摘要 -3. **Web Dashboard 集成** — 通过 `Context.register_web_api()` 暴露分析数据 - ---- - -## 5. 逐文档批注汇总 - -### 01_requirements_analysis.md - -| 条目 | 批注 | -|------|------| -| §1.1 上帝类诊断 | ✅ 准确。AutoScheduler 1003 行确实需要拆分 | -| §1.2 异常处理过度 | ✅ 准确。但当前代码已比描述改善不少 (BaseAnalyzer 有结构化异常处理) | -| §1.3 NapCat 交互稳定性 | ⚠️ 部分已解决。当前已有 URL→Base64→文本 三级降级 | -| §1.4 LLM 瓶颈 | ⚠️ 部分已解决。多 Provider 配置和并发分析已实现 | -| §2 重构目标 | 🔴 目标过于宏大。EventBus + DDD + ACL 对插件规模来说过度 | -| §3.1 配置兼容 | ✅ 非常正确且重要 | -| §3.2 EventBus | 🔴 应复用 AstrBot 事件系统或采用更轻量方案 | -| §3.3 TraceID | ✅ 方向正确,建议用 contextvars 实现 | - -### 02_architecture_design.md - -| 条目 | 批注 | -|------|------| -| 总体架构图 | 🟡 设计精美但与 AstrBot Pipeline 架构有冲突 | -| §2.1 EventBus | 🔴 与 AstrBot 内置 EventBus 重复建设 | -| §2.2 TraceContext | ✅ 理念正确 | -| §2.2 LLMService 多供应商 | ⚠️ 已经实现,文档未反映现状 | -| §2.3 AnalysisOrchestrator | ✅ 概念合理,但不必通过事件驱动,直接调用即可 | -| §2.3 MessageService | 🟡 概念可取,命名建议改为 MessageSender | -| §2.4 TaskQueue | 🟡 当前 RetryManager 已有队列,合并而非新建 | -| §3 事件流转 | 🔴 4 个阶段 7 个事件类型 — 对于"定时分析→生成报告→发送"的线性流程来说过度抽象 | -| §4 Circuit Breaker | ✅ 这是真正缺失的能力,值得实现 | - -### 03_domain_model.md - -| 条目 | 批注 | -|------|------| -| AnalysisTask 聚合根 | 🔴 过度设计。用 `@dataclass AnalysisContext(trace_id, group_id, started_at)` 即可 | -| GroupConfig 实体 | 🔴 不需要。ConfigManager 已充分封装 | -| LLMRequest 值对象 | 🟡 概念有用,但 `module_tag` 已通过 `provider_id_key` 间接实现 | -| LLMConfig 值对象 | 🟡 理论上好,但 AstrBot Provider 体系已管理 LLM 配置 | -| RetryPolicy 值对象 | ✅ 有用。当前重试参数散落在 ConfigManager 各方法中,统一为一个对象是好的 | -| ILLMService 接口 | 🟡 BaseAnalyzer 模板方法模式已经提供了类似抽象 | -| IEventBus 接口 | 🔴 不需要自建 | - -### 04_infrastructure_layer.md - -| 条目 | 批注 | -|------|------| -| AsyncEventBus 实现 | 🔴 不建议。create_task 异常处理有隐患 | -| ResilientLLMClient | 🟡 熔断器和限流逻辑有价值,但应增强现有 `call_provider_with_retry` 而非新建类 | -| NapCatAdapter 增强 | ✅ 消息获取重试有价值。但应回归 AstrBot 抽象 API | -| TaskQueueService | 🟡 RetryManager 已有此能力,应增强而非新建 | - -### 05_refactoring_roadmap.md - -| 条目 | 批注 | -|------|------| -| Phase 1 基础设施 | 🔴 方向有误。不应以 EventBus 为起点,应以"职责提取"为起点 | -| Phase 2 事件驱动迁移 | 🔴 风险高。实质是重写核心流程,不是"迁移" | -| Phase 3 配置迁移 | ✅ 配置兼容策略正确 | -| Phase 3 移除上帝类 | ✅ 方向正确,但应在 Phase 1 就开始 | -| Phase 4 验证 | ✅ 压力测试和长稳测试都是必要的 | -| 缺失: 时间估计 | 🔴 四个 Phase 均无时间线 | -| 缺失: 回退方案 | 🔴 如果某个 Phase 失败,如何回退? | -| 缺失: 功能开关 | 🟡 建议使用 Feature Flag 渐进切换新旧实现 | - ---- - -## 6. 最终建议清单 - -### 必须做 (P0) - -1. **利用 AstrBot `Context.task_scheduler` (APScheduler) 替代手写定时循环** — 消除 `_scheduler_loop` 中复杂的时间计算和 sleep 逻辑 -2. **从 AutoScheduler 提取 MessageSender** — 至少减少 400 行代码 -3. **增加 TraceID** — 基于 `contextvars` 实现,零侵入 -4. **修复 `_send_image_message` 中的双重 `return False`** — 这是一个实际 bug (auto_scheduler.py 末尾连续两个 `return False`) - -### 应该做 (P1) - -5. **LLM 熔断器** — 简单的失败计数 + 冷却期,在 `call_provider_with_retry()` 层面实现 -6. **全局 LLM 并发限制** — `asyncio.Semaphore` 控制同时发起的 LLM API 请求数量 -7. **使用 `OnPlatformLoaded` 钩子** 初始化 Bot 实例,替代 `asyncio.sleep(30)` 的硬编码等待 -8. **RetryPolicy 数据类** — 统一重试参数 - -### 可以做 (P2) - -9. **轻量级插件内事件注册表** — 为未来扩展(Webhook、数据库存储)预留回调接口 -10. **AnalysisContext 数据类** — 轻量级追踪上下文,而非完整 DDD 聚合根 -11. **历史分析结果存储** — 利用 `Star.put_kv()` 持久化 - -### 不建议做 - -12. ❌ 自建 AsyncEventBus + DomainEvent 体系 -13. ❌ 自建 NapCat ACL 防腐层 -14. ❌ AnalysisTask 聚合根 + TaskStatus 状态机 -15. ❌ GroupConfig 独立实体 -16. ❌ 完整的 ILLMService 接口 (BaseAnalyzer 模板方法已足够) - ---- - -## 7. 结语 - -这套重构文档展现了作者对 DDD、事件驱动架构的深入理解,架构设计的**理论水平很高**。但在 AstrBot 插件的具体场景下,需要在"理想架构"和"实际收益"之间找到平衡。 - -核心原则: -> **一个好的插件架构不是最先进的架构,而是最适合宿主框架的架构。** - -当前代码实际上已经完成了一次不错的模块化重构 (v4.6.9),BaseAnalyzer 模板模式、多 Provider 回退、并发分析等设计都很好。下一步的重点应该是: - -1. **减法** — 从 AutoScheduler 中提取职责 -2. **对齐** — 尽可能复用 AstrBot 框架能力 -3. **增强** — 补上真正缺失的熔断、限流、TraceID - -而不是引入一套全新的事件驱动 + DDD 架构体系。 - diff --git a/docs/07_ddd_refactoring_analysis.md b/docs/07_ddd_refactoring_analysis.md deleted file mode 100644 index 9755c41..0000000 --- a/docs/07_ddd_refactoring_analysis.md +++ /dev/null @@ -1,698 +0,0 @@ -# 07. DDD 重构分析报告 (Domain-Driven Design Refactoring Analysis) - -> **分析日期**: 2026-02-08 -> **分析范围**: 插件完整代码库 + 现有文档 (01-06) -> **分析目的**: 基于 DDD 范式,结合现有代码现状和 06_review.md 的务实建议,制定可落地的重构方案 - ---- - -## 1. 执行摘要 (Executive Summary) - -### 1.1 当前状态评估 - -经过对代码库的深度分析,本插件已经完成了一次显著的重构(基于 06_review.md 的建议),当前架构状态: - -| 维度 | 状态 | 说明 | -|------|------|------| -| **模块拆分** | ✅ 已完成 | AutoScheduler 已从 1000+ 行精简至 517 行 | -| **职责分离** | ✅ 已完成 | MessageSender、ReportDispatcher、BotManager 已独立 | -| **TraceID** | ✅ 已实现 | 使用 contextvars 实现零侵入链路追踪 | -| **熔断器** | ✅ 已实现 | CircuitBreaker + GlobalRateLimiter 已就绪 | -| **框架对齐** | ✅ 已完成 | 使用 Context.cron_manager + OnPlatformLoaded 钩子 | -| **DDD 分层** | ⚠️ 部分 | 有模块划分但未严格遵循 DDD 分层架构 | - -### 1.2 核心结论 - -**本插件已完成 06_review.md 建议的务实重构,代码质量良好。** - -进一步的 DDD 重构应聚焦于: -1. **领域边界明确化** - 定义清晰的限界上下文 -2. **领域模型增强** - 引入轻量级 DDD 战术模式 -3. **依赖方向规范** - 确保依赖指向领域层 -4. **可测试性提升** - 通过接口抽象支持单元测试 - ---- - -## 2. 现有架构分析 - -### 2.1 当前目录结构 - -``` -astrbot_plugin_qq_group_daily_analysis/ -├── main.py # 插件入口 (Application Layer - Controller) -├── src/ -│ ├── core/ # 核心模块 -│ │ ├── bot_manager.py # Bot 实例管理 (Infrastructure) -│ │ ├── config.py # 配置管理 (Infrastructure) -│ │ ├── history_manager.py # 历史记录管理 (Infrastructure) -│ │ ├── message_handler.py # 消息处理 (Application Service) -│ │ └── message_sender.py # 消息发送 (Infrastructure) -│ ├── scheduler/ # 调度模块 -│ │ ├── auto_scheduler.py # 自动调度器 (Application Service) -│ │ └── retry.py # 重试管理器 (Infrastructure) -│ ├── analysis/ # 分析模块 -│ │ ├── llm_analyzer.py # LLM 分析协调器 (Application Service) -│ │ ├── analyzers/ # 具体分析器 (Domain Services) -│ │ │ ├── base_analyzer.py -│ │ │ ├── topic_analyzer.py -│ │ │ ├── user_title_analyzer.py -│ │ │ └── golden_quote_analyzer.py -│ │ └── utils/ # 分析工具 -│ │ ├── json_utils.py -│ │ └── llm_utils.py -│ ├── models/ # 数据模型 -│ │ └── data_models.py # 数据结构定义 (Domain Models) -│ ├── reports/ # 报告模块 -│ │ ├── dispatcher.py # 报告分发器 (Application Service) -│ │ ├── generators.py # 报告生成器 (Domain Service) -│ │ └── templates/ # 报告模板 (Infrastructure) -│ ├── visualization/ # 可视化模块 -│ │ └── activity_charts.py # 活跃度图表 (Domain Service) -│ └── utils/ # 工具模块 -│ ├── helpers.py # 辅助函数 -│ ├── pdf_utils.py # PDF 工具 (Infrastructure) -│ ├── resilience.py # 熔断器/限流器 (Infrastructure) -│ └── trace_context.py # 链路追踪 (Infrastructure) -└── docs/ # 文档 -``` - -### 2.2 当前架构优点 - -| 优点 | 体现 | -|------|------| -| **模块化清晰** | 按功能划分目录:core、scheduler、analysis、reports、visualization | -| **职责单一** | MessageSender 只负责发送,ReportDispatcher 只负责分发 | -| **可观测性** | TraceContext + TraceLogFilter 实现全链路追踪 | -| **弹性设计** | CircuitBreaker + GlobalRateLimiter + RetryManager | -| **框架对齐** | 使用 AstrBot 的 cron_manager、platform_manager、put_kv_data | -| **并发控制** | asyncio.Semaphore 控制并发,asyncio.gather 并行执行 | - -### 2.3 当前架构不足 (DDD 视角) - -| 不足 | 说明 | 影响 | -|------|------|------| -| **领域边界模糊** | 没有明确的限界上下文定义 | 模块间耦合度难以评估 | -| **贫血模型** | data_models.py 只有数据结构,无行为 | 业务逻辑分散在 Service 中 | -| **依赖方向混乱** | Application 层直接依赖 Infrastructure | 测试困难,替换实现困难 | -| **缺少聚合根** | 没有定义实体的一致性边界 | 状态管理分散 | -| **接口抽象不足** | 直接依赖具体实现类 | Mock 测试困难 | - ---- - -## 3. DDD 重构方案 - -### 3.1 限界上下文识别 (Bounded Contexts) - -基于业务能力分析,本插件包含以下限界上下文: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 群聊日常分析插件 (Plugin) │ -├─────────────────────────────────────────────────────────────────┤ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ 调度上下文 │ │ 分析上下文 │ │ 报告上下文 │ │ -│ │ (Scheduling) │ │ (Analysis) │ │ (Reporting) │ │ -│ │ │ │ │ │ │ │ -│ │ - 定时任务 │ │ - 话题分析 │ │ - 报告生成 │ │ -│ │ - 并发控制 │ │ - 用户画像 │ │ - 格式转换 │ │ -│ │ - 任务编排 │ │ - 金句提取 │ │ - 消息发送 │ │ -│ │ │ │ - 统计计算 │ │ - 重试机制 │ │ -│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ -│ │ │ │ │ -│ └────────────────────┼────────────────────┘ │ -│ │ │ -│ ┌─────────────────────────────┴─────────────────────────────┐ │ -│ │ 共享内核 (Shared Kernel) │ │ -│ │ - 配置管理 (ConfigManager) │ │ -│ │ - Bot 管理 (BotManager) │ │ -│ │ - 链路追踪 (TraceContext) │ │ -│ │ - 弹性组件 (CircuitBreaker, RateLimiter) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 DDD 分层架构设计 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Interface Layer │ -│ (接口层 / 用户界面) │ -│ main.py - 命令处理、事件响应、框架集成 │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ (应用层 / 用例) │ -│ - AnalysisOrchestrator: 分析流程编排 │ -│ - SchedulingService: 定时任务管理 │ -│ - ReportingService: 报告生成与分发 │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Domain Layer │ -│ (领域层 / 核心业务) │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Entities (实体) │ │ -│ │ - AnalysisTask: 分析任务实体 │ │ -│ │ - GroupAnalysisResult: 群分析结果实体 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Value Objects (值对象) │ │ -│ │ - SummaryTopic, UserTitle, GoldenQuote │ │ -│ │ - GroupStatistics, TokenUsage │ │ -│ │ - AnalysisContext, RetryPolicy │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Domain Services (领域服务) │ │ -│ │ - TopicAnalyzer, UserTitleAnalyzer, GoldenQuoteAnalyzer│ │ -│ │ - StatisticsCalculator, ActivityVisualizer │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Repository Interfaces (仓储接口) │ │ -│ │ - IAnalysisHistoryRepository │ │ -│ │ - IMessageRepository │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Infrastructure Layer │ -│ (基础设施层 / 实现) │ -│ - KVAnalysisHistoryRepository: 使用 AstrBot KV 存储 │ -│ - OneBotMessageRepository: 通过 OneBot API 获取消息 │ -│ - MessageSender: 消息发送实现 │ -│ - LLMClient: LLM API 调用实现 │ -│ - ConfigManager: 配置读写实现 │ -│ - BotManager: Bot 实例管理实现 │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 3.3 目标目录结构 - -``` -astrbot_plugin_qq_group_daily_analysis/ -├── main.py # Interface Layer -├── src/ -│ ├── application/ # Application Layer -│ │ ├── __init__.py -│ │ ├── analysis_orchestrator.py # 分析流程编排 -│ │ ├── scheduling_service.py # 定时任务服务 -│ │ └── reporting_service.py # 报告服务 -│ │ -│ ├── domain/ # Domain Layer -│ │ ├── __init__.py -│ │ ├── entities/ # 实体 -│ │ │ ├── __init__.py -│ │ │ ├── analysis_task.py # 分析任务实体 -│ │ │ └── analysis_result.py # 分析结果实体 -│ │ ├── value_objects/ # 值对象 -│ │ │ ├── __init__.py -│ │ │ ├── topic.py # SummaryTopic -│ │ │ ├── user_title.py # UserTitle -│ │ │ ├── golden_quote.py # GoldenQuote -│ │ │ ├── statistics.py # GroupStatistics, TokenUsage -│ │ │ └── analysis_context.py # AnalysisContext -│ │ ├── services/ # 领域服务 -│ │ │ ├── __init__.py -│ │ │ ├── topic_analyzer.py -│ │ │ ├── user_title_analyzer.py -│ │ │ ├── golden_quote_analyzer.py -│ │ │ ├── statistics_calculator.py -│ │ │ └── report_generator.py -│ │ └── repositories/ # 仓储接口 -│ │ ├── __init__.py -│ │ ├── analysis_history_repository.py -│ │ └── message_repository.py -│ │ -│ ├── infrastructure/ # Infrastructure Layer -│ │ ├── __init__.py -│ │ ├── persistence/ # 持久化实现 -│ │ │ ├── __init__.py -│ │ │ └── kv_analysis_history_repository.py -│ │ ├── messaging/ # 消息通信 -│ │ │ ├── __init__.py -│ │ │ ├── onebot_message_repository.py -│ │ │ ├── message_sender.py -│ │ │ └── retry_manager.py -│ │ ├── llm/ # LLM 集成 -│ │ │ ├── __init__.py -│ │ │ ├── llm_client.py -│ │ │ └── llm_utils.py -│ │ ├── bot/ # Bot 管理 -│ │ │ ├── __init__.py -│ │ │ └── bot_manager.py -│ │ ├── config/ # 配置管理 -│ │ │ ├── __init__.py -│ │ │ └── config_manager.py -│ │ └── resilience/ # 弹性组件 -│ │ ├── __init__.py -│ │ ├── circuit_breaker.py -│ │ ├── rate_limiter.py -│ │ └── trace_context.py -│ │ -│ └── shared/ # 共享内核 -│ ├── __init__.py -│ └── exceptions.py # 自定义异常 -│ -├── tests/ # 测试 -│ ├── unit/ -│ │ ├── domain/ -│ │ └── application/ -│ └── integration/ -│ -└── docs/ # 文档 -``` - ---- - -## 4. 领域模型设计 - -### 4.1 实体 (Entities) - -#### 4.1.1 AnalysisTask (分析任务) - -```python -# src/domain/entities/analysis_task.py -from dataclasses import dataclass, field -from enum import Enum -from typing import Optional -import time -import uuid - -class TaskStatus(Enum): - PENDING = "pending" - FETCHING_MESSAGES = "fetching_messages" - ANALYZING = "analyzing" - GENERATING_REPORT = "generating_report" - SENDING = "sending" - COMPLETED = "completed" - FAILED = "failed" - -@dataclass -class AnalysisTask: - """ - 分析任务实体 - 聚合根 - 封装单次群聊分析的完整生命周期 - """ - id: str = field(default_factory=lambda: str(uuid.uuid4())[:8]) - group_id: str = "" - platform_id: str = "" - trace_id: str = "" - status: TaskStatus = TaskStatus.PENDING - is_manual: bool = False - - # 时间戳 - created_at: float = field(default_factory=time.time) - started_at: Optional[float] = None - completed_at: Optional[float] = None - - # 结果引用 - result_id: Optional[str] = None - error_message: Optional[str] = None - - # 业务方法 - def start(self): - """开始执行任务""" - if self.status != TaskStatus.PENDING: - raise ValueError(f"Cannot start task in {self.status} status") - self.status = TaskStatus.FETCHING_MESSAGES - self.started_at = time.time() - - def advance_to(self, status: TaskStatus): - """推进任务状态""" - self.status = status - - def complete(self, result_id: str): - """完成任务""" - self.status = TaskStatus.COMPLETED - self.result_id = result_id - self.completed_at = time.time() - - def fail(self, error: str): - """标记失败""" - self.status = TaskStatus.FAILED - self.error_message = error - self.completed_at = time.time() - - @property - def duration(self) -> Optional[float]: - """任务耗时""" - if self.started_at and self.completed_at: - return self.completed_at - self.started_at - return None -``` - -#### 4.1.2 GroupAnalysisResult (分析结果) - -```python -# src/domain/entities/analysis_result.py -from dataclasses import dataclass, field -from typing import List, Optional -import time - -@dataclass -class GroupAnalysisResult: - """ - 群分析结果实体 - 聚合所有分析产出 - """ - id: str = field(default_factory=lambda: str(uuid.uuid4())[:8]) - group_id: str = "" - trace_id: str = "" - - # 分析结果 - statistics: Optional['GroupStatistics'] = None - topics: List['SummaryTopic'] = field(default_factory=list) - user_titles: List['UserTitle'] = field(default_factory=list) - golden_quotes: List['GoldenQuote'] = field(default_factory=list) - - # 元数据 - message_count: int = 0 - analysis_days: int = 1 - created_at: float = field(default_factory=time.time) - - # 部分失败记录 - partial_failures: List[str] = field(default_factory=list) - - def add_partial_failure(self, module: str): - """记录部分失败""" - if module not in self.partial_failures: - self.partial_failures.append(module) - - def is_complete(self) -> bool: - """检查是否完整""" - return len(self.partial_failures) == 0 - - def to_dict(self) -> dict: - """转换为字典(用于报告生成)""" - return { - "statistics": self.statistics, - "topics": self.topics, - "user_titles": self.user_titles, - "golden_quotes": self.golden_quotes, - } -``` - -### 4.2 值对象 (Value Objects) - -当前 `data_models.py` 中的类已经是良好的值对象设计,保持不变: - -- `SummaryTopic` - 话题摘要 -- `UserTitle` - 用户称号 -- `GoldenQuote` - 金句 -- `GroupStatistics` - 群统计 -- `TokenUsage` - Token 使用量 -- `EmojiStatistics` - 表情统计 -- `ActivityVisualization` - 活跃度可视化 - -新增值对象: - -```python -# src/domain/value_objects/analysis_context.py -from dataclasses import dataclass - -@dataclass(frozen=True) -class AnalysisContext: - """ - 分析上下文值对象 - 封装单次分析的元信息 - """ - trace_id: str - group_id: str - platform_id: str - analysis_days: int - is_manual: bool - unified_msg_origin: str = "" - -@dataclass(frozen=True) -class RetryPolicy: - """ - 重试策略值对象 - """ - max_retries: int = 3 - base_delay: float = 5.0 - max_delay: float = 60.0 - exponential_base: float = 2.0 - jitter_range: tuple = (1.0, 5.0) -``` - -### 4.3 仓储接口 (Repository Interfaces) - -```python -# src/domain/repositories/analysis_history_repository.py -from abc import ABC, abstractmethod -from typing import Optional, List - -class IAnalysisHistoryRepository(ABC): - """分析历史仓储接口""" - - @abstractmethod - async def save(self, group_id: str, result: 'GroupAnalysisResult') -> bool: - """保存分析结果""" - pass - - @abstractmethod - async def get(self, group_id: str, date_str: str, time_str: str) -> Optional[dict]: - """获取指定时间的分析结果""" - pass - - @abstractmethod - async def exists(self, group_id: str, date_str: str, time_str: str) -> bool: - """检查是否存在分析记录""" - pass - - @abstractmethod - async def list_by_group(self, group_id: str, limit: int = 10) -> List[dict]: - """列出群的历史分析""" - pass - - -# src/domain/repositories/message_repository.py -from abc import ABC, abstractmethod -from typing import List - -class IMessageRepository(ABC): - """消息仓储接口""" - - @abstractmethod - async def fetch_messages( - self, - group_id: str, - days: int, - platform_id: str - ) -> List[dict]: - """获取群消息""" - pass -``` - ---- - -## 5. 重构路线图 - -### Phase 0: 准备工作 (1 天) - -**目标**: 建立重构基础设施 - -1. **创建目录结构** - - 按照 3.3 节创建新的目录结构 - - 保留原有文件,采用渐进式迁移 - -2. **定义接口** - - 创建 `IAnalysisHistoryRepository` 接口 - - 创建 `IMessageRepository` 接口 - - 创建 `IMessageSender` 接口 - -3. **添加共享异常** - ```python - # src/shared/exceptions.py - class AnalysisError(Exception): pass - class MessageFetchError(AnalysisError): pass - class LLMError(AnalysisError): pass - class ReportGenerationError(AnalysisError): pass - class MessageSendError(AnalysisError): pass - ``` - -### Phase 1: 领域层提取 (3-4 天) - -**目标**: 将核心业务逻辑迁移到领域层 - -1. **迁移值对象** - - 将 `data_models.py` 拆分到 `domain/value_objects/` - - 添加 `AnalysisContext` 和 `RetryPolicy` - -2. **创建实体** - - 实现 `AnalysisTask` 实体 - - 实现 `GroupAnalysisResult` 实体 - -3. **迁移领域服务** - - 将 `analyzers/` 移动到 `domain/services/` - - 将 `ActivityVisualizer` 移动到 `domain/services/` - - 确保领域服务不依赖基础设施 - -**验收标准**: -- 领域层代码无外部依赖(除 Python 标准库和 dataclasses) -- 所有领域逻辑可独立测试 - -### Phase 2: 基础设施层实现 (2-3 天) - -**目标**: 实现仓储和外部集成 - -1. **实现仓储** - - `KVAnalysisHistoryRepository` 实现 `IAnalysisHistoryRepository` - - `OneBotMessageRepository` 实现 `IMessageRepository` - -2. **迁移基础设施组件** - - 将 `message_sender.py` 移动到 `infrastructure/messaging/` - - 将 `bot_manager.py` 移动到 `infrastructure/bot/` - - 将 `config.py` 移动到 `infrastructure/config/` - - 将 `resilience.py` 拆分到 `infrastructure/resilience/` - -3. **LLM 客户端封装** - - 创建 `LLMClient` 封装 LLM 调用 - - 集成熔断器和限流器 - -**验收标准**: -- 基础设施层实现所有仓储接口 -- 可通过 Mock 替换任意基础设施组件 - -### Phase 3: 应用层重构 (2-3 天) - -**目标**: 实现用例编排 - -1. **创建应用服务** - - `AnalysisOrchestrator`: 编排完整分析流程 - - `SchedulingService`: 管理定时任务 - - `ReportingService`: 处理报告生成和发送 - -2. **依赖注入** - - 应用服务通过构造函数注入仓储接口 - - 使用接口而非具体实现 - -3. **重构 AutoScheduler** - - 将业务逻辑委托给 `AnalysisOrchestrator` - - AutoScheduler 仅保留调度职责 - -**验收标准**: -- 应用层仅依赖领域层接口 -- 可为应用服务编写单元测试 - -### Phase 4: 接口层对接 (1-2 天) - -**目标**: 更新入口点 - -1. **重构 main.py** - - 使用依赖注入组装各层 - - 命令处理器委托给应用服务 - -2. **保持向后兼容** - - 确保所有命令功能不变 - - 配置格式保持兼容 - -**验收标准**: -- 所有现有功能正常工作 -- 通过 `ruff check` 和 `ruff format` - -### Phase 5: 测试与文档 (2 天) - -**目标**: 补充测试和更新文档 - -1. **单元测试** - - 领域实体测试 - - 领域服务测试 - - 应用服务测试(Mock 仓储) - -2. **集成测试** - - 端到端分析流程测试 - -3. **文档更新** - - 更新 README - - 更新架构文档 - ---- - -## 6. 风险评估与缓解 - -| 风险 | 可能性 | 影响 | 缓解措施 | -|------|--------|------|----------| -| 重构引入回归 Bug | 中 | 高 | 渐进式迁移,保留原有代码直到验证完成 | -| 过度设计 | 中 | 中 | 遵循 YAGNI,仅实现必要的抽象 | -| 性能下降 | 低 | 中 | 保持异步架构,避免不必要的对象创建 | -| 团队学习曲线 | 中 | 低 | 提供清晰的文档和代码示例 | -| 与 AstrBot 框架冲突 | 低 | 高 | 继续遵循 06_review.md 的框架对齐原则 | - ---- - -## 7. 不建议实施的方案 - -基于 06_review.md 的分析和当前代码状态,以下方案**不建议**实施: - -| 方案 | 原因 | -|------|------| -| 自建 EventBus | AstrBot 已有事件系统,自建会造成重复 | -| 完整 CQRS 模式 | 插件规模不足以支撑 CQRS 复杂度 | -| 独立数据库 | 应使用 AstrBot 的 KV 存储 | -| 复杂的 Saga 模式 | 当前流程足够简单,不需要分布式事务 | -| 领域事件 + 事件溯源 | 过度设计,简单的状态机即可 | - ---- - -## 8. 总结与建议 - -### 8.1 当前状态 - -本插件已经完成了一次成功的务实重构,代码质量显著提升: -- ✅ AutoScheduler 精简化 -- ✅ MessageSender、ReportDispatcher 独立 -- ✅ TraceContext 链路追踪 -- ✅ CircuitBreaker + RateLimiter 弹性设计 -- ✅ 框架 API 对齐 - -### 8.2 DDD 重构建议 - -**推荐程度**: ⭐⭐⭐ (可选,非必须) - -当前代码已经足够好用。DDD 重构的主要收益是: -1. **更好的可测试性** - 通过接口抽象支持 Mock -2. **更清晰的边界** - 领域层与基础设施层分离 -3. **更好的可扩展性** - 新增分析器更容易 - -**建议优先级**: -1. **P0 (推荐)**: 定义仓储接口,提升可测试性 -2. **P1 (可选)**: 引入 AnalysisTask 实体,统一状态管理 -3. **P2 (可选)**: 完整的目录结构重组 - -### 8.3 下一步行动 - -如果决定进行 DDD 重构: -1. 从 Phase 0 开始,先建立接口定义 -2. 采用渐进式迁移,不要一次性重写 -3. 每个 Phase 完成后进行功能验证 -4. 保持与 AstrBot 框架的对齐 - -如果决定维持现状: -1. 当前架构已足够支撑业务需求 -2. 可以在需要时逐步引入 DDD 元素 -3. 重点关注功能迭代而非架构重构 - ---- - -## 附录 A: 术语表 - -| 术语 | 定义 | -|------|------| -| **限界上下文 (Bounded Context)** | 领域模型的边界,定义了模型的适用范围 | -| **实体 (Entity)** | 具有唯一标识的领域对象,生命周期内身份不变 | -| **值对象 (Value Object)** | 无唯一标识的领域对象,通过属性值定义 | -| **聚合根 (Aggregate Root)** | 聚合的入口点,保证聚合内的一致性 | -| **领域服务 (Domain Service)** | 无状态的领域逻辑,不属于任何实体 | -| **仓储 (Repository)** | 领域对象的持久化抽象 | -| **应用服务 (Application Service)** | 用例编排,协调领域对象完成业务流程 | - -## 附录 B: 参考资料 - -1. Eric Evans - *Domain-Driven Design: Tackling Complexity in the Heart of Software* -2. Vaughn Vernon - *Implementing Domain-Driven Design* -3. 06_review.md - 本项目的务实重构审查报告 -4. AstrBot 官方文档 - https://astrbot.app/ diff --git a/docs/08_cross_platform_decoupling_analysis.md b/docs/08_cross_platform_decoupling_analysis.md deleted file mode 100644 index 9bff9b9..0000000 --- a/docs/08_cross_platform_decoupling_analysis.md +++ /dev/null @@ -1,968 +0,0 @@ -# 08. 跨平台解耦调研报告 (Cross-Platform Decoupling Analysis) - -> **调研日期**: 2026-02-08 -> **调研范围**: AstrBot 平台抽象层 + 插件 QQ 硬编码分析 -> **调研目的**: 分析如何将插件从 QQ 专属改造为跨平台通用插件 - ---- - -## 1. 执行摘要 - -### 1.1 当前问题 - -本插件 (`astrbot_plugin_qq_group_daily_analysis`) 当前存在严重的平台耦合问题: - -| 问题类型 | 数量 | 影响 | -|----------|------|------| -| **直接导入 aiocqhttp** | 1 处 | 插件无法在非 QQ 平台加载 | -| **AiocqhttpMessageEvent 类型检查** | 12+ 处 | 所有命令仅限 QQ 平台 | -| **OneBot API 调用 (call_action)** | 8+ 处 | 消息获取/发送依赖 OneBot | -| **QQ 特定数据结构** | 15+ 处 | 消息格式、表情类型等 | -| **QQ 号相关逻辑** | 10+ 处 | bot_qq_id、self_id 等 | - -### 1.2 核心发现 - -**AstrBot 已提供完善的跨平台抽象层**,支持 12+ 个平台: - -| 平台 | 适配器 | 消息历史支持 | -|------|--------|--------------| -| QQ (OneBot v11) | `aiocqhttp` | ✅ `get_group_msg_history` | -| QQ 官方 | `qqofficial` | ❌ 不支持 | -| Telegram | `telegram` | ✅ 可通过 API 获取 | -| Discord | `discord` | ✅ 可通过 API 获取 | -| Slack | `slack` | ✅ 可通过 API 获取 | -| 飞书 | `lark` | ✅ 可通过 API 获取 | -| 钉钉 | `dingtalk` | ⚠️ 有限支持 | -| 企业微信 | `wecom` | ⚠️ 有限支持 | -| Misskey | `misskey` | ✅ 可通过 API 获取 | -| Satori | `satori` | 取决于实现 | -| 微信公众号 | `weixin_offacc` | ❌ 不支持 | -| WebChat | `webchat` | ❌ 不支持 | - -### 1.3 解耦可行性评估 - -| 维度 | 评估 | 说明 | -|------|------|------| -| **技术可行性** | ✅ 高 | AstrBot 已有完善的平台抽象 | -| **工作量** | 中等 | 约 3-5 天工作量 | -| **风险** | 低 | 渐进式重构,可保持兼容 | -| **收益** | 高 | 支持 5+ 主流平台 | - ---- - -## 2. AstrBot 平台抽象层分析 - -### 2.1 核心架构 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ AstrBot Core │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Platform Abstraction │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ -│ │ │ Platform │ │ AstrMessage │ │ MessageType │ │ │ -│ │ │ (Abstract) │ │ Event │ │ (Enum) │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ -│ │ │ AstrBotMsg │ │ Group │ │MessageMember │ │ │ -│ │ │ (Model) │ │ (Model) │ │ (Model) │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Platform Adapters │ │ -│ │ │ │ -│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ -│ │ │aiocqhttp│ │telegram│ │discord │ │ slack │ │ lark │ │ │ -│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │ -│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ -│ │ │dingtalk│ │ wecom │ │misskey │ │ satori │ │ webchat│ │ │ -│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 核心抽象类 - -#### 2.2.1 Platform (平台基类) - -**文件**: `astrbot/core/platform/platform.py` - -```python -class Platform(abc.ABC): - """平台适配器基类""" - - def __init__(self, config: dict, event_queue: Queue): - self.config = config - self._event_queue = event_queue - self.client_self_id = uuid.uuid4().hex - - @abc.abstractmethod - def run(self) -> Coroutine[Any, Any, None]: - """启动平台""" - raise NotImplementedError - - @abc.abstractmethod - def meta(self) -> PlatformMetadata: - """获取平台元数据""" - raise NotImplementedError - - async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): - """通过会话发送消息(跨平台统一接口)""" - pass - - def commit_event(self, event: AstrMessageEvent): - """提交事件到事件队列""" - self._event_queue.put_nowait(event) - - def get_client(self): - """获取平台客户端对象""" - pass -``` - -#### 2.2.2 AstrMessageEvent (消息事件基类) - -**文件**: `astrbot/core/platform/astr_message_event.py` - -```python -class AstrMessageEvent(abc.ABC): - """统一消息事件基类 - 所有平台事件的父类""" - - def __init__( - self, - message_str: str, - message_obj: AstrBotMessage, - platform_meta: PlatformMetadata, - session_id: str, - ): - self.message_str = message_str # 纯文本消息 - self.message_obj = message_obj # 完整消息对象 - self.platform_meta = platform_meta # 平台元数据 - self.session = MessageSession(...) # 会话信息 - - # 统一的跨平台方法 - def get_platform_name(self) -> str: # 获取平台类型 - def get_platform_id(self) -> str: # 获取平台实例ID - def get_message_str(self) -> str: # 获取消息文本 - def get_message_type(self) -> MessageType: # 获取消息类型 - def get_group_id(self) -> str: # 获取群组ID - def get_self_id(self) -> str: # 获取机器人ID - def get_sender_id(self) -> str: # 获取发送者ID - def get_sender_name(self) -> str: # 获取发送者名称 - - # 统一的发送方法 - async def send(self, message: MessageChain): - """发送消息(由子类实现具体逻辑)""" - pass - - async def get_group(self, group_id: str = None) -> Group | None: - """获取群组信息(由支持的平台实现)""" - pass -``` - -#### 2.2.3 AstrBotMessage (统一消息模型) - -**文件**: `astrbot/core/platform/astrbot_message.py` - -```python -class AstrBotMessage: - """AstrBot 统一消息对象""" - - type: MessageType # 消息类型 (GROUP_MESSAGE, FRIEND_MESSAGE, OTHER) - self_id: str # 机器人ID - session_id: str # 会话ID - message_id: str # 消息ID - group: Group | None # 群组信息 - sender: MessageMember # 发送者信息 - message: list[BaseMessageComponent] # 消息链 - message_str: str # 纯文本消息 - raw_message: object # 原始消息对象 - timestamp: int # 时间戳 - -class MessageMember: - user_id: str # 用户ID (平台无关) - nickname: str | None # 昵称 - -class Group: - group_id: str # 群组ID (平台无关) - group_name: str | None # 群名称 - group_owner: str | None # 群主ID - group_admins: list[str] # 管理员ID列表 - members: list[MessageMember] # 群成员列表 -``` - -#### 2.2.4 MessageType (消息类型枚举) - -**文件**: `astrbot/core/platform/message_type.py` - -```python -class MessageType(Enum): - GROUP_MESSAGE = "GroupMessage" # 群组消息 - FRIEND_MESSAGE = "FriendMessage" # 私聊消息 - OTHER_MESSAGE = "OtherMessage" # 其他消息 -``` - -### 2.3 各平台适配器对比 - -| 平台 | 事件类 | 消息获取方法 | 消息发送方法 | 群信息获取 | -|------|--------|--------------|--------------|------------| -| aiocqhttp | `AiocqhttpMessageEvent` | `call_action("get_group_msg_history")` | `send_group_msg` | `get_group_info` | -| telegram | `TelegramPlatformEvent` | `get_chat_history()` | `send_message()` | `get_chat()` | -| discord | `DiscordPlatformEvent` | `channel.history()` | `channel.send()` | `get_channel()` | -| slack | `SlackMessageEvent` | `conversations_history()` | `chat_postMessage()` | `conversations_info()` | -| lark | `LarkMessageEvent` | 飞书 API | 飞书 API | 飞书 API | - -### 2.4 平台检测与适配模式 - -**正确的跨平台写法**: - -```python -# ✅ 推荐:使用基类 AstrMessageEvent -from astrbot.api.event import AstrMessageEvent - -@filter.command("分析") -async def analyze(self, event: AstrMessageEvent): - # 使用统一接口 - group_id = event.get_group_id() - platform = event.get_platform_name() - - # 根据平台选择策略 - if platform == "aiocqhttp": - messages = await self._fetch_qq_messages(event) - elif platform == "telegram": - messages = await self._fetch_telegram_messages(event) - elif platform == "discord": - messages = await self._fetch_discord_messages(event) - else: - yield event.plain_result(f"❌ 平台 {platform} 暂不支持消息历史获取") - return -``` - ---- - -## 3. 插件 QQ 硬编码清单 - -### 3.1 硬编码分类汇总 - -| 类别 | 文件 | 行数 | 严重程度 | 解耦难度 | -|------|------|------|----------|----------| -| 直接导入 aiocqhttp | `main.py` | 14-16 | 🔴 高 | 低 | -| 类型检查 AiocqhttpMessageEvent | `main.py` | 多处 | 🔴 高 | 低 | -| OneBot API 调用 | `message_handler.py` | 90-110 | 🔴 高 | 中 | -| OneBot API 调用 | `auto_scheduler.py` | 85-90 | 🔴 高 | 中 | -| QQ 表情类型处理 | `message_handler.py` | 206-256 | 🟡 中 | 中 | -| QQ 号相关逻辑 | `bot_manager.py` | 多处 | 🟡 中 | 低 | -| 消息格式假设 | `message_handler.py` | 133-147 | 🟡 中 | 中 | -| 错误码检查 | 多文件 | 多处 | 🟢 低 | 低 | - -### 3.2 详细硬编码清单 - -#### 3.2.1 main.py - 入口文件 - -```python -# 🔴 硬编码 1: 直接导入 aiocqhttp 事件类 -# 行 14-16 -from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import ( - AiocqhttpMessageEvent, -) - -# 🔴 硬编码 2-13: 所有命令都限制 QQ 平台 -# 行 122, 128-129 -async def analyze_group_daily(self, event: AiocqhttpMessageEvent, ...): - if not isinstance(event, AiocqhttpMessageEvent): - yield event.plain_result("❌ 此功能仅支持QQ群聊") - return - -# 同样的模式在以下命令中重复: -# - set_output_format (行 330, 336-338) -# - set_report_template (行 377, 383-385) -# - view_templates (行 447, 452-454) -# - install_pdf_deps (行 533, 538-540) -# - analysis_settings (行 556, 567-569) - -# 🔴 硬编码 14: 直接调用 OneBot API 发送消息 -# 行 218-225 -if hasattr(bot_instance, "api") and hasattr(bot_instance.api, "call_action"): - await bot_instance.api.call_action( - "send_group_msg", - group_id=int(group_id), - message=message_chain, - ) -``` - -#### 3.2.2 message_handler.py - 消息处理 - -```python -# 🔴 硬编码 15: QQ 号提取逻辑 -# 行 40-48 -def _extract_bot_qq_id_from_instance(self, bot_instance): - """从bot实例中提取QQ号(单个)""" - 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) - ... - -# 🔴 硬编码 16: OneBot API 调用获取消息历史 -# 行 90-111 -if hasattr(bot_instance, "call_action"): - result = await bot_instance.call_action( - "get_group_msg_history", **payloads - ) -elif hasattr(bot_instance, "api"): - # QQ 官方 bot (botClient) 不支持历史消息 - logger.error("检测到 QQ 官方 Bot,官方 API 不支持获取历史消息") - return [] - -# 🟡 硬编码 17: QQ 消息格式假设 -# 行 124-147 -round_messages = result.get("messages", []) -for msg in round_messages: - msg_time = datetime.fromtimestamp(msg.get("time", 0)) - sender_id = str(msg.get("sender", {}).get("user_id", "")) - -# 🟡 硬编码 18-22: QQ 特定表情类型处理 -# 行 206-256 -elif content.get("type") == "face": # QQ基础表情 - emoji_statistics.face_count += 1 -elif content.get("type") == "mface": # 动画表情/魔法表情 - emoji_statistics.mface_count += 1 -elif content.get("type") == "bface": # 超级表情 - emoji_statistics.bface_count += 1 -elif content.get("type") == "sface": # 小表情 - emoji_statistics.sface_count += 1 -``` - -#### 3.2.3 auto_scheduler.py - 自动调度 - -```python -# 🔴 硬编码 23: OneBot API 调用获取群信息 -# 行 85-92 -if hasattr(bot_instance, "call_action"): - result = await bot_instance.call_action( - "get_group_info", group_id=int(group_id) - ) - -# 🟡 硬编码 24: OneBot 错误码检查 -# 行 100-107 -if "retcode=1200" in error_msg or "消息undefined不存在" in error_msg: - logger.warning(f"群 {group_id} 机器人不在此群中") - -# 🔴 硬编码 25: OneBot API 获取群列表 -# 行 478-479 -result = await call_action_func("get_group_list") -``` - -#### 3.2.4 bot_manager.py - Bot 管理 - -```python -# 🟡 硬编码 26-28: QQ 号相关属性和方法 -# 行 18, 39-47, 80-82 -self._bot_qq_ids = [] # 命名暗示 QQ 专属 - -def set_bot_qq_ids(self, bot_qq_ids): - """设置bot QQ号(支持单个QQ号或QQ号列表)""" - -def has_bot_qq_id(self) -> bool: - """检查是否有配置的bot QQ号""" - -# 🟡 硬编码 29: 平台检查硬编码 -# 行 151-154 -if hasattr(event, "get_platform_name") and event.get_platform_name() != "aiocqhttp": - return False -``` - -#### 3.2.5 message_sender.py - 消息发送 - -```python -# 🔴 硬编码 30-32: OneBot API 调用发送消息 -# 行 38-40, 73-75, 117-119 -await bot.api.call_action("send_group_msg", group_id=group_id, message=...) -``` - -#### 3.2.6 retry.py - 重试管理 - -```python -# 🔴 硬编码 33-34: OneBot API 调用 -# 行 192-206 -if hasattr(bot, "api") and hasattr(bot.api, "call_action"): - result = await bot.api.call_action( - "send_group_msg", group_id=int(task.group_id), message=message - ) - -# 行 296-304 -await bot.api.call_action( - "send_group_forward_msg", - group_id=int(task.group_id), - messages=nodes, -) -``` - -### 3.3 硬编码影响分析 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 硬编码影响链 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ main.py │ -│ └── import AiocqhttpMessageEvent ──────────────────────────┐ │ -│ │ │ │ -│ ▼ │ │ -│ 所有命令处理器 │ │ -│ (12个命令全部限制 QQ) │ │ -│ │ │ │ -│ ▼ │ │ -│ message_handler.py │ │ -│ └── call_action("get_group_msg_history") ──────────────────┤ │ -│ │ │ │ -│ ▼ │ │ -│ auto_scheduler.py │ │ -│ └── call_action("get_group_info") ─────────────────────────┤ │ -│ └── call_action("get_group_list") ─────────────────────────┤ │ -│ │ │ │ -│ ▼ │ │ -│ message_sender.py / retry.py │ │ -│ └── call_action("send_group_msg") ─────────────────────────┘ │ -│ └── call_action("send_group_forward_msg") │ -│ │ -│ 结果: 插件完全无法在非 QQ 平台使用 │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 4. 跨平台解耦方案 - -### 4.1 设计目标 - -1. **平台无关的核心逻辑** - 分析、报告生成与平台解耦 -2. **可插拔的平台适配器** - 每个平台独立的消息获取/发送实现 -3. **渐进式迁移** - 保持 QQ 功能完整,逐步添加其他平台 -4. **统一的接口抽象** - 定义清晰的平台能力接口 - -### 4.2 架构设计 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Plugin Architecture (目标) │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Application Layer │ │ -│ │ main.py - 使用 AstrMessageEvent 基类 │ │ -│ │ - 命令处理器接受所有平台事件 │ │ -│ │ - 根据平台能力选择处理策略 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Domain Layer │ │ -│ │ - MessageAnalyzer (平台无关) │ │ -│ │ - ReportGenerator (平台无关) │ │ -│ │ - LLMAnalyzer (平台无关) │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Platform Abstraction Layer │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────┐ │ │ -│ │ │ IPlatformMessageRepository (Interface) │ │ │ -│ │ │ - fetch_messages(group_id, days) -> List[Msg] │ │ │ -│ │ │ - get_group_info(group_id) -> GroupInfo │ │ │ -│ │ │ - get_group_list() -> List[str] │ │ │ -│ │ └──────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────┐ │ │ -│ │ │ IPlatformMessageSender (Interface) │ │ │ -│ │ │ - send_text(group_id, text) -> bool │ │ │ -│ │ │ - send_image(group_id, image) -> bool │ │ │ -│ │ │ - send_file(group_id, file) -> bool │ │ │ -│ │ └──────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────┐ │ │ -│ │ │ PlatformCapabilities (Value Object) │ │ │ -│ │ │ - supports_message_history: bool │ │ │ -│ │ │ - supports_group_list: bool │ │ │ -│ │ │ - supports_file_upload: bool │ │ │ -│ │ │ - supports_forward_message: bool │ │ │ -│ │ └──────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Platform Implementations │ │ -│ │ │ │ -│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ -│ │ │ OneBot │ │ Telegram │ │ Discord │ ... │ │ -│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │ -│ │ └────────────┘ └────────────┘ └────────────┘ │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 4.3 接口定义 - -#### 4.3.1 平台消息仓储接口 - -```python -# src/platform/interfaces.py -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import List, Optional - -@dataclass -class UnifiedMessage: - """统一消息格式""" - message_id: str - sender_id: str - sender_name: str - content: str # 纯文本内容 - raw_content: list # 原始消息链 - timestamp: int - message_type: str # text, image, file, etc. - -@dataclass -class UnifiedGroup: - """统一群组格式""" - group_id: str - group_name: str - member_count: int - owner_id: Optional[str] = None - -@dataclass -class PlatformCapabilities: - """平台能力描述""" - platform_name: str - supports_message_history: bool = False - supports_group_list: bool = False - supports_group_info: bool = False - supports_file_upload: bool = False - supports_forward_message: bool = False - max_message_history_days: int = 0 - -class IPlatformMessageRepository(ABC): - """平台消息仓储接口""" - - @abstractmethod - async def fetch_messages( - self, - group_id: str, - days: int, - max_count: int = 1000 - ) -> List[UnifiedMessage]: - """获取群消息历史""" - pass - - @abstractmethod - async def get_group_info(self, group_id: str) -> Optional[UnifiedGroup]: - """获取群信息""" - pass - - @abstractmethod - async def get_group_list(self) -> List[str]: - """获取群列表""" - pass - - @abstractmethod - def get_capabilities(self) -> PlatformCapabilities: - """获取平台能力""" - pass - -class IPlatformMessageSender(ABC): - """平台消息发送接口""" - - @abstractmethod - async def send_text(self, group_id: str, text: str) -> bool: - """发送文本消息""" - pass - - @abstractmethod - async def send_image(self, group_id: str, image_url: str) -> bool: - """发送图片消息""" - pass - - @abstractmethod - async def send_file(self, group_id: str, file_path: str) -> bool: - """发送文件""" - pass -``` - -#### 4.3.2 OneBot 实现示例 - -```python -# src/platform/adapters/onebot_adapter.py -from ..interfaces import ( - IPlatformMessageRepository, - IPlatformMessageSender, - UnifiedMessage, - UnifiedGroup, - PlatformCapabilities, -) - -class OneBotMessageRepository(IPlatformMessageRepository): - """OneBot v11 消息仓储实现""" - - def __init__(self, bot_instance): - self.bot = bot_instance - - async def fetch_messages( - self, group_id: str, days: int, max_count: int = 1000 - ) -> List[UnifiedMessage]: - """通过 get_group_msg_history 获取消息""" - if not hasattr(self.bot, "call_action"): - return [] - - try: - result = await self.bot.call_action( - "get_group_msg_history", - group_id=int(group_id), - count=max_count, - ) - - messages = [] - for msg in result.get("messages", []): - # 转换为统一格式 - unified = self._convert_message(msg) - if unified: - messages.append(unified) - return messages - - except Exception as e: - logger.error(f"OneBot fetch_messages failed: {e}") - return [] - - def _convert_message(self, raw_msg: dict) -> UnifiedMessage: - """将 OneBot 消息转换为统一格式""" - sender = raw_msg.get("sender", {}) - - # 提取纯文本内容 - text_parts = [] - for seg in raw_msg.get("message", []): - if seg.get("type") == "text": - text_parts.append(seg.get("data", {}).get("text", "")) - - return UnifiedMessage( - message_id=str(raw_msg.get("message_id", "")), - sender_id=str(sender.get("user_id", "")), - sender_name=sender.get("nickname", "") or sender.get("card", ""), - content="".join(text_parts), - raw_content=raw_msg.get("message", []), - timestamp=raw_msg.get("time", 0), - message_type="mixed", - ) - - def get_capabilities(self) -> PlatformCapabilities: - return PlatformCapabilities( - platform_name="onebot", - supports_message_history=True, - supports_group_list=True, - supports_group_info=True, - supports_file_upload=True, - supports_forward_message=True, - max_message_history_days=7, - ) -``` - -#### 4.3.3 Telegram 实现示例 - -```python -# src/platform/adapters/telegram_adapter.py -class TelegramMessageRepository(IPlatformMessageRepository): - """Telegram 消息仓储实现""" - - def __init__(self, bot_client): - self.bot = bot_client - - async def fetch_messages( - self, group_id: str, days: int, max_count: int = 1000 - ) -> List[UnifiedMessage]: - """通过 Telegram API 获取消息历史""" - try: - from datetime import datetime, timedelta - - # Telegram 使用 chat_id - chat_id = int(group_id) - - # 获取消息历史 (需要 bot 有读取历史的权限) - messages = [] - async for message in self.bot.get_chat_history( - chat_id=chat_id, - limit=max_count, - ): - # 过滤时间范围 - msg_time = message.date - if msg_time < datetime.now() - timedelta(days=days): - break - - unified = self._convert_message(message) - if unified: - messages.append(unified) - - return messages - - except Exception as e: - logger.error(f"Telegram fetch_messages failed: {e}") - return [] - - def get_capabilities(self) -> PlatformCapabilities: - return PlatformCapabilities( - platform_name="telegram", - supports_message_history=True, - supports_group_list=True, - supports_group_info=True, - supports_file_upload=True, - supports_forward_message=True, - max_message_history_days=30, - ) -``` - -### 4.4 平台适配器工厂 - -```python -# src/platform/factory.py -from typing import Optional -from .interfaces import IPlatformMessageRepository, IPlatformMessageSender -from .adapters.onebot_adapter import OneBotMessageRepository, OneBotMessageSender -from .adapters.telegram_adapter import TelegramMessageRepository, TelegramMessageSender -from .adapters.discord_adapter import DiscordMessageRepository, DiscordMessageSender - -class PlatformAdapterFactory: - """平台适配器工厂""" - - @staticmethod - def create_repository( - platform_name: str, - bot_instance - ) -> Optional[IPlatformMessageRepository]: - """根据平台类型创建消息仓储""" - - adapters = { - "aiocqhttp": OneBotMessageRepository, - "telegram": TelegramMessageRepository, - "discord": DiscordMessageRepository, - "slack": SlackMessageRepository, - "lark": LarkMessageRepository, - } - - adapter_class = adapters.get(platform_name) - if adapter_class: - return adapter_class(bot_instance) - - return None - - @staticmethod - def create_sender( - platform_name: str, - bot_instance - ) -> Optional[IPlatformMessageSender]: - """根据平台类型创建消息发送器""" - - senders = { - "aiocqhttp": OneBotMessageSender, - "telegram": TelegramMessageSender, - "discord": DiscordMessageSender, - "slack": SlackMessageSender, - "lark": LarkMessageSender, - } - - sender_class = senders.get(platform_name) - if sender_class: - return sender_class(bot_instance) - - return None - - @staticmethod - def get_supported_platforms() -> list[str]: - """获取支持的平台列表""" - return ["aiocqhttp", "telegram", "discord", "slack", "lark"] -``` - -### 4.5 重构后的命令处理器 - -```python -# main.py (重构后) -from astrbot.api.event import AstrMessageEvent # 使用基类 - -class GroupDailyAnalysis(Star): # 改名,去掉 QQ 前缀 - - @filter.command("群分析") - @filter.permission_type(PermissionType.ADMIN) - async def analyze_group_daily( - self, event: AstrMessageEvent, days: int | None = None # 使用基类 - ): - """分析群聊日常活动 - 跨平台支持""" - - group_id = event.get_group_id() - if not group_id: - yield event.plain_result("❌ 请在群聊中使用此命令") - return - - platform_name = event.get_platform_name() - - # 获取平台适配器 - repository = self._get_repository_for_platform(platform_name, event) - if not repository: - yield event.plain_result(f"❌ 平台 {platform_name} 暂不支持此功能") - return - - # 检查平台能力 - capabilities = repository.get_capabilities() - if not capabilities.supports_message_history: - yield event.plain_result( - f"❌ 平台 {platform_name} 不支持获取消息历史" - ) - return - - # 使用统一接口获取消息 - messages = await repository.fetch_messages(group_id, days or 1) - - if not messages: - yield event.plain_result("❌ 未找到足够的消息记录") - return - - # 后续分析逻辑不变... - yield event.plain_result(f"📊 已获取 {len(messages)} 条消息,正在分析...") - - # 分析和报告生成使用统一的消息格式 - analysis_result = await self.message_analyzer.analyze_unified_messages( - messages, group_id, event.unified_msg_origin - ) - - # 发送报告 - await self._send_report(event, analysis_result) -``` - ---- - -## 5. 执行路线图 - -### Phase 0: 准备工作 (0.5 天) - -**目标**: 建立基础设施 - -| 任务 | 说明 | -|------|------| -| 创建 `src/platform/` 目录 | 平台抽象层 | -| 定义接口文件 | `interfaces.py` | -| 创建工厂类 | `factory.py` | - -### Phase 1: OneBot 适配器 (1 天) - -**目标**: 将现有 QQ 逻辑封装为适配器 - -| 任务 | 说明 | -|------|------| -| 实现 `OneBotMessageRepository` | 封装 `get_group_msg_history` | -| 实现 `OneBotMessageSender` | 封装消息发送 | -| 添加消息格式转换 | OneBot → UnifiedMessage | -| 单元测试 | 确保功能不变 | - -### Phase 2: 核心逻辑解耦 (1 天) - -**目标**: 使核心逻辑平台无关 - -| 任务 | 说明 | -|------|------| -| 修改 `MessageHandler` | 使用 `UnifiedMessage` | -| 修改 `MessageAnalyzer` | 移除平台假设 | -| 修改 `AutoScheduler` | 使用适配器工厂 | -| 修改 `BotManager` | 重命名 QQ 相关方法 | - -### Phase 3: main.py 重构 (0.5 天) - -**目标**: 使命令处理器跨平台 - -| 任务 | 说明 | -|------|------| -| 移除 `AiocqhttpMessageEvent` 导入 | 使用基类 | -| 移除类型检查 | 改用能力检查 | -| 添加平台适配器选择逻辑 | 根据 `platform_name` | -| 更新错误消息 | 更通用的提示 | - -### Phase 4: 添加 Telegram 支持 (1 天) - -**目标**: 验证跨平台架构 - -| 任务 | 说明 | -|------|------| -| 实现 `TelegramMessageRepository` | 使用 python-telegram-bot | -| 实现 `TelegramMessageSender` | | -| 测试 Telegram 群分析 | 端到端验证 | - -### Phase 5: 添加更多平台 (可选) - -| 平台 | 优先级 | 工作量 | -|------|--------|--------| -| Discord | P1 | 1 天 | -| Slack | P2 | 1 天 | -| 飞书 | P2 | 1 天 | -| 钉钉 | P3 | 1 天 | - ---- - -## 6. 风险与缓解 - -| 风险 | 可能性 | 影响 | 缓解措施 | -|------|--------|------|----------| -| 平台 API 差异大 | 高 | 中 | 统一消息格式 + 能力检查 | -| 消息历史获取受限 | 高 | 高 | 明确标注平台能力,提供降级方案 | -| 表情/特殊消息处理 | 中 | 低 | 只提取文本内容进行分析 | -| 测试覆盖不足 | 中 | 中 | 为每个适配器编写集成测试 | -| 性能差异 | 低 | 低 | 异步处理 + 缓存 | - ---- - -## 7. 总结 - -### 7.1 关键发现 - -1. **AstrBot 已有完善的平台抽象** - 不需要自建抽象层 -2. **插件硬编码严重但可解耦** - 约 34 处需要修改 -3. **核心分析逻辑平台无关** - LLM 分析、报告生成不受影响 -4. **渐进式迁移可行** - 可以保持 QQ 功能同时添加新平台 - -### 7.2 建议优先级 - -| 优先级 | 任务 | 收益 | -|--------|------|------| -| **P0** | 定义平台抽象接口 | 架构基础 | -| **P0** | 封装 OneBot 适配器 | 保持现有功能 | -| **P1** | 重构 main.py 使用基类 | 解除平台限制 | -| **P1** | 添加 Telegram 支持 | 验证架构 | -| **P2** | 添加 Discord 支持 | 扩大用户群 | - -### 7.3 预期成果 - -- ✅ 插件可在 5+ 主流平台运行 -- ✅ 新增平台只需实现适配器接口 -- ✅ 核心逻辑无需修改 -- ✅ 保持与 AstrBot 框架的对齐 - ---- - -## 附录 A: 平台 API 对比 - -| 功能 | OneBot v11 | Telegram | Discord | Slack | -|------|------------|----------|---------|-------| -| 获取消息历史 | `get_group_msg_history` | `get_chat_history` | `channel.history()` | `conversations.history` | -| 获取群信息 | `get_group_info` | `get_chat` | `get_channel` | `conversations.info` | -| 获取群列表 | `get_group_list` | `get_my_commands` | `guilds` | `conversations.list` | -| 发送文本 | `send_group_msg` | `send_message` | `channel.send` | `chat.postMessage` | -| 发送图片 | `[CQ:image]` | `send_photo` | `channel.send(file=)` | `files.upload` | -| 发送文件 | `[CQ:file]` | `send_document` | `channel.send(file=)` | `files.upload` | -| 转发消息 | `send_group_forward_msg` | N/A | N/A | N/A | - -## 附录 B: 参考资料 - -1. AstrBot 官方文档 - https://astrbot.app/ -2. OneBot v11 标准 - https://github.com/botuniverse/onebot-11 -3. python-telegram-bot - https://python-telegram-bot.org/ -4. Pycord (Discord) - https://pycord.dev/ -5. Slack SDK - https://slack.dev/python-slack-sdk/ diff --git a/docs/09_ddd_cross_platform_complete_guide.md b/docs/09_ddd_cross_platform_complete_guide.md deleted file mode 100644 index 1d37a38..0000000 --- a/docs/09_ddd_cross_platform_complete_guide.md +++ /dev/null @@ -1,796 +0,0 @@ -# 09. DDD + 跨平台重构完整方案 (DDD & Cross-Platform Refactoring Complete Guide) - -> **文档日期**: 2026-02-08 -> **版本**: v1.0 -> **整合文档**: 07_ddd_refactoring_analysis.md + 08_cross_platform_decoupling_analysis.md -> **目的**: 提供完整的 DDD 重构方案,同时解决跨平台解耦问题 - ---- - -## 1. 执行摘要 - -### 1.1 问题分析 - -本插件存在两个核心问题需要同时解决: - -| 问题 | 现状 | 影响 | -|------|------|------| -| **平台耦合** | 34 处 QQ 硬编码 | 完全无法在其他平台使用 | -| **架构分层混乱** | 业务逻辑与基础设施混合 | 测试困难,维护成本高 | - -### 1.2 解决方案概述 - -采用 **DDD 分层架构 + 平台适配器模式**: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Interface Layer (main.py) │ -│ - 使用 AstrMessageEvent 基类,不再限制 QQ 平台 │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ - AnalysisOrchestrator: 分析流程编排 │ -│ - SchedulingService: 定时任务管理 │ -│ - ReportingService: 报告生成与分发 │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Domain Layer (平台无关) │ -│ - Entities: AnalysisTask, GroupAnalysisResult │ -│ - Value Objects: UnifiedMessage, PlatformCapabilities │ -│ - Domain Services: TopicAnalyzer, StatisticsCalculator, etc. │ -│ - Repository Interfaces: IMessageRepository, IMessageSender │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Infrastructure Layer │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Platform Adapters (实现 Repository Interfaces) │ │ -│ │ - OneBotAdapter (QQ) │ │ -│ │ - TelegramAdapter │ │ -│ │ - DiscordAdapter │ │ -│ │ - SlackAdapter │ │ -│ │ - LarkAdapter (飞书) │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ - LLMClient, ConfigManager, CircuitBreaker, etc. │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 1.3 预期收益 - -| 收益 | 量化 | -|------|------| -| 跨平台支持 | 从 1 个平台扩展到 5+ 个平台 | -| 可测试性 | 单元测试覆盖率可达 80%+ | -| 可维护性 | 新增平台只需 1 天工作量 | -| 可扩展性 | 新增分析器只需实现接口 | - ---- - -## 2. 限界上下文 (Bounded Contexts) - -### 2.1 上下文划分 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 群聊日常分析插件 (Plugin) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ 调度上下文 │ │ 分析上下文 │ │ 报告上下文 │ │ 平台上下文 │ │ -│ │ (Scheduling)│ │ (Analysis) │ │ (Reporting) │ │ (Platform) │ │ -│ │ │ │ │ │ │ │ [核心] │ │ -│ │ - 定时任务 │ │ - 话题分析 │ │ - 报告生成 │ │ - 消息获取 │ │ -│ │ - 并发控制 │ │ - 用户画像 │ │ - 格式转换 │ │ - 消息发送 │ │ -│ │ - 任务编排 │ │ - 金句提取 │ │ - 重试机制 │ │ - 群组信息 │ │ -│ │ │ │ - 统计计算 │ │ │ │ - 平台能力 │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ -│ │ │ │ │ │ -│ └────────────────┴────────────────┴────────────────┘ │ -│ │ │ -│ ┌─────────────────────────────────┴─────────────────────────────────┐ │ -│ │ 共享内核 (Shared Kernel) │ │ -│ │ - ConfigManager: 配置管理 │ │ -│ │ - TraceContext: 链路追踪 │ │ -│ │ - CircuitBreaker, RateLimiter: 弹性组件 │ │ -│ │ - UnifiedMessage: 统一消息模型 [跨平台核心] │ │ -│ │ - PlatformCapabilities: 平台能力描述 [跨平台核心] │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 上下文职责 - -| 上下文 | 职责 | 关键组件 | -|--------|------|----------| -| **调度** | 定时任务、并发控制、任务编排 | SchedulingService, AutoScheduler | -| **分析** | 话题分析、用户画像、金句提取、统计 | TopicAnalyzer, UserTitleAnalyzer, StatisticsCalculator | -| **报告** | 报告生成、格式转换、发送、重试 | ReportGenerator, ReportDispatcher, RetryManager | -| **平台** | 消息获取、消息发送、群组信息 | PlatformAdapter, IMessageRepository, IMessageSender | - ---- - -## 3. 领域模型设计 - -### 3.1 统一消息模型 (UnifiedMessage) - -**这是跨平台的核心抽象**,所有平台的消息都会被转换为此格式: - -```python -# src/domain/value_objects/unified_message.py -from dataclasses import dataclass -from typing import Optional, Any -from enum import Enum - -class MessageContentType(Enum): - TEXT = "text" - IMAGE = "image" - FILE = "file" - EMOJI = "emoji" - REPLY = "reply" - FORWARD = "forward" - UNKNOWN = "unknown" - -@dataclass(frozen=True) -class MessageContent: - """消息内容值对象""" - type: MessageContentType - text: str = "" - url: str = "" - emoji_id: str = "" - raw_data: Any = None # 保留原始数据用于调试 - -@dataclass(frozen=True) -class UnifiedMessage: - """ - 统一消息格式 - 跨平台核心值对象 - - 设计原则: - 1. 只保留分析需要的字段 - 2. 使用平台无关的类型 - 3. 不可变 (frozen=True) - """ - message_id: str - sender_id: str - sender_name: str - group_id: str - text_content: str # 纯文本,用于 LLM 分析 - contents: tuple[MessageContent, ...] # 完整消息链 - timestamp: int - platform: str # 来源平台标识 - reply_to: Optional[str] = None - - def has_text(self) -> bool: - return bool(self.text_content.strip()) - - def get_emoji_count(self) -> int: - return sum(1 for c in self.contents if c.type == MessageContentType.EMOJI) -``` - -### 3.2 平台能力描述 (PlatformCapabilities) - -```python -# src/domain/value_objects/platform_capabilities.py -from dataclasses import dataclass - -@dataclass(frozen=True) -class PlatformCapabilities: - """ - 平台能力描述 - 用于运行时决策 - - 每个平台适配器必须声明自己的能力, - 应用层根据能力决定是否可以执行某些操作。 - """ - platform_name: str - - # 消息获取能力 - supports_message_history: bool = False - max_message_history_days: int = 0 - max_message_count: int = 0 - - # 群组信息能力 - supports_group_list: bool = False - supports_group_info: bool = False - supports_member_list: bool = False - - # 消息发送能力 - supports_text_message: bool = True - supports_image_message: bool = True - supports_file_message: bool = False - supports_forward_message: bool = False - - def can_analyze(self) -> bool: - """是否支持群聊分析""" - return self.supports_message_history and self.max_message_history_days > 0 - - -# 预定义平台能力 -ONEBOT_CAPABILITIES = PlatformCapabilities( - platform_name="onebot", - supports_message_history=True, max_message_history_days=7, max_message_count=10000, - supports_group_list=True, supports_group_info=True, supports_member_list=True, - supports_text_message=True, supports_image_message=True, - supports_file_message=True, supports_forward_message=True, -) - -TELEGRAM_CAPABILITIES = PlatformCapabilities( - platform_name="telegram", - supports_message_history=True, max_message_history_days=30, max_message_count=10000, - supports_group_list=True, supports_group_info=True, supports_member_list=True, - supports_text_message=True, supports_image_message=True, supports_file_message=True, -) - -DISCORD_CAPABILITIES = PlatformCapabilities( - platform_name="discord", - supports_message_history=True, max_message_history_days=30, max_message_count=10000, - supports_group_list=True, supports_group_info=True, supports_member_list=True, - supports_text_message=True, supports_image_message=True, supports_file_message=True, -) -``` - -### 3.3 仓储接口 - -```python -# src/domain/repositories/message_repository.py -from abc import ABC, abstractmethod -from typing import List -from ..value_objects.unified_message import UnifiedMessage -from ..value_objects.platform_capabilities import PlatformCapabilities - -class IMessageRepository(ABC): - """消息仓储接口 - 每个平台适配器必须实现""" - - @abstractmethod - async def fetch_messages( - self, group_id: str, days: int, max_count: int = 1000 - ) -> List[UnifiedMessage]: - """获取群组历史消息,返回统一格式""" - pass - - @abstractmethod - def get_capabilities(self) -> PlatformCapabilities: - """获取平台能力描述""" - pass - - -# src/domain/repositories/message_sender.py -class IMessageSender(ABC): - """消息发送接口""" - - @abstractmethod - async def send_text(self, group_id: str, text: str) -> bool: - pass - - @abstractmethod - async def send_image(self, group_id: str, image_url: str, caption: str = "") -> bool: - pass - - @abstractmethod - async def send_file(self, group_id: str, file_path: str) -> bool: - pass - - -# src/domain/repositories/group_info_repository.py -@dataclass -class UnifiedGroup: - group_id: str - group_name: str - member_count: int - owner_id: Optional[str] = None - -class IGroupInfoRepository(ABC): - """群组信息仓储接口""" - - @abstractmethod - async def get_group_info(self, group_id: str) -> Optional[UnifiedGroup]: - pass - - @abstractmethod - async def get_group_list(self) -> List[str]: - pass -``` - -### 3.4 实体设计 - -```python -# src/domain/entities/analysis_task.py -from dataclasses import dataclass, field -from enum import Enum -import time, uuid - -class TaskStatus(Enum): - PENDING = "pending" - CHECKING_PLATFORM = "checking_platform" - FETCHING_MESSAGES = "fetching_messages" - ANALYZING = "analyzing" - GENERATING_REPORT = "generating_report" - SENDING = "sending" - COMPLETED = "completed" - FAILED = "failed" - UNSUPPORTED_PLATFORM = "unsupported_platform" - -@dataclass -class AnalysisTask: - """分析任务实体 - 聚合根""" - id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) - group_id: str = "" - platform_name: str = "" - trace_id: str = "" - status: TaskStatus = TaskStatus.PENDING - is_manual: bool = False - created_at: float = field(default_factory=time.time) - started_at: Optional[float] = None - completed_at: Optional[float] = None - result_id: Optional[str] = None - error_message: Optional[str] = None - - def start(self, capabilities: PlatformCapabilities) -> bool: - """开始任务,验证平台能力""" - if not capabilities.can_analyze(): - self.status = TaskStatus.UNSUPPORTED_PLATFORM - self.error_message = f"Platform {capabilities.platform_name} does not support analysis" - return False - self.status = TaskStatus.FETCHING_MESSAGES - self.started_at = time.time() - return True - - def complete(self, result_id: str): - self.status = TaskStatus.COMPLETED - self.result_id = result_id - self.completed_at = time.time() - - def fail(self, error: str): - self.status = TaskStatus.FAILED - self.error_message = error - self.completed_at = time.time() -``` - ---- - -## 4. 平台适配器设计 - -### 4.1 适配器架构 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ PlatformAdapter (Base) │ -│ - message_repository: IMessageRepository │ -│ - message_sender: IMessageSender │ -│ - group_info_repository: IGroupInfoRepository │ -│ - capabilities: PlatformCapabilities │ -└─────────────────────────────────────────────────────────────────┘ - △ - ┌─────────────────────┼─────────────────────┐ - │ │ │ -┌───────┴───────┐ ┌─────────┴─────────┐ ┌───────┴───────┐ -│ OneBotAdapter │ │ TelegramAdapter │ │ DiscordAdapter │ -│ │ │ │ │ │ -│ - call_action │ │ - python-telegram │ │ - pycord │ -│ - OneBot v11 │ │ -bot API │ │ - Discord API │ -└───────────────┘ └───────────────────┘ └───────────────┘ -``` - -### 4.2 OneBot 适配器实现 - -```python -# src/infrastructure/platform/adapters/onebot_adapter.py -from datetime import datetime, timedelta -from typing import List, Optional, Any -from astrbot.api import logger - -from ....domain.repositories.message_repository import IMessageRepository -from ....domain.value_objects.unified_message import UnifiedMessage, MessageContent, MessageContentType -from ....domain.value_objects.platform_capabilities import PlatformCapabilities, ONEBOT_CAPABILITIES - -class OneBotMessageRepository(IMessageRepository): - """OneBot v11 消息仓储实现""" - - def __init__(self, bot_instance: Any, bot_self_ids: List[str] = None): - self.bot = bot_instance - self.bot_self_ids = bot_self_ids or [] - - async def fetch_messages(self, group_id: str, days: int, max_count: int = 1000) -> List[UnifiedMessage]: - if not hasattr(self.bot, "call_action"): - return [] - - try: - result = await self.bot.call_action( - "get_group_msg_history", - group_id=int(group_id), - count=max_count, - ) - - if not result or "messages" not in result: - return [] - - end_time = datetime.now() - start_time = end_time - timedelta(days=days) - - 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 - - unified = self._convert_message(raw_msg, group_id) - if unified: - messages.append(unified) - - return messages - - except Exception as e: - if "retcode=1200" in str(e): - logger.warning(f"Bot not in group {group_id}") - else: - logger.error(f"OneBot fetch_messages failed: {e}") - return [] - - def _convert_message(self, raw_msg: dict, group_id: str) -> Optional[UnifiedMessage]: - """将 OneBot 消息转换为统一格式""" - try: - sender = raw_msg.get("sender", {}) - message_chain = raw_msg.get("message", []) - - contents = [] - text_parts = [] - - for seg in message_chain: - seg_type = seg.get("type", "") - seg_data = seg.get("data", {}) - - if seg_type == "text": - text = seg_data.get("text", "") - text_parts.append(text) - contents.append(MessageContent(type=MessageContentType.TEXT, text=text)) - elif seg_type == "image": - contents.append(MessageContent(type=MessageContentType.IMAGE, url=seg_data.get("url", ""))) - elif seg_type in ("face", "mface", "bface", "sface"): - contents.append(MessageContent(type=MessageContentType.EMOJI, emoji_id=str(seg_data.get("id", "")))) - else: - contents.append(MessageContent(type=MessageContentType.UNKNOWN, raw_data=seg)) - - return UnifiedMessage( - message_id=str(raw_msg.get("message_id", "")), - sender_id=str(sender.get("user_id", "")), - sender_name=sender.get("nickname", "") or sender.get("card", ""), - group_id=group_id, - text_content="".join(text_parts), - contents=tuple(contents), - timestamp=raw_msg.get("time", 0), - platform="onebot", - ) - except Exception as e: - logger.warning(f"Failed to convert message: {e}") - return None - - def get_capabilities(self) -> PlatformCapabilities: - return ONEBOT_CAPABILITIES -``` - -### 4.3 适配器工厂 - -```python -# src/infrastructure/platform/factory.py -from typing import Optional, Any - -class PlatformAdapterFactory: - """平台适配器工厂""" - - _adapters = { - "aiocqhttp": OneBotAdapter, - # "telegram": TelegramAdapter, - # "discord": DiscordAdapter, - } - - @classmethod - def create(cls, platform_name: str, bot_instance: Any, config: dict = None) -> Optional[PlatformAdapter]: - adapter_class = cls._adapters.get(platform_name) - if adapter_class is None: - return None - return adapter_class(bot_instance, config) - - @classmethod - def get_supported_platforms(cls) -> list[str]: - return list(cls._adapters.keys()) - - @classmethod - def is_supported(cls, platform_name: str) -> bool: - return platform_name in cls._adapters -``` - ---- - -## 5. 应用层设计 - -### 5.1 分析编排器 - -```python -# src/application/analysis_orchestrator.py -from typing import Optional -import asyncio -from astrbot.api import logger - -from ..domain.entities.analysis_task import AnalysisTask, TaskStatus -from ..domain.entities.analysis_result import GroupAnalysisResult -from ..domain.repositories.message_repository import IMessageRepository - -class AnalysisOrchestrator: - """ - 分析流程编排器 - 应用层核心 - - 职责:协调消息获取、分析、报告生成 - 特点:平台无关,通过仓储接口与基础设施层交互 - """ - - def __init__(self, config_manager, llm_client, history_repository): - self.config_manager = config_manager - self.llm_client = llm_client - self.history_repository = history_repository - - # 领域服务 - self.statistics_calculator = StatisticsCalculator() - self.topic_analyzer = TopicAnalyzer(llm_client) - self.user_title_analyzer = UserTitleAnalyzer(llm_client) - self.golden_quote_analyzer = GoldenQuoteAnalyzer(llm_client) - - async def execute( - self, - task: AnalysisTask, - message_repository: IMessageRepository, - unified_msg_origin: str = "", - ) -> Optional[GroupAnalysisResult]: - """执行分析任务""" - try: - # 1. 验证平台能力 - capabilities = message_repository.get_capabilities() - if not task.start(capabilities): - logger.warning(f"Platform {capabilities.platform_name} not supported") - return None - - # 2. 获取消息 - days = self.config_manager.get_analysis_days() - max_count = self.config_manager.get_max_messages() - - messages = await message_repository.fetch_messages(task.group_id, days, max_count) - - if not messages: - task.fail("No messages found") - return None - - min_threshold = self.config_manager.get_min_messages_threshold() - if len(messages) < min_threshold: - task.fail(f"Not enough messages: {len(messages)} < {min_threshold}") - return None - - # 3. 执行分析 - task.advance_to(TaskStatus.ANALYZING) - - result = GroupAnalysisResult( - group_id=task.group_id, - trace_id=task.trace_id, - message_count=len(messages), - ) - - # 统计计算 (本地) - result.statistics = self.statistics_calculator.calculate(messages) - - # LLM 分析 (并行) - topics, titles, quotes = await asyncio.gather( - self.topic_analyzer.analyze(messages, unified_msg_origin), - self.user_title_analyzer.analyze(messages, unified_msg_origin), - self.golden_quote_analyzer.analyze(messages, unified_msg_origin), - return_exceptions=True - ) - - # 处理结果 - if not isinstance(topics, Exception): - result.topics = topics - if not isinstance(titles, Exception): - result.user_titles = titles - if not isinstance(quotes, Exception): - result.golden_quotes = quotes - - # 4. 保存并完成 - await self.history_repository.save(task.group_id, result) - task.complete(result.id) - - return result - - except Exception as e: - task.fail(str(e)) - logger.error(f"Analysis failed: {e}", exc_info=True) - return None -``` - ---- - -## 6. 目标目录结构 - -``` -astrbot_plugin_group_daily_analysis/ # 去掉 QQ 前缀 -├── main.py # Interface Layer -├── src/ -│ ├── application/ # Application Layer -│ │ ├── __init__.py -│ │ ├── analysis_orchestrator.py # 分析流程编排 -│ │ ├── scheduling_service.py # 定时任务服务 -│ │ └── reporting_service.py # 报告服务 -│ │ -│ ├── domain/ # Domain Layer -│ │ ├── __init__.py -│ │ ├── entities/ -│ │ │ ├── analysis_task.py -│ │ │ └── analysis_result.py -│ │ ├── value_objects/ -│ │ │ ├── unified_message.py # 统一消息格式 -│ │ │ ├── platform_capabilities.py # 平台能力 -│ │ │ ├── topic.py, user_title.py, etc. -│ │ ├── services/ -│ │ │ ├── topic_analyzer.py -│ │ │ ├── statistics_calculator.py -│ │ │ └── report_generator.py -│ │ └── repositories/ -│ │ ├── message_repository.py # IMessageRepository -│ │ ├── message_sender.py # IMessageSender -│ │ └── group_info_repository.py # IGroupInfoRepository -│ │ -│ ├── infrastructure/ # Infrastructure Layer -│ │ ├── __init__.py -│ │ ├── platform/ -│ │ │ ├── factory.py # 适配器工厂 -│ │ │ ├── base.py # 适配器基类 -│ │ │ └── adapters/ -│ │ │ ├── onebot_adapter.py # OneBot (QQ) -│ │ │ ├── telegram_adapter.py -│ │ │ ├── discord_adapter.py -│ │ │ └── slack_adapter.py -│ │ ├── persistence/ -│ │ ├── llm/ -│ │ ├── config/ -│ │ └── resilience/ -│ │ -│ └── shared/ -│ ├── exceptions.py -│ └── constants.py -│ -├── tests/ -└── docs/ -``` - ---- - -## 7. 重构路线图 - -### 总览 - -| Phase | 内容 | 工作量 | 优先级 | -|-------|------|--------|--------| -| Phase 0 | 准备工作:目录结构、接口定义 | 1 天 | P0 | -| Phase 1 | 平台适配器:OneBot 实现 | 2-3 天 | P0 | -| Phase 2 | 领域层:值对象、实体、服务 | 2-3 天 | P0 | -| Phase 3 | 应用层:编排器、服务 | 2-3 天 | P0 | -| Phase 4 | 接口层:main.py 重构 | 1-2 天 | P0 | -| Phase 5 | 新平台:Telegram、Discord | 每平台 1 天 | P1 | -| Phase 6 | 测试与文档 | 2 天 | P1 | -| **总计** | | **12-16 天** | | - -### Phase 0: 准备工作 (1 天) - -```bash -# 创建目录结构 -mkdir -p src/{application,domain/{entities,value_objects,services,repositories},infrastructure/{platform/adapters,persistence,llm,config,resilience},shared} -``` - -**任务清单**: -- [ ] 创建目录结构 -- [ ] 定义 UnifiedMessage 值对象 -- [ ] 定义 PlatformCapabilities 值对象 -- [ ] 定义 IMessageRepository 接口 -- [ ] 定义 IMessageSender 接口 -- [ ] 定义 IGroupInfoRepository 接口 -- [ ] 定义共享异常类 - -### Phase 1: 平台适配器 (2-3 天) - -**任务清单**: -- [ ] 实现 OneBotMessageRepository -- [ ] 实现 OneBotMessageSender -- [ ] 实现 OneBotGroupInfoRepository -- [ ] 实现 OneBotAdapter (组合) -- [ ] 实现 PlatformAdapterFactory -- [ ] 编写单元测试 - -### Phase 2: 领域层重构 (2-3 天) - -**任务清单**: -- [ ] 迁移 data_models.py 到 value_objects/ -- [ ] 实现 AnalysisTask 实体 -- [ ] 实现 GroupAnalysisResult 实体 -- [ ] 重构分析器使用 UnifiedMessage -- [ ] 重构 StatisticsCalculator - -### Phase 3: 应用层重构 (2-3 天) - -**任务清单**: -- [ ] 实现 AnalysisOrchestrator -- [ ] 实现 SchedulingService -- [ ] 实现 ReportingService -- [ ] 重构 AutoScheduler - -### Phase 4: 接口层重构 (1-2 天) - -**任务清单**: -- [ ] 移除 AiocqhttpMessageEvent 导入 -- [ ] 使用 AstrMessageEvent 基类 -- [ ] 添加平台适配器选择逻辑 -- [ ] 更新错误消息 - -### Phase 5: 新平台支持 (每平台 1 天) - -**Telegram 适配器**: -- [ ] TelegramMessageRepository -- [ ] TelegramMessageSender -- [ ] TelegramAdapter - -**Discord 适配器**: -- [ ] DiscordMessageRepository -- [ ] DiscordMessageSender -- [ ] DiscordAdapter - ---- - -## 8. 风险与缓解 - -| 风险 | 可能性 | 影响 | 缓解措施 | -|------|--------|------|----------| -| 重构引入回归 | 中 | 高 | 渐进式迁移,保留原代码 | -| 平台 API 差异 | 高 | 中 | 统一消息格式 + 能力检查 | -| 消息历史受限 | 高 | 高 | 明确能力声明,提供降级 | -| 过度设计 | 中 | 中 | YAGNI 原则 | - ---- - -## 9. 总结 - -### 9.1 核心设计决策 - -1. **UnifiedMessage** - 跨平台消息抽象,领域层只处理此格式 -2. **PlatformCapabilities** - 平台能力声明,运行时决策 -3. **IMessageRepository** - 消息获取抽象,每个平台实现 -4. **PlatformAdapterFactory** - 适配器工厂,统一创建入口 - -### 9.2 收益总结 - -| 维度 | 重构前 | 重构后 | -|------|--------|--------| -| 平台支持 | 仅 QQ | QQ + Telegram + Discord + ... | -| 可测试性 | 困难 | 80%+ 覆盖率 | -| 新增平台 | N/A | 1 天 | -| 新增分析器 | 需要了解 QQ API | 只需实现接口 | - -### 9.3 参考文档 - -- 07_ddd_refactoring_analysis.md - 原 DDD 分析 -- 08_cross_platform_decoupling_analysis.md - 跨平台调研 -- 06_review.md - 务实重构审查 - ---- - -## 附录: 平台能力对比 - -| 功能 | OneBot | Telegram | Discord | Slack | 飞书 | -|------|--------|----------|---------|-------|------| -| 消息历史 | ✅ 7天 | ✅ 30天 | ✅ 30天 | ✅ 30天 | ✅ 30天 | -| 群列表 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 发送图片 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 发送文件 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 转发消息 | ✅ | ❌ | ❌ | ❌ | ❌ | -| 消息回应 | ❌ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/10_platform_abstraction_layer.md b/docs/10_platform_abstraction_layer.md deleted file mode 100644 index 5be0f7b..0000000 --- a/docs/10_platform_abstraction_layer.md +++ /dev/null @@ -1,2044 +0,0 @@ -# 10. 平台抽象层详细设计 (Platform Abstraction Layer Design) - -> **文档日期**: 2026-02-08 -> **版本**: v1.1 -> **前置文档**: 09_ddd_cross_platform_complete_guide.md -> **目的**: 详细定义平台上下文的设计、仓储接口、适配器实现示例 -> **更新**: v1.1 添加用户头像获取跨平台抽象 - ---- - -## 1. 平台限界上下文 (Platform Bounded Context) - -### 1.1 上下文定位 - -平台上下文是整个插件的**反腐败层 (Anti-Corruption Layer)**,负责: - -1. **隔离外部平台差异** - 将不同平台的 API 差异封装在适配器内 -2. **提供统一抽象** - 向领域层暴露统一的消息、群组、发送接口 -3. **声明平台能力** - 让应用层知道当前平台支持哪些功能 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 平台限界上下文 (Platform Context) │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌────────────────────────────────────────────────────────────────────────┐ │ -│ │ 领域模型 (Domain Model) │ │ -│ │ │ │ -│ │ 值对象: │ │ -│ │ - UnifiedMessage: 统一消息格式 │ │ -│ │ - MessageContent: 消息内容片段 │ │ -│ │ - PlatformCapabilities: 平台能力描述 │ │ -│ │ - UnifiedGroup: 统一群组信息 │ │ -│ │ - UnifiedMember: 统一成员信息 │ │ -│ │ │ │ -│ │ 仓储接口: │ │ -│ │ - IMessageRepository: 消息获取 │ │ -│ │ - IMessageSender: 消息发送 │ │ -│ │ - IGroupInfoRepository: 群组信息 │ │ -│ └────────────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────────────────────────────────────────────┐ │ -│ │ 适配器层 (Adapter Layer) │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ -│ │ │ OneBotAdapter│ │TelegramAdapter│ │DiscordAdapter│ │ │ -│ │ │ │ │ │ │ │ │ │ -│ │ │ - QQ/OneBot │ │ - Bot API │ │ - pycord │ │ │ -│ │ │ - v11/v12 │ │ - 6.x/7.x │ │ - 2.x │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ -│ │ │ SlackAdapter │ │ LarkAdapter │ │DingTalkAdapter│ │ │ -│ │ │ │ │ (飞书) │ │ (钉钉) │ │ │ -│ │ │ - Bolt API │ │ - Open API │ │ - Robot API │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ -│ └────────────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────────────────────────────────────────────┐ │ -│ │ 工厂 (Factory) │ │ -│ │ │ │ -│ │ PlatformAdapterFactory.create(platform_name, bot_instance, config) │ │ -│ │ → 返回 PlatformAdapter 实例 │ │ -│ └────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 1.2 上下文边界 - -| 边界内 (In Context) | 边界外 (Out of Context) | -|---------------------|------------------------| -| 消息格式转换 | 消息分析逻辑 | -| 平台 API 调用 | 报告生成 | -| 能力声明 | 定时调度 | -| 错误转换 | 业务规则 | - ---- - -## 2. 核心值对象设计 - -### 2.1 UnifiedMessage (统一消息) - -```python -# src/domain/value_objects/unified_message.py -from dataclasses import dataclass, field -from typing import Optional, Any, Tuple -from enum import Enum -from datetime import datetime - - -class MessageContentType(Enum): - """消息内容类型枚举""" - TEXT = "text" # 纯文本 - IMAGE = "image" # 图片 - FILE = "file" # 文件 - EMOJI = "emoji" # 表情/贴纸 - REPLY = "reply" # 回复引用 - FORWARD = "forward" # 转发 - AT = "at" # @提及 - VOICE = "voice" # 语音 - VIDEO = "video" # 视频 - LOCATION = "location" # 位置 - UNKNOWN = "unknown" # 未知类型 - - -@dataclass(frozen=True) -class MessageContent: - """ - 消息内容片段值对象 - - 不可变,用于组成消息链 - """ - type: MessageContentType - text: str = "" - url: str = "" - emoji_id: str = "" - emoji_name: str = "" - at_user_id: str = "" - 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 都是字符串 - 避免平台差异 - """ - # 基础标识 - message_id: str - sender_id: str - sender_name: str - group_id: str - - # 消息内容 - text_content: str # 提取的纯文本,用于 LLM 分析 - contents: Tuple[MessageContent, ...] = field(default_factory=tuple) # 完整消息链 - - # 时间信息 - timestamp: int = 0 # Unix 时间戳 - - # 平台信息 - platform: str = "unknown" # 来源平台标识 - - # 可选信息 - reply_to_id: Optional[str] = None # 回复的消息 ID - sender_card: Optional[str] = None # 群名片/备注 - - # 分析辅助方法 - def has_text(self) -> bool: - """是否有文本内容""" - return bool(self.text_content.strip()) - - def get_display_name(self) -> str: - """获取显示名称,优先群名片""" - return self.sender_card or self.sender_name or self.sender_id - - def get_emoji_count(self) -> int: - """获取表情数量""" - return sum(1 for c in self.contents if c.is_emoji()) - - def get_text_length(self) -> int: - """获取文本长度""" - return len(self.text_content) - - def get_datetime(self) -> datetime: - """获取消息时间""" - return datetime.fromtimestamp(self.timestamp) - - def to_analysis_format(self) -> str: - """转换为分析格式(用于 LLM)""" - name = self.get_display_name() - return f"[{name}]: {self.text_content}" - - -# 消息列表类型别名 -MessageList = list[UnifiedMessage] -``` - -### 2.2 PlatformCapabilities (平台能力) - -```python -# src/domain/value_objects/platform_capabilities.py -from dataclasses import dataclass -from typing import Optional - - -@dataclass(frozen=True) -class PlatformCapabilities: - """ - 平台能力描述 - 用于运行时决策 - - 每个平台适配器必须声明自己的能力, - 应用层根据能力决定是否可以执行某些操作。 - - 设计原则: - 1. 所有字段都有默认值(最保守的假设) - 2. 不可变 - 3. 提供便捷的检查方法 - """ - # 平台标识 - platform_name: str - platform_version: str = "unknown" - - # ========== 消息获取能力 ========== - supports_message_history: bool = False # 是否支持获取历史消息 - max_message_history_days: int = 0 # 最大历史天数 - max_message_count: int = 0 # 单次最大消息数 - supports_message_search: bool = False # 是否支持消息搜索 - - # ========== 群组信息能力 ========== - supports_group_list: bool = False # 是否支持获取群列表 - supports_group_info: bool = False # 是否支持获取群信息 - supports_member_list: bool = False # 是否支持获取成员列表 - supports_member_info: bool = False # 是否支持获取成员信息 - - # ========== 消息发送能力 ========== - supports_text_message: bool = True # 发送文本 - supports_image_message: bool = False # 发送图片 - supports_file_message: bool = False # 发送文件 - supports_forward_message: bool = False # 转发消息 - supports_reply_message: bool = False # 回复消息 - max_text_length: int = 4096 # 最大文本长度 - max_image_size_mb: float = 10.0 # 最大图片大小 - - # ========== 特殊能力 ========== - supports_at_all: bool = False # @全体成员 - supports_recall: bool = False # 撤回消息 - supports_edit: bool = False # 编辑消息 - - # ========== 头像能力 ========== - supports_user_avatar: bool = True # 是否支持获取用户头像 - supports_group_avatar: bool = False # 是否支持获取群组头像 - avatar_needs_api_call: bool = False # 获取头像是否需要 API 调用 - avatar_sizes: tuple = (100,) # 可用的头像尺寸 - - # ========== 检查方法 ========== - def can_analyze(self) -> bool: - """是否支持群聊分析(核心能力)""" - return ( - self.supports_message_history - and self.max_message_history_days > 0 - and self.max_message_count > 0 - ) - - def can_send_report(self, format: str = "image") -> bool: - """是否可以发送报告""" - if format == "text": - return self.supports_text_message - elif format == "image": - return self.supports_image_message - elif format == "pdf": - return self.supports_file_message - return False - - def get_effective_days(self, requested_days: int) -> int: - """获取实际可用的天数""" - return min(requested_days, self.max_message_history_days) - - def get_effective_count(self, requested_count: int) -> int: - """获取实际可用的消息数""" - return min(requested_count, self.max_message_count) - - -# ========== 预定义平台能力 ========== - -ONEBOT_V11_CAPABILITIES = PlatformCapabilities( - platform_name="onebot", - platform_version="v11", - # 消息历史 - supports_message_history=True, - max_message_history_days=7, - max_message_count=10000, - # 群组 - supports_group_list=True, - supports_group_info=True, - supports_member_list=True, - supports_member_info=True, - # 发送 - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - supports_forward_message=True, - supports_reply_message=True, - max_text_length=4500, - # 特殊 - supports_at_all=True, - supports_recall=True, - # 头像 - QQ 通过 URL 模板直接构造,无需 API 调用 - supports_user_avatar=True, - supports_group_avatar=True, - avatar_needs_api_call=False, - avatar_sizes=(40, 100, 140, 160, 640), -) - -TELEGRAM_CAPABILITIES = PlatformCapabilities( - platform_name="telegram", - platform_version="bot_api_7.x", - # 消息历史 - Telegram Bot API 不支持获取历史消息 - # 需要使用 Telethon (MTProto) 才能获取 - supports_message_history=False, # Bot API 不支持 - max_message_history_days=0, - max_message_count=0, - # 群组 - supports_group_list=False, # Bot 只能看到自己在的群 - supports_group_info=True, - supports_member_list=True, # 需要管理员权限 - # 发送 - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - supports_reply_message=True, - max_text_length=4096, - max_image_size_mb=50.0, - # 特殊 - supports_edit=True, - # 头像 - 需要调用 getUserProfilePhotos + getFile API - supports_user_avatar=True, - supports_group_avatar=True, - avatar_needs_api_call=True, - avatar_sizes=(160, 320, 640), -) - -TELEGRAM_USERBOT_CAPABILITIES = PlatformCapabilities( - platform_name="telegram_userbot", - platform_version="telethon", - # UserBot 可以获取历史消息 - supports_message_history=True, - max_message_history_days=365, - max_message_count=10000, - # 群组 - supports_group_list=True, - supports_group_info=True, - supports_member_list=True, - # 发送 - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - supports_reply_message=True, - # 头像 - supports_user_avatar=True, - supports_group_avatar=True, - avatar_needs_api_call=True, - avatar_sizes=(160, 320, 640), -) - -DISCORD_CAPABILITIES = PlatformCapabilities( - platform_name="discord", - platform_version="api_v10", - # Discord 支持获取历史消息 - supports_message_history=True, - max_message_history_days=30, - max_message_count=10000, - # 群组 - supports_group_list=True, - supports_group_info=True, - supports_member_list=True, - # 发送 - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - supports_reply_message=True, - max_text_length=2000, - max_image_size_mb=8.0, - # 特殊 - supports_edit=True, - # 头像 - 通过 CDN URL 模板构造,无需 API 调用 - supports_user_avatar=True, - supports_group_avatar=True, - avatar_needs_api_call=False, - avatar_sizes=(16, 32, 64, 128, 256, 512, 1024, 2048, 4096), -) - -SLACK_CAPABILITIES = PlatformCapabilities( - platform_name="slack", - platform_version="web_api", - # Slack 支持获取历史消息 - supports_message_history=True, - max_message_history_days=90, # 免费版有限制 - max_message_count=1000, - # 群组 - supports_group_list=True, - supports_group_info=True, - supports_member_list=True, - # 发送 - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - supports_reply_message=True, - max_text_length=40000, - # 特殊 - supports_edit=True, - # 头像 - 从 users.info API 的 profile.image_* 字段获取 - supports_user_avatar=True, - supports_group_avatar=False, # Slack 频道没有头像 - avatar_needs_api_call=True, - avatar_sizes=(24, 32, 48, 72, 192, 512, 1024), -) - -LARK_CAPABILITIES = PlatformCapabilities( - platform_name="lark", - platform_version="open_api", - # 飞书支持获取历史消息 - supports_message_history=True, - max_message_history_days=30, - max_message_count=50, # 每次请求最多 50 条 - # 群组 - supports_group_list=True, - supports_group_info=True, - supports_member_list=True, - # 发送 - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - supports_reply_message=True, - # 头像 - 从用户信息 API 的 avatar 字段获取 - supports_user_avatar=True, - supports_group_avatar=True, - avatar_needs_api_call=True, - avatar_sizes=(72, 240, 640), -) - -DINGTALK_CAPABILITIES = PlatformCapabilities( - platform_name="dingtalk", - platform_version="robot_api", - # 钉钉机器人不支持获取历史消息 - supports_message_history=False, - max_message_history_days=0, - max_message_count=0, - # 群组 - supports_group_list=False, - supports_group_info=False, - supports_member_list=False, - # 发送 - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - # 头像 - 钉钉机器人 API 不支持获取头像 - supports_user_avatar=False, - supports_group_avatar=False, - avatar_needs_api_call=False, - avatar_sizes=(), -) - - -# 能力查找表 -PLATFORM_CAPABILITIES = { - "aiocqhttp": ONEBOT_V11_CAPABILITIES, - "onebot": ONEBOT_V11_CAPABILITIES, - "telegram": TELEGRAM_CAPABILITIES, - "telegram_userbot": TELEGRAM_USERBOT_CAPABILITIES, - "discord": DISCORD_CAPABILITIES, - "slack": SLACK_CAPABILITIES, - "lark": LARK_CAPABILITIES, - "feishu": LARK_CAPABILITIES, # 别名 - "dingtalk": DINGTALK_CAPABILITIES, -} - - -def get_capabilities(platform_name: str) -> Optional[PlatformCapabilities]: - """根据平台名称获取能力描述""" - return PLATFORM_CAPABILITIES.get(platform_name.lower()) -``` - -### 2.3 UnifiedGroup (统一群组信息) - -```python -# src/domain/value_objects/unified_group.py -from dataclasses import dataclass -from typing import Optional, List - - -@dataclass(frozen=True) -class UnifiedMember: - """统一成员信息""" - user_id: str - nickname: str - card: Optional[str] = None # 群名片 - role: str = "member" # owner, admin, member - join_time: Optional[int] = None - avatar_url: Optional[str] = None # 头像 URL - avatar_data: Optional[str] = None # 头像 Base64 数据 (用于模板渲染) - - def get_display_name(self) -> str: - return self.card or self.nickname or self.user_id - - -@dataclass(frozen=True) -class UnifiedGroup: - """统一群组信息""" - group_id: str - group_name: str - member_count: int = 0 - owner_id: Optional[str] = None - create_time: Optional[int] = None - description: Optional[str] = None - platform: str = "unknown" -``` - ---- - -## 3. 仓储接口设计 - -### 3.1 IMessageRepository (消息仓储) - -```python -# src/domain/repositories/message_repository.py -from abc import ABC, abstractmethod -from typing import List, Optional -from ..value_objects.unified_message import UnifiedMessage -from ..value_objects.platform_capabilities import PlatformCapabilities - - -class IMessageRepository(ABC): - """ - 消息仓储接口 - - 每个平台适配器必须实现此接口。 - 所有方法都返回统一格式,隐藏平台差异。 - """ - - @abstractmethod - async def fetch_messages( - self, - group_id: str, - days: int = 1, - max_count: int = 1000, - before_id: Optional[str] = None, - ) -> List[UnifiedMessage]: - """ - 获取群组历史消息 - - Args: - group_id: 群组 ID - days: 获取最近 N 天的消息 - max_count: 最大消息数量 - before_id: 获取此消息之前的消息(用于分页) - - Returns: - 统一格式的消息列表,按时间升序排列 - - Raises: - PlatformNotSupportedError: 平台不支持此功能 - PlatformAPIError: API 调用失败 - """ - pass - - @abstractmethod - def get_capabilities(self) -> PlatformCapabilities: - """获取平台能力描述""" - pass - - @abstractmethod - def get_platform_name(self) -> str: - """获取平台名称""" - pass - - -class IMessageSender(ABC): - """ - 消息发送接口 - """ - - @abstractmethod - async def send_text( - self, - group_id: str, - text: str, - reply_to: Optional[str] = None, - ) -> bool: - """ - 发送文本消息 - - Args: - group_id: 目标群组 - text: 文本内容 - reply_to: 回复的消息 ID(可选) - - Returns: - 是否发送成功 - """ - pass - - @abstractmethod - async def send_image( - self, - group_id: str, - image_path: str, - caption: str = "", - ) -> bool: - """ - 发送图片消息 - - Args: - group_id: 目标群组 - image_path: 图片本地路径或 URL - caption: 图片说明(可选) - - Returns: - 是否发送成功 - """ - pass - - @abstractmethod - async def send_file( - self, - group_id: str, - file_path: str, - filename: Optional[str] = None, - ) -> bool: - """ - 发送文件 - - Args: - group_id: 目标群组 - file_path: 文件本地路径 - filename: 显示的文件名(可选) - - Returns: - 是否发送成功 - """ - pass - - def get_capabilities(self) -> PlatformCapabilities: - """获取平台能力描述""" - pass - - -class IGroupInfoRepository(ABC): - """ - 群组信息仓储接口 - """ - - @abstractmethod - async def get_group_info(self, group_id: str) -> Optional['UnifiedGroup']: - """获取群组信息""" - pass - - @abstractmethod - async def get_group_list(self) -> List[str]: - """获取 Bot 所在的所有群组 ID""" - pass - - @abstractmethod - async def get_member_list(self, group_id: str) -> List['UnifiedMember']: - """获取群组成员列表""" - pass - - @abstractmethod - async def get_member_info( - self, - group_id: str, - user_id: str, - ) -> Optional['UnifiedMember']: - """获取指定成员信息""" - pass - - -class IAvatarRepository(ABC): - """ - 头像仓储接口 - - 用于获取用户和群组头像,每个平台的实现方式不同: - - QQ/OneBot: 通过 URL 模板直接构造 (q1.qlogo.cn) - - Telegram: 需要调用 API 获取 file_id 再转换为 URL - - Discord: 通过 CDN URL 模板构造 (cdn.discordapp.com) - - Slack: 从用户信息的 profile.image_* 字段获取 - - 飞书: 从用户信息的 avatar 字段获取 - """ - - @abstractmethod - async def get_user_avatar_url( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """ - 获取用户头像 URL - - Args: - user_id: 用户 ID - size: 期望的头像尺寸 (会选择最接近的可用尺寸) - - Returns: - 头像 URL,如果无法获取则返回 None - """ - pass - - @abstractmethod - async def get_user_avatar_data( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """ - 获取用户头像的 Base64 数据 - - 用于需要内嵌图片的场景(如 HTML 模板渲染) - - Args: - user_id: 用户 ID - size: 期望的头像尺寸 - - Returns: - Base64 编码的图片数据 (data:image/png;base64,...), - 如果无法获取则返回 None - """ - pass - - @abstractmethod - async def get_group_avatar_url( - self, - group_id: str, - size: int = 100, - ) -> Optional[str]: - """ - 获取群组头像 URL - - Args: - group_id: 群组 ID - size: 期望的头像尺寸 - - Returns: - 群组头像 URL,如果无法获取则返回 None - """ - pass - - @abstractmethod - async def batch_get_avatar_urls( - self, - user_ids: List[str], - size: int = 100, - ) -> Dict[str, Optional[str]]: - """ - 批量获取用户头像 URL - - 用于报告生成等需要一次性获取多个头像的场景 - - Args: - user_ids: 用户 ID 列表 - size: 期望的头像尺寸 - - Returns: - 用户 ID 到头像 URL 的映射,无法获取的用户值为 None - """ - pass - - def get_default_avatar_url(self) -> str: - """获取默认头像 URL(当无法获取用户头像时使用)""" - # 可以返回一个通用的默认头像 - return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyaDE2di0yYzAtMi42Ni01LjMzLTQtOC00eiIvPjwvc3ZnPg==" -``` - -### 3.2 头像获取各平台实现策略 - -由于各平台头像获取方式差异较大,下面详细说明每个平台的实现策略: - -#### 3.2.1 QQ/OneBot 头像获取 - -QQ 头像可以通过 URL 模板直接构造,无需 API 调用: - -```python -# src/infrastructure/platform/adapters/onebot_avatar.py - -class OneBotAvatarRepository(IAvatarRepository): - """OneBot 头像仓储实现""" - - # 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] - - def _get_nearest_size(self, requested_size: int) -> int: - """获取最接近的可用尺寸""" - return min(self.AVAILABLE_SIZES, key=lambda x: abs(x - requested_size)) - - async def get_user_avatar_url( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 QQ 用户头像 URL""" - actual_size = self._get_nearest_size(size) - 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 - ) - - async def get_user_avatar_data( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 QQ 用户头像的 Base64 数据""" - import aiohttp - import base64 - - url = await self.get_user_avatar_url(user_id, size) - if not url: - return None - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=5) as resp: - if resp.status == 200: - data = await resp.read() - 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 - return None - - async def get_group_avatar_url( - self, - group_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 QQ 群头像 URL""" - actual_size = self._get_nearest_size(size) - return self.GROUP_AVATAR_TEMPLATE.format( - group_id=group_id, - size=actual_size - ) - - async def batch_get_avatar_urls( - self, - user_ids: List[str], - size: int = 100, - ) -> Dict[str, Optional[str]]: - """批量获取 QQ 用户头像 URL(无需 API 调用,直接构造)""" - return { - user_id: await self.get_user_avatar_url(user_id, size) - for user_id in user_ids - } -``` - -#### 3.2.2 Telegram 头像获取 - -Telegram 需要通过 Bot API 获取用户头像: - -```python -# src/infrastructure/platform/adapters/telegram_avatar.py - -class TelegramAvatarRepository(IAvatarRepository): - """Telegram 头像仓储实现""" - - def __init__(self, bot_token: str): - self.bot_token = bot_token - self.api_base = f"https://api.telegram.org/bot{bot_token}" - self.file_base = f"https://api.telegram.org/file/bot{bot_token}" - - async def get_user_avatar_url( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """ - 获取 Telegram 用户头像 URL - - 流程: - 1. 调用 getUserProfilePhotos 获取用户头像列表 - 2. 选择合适尺寸的 PhotoSize - 3. 调用 getFile 获取 file_path - 4. 构造完整的文件 URL - """ - import aiohttp - - try: - async with aiohttp.ClientSession() as session: - # Step 1: 获取用户头像列表 - photos_url = f"{self.api_base}/getUserProfilePhotos" - async with session.get(photos_url, params={ - "user_id": user_id, - "limit": 1 - }) as resp: - if resp.status != 200: - return None - data = await resp.json() - - if not data.get("ok") or not data.get("result", {}).get("photos"): - return None - - # Step 2: 选择合适尺寸的 PhotoSize - photos = data["result"]["photos"][0] # 最新的头像 - # Telegram 提供多个尺寸: 小(160x160), 中(320x320), 大(640x640) - # 选择最接近请求尺寸的 - best_photo = min(photos, key=lambda p: abs(p.get("width", 0) - size)) - file_id = best_photo.get("file_id") - - if not file_id: - return None - - # Step 3: 获取 file_path - file_url = f"{self.api_base}/getFile" - async with session.get(file_url, params={"file_id": file_id}) as resp: - if resp.status != 200: - return None - file_data = await resp.json() - - if not file_data.get("ok"): - return None - - file_path = file_data["result"].get("file_path") - if not file_path: - return None - - # Step 4: 构造完整 URL - return f"{self.file_base}/{file_path}" - - except Exception: - return None - - async def get_user_avatar_data( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Telegram 用户头像的 Base64 数据""" - import aiohttp - import base64 - - url = await self.get_user_avatar_url(user_id, size) - if not url: - return None - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=10) as resp: - if resp.status == 200: - data = await resp.read() - b64 = base64.b64encode(data).decode('utf-8') - return f"data:image/jpeg;base64,{b64}" - except Exception: - pass - return None - - async def get_group_avatar_url( - self, - group_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Telegram 群组头像 URL""" - import aiohttp - - try: - async with aiohttp.ClientSession() as session: - # 获取群组信息 - chat_url = f"{self.api_base}/getChat" - async with session.get(chat_url, params={"chat_id": group_id}) as resp: - if resp.status != 200: - return None - data = await resp.json() - - if not data.get("ok"): - return None - - photo = data["result"].get("photo") - if not photo: - return None - - # 获取大尺寸头像 - file_id = photo.get("big_file_id") or photo.get("small_file_id") - if not file_id: - return None - - # 获取 file_path - file_url = f"{self.api_base}/getFile" - async with session.get(file_url, params={"file_id": file_id}) as resp: - if resp.status != 200: - return None - file_data = await resp.json() - - if not file_data.get("ok"): - return None - - file_path = file_data["result"].get("file_path") - return f"{self.file_base}/{file_path}" if file_path else None - - except Exception: - return None - - async def batch_get_avatar_urls( - self, - user_ids: List[str], - size: int = 100, - ) -> Dict[str, Optional[str]]: - """批量获取 Telegram 用户头像 URL""" - import asyncio - - async def get_avatar(user_id: str): - return user_id, await self.get_user_avatar_url(user_id, size) - - results = await asyncio.gather(*[get_avatar(uid) for uid in user_ids]) - return dict(results) -``` - -#### 3.2.3 Discord 头像获取 - -Discord 头像可以通过 CDN URL 模板构造: - -```python -# src/infrastructure/platform/adapters/discord_avatar.py - -class DiscordAvatarRepository(IAvatarRepository): - """Discord 头像仓储实现""" - - CDN_BASE = "https://cdn.discordapp.com" - - # 有效尺寸: 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 - VALID_SIZES = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] - - def __init__(self, user_cache: dict = None): - """ - Args: - user_cache: 用户信息缓存 {user_id: {"avatar": "hash", "discriminator": "0"}} - """ - self.user_cache = user_cache or {} - - def _get_valid_size(self, requested_size: int) -> int: - """获取有效的尺寸(必须是 2 的幂次方,16-4096)""" - for size in self.VALID_SIZES: - if size >= requested_size: - return size - return 1024 # 默认 - - def _get_default_avatar_index(self, user_id: str, discriminator: str = "0") -> int: - """计算默认头像索引""" - if discriminator == "0": - # 新用户名系统: (user_id >> 22) % 6 - return (int(user_id) >> 22) % 6 - else: - # 旧系统: int(discriminator) % 5 - return int(discriminator) % 5 - - async def get_user_avatar_url( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Discord 用户头像 URL""" - actual_size = self._get_valid_size(size) - - user_info = self.user_cache.get(user_id, {}) - avatar_hash = user_info.get("avatar") - - if avatar_hash: - # 有自定义头像 - # 检查是否是动态头像 (以 a_ 开头) - is_animated = avatar_hash.startswith("a_") - ext = "gif" if is_animated else "png" - return f"{self.CDN_BASE}/avatars/{user_id}/{avatar_hash}.{ext}?size={actual_size}" - else: - # 使用默认头像 - discriminator = user_info.get("discriminator", "0") - index = self._get_default_avatar_index(user_id, discriminator) - return f"{self.CDN_BASE}/embed/avatars/{index}.png" - - async def get_user_avatar_data( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Discord 用户头像的 Base64 数据""" - import aiohttp - import base64 - - url = await self.get_user_avatar_url(user_id, size) - if not url: - return None - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=5) as resp: - if resp.status == 200: - data = await resp.read() - 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 - return None - - async def get_group_avatar_url( - self, - group_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Discord 服务器/频道图标 URL""" - # Discord 的 Guild Icon URL 格式 - # 需要有 icon_hash 信息 - guild_info = self.user_cache.get(f"guild_{group_id}", {}) - icon_hash = guild_info.get("icon") - - if icon_hash: - actual_size = self._get_valid_size(size) - is_animated = icon_hash.startswith("a_") - ext = "gif" if is_animated else "png" - return f"{self.CDN_BASE}/icons/{group_id}/{icon_hash}.{ext}?size={actual_size}" - return None - - async def batch_get_avatar_urls( - self, - user_ids: List[str], - size: int = 100, - ) -> Dict[str, Optional[str]]: - """批量获取 Discord 用户头像 URL(无需 API 调用)""" - return { - user_id: await self.get_user_avatar_url(user_id, size) - for user_id in user_ids - } -``` - -#### 3.2.4 Slack 头像获取 - -Slack 需要从用户信息 API 获取头像: - -```python -# src/infrastructure/platform/adapters/slack_avatar.py - -class SlackAvatarRepository(IAvatarRepository): - """Slack 头像仓储实现""" - - # Slack 提供的头像尺寸 - SIZE_FIELDS = { - 24: "image_24", - 32: "image_32", - 48: "image_48", - 72: "image_72", - 192: "image_192", - 512: "image_512", - 1024: "image_1024", - } - - def __init__(self, bot_token: str): - self.bot_token = bot_token - self.api_base = "https://slack.com/api" - - def _get_size_field(self, requested_size: int) -> str: - """获取最接近请求尺寸的字段名""" - sizes = sorted(self.SIZE_FIELDS.keys()) - for size in sizes: - if size >= requested_size: - return self.SIZE_FIELDS[size] - return "image_512" # 默认 - - async def get_user_avatar_url( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Slack 用户头像 URL""" - import aiohttp - - try: - async with aiohttp.ClientSession() as session: - url = f"{self.api_base}/users.info" - headers = {"Authorization": f"Bearer {self.bot_token}"} - - async with session.get(url, headers=headers, params={"user": user_id}) as resp: - if resp.status != 200: - return None - data = await resp.json() - - if not data.get("ok"): - return None - - profile = data.get("user", {}).get("profile", {}) - - # 尝试获取请求尺寸的头像 - size_field = self._get_size_field(size) - avatar_url = profile.get(size_field) - - # 如果没有,尝试获取其他尺寸 - if not avatar_url: - for field in ["image_512", "image_192", "image_72", "image_48"]: - avatar_url = profile.get(field) - if avatar_url: - break - - return avatar_url - - except Exception: - return None - - async def get_user_avatar_data( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Slack 用户头像的 Base64 数据""" - import aiohttp - import base64 - - url = await self.get_user_avatar_url(user_id, size) - if not url: - return None - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=5) as resp: - if resp.status == 200: - data = await resp.read() - 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 - return None - - async def get_group_avatar_url( - self, - group_id: str, - size: int = 100, - ) -> Optional[str]: - """Slack 频道没有头像概念,返回 None""" - return None - - async def batch_get_avatar_urls( - self, - user_ids: List[str], - size: int = 100, - ) -> Dict[str, Optional[str]]: - """批量获取 Slack 用户头像 URL""" - import asyncio - - async def get_avatar(user_id: str): - return user_id, await self.get_user_avatar_url(user_id, size) - - # Slack API 有速率限制,需要控制并发 - results = {} - for user_id in user_ids: - results[user_id] = await self.get_user_avatar_url(user_id, size) - await asyncio.sleep(0.1) # 避免触发速率限制 - - return results -``` - -### 3.3 平台异常定义 - -```python -# src/domain/exceptions.py - -class PlatformError(Exception): - """平台相关错误基类""" - def __init__(self, message: str, platform: str = "unknown"): - self.platform = platform - super().__init__(f"[{platform}] {message}") - - -class PlatformNotSupportedError(PlatformError): - """平台不支持此功能""" - pass - - -class PlatformAPIError(PlatformError): - """平台 API 调用失败""" - def __init__(self, message: str, platform: str, status_code: int = None): - self.status_code = status_code - super().__init__(message, platform) - - -class PlatformAuthError(PlatformError): - """平台认证失败""" - pass - - -class PlatformRateLimitError(PlatformError): - """平台请求频率限制""" - def __init__(self, message: str, platform: str, retry_after: int = None): - self.retry_after = retry_after - super().__init__(message, platform) - - -class BotNotInGroupError(PlatformError): - """Bot 不在目标群组中""" - def __init__(self, group_id: str, platform: str): - self.group_id = group_id - super().__init__(f"Bot not in group {group_id}", platform) -``` - ---- - -## 4. 适配器实现示例 - -### 4.1 适配器基类 - -```python -# src/infrastructure/platform/base.py -from abc import ABC -from typing import Any, Optional - -from ...domain.repositories.message_repository import ( - IMessageRepository, - IMessageSender, - IGroupInfoRepository, -) -from ...domain.value_objects.platform_capabilities import PlatformCapabilities - - -class PlatformAdapter(ABC): - """ - 平台适配器基类 - - 组合了消息仓储、消息发送、群组信息三个接口。 - 每个平台适配器继承此类并实现所有方法。 - """ - - def __init__(self, bot_instance: Any, config: dict = None): - self.bot = bot_instance - self.config = config or {} - self._capabilities: Optional[PlatformCapabilities] = None - - @property - def capabilities(self) -> PlatformCapabilities: - """平台能力(延迟初始化)""" - if self._capabilities is None: - self._capabilities = self._init_capabilities() - return self._capabilities - - def _init_capabilities(self) -> PlatformCapabilities: - """初始化平台能力,子类必须实现""" - raise NotImplementedError - - # 以下方法由子类实现,对应三个接口 - # IMessageRepository - async def fetch_messages(self, group_id: str, days: int, max_count: int): ... - - # IMessageSender - async def send_text(self, group_id: str, text: str, reply_to: str = None): ... - async def send_image(self, group_id: str, image_path: str, caption: str = ""): ... - async def send_file(self, group_id: str, file_path: str, filename: str = None): ... - - # IGroupInfoRepository - async def get_group_info(self, group_id: str): ... - async def get_group_list(self) -> list[str]: ... - async def get_member_list(self, group_id: str): ... -``` - -### 4.2 OneBot 适配器完整实现 - -```python -# src/infrastructure/platform/adapters/onebot_adapter.py -from datetime import datetime, timedelta -from typing import List, Optional, Any -import asyncio - -from astrbot.api import logger - -from ....domain.value_objects.unified_message import ( - UnifiedMessage, - MessageContent, - MessageContentType, -) -from ....domain.value_objects.platform_capabilities import ( - PlatformCapabilities, - ONEBOT_V11_CAPABILITIES, -) -from ....domain.value_objects.unified_group import UnifiedGroup, UnifiedMember -from ....domain.exceptions import ( - PlatformAPIError, - BotNotInGroupError, - PlatformNotSupportedError, -) -from ..base import PlatformAdapter - - -class OneBotAdapter(PlatformAdapter): - """ - OneBot v11 协议适配器 - - 支持 NapCat、go-cqhttp、Lagrange 等 OneBot 实现 - """ - - def __init__(self, bot_instance: Any, config: dict = None): - 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: - return ONEBOT_V11_CAPABILITIES - - # ==================== IMessageRepository ==================== - - async def fetch_messages( - self, - group_id: str, - days: int = 1, - max_count: int = 1000, - before_id: Optional[str] = None, - ) -> List[UnifiedMessage]: - """获取群组历史消息""" - - if not hasattr(self.bot, "call_action"): - raise PlatformNotSupportedError( - "Bot instance does not support call_action", - "onebot" - ) - - try: - # 调用 OneBot API - result = await self.bot.call_action( - "get_group_msg_history", - group_id=int(group_id), - count=max_count, - ) - - if not result or "messages" not in result: - logger.warning(f"No messages returned for group {group_id}") - return [] - - # 计算时间范围 - end_time = datetime.now() - start_time = end_time - timedelta(days=days) - - 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 - - # 过滤 Bot 自己的消息 - sender_id = str(raw_msg.get("sender", {}).get("user_id", "")) - if sender_id in self.bot_self_ids: - continue - - # 转换消息 - unified = self._convert_message(raw_msg, group_id) - if unified: - messages.append(unified) - - # 按时间升序排列 - messages.sort(key=lambda m: m.timestamp) - - logger.info(f"Fetched {len(messages)} messages from group {group_id}") - return messages - - except Exception as e: - error_str = str(e) - if "retcode=1200" in error_str or "1200" in error_str: - raise BotNotInGroupError(group_id, "onebot") - raise PlatformAPIError(f"Failed to fetch messages: {e}", "onebot") - - def _convert_message(self, raw_msg: dict, group_id: str) -> Optional[UnifiedMessage]: - """将 OneBot 消息转换为统一格式""" - 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}}] - - contents = [] - text_parts = [] - - for seg in message_chain: - seg_type = seg.get("type", "") - seg_data = seg.get("data", {}) - - if seg_type == "text": - text = seg_data.get("text", "") - text_parts.append(text) - contents.append(MessageContent( - type=MessageContentType.TEXT, - text=text - )) - - elif seg_type == "image": - contents.append(MessageContent( - type=MessageContentType.IMAGE, - url=seg_data.get("url", seg_data.get("file", "")) - )) - - elif seg_type == "at": - at_qq = seg_data.get("qq", "") - contents.append(MessageContent( - type=MessageContentType.AT, - at_user_id=str(at_qq) - )) - - elif seg_type in ("face", "mface", "bface", "sface"): - contents.append(MessageContent( - type=MessageContentType.EMOJI, - emoji_id=str(seg_data.get("id", "")), - raw_data={"face_type": seg_type} - )) - - elif seg_type == "reply": - contents.append(MessageContent( - type=MessageContentType.REPLY, - raw_data={"reply_id": seg_data.get("id", "")} - )) - - elif seg_type == "forward": - contents.append(MessageContent( - type=MessageContentType.FORWARD, - raw_data=seg_data - )) - - elif seg_type == "record": - contents.append(MessageContent( - type=MessageContentType.VOICE, - url=seg_data.get("url", seg_data.get("file", "")) - )) - - elif seg_type == "video": - contents.append(MessageContent( - type=MessageContentType.VIDEO, - url=seg_data.get("url", seg_data.get("file", "")) - )) - - else: - contents.append(MessageContent( - type=MessageContentType.UNKNOWN, - raw_data=seg - )) - - # 提取回复 ID - reply_to = None - for c in contents: - if c.type == MessageContentType.REPLY and c.raw_data: - reply_to = str(c.raw_data.get("reply_id", "")) - break - - return UnifiedMessage( - message_id=str(raw_msg.get("message_id", "")), - sender_id=str(sender.get("user_id", "")), - sender_name=sender.get("nickname", ""), - sender_card=sender.get("card", "") or None, - group_id=group_id, - text_content="".join(text_parts), - contents=tuple(contents), - timestamp=raw_msg.get("time", 0), - platform="onebot", - reply_to_id=reply_to, - ) - - except Exception as e: - logger.warning(f"Failed to convert OneBot message: {e}") - return None - - def get_capabilities(self) -> PlatformCapabilities: - return self.capabilities - - def get_platform_name(self) -> str: - return "onebot" - - # ==================== IMessageSender ==================== - - async def send_text( - self, - group_id: str, - text: str, - reply_to: Optional[str] = None, - ) -> bool: - """发送文本消息""" - try: - message = [{"type": "text", "data": {"text": text}}] - - if reply_to: - message.insert(0, {"type": "reply", "data": {"id": reply_to}}) - - await self.bot.call_action( - "send_group_msg", - group_id=int(group_id), - message=message, - ) - return True - except Exception as e: - logger.error(f"Failed to send text: {e}") - return False - - async def send_image( - self, - group_id: str, - image_path: str, - caption: str = "", - ) -> bool: - """发送图片消息""" - try: - message = [] - - if caption: - message.append({"type": "text", "data": {"text": caption}}) - - # 支持本地路径和 URL - if image_path.startswith(("http://", "https://")): - file_str = image_path - else: - file_str = f"file:///{image_path}" - - message.append({"type": "image", "data": {"file": file_str}}) - - await self.bot.call_action( - "send_group_msg", - group_id=int(group_id), - message=message, - ) - return True - except Exception as e: - logger.error(f"Failed to send image: {e}") - return False - - async def send_file( - self, - group_id: str, - file_path: str, - filename: Optional[str] = None, - ) -> 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], - ) - return True - except Exception as e: - logger.error(f"Failed to send file: {e}") - return False - - # ==================== IGroupInfoRepository ==================== - - async def get_group_info(self, group_id: str) -> Optional[UnifiedGroup]: - """获取群组信息""" - try: - result = await self.bot.call_action( - "get_group_info", - group_id=int(group_id), - ) - - if not result: - return None - - return UnifiedGroup( - group_id=str(result.get("group_id", group_id)), - group_name=result.get("group_name", ""), - member_count=result.get("member_count", 0), - owner_id=str(result.get("owner_id", "")) or None, - create_time=result.get("group_create_time"), - platform="onebot", - ) - except Exception as e: - logger.error(f"Failed to get group info: {e}") - return None - - async def get_group_list(self) -> List[str]: - """获取 Bot 所在的所有群组 ID""" - try: - result = await self.bot.call_action("get_group_list") - return [str(g.get("group_id", "")) for g in result or []] - except Exception as e: - logger.error(f"Failed to get group list: {e}") - return [] - - async def get_member_list(self, group_id: str) -> List[UnifiedMember]: - """获取群组成员列表""" - try: - result = await self.bot.call_action( - "get_group_member_list", - group_id=int(group_id), - ) - - members = [] - for m in result or []: - members.append(UnifiedMember( - user_id=str(m.get("user_id", "")), - nickname=m.get("nickname", ""), - card=m.get("card", "") or None, - role=m.get("role", "member"), - join_time=m.get("join_time"), - )) - return members - except Exception as e: - logger.error(f"Failed to get member list: {e}") - return [] - - async def get_member_info( - self, - group_id: str, - user_id: str, - ) -> Optional[UnifiedMember]: - """获取指定成员信息""" - try: - result = await self.bot.call_action( - "get_group_member_info", - group_id=int(group_id), - user_id=int(user_id), - ) - - if not result: - return None - - return UnifiedMember( - user_id=str(result.get("user_id", user_id)), - nickname=result.get("nickname", ""), - card=result.get("card", "") or None, - role=result.get("role", "member"), - join_time=result.get("join_time"), - ) - except Exception as e: - logger.error(f"Failed to get member info: {e}") - return None -``` - -### 4.3 适配器工厂 - -```python -# src/infrastructure/platform/factory.py -from typing import Optional, Any, Dict, Type -from astrbot.api import logger - -from .base import PlatformAdapter -from .adapters.onebot_adapter import OneBotAdapter -# from .adapters.telegram_adapter import TelegramAdapter # 待实现 -# from .adapters.discord_adapter import DiscordAdapter # 待实现 - - -class PlatformAdapterFactory: - """ - 平台适配器工厂 - - 根据平台名称创建对应的适配器实例。 - 使用注册表模式,便于扩展新平台。 - """ - - # 适配器注册表 - _adapters: Dict[str, Type[PlatformAdapter]] = { - "aiocqhttp": OneBotAdapter, - "onebot": OneBotAdapter, - # "telegram": TelegramAdapter, - # "discord": DiscordAdapter, - } - - @classmethod - def register(cls, platform_name: str, adapter_class: Type[PlatformAdapter]): - """注册新的适配器""" - cls._adapters[platform_name.lower()] = adapter_class - logger.info(f"Registered platform adapter: {platform_name}") - - @classmethod - def create( - cls, - platform_name: str, - bot_instance: Any, - config: dict = None, - ) -> Optional[PlatformAdapter]: - """ - 创建平台适配器 - - Args: - platform_name: 平台名称(如 "aiocqhttp", "telegram") - bot_instance: AstrBot 传入的 bot 实例 - config: 配置字典 - - Returns: - 平台适配器实例,如果平台不支持则返回 None - """ - adapter_class = cls._adapters.get(platform_name.lower()) - - if adapter_class is None: - logger.warning(f"Unsupported platform: {platform_name}") - return None - - try: - adapter = adapter_class(bot_instance, config) - logger.info(f"Created {platform_name} adapter with capabilities: {adapter.capabilities}") - return adapter - except Exception as e: - logger.error(f"Failed to create {platform_name} adapter: {e}") - return None - - @classmethod - def get_supported_platforms(cls) -> list[str]: - """获取所有支持的平台名称""" - return list(cls._adapters.keys()) - - @classmethod - def is_supported(cls, platform_name: str) -> bool: - """检查平台是否支持""" - return platform_name.lower() in cls._adapters - - @classmethod - def get_analyzable_platforms(cls) -> list[str]: - """获取支持分析功能的平台""" - result = [] - for name, adapter_class in cls._adapters.items(): - try: - # 创建临时实例检查能力 - temp = adapter_class.__new__(adapter_class) - temp._capabilities = None - caps = temp._init_capabilities() - if caps.can_analyze(): - result.append(name) - except Exception: - pass - return result -``` - ---- - -## 5. 更新后的目录结构 - -``` -astrbot_plugin_group_daily_analysis/ -├── main.py # Interface Layer (入口) -├── metadata.yaml -├── requirements.txt -│ -├── src/ -│ ├── __init__.py -│ │ -│ ├── application/ # Application Layer -│ │ ├── __init__.py -│ │ ├── analysis_orchestrator.py # 分析流程编排 -│ │ ├── scheduling_service.py # 定时任务服务 -│ │ └── reporting_service.py # 报告服务 -│ │ -│ ├── domain/ # Domain Layer (平台无关) -│ │ ├── __init__.py -│ │ │ -│ │ ├── entities/ # 实体 -│ │ │ ├── __init__.py -│ │ │ ├── analysis_task.py # 分析任务聚合根 -│ │ │ └── analysis_result.py # 分析结果实体 -│ │ │ -│ │ ├── value_objects/ # 值对象 -│ │ │ ├── __init__.py -│ │ │ ├── unified_message.py # ★ 统一消息格式 -│ │ │ ├── platform_capabilities.py # ★ 平台能力描述 -│ │ │ ├── unified_group.py # ★ 统一群组信息 -│ │ │ ├── topic.py # 话题 -│ │ │ ├── user_title.py # 用户称号 -│ │ │ ├── golden_quote.py # 金句 -│ │ │ └── statistics.py # 统计数据 -│ │ │ -│ │ ├── services/ # 领域服务 -│ │ │ ├── __init__.py -│ │ │ ├── topic_analyzer.py # 话题分析 -│ │ │ ├── user_title_analyzer.py # 用户称号分析 -│ │ │ ├── golden_quote_analyzer.py # 金句分析 -│ │ │ ├── statistics_calculator.py # 统计计算 -│ │ │ └── report_generator.py # 报告生成 -│ │ │ -│ │ ├── repositories/ # ★ 仓储接口 -│ │ │ ├── __init__.py -│ │ │ ├── message_repository.py # IMessageRepository -│ │ │ ├── message_sender.py # IMessageSender -│ │ │ └── group_info_repository.py # IGroupInfoRepository -│ │ │ -│ │ └── exceptions.py # 领域异常 -│ │ -│ ├── infrastructure/ # Infrastructure Layer -│ │ ├── __init__.py -│ │ │ -│ │ ├── platform/ # ★ 平台适配层 -│ │ │ ├── __init__.py -│ │ │ ├── base.py # 适配器基类 -│ │ │ ├── factory.py # 适配器工厂 -│ │ │ │ -│ │ │ └── adapters/ # 具体适配器 -│ │ │ ├── __init__.py -│ │ │ ├── onebot_adapter.py # ★ OneBot (QQ) -│ │ │ ├── telegram_adapter.py # Telegram (待实现) -│ │ │ ├── discord_adapter.py # Discord (待实现) -│ │ │ ├── slack_adapter.py # Slack (待实现) -│ │ │ └── lark_adapter.py # 飞书 (待实现) -│ │ │ -│ │ ├── persistence/ # 持久化 -│ │ │ ├── __init__.py -│ │ │ └── history_repository.py # 历史记录存储 -│ │ │ -│ │ ├── llm/ # LLM 客户端 -│ │ │ ├── __init__.py -│ │ │ └── llm_client.py -│ │ │ -│ │ ├── config/ # 配置 -│ │ │ ├── __init__.py -│ │ │ └── config_manager.py -│ │ │ -│ │ └── resilience/ # 弹性组件 -│ │ ├── __init__.py -│ │ ├── circuit_breaker.py -│ │ ├── rate_limiter.py -│ │ └── retry.py -│ │ -│ └── shared/ # 共享组件 -│ ├── __init__.py -│ ├── constants.py -│ └── trace_context.py -│ -├── tests/ # 测试 -│ ├── __init__.py -│ ├── unit/ -│ │ ├── domain/ -│ │ │ └── test_unified_message.py -│ │ └── infrastructure/ -│ │ └── test_onebot_adapter.py -│ └── integration/ -│ -└── docs/ # 文档 - ├── 09_ddd_cross_platform_complete_guide.md - └── 10_platform_abstraction_layer.md # 本文档 -``` - ---- - -## 6. 重构路线图(更新版) - -### 6.1 总览 - -| Phase | 内容 | 工作量 | 状态 | -|-------|------|--------|------| -| **Phase 0** | 准备:目录结构、接口定义 | 1 天 | 📋 待开始 | -| **Phase 1** | 平台适配器:OneBot 实现 | 2-3 天 | 📋 待开始 | -| **Phase 2** | 领域层:值对象、实体、服务 | 2-3 天 | 📋 待开始 | -| **Phase 3** | 应用层:编排器、服务 | 2-3 天 | 📋 待开始 | -| **Phase 4** | 接口层:main.py 重构 | 1-2 天 | 📋 待开始 | -| **Phase 5** | 新平台:Telegram、Discord | 每平台 1 天 | 📋 待开始 | -| **Phase 6** | 测试与文档 | 2 天 | 📋 待开始 | -| **总计** | | **12-16 天** | | - -### 6.2 Phase 0 详细任务 - -```bash -# 创建目录结构 -mkdir -p src/{application,domain/{entities,value_objects,services,repositories},infrastructure/{platform/adapters,persistence,llm,config,resilience},shared} -touch src/__init__.py -touch src/{application,domain,infrastructure,shared}/__init__.py -touch src/domain/{entities,value_objects,services,repositories}/__init__.py -touch src/infrastructure/{platform,persistence,llm,config,resilience}/__init__.py -touch src/infrastructure/platform/adapters/__init__.py -``` - -**检查清单**: -- [ ] 创建完整目录结构 -- [ ] 定义 `UnifiedMessage` 值对象 -- [ ] 定义 `PlatformCapabilities` 值对象 -- [ ] 定义 `UnifiedGroup` 和 `UnifiedMember` 值对象 -- [ ] 定义 `IMessageRepository` 接口 -- [ ] 定义 `IMessageSender` 接口 -- [ ] 定义 `IGroupInfoRepository` 接口 -- [ ] 定义平台异常类 -- [ ] 创建适配器基类 `PlatformAdapter` -- [ ] 创建适配器工厂 `PlatformAdapterFactory` - -### 6.3 Phase 1 详细任务 - -**检查清单**: -- [ ] 实现 `OneBotAdapter._convert_message()` 消息转换 -- [ ] 实现 `OneBotAdapter.fetch_messages()` 消息获取 -- [ ] 实现 `OneBotAdapter.send_text/image/file()` 消息发送 -- [ ] 实现 `OneBotAdapter.get_group_info/list()` 群组信息 -- [ ] 注册到 `PlatformAdapterFactory` -- [ ] 编写单元测试 - -### 6.4 Phase 5 详细任务(跨平台阶段) - -#### Telegram 适配器 -- [ ] 研究 python-telegram-bot 或 Telethon API -- [ ] 实现 `TelegramAdapter` 基础结构 -- [ ] 实现消息格式转换 -- [ ] 处理 Telegram 特有的消息类型(贴纸、动图等) -- [ ] 测试并验证 - -#### Discord 适配器 -- [ ] 研究 pycord 或 discord.py API -- [ ] 实现 `DiscordAdapter` 基础结构 -- [ ] 实现消息格式转换 -- [ ] 处理 Discord 特有功能(embed、reaction 等) -- [ ] 测试并验证 - ---- - -## 7. 平台能力对比表 - -| 功能 | OneBot | Telegram Bot | Telegram User | Discord | Slack | 飞书 | 钉钉 | -|------|--------|--------------|---------------|---------|-------|------|------| -| **消息历史** | ✅ 7天 | ❌ | ✅ 365天 | ✅ 30天 | ✅ 90天 | ✅ 30天 | ❌ | -| **群列表** | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | -| **成员列表** | ✅ | ✅* | ✅ | ✅ | ✅ | ✅ | ❌ | -| **发送图片** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **发送文件** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **可分析** | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | -| **用户头像** | ✅ URL模板 | ✅ API调用 | ✅ API调用 | ✅ CDN模板 | ✅ API调用 | ✅ API调用 | ❌ | -| **群组头像** | ✅ URL模板 | ✅ API调用 | ✅ API调用 | ✅ CDN模板 | ❌ | ✅ API调用 | ❌ | -| **头像尺寸** | 40-640 | 160-640 | 160-640 | 16-4096 | 24-1024 | 72-640 | - | - -> *: 需要管理员权限 - -### 7.1 头像获取方式对比 - -| 平台 | 获取方式 | URL 模板 | 需要 API 调用 | 备注 | -|------|----------|----------|--------------|------| -| **QQ/OneBot** | URL 模板 | `q1.qlogo.cn/g?b=qq&nk={user_id}&s={size}` | ❌ | 直接构造,无需认证 | -| **Telegram** | API 调用 | - | ✅ | 需要 getUserProfilePhotos + getFile | -| **Discord** | CDN 模板 | `cdn.discordapp.com/avatars/{user_id}/{hash}.png` | ❌ | 需要缓存 avatar_hash | -| **Slack** | API 调用 | - | ✅ | users.info API,profile.image_* | -| **飞书** | API 调用 | - | ✅ | 用户信息 API,avatar 字段 | -| **钉钉** | 不支持 | - | - | 机器人 API 无头像能力 | - ---- - -## 8. 总结 - -本文档详细定义了: - -1. **平台限界上下文** - 作为反腐败层隔离外部平台差异 -2. **核心值对象** - `UnifiedMessage`, `PlatformCapabilities`, `UnifiedGroup`, `UnifiedMember` -3. **仓储接口** - `IMessageRepository`, `IMessageSender`, `IGroupInfoRepository`, `IAvatarRepository` -4. **头像获取抽象** - 各平台头像获取的统一接口和具体实现 -5. **OneBot 适配器完整实现** - 可直接使用的代码 -6. **目录结构** - 清晰的分层架构 -7. **重构路线图** - 详细的任务清单 - -按照此设计实施,可以实现: -- 从 QQ 专属扩展到多平台支持 -- 新增平台只需实现适配器 -- 领域逻辑完全平台无关 -- 可测试性大幅提升 -- **模板可以使用统一的头像接口,无需关心平台差异** diff --git a/docs/11_ddd_implementation_status.md b/docs/11_ddd_implementation_status.md deleted file mode 100644 index 88d4e5a..0000000 --- a/docs/11_ddd_implementation_status.md +++ /dev/null @@ -1,317 +0,0 @@ -# 11. DDD 重构实施状态文档 (DDD Refactoring Implementation Status) - -> **文档日期**: 2026-02-08 -> **版本**: v2.1 -> **状态**: Phase 2 完成 (100%) - ---- - -## 1. 实施概述 - -### 1.1 架构决策记录 (ADR) - -#### ADR-001: 采用渐进式集成而非完全重构 - -**背景**: 原计划对所有分析器进行完全重构以使用 UnifiedMessage 格式。 - -**决策**: 采用渐进式集成方式: -- 新建 DDD 分层结构 (domain/infrastructure/application) -- 现有分析器代码保持不变 -- 通过 MessageConverter 提供双向转换 -- AnalysisOrchestrator 作为新旧代码的桥梁 - -**原因**: -1. 现有分析器代码已经稳定运行 -2. 完全重构风险高,可能引入新 bug -3. 渐进式迁移允许逐步验证 -4. 保持向后兼容性 - -**后果**: -- 正面:风险低,可逐步迁移 -- 负面:短期内存在两套消息格式 - ---- - -## 2. 已实现的架构层 - -### 2.1 领域层 (Domain Layer) ✅ 100% - -``` -src/domain/ -├── __init__.py -├── exceptions.py # 领域异常层次结构 -├── entities/ -│ ├── __init__.py -│ ├── analysis_task.py # 分析任务聚合根 -│ └── analysis_result.py # 分析结果实体 -├── value_objects/ -│ ├── __init__.py -│ ├── unified_message.py # 统一消息格式 (核心) -│ ├── platform_capabilities.py # 平台能力声明 -│ ├── unified_group.py # 统一群组/成员信息 -│ ├── topic.py # 话题值对象 -│ ├── user_title.py # 用户称号值对象 -│ ├── golden_quote.py # 金句值对象 -│ └── statistics.py # 统计数据值对象 -├── services/ # ✅ 完整实现 -│ ├── __init__.py -│ ├── statistics_calculator.py # 统计计算服务 -│ ├── report_generator.py # 报告生成服务 -│ ├── topic_analyzer.py # 话题分析服务接口与适配器 ✅ NEW -│ ├── user_title_analyzer.py # 用户称号分析服务接口与适配器 ✅ NEW -│ └── golden_quote_analyzer.py # 金句分析服务接口与适配器 ✅ NEW -└── repositories/ - ├── __init__.py - ├── message_repository.py # IMessageRepository, IMessageSender, IGroupInfoRepository - └── avatar_repository.py # IAvatarRepository -``` - -**新增值对象**: -- `Topic`: 不可变话题值对象,支持 from_dict/to_dict -- `UserTitle`: 不可变用户称号值对象,平台无关的用户ID -- `GoldenQuote`: 不可变金句值对象 -- `TokenUsage`, `EmojiStatistics`, `GroupStatistics`, `UserStatistics`: 统计相关值对象 - -**新增领域服务**: -- `StatisticsCalculator`: 从 UnifiedMessage 计算群聊统计 -- `ReportGenerator`: 生成平台无关的分析报告 -- `ITopicAnalyzer` + `TopicAnalyzerAdapter`: 话题分析服务接口与适配器 -- `IUserTitleAnalyzer` + `UserTitleAnalyzerAdapter`: 用户称号分析服务接口与适配器 -- `IGoldenQuoteAnalyzer` + `GoldenQuoteAnalyzerAdapter`: 金句分析服务接口与适配器 - -**新增领域异常**: -- `DomainException` 基类 -- `AnalysisException`, `LLMException`, `PlatformException` 等层次结构 - -**关键设计**: -- `UnifiedMessage`: 不可变值对象,所有平台消息的统一抽象 -- `PlatformCapabilities`: 声明式能力描述,支持运行时能力检查 -- Repository 接口:定义平台无关的数据访问契约 - -### 2.2 基础设施层 (Infrastructure Layer) ✅ 100% - -``` -src/infrastructure/ -├── __init__.py -├── platform/ -│ ├── __init__.py -│ ├── base.py # PlatformAdapter 基类 -│ ├── factory.py # PlatformAdapterFactory 工厂 -│ └── adapters/ -│ ├── __init__.py -│ └── onebot_adapter.py # OneBot v11 完整实现 -├── persistence/ # ✅ NEW -│ ├── __init__.py -│ └── history_repository.py # 历史记录存储实现 -├── llm/ # ✅ NEW -│ ├── __init__.py -│ └── llm_client.py # LLM 客户端封装 -├── config/ # ✅ NEW -│ ├── __init__.py -│ └── config_manager.py # 集中配置管理 -└── resilience/ # ✅ NEW - ├── __init__.py - ├── circuit_breaker.py # 熔断器 - ├── rate_limiter.py # 令牌桶限流器 - └── retry.py # 指数退避重试 -``` - -**新增组件**: -- `HistoryRepository`: JSON 文件存储,支持按日期查询历史 -- `LLMClient`: 封装 AstrBot 的 LLM provider 系统 -- `ConfigManager`: 统一配置访问,支持点号分隔的键路径 -- `CircuitBreaker`: 熔断器模式,防止级联故障 -- `RateLimiter`: 令牌桶算法限流 -- `retry_async`: 指数退避重试装饰器 - -**关键设计**: -- `PlatformAdapter`: 组合所有 Repository 接口的抽象基类 -- `OneBotAdapter`: 完整实现消息获取、发送、群组信息、头像获取 -- `PlatformAdapterFactory`: 注册表模式,支持动态添加新平台 - -**支持的平台**: -- ✅ OneBot v11 (aiocqhttp) - 完整实现 -- 🔲 Telegram - 预留接口 -- 🔲 Discord - 预留接口 -- 🔲 Slack - 预留接口 - -### 2.3 应用层 (Application Layer) ✅ 100% - -``` -src/application/ -├── __init__.py -├── analysis_orchestrator.py # 分析流程编排器 -├── message_converter.py # 消息格式转换器 -├── scheduling_service.py # 定时任务服务 ✅ NEW -└── reporting_service.py # 报告服务 ✅ NEW -``` - -**新增服务**: -- `SchedulingService`: 定时任务管理,支持按时间调度分析 -- `ReportingService`: 报告生成和存储协调服务 - -**关键设计**: -- `AnalysisOrchestrator`: - - 使用 PlatformAdapter 获取消息 (DDD 方式) - - 提供 `fetch_messages_as_raw()` 兼容现有分析器 - - 封装平台能力检查逻辑 - -- `MessageConverter`: - - `from_onebot_message()`: OneBot dict → UnifiedMessage - - `to_onebot_message()`: UnifiedMessage → OneBot dict - - `unified_to_analysis_text()`: 生成 LLM 分析用文本 - -### 2.4 共享层 (Shared Layer) ✅ NEW - -``` -src/shared/ -├── __init__.py -├── constants.py # 全局常量定义 -└── trace_context.py # 请求追踪上下文 -``` - -**新增组件**: -- `constants.py`: 平台标识、任务状态、错误码等常量 -- `TraceContext`: 请求追踪,支持 context manager 和装饰器 - -### 2.5 核心层集成 (Core Layer Integration) ✅ - -**BotManager 重构**: -- 自动创建 `PlatformAdapter` alongside bot instances -- 新增 `get_adapter()`, `has_adapter()`, `can_analyze()` 方法 -- 新增 `_detect_platform_name()` 自动平台检测 -- `get_status_info()` 包含 adapter 信息 - -```python -# 使用示例 -adapter = bot_manager.get_adapter(platform_id) -if adapter: - caps = adapter.get_capabilities() - if caps.can_analyze(): - messages = await adapter.fetch_messages(group_id, days=1) -``` - ---- - -## 3. 与原设计文档的差异 - -### 3.1 文档 09 vs 实际实现 - -| 原设计 | 实际实现 | 原因 | -|--------|----------|------| -| 完全重构分析器 | 保持现有分析器 | 风险控制 | -| main.py 使用 AstrMessageEvent | 保持 AiocqhttpMessageEvent | 渐进式迁移 | -| 所有分析使用 UnifiedMessage | 通过 Converter 兼容 | 向后兼容 | - -### 3.2 后续迁移路径 - -1. **Phase 1 (当前)**: DDD 基础架构就位,现有代码不变 -2. **Phase 2**: 新功能使用 DDD 架构开发 -3. **Phase 3**: 逐步将现有分析器迁移到 UnifiedMessage -4. **Phase 4**: 移除 MessageConverter,完成迁移 - ---- - -## 4. 验证状态 - -### 4.1 Docker 容器验证 ✅ - -```bash -# 验证命令 -docker exec astrbot python -c " -from src.domain.value_objects import UnifiedMessage, PlatformCapabilities -from src.infrastructure.platform import PlatformAdapterFactory -from src.application import AnalysisOrchestrator, MessageConverter -print('All imports successful!') -print(f'Supported platforms: {PlatformAdapterFactory.get_supported_platforms()}') -" - -# 输出 -All DDD layer imports successful! -Supported platforms: ['aiocqhttp', 'onebot'] -``` - -### 4.2 待验证项 - -- [ ] 完整分析流程端到端测试 -- [ ] OneBotAdapter 消息获取实际测试 -- [ ] 报告生成与发送测试 - ---- - -## 5. 使用指南 - -### 5.1 新代码使用 DDD 架构 - -```python -from src.infrastructure.platform import PlatformAdapterFactory -from src.application import AnalysisOrchestrator, AnalysisConfig - -# 创建适配器 -adapter = PlatformAdapterFactory.create("aiocqhttp", bot_instance, config) - -# 创建编排器 -orchestrator = AnalysisOrchestrator(adapter, AnalysisConfig(days=1)) - -# 检查能力 -if orchestrator.can_analyze(): - # 获取统一格式消息 - messages = await orchestrator.fetch_messages(group_id) - - # 或获取原始格式 (兼容现有分析器) - raw_messages = await orchestrator.fetch_messages_as_raw(group_id) -``` - -### 5.2 现有代码保持不变 - -现有的 `MessageHandler`, `MessageAnalyzer`, `LLMAnalyzer` 等继续使用原始 dict 格式,无需修改。 - ---- - -## 6. Git 提交记录 - -| Commit | 描述 | -|--------|------| -| `c1d3bf5` | feat: add DDD architecture layers (domain, infrastructure, application) | -| `8d5d95a` | docs: add DDD implementation status and architecture decisions | -| `59ab291` | chore: simplify .gitignore with glob pattern for __pycache__ | -| `62a91a9` | refactor: integrate PlatformAdapterFactory into BotManager | -| `8f18783` | docs: update DDD implementation status to Phase 1 complete | -| `7ef58f9` | feat: complete DDD Phase 2 - domain services, infrastructure layers, shared | -| `39cc318` | feat: 完善 DDD 架构 - 添加领域分析器服务适配层 | - ---- - -## 7. 下一步计划 - -1. ✅ ~~将 BotManager 集成 PlatformAdapterFactory~~ -2. ✅ ~~添加 domain/value_objects (Topic, UserTitle, GoldenQuote, Statistics)~~ -3. ✅ ~~添加 domain/services (StatisticsCalculator, ReportGenerator)~~ -4. ✅ ~~添加 infrastructure 子模块 (persistence, llm, config, resilience)~~ -5. ✅ ~~添加 application 服务 (SchedulingService, ReportingService)~~ -6. ✅ ~~添加 shared 组件 (constants, TraceContext)~~ -7. ✅ ~~添加 domain/services 分析器服务接口与适配器~~ -8. 🔲 添加更多平台适配器 (Telegram, Discord) - 按需实现 -9. 🔲 编写单元测试覆盖 DDD 层 -10. 🔲 端到端测试完整分析流程 -11. 🔲 逐步迁移现有分析器到 UnifiedMessage 格式 - ---- - -## 8. 架构验证 - -### 8.1 Docker 容器内验证通过 ✅ - -```bash -docker exec astrbot python -c " -from src.domain.value_objects import UnifiedMessage, PlatformCapabilities, Topic, UserTitle, GoldenQuote -from src.domain.services import StatisticsCalculator, ReportGenerator, ITopicAnalyzer, IUserTitleAnalyzer, IGoldenQuoteAnalyzer -from src.domain.repositories import IMessageRepository, IMessageSender, IGroupInfoRepository -from src.domain.entities import AnalysisTask, AnalysisResult, GroupAnalysisResult -from src.infrastructure.platform import PlatformAdapterFactory -from src.infrastructure.resilience import CircuitBreaker, RateLimiter -from src.application import AnalysisOrchestrator, MessageConverter -from src.shared.constants import Platform, TaskStatus, ContentType, ReportFormat -print('✅ 所有 DDD 层导入成功!') -" -``` diff --git a/docs/12_platform_integration_guide.md b/docs/12_platform_integration_guide.md deleted file mode 100644 index c2c4c2a..0000000 --- a/docs/12_platform_integration_guide.md +++ /dev/null @@ -1,658 +0,0 @@ -# 平台接入开发指南 - -本文档说明如何为群聊日报分析插件接入新的消息平台。 - -## 目录 - -1. [架构概述](#架构概述) -2. [快速开始](#快速开始) -3. [详细步骤](#详细步骤) -4. [接口说明](#接口说明) -5. [最佳实践](#最佳实践) -6. [示例代码](#示例代码) -7. [测试指南](#测试指南) - ---- - -## 架构概述 - -本插件采用 DDD(领域驱动设计)架构,通过平台适配器模式实现多平台支持: - -``` -┌─────────────────────────────────────────────────────────┐ -│ 应用层 (Application) │ -│ AnalysisOrchestrator │ -└─────────────────────────┬───────────────────────────────┘ - │ 使用 - ▼ -┌─────────────────────────────────────────────────────────┐ -│ 基础设施层 (Infrastructure) │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ PlatformAdapter (抽象基类) │ │ -│ │ - fetch_messages() │ │ -│ │ - send_text/image/file() │ │ -│ │ - get_group_info() │ │ -│ │ - convert_to_raw_format() │ │ -│ └─────────────────────────────────────────────────┘ │ -│ ▲ ▲ ▲ │ -│ │ │ │ │ -│ ┌────────┴───┐ ┌──────┴──────┐ ┌────┴────────┐ │ -│ │OneBotAdapter│ │DiscordAdapter│ │ 新平台Adapter │ │ -│ │ (QQ平台) │ │ (Discord) │ │ (待实现) │ │ -│ └────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### 核心组件 - -| 组件 | 路径 | 说明 | -|------|------|------| -| PlatformAdapter | `src/infrastructure/platform/base.py` | 平台适配器抽象基类 | -| PlatformAdapterFactory | `src/infrastructure/platform/factory.py` | 适配器工厂,管理注册和创建 | -| UnifiedMessage | `src/domain/value_objects/unified_message.py` | 统一消息格式 | -| PlatformCapabilities | `src/domain/value_objects/platform_capabilities.py` | 平台能力声明 | - ---- - -## 快速开始 - -接入新平台只需 3 步: - -### 步骤 1:创建适配器文件 - -```bash -# 在 adapters 目录下创建新文件 -touch src/infrastructure/platform/adapters/your_platform_adapter.py -``` - -### 步骤 2:实现适配器类 - -```python -from ..base import PlatformAdapter -from ....domain.value_objects.platform_capabilities import PlatformCapabilities - -class YourPlatformAdapter(PlatformAdapter): - def _init_capabilities(self) -> PlatformCapabilities: - return PlatformCapabilities( - platform_name="your_platform", - supports_message_history=True, - # ... 其他能力 - ) - - # 实现所有抽象方法... -``` - -### 步骤 3:注册适配器 - -在 `factory.py` 的 `_register_adapters()` 函数中添加: - -```python -try: - from .adapters.your_platform_adapter import YourPlatformAdapter - PlatformAdapterFactory.register("your_platform", YourPlatformAdapter) -except ImportError: - pass -``` - ---- - -## 详细步骤 - -### 1. 定义平台能力 - -首先,明确你的平台支持哪些功能。以 `Discord` 为例: - -```python -from ....domain.value_objects.platform_capabilities import PlatformCapabilities - -DISCORD_CAPABILITIES = PlatformCapabilities( - platform_name="discord", # 平台标识符 - platform_version="api_v10", # 平台版本 - supports_message_history=True, # 是否支持历史消息获取 - max_message_history_days=30, # 历史消息最大天数 - max_message_count=10000, # 最大消息数量 - supports_group_list=True, # 是否支持获取群列表 - supports_group_info=True, # 是否支持获取群信息 - supports_member_list=True, # 是否支持获取成员列表 - supports_text_message=True, # 是否支持文本消息 - supports_image_message=True, # 是否支持图片消息 - supports_file_message=True, # 是否支持文件消息 - supports_reply_message=True, # 是否支持回复消息 - max_text_length=2000, # 最大文本长度 - max_image_size_mb=8.0, # 最大图片大小 - supports_edit=True, # 是否支持编辑 - supports_user_avatar=True, # 是否支持用户头像 - supports_group_avatar=True, # 是否支持群头像 - avatar_sizes=(16, 32, 64, 128, 256, 512, 1024, 2048, 4096), # 支持的头像尺寸 -) -``` - -### 2. 实现消息获取 - -消息获取是分析的核心。你需要实现 `fetch_messages` 方法。 - -**以 Discord 为例:** - -```python - async def fetch_messages( - self, - group_id: str, - days: int = 1, - max_count: int = 100, - before_id: Optional[str] = None, - ) -> List[UnifiedMessage]: - """ - 获取 Discord 频道消息历史 - """ - if not discord: - logger.error("未安装 py-cord 库,无法使用 Discord 适配器") - return [] - - try: - channel_id = int(group_id) - channel = self._discord_client.get_channel(channel_id) - # ... 获取 channel 逻辑 ... - - end_time = datetime.now() - start_time = end_time - timedelta(days=days) - - messages = [] - - # 构建 history 参数 - history_kwargs = {"limit": max_count, "after": start_time} - if before_id: - # 处理分页 - try: - history_kwargs["before"] = discord.Object(id=int(before_id)) - except ValueError: - 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 - - unified = self._convert_message(msg, group_id) - if unified: - messages.append(unified) - - # 按时间升序排序 - messages.sort(key=lambda m: m.timestamp) - return messages - - except Exception as e: - logger.error(f"获取 Discord 消息失败: {e}", exc_info=True) - return [] -``` - -### 3. 实现消息转换 - -将平台原生消息转换为 `UnifiedMessage`。这是解耦的关键。 - -**以 Discord 为例:** - -```python - def _convert_message(self, raw_msg: Any, group_id: str) -> Optional[UnifiedMessage]: - """ - 将 Discord 消息转换为统一格式 - """ - try: - contents = [] - - # 1. 文本内容 - if raw_msg.content: - contents.append( - MessageContent(type=MessageContentType.TEXT, text=raw_msg.content) - ) - - # 2. 附件处理 (图片/视频/文件) - for attachment in raw_msg.attachments: - content_type = attachment.content_type or "" - if content_type.startswith("image/"): - contents.append( - MessageContent( - type=MessageContentType.IMAGE, url=attachment.url - ) - ) - # ... 处理其他类型 ... - - # 3. 嵌入内容 (Embeds) - for embed in raw_msg.embeds: - if embed.image: - contents.append( - MessageContent( - type=MessageContentType.IMAGE, url=embed.image.url - ) - ) - # ... - - # 4. 贴纸 (Stickers) - if raw_msg.stickers: - for sticker in raw_msg.stickers: - contents.append( - MessageContent( - type=MessageContentType.IMAGE, # 贴纸视为图片 - url=sticker.url, - # ... - ) - ) - - # 构造 UnifiedMessage - return UnifiedMessage( - message_id=str(raw_msg.id), - sender_id=str(raw_msg.author.id), - sender_name=raw_msg.author.name, # 用户名 - sender_card=getattr(raw_msg.author, "nick", None) or getattr(raw_msg.author, "global_name", None), # 优先显示服务器昵称 - group_id=group_id, - text_content=raw_msg.content, # 用于 LLM 分析的纯文本 - contents=tuple(contents), - timestamp=int(raw_msg.created_at.timestamp()), - platform="discord", - reply_to_id=str(raw_msg.reference.message_id) if raw_msg.reference else None, - ) - except Exception as e: - logger.error(f"转换 Discord 消息失败: {e}") - return None -``` - -### 4. 实现原生格式转换 - -为了保持与现有分析器(如 `MessageHandler`)的向后兼容性,需要实现 `convert_to_raw_format`。 - -```python - def convert_to_raw_format(self, messages: List[UnifiedMessage]) -> List[dict]: - """ - 将统一消息格式转换为 OneBot 兼容格式 (用于兼容 MessageHandler) - """ - raw_messages = [] - for msg in messages: - # 构造 OneBot 风格的消息字典 - raw_msg = { - "message_id": msg.message_id, - "group_id": msg.group_id, - "time": msg.timestamp, - "sender": { - "user_id": msg.sender_id, - "nickname": msg.sender_name, - "card": msg.sender_card, - }, - "message": [], - } - - # 构造消息链 - for content in msg.contents: - if content.type == MessageContentType.TEXT: - raw_msg["message"].append( - {"type": "text", "data": {"text": content.text}} - ) - elif content.type == MessageContentType.IMAGE: - raw_msg["message"].append( - { - "type": "image", - "data": {"url": content.url, "file": content.url}, - } - ) - # ... 其他类型 ... - - raw_messages.append(raw_msg) - - return raw_messages -``` - -### 5. 实现消息发送 - -实现发送文本、图片等功能。 - -**以 Discord 为例:** - -```python - async def send_image( - self, - group_id: str, - image_path: str, - caption: str = "", - ) -> bool: - """发送图片到 Discord 频道""" - # ... 获取 channel ... - - try: - # 处理本地文件或 URL - file_to_send = None - if image_path.startswith(("http://", "https://")): - # URL 方式,需要下载图片后作为文件发送 - # 因为 Discord 无法访问内部/本地 URL - import aiohttp - from io import BytesIO - - async with aiohttp.ClientSession() as session: - async with session.get(image_path) as response: - if response.status == 200: - image_data = await response.read() - # ... - file_to_send = discord.File(BytesIO(image_data), filename="report.png") - 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) - return True - - except Exception as e: - logger.error(f"Discord 发送图片失败: {e}") - return False -``` - -### 6. 实现群组和成员信息获取 - -实现 `get_group_info`、`get_group_list`、`get_member_list` 等方法,以便插件可以自动发现群组并获取成员信息。 - -**以 Discord 为例:** - -```python - async def get_group_info(self, group_id: str) -> Optional[UnifiedGroup]: - """获取 Discord 频道信息""" - # ... 获取 channel ... - - # 尝试获取 Guild 信息 - guild = getattr(channel, "guild", None) - group_name = getattr(channel, "name", str(channel.id)) - - if guild: - member_count = guild.member_count - owner_id = str(guild.owner_id) - else: - # 私信 - member_count = len(getattr(channel, "recipients", [])) + 1 - owner_id = None - - return UnifiedGroup( - group_id=str(channel.id), - group_name=group_name, - member_count=member_count, - owner_id=owner_id, - create_time=int(channel.created_at.timestamp()), - platform="discord", - ) -``` - -### 7. 实现头像获取 - -实现 `get_user_avatar_url` 等方法,用于生成报告时的头像显示。 - -**以 Discord 为例:** - -```python - async def get_user_avatar_url( - self, - user_id: str, - size: int = 100, - ) -> Optional[str]: - """获取 Discord 用户头像 URL""" - # ... 获取 user ... - - if user: - # 调整 size 到最接近的 2 的幂次方 (Discord 要求) - allowed_sizes = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] - target_size = min(allowed_sizes, key=lambda x: abs(x - size)) - - # display_avatar 自动处理默认头像 - return user.display_avatar.with_size(target_size).url - return None -``` - ---- - -## 接口说明 - -### PlatformAdapter 必须实现的方法 - -| 方法 | 说明 | 返回类型 | -|------|------|----------| -| `_init_capabilities()` | 初始化平台能力 | `PlatformCapabilities` | -| `fetch_messages()` | 获取消息历史 | `List[UnifiedMessage]` | -| `convert_to_raw_format()` | 转换为原生格式 | `List[dict]` | -| `send_text()` | 发送文本 | `bool` | -| `send_image()` | 发送图片 | `bool` | -| `send_file()` | 发送文件 | `bool` | -| `get_group_info()` | 获取群组信息 | `Optional[UnifiedGroup]` | -| `get_group_list()` | 获取群组列表 | `List[str]` | -| `get_member_list()` | 获取成员列表 | `List[UnifiedMember]` | -| `get_member_info()` | 获取成员信息 | `Optional[UnifiedMember]` | -| `get_user_avatar_url()` | 获取头像 URL | `Optional[str]` | -| `get_user_avatar_data()` | 获取头像 Base64 | `Optional[str]` | -| `get_group_avatar_url()` | 获取群头像 URL | `Optional[str]` | -| `batch_get_avatar_urls()` | 批量获取头像 | `Dict[str, Optional[str]]` | - -### UnifiedMessage 字段 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `message_id` | `str` | 消息唯一 ID | -| `sender_id` | `str` | 发送者 ID | -| `sender_name` | `str` | 发送者昵称 | -| `sender_card` | `Optional[str]` | 发送者群名片 | -| `group_id` | `str` | 群组 ID | -| `text_content` | `str` | 纯文本内容 | -| `contents` | `Tuple[MessageContent, ...]` | 消息内容列表 | -| `timestamp` | `int` | Unix 时间戳 | -| `platform` | `str` | 平台标识 | -| `reply_to_id` | `Optional[str]` | 回复的消息 ID | - -### MessageContentType 枚举 - -| 类型 | 说明 | -|------|------| -| `TEXT` | 文本 | -| `IMAGE` | 图片 | -| `AT` | @某人 | -| `EMOJI` | 表情 | -| `REPLY` | 回复 | -| `FORWARD` | 转发 | -| `VOICE` | 语音 | -| `VIDEO` | 视频 | -| `FILE` | 文件 | -| `UNKNOWN` | 未知类型 | - ---- - -## 最佳实践 - -### 1. 不要硬编码平台特定逻辑 - -❌ **错误做法**: -```python -# 在应用层硬编码平台判断 -if platform == "qq": - messages = fetch_qq_messages() -elif platform == "discord": - messages = fetch_discord_messages() -``` - -✅ **正确做法**: -```python -# 使用适配器模式 -adapter = PlatformAdapterFactory.create(platform_name, bot_instance, config) -messages = await adapter.fetch_messages(group_id, days, max_count) -``` - -### 2. 使用中文注释 - -所有代码注释必须使用中文: - -```python -def fetch_messages(self, group_id: str, days: int = 1) -> List[UnifiedMessage]: - """ - 获取群组消息历史 - - 参数: - group_id: 群组 ID - days: 获取多少天内的消息 - - 返回: - UnifiedMessage 列表 - """ -``` - -### 3. 优雅处理异常 - -```python -async def fetch_messages(self, ...) -> List[UnifiedMessage]: - try: - # 正常逻辑 - return messages - except SpecificError as e: - logger.warning(f"获取消息失败: {e}") - return [] - except Exception: - # 不要让异常传播到上层 - return [] -``` - -### 4. 过滤机器人自己的消息 - -```python -# 在 __init__ 中保存机器人 ID -self.bot_user_id = config.get("bot_user_id", "") - -# 在 fetch_messages 中过滤 -if str(msg.author.id) == self.bot_user_id: - continue -``` - -### 5. 声明正确的平台能力 - -如果平台不支持某功能,在 `PlatformCapabilities` 中正确声明: - -```python -PlatformCapabilities( - supports_message_history=False, # 不支持历史消息获取 - max_message_history_days=0, # 无法获取历史消息 -) -``` - ---- - -## 示例代码 - -完整的适配器示例请参考: - -- **OneBot 适配器**(QQ):`src/infrastructure/platform/adapters/onebot_adapter.py` -- **Discord 适配器**(骨架):`src/infrastructure/platform/adapters/discord_adapter.py` - ---- - -## 测试指南 - -### 1. 单元测试 - -为适配器编写单元测试: - -```python -# tests/unit/infrastructure/platform/test_your_adapter.py - -import pytest -from src.infrastructure.platform.adapters.your_platform_adapter import YourPlatformAdapter - -class TestYourPlatformAdapter: - def test_init_capabilities(self): - adapter = YourPlatformAdapter(mock_bot, {}) - caps = adapter.get_capabilities() - assert caps.platform_name == "your_platform" - assert caps.supports_message_history == True - - @pytest.mark.asyncio - async def test_fetch_messages(self): - adapter = YourPlatformAdapter(mock_bot, {}) - messages = await adapter.fetch_messages("group_123", days=1) - assert isinstance(messages, list) -``` - -### 2. Docker 容器内验证 - -在 Docker 容器内验证适配器注册: - -```bash -docker exec astrbot python -c " -from data.plugins.astrbot_plugin_qq_group_daily_analysis.src.infrastructure.platform import PlatformAdapterFactory -print('支持的平台:', PlatformAdapterFactory.get_supported_platforms()) -print('Discord 支持:', PlatformAdapterFactory.is_supported('discord')) -" -``` - -### 3. 集成测试 - -确保适配器与 `AnalysisOrchestrator` 正确集成: - -```python -from src.application.analysis_orchestrator import AnalysisOrchestrator - -orchestrator = AnalysisOrchestrator.create_for_platform( - platform_name="your_platform", - bot_instance=bot, - config={}, -) -assert orchestrator is not None -assert orchestrator.can_analyze() == True -``` - ---- - -## 常见问题 - -### Q: 如何处理平台特定的消息类型? - -使用 `MessageContentType.UNKNOWN` 并在 `raw_data` 中保存原始数据: - -```python -contents.append(MessageContent( - type=MessageContentType.UNKNOWN, - raw_data={"platform_specific_type": "sticker", "data": sticker_data} -)) -``` - -### Q: 如何支持分页获取消息? - -使用 `before_id` 参数: - -```python -async def fetch_messages(self, ..., before_id: Optional[str] = None): - if before_id: - # 从此消息 ID 之前开始获取 - messages = await api.get_history(before=before_id, limit=max_count) - else: - messages = await api.get_history(limit=max_count) -``` - -### Q: 如何处理不支持的功能? - -在能力声明中标记为不支持,并在方法中返回空/默认值: - -```python -# 能力声明 -PlatformCapabilities(supports_member_list=False) - -# 方法实现 -async def get_member_list(self, group_id: str) -> List[UnifiedMember]: - return [] # 平台不支持,返回空列表 -``` - ---- - -## 贡献检查清单 - -在提交 PR 之前,请确保: - -- [ ] 适配器继承自 `PlatformAdapter` -- [ ] 实现了所有抽象方法 -- [ ] 在工厂中注册了适配器 -- [ ] 所有注释使用中文 -- [ ] 编写了单元测试 -- [ ] 在 Docker 容器内验证通过 -- [ ] 更新了相关文档 - ---- - -*最后更新:2026-02-08* \ No newline at end of file diff --git a/docs/LOG_VIEWING_RESEARCH.md b/docs/LOG_VIEWING_RESEARCH.md deleted file mode 100644 index cfb83ac..0000000 --- a/docs/LOG_VIEWING_RESEARCH.md +++ /dev/null @@ -1,677 +0,0 @@ -# AstrBot 日志查看方式研究报告 - -## 概述 - -本文档详细说明了 AstrBot 的日志系统架构、查看方式、以及如何在代码中集成日志查看功能。 - ---- - -## 1. 日志文件默认位置 - -### 1.1 日志文件存储位置 - -根据配置,AstrBot 的日志文件存储在以下位置: - -| 日志类型 | 默认位置 | 配置键 | 说明 | -|---------|--------|-------|------| -| **普通日志** | `data/logs/astrbot.log` | `log_file_path` | 主应用日志,记录应用运行信息 | -| **Trace 日志** | `data/logs/astrbot.trace.log` | `trace_log_path` | 链路追踪日志,记录请求跨度信息 | - -### 1.2 日志文件配置参数 - -```python -# 配置文件位置: astrbot/core/config/default.py -DEFAULT_CONFIG = { - "log_level": "INFO", # 日志级别(DEBUG, INFO, WARNING, ERROR, CRITICAL) - "log_file_enable": False, # 是否启用文件日志(默认禁用) - "log_file_path": "logs/astrbot.log", # 日志文件相对路径(相对于 data/ 目录) - "log_file_max_mb": 20, # 单个日志文件最大大小(MB) - "trace_enable": False, # 是否启用 Trace 记录 - "trace_log_enable": False, # 是否启用 Trace 文件日志 - "trace_log_path": "logs/astrbot.trace.log", # Trace 日志文件路径 - "trace_log_max_mb": 20, # Trace 日志文件最大大小 -} -``` - -### 1.3 日志目录基路径 - -- **根数据目录**: `data/` -- **日志目录**: `data/logs/` -- **获取方法**(Python 代码): - ```python - from astrbot.core.utils.astrbot_path import get_astrbot_data_path - log_dir = os.path.join(get_astrbot_data_path(), "logs") - ``` - -### 1.4 日志文件轮转配置 - -当启用文件日志时,AstrBot 使用 `RotatingFileHandler`: -- 最大单个文件大小:`log_file_max_mb`(默认 20MB) -- 备份文件数量:3 个 -- 超过大小后自动轮转:`astrbot.log.1`, `astrbot.log.2`, `astrbot.log.3` - ---- - -## 2. Dashboard 中的日志查看功能 - -### 2.1 Dashboard 路由和 API - -AstrBot Dashboard 提供以下日志相关的 REST API(代码位置:`astrbot/dashboard/routes/log.py`): - -| API 端点 | 方法 | 功能 | 说明 | -|---------|------|------|------| -| `/api/live-log` | GET | 实时日志流 | Server-Sent Events (SSE) 连接,推送实时日志 | -| `/api/log-history` | GET | 日志历史 | 获取缓存的日志历史(JSON 格式) | -| `/api/trace/settings` | GET | Trace 设置查询 | 获取当前 Trace 启用状态 | -| `/api/trace/settings` | POST | Trace 设置更新 | 更新 Trace 启用/禁用状态 | - -### 2.2 实时日志流(SSE) - -#### 连接方式 - -**前端代码**(Vue.js,位置:`dashboard/src/stores/common.js`): - -```javascript -fetch('/api/live-log', { - method: 'GET', - headers: { - 'Content-Type': 'multipart/form-data', - 'Authorization': 'Bearer ' + localStorage.getItem('token') - }, - cache: 'no-cache', -}).then(response => { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - // 处理流式数据... -}) -``` - -#### SSE 消息格式 - -后端返回的 SSE 消息格式(`astrbot/dashboard/routes/log.py`): - -``` -id: {timestamp} -data: {json_object} - -``` - -JSON 对象结构: -```json -{ - "type": "log", - "level": "INFO", - "data": "[12:34:56] [Core] [INFO] [file.py:123]: Log message", - "time": 1697787296.123456, - "uuid": "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" -} -``` - -#### 日志缓存和重放 - -- **缓存大小**: 最多 500 条日志(常量 `CACHED_SIZE = 500`) -- **缓存数据结构**: `deque(maxlen=500)`(环形缓冲区) -- **浏览器断网重连**: 发送 `Last-Event-ID` 请求头,服务端根据时间戳重放缺失的日志 - -### 2.3 Dashboard 前端界面 - -#### 控制台页面(Console) - -**路由**: `/console` -**组件**: [ConsolePage.vue](dashboard/src/views/ConsolePage.vue) - -功能: -- ✅ 实时日志显示(通过 SSE 连接) -- ✅ 日志级别过滤(DEBUG, INFO, WARNING, ERROR, CRITICAL) -- ✅ 自动滚动开关 -- ✅ pip 包安装界面 - -日志样式: -``` -[12:34:56] [Core] [INFO] [astrbot.py:123]: AstrBot started successfully -[12:34:57] [Plug] [WARN] [plugin.py:45]: Missing dependency -[12:35:00] [Core] [ERRO] [error.py:78]: Connection timeout [v4.14.4] -``` - -#### 链路追踪页面(Trace) - -**路由**: `/trace` -**组件**: [TracePage.vue](dashboard/src/views/TracePage.vue) - -功能: -- ✅ 实时链路追踪显示 -- ✅ Trace 启用/禁用开关 -- ✅ Trace 事件详细信息展示 - -#### 日志显示器组件 - -**组件**: [ConsoleDisplayer.vue](dashboard/src/components/shared/ConsoleDisplayer.vue) - -特性: -- 日志级别色彩标记 -- ANSI 颜色代码转换为 HTML 样式 -- 日志缓存最多保留 1000 条(configurable) -- 自动滚动到最新日志 - ---- - -## 3. LogBroker 架构(发布-订阅模式) - -### 3.1 LogBroker 类设计 - -**位置**: `astrbot/core/log.py` - -```python -class LogBroker: - """日志代理类, 用于缓存和分发日志消息""" - - def __init__(self): - self.log_cache = deque(maxlen=CACHED_SIZE) # 环形缓冲区 - self.subscribers: list[Queue] = [] # 订阅者列表 - - def register(self) -> Queue: - """注册新的订阅者,返回一个队列用于接收日志""" - q = Queue(maxsize=CACHED_SIZE + 10) - self.subscribers.append(q) - return q - - def unregister(self, q: Queue): - """取消订阅""" - self.subscribers.remove(q) - - def publish(self, log_entry: dict): - """发布日志到所有订阅者(非阻塞方式)""" - self.log_cache.append(log_entry) - for q in self.subscribers: - try: - q.put_nowait(log_entry) - except asyncio.QueueFull: - pass # 订阅者队列满,丢弃该日志 -``` - -### 3.2 工作流程图 - -``` -日志记录器 (logger) - ↓ -LogQueueHandler (日志处理器) - ↓ -LogBroker.publish(log_entry) - ├→ 添加到 log_cache(环形缓冲区) - └→ 分发给所有订阅者的队列 - ├→ Dashboard SSE 连接 - ├→ Trace 日志记录器 - └→ 其他订阅者 -``` - -### 3.3 日志项结构 - -```python -log_entry = { - "level": "INFO", # 日志级别 - "time": 1697787296.123, # Unix 时间戳 - "data": "Log message text", # 格式化的日志文本 -} -``` - -### 3.4 在代码中集成 LogBroker - -#### 启动应用时初始化 - -```python -# main.py 或 cmd_run.py -from astrbot.core import LogBroker, LogManager, logger - -# 创建日志代理 -log_broker = LogBroker() - -# 将日志处理器连接到 LogBroker -LogManager.set_queue_handler(logger, log_broker) - -# 传递给应用初始化器 -core_lifecycle = InitialLoader(db, log_broker) -``` - -#### 在 Dashboard 中使用 - -```python -# dashboard/routes/log.py -class LogRoute(Route): - def __init__(self, context: RouteContext, log_broker: LogBroker) -> None: - self.log_broker = log_broker - # 注册 API 路由... - - async def log(self) -> QuartResponse: - """SSE 日志流""" - queue = self.log_broker.register() # 注册订阅者 - try: - while True: - message = await queue.get() # 等待日志 - yield _format_log_sse(message, current_ts) - finally: - self.log_broker.unregister(queue) # 取消订阅 -``` - ---- - -## 4. Trace 日志系统 - -### 4.1 Trace 概念 - -Trace 日志用于记录**请求的整个链路**,包括: -- 跨度信息(span_id) -- 请求发起者(sender_name) -- 操作阶段(action) -- 自定义字段(fields) - -### 4.2 Trace 启用配置 - -**配置项**: -```python -"trace_enable": False, # 启用 Trace 记录 -"trace_log_enable": False, # 启用 Trace 文件日志 -"trace_log_path": "logs/astrbot.trace.log", # Trace 日志文件路径 -``` - -**Dashboard 设置**: `/api/trace/settings` 端点可动态启用/禁用 Trace - -### 4.3 使用 TraceSpan 记录链路 - -**代码位置**: `astrbot/core/utils/trace.py` - -```python -from astrbot.core.utils.trace import TraceSpan - -# 创建 Trace 跨度 -span = TraceSpan( - name="group_analysis", - umo="qq_group", - sender_name="QQGroup:123456789", - message_outline="Daily analysis request" -) - -# 记录不同阶段的操作 -span.record("start", step="initialization") -span.record("process", data_count=1000) -span.record("end", result_code=200) -``` - -### 4.4 Trace 日志格式 - -**发布到 LogBroker**: -```json -{ - "type": "trace", - "level": "TRACE", - "time": 1697787296.123, - "span_id": "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx", - "name": "group_analysis", - "umo": "qq_group", - "sender_name": "QQGroup:123456789", - "message_outline": "Daily analysis request", - "action": "start", - "fields": {"step": "initialization"} -} -``` - -**写入文件**(JSON 格式,每行一条): -```json -[2024-01-01 12:34:56] {"type":"trace","span_id":"...","name":"group_analysis",...} -``` - -### 4.5 Trace 查询方式 - -1. **Dashboard UI** (`/trace` 路由) - - 实时查看所有 Trace 事件 - - 可启用/禁用 Trace 记录 - -2. **日志文件查询** - - 文件位置:`data/logs/astrbot.trace.log` - - 使用 `jq` 或 Python 解析 NDJSON 格式 - -3. **span_id 查询** - 用于追踪单个请求 - ```bash - grep "span_id.*abc123" data/logs/astrbot.trace.log - ``` - ---- - -## 5. 日志访问方式总结 - -### 5.1 实时日志查看 - -| 方式 | 说明 | 适用场景 | -|------|------|---------| -| **Dashboard Console** | 浏览器访问 `http://localhost:6185/#/console` | 实时监控、界面友好 | -| **REST API SSE** | `GET /api/live-log`(Server-Sent Events) | 集成第三方系统 | -| **文件直接查看** | `tail -f data/logs/astrbot.log` | 服务器终端查看 | - -### 5.2 历史日志查看 - -| 方式 | 说明 | 命令/代码 | -|------|------|---------| -| **Dashboard History** | `GET /api/log-history` 返回缓存的日志 | 返回最近 500 条 | -| **文件查询** | 日志文件存储在 `data/logs/astrbot.log` | `grep` 或编辑器打开 | -| **日志分析** | Python/shell 脚本处理日志文件 | 自定义分析 | - -### 5.3 Trace 日志查看 - -| 方式 | 说明 | 对应 API | -|------|------|---------| -| **Dashboard Trace** | 实时追踪链路,浏览器访问 `/trace` | SSE 推送 | -| **Trace 文件** | `data/logs/astrbot.trace.log`(NDJSON 格式) | 离线分析 | -| **span_id 查询** | 按 span_id 追踪单个请求 | `grep` 搜索 | - ---- - -## 6. 插件/群分析日志集成示例 - -### 6.1 为群分析日志添加 Trace ID - -对于"QQ 群日常分析插件"(`astrbot_plugin_qq_group_daily_analysis`),可以这样集成日志追踪: - -```python -# main.py 或分析模块 -from astrbot import logger -from astrbot.core.utils.trace import TraceSpan - -class GroupAnalyzer: - def analyze_group(self, group_id: str): - # 创建追踪跨度 - span = TraceSpan( - name="group_daily_analysis", - umo="qq_group", - sender_name=f"QQGroup:{group_id}", - message_outline=f"Daily analysis for group {group_id}" - ) - - try: - span.record("start", group_id=group_id) - logger.info(f"[GroupAnalysis] Starting analysis for group: {group_id}") - - # 分析逻辑... - data = self._fetch_messages(group_id) - span.record("fetch_complete", message_count=len(data)) - - # 处理数据... - result = self._process_data(data) - span.record("process_complete", result_code=200) - - logger.info(f"[GroupAnalysis] Analysis complete for {group_id}") - return result - - except Exception as e: - span.record("error", error_type=type(e).__name__, error_msg=str(e)) - logger.error(f"[GroupAnalysis] Error analyzing group {group_id}: {e}") - raise -``` - -### 6.2 查询特定群的日志 - -**在 Dashboard 中**: -1. 打开 `/console` 页面 -2. 输入日志过滤器(或查看所有日志) -3. 搜索 `GroupAnalysis` 或特定的 group_id - -**通过命令行**: -```bash -# 查找特定群的日志 -grep "QQGroup:123456789" data/logs/astrbot.log - -# 或查找 Trace 日志 -grep "group_id.*123456789" data/logs/astrbot.trace.log -``` - -### 6.3 日志格式确保 - -在日志中需要包含: -- **时间戳**: 自动添加(格式 `HH:MM:SS`) -- **日志级别**: DEBUG, INFO, WARNING, ERROR, CRITICAL -- **来源标记**: [Core] 或 [Plug] -- **文件和行号**: 自动添加 -- **消息内容**: 手动添加 - -**示例日志行**: -``` -[12:34:56] [Plug] [INFO] [group_analyzer.py:145]: [GroupAnalysis] Analysis complete for QQGroup:123456789 -``` - ---- - -## 7. LogManager 高级配置 - -### 7.1 配置日志级别 - -```python -from astrbot.core import LogManager, logger - -# 根据配置设置日志级别 -config = { - "log_level": "DEBUG", - "log_file_enable": True, - "log_file_path": "logs/astrbot.log", - "log_file_max_mb": 50, -} - -LogManager.configure_logger(logger, config) -``` - -### 7.2 配置 Trace 日志 - -```python -# 启用 Trace 日志文件 -config = { - "trace_enable": True, - "trace_log_enable": True, - "trace_log_path": "logs/astrbot.trace.log", - "trace_log_max_mb": 30, -} - -LogManager.configure_trace_logger(config) -``` - -### 7.3 日志过滤器 - -LogManager 自动添加以下过滤器: - -| 过滤器 | 功能 | 输出示例 | -|-------|------|--------| -| **PluginFilter** | 标记日志来源(Core/Plug) | `[Core]` 或 `[Plug]` | -| **FileNameFilter** | 修改文件名格式 | `folder.filename` | -| **LevelNameFilter** | 4 字母缩写 | `DBUG`, `INFO`, `WARN`, `ERRO`, `CRIT` | -| **AstrBotVersionTagFilter** | 在 WARNING 及以上追加版本 | `[v4.14.4]` | - ---- - -## 8. 日志配置管理 - -### 8.1 配置文件路径 - -- **配置文件**: `data/cmd_config.json` -- **默认配置**: `astrbot/core/config/default.py` -- **编辑方式**: - 1. 直接编辑 JSON 文件 - 2. 通过 Dashboard 管理面板修改(未来功能) - -### 8.2 配置更新 - -配置更改后,需要重启应用以生效(或通过 API 动态更新)。 - -### 8.3 环境变量支持 - -**根目录自定义**(可选): -```bash -export ASTRBOT_ROOT=/path/to/root -# 数据目录将为 /path/to/root/data -``` - ---- - -## 9. 代码示例汇总 - -### 9.1 获取日志记录器 - -```python -from astrbot.core import logger - -# 已配置的全局日志记录器 -logger.info("Message") -logger.debug("Debug message") -logger.warning("Warning") -logger.error("Error") -logger.critical("Critical error") -``` - -### 9.2 创建自定义日志记录器 - -```python -from astrbot.core import LogManager - -# 获取命名日志记录器 -plugin_logger = LogManager.GetLogger("my_plugin") -plugin_logger.info("Plugin message") -``` - -### 9.3 发起 Trace 追踪 - -```python -from astrbot.core.utils.trace import TraceSpan - -span = TraceSpan( - name="custom_operation", - umo="custom_type", - sender_name="CustomOperator", - message_outline="Operation description" -) - -span.record("stage1", param1="value1") -span.record("stage2", param2="value2", status="success") -``` - -### 9.4 订阅日志流(自定义) - -```python -import asyncio -from astrbot.core import LogBroker - -# 从 LogBroker 获取日志队列 -log_broker = app.log_broker # 从应用上下文获取 - -async def listen_logs(): - queue = log_broker.register() - try: - while True: - log_entry = await queue.get() - print(f"[{log_entry['level']}] {log_entry['data']}") - finally: - log_broker.unregister(queue) - -# 运行监听器 -asyncio.run(listen_logs()) -``` - ---- - -## 10. 常见问题和最佳实践 - -### 10.1 为什么文件日志默认禁用? - -- 性能考虑(避免磁盘 I/O 开销) -- 容器环境中不需要持久化 -- 大多数用户通过 Dashboard 查看日志 - -### 10.2 启用文件日志的步骤 - -1. 编辑 `data/cmd_config.json`: - ```json - { - "log_file_enable": true, - "log_file_path": "logs/astrbot.log", - "log_file_max_mb": 50 - } - ``` -2. 重启 AstrBot 应用 -3. 日志将写入 `data/logs/astrbot.log` - -### 10.3 日志级别选择 - -- **DEBUG**: 开发调试,包含所有详细信息 -- **INFO**: 生产环境推荐,记录重要事件 -- **WARNING**: 只记录警告和错误 -- **ERROR**: 仅记录错误 -- **CRITICAL**: 仅记录严重错误 - -### 10.4 Trace ID 用于群分析追踪 - -对于"QQ 群日常分析"场景: -- 每个群的分析请求都有唯一的 `span_id` -- 可通过此 ID 追踪整个分析流程 -- 涉及多群并发时日志清晰分离 - -**查询示例**: -```bash -# 查找 span_id 相关的所有日志 -grep "span_id: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" data/logs/astrbot.trace.log -``` - -### 10.5 性能考虑 - -- **日志缓存**: 最多 500 条,超出自动淘汰(先进先出) -- **SSE 连接**: 断网自动重连,支持日志补发 -- **文件轮转**: 单个文件超过 20MB(可配置)自动轮转 - ---- - -## 11. 相关文件速查表 - -| 功能 | 文件路径 | -|------|--------| -| 日志核心逻辑 | `astrbot/core/log.py` | -| 日志管理器 | `astrbot/core/log.py` (LogManager 类) | -| Trace 系统 | `astrbot/core/utils/trace.py` | -| Dashboard API | `astrbot/dashboard/routes/log.py` | -| 默认配置 | `astrbot/core/config/default.py` | -| 路径工具 | `astrbot/core/utils/astrbot_path.py` | -| Dashboard Console UI | `dashboard/src/views/ConsolePage.vue` | -| 日志显示器组件 | `dashboard/src/components/shared/ConsoleDisplayer.vue` | -| Trace UI | `dashboard/src/views/TracePage.vue` | -| 公共 Store | `dashboard/src/stores/common.js` | - ---- - -## 12. 总结 - -### 日志查看的完整流程 - -1. **应用启动** - - LogBroker 初始化 - - LogQueueHandler 连接到日志记录器 - -2. **日志产生** - - 应用或插件调用 `logger.info()` 等方法 - - LogQueueHandler 拦截日志记录 - -3. **日志分发** - - LogBroker.publish() 添加到缓存 - - 分发给所有订阅者(Dashboard SSE、Trace 日志等) - -4. **用户查看** - - **Dashboard Console**: 实时看到日志 - - **REST API**: 获取历史日志或 SSE 流 - - **文件**: 直接查看 `data/logs/astrbot.log` - -5. **Trace 追踪** - - 创建 TraceSpan 记录请求链路 - - Dashboard `/trace` 实时查看 - - 或通过 span_id 在文件中查询 - -### 推荐使用方式 - -- **开发调试**: Dashboard `/console` 页面 -- **生产监控**: 启用文件日志 + 日志收集系统 -- **问题诊断**: 通过 span_id 追踪完整请求链路 -- **群分析**: 在日志中包含 group_id,便于后续查询 - ---- - -*本文档基于 AstrBot v4.14.4 代码分析生成。* diff --git a/docs/MULTI_PLATFORM_INTEGRATION_GUIDE.md b/docs/MULTI_PLATFORM_INTEGRATION_GUIDE.md deleted file mode 100644 index 32c4cbd..0000000 --- a/docs/MULTI_PLATFORM_INTEGRATION_GUIDE.md +++ /dev/null @@ -1,1049 +0,0 @@ -# 多平台接入完整指南 - -> **版本**: 1.0 -> **更新日期**: 2026-02-09 -> **适用版本**: `astrbot_plugin_qq_group_daily_analysis` v0.3.0+ - ---- - -## 目录 - -1. [架构总览](#1-架构总览) -2. [核心组件](#2-核心组件) -3. [接入新平台的完整步骤](#3-接入新平台的完整步骤) -4. [详细接口规范](#4-详细接口规范) -5. [Corner Cases 与注意事项](#5-corner-cases-与注意事项) -6. [平台差异对照表](#6-平台差异对照表) -7. [调试与故障排查](#7-调试与故障排查) -8. [现有适配器参考](#8-现有适配器参考) -9. [测试清单](#9-测试清单) - ---- - -## 1. 架构总览 - -本插件采用 **DDD (领域驱动设计)** 架构,通过 **适配器模式** 实现多平台支持。核心设计原则: - -``` -┌────────────────────────────────────────────────────────────────────┐ -│ main.py (入口层) │ -│ - 处理 AstrBot 事件和命令 │ -│ - 协调各层组件 │ -└────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────┐ -│ 应用层 (Application Layer) │ -│ - AnalysisApplicationService: 编排完整的分析流程 │ -│ - AutoScheduler: 定时任务管理 │ -└────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────┐ -│ 领域层 (Domain Layer) │ -│ - 值对象: UnifiedMessage, PlatformCapabilities, UnifiedGroup │ -│ - 领域服务: AnalysisDomainService, StatisticsService │ -│ - 仓储接口: IMessageRepository, IMessageSender, IAvatarRepository │ -└────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────┐ -│ 基础设施层 (Infrastructure Layer) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ PlatformAdapter (抽象基类) │ │ -│ │ 实现: IMessageRepository + IMessageSender + │ │ -│ │ IGroupInfoRepository + IAvatarRepository │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ ▲ ▲ ▲ ▲ │ -│ │ │ │ │ │ -│ ┌────────┴───┐ ┌──────┴──────┐ ┌────┴────────┐ ┌──┴─────────┐ │ -│ │OneBotAdapter│ │DiscordAdapter│ │TelegramAdapter│ │ 新平台Adapter│ │ -│ └────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ BotManager: 管理多平台 Bot 实例和适配器 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ PlatformAdapterFactory: 适配器注册与创建工厂 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────────────────────┘ -``` - -### 1.1 核心设计原则 - -| 原则 | 说明 | -|------|------| -| **平台隔离** | 所有平台特定代码都封装在对应的 Adapter 中 | -| **统一接口** | 通过 `UnifiedMessage` 等值对象实现跨平台数据标准化 | -| **能力声明** | 每个适配器通过 `PlatformCapabilities` 声明支持的功能 | -| **懒加载** | 支持 Bot 客户端的延迟初始化,适应不同平台的启动时序 | -| **容错设计** | 所有方法都有异常处理,失败时返回空值而非抛出异常 | - ---- - -## 2. 核心组件 - -### 2.1 文件结构 - -``` -src/ -├── domain/ -│ ├── value_objects/ -│ │ ├── unified_message.py # 统一消息格式 -│ │ ├── unified_group.py # 统一群组/成员信息 -│ │ └── platform_capabilities.py # 平台能力声明 -│ └── repositories/ -│ ├── message_repository.py # 消息仓储接口 -│ └── avatar_repository.py # 头像仓储接口 -│ -├── infrastructure/ -│ └── platform/ -│ ├── base.py # PlatformAdapter 抽象基类 -│ ├── factory.py # PlatformAdapterFactory 工厂 -│ ├── bot_manager.py # BotManager 多平台管理 -│ └── adapters/ -│ ├── onebot_adapter.py # OneBot v11 适配器 -│ └── discord_adapter.py # Discord 适配器 -``` - -### 2.2 接口依赖关系 - -```python -class PlatformAdapter( - IMessageRepository, # 消息获取 - IMessageSender, # 消息发送 - IGroupInfoRepository, # 群组信息 - IAvatarRepository, # 头像获取 - ABC # 抽象基类 -): - pass -``` - ---- - -## 3. 接入新平台的完整步骤 - -### 步骤 1:创建适配器文件 - -```bash -# 在 adapters 目录创建新文件 -# src/infrastructure/platform/adapters/your_platform_adapter.py -``` - -### 步骤 2:实现适配器类 - -```python -""" -YourPlatform 平台适配器 - -支持 YourPlatform 的消息获取、发送和群组管理功能。 -""" - -from typing import Any, Optional -from datetime import datetime, timedelta - -from ....domain.value_objects.platform_capabilities import PlatformCapabilities -from ....domain.value_objects.unified_group import UnifiedGroup, UnifiedMember -from ....domain.value_objects.unified_message import ( - MessageContent, - MessageContentType, - UnifiedMessage, -) -from ....utils.logger import logger -from ..base import PlatformAdapter - - -class YourPlatformAdapter(PlatformAdapter): - """YourPlatform 平台适配器实现""" - - def __init__(self, bot_instance: Any, config: dict | None = None): - super().__init__(bot_instance, config) - # 1. 保存机器人自身 ID(用于消息过滤) - self.bot_user_id = str(config.get("bot_user_id", "")) if config else "" - # 2. 可选:缓存 SDK 客户端 - self._cached_client = None - - def _init_capabilities(self) -> PlatformCapabilities: - """声明平台能力 - 这是最重要的方法之一""" - return PlatformCapabilities( - platform_name="your_platform", - platform_version="v1.0", - # === 消息获取能力 === - supports_message_history=True, # 是否支持获取历史消息 - max_message_history_days=30, # 最大历史天数 - max_message_count=10000, # 单次最大消息数 - # === 群组信息能力 === - supports_group_list=True, # 是否支持获取群列表 - supports_group_info=True, # 是否支持获取群信息 - supports_member_list=True, # 是否支持获取成员列表 - supports_member_info=True, # 是否支持获取成员信息 - # === 消息发送能力 === - supports_text_message=True, # 发送文本 - supports_image_message=True, # 发送图片 - supports_file_message=True, # 发送文件 - supports_reply_message=True, # 回复消息 - max_text_length=4096, # 最大文本长度 - max_image_size_mb=10.0, # 最大图片大小 - # === 头像能力 === - supports_user_avatar=True, # 用户头像 - supports_group_avatar=False, # 群组头像 - avatar_needs_api_call=True, # 是否需要 API 调用 - avatar_sizes=(100, 200, 400), # 支持的头像尺寸 - ) - - # ... 实现所有抽象方法 ... -``` - -### 步骤 3:在工厂中注册 - -修改 `src/infrastructure/platform/factory.py`: - -```python -def _register_adapters(): - # ... 现有注册 ... - - try: - from .adapters.your_platform_adapter import YourPlatformAdapter - PlatformAdapterFactory.register("your_platform", YourPlatformAdapter) - # 可选:添加别名 - PlatformAdapterFactory.register("your_platform_alias", YourPlatformAdapter) - except ImportError: - pass - - -_register_adapters() -``` - -### 步骤 4:更新 BotManager 的平台检测 (可选) - -如果 AstrBot 无法自动识别你的平台类型,需要在 `bot_manager.py` 的 `_detect_platform_name` 方法中添加检测逻辑: - -```python -def _detect_platform_name(self, bot_instance) -> str | None: - # ... 现有逻辑 ... - - # 添加 YourPlatform 的特征检测 - if hasattr(bot_instance, "your_platform_specific_method"): - return "your_platform" - - # 类名匹配 - class_name = type(bot_instance).__name__.lower() - if "yourplatform" in class_name: - return "your_platform" - - return None -``` - ---- - -## 4. 详细接口规范 - -### 4.1 IMessageRepository (消息获取) - -```python -async def fetch_messages( - self, - group_id: str, - days: int = 1, - max_count: int = 100, - before_id: str | None = None, -) -> list[UnifiedMessage]: - """ - 获取群组历史消息 - - 参数: - group_id: 群组/频道 ID (字符串格式) - days: 获取最近 N 天的消息 - max_count: 最大消息数量 - before_id: 分页锚点消息 ID - - 返回: - 统一格式的消息列表,按时间 **升序** 排列 - - 重要事项: - 1. 必须过滤机器人自己的消息 - 2. 必须进行时间范围过滤 - 3. 返回前需要按时间排序 - 4. 异常时返回空列表,不要抛出异常 - """ -``` - -### 4.2 消息转换 (_convert_message) - -```python -def _convert_message(self, raw_msg: Any, group_id: str) -> UnifiedMessage | None: - """ - 将平台原生消息转换为 UnifiedMessage - - 关键字段说明: - - message_id: 消息唯一 ID (字符串) - - sender_id: 发送者 ID (字符串) - - sender_name: 发送者基础名称 - - sender_card: 群内名片/昵称 (优先显示) - - group_id: 群组 ID - - text_content: 纯文本内容 (用于 LLM 分析) - - contents: 消息链 (文本+图片+表情等) - - timestamp: Unix 时间戳 (整数) - - platform: 平台标识 - - reply_to_id: 回复的消息 ID (可选) - """ -``` - -### 4.3 convert_to_raw_format (向后兼容) - -```python -def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: - """ - 将统一消息格式转换为 OneBot 风格的字典格式 - - 这是为了兼容现有的 MessageHandler 分析逻辑。 - 必须生成符合以下结构的字典: - - { - "message_id": "...", - "group_id": "...", - "time": 1234567890, # Unix 时间戳 - "sender": { - "user_id": "...", - "nickname": "...", - "card": "..." # 群名片 - }, - "message": [ - {"type": "text", "data": {"text": "..."}}, - {"type": "image", "data": {"url": "...", "file": "..."}}, - {"type": "at", "data": {"qq": "..."}}, - # ... - ], - "user_id": "...", # 冗余字段,兼容用 - } - """ -``` - -### 4.4 IMessageSender (消息发送) - -```python -async def send_text(self, group_id: str, text: str, reply_to: str | None = None) -> bool: - """发送文本消息,返回是否成功""" - -async def send_image(self, group_id: str, image_path: str, caption: str = "") -> bool: - """ - 发送图片消息 - - image_path 可能是: - - 本地文件路径: "/path/to/image.png" - - HTTP URL: "https://example.com/image.png" - - 需要根据平台特性处理不同情况 - """ - -async def send_file(self, group_id: str, file_path: str, filename: str | None = None) -> bool: - """发送文件消息""" - -async def send_forward_msg(self, group_id: str, nodes: list[dict]) -> bool: - """ - 发送合并转发消息 - - nodes 格式: - [ - { - "type": "node", - "data": { - "name": "发送者名称", - "uin": "发送者ID", - "content": "消息内容" - } - }, - ... - ] - - 如果平台不支持合并转发,应转换为多条普通消息发送 - """ -``` - -### 4.5 IAvatarRepository (头像获取) - -```python -async def get_user_avatar_url(self, user_id: str, size: int = 100) -> str | None: - """ - 获取用户头像 URL - - 不同平台的策略: - - QQ/OneBot: 直接通过 URL 模板构造,无需 API 调用 - - Discord: 通过 CDN URL 模板构造,需要对齐到 2 的幂次方尺寸 - - Telegram: 需要调用 API 获取 file_id 再转换 - - Slack: 从用户信息 API 的 profile.image_* 字段获取 - """ - -async def get_user_avatar_data(self, user_id: str, size: int = 100) -> str | None: - """ - 获取头像的 Base64 数据 - - 格式: "data:image/png;base64,..." - 用于 HTML 模板渲染 - 如果不支持,返回 None - """ - -async def batch_get_avatar_urls(self, user_ids: list[str], size: int = 100) -> dict[str, str | None]: - """批量获取头像 URL""" -``` - ---- - -## 5. Corner Cases 与注意事项 - -### 5.1 机器人客户端获取 - -> [!CAUTION] -> **懒加载问题**:许多平台的 Bot 客户端在插件初始化时可能尚未准备好。 - -**Discord 适配器的解决方案**: - -```python -@property -def _discord_client(self) -> Any: - """懒加载 + 多路径探测""" - if self._cached_client: - return self._cached_client - - # 探测路径 A: bot 本身就是 Client - if hasattr(self.bot, "get_channel"): - self._cached_client = self.bot - # 探测路径 B: bot.client - elif hasattr(self.bot, "client"): - self._cached_client = self.bot.client - # 探测路径 C: 其他常见属性名 - else: - for attr in ("_client", "discord_client", "_discord_client"): - if hasattr(self.bot, attr): - client = getattr(self.bot, attr) - if hasattr(client, "get_channel"): - self._cached_client = client - break - - # 兜底:从客户端获取机器人 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) - - return self._cached_client -``` - -### 5.2 机器人消息过滤 - -> [!IMPORTANT] -> 必须过滤掉机器人自己发送的消息,否则分析报告会包含机器人的回复。 - -```python -# 在 fetch_messages 中 -for msg in raw_messages: - sender_id = str(msg.author.id) - # 检查是否是机器人自己 - if self.bot_user_id and sender_id == self.bot_user_id: - continue - # ... 处理消息 -``` - -**注意**:机器人 ID 可能来自多个来源: -1. 配置文件中的 `bot_user_id` 或 `bot_qq_ids` -2. 运行时从 `bot.user.id` 获取 -3. 从消息事件中提取 - -### 5.3 发送者名称优先级 - -> [!TIP] -> 不同平台对用户名称的定义不同,需要正确设置优先级。 - -**Discord 的名称层级**: -```python -# 1. 服务器昵称 (nick) - 最具体 -# 2. 全局显示名 (global_name) - 用户设置的显示名 -# 3. 用户名 (name) - 基础用户名 - -sender_card = None -if hasattr(raw_msg.author, "nick") and raw_msg.author.nick: - sender_card = raw_msg.author.nick -elif hasattr(raw_msg.author, "global_name") and raw_msg.author.global_name: - sender_card = raw_msg.author.global_name - -return UnifiedMessage( - sender_name=raw_msg.author.name, # 基础名称 - sender_card=sender_card, # 优先显示的群内名片 - # ... -) -``` - -**OneBot/QQ 的名称层级**: -```python -sender_name = sender.get("nickname", "") -sender_card = sender.get("card", "") or None # 空字符串转为 None -``` - -### 5.4 图片发送策略 - -> [!WARNING] -> 不同平台对图片发送的处理方式差异很大。 - -**场景 1:本地文件** - -| 平台 | 处理方式 | -|------|----------| -| OneBot | `file:///path/to/image.png` | -| Discord | `discord.File(image_path)` | - -**场景 2:HTTP URL** - -| 平台 | 处理方式 | -|------|----------| -| OneBot | 直接使用 URL(后端自动下载) | -| Discord | **必须下载到内存再发送**(Discord 无法访问内部 URL) | - -**Discord 的 URL 图片处理**: -```python -async def send_image(self, group_id: str, image_path: str, caption: str = "") -> bool: - if image_path.startswith(("http://", "https://")): - # 下载到内存 - async with aiohttp.ClientSession() as session: - async with session.get(image_path, timeout=aiohttp.ClientTimeout(total=30)) as resp: - if resp.status == 200: - data = await resp.read() - file_to_send = discord.File(BytesIO(data), filename="report.png") - await channel.send(file=file_to_send) - else: - # 兜底:直接发送 URL 让 Discord 尝试解析 - await channel.send(content=image_path) - else: - file_to_send = discord.File(image_path) - await channel.send(file=file_to_send) -``` - -### 5.5 频道/群组获取的缓存与网络请求 - -> [!NOTE] -> 大多数平台 SDK 都有缓存机制,但缓存可能不完整。 - -```python -# Discord 的双重获取策略 -channel = self._discord_client.get_channel(channel_id) # 从缓存获取 -if not channel: - # 缓存未命中,发起网络请求 - try: - channel = await self._discord_client.fetch_channel(channel_id) - except Exception as e: - logger.debug(f"获取频道失败: {e}") - return [] -``` - -### 5.6 消息历史 API 的限制 - -| 平台 | 限制说明 | -|------|----------| -| OneBot/QQ | 依赖后端实现,NapCat 支持较好,go-cqhttp 需要配置 | -| Discord | 需要 "Read Message History" 权限,默认返回降序需要排序 | -| Telegram Bot API | **不支持获取历史消息**,需要 Telethon/MTProto | -| Slack | 免费版有 90 天限制,每次最多 1000 条 | - -### 5.7 头像尺寸对齐 - -不同平台支持的头像尺寸不同,需要对齐到最近的有效值: - -```python -# Discord: 必须是 2 的幂次方 -DISCORD_SIZES = (16, 32, 64, 128, 256, 512, 1024, 2048, 4096) - -# QQ: 固定尺寸 -QQ_SIZES = (40, 100, 140, 160, 640) - -def _get_nearest_size(self, requested_size: int, available_sizes: tuple) -> int: - """获取最接近的可用尺寸""" - return min(available_sizes, key=lambda x: abs(x - requested_size)) -``` - -### 5.8 合并转发消息的兼容处理 - -> [!IMPORTANT] -> 并非所有平台都支持合并转发消息。 - -**OneBot**:原生支持 `send_group_forward_msg` - -**Discord**:需要转换为多条普通消息 -```python -async def send_forward_msg(self, group_id: str, nodes: list[dict]) -> bool: - # 将节点汇总为格式化文本 - lines = ["📊 **结构化报告摘要**\n"] - for node in nodes: - data = node.get("data", node) - name = data.get("name", "AstrBot") - content = data.get("content", "") - lines.append(f"**[{name}]**:\n{content}\n") - - full_text = "\n".join(lines) - - # 分段处理(Discord 限制 2000 字符) - if len(full_text) > 1900: - parts = [full_text[i:i+1900] for i in range(0, len(full_text), 1900)] - for part in parts: - await channel.send(content=part) - else: - await channel.send(content=full_text) -``` - -### 5.9 平台 ID 与群组 ID 的区别 - -| 类型 | 说明 | 示例 | -|------|------|------| -| `platform_id` | AstrBot 平台实例的唯一标识 | `"discord-main"`, `"onebot-qq1"` | -| `group_id` | 群组/频道的 ID | `"123456789"` (QQ群号), `"987654321"` (Discord频道ID) | - -**BotManager 通过 `platform_id` 管理多个平台实例**: -```python -# 获取特定平台的适配器 -adapter = bot_manager.get_adapter(platform_id="discord-main") - -# 如果只有一个平台,可以省略 platform_id -adapter = bot_manager.get_adapter() -``` - -### 5.10 异步上下文中的同步操作 - -> [!CAUTION] -> 避免在异步方法中执行阻塞的同步操作。 - -**错误示例**: -```python -async def fetch_messages(self, ...): - # ❌ 这会阻塞事件循环 - with open("cache.json", "r") as f: - cache = json.load(f) -``` - -**正确示例**: -```python -async def fetch_messages(self, ...): - # ✅ 使用 asyncio.to_thread - cache = await asyncio.to_thread(self._load_cache_sync) - -def _load_cache_sync(self): - with open("cache.json", "r") as f: - return json.load(f) -``` - ---- - -## 6. 平台差异对照表 - -### 6.1 能力对比 - -| 能力 | OneBot (QQ) | Discord | Telegram Bot | Telegram UserBot | Slack | -|------|-------------|---------|--------------|------------------|-------| -| 历史消息获取 | ✅ | ✅ | ❌ | ✅ | ✅ | -| 最大历史天数 | 7 | 30 | 0 | 365 | 90 | -| 群列表获取 | ✅ | ✅ | ❌ | ✅ | ✅ | -| 成员列表获取 | ✅ | ✅ | ⚠️ | ✅ | ✅ | -| 图片消息 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 文件消息 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 合并转发 | ✅ | ⚠️ | ❌ | ❌ | ❌ | -| 用户头像 | ✅ (URL模板) | ✅ (CDN) | ✅ (API) | ✅ (API) | ✅ (API) | -| 编辑消息 | ❌ | ✅ | ✅ | ✅ | ✅ | -| 撤回消息 | ✅ | ❌ | ❌ | ❌ | ❌ | - -### 6.2 消息类型映射 - -| MessageContentType | OneBot 类型 | Discord 类型 | 说明 | -|--------------------|-------------|--------------|------| -| TEXT | `text` | `content` | 纯文本 | -| IMAGE | `image` | `attachment` (image/*) | 图片 | -| VIDEO | `video` | `attachment` (video/*) | 视频 | -| VOICE | `record` | `attachment` (audio/*) | 语音 | -| FILE | (文件消息) | `attachment` (其他) | 文件 | -| AT | `at` | `@mention` | @提及 | -| EMOJI | `face`, `mface` 等 | `sticker`, `emoji` | 表情 | -| REPLY | `reply` | `reference` | 回复 | -| FORWARD | `forward` | N/A | 转发 | - ---- - -## 7. 调试与故障排查 - -### 7.1 常见问题 - -**Q1: 适配器创建失败** - -检查以下几点: -1. 适配器类是否正确继承 `PlatformAdapter` -2. 是否实现了所有抽象方法 -3. 工厂注册是否正确 -4. 依赖库是否已安装 - -```python -# 验证注册 -from src.infrastructure.platform import PlatformAdapterFactory -print(PlatformAdapterFactory.get_supported_platforms()) -``` - -**Q2: 消息获取返回空列表** - -1. 检查 Bot 是否有权限获取历史消息 -2. 检查 `group_id` 格式是否正确 -3. 检查时间范围是否合理 -4. 查看日志中的异常信息 - -**Q3: 图片发送失败** - -1. 检查文件路径是否正确 -2. URL 是否可访问 -3. 文件大小是否超过限制 -4. 是否有发送图片的权限 - -### 7.2 调试日志 - -适配器内部使用统一的 logger: - -```python -from ....utils.logger import logger - -# 使用示例 -logger.debug(f"正在获取频道 {group_id} 的消息") -logger.warning(f"API 返回非预期结果: {response}") -logger.error(f"消息发送失败: {e}", exc_info=True) -``` - -### 7.3 容器内验证 - -```bash -# 检查支持的平台 -docker exec astrbot python -c " -from data.plugins.astrbot_plugin_qq_group_daily_analysis.src.infrastructure.platform import PlatformAdapterFactory -print('支持的平台:', PlatformAdapterFactory.get_supported_platforms()) -" - -# 检查适配器创建 -docker exec astrbot python -c " -from data.plugins.astrbot_plugin_qq_group_daily_analysis.src.infrastructure.platform import PlatformAdapterFactory -adapter = PlatformAdapterFactory.create('discord', None, {}) -if adapter: - print('Discord 能力:', adapter.get_capabilities()) -else: - print('创建失败') -" -``` - ---- - -## 8. 现有适配器参考 - -### 8.1 OneBot 适配器 (`onebot_adapter.py`) - -**特点**: -- 头像通过 URL 模板直接构造,无需 API 调用 -- 支持多种表情类型 (`face`, `mface`, `bface`, `sface`) -- 消息格式可能是字符串或列表,需要兼容处理 -- 支持合并转发消息 - -**关键代码**: - -```python -# 头像 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}/" - -# 消息格式兼容 -if isinstance(message_chain, str): - message_chain = [{"type": "text", "data": {"text": message_chain}}] -``` - -### 8.2 Discord 适配器 (`discord_adapter.py`) - -**特点**: -- 需要懒加载和多路径探测获取客户端 -- 头像尺寸必须对齐到 2 的幂次方 -- 图片发送需要先下载再上传 -- 合并转发需要转换为格式化文本 -- 支持处理 Embed 和 Sticker - -**关键代码**: - -```python -# 多路径客户端探测 -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 - -# 头像尺寸对齐 -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 -``` - ---- - -## 9. 测试清单 - -### 9.1 单元测试 - -```python -# tests/unit/infrastructure/platform/test_your_adapter.py - -import pytest -from src.infrastructure.platform.adapters.your_platform_adapter import YourPlatformAdapter - -class TestYourPlatformAdapter: - def test_init_capabilities(self): - adapter = YourPlatformAdapter(mock_bot, {}) - caps = adapter.get_capabilities() - assert caps.platform_name == "your_platform" - assert caps.supports_message_history == True - assert caps.can_analyze() == True - - @pytest.mark.asyncio - async def test_fetch_messages_empty(self): - adapter = YourPlatformAdapter(mock_bot, {}) - messages = await adapter.fetch_messages("invalid_group", days=1) - assert isinstance(messages, list) - assert len(messages) == 0 - - @pytest.mark.asyncio - async def test_convert_to_raw_format(self): - # 测试消息格式转换 - pass -``` - -### 9.2 集成测试清单 - -- [ ] 适配器能正确注册到工厂 -- [ ] BotManager 能自动发现平台实例 -- [ ] 消息获取返回正确格式 -- [ ] 消息发送能正常工作 -- [ ] 头像 URL 能正确生成 -- [ ] 不支持的功能返回合理的默认值 - -### 9.3 手动测试步骤 - -1. **启动 AstrBot 并加载插件** - ```bash - docker-compose up -d - docker logs -f astrbot - ``` - -2. **检查平台发现日志** - - 应该看到 "已创建 X 个 PlatformAdapter" - -3. **使用命令测试** - - `/群分析` - 检查消息获取和报告生成 - - `/分析设置 status` - 检查状态输出 - -4. **检查输出结果** - - 图片报告应正确显示 - - 用户名称应使用群内名片 - ---- - -## 附录 A:完整适配器模板 - -```python -""" -NewPlatform 平台适配器模板 -""" - -from datetime import datetime, timedelta -from typing import Any - -from ....domain.value_objects.platform_capabilities import PlatformCapabilities -from ....domain.value_objects.unified_group import UnifiedGroup, UnifiedMember -from ....domain.value_objects.unified_message import ( - MessageContent, - MessageContentType, - UnifiedMessage, -) -from ....utils.logger import logger -from ..base import PlatformAdapter - - -class NewPlatformAdapter(PlatformAdapter): - """NewPlatform 平台适配器""" - - def __init__(self, bot_instance: Any, config: dict | None = None): - super().__init__(bot_instance, config) - self.bot_user_id = str(config.get("bot_user_id", "")) if config else "" - - def _init_capabilities(self) -> PlatformCapabilities: - return PlatformCapabilities( - platform_name="new_platform", - platform_version="v1.0", - supports_message_history=True, - max_message_history_days=30, - max_message_count=1000, - supports_group_list=True, - supports_group_info=True, - supports_member_list=True, - supports_member_info=True, - supports_text_message=True, - supports_image_message=True, - supports_file_message=True, - supports_reply_message=True, - max_text_length=4096, - supports_user_avatar=True, - supports_group_avatar=False, - ) - - # ==================== IMessageRepository ==================== - - async def fetch_messages( - self, - group_id: str, - days: int = 1, - max_count: int = 100, - before_id: str | None = None, - ) -> list[UnifiedMessage]: - try: - # TODO: 调用平台 API 获取消息 - raw_messages = [] - - end_time = datetime.now() - start_time = end_time - timedelta(days=days) - - messages = [] - for raw_msg in raw_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_id", "")) - if self.bot_user_id and sender_id == self.bot_user_id: - continue - - unified = self._convert_message(raw_msg, group_id) - if unified: - messages.append(unified) - - messages.sort(key=lambda m: m.timestamp) - return messages - - except Exception as e: - logger.error(f"获取消息失败: {e}", exc_info=True) - return [] - - def _convert_message(self, raw_msg: dict, group_id: str) -> UnifiedMessage | None: - try: - return UnifiedMessage( - message_id=str(raw_msg.get("id", "")), - sender_id=str(raw_msg.get("sender_id", "")), - sender_name=raw_msg.get("sender_name", ""), - sender_card=raw_msg.get("sender_card"), - group_id=group_id, - text_content=raw_msg.get("text", ""), - contents=(MessageContent(type=MessageContentType.TEXT, text=raw_msg.get("text", "")),), - timestamp=raw_msg.get("time", 0), - platform="new_platform", - reply_to_id=raw_msg.get("reply_to"), - ) - except Exception as e: - logger.debug(f"消息转换失败: {e}") - return None - - def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: - return [ - { - "message_id": msg.message_id, - "group_id": msg.group_id, - "time": msg.timestamp, - "sender": { - "user_id": msg.sender_id, - "nickname": msg.sender_name, - "card": msg.sender_card or "", - }, - "message": [{"type": "text", "data": {"text": c.text}} for c in msg.contents if c.type == MessageContentType.TEXT], - "user_id": msg.sender_id, - } - for msg in messages - ] - - # ==================== IMessageSender ==================== - - async def send_text(self, group_id: str, text: str, reply_to: str | None = None) -> bool: - try: - # TODO: 实现发送逻辑 - return True - except Exception as e: - logger.error(f"发送文本失败: {e}") - return False - - async def send_image(self, group_id: str, image_path: str, caption: str = "") -> bool: - try: - # TODO: 实现发送逻辑 - return True - except Exception as e: - logger.error(f"发送图片失败: {e}") - return False - - async def send_file(self, group_id: str, file_path: str, filename: str | None = None) -> bool: - try: - # TODO: 实现发送逻辑 - return True - except Exception as e: - logger.error(f"发送文件失败: {e}") - return False - - # ==================== IGroupInfoRepository ==================== - - async def get_group_info(self, group_id: str) -> UnifiedGroup | None: - try: - # TODO: 实现获取逻辑 - return UnifiedGroup( - group_id=group_id, - group_name="Unknown", - member_count=0, - platform="new_platform", - ) - except Exception: - return None - - async def get_group_list(self) -> list[str]: - try: - # TODO: 实现获取逻辑 - return [] - except Exception: - return [] - - async def get_member_list(self, group_id: str) -> list[UnifiedMember]: - try: - # TODO: 实现获取逻辑 - return [] - except Exception: - return [] - - async def get_member_info(self, group_id: str, user_id: str) -> UnifiedMember | None: - try: - # TODO: 实现获取逻辑 - return None - except Exception: - return None - - # ==================== IAvatarRepository ==================== - - async def get_user_avatar_url(self, user_id: str, size: int = 100) -> str | None: - try: - # TODO: 实现获取逻辑 - return None - except Exception: - return None - - async def get_user_avatar_data(self, user_id: str, size: int = 100) -> str | None: - return None - - async def get_group_avatar_url(self, group_id: str, size: int = 100) -> str | None: - return None - - async def batch_get_avatar_urls(self, user_ids: list[str], size: int = 100) -> dict[str, str | None]: - return {uid: await self.get_user_avatar_url(uid, size) for uid in user_ids} -``` - ---- - -*文档最后更新: 2026-02-09* diff --git a/docs/TRACEID_IMPLEMENTATION.md b/docs/TRACEID_IMPLEMENTATION.md deleted file mode 100644 index a1711bd..0000000 --- a/docs/TRACEID_IMPLEMENTATION.md +++ /dev/null @@ -1,332 +0,0 @@ -# TraceID 实现代码示例 - -这个文件展示如何在插件中实现 contextvars + logging.Filter 方案。 - -## 文件结构 - -``` -astrbot_plugin_qq_group_daily_analysis/ -├── src/ -│ ├── utils/ -│ │ ├── trace.py # ← 新增:TraceID 相关 -│ │ └── helpers.py -│ ├── core/ -│ │ └── config.py -│ └── ... -├── main.py # ← 需要修改:注册 Filter -└── ... -``` - -## 1. 新增文件:src/utils/trace.py - -```python -""" -TraceID 追踪工具模块 -提供分布式追踪的 trace_id 上下文管理 -""" - -import contextvars -import logging -import time -from astrbot.api import logger - -# ============ ContextVar 定义 ============ -_trace_id: contextvars.ContextVar[str] = contextvars.ContextVar( - 'trace_id', - default='' -) - - -# ============ Filter 实现 ============ -class TraceIDFilter(logging.Filter): - """ - 将 trace_id 自动注入到日志记录 - - 使用方式: - logger.addFilter(TraceIDFilter()) - # 之后所有日志的 record 对象都会有 trace_id 属性 - """ - - def filter(self, record): - """添加 trace_id 到日志记录""" - trace_id = _trace_id.get('') - record.trace_id = trace_id if trace_id else 'no-trace' - return True - - -# ============ 接口函数 ============ -def set_trace_id(group_id: str, timestamp: int = None) -> str: - """ - 设置当前协程的 trace_id - - Args: - group_id: 群 ID - timestamp: 时间戳(如果为 None 则使用当前时间) - - Returns: - 生成的 trace_id 字符串 - - Example: - >>> trace_id = set_trace_id("123456789") - >>> logger.info("分析开始") # 日志自动包含 trace_id - """ - if timestamp is None: - timestamp = int(time.time()) - - trace_id = f"{group_id}-{timestamp}" - _trace_id.set(trace_id) - return trace_id - - -def get_trace_id() -> str: - """获取当前协程的 trace_id""" - return _trace_id.get('no-trace') - - -def clear_trace_id(): - """清理当前协程的 trace_id""" - _trace_id.set('') - - -def with_trace_id(group_id: str): - """ - 上下文管理器:自动管理 trace_id 的生命周期 - - Example: - >>> from src.utils.trace import with_trace_id - >>> - >>> async def analyze_group(group_id: str): - ... with with_trace_id(group_id): - ... logger.info("开始分析") # 自动包含 trace_id - ... # ... - ... logger.info("分析完成") - ... # 退出时自动清理 trace_id - """ - class TraceContextManager: - def __init__(self, group_id): - self.group_id = group_id - self.trace_id = None - - def __enter__(self): - self.trace_id = set_trace_id(self.group_id) - return self.trace_id - - def __exit__(self, exc_type, exc_val, exc_tb): - clear_trace_id() - return False - - return TraceContextManager(group_id) - - -# ============ 初始化函数 ============ -def setup_trace_logging(): - """ - 初始化 TraceID 日志追踪 - - 在插件启动时调用一次即可: - from src.utils.trace import setup_trace_logging - setup_trace_logging() - """ - # 注册 Filter 到 AstrBot logger - trace_filter = TraceIDFilter() - - # 检查是否已注册(避免重复注册) - for existing_filter in logger.filters: - if isinstance(existing_filter, TraceIDFilter): - return # 已经注册过了 - - logger.addFilter(trace_filter) - logger.info("[Trace] TraceID 日志追踪已启用") -``` - -## 2. 修改文件:main.py(插件主文件) - -在 `__init__` 方法中添加初始化: - -```python -# main.py -from src.utils.trace import setup_trace_logging - -class QQGroupDailyAnalysis(Star): - def __init__(self, context: Context, config: AstrBotConfig): - super().__init__(context) - # ... 其他初始化代码 ... - - # ← 新增:初始化 TraceID 追踪 - setup_trace_logging() - - # ... 其他初始化代码 ... -``` - -## 3. 修改文件:src/scheduler/auto_scheduler.py - -在群分析方法中使用 TraceID: - -```python -# auto_scheduler.py -from src.utils.trace import set_trace_id, clear_trace_id, with_trace_id - -class AutoScheduler: - # ... 其他方法 ... - - async def _perform_auto_analysis_for_group(self, group_id: str): - """为指定群执行自动分析""" - - # ← 方案 1:使用上下文管理器(推荐) - with with_trace_id(group_id): - try: - logger.info(f"开始为群 {group_id} 执行自动分析") - # ... 分析逻辑,所有 logger 调用都自动包含 trace_id ... - except Exception as e: - logger.error(f"群 {group_id} 自动分析失败: {e}") - - # ← 方案 2:手动设置/清理(传统方式) - # set_trace_id(group_id) - # try: - # logger.info(f"开始为群 {group_id} 执行自动分析") - # # ... 分析逻辑 ... - # finally: - # clear_trace_id() -``` - -## 4. 修改文件:src/utils/helpers.py - -在 `MessageAnalyzer` 中使用 TraceID: - -```python -# helpers.py -from src.utils.trace import with_trace_id - -class MessageAnalyzer: - async def analyze_messages( - self, - messages: list[dict], - group_id: str, - unified_msg_origin: str = None - ) -> dict: - """完整的消息分析流程""" - - with with_trace_id(group_id): - try: - logger.info("开始消息分析") - - # 基础统计 - statistics = await asyncio.to_thread(...) - logger.info(f"统计完成:{len(messages)} 条消息") - - # LLM 分析 - topics, user_titles, golden_quotes, token_usage = \ - await self.llm_analyzer.analyze_all_concurrent(...) - logger.info(f"LLM 分析完成") - - return { - "statistics": statistics, - "topics": topics, - "user_titles": user_titles, - } - except Exception as e: - logger.error(f"消息分析失败: {e}") - return None -``` - -## 5. 日志输出效果 - -启动后,日志会自动包含 trace_id: - -```bash -$ python main.py - -[10:30:45] [Plug] [INFO ] [auto_scheduler:100]: [123456789-1707292800] 开始为群 123456789 执行自动分析 -[10:30:45] [Plug] [INFO ] [message_handler:50]: [123456789-1707292800] 开始获取消息 -[10:30:46] [Plug] [INFO ] [message_handler:100]: [123456789-1707292800] 获取成功,共 256 条消息 -[10:30:47] [Plug] [INFO ] [helpers:200]: [123456789-1707292800] 开始消息分析 -[10:30:48] [Plug] [INFO ] [llm_analyzer:300]: [123456789-1707292800] 开始 LLM 分析 -[10:30:50] [Plug] [INFO ] [llm_analyzer:350]: [123456789-1707292800] LLM 分析完成 -[10:30:51] [Plug] [INFO ] [report_generator:400]: [123456789-1707292800] 报告生成中 -[10:30:53] [Plug] [INFO ] [auto_scheduler:450]: [123456789-1707292800] 分析完成,耗时 8s -``` - -## 6. 查看日志 - -### 终端实时查看 - -```bash -# 看所有日志 -tail -f logs/astrbot.log - -# 看特定群的日志 -tail -f logs/astrbot.log | grep "123456789" - -# 或使用 grep 搜索 -grep "123456789-1707292800" logs/astrbot.log -``` - -### 按 TraceID 追踪完整链路 - -```bash -# 获取某个 trace_id 的所有日志 -grep "123456789-1707292800" logs/astrbot.log - -# 按时间戳统计分析耗时 -# trace_id 格式 {group_id}-{start_timestamp} -# 可从日志时间戳对比,计算耗时 -``` - -## 7. 成本统计 - -| 项目 | 工作量 | 难度 | -|------|--------|------| -| 新增 trace.py | ~80 行 | 低 | -| 修改 main.py | 3 行 | 低 | -| 修改 auto_scheduler.py | ~10 行 | 低 | -| 修改 helpers.py | ~5 行 | 低 | -| 修改其他文件 | 可选(向后兼容) | 低 | -| **总计** | **~100 行** | **低** | - -## 8. 向后兼容性 - -✅ **完全向后兼容** - -- 现有代码无需改动 -- 新增代码仅在初始化时注册 Filter -- 所有日志输出不变,仅在 record 对象中添加 trace_id 字段 -- 如果日志格式未修改,trace_id 不会显示,但在代码中可以访问 - -## 9. 扩展应用 - -### 应用 1:性能分析 - -```python -from src.utils.trace import get_trace_id -import time - -start = time.time() -# ... 某个操作 ... -elapsed = time.time() - start -logger.info(f"操作耗时 {elapsed:.2f}s") -# 日志自动包含 trace_id,可后续统计所有群的平均耗时 -``` - -### 应用 2:错误统计 - -```bash -# 找出所有失败的 trace_id -grep "ERROR" logs/astrbot.log | awk -F'[]' '{print $2}' | sort | uniq -c - -# 输出: -# 3 123456789-1707292800 -# 2 987654321-1707292801 -# → 表示这两个分析任务各失败了 3 次和 2 次 -``` - -### 应用 3:分布式追踪 - -如果以后扩展到分布式部署,trace_id 可以传递到其他服务: - -```python -# 跨服务调用时传递 trace_id -async def call_external_service(url, data): - headers = {"X-Trace-ID": get_trace_id()} - return await http_client.post(url, json=data, headers=headers) -``` - diff --git a/docs/group_memory_feasibility_review.md b/docs/group_memory_feasibility_review.md new file mode 100644 index 0000000..60916b6 --- /dev/null +++ b/docs/group_memory_feasibility_review.md @@ -0,0 +1,461 @@ +# 14. 群聊记忆模块可执行性复盘与研究记录 + +## 1. 文档目的 + +本文档用于沉淀上一轮“群聊记忆模块提案”的后续研究结果,重点回答两个问题: + +1. 这套方案在当前插件代码里是否真的能落地? +2. 在 AstrBot 宿主生态下,哪些能力可以直接复用,哪些地方必须调整? + +这份文档不再重复完整愿景,而是保留讨论过程中的关键判断、约束、风险和修订方向,作为后续 Phase 1 设计与开发的依据。 + +## 2. 研究范围 + +本轮研究从两个方向并行展开: + +### 2.1 插件内部可执行性 + +关注点包括: + +- 现有分析链路有哪些真实接入点 +- 当前数据模型是否足以支撑长期画像 +- KV 持久化是否能承接记忆库 +- 调度、并发、回退机制是否会被影响 + +### 2.2 AstrBot 宿主生态适配性 + +关注点包括: + +- AstrBot 是否已经提供插件级 KV 和数据目录能力 +- 宿主是否有可复用的 cron/scheduler 机制 +- 会话、人格、UMO、消息历史这些基础设施如何复用 +- 哪些宿主能力不适合拿来存长期记忆 + +## 3. 研究输入 + +本轮研究主要基于以下资料和代码: + +- 群聊记忆总提案:`docs/group_memory_proposal.md` +- 插件应用层编排:`src/application/services/analysis_application_service.py` +- 插件增量存储:`src/infrastructure/persistence/incremental_store.py` +- 插件历史摘要存储:`src/infrastructure/persistence/history_manager.py` +- 插件消息入库:`src/application/services/message_processing_service.py` +- 插件配置管理:`src/infrastructure/config/config_manager.py` +- 插件最终报告生成:`src/domain/services/report_generator.py` +- AstrBot 插件 KV:`astrbot/core/utils/plugin_kv_store.py` +- AstrBot 路径工具:`astrbot/core/utils/astrbot_path.py` +- AstrBot 共享偏好:`astrbot/core/utils/shared_preferences.py` +- AstrBot 定时任务管理:`astrbot/core/cron/manager.py` +- AstrBot 会话与 UMO:`astrbot/core/platform/message_session.py` +- AstrBot 人格管理:`astrbot/core/persona_mgr.py` + +## 4. 当前插件已经具备的“记忆底座” + +研究后确认,插件并不是从零开始。 + +当前已经存在三层非常重要的中间能力: + +### 4.1 原始消息层 + +插件在群消息到达时,已经通过 `MessageProcessingService` 将消息写入宿主的 `message_history_manager`。 + +这意味着: + +- 原始聊天记录已经有统一入口 +- 记忆系统不需要再自己拦第二份原始消息日志 +- 长期记忆应当是“结构化提炼层”,不是“原始历史副本层” + +### 4.2 增量批次层 + +插件已经有成熟的增量分析能力: + +- 增量分析时只处理新消息 +- 生成 `IncrementalBatch` +- 将话题、金句、用户统计、参与者等中间结果落入 KV +- 最终报告时再按窗口合并 + +这一层非常适合作为长期记忆的“短期原料层”。 + +换句话说: + +- `IncrementalBatch` 不是长期记忆本身 +- 但它天然适合作为长期记忆抽取的输入 + +### 4.3 最终汇总层 + +插件在最终报告阶段已经能做: + +- 滑动窗口合并 +- 用户称号分析 +- 质量锐评汇总 +- 报告生成与发送 + +这使得“记忆检索增强最终总结”成为可能。 + +## 5. 插件侧核心研究结论 + +### 5.1 方案方向是成立的 + +结论很明确: + +- 方案不是空中楼阁 +- 现有插件结构足够支撑“长期记忆层”的加入 +- 不需要推倒重写现有分析链路 + +真正的问题不在于“能不能做”,而在于“第一期要做到什么程度才可控”。 + +### 5.2 真实接入点只有三个 + +研究后确认,长期记忆闭环真正适合挂接的位置只有三处: + +1. `execute_incremental_analysis()` 成功保存批次之后 +2. `execute_incremental_final_report()` 生成最终总结之前 +3. `execute_incremental_final_report()` 成功完成之后 + +它们分别对应: + +- 批次后抽取 +- 总结前检索 +- 总结后刷新 + +这意味着提案中的记忆闭环应该收敛为: + +```text +IncrementalBatch saved +-> extract candidate memories +-> update long-term memory + +Final report begins +-> retrieve relevant memory digests +-> inject into summary prompt + +Final report succeeds +-> refresh group/member snapshots +``` + +### 5.3 现在还没有“记忆上下文注入链路” + +这是本轮 review 里最重要的结论之一。 + +现有 `ReportGenerator` 和 LLM 分析链路只关心: + +- 统计数据 +- 话题 +- 用户称号 +- 金句 +- 聊天质量 + +但提案里想要的是: + +- 群长期画像摘要 +- 成员长期画像摘要 +- 历史 episode 摘要 + +这些内容目前没有地方可以传进去。 + +所以如果要实现“记忆增强总结”,必须先新增一种中间结构,比如: + +- `memory_context` +- `memory_digest` +- `retrieved_memory_bundle` + +然后把它接进: + +- 最终总结 prompt 构建 +- 分析结果中间对象 +- 可能的报告文案生成逻辑 + +### 5.4 成员长期画像所需信号还不够丰富 + +当前插件对用户的统计维度主要包括: + +- 消息数 +- 字符数 +- 回复数 +- 表情数 +- 活跃时段 + +这些信号可以支持: + +- 活跃用户画像 +- 时段偏好 +- 轻量角色归纳 + +但还不足以稳定支撑: + +- 话题偏好图谱 +- 风格特征 +- 关系线索 +- 长期行为漂移判断 + +因此提案需要修正: + +- 第一阶段的成员画像只能做轻量版 +- 更复杂的画像特征,需要后续新增规则抽取或单独 LLM 画像流程 + +### 5.5 `thread_scope_id` 暂时不适合作为首期目标 + +虽然从理论上,Telegram topic、Discord thread、子频道这些都应该成为独立子作用域,但当前插件的稳定主键仍然以 `group_id` 为核心。 + +研究中发现: + +- 消息处理层当前只稳定存群级会话 +- 调度层也是按群名单工作 +- 增量批次与汇总模型也默认按群聚合 + +这意味着如果一开始就做 thread 级记忆,会显著扩大改造面: + +- 消息侧要持久化更多上下文字段 +- 调度侧要知道 thread scope +- 存储层要支持群内多层 scope +- 检索和回写逻辑要区分群画像与 thread 画像 + +因此结论是: + +- 首期先做 `group_scope_id` +- thread / topic scope 作为 Phase 2 或 Phase 3 扩展 + +## 6. 宿主生态侧核心研究结论 + +### 6.1 插件 KV 完全可以承接第一版长期记忆 + +AstrBot 已经提供插件级 KV: + +- `put_kv_data` +- `get_kv_data` +- `delete_kv_data` + +这意味着长期记忆第一版不需要引入新数据库。 + +但同时也有边界: + +- 只适合 JSON 友好结构 +- 不适合无限长单 key 写入 +- 不提供复杂索引 +- 不提供并发控制 + +因此要像当前 `IncrementalStore` 一样自行设计: + +- 主体数据 key +- 索引 key +- 清理策略 +- 分片策略 + +### 6.2 `plugin_data` 目录适合保存大快照或导出物 + +如果未来长期记忆需要: + +- 调试快照 +- 大型归档 +- 导出文件 +- 可能的 embedding 文件 + +则应当放到: + +- `data/plugin_data/{plugin_name}/` + +这与宿主规范一致,也有利于备份和迁移。 + +### 6.3 不应该再自己起一套新的 scheduler + +AstrBot 已经有 `cron_manager`,其背后是: + +- APScheduler +- DB-backed cron metadata +- 可追踪的执行状态 +- 持久化的 next run 信息 + +因此长期记忆需要的: + +- 每日 compact +- 每周深度重写 +- 过期记忆清理 + +都应该挂到宿主 cron,而不是插件里再单独维护一个新定时器。 + +### 6.4 UMO / MessageSession / persona_manager 都可以复用 + +宿主已经提供: + +- 标准 UMO 格式:`platform_id:message_type:session_id` +- `MessageSession` +- `persona_manager` +- `conversation_manager` + +这意味着: + +- 群记忆 scope 的主键应直接遵循 UMO 风格 +- 总结时的人格选择逻辑不需要自造 +- 记忆增强 prompt 应尊重当前会话对应的人格设定 + +### 6.5 SharedPreferences 不适合作为长期记忆主存储 + +这轮研究明确确认: + +- SharedPreferences 更适合偏好设置 +- 不适合作为长期记忆实体的主存储层 + +原因包括: + +- 语义职责不匹配 +- 有临时缓存清理机制 +- 容易把配置型数据和记忆型数据混杂 + +正确做法应该是: + +- 长期记忆主存储:插件 KV / plugin_data +- 会话人格、服务偏好等:继续走 SharedPreferences / persona_manager + +## 7. 研究中沉淀出的主要风险 + +### 7.1 存储膨胀风险 + +如果每个群不断累积 episode 和记忆索引,KV 体积会不断扩大。 + +具体风险: + +- episode 列表过长 +- 成员画像过多 +- 快照长期不重写 +- 某些高活跃群写入频率过高 + +因此 compact 不是优化项,而是必要功能。 + +### 7.2 并发竞争风险 + +当前插件已经有较复杂的调度和增量分析流程。 + +如果长期记忆写入与增量批次写入同时发生,可能出现: + +- 同 scope 下索引竞争 +- 批次成功但记忆写入失败 +- 定时清理与写入冲突 + +因此 MemoryStore 必须引入自己的 scope 级锁,不能裸写 KV。 + +### 7.3 画像过拟合风险 + +如果只用单日或少量消息更新长期画像,容易导致: + +- 短期异常被错误固化 +- 某次玩梗变成“长期角色” +- 某天沉默被误判为风格变化 + +因此需要: + +- 候选记忆区 +- 置信度机制 +- 稳定画像与近期 delta 分离 + +### 7.4 集成链路复杂度风险 + +长期记忆如果直接改动所有 analyzer,会迅速扩大工程复杂度。 + +所以首期最稳妥的方案应限制在: + +- 只增强最终总结 +- 不强制所有分析模块都接入长期记忆 + +## 8. 研究后形成的关键修订 + +基于本轮 review,原始提案需要做以下收敛: + +### 8.1 目标收敛 + +原始目标: + +- 群级记忆 +- 成员长期画像 +- 事件记忆 +- 多层作用域 +- 安全治理 +- 遗忘 +- 复杂检索 + +修订后首期目标: + +- 群级长期画像 +- 成员轻量长期画像 +- episode 事件记忆 +- 群级作用域 +- 基础 compact +- 最终总结增强 + +### 8.2 技术路线收敛 + +原始路线允许多种可能: + +- KV +- 文件 +- 向量库 +- 图结构 + +修订后首期路线明确为: + +- 主存储:插件 KV +- 大快照:plugin_data +- 不引入向量库 +- 不引入图数据库 + +### 8.3 集成范围收敛 + +原始设想偏全链路增强。 + +修订后: + +- 首期只动增量分析后、最终总结前后这三个接入点 +- 首期不强行改造所有 analyzer +- 首期以 memory digest 注入最终总结为主 + +## 9. 当前最合理的 Phase 1 方向 + +研究后,我们认为最合理的第一阶段是: + +### 9.1 做什么 + +- 按 `group_scope_id` 构建群级记忆空间 +- 从 `IncrementalBatch` 抽取 episode 和记忆候选 +- 维护 `GroupProfile` +- 维护 `MemberProfileLite` +- 最终总结前检索记忆摘要并增强 prompt +- 定时 compact 和记忆淘汰 + +### 9.2 不做什么 + +- 不做 thread/topic scope +- 不做复杂关系图谱 +- 不做 embedding 检索 +- 不做跨群用户统一画像 +- 不做高风险人格推断 + +## 10. 对后续开发的直接指导 + +### 10.1 必须优先做的事情 + +1. 设计 `MemoryStore` +2. 设计 `memory` 配置组 +3. 确定 `memory_digest` 在总结链路中的传递方式 +4. 定义首期 `MemberProfileLite` 的字段边界 + +### 10.2 应优先避免的事情 + +- 一开始就追求“非常聪明”的画像系统 +- 让长期记忆深度侵入所有 LLM analyzer +- 引入新的数据库依赖 +- 在插件内重复造 scheduler + +## 11. 结论 + +本轮研究最终得到的结论是: + +- 群聊记忆模块在当前插件与 AstrBot 生态中是可执行的。 +- 现有的增量批次、插件 KV、UMO、人格管理和宿主 cron 都为该方案提供了现实基础。 +- 但原始提案的范围偏大,若直接照单全做,工程风险会明显上升。 + +因此更可取的路线是: + +- 保留原提案的长期方向 +- 用本轮研究结论收敛首期范围 +- 先交付一版“真正能跑、能演化、能清理”的群级记忆系统 + +后续文档将基于本文的研究结果,给出修订后的 Phase 1 方案。 diff --git a/docs/group_memory_phase1_proposal.md b/docs/group_memory_phase1_proposal.md new file mode 100644 index 0000000..0a4bb0c --- /dev/null +++ b/docs/group_memory_phase1_proposal.md @@ -0,0 +1,653 @@ +# 15. 群聊记忆模块 Phase 1 提案 + +## 1. 文档定位 + +本文档是基于以下两份前置文档收敛后的第一阶段设计: + +- `docs/group_memory_proposal.md` +- `docs/group_memory_feasibility_review.md` + +与总提案不同,本文只回答一个问题: + +在当前插件代码与 AstrBot 宿主生态的真实边界下,第一阶段到底做什么、怎么做、做到什么程度算完成。 + +## 2. Phase 1 目标 + +### 2.1 目标描述 + +Phase 1 的目标不是做一个“完美的认知架构”,而是交付一个可运行、可演化、可清理的群级长期记忆系统。 + +它需要满足以下能力: + +- 以群为天然作用域,维护长期记忆空间。 +- 能从增量批次中抽取结构化长期记忆候选。 +- 能维护群级长期画像。 +- 能维护群内成员轻量长期画像。 +- 能在最终总结前检索相关长期记忆,并增强总结 prompt。 +- 能通过 compact / retention 机制控制存储膨胀。 + +### 2.2 交付标准 + +当 Phase 1 完成时,应具备以下可见效果: + +- 同一个群的总结开始体现持续的群风格。 +- 对同一个成员的描述不再完全依赖当天表现,而是出现轻量长期连续性。 +- 总结中可以自然提到与近期历史相关的延续话题。 +- 长期记忆不会无限增长,并且支持清理和重建。 + +## 3. Phase 1 范围 + +### 3.1 In Scope + +- 群级记忆作用域:`group_scope_id` +- 事件型记忆:`Episode` +- 群画像:`GroupProfile` +- 成员轻量画像:`MemberProfileLite` +- 最终总结记忆增强 +- 基础 compact / cleanup +- 基础安全过滤 + +### 3.2 Out of Scope + +- thread / topic 级作用域 +- 跨群统一成员画像 +- embedding / vector retrieval +- 图谱式关系建模 +- 高复杂度人格漂移模型 +- 大规模原始聊天回放式记忆拼装 + +## 4. 核心设计原则 + +### 4.1 首期只做群级作用域 + +Phase 1 的长期记忆主键统一为: + +```text +group_scope_id = "{platform_id}:GroupMessage:{group_id}" +``` + +说明: + +- `platform_id` 使用 AstrBot 的标准平台实例 ID +- `GroupMessage` 保持与 UMO 格式一致 +- `group_id` 与插件当前已有调度和增量分析逻辑保持一致 + +这样可以直接与当前插件工作流兼容。 + +### 4.2 记忆增强只影响最终总结 + +首期不要求所有 analyzer 都接入长期记忆。 + +仅在最终总结链路中引入长期记忆摘要: + +- 群画像摘要 +- 成员画像摘要 +- 相关历史事件摘要 + +这样可以显著降低改造面。 + +### 4.3 长期画像只做轻量版 + +首期成员画像不追求复杂人格建模,只保留插件当前信号源能够稳定支持的维度。 + +## 5. Phase 1 数据模型 + +## 5.1 `GroupProfile` + +用于描述一个群的长期稳定特征。 + +建议字段: + +```json +{ + "scope_id": "qq:GroupMessage:123456", + "summary": "这是一个以插件开发和部署交流为主,同时夹杂大量整活和吐槽的技术群。", + "tone_tags": ["技术讨论", "高频吐槽", "互助答疑"], + "recurring_topics": ["AstrBot", "插件开发", "平台适配", "LLM配置"], + "interaction_style": ["高信息密度", "熟人化交流", "延续性梗较多"], + "core_members": [ + {"user_id": "u1", "role": "答疑核心"}, + {"user_id": "u2", "role": "整活与吐槽"} + ], + "confidence": 0.72, + "updated_at": 1710000000 +} +``` + +### 5.2 `MemberProfileLite` + +用于描述一个成员在某个群内的轻量长期画像。 + +建议字段: + +```json +{ + "scope_id": "qq:GroupMessage:123456", + "user_id": "789", + "display_name": "Simon", + "activity_traits": ["高频发言", "晚间活跃"], + "topic_preferences": ["插件架构", "调度逻辑", "兼容性问题"], + "style_traits": ["直接", "技术密度高", "偶尔吐槽"], + "role_tags": ["答疑者", "推进者"], + "confidence": 0.64, + "last_active_at": 1710000000, + "updated_at": 1710000000 +} +``` + +注意: + +- `style_traits` 在首期只允许用保守表达 +- `role_tags` 必须是群内行为角色,不是现实人格标签 + +### 5.3 `Episode` + +用于描述最近一段时间中值得记住的事件型记忆。 + +建议字段: + +```json +{ + "memory_id": "ep_xxx", + "scope_id": "qq:GroupMessage:123456", + "time_range": { + "start_ts": 1710000000, + "end_ts": 1710001200 + }, + "summary": "群里围绕飞书成员权限预热方案持续讨论,最终收敛到先执行缓存预检查的做法。", + "keywords": ["飞书", "权限", "缓存预热"], + "participants": ["u1", "u2", "u3"], + "importance": 0.81, + "source_batch_ids": ["batch_a", "batch_b"], + "expires_at": 1712592000 +} +``` + +### 5.4 `MemorySnapshot` + +用于在最终总结阶段快速构建记忆上下文,而不是每次都从零扫描大量条目。 + +建议字段: + +```json +{ + "scope_id": "qq:GroupMessage:123456", + "group_profile_digest": "...", + "member_profile_digests": [ + {"user_id": "u1", "digest": "..."}, + {"user_id": "u2", "digest": "..."} + ], + "recent_episode_digest": [ + {"memory_id": "ep_1", "digest": "..."}, + {"memory_id": "ep_2", "digest": "..."} + ], + "updated_at": 1710000000 +} +``` + +## 6. 存储设计 + +### 6.1 存储介质 + +Phase 1 采用: + +- 主存储:插件 KV +- 大快照与导出:`plugin_data` + +不引入新数据库。 + +### 6.2 KV Key 设计 + +建议采用如下 key 结构: + +```text +mem_group_profile_{scope_id} +mem_member_profile_index_{scope_id} +mem_member_profile_{scope_id}_{user_id} +mem_episode_index_{scope_id} +mem_episode_{scope_id}_{memory_id} +mem_snapshot_{scope_id} +mem_meta_{scope_id} +``` + +### 6.3 Index 设计 + +为了避免单 key 无限膨胀,所有列表型结构都采用: + +- 单独索引 key +- 单条实体 key + +与 `IncrementalStore` 的模式保持一致。 + +例如: + +- `mem_episode_index_{scope_id}` 存储最近 episode 的 id + timestamp +- `mem_episode_{scope_id}_{memory_id}` 存储单个 episode 正文 + +### 6.4 并发控制 + +MemoryStore 必须具备 scope 级锁。 + +理由: + +- 增量分析可能并发触发 +- 最终报告与 compact 可能冲突 +- KV 本身不提供锁 + +建议做法: + +- 在 `MemoryApplicationService` 内使用 `asyncio.Lock` +- lock key 为 `memory:{scope_id}` + +## 7. 配置设计 + +建议新增 `memory` 配置组: + +```yaml +memory: + enabled: true + enable_memory_in_summary: true + enable_group_profile: true + enable_member_profile_lite: true + max_episode_per_group: 120 + max_member_profile_count: 200 + episode_retention_days: 21 + snapshot_refresh_interval_hours: 24 + retrieve_episode_top_k: 4 + retrieve_member_top_k: 5 + min_profile_confidence: 0.45 + enable_memory_safety_filter: true +``` + +## 8. 新增模块设计 + +## 8.1 `MemoryStore` + +位置建议: + +```text +src/infrastructure/persistence/memory_store.py +``` + +职责: + +- 保存群画像 +- 保存成员轻量画像 +- 保存 episode +- 保存 snapshot +- 查询与清理 + +### 8.2 `MemoryApplicationService` + +位置建议: + +```text +src/application/services/memory_application_service.py +``` + +职责: + +- 统一封装 extract / update / retrieve / compact +- 维护 scope 级锁 +- 控制错误回退策略 + +### 8.3 `MemoryExtractor` + +位置建议: + +```text +src/infrastructure/analysis/analyzers/memory_extractor.py +``` + +职责: + +- 从 `IncrementalBatch` 提取 episode candidate +- 从 `IncrementalState` / `analysis_result` 提取画像候选 + +### 8.4 `MemoryUpdater` + +位置建议: + +```text +src/domain/services/memory_updater.py +``` + +职责: + +- 合并候选记忆到长期存储 +- 做去重、置信度调整、替换和衰减 + +### 8.5 `MemoryPromptBuilder` + +位置建议: + +```text +src/domain/services/memory_prompt_builder.py +``` + +职责: + +- 将检索到的记忆拼成有限长度的 `memory_digest` +- 控制 prompt 大小 +- 避免冗余或极低置信度记忆进入总结 prompt + +## 9. 与现有代码的集成点 + +### 9.1 插件初始化 + +在 `main.py` 中新增: + +- `MemoryStore` +- `MemoryApplicationService` + +并注入到 `AnalysisApplicationService`。 + +### 9.2 增量分析成功后 + +在 `execute_incremental_analysis()` 成功保存 `IncrementalBatch` 后,新增: + +```text +await memory_application_service.update_from_batch(batch, platform_id) +``` + +要求: + +- best-effort +- 失败只记录日志 +- 不阻塞主流程成功返回 + +### 9.3 最终报告前 + +在 `execute_incremental_final_report()` 中构建最终总结前,新增: + +```text +memory_bundle = await memory_application_service.retrieve_for_summary( + scope_id=group_scope_id, + state=state, +) +``` + +返回内容应至少包括: + +- `group_profile_digest` +- `member_profile_digests` +- `episode_digests` + +### 9.4 最终总结 prompt 注入 + +需要在最终总结使用的 prompt 构建中引入: + +- 当前窗口摘要 +- 记忆摘要 + +注意: + +- 记忆增强只作用于最终总结相关 prompt +- 不强制所有 analyzer 都接入 + +### 9.5 最终报告成功后 + +在最终报告成功完成后,再执行: + +```text +await memory_application_service.refresh_from_final_result( + scope_id=group_scope_id, + state=state, + analysis_result=analysis_result, +) +``` + +用于更新: + +- `GroupProfile` +- `MemberProfileLite` +- `MemorySnapshot` + +## 10. Phase 1 检索设计 + +### 10.1 检索输入 + +检索阶段只基于以下信息: + +- 当前窗口话题 +- 当前活跃成员列表 +- 当前窗口关键词 + +### 10.2 检索目标 + +检索: + +- 最近相关的 3 到 4 条 episode +- 当前最活跃成员对应的画像摘要 +- 一个群级画像摘要 + +### 10.3 检索排序 + +Phase 1 不做 embedding,相似度以规则为主: + +- 关键词重合 +- 参与者重合 +- 时间接近度 +- importance +- confidence + +建议排序公式: + +```text +score = + keyword_overlap * 0.35 + + participant_overlap * 0.20 + + recency * 0.20 + + importance * 0.15 + + confidence * 0.10 +``` + +## 11. Phase 1 画像更新设计 + +### 11.1 `GroupProfile` 更新 + +更新来源: + +- 最近 N 个 batch 的 recurring topics +- 最终总结的群体风格描述 +- 最近活跃成员构成 + +更新原则: + +- 不做完全覆盖 +- 优先保留已有稳定标签 +- 对新标签先低置信度写入 + +### 11.2 `MemberProfileLite` 更新 + +更新来源: + +- 用户活跃统计 +- 其参与的话题 +- 最终总结里的用户描述 + +更新原则: + +- 标签必须保守 +- 需要多次出现才升高置信度 +- 允许保留“近期变化”但不立即改写长期画像 + +### 11.3 `Episode` 更新 + +更新来源: + +- 增量批次中的话题、金句、参与者、关键词 + +更新原则: + +- 同主题且时间相邻的 episode 可以合并 +- 长期无引用 episode 自动过期 + +## 12. 总结 Prompt 设计 + +### 12.1 Prompt 结构 + +建议在最终总结 prompt 中新增如下区块: + +```text +[当前窗口信息] +... + +[群长期画像摘要] +... + +[相关历史事件摘要] +... + +[活跃成员长期画像摘要] +... +``` + +### 12.2 使用规则 + +必须明确约束模型: + +- 以当前窗口事实为主 +- 长期记忆只做背景增强 +- 低置信度信息不得用确定性口吻表达 + +### 12.3 预期输出增强 + +记忆增强后,最终总结建议包含以下隐含效果: + +- 话题延续性 +- 人物连续性 +- 群风格辨识度 +- 今日反差点 + +## 13. 安全与治理 + +### 13.1 敏感信息过滤 + +在写入长期记忆前,过滤: + +- 电话号 +- 邮箱 +- 地址 +- 明显私人身份信息 + +### 13.2 保守画像原则 + +禁用以下危险画像方式: + +- 现实人格判断 +- 心理诊断式标签 +- 政治、宗教、健康等敏感推断 + +### 13.3 低置信度不进最终总结 + +如果: + +- 画像 `confidence` 太低 +- episode `importance` 太低 +- 内容带明显攻击性或疑似玩梗误导 + +则不进入最终总结的记忆增强上下文。 + +## 14. Compact 与 Cleanup 设计 + +### 14.1 执行方式 + +通过 AstrBot `cron_manager` 注册: + +- 每日轻量 compact +- 每周深度 snapshot 重建 + +### 14.2 每日 compact 内容 + +- 删除过期 episode +- 限制最大 episode 数 +- 降低长期未更新画像的置信度 + +### 14.3 每周 snapshot 重建 + +- 重写 `MemorySnapshot` +- 合并过旧且低价值的 episode +- 修正摘要冗余 + +## 15. 开发顺序建议 + +### Step 1 + +新增: + +- `memory_models.py` +- `memory_store.py` +- `memory_application_service.py` + +### Step 2 + +在 `main.py` 中完成依赖注入。 + +### Step 3 + +在 `execute_incremental_analysis()` 中接入 `update_from_batch()`。 + +### Step 4 + +在 `execute_incremental_final_report()` 中接入 `retrieve_for_summary()`。 + +### Step 5 + +把 `memory_digest` 接入最终总结 prompt。 + +### Step 6 + +在最终报告成功后接入 `refresh_from_final_result()`。 + +### Step 7 + +接入 `cron_manager` 做 compact / cleanup。 + +## 16. 测试建议 + +### 16.1 单元测试 + +- MemoryStore 的保存、查询、清理 +- 画像更新规则 +- episode 合并规则 +- memory digest 长度控制 + +### 16.2 集成测试 + +- 增量批次后能生成记忆 +- 最终总结前能正确检索记忆 +- 记忆失败不影响主分析结果 +- compact 后索引仍然一致 + +### 16.3 回归重点 + +- 自动分析调度是否变慢 +- 增量分析成功率是否下降 +- 现有报告生成是否被破坏 +- KV key 数量是否增长过快 + +## 17. 验收标准 + +Phase 1 可以判定完成的标准如下: + +1. 至少一个群在连续多次增量分析后形成可检索的长期记忆。 +2. 最终总结能稳定引用群画像、成员画像或历史 episode 中的一部分信息。 +3. 记忆写入失败不会导致分析失败。 +4. compact / cleanup 可运行,且能控制记忆条目规模。 +5. 不引入新的数据库依赖,不破坏现有调度与报告流程。 + +## 18. 结论 + +Phase 1 的核心不是“做一个很强的记忆系统”,而是: + +- 把长期记忆作为当前增量分析链路上的第四层结构化沉淀; +- 把它首先用于增强最终总结; +- 以最低风险方式,为后续更复杂的成员画像、关系图谱和 thread 级记忆打基础。 + +只要这一阶段做稳,后续迭代就会非常自然: + +- Phase 2 可以增加更强的画像与漂移判断 +- Phase 3 再考虑更细粒度 scope 和向量检索 + +但在现在,最重要的是先把这条基础链路跑通。 diff --git a/docs/group_memory_prd.md b/docs/group_memory_prd.md new file mode 100644 index 0000000..d01f4b1 --- /dev/null +++ b/docs/group_memory_prd.md @@ -0,0 +1,811 @@ +# 群聊记忆模块 PRD(初版) + +## 1. 文档信息 + +- 项目名称:群聊记忆模块(Group Memory System) +- 所属插件:`astrbot_plugin_qq_group_daily_analysis` +- 文档版本:v0.2 +- 文档类型:项目 PRD / 执行计划总纲 +- 当前状态:立项完成,进入 Phase 1 设计冻结与落地准备阶段 + +### 1.1 关联文档 + +- 总提案:`docs/group_memory_proposal.md` +- 可执行性复盘:`docs/group_memory_feasibility_review.md` +- Phase 1 技术提案:`docs/group_memory_phase1_proposal.md` + +### 1.2 本文档目的 + +本文档用于将前置讨论、代码研究、宿主生态 review 和 Phase 1 方案统一收敛为一份项目级 PRD。 + +它重点回答以下问题: + +- 这个项目为什么值得做 +- 目标用户是谁,核心价值是什么 +- 项目的短期、中期、长期目标分别是什么 +- 当前已经取得了哪些阶段性成果 +- 接下来应该如何推进,按什么里程碑验收 +- 如何定义成功、如何灰度、如何回滚、如何持续演化 + +## 2. 项目背景 + +当前插件已经具备较强的群聊分析能力: + +- 支持按群抓取消息并做全量分析 +- 支持增量分析与滑动窗口汇总 +- 支持话题、金句、用户称号、聊天质量等分析模块 +- 支持多平台(QQ、Telegram、Discord、Lark 等)接入 + +但从实际产品体验来看,当前总结仍然主要停留在“当前窗口总结”层面。 + +具体问题包括: + +- 总结对“这个群长期是什么风格”缺乏持续理解 +- 同一个成员在不同天的描述缺少连贯性 +- 今天的讨论与前几天、前几周的延续关系不容易被识别 +- 群聊总结还不够“像这个群自己的总结” + +因此,需要在现有分析链路之上,引入一层面向总结任务的长期记忆能力。 + +## 3. 项目机会与价值判断 + +### 3.1 产品机会 + +如果插件能够具备长期记忆,它将不再只是“自动生成日报”的工具,而会逐步演变为: + +- 群文化观察器 +- 群长期叙事整理器 +- 人物连续性总结器 +- 社区运营辅助工具 + +### 3.2 用户价值 + +用户最终感知到的价值应当是: + +- 总结更连续:今天的内容和历史脉络能连起来 +- 总结更有群味:不同群的输出风格不再趋同 +- 人物更稳定:对同一成员的描述更连贯 +- 信息密度更高:不需要翻历史也能理解当日讨论的上下文 + +### 3.3 工程价值 + +从工程角度,这个项目还能为插件建立一个可持续复用的长期认知底座,后续可以继续承接: + +- 周报 / 月报 +- 群文化档案 +- 历史事件回顾 +- 更强的角色识别与内容推荐 + +## 4. 项目愿景 + +### 4.1 长期愿景 + +将插件从“按天生成总结的分析器”,升级为“对每个群拥有持续认知的总结 Agent”。 + +### 4.2 产品愿景 + +让最终输出的群聊总结具备以下特征: + +- 有历史脉络 +- 有群体气质 +- 有人物连续性 +- 有更强的辨识度 + +### 4.3 工程愿景 + +在不推倒现有插件架构的前提下,构建一个: + +- 可增量演进 +- 可观测 +- 可治理 +- 可清理 +- 可分阶段扩展 + +的长期记忆底座。 + +## 5. 目标用户、场景与干系人 + +### 5.1 目标用户 + +主要用户包括: + +- 使用插件做群总结的群管理员 +- 将群总结作为日报、群档案、协作回顾材料的使用者 +- 高活跃社区 / 项目群的维护者 +- 希望总结更“有群味”的插件高级用户 + +### 5.2 核心使用场景 + +#### 场景 A:日常群总结增强 + +用户希望机器人生成的总结,不只是“今天聊了什么”,而是: + +- 今天的话题与近期历史有什么延续关系 +- 哪些人延续了长期角色,哪些人出现了反差 +- 今天的群氛围是否符合这个群一贯的风格 + +#### 场景 B:持续运营型群管理 + +在技术群、项目群、兴趣群中,管理者希望通过总结快速把握: + +- 群近期主线在往哪里演化 +- 哪些成员是稳定的活跃核心 +- 哪些 recurring topics 值得关注 + +#### 场景 C:历史回顾与群文化沉淀 + +当群聊逐步积累后,用户希望总结能形成: + +- 群风格档案 +- 群内长期角色感知 +- 历史高光事件回顾 + +### 5.3 关键干系人 + +本项目的关键干系人包括: + +- 产品 / 方案负责人:确定目标边界、阶段优先级与质量标准 +- 插件开发者:负责设计、实现、测试、治理与持续演进 +- 群管理员 / 高级用户:在灰度期间提供真实反馈 +- AstrBot 宿主生态:提供 KV、cron、UMO、persona、message history 等底层能力边界 + +## 6. 用户痛点 + +当前产品的主要痛点可归纳为: + +- 总结偏当期,历史连续性不足 +- 对同一成员的描述不够稳定 +- 群风格和群文化难以持续沉淀 +- 不同群的总结容易趋同,缺乏独特性 +- 现有分析结果缺少“长期认知层” + +## 7. 项目目标 + +### 7.1 短期目标(Phase 1) + +在当前插件与 AstrBot 生态下,先交付一版可运行的群级长期记忆系统。 + +短期目标包括: + +- 按群建立长期记忆空间 +- 从增量批次中提取事件型记忆 +- 构建群级长期画像 +- 构建成员轻量长期画像 +- 在最终总结前检索长期记忆并增强 prompt +- 增加 compact / retention 能力 + +### 7.2 中期目标(Phase 2) + +让长期记忆开始具备更强的稳定性与演化能力。 + +中期目标包括: + +- 引入画像置信度机制 +- 引入稳定画像与近期变化的分层 +- 增加更强的画像更新规则 +- 增强群内长期关系线索 +- 提升记忆检索与摘要质量 + +### 7.3 长期目标(Phase 3) + +将长期记忆升级为插件真正的“群聊认知底座”。 + +长期目标包括: + +- 支持更细粒度的 thread / topic scope +- 增加向量检索或更高级的相关性检索 +- 形成历史阶段切片能力 +- 支持周报、月报、群文化档案等更高阶能力 + +### 7.4 目标树 + +本项目的目标树可以概括为: + +- 一级目标:让群聊总结具备长期认知能力 +- 二级目标 A:让总结具备历史连续性 +- 二级目标 B:让总结具备群体风格辨识度 +- 二级目标 C:让成员描述具备长期一致性 +- 二级目标 D:让工程实现具备可控性、可观测性和可回滚能力 + +对应的阶段性结果是: + +- Phase 1:建立基础长期记忆闭环 +- Phase 2:增强画像稳定性与演化能力 +- Phase 3:扩展到更强的认知与衍生产品能力 + +## 8. 非目标 + +本项目当前阶段明确不追求: + +- 跨群统一用户画像 +- 复杂人格诊断或现实人格推断 +- 图数据库级关系图谱 +- 全量原始消息长期镜像 +- 一开始就把长期记忆接入所有 analyzer + +## 9. 当前阶段性成果 + +截至当前版本,项目已经完成以下阶段性成果: + +### 9.1 已完成的文档成果 + +- 完成总提案文档,明确长期愿景和整体架构方向 +- 完成可执行性复盘,明确插件侧与宿主生态的真实边界 +- 完成收敛后的 Phase 1 提案,明确首期范围与技术路线 +- 完成项目级 PRD,统一目标、里程碑、成功指标和执行策略 + +### 9.2 已确认的技术结论 + +- 当前插件已有增量批次与滑动窗口汇总,适合作为长期记忆原料层 +- AstrBot 已提供插件级 KV、`plugin_data`、UMO、persona、cron 等核心能力 +- 首期适合采用“群级作用域 + 轻量画像 + episode 记忆 + 最终总结增强”的方案 +- 首期不适合引入 thread 级 scope、向量检索和复杂关系图谱 + +### 9.3 当前尚未开始的内容 + +- 代码骨架尚未落地 +- `MemoryStore` 尚未实现 +- `memory_digest` 尚未接入最终总结链路 +- compact / cleanup 尚未接入宿主 cron + +## 10. 产品方案总览 + +### 10.1 一句话总结 + +在现有增量分析链路和最终报告链路之间增加一层长期记忆闭环,使插件可以按群沉淀“事件、画像与风格”,并将其反哺到最终总结中。 + +### 10.2 总体数据流 + +```text +原始消息 +-> 消息清洗 +-> IncrementalBatch +-> 记忆提取 +-> 长期记忆更新 + +最终总结前 +-> 记忆检索 +-> memory_digest +-> 总结 prompt 增强 + +总结成功后 +-> 群画像 / 成员画像 / 快照刷新 +``` + +### 10.3 核心策略 + +本项目采用“先骨架、后增强;先群级、后细分;先可控、后复杂”的推进策略。 + +对应原则为: + +- 先接通主链路,再优化画像质量 +- 先让记忆增强最终总结,再考虑横向扩散到其他 analyzer +- 先保证 compact、开关、回滚,再考虑更激进的能力扩展 + +## 11. Phase 1 范围 + +### 11.1 功能需求 + +#### FR-1 群级长期记忆空间 + +系统需要为每个群建立独立的长期记忆命名空间。 + +首期主键: + +```text +group_scope_id = "{platform_id}:GroupMessage:{group_id}" +``` + +#### FR-2 事件型记忆抽取 + +系统需要能够从 `IncrementalBatch` 中抽取可复用的 episode 记忆。 + +#### FR-3 群画像 + +系统需要维护一个可持续更新的群长期画像,用于描述: + +- 群主题倾向 +- 群风格 +- recurring topics +- 核心成员角色 + +#### FR-4 成员轻量画像 + +系统需要维护成员在某个群内的轻量长期画像,用于描述: + +- 活跃特征 +- 话题偏好 +- 风格倾向 +- 群内角色标签 + +#### FR-5 最终总结记忆增强 + +系统需要在最终总结前检索长期记忆,并将其注入总结 prompt。 + +#### FR-6 记忆 compact / retention + +系统需要定期清理过期 episode、限制索引规模、重建快照。 + +### 11.2 非功能需求 + +- 不引入新数据库依赖 +- 不显著降低现有自动分析成功率 +- 记忆写入失败不能导致主分析失败 +- 记忆摘要必须可控,不可无限增大 prompt +- 必须提供基本可观测性与回滚手段 + +### 11.3 优先级分层 + +#### P0 + +- MemoryStore +- Episode 抽取 +- GroupProfile +- MemberProfileLite +- memory_digest 注入最终总结 +- compact / cleanup + +#### P1 + +- 置信度机制 +- 近期变化摘要 +- 更细致的成员画像更新规则 +- 群画像快照优化 + +#### P2 + +- thread / topic scope +- 更高级相似度检索 +- 向量化扩展 +- 周报 / 月报等衍生能力 + +## 12. 技术与生态约束 + +### 12.1 插件内部约束 + +- 真实接入点主要集中在增量分析后、最终总结前后 +- 当前报告与 prompt 链路还没有 memory context 注入口 +- 当前成员统计信号不足以支持重画像,因此首期必须做轻量版 +- 并发写入需要 scope 级锁,不能直接裸写 KV + +### 12.2 AstrBot 宿主约束 + +- 插件 KV 适合存结构化 JSON 记忆,但需要自行管理索引与膨胀 +- `plugin_data` 适合存放大快照、调试导出或未来更重的离线材料 +- `cron_manager` 应作为 compact / cleanup 的统一调度入口 +- `SharedPreferences` 不应作为长期记忆主存储 +- `persona_manager`、`UMO`、`MessageSession` 应继续作为会话上下文与人格配置的标准来源 + +### 12.3 前置依赖与阻塞项 + +当前进入实现前,仍存在几个必须显式管理的前置依赖: + +- `IncrementalBatch` 和最终总结链路的结构必须保持稳定,避免记忆模块依附的输入频繁变化 +- `AnalysisApplicationService` 需要预留安全接入点,用于挂载批次后更新、总结前检索和总结后刷新 +- 最终总结 prompt 构建链路需要确认可安全接入 `memory_digest` +- 插件配置层需要允许新增 `memory` 配置组 +- 宿主 AstrBot 当前版本需要满足插件 KV 和 `cron_manager` 的使用前提 + +当前已知阻塞项包括: + +- 如果 `memory_digest` 无法稳定接入总结 prompt,Phase 1 的核心产品价值会明显下降 +- 如果 `MemberProfileLite` 的信号质量不足,成员画像必须以实验能力或降级模式上线 +- 如果灰度样本群不足,主观质量提升将难以验证 + +### 12.4 决策门槛 + +在进入 PR-1 开发前,需要冻结以下决策: + +1. `memory_digest` 的挂接入口和长度上限 +2. `MemberProfileLite` 的字段边界,以及哪些字段允许规则生成、哪些字段允许依赖 LLM +3. Phase 1 是否支持“仅群画像 + episode,不启用成员画像”的降级发布模式 +4. 灰度阶段的主观评估模板与责任人 +5. 手动清理指定群记忆是否纳入首期范围 + +## 13. 执行计划与里程碑 + +### 13.1 执行顺序 + +建议按以下顺序推进: + +1. 文档与接口对齐 +2. 存储与应用服务骨架 +3. 批次后记忆更新 +4. 总结前记忆检索 +5. Prompt 增强与摘要注入 +6. Compact、监控与治理 +7. 灰度、复盘与下一阶段规划 + +### 13.2 建议时间线 + +以下时间线作为 Phase 1 初版建议节奏,可根据开发资源做微调: + +- 第 1 周:冻结 Phase 1 范围、接口草案、关键决策门槛 +- 第 2 周:完成 `MemoryStore`、配置接入、基础单元测试 +- 第 3 周:打通增量批次后的记忆更新链路 +- 第 4 周:打通总结前检索与 `memory_digest` 注入 +- 第 5 周:接入 compact / cleanup / observability +- 第 6 周:灰度验证、回归修复、文档收尾 + +### 13.3 并行工作流建议 + +建议至少并行为三个工作流: + +- 工作流 A:存储与配置 + - `MemoryStore` + - `memory` 配置组 + - 索引、清理、存储约束 +- 工作流 B:写入与更新 + - `update_from_batch()` + - `GroupProfile` / `MemberProfileLite` 更新规则 + - episode 生成与合并 +- 工作流 C:检索与总结增强 + - `retrieve_for_summary()` + - `memory_digest` + - prompt 注入与输出回归验证 + +### 13.4 Milestone 0:方案收敛完成 + +交付物: + +- 总提案 +- 可执行性复盘 +- Phase 1 技术提案 +- PRD 初版 + +完成标准: + +- 项目边界明确 +- 首期范围收敛 +- 关键技术路线达成一致 + +### 13.5 Milestone 1:存储与骨架落地 + +交付物: + +- `MemoryStore` +- `memory` 配置组 +- `MemoryApplicationService` +- 基础数据模型 + +完成标准: + +- 可以为任意群读写基础长期记忆结构 +- 支持 scope 级锁 +- 支持基础 cleanup + +### 13.6 Milestone 2:批次后记忆更新 + +交付物: + +- 从 `IncrementalBatch` 抽取 episode +- 更新 `GroupProfile` +- 更新 `MemberProfileLite` + +完成标准: + +- 增量分析后,记忆库中有可观测的新记忆条目 +- 写入失败不影响主流程成功 + +### 13.7 Milestone 3:总结前记忆增强 + +交付物: + +- 记忆检索逻辑 +- `memory_digest` +- 最终总结 prompt 注入 + +完成标准: + +- 最终总结可以引用历史 episode 或长期画像信息 +- prompt 大小可控 + +### 13.8 Milestone 4:运维与治理 + +交付物: + +- compact / cleanup cron +- 监控指标 +- 清理与重建策略 + +完成标准: + +- 记忆体量受控 +- 可观测性到位 +- 有回滚与修复路径 + +## 14. 阶段性成绩定义 + +为便于项目推进,本项目采用“阶段性成绩”作为每个阶段的验收维度。 + +### 14.1 文档阶段成绩 + +- 完成需求收敛 +- 完成技术边界确认 +- 完成路线图、执行计划和 PRD + +### 14.2 首期实现阶段成绩 + +- 任意目标群可生成至少一种长期记忆条目 +- 最终总结可稳定引用长期记忆 +- 成员描述开始出现轻量连续性 +- compact 机制能控制数据规模 + +### 14.3 中期演进阶段成绩 + +- 群画像更加稳定 +- 成员画像开始具备阶段差异识别能力 +- 历史脉络感明显增强 + +## 15. 成功指标 + +### 15.1 产品指标 + +- 记忆增强总结启用率 +- 启用记忆的群占比 +- 用户对总结“连续性 / 群味 / 人物稳定性”的主观反馈 + +### 15.2 系统指标 + +- 在灰度群连续 7 天样本窗口内,记忆写入成功率 >= 99% +- 在灰度群连续 7 天样本窗口内,记忆检索成功率 >= 99% +- 记忆增强开启后,自动分析总体成功率不低于未开启前 7 天基线 +- 在灰度群样本中,单次最终总结的 P95 额外耗时不超过 20% + +### 15.3 质量指标 + +- 被注入 prompt 的低置信度画像比例 <= 5% +- compact 后索引一致性抽样正确率 = 100% +- 单群 episode 数量不超过配置上限,且 7 天内无持续失控增长 + +### 15.4 阶段门槛指标 + +- 内部验证阶段:至少 3 个测试群连续运行 3 天无主流程阻断 +- 白名单灰度阶段:至少 5 个真实群连续运行 7 天,总结成功率不低于历史基线 +- 阶段推进门槛:主观反馈中“连续性 / 群味”正向评价占比 >= 70% + +## 16. 可观测性与运营保障 + +### 16.1 日志与追踪 + +建议新增以下日志分类: + +- memory.extract +- memory.update +- memory.retrieve +- memory.compact +- memory.safety_filter + +### 16.2 核心监控点 + +- 每群记忆条目数量 +- 每群 episode 数量 +- 每次检索返回条目数 +- 每次总结注入的 digest 长度 +- compact 执行成功率 + +### 16.3 运维操作 + +建议后续提供运维能力: + +- 清空指定群记忆 +- 清理指定成员画像 +- 仅重建最近 N 天记忆 + +## 17. 发布策略 + +### 17.1 首发策略 + +建议采用灰度启用: + +- 通过 `memory.enabled` 控制总开关 +- 可先仅对白名单群启用 +- 可先只启用群画像,不启用成员画像 + +### 17.2 灰度节奏建议 + +建议采用三步灰度: + +1. 内部验证:只在少量测试群启用群画像与 episode +2. 小范围灰度:对白名单真实用户群启用完整 Phase 1 +3. 默认可选:保留开关,允许更多群自主启用 + +每一阶段进入下一步前,都需要满足: + +- 总结成功率没有明显回退 +- 记忆体量增长可控 +- 用户对总结质量的主观反馈为正 + +建议采用统一人工评估模板,至少覆盖: + +- 历史连续性感知 +- 群风格辨识度 +- 人物描述稳定性 +- 是否出现明显误判或“编造感” + +### 17.3 回滚策略 + +如果出现以下情况,应允许快速关闭记忆增强: + +- 总结成功率明显下降 +- 写入失败率异常升高 +- prompt 长度失控 +- 画像内容明显失真 + +回滚方式: + +- 关闭 `memory.enabled` +- 保留已有记忆数据但停止读写 +- 必要时执行清理或重建 + +## 18. 主要风险与缓解 + +### 18.1 存储膨胀 + +风险: + +- 高活跃群 episode 增长过快 + +缓解: + +- retention 上限 +- compact cron +- snapshot 重写 + +### 18.2 并发竞争 + +风险: + +- 增量分析、总结、compact 同 scope 冲突 + +缓解: + +- scope 级锁 +- best-effort 写入 +- 索引一致性检查 + +### 18.3 画像误判 + +风险: + +- 短期行为被固化成长期画像 + +缓解: + +- 首期仅做轻量画像 +- 引入保守标签 +- 低置信度不进入最终总结 + +### 18.4 Prompt 污染 + +风险: + +- 记忆注入过长或噪声过大,影响总结质量 + +缓解: + +- digest 限长 +- 只注入高相关、高置信度内容 +- 记忆增强仅在最终总结使用 + +### 18.5 宿主兼容性风险 + +风险: + +- 与 AstrBot 的 cron、persona、KV 使用方式不一致,导致后续维护成本升高 + +缓解: + +- 明确继续复用插件 KV +- compact 统一挂载 `cron_manager` +- 作用域继续遵循 UMO 风格 +- 人格能力继续通过宿主 `persona_manager` 读取 + +## 19. 项目实施建议 + +### 19.1 推荐开发顺序 + +1. 建立数据模型与 MemoryStore +2. 建立 `memory` 配置组 +3. 接入增量分析后的记忆更新 +4. 接入总结前记忆检索 +5. 接入最终总结 prompt 增强 +6. 接入 compact / cleanup +7. 完成监控与回滚能力 + +### 19.2 推荐 PR 拆分 + +建议至少拆为以下 PR: + +- PR-1:数据模型、配置、MemoryStore +- PR-2:批次后记忆更新 +- PR-3:总结前记忆检索与 prompt 注入 +- PR-4:compact / cleanup / observability +- PR-5:文档与运维能力补充 + +### 19.3 资源假设 + +当前默认资源假设为: + +- 开发资源以插件内部演进为主,不引入单独基础设施项目 +- 宿主 AstrBot 不需要为 Phase 1 做重大改造 +- 早期验证样本以少量真实群和开发测试群为主 +- Phase 1 的主要成本集中在存储骨架、总结链路接入和治理能力 + +### 19.4 推荐职责划分 + +建议按以下职责划分推进: + +- 方案负责人:冻结边界、维护 PRD、确认灰度门槛 +- 存储与应用层负责人:实现 `MemoryStore`、`MemoryApplicationService`、cleanup +- 总结增强负责人:实现 `memory_digest`、检索、prompt 注入 +- 验证与运维负责人:维护灰度名单、跟踪指标、执行回滚与清理 + +### 19.5 建议阶段 Gate + +- Gate A:接口冻结 + - `MemoryStore`、`memory_digest`、`MemberProfileLite` 字段边界冻结 +- Gate B:主链路打通 + - 增量批次写入与总结前检索均已跑通 +- Gate C:治理到位 + - compact、日志、开关、回滚已可用 +- Gate D:灰度通过 + - 指标与主观反馈满足推进门槛 + +## 20. Definition of Done + +当以下条件同时满足时,Phase 1 可以视为完成: + +- 代码链路完整贯通 +- 至少一个群能够沉淀长期记忆 +- 最终总结稳定引用长期记忆 +- 现有分析主流程不被破坏 +- compact / retention 已上线 +- 日志、指标、开关、回滚策略齐备 + +补充说明: + +- 若成员画像质量暂时未达预期,但群画像 + episode + 记忆增强总结已稳定工作,可允许以“成员画像受限 / 受开关控制”的降级模式发布 +- 若 compact / cleanup 未完成,则不得视为 Phase 1 可发布 +- 若 `memory_digest` 仍无法稳定接入最终总结链路,则 Phase 1 只能视为内部技术验证,不视为正式交付 + +## 21. 开放问题 + +当前仍需在实施前进一步明确的开放问题包括: + +- `memory_digest` 最终挂接在哪个总结 prompt 入口最稳妥 +- `MemberProfileLite` 中哪些字段可以完全基于规则生成,哪些必须依赖 LLM +- Phase 1 是否需要在配置层支持“仅群画像模式” +- 是否需要在首期就提供手动清理指定群记忆的管理命令 +- 灰度阶段的人工评估标准是否需要固定模板 + +建议按优先级理解这些开放问题: + +- P0:`memory_digest` 挂点、成员画像边界、降级发布模式 +- P1:人工评估模板、管理命令范围 + +这些问题不会阻止骨架开发启动,但会影响功能边界和 PR 拆分细节。 + +## 22. 下一步行动 + +基于当前 PRD,下一步建议立即进入: + +1. 明确 `MemoryStore` 与 `MemoryApplicationService` 的接口草案 +2. 明确 `memory_digest` 的中间结构 +3. 明确首期 `MemberProfileLite` 字段边界 +4. 输出第一批代码实现任务清单 +5. 按 PR-1 启动第一批代码骨架实现 + +## 23. 结论 + +群聊记忆模块不是对现有总结功能的“小修小补”,而是一次产品能力层级的升级。 + +它会把插件从“会分析今天的群聊”推进到“能理解这个群的长期叙事和人物结构”。 + +当前最正确的推进方式,不是一步做大,而是: + +- 先用 PRD 固化目标与边界 +- 先完成可执行的 Phase 1 +- 在稳定运行后,再向更复杂的长期认知能力演进 + +这也是本项目当前的核心执行策略。 diff --git a/docs/group_memory_proposal.md b/docs/group_memory_proposal.md new file mode 100644 index 0000000..c2ed805 --- /dev/null +++ b/docs/group_memory_proposal.md @@ -0,0 +1,908 @@ +# 13. 群聊总结记忆模块提案 (Group Memory Proposal) + +## 1. 背景与目标 + +当前插件已经具备以下能力: + +- 以群为单位抓取消息并做一次性全量分析。 +- 以群为单位执行增量分析,并将结果沉淀为 `IncrementalBatch`。 +- 在最终报告阶段,基于窗口内累积数据生成话题、金句、用户称号与聊天质量总结。 + +这套链路已经很好地解决了“今天聊了什么”的问题,但还没有真正解决“这个群一直在聊什么、这群人分别是什么风格、今天的内容与历史脉络是什么关系”这三个更高阶的问题。 + +如果我们希望总结更有“群味”,就需要引入一层长期记忆系统,让插件从“按天生成报告的分析器”升级为“对每个群拥有持续认知的总结 Agent”。 + +本提案的目标是: + +- 为插件引入天然分群的长期记忆能力。 +- 在群级记忆之下,为每个成员构建长期画像,但画像仅在该群作用域内生效。 +- 让最终总结能够引用“近期上下文 + 历史脉络 + 人物画像”,生成更具连续性、辨识度和趣味性的分析结果。 +- 复用当前的增量分析与 KV 存储架构,避免另起一套重系统。 +- 在设计上预留遗忘、安全、回滚和逐步上线能力。 + +## 2. 设计原则 + +本方案借鉴“记忆系统”相关研究的核心思想,但会严格贴合当前插件的实际边界。 + +### 2.1 作用域优先于全局统一 + +“天然分群”是第一原则。记忆不是全局共享的,而是默认按群隔离。 + +- 同一个用户在不同群的画像必须分开。 +- 同一平台不同群必须分开。 +- 不同平台即使群号相同,也必须分开。 +- Telegram 话题子会话、Discord 频道等特殊场景,应允许在群级作用域下继续细分。 + +### 2.2 记忆不是原始聊天记录的简单堆积 + +原始消息属于“可回放的数据源”,不是“可直接喂给总结器的记忆”。 + +记忆层应该只保存经过抽取、压缩、结构化后的高价值信息,包括: + +- 群级长期主题与 recurring 梗。 +- 成员长期角色、风格、兴趣、关系倾向。 +- 对总结真正有帮助的事件片段与证据引用。 +- 可供检索和动态拼装的摘要单元。 + +### 2.3 短期记忆和长期记忆分层 + +参考当前插件现状,可以将记忆分成两层: + +- 短期记忆:当前分析窗口内的消息、增量批次、当日热点。 +- 长期记忆:跨天持续存在的群画像、成员画像、稳定话题、长期事件线索。 + +短期记忆回答“今天发生了什么”,长期记忆回答“这件事在这个群里意味着什么”。 + +### 2.4 先做可控的文本结构化记忆,再考虑向量化增强 + +第一阶段不建议直接引入复杂向量数据库。更适合基于当前 KV 架构先实现: + +- 结构化事实卡片 +- 事件摘要 +- 群画像快照 +- 成员画像快照 +- 检索排序逻辑 + +等第一版跑稳,再视需要增加 embedding 检索。 + +### 2.5 安全、遗忘、可审计是基础能力 + +长期记忆一旦落地,就不再只是“功能增强”,而是“长期持有群内认知”的系统。因此必须一开始就考虑: + +- 敏感信息不过度沉淀。 +- 可以按群清除、按人清除、按时间淘汰。 +- 每条高价值记忆尽量保留来源批次和时间区间。 +- 对投毒内容、恶搞内容、短期异常行为有抑制机制。 + +## 3. 与当前实现的关系 + +当前代码中,已经存在非常适合作为记忆系统底座的能力: + +- `MessageProcessingService` 负责消息进入插件侧存储。 +- `AnalysisApplicationService.execute_incremental_analysis()` 负责把新消息切成增量批次。 +- `IncrementalBatch` 已经保存了用户统计、话题、金句、参与者等中间结构。 +- `IncrementalStore` 已经实现了“按群分桶 + 索引 + 单条 KV”的持久化模式。 +- `IncrementalMergeService` 已经实现了“窗口查询 -> 合并聚合 -> 最终报告”的工作流。 + +这意味着我们不需要新造一套完全独立的 pipeline,而是可以在现有链路上增加一层 `Memory Pipeline`: + +```mermaid +graph TD + A["原始群消息"] --> B["消息清洗"] + B --> C["增量分析批次 IncrementalBatch"] + C --> D["记忆提取 Memory Extract"] + D --> E["群记忆库 Group Memory Bank"] + E --> F["检索 Memory Retrieve"] + C --> G["最终报告聚合"] + F --> G + G --> H["更具连续性的群聊总结"] +``` + +核心思路不是替换当前分析流程,而是在“增量批次”和“最终报告”之间增加一个长期记忆闭环。 + +## 4. 记忆系统总体架构 + +### 4.1 两条主线 + +本提案建议把记忆拆成两条并行主线: + +1. 群级记忆 +2. 群内成员画像 + +它们共享同一个群作用域,但负责不同层面的认知沉淀。 + +### 4.2 群级记忆 + +群级记忆关注“这个群整体是什么样的”。 + +建议长期维护以下内容: + +- 稳定话题带:这个群最近长期反复出现的话题、梗、项目、活动。 +- 群体互动风格:高能整活、技术答疑、日常陪聊、吐槽、运营协作等。 +- 群体事件线:某个持续数天甚至数周的话题演变。 +- 群体角色结构:谁是活跃核心、谁是梗王、谁常带节奏、谁负责答疑。 +- 历史高光片段:值得在总结中反复引用的经典事件或金句。 + +### 4.3 群内成员画像 + +成员画像关注“这个人在这个群里通常扮演什么角色”。 + +注意这里的画像必须是“群内画像”,而不是跨群统一人格。 + +建议维护的维度: + +- 基础活跃特征:发言频次、活跃时段、长短文倾向、回复倾向。 +- 话题偏好:更常参与哪类讨论。 +- 风格特征:认真解答型、吐槽型、整活型、观察型、组织者型等。 +- 表达习惯:常用口头禅、格式偏好、语气特征、梗密度。 +- 群内角色:提问者、答疑者、情报员、乐子人、记录员等。 +- 稳定关系线索:经常和谁互动、常在谁的话题下接话。 +- 置信度与更新时间:画像是否稳定、最近是否发生明显漂移。 + +## 5. 作用域设计:如何做到“天然分群” + +### 5.1 记忆命名空间 + +建议引入统一的记忆作用域 ID,而不是只使用裸 `group_id`。 + +推荐主键: + +```text +group_scope_id = "{platform_id}:GroupMessage:{group_id}" +``` + +对于 Telegram 话题、Discord 线程等子空间,可以继续扩展: + +```text +thread_scope_id = "{platform_id}:GroupMessage:{group_id}#{topic_or_thread_id}" +``` + +### 5.2 分层隔离规则 + +- 默认所有长期记忆写入 `group_scope_id` 命名空间。 +- 如果平台具备更细的子话题结构,可以把 thread 级信息先写入 `thread_scope_id`,再在群级进行抽象汇总。 +- 成员画像的主键必须包含群作用域: + +```text +member_profile_key = "{group_scope_id}:{sender_id}" +``` + +这样可以天然保证: + +- 同一个 QQ 号在 A 群和 B 群是两份画像。 +- 同一个人跨平台不会误合并。 +- 同一个 Telegram 父群下不同 topic 可以选择共享群画像,但保留 thread 层差异。 + +## 6. 记忆分类设计 + +### 6.1 情景记忆 (Episodic Memory) + +情景记忆记录“发生过什么”。 + +在本插件里,适合存为: + +- 每次增量批次提炼出的事件摘要 +- 某日总结后的关键结论 +- 多日持续话题的阶段性节点 +- 高光互动或典型群事件 + +示例: + +```json +{ + "memory_id": "episode_xxx", + "scope_id": "qq:GroupMessage:123456", + "type": "episode", + "timestamp": 1710000000, + "summary": "群里围绕 AstrBot 插件的飞书适配展开了长时间讨论,最终确认了权限预热方案。", + "entities": ["飞书", "适配", "权限预热"], + "participants": ["user_a", "user_b", "user_c"], + "importance": 0.83, + "source_batch_ids": ["batch_1", "batch_2"] +} +``` + +### 6.2 语义记忆 (Semantic Memory) + +语义记忆记录“这个群长期是什么样、这个人长期是什么样”。 + +在本插件里,适合存为: + +- 群长期画像 +- 成员长期画像 +- 稳定话题标签 +- 持续关系与角色归纳 + +示例: + +```json +{ + "profile_id": "member_xxx", + "scope_id": "qq:GroupMessage:123456:user_789", + "type": "member_profile", + "stable_traits": [ + "高频参与插件开发讨论", + "回答偏直接且技术密度高", + "常在别人卡住时补关键实现细节" + ], + "topic_preferences": ["插件架构", "LLM", "平台适配"], + "style_signals": ["理性", "高信息密度", "偶尔吐槽"], + "confidence": 0.78, + "updated_at": 1710000000 +} +``` + +### 6.3 轨迹内记忆与跨轨迹记忆 + +按插件当前实际能力,可以这样映射: + +- 轨迹内记忆:本次分析窗口中的原始消息、清洗结果、增量批次、最终聚合状态。 +- 跨轨迹记忆:群画像、成员画像、历史事件卡片、长期主题索引。 + +其中: + +- `IncrementalBatch` 更像短期情景记忆的原材料。 +- `GroupMemoryBank` 才是真正的跨轨迹长期记忆。 + +## 7. 核心模块设计 + +### 7.1 新增模块一览 + +建议新增以下核心模块: + +- `MemoryScopeResolver` +- `MemoryExtractor` +- `MemoryUpdater` +- `MemoryRetriever` +- `MemoryStore` +- `MemoryCompactor` +- `MemorySafetyGuard` + +### 7.2 推荐目录结构 + +建议采用与当前插件一致的分层风格: + +```text +src/ + application/ + services/ + memory_application_service.py + domain/ + entities/ + memory_models.py + services/ + memory_compactor.py + memory_retriever.py + memory_updater.py + infrastructure/ + persistence/ + memory_store.py + analysis/ + analyzers/ + memory_extractor.py + memory_profile_analyzer.py +``` + +### 7.3 模块职责 + +#### MemoryScopeResolver + +负责把平台、群号、话题等信息统一转换为记忆命名空间。 + +#### MemoryExtractor + +负责从以下输入中抽取可沉淀的记忆单元: + +- 增量批次 +- 合并后的窗口状态 +- 最终报告结果 +- 可选的原始消息片段 + +提取结果不是最终写库格式,而是标准化的候选记忆: + +- `EpisodeCandidate` +- `GroupFactCandidate` +- `MemberTraitCandidate` +- `RelationshipCandidate` + +#### MemoryUpdater + +负责把候选记忆合并进长期记忆库,处理: + +- 去重 +- 冲突解决 +- 置信度更新 +- 漂移检测 +- 快照刷新 + +#### MemoryRetriever + +负责在生成最终总结前,根据当前窗口内容检索最相关的长期记忆。 + +#### MemoryCompactor + +负责记忆衰减、归档和淘汰,防止 KV 无限膨胀。 + +#### MemorySafetyGuard + +负责敏感信息过滤、异常记忆降权、对抗投毒与可疑内容隔离。 + +## 8. 存储模型设计 + +### 8.1 为什么继续使用 KV + +当前插件已经大量使用 `put_kv_data/get_kv_data`,并且 `IncrementalStore` 已经证明这条路在插件规模下是可行的。 + +因此第一版长期记忆建议继续采用 KV 模式,原因有三点: + +- 与现有基础设施完全兼容。 +- 开发成本低,迁移风险小。 +- 足以支撑“按群索引 + 按条加载 + 定期清理”的模式。 + +### 8.2 推荐 KV 键设计 + +```text +mem_group_profile_{scope_id} +mem_member_profile_{scope_id}_{user_id} +mem_episode_index_{scope_id} +mem_episode_{scope_id}_{memory_id} +mem_fact_index_{scope_id} +mem_fact_{scope_id}_{memory_id} +mem_relation_index_{scope_id} +mem_relation_{scope_id}_{pair_id} +mem_snapshot_{scope_id} +mem_meta_{scope_id} +``` + +### 8.3 推荐实体 + +#### GroupProfile + +群的长期语义画像。 + +建议字段: + +- `scope_id` +- `summary` +- `tone_tags` +- `recurring_topics` +- `group_archetypes` +- `inside_jokes` +- `core_members` +- `recent_focus_shift` +- `confidence` +- `updated_at` + +#### MemberProfile + +某个成员在某个群内的长期画像。 + +建议字段: + +- `scope_id` +- `user_id` +- `display_name` +- `role_tags` +- `topic_preferences` +- `style_traits` +- `behavior_patterns` +- `relationship_hints` +- `notable_phrases` +- `confidence` +- `stability_score` +- `last_active_at` +- `updated_at` + +#### MemoryEpisode + +事件型记忆。 + +建议字段: + +- `memory_id` +- `scope_id` +- `time_range` +- `summary` +- `keywords` +- `participants` +- `importance` +- `novelty` +- `source_batch_ids` +- `evidence_refs` +- `expires_at` + +#### GroupSnapshot + +面向报告生成的快照,属于“为检索优化的缓存层”。 + +建议字段: + +- `scope_id` +- `last_compacted_at` +- `weekly_summary` +- `active_members_digest` +- `recent_episodes_digest` +- `prompt_ready_context` + +它的作用是减少每次生成报告前都扫描大量细粒度记忆条目。 + +## 9. 提取、更新、检索、应用闭环 + +### 9.1 Extract:从增量批次中抽取记忆 + +建议把长期记忆抽取的主要入口放在两个时机: + +1. 每次 `execute_incremental_analysis()` 成功后 +2. 每次 `execute_incremental_final_report()` 成功后 + +两者职责不同: + +- 增量后抽取:更偏事件化、细粒度、低延迟。 +- 最终报告后抽取:更偏总结化、语义化、画像刷新。 + +### 9.2 Update:动态更新而不是覆盖重写 + +长期画像不应每次由 LLM 全量重写,而应基于已有画像增量更新。 + +建议采用以下策略: + +- 新证据与已有画像一致:提升置信度。 +- 新证据与已有画像冲突但频率低:记录为短期漂移,不立即覆盖。 +- 新证据持续多次出现:触发画像改写。 +- 久未出现的标签:逐步衰减。 + +例如: + +- 某成员偶尔一天高频整活,不应立刻把其画像改成“纯乐子人”。 +- 但如果连续多周都以某种风格出现,画像就应逐步收敛。 + +### 9.3 Retrieve:多因子检索,而不是只看语义相似度 + +建议检索评分由多个因素共同构成: + +```text +retrieval_score = + semantic_similarity * 0.35 + + recency_score * 0.20 + + importance_score * 0.20 + + confidence_score * 0.15 + + coverage_bonus * 0.10 +``` + +含义如下: + +- `semantic_similarity`:与当前窗口话题、人名、事件的匹配程度。 +- `recency_score`:越近期的记忆越容易被取回。 +- `importance_score`:高光事件、稳定画像优先。 +- `confidence_score`:低置信度画像不应频繁参与总结。 +- `coverage_bonus`:避免所有记忆都聚焦一个人或一个话题。 + +### 9.4 Application:记忆如何作用到最终总结 + +长期记忆不应该直接“覆盖”今天的分析结果,而应该作为增强上下文注入给总结器。 + +建议新增一个最终总结增强 prompt,上下文包括: + +- 当前滑动窗口摘要 +- 群级长期画像摘要 +- 当前热点相关的历史事件片段 +- 当前活跃成员的长期画像摘录 + +生成目标可以新增以下维度: + +- 今天的话题与群历史脉络的关系 +- 哪些成员表现符合长期画像,哪些表现出现反差 +- 这个群今天的“群味”体现在哪里 +- 哪些梗或冲突是延续性的,哪些是新出现的 + +## 10. 成员长期画像设计 + +### 10.1 为什么不能只靠“用户称号” + +当前插件的 `user_title` 更像一次性报告中的“今日称号”,它有趣,但不稳定,也不一定适合作为长期画像。 + +长期画像与用户称号的区别: + +- 用户称号:偏展示、偏当期、强调可读性。 +- 成员画像:偏记忆、偏长期、强调连续性和可检索性。 + +建议两者并存: + +- `MemberProfile` 用于长期记忆。 +- `UserTitle` 继续用于当日报告展示。 + +后续可以让 `UserTitle` 参考 `MemberProfile`,使称号更贴近这个人的长期群内形象。 + +### 10.2 推荐画像维度 + +建议将成员画像分为五层: + +1. 活跃层 +2. 主题层 +3. 风格层 +4. 关系层 +5. 稳定性层 + +#### 活跃层 + +- 活跃时段 +- 发言频率 +- 长短文倾向 +- 回复比率 + +#### 主题层 + +- 常参与话题 +- 擅长话题 +- 经常引发的话题 + +#### 风格层 + +- 理性/感性 +- 干货/整活 +- 直接/委婉 +- 高密度/短促型 + +#### 关系层 + +- 高频互动对象 +- 常被谁接话 +- 常和谁形成固定组合 + +#### 稳定性层 + +- 画像置信度 +- 最近是否漂移 +- 最近一次确认时间 + +### 10.3 画像更新策略 + +建议采用“双层画像”: + +- `stable_profile`:长期稳定特征 +- `recent_profile_delta`:近期变化特征 + +这样总结时可以自然写出: + +- “A 依旧承担技术答疑角色,但今天明显比平时更活跃。” +- “B 平时更偏潜水观察,今天却成了整场讨论的节奏中心。” + +这会让总结非常有辨识度。 + +## 11. 群级长期画像设计 + +### 11.1 群画像维度 + +建议维护以下字段: + +- `group_identity`: 这个群整体的气质与功能定位 +- `recurring_topics`: 长期反复出现的话题 +- `interaction_style`: 群体交流风格 +- `humor_style`: 梗文化、吐槽风格、常见互动模板 +- `coordination_mode`: 是松散闲聊还是任务协作型 +- `core_cast`: 群里典型核心成员及其长期分工 +- `recent_phase`: 群当前处于什么阶段 + +### 11.2 群画像的价值 + +它可以直接增强最终总结的“辨识度”: + +- 同样是技术群,有的群偏认真答疑,有的群偏边做边吐槽。 +- 同样是闲聊群,有的群是高频玩梗,有的群是生活流水账。 + +如果没有群画像,LLM 很容易把不同群总结成同一种腔调。 + +## 12. 与现有流程的集成点 + +### 12.1 在 `execute_incremental_analysis()` 后接入 + +建议在成功保存 `IncrementalBatch` 后,异步触发: + +```text +IncrementalBatch -> MemoryExtractor.extract_from_batch() + -> MemoryUpdater.apply_candidates() +``` + +适合提取: + +- 事件摘要 +- 人物短期行为信号 +- 新出现的话题线索 + +### 12.2 在 `execute_incremental_final_report()` 前接入检索 + +在构建最终报告增强 prompt 前,执行: + +```text +IncrementalState -> MemoryRetriever.retrieve_for_report() + -> prompt_context_builder +``` + +适合检索: + +- 与今日话题相关的历史事件 +- 今日活跃成员的长期画像摘要 +- 群近期稳定风格 + +### 12.3 在 `execute_incremental_final_report()` 后接入刷新 + +最终报告成功后,再做一次高层抽象更新: + +```text +AnalysisResult -> MemoryExtractor.extract_from_final_report() + -> MemoryUpdater.refresh_profiles() +``` + +适合更新: + +- 群画像快照 +- 成员稳定画像 +- 本周阶段性群状态 + +## 13. Prompt 设计建议 + +### 13.1 不建议把全部长期记忆原样塞进 Prompt + +长期记忆一旦增长,直接拼 prompt 会迅速失控。 + +建议只注入三类摘要: + +- `group_memory_digest` +- `member_profile_digest` +- `related_episode_digest` + +### 13.2 推荐增强 Prompt 结构 + +```text +你正在为一个长期观察过的群生成总结。 + +[当前窗口事实] +- 今天/本次窗口的核心话题 +- 活跃成员 +- 金句 + +[群长期画像] +- 群的长期风格 +- 最近持续关注的主题 + +[相关历史事件] +- 与本次窗口最相关的 3~5 条历史记忆 + +[成员画像摘录] +- 本次最活跃的若干成员的长期角色与近期变化 + +请输出: +1. 今日总结 +2. 历史脉络 +3. 人物动态 +4. 群风格点评 +``` + +### 13.3 报告风格控制 + +长期记忆引入后,最容易出现的问题是“总结越来越像编年史,少了当日鲜活感”。 + +因此 prompt 里要明确要求: + +- 今日窗口事实优先于长期记忆。 +- 长期记忆只用于解释和增强,不用于替代当日观察。 +- 对低置信度画像使用保守措辞。 + +## 14. 遗忘与压缩机制 + +### 14.1 为什么必须遗忘 + +如果没有遗忘,系统会逐渐出现: + +- 存储无限膨胀 +- 老梗长期霸榜 +- 过期人设难以纠正 +- Prompt 上下文越来越脏 + +### 14.2 建议遗忘策略 + +#### Episode 衰减 + +- 普通事件:7 到 30 天后衰减 +- 高重要度事件:可延长到 90 天或人工保留 + +#### 画像标签衰减 + +- 长时间未被新证据支持的特征逐步降权 +- 当 `confidence` 低于阈值时,不再用于总结 + +#### 快照重写 + +- 周期性重写 `GroupSnapshot` +- 用新快照替换碎片化旧摘要,降低检索成本 + +### 14.3 Compaction 周期 + +建议沿用现有定时任务能力,新增低频后台任务: + +- 每日轻量 compact 一次 +- 每周做一次深度重写 + +## 15. 安全与风险控制 + +### 15.1 主要风险 + +引入长期记忆后,风险主要来自四类: + +- 敏感信息持久化 +- 恶意投毒 +- 短期异常行为被误认成长期画像 +- 旧画像固化带来的刻板化偏差 + +### 15.2 防护建议 + +#### 敏感信息过滤 + +在写入长期记忆前,过滤或脱敏以下内容: + +- 账号、手机号、邮箱、住址 +- 明显私人隐私 +- 高风险身份信息 + +#### 低置信度隔离 + +对于只出现一次、措辞极端、带强烈攻击性的候选记忆: + +- 不直接写入稳定画像 +- 先放入短期候选区 +- 需要多次证据支持才转正 + +#### 证据回链 + +长期记忆条目建议保留: + +- 来源批次 ID +- 首次出现时间 +- 最近确认时间 + +这样有助于后续做调试与问题追踪。 + +#### 可清理接口 + +建议未来提供以下运维能力: + +- 清空指定群记忆 +- 清空指定成员画像 +- 仅重建最近 N 天记忆 + +## 16. 分阶段落地方案 + +### Phase 1:最小可用版 + +目标:先跑通“群级长期记忆 + 群内成员画像”的基础闭环。 + +范围: + +- 新增 `MemoryStore` +- 基于增量批次提取简单 episode +- 维护群画像快照 +- 维护成员画像快照 +- 在最终总结前做检索增强 + +不做: + +- 向量检索 +- 跨群身份合并 +- 复杂关系图谱 + +### Phase 2:增强版 + +目标:让画像更稳定、总结更有连续性。 + +范围: + +- 引入画像置信度与漂移检测 +- 引入群内关系线索 +- 引入候选记忆区与转正机制 +- 引入 compact/forgetting 任务 + +### Phase 3:高级版 + +目标:让记忆真正变成“群聊认知底座”。 + +范围: + +- 可选 embedding 检索 +- 群内长期梗图谱 +- 历史阶段自动切片 +- 更细粒度的 thread/topic 层记忆 + +## 17. 对当前代码的建议改动顺序 + +建议按最小侵入顺序推进: + +1. 新增 `memory_models.py` 定义核心数据结构。 +2. 新增 `memory_store.py`,完全参考 `IncrementalStore` 的索引化 KV 模式。 +3. 新增 `memory_application_service.py`,封装提取、更新、检索入口。 +4. 在 `main.py` 初始化时注入 `MemoryStore` 与 `MemoryApplicationService`。 +5. 在 `AnalysisApplicationService.execute_incremental_analysis()` 成功后调用记忆更新。 +6. 在 `AnalysisApplicationService.execute_incremental_final_report()` 中,在最终 LLM 总结前调用记忆检索。 +7. 在 `ReportGenerator` 或总结 prompt 构建处新增“记忆增强上下文”。 + +这样改的优点是: + +- 不会破坏当前增量分析主链路。 +- 问题定位更容易。 +- 可以按开关灰度启用。 + +## 18. 配置项建议 + +建议新增独立配置组 `memory`: + +```yaml +memory: + enabled: true + memory_scope_mode: "group" + max_episode_per_group: 200 + max_member_profile_count: 300 + episode_retention_days: 30 + profile_decay_days: 45 + min_profile_confidence: 0.45 + retrieve_episode_top_k: 5 + retrieve_member_top_k: 6 + enable_memory_in_summary: true + enable_member_long_profile: true + enable_memory_safety_filter: true +``` + +后续如要灰度,还可以支持: + +- 仅对白名单群启用记忆 +- 仅启用群画像,不启用成员长期画像 + +## 19. 预期收益 + +记忆系统上线后,预期收益主要体现在四个方面: + +### 19.1 总结更像“这个群”的总结 + +不再是泛化的日常摘要,而是能体现群长期氛围、 recurring 梗和成员关系的总结。 + +### 19.2 成员描写更稳定 + +同一个人不会每天都被总结成完全不同的角色,人物描写会更连贯。 + +### 19.3 历史脉络更清晰 + +能够回答: + +- “这事是不是昨天就开始了?” +- “这个梗是不是最近一直在延续?” +- “今天谁的表现和平时反差最大?” + +### 19.4 后续功能拓展空间更大 + +有了长期记忆底座,未来可以继续扩展: + +- 周报/月报 +- 历史事件回顾 +- 群文化档案 +- 成员成长轨迹 + +## 20. 非目标与边界 + +为了避免第一版过重,本提案明确以下内容不作为首期目标: + +- 不做跨群统一用户画像。 +- 不做全量原始消息长期缓存替代。 +- 不做复杂图数据库。 +- 不做自动判定现实身份或真实人格。 +- 不让长期画像直接决定管理动作或高风险决策。 + +## 21. 结论 + +这套记忆模块的关键,不是给插件“多塞一些历史聊天记录”,而是构建一层真正面向总结任务的认知结构。 + +对于当前插件来说,最合适的路线不是推倒重来,而是: + +- 继续保留现有的增量批次机制作为短期记忆原材料; +- 在其上增加群级长期记忆库; +- 以“群作用域 + 成员作用域”双层结构实现天然分群; +- 通过提取、更新、检索、遗忘、安全治理,形成一个可持续演化的闭环。 + +如果这套方案落地成功,插件的总结能力会从“会总结消息”提升到“理解这个群的长期叙事和人物结构”,这会是体验层面非常明显的一次升级。