From c18224cb82fa252773d2ede1fb252078bdaa1d37 Mon Sep 17 00:00:00 2001 From: clown145 Date: Mon, 20 Jul 2026 03:24:50 +0800 Subject: [PATCH] =?UTF-8?q?[v4.11.0]=20-=20=E2=9C=A8=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20QQ=20=E5=AE=98=E6=96=B9=E6=9C=BA=E5=99=A8=E4=BA=BA=E7=BE=A4?= =?UTF-8?q?=E8=81=8A=E5=88=86=E6=9E=90=E4=B8=8E=E4=B8=93=E7=94=A8=20Markdo?= =?UTF-8?q?wn=20=E6=8A=A5=E5=91=8A=20(@clown145=20#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add QQ official bot group analysis support * fix: use text progress replies for QQ official bot * fix: harden QQ official proactive reporting * feat: enhance QQ official markdown reports * refactor: isolate QQ official markdown reporting * fix: restore platform adapter exports * style: format QQ official changes * fix(main): 添加 html_render 类型声明和 Callable 导入 为 self.html_render 属性添加显式类型声明,解决定时任务中可能因缺少类型注解导致的隐式错误。 * fix(analysis): 消除 execute_daily_analysis 中重复的 bot_self_ids 赋值 去除第二次冗余的 config_manager.get_bot_self_ids() 调用,直接复用已获取的变量。 * cleanup(domain): 移除未使用的分析器适配器和死代码服务文件 删除 golden_quote_analyzer.py、topic_analyzer.py、user_title_analyzer.py(均为未被引用的接口+适配器包装)report_generator.py(旧的文本报告生成器)statistics_calculator.py(与 StatisticsService 功能重复)更新 domain/services/__init__.py 移除对上述文件的引用。 * cleanup(domain): 移除冗余的实体和值对象文件,收敛数据模型至 domain/models/data_models.py 删除 analysis_result.py(与 data_models.py 重复的 SummaryTopic/UserTitle/GoldenQuote 等类定义)value_objects/golden_quote.py、statistics.py、topic.py、user_title.py(均为未被引用的 frozen dataclass 迁移残留)。所有活跃代码已统一导入 domain/models/data_models.py。 * refactor(llm): 抽取 _make_session_id 辅助方法消除重复代码 在 5 个方法(analyze_topics、analyze_user_titles、analyze_golden_quotes、analyze_all_concurrent、analyze_incremental_concurrent)中出现完全相同的 datetime.now().strftime(...) + umo 拼接逻辑,已提取为 _make_session_id 静态方法。 * refactor(message): 正则表达式提升为模块级常量避免重复编译 DISCORD_CUSTOM_EMOJI_PATTERN 和 COMMAND_PATTERN 从类属性移至模块级常量,避免每次实例化 MessageCleanerService 时重新编译正则。 * arch(domain): 定义 IActivityVisualizer 接口并通过依赖注入消除领域层反向依赖 创建 IActivityVisualizer 接口于 domain/repositories/visualization_repository.py,StatisticsService 改由依赖注入接收该接口;ActivityVisualizer 继承接口。消除原 StatisticsService 直接 import infrastructure.visualization 的 DDD 违规。 * fix(main): 补全 Callable 导入和 html_render 类型声明 初次提交(877adb6)因 git add -p 交互式分块时 BOM 导致错误的 hunk 被暂存,Callable 导入和 html_render 类型声明丢失。本次补全这两项修改。 * fix(platform): 补充 QQOfficialAdapter、TelegramAdapter、DiscordAdapter 导出 * refactor(main): 提取内嵌的文本报告生成/发送函数为独立类方法 将 _send_analysis_report 方法中的 generate_text_reports() 和 send_text_reports() 内嵌异步函数提取为 _generate_text_reports 和 _send_text_reports 私有方法。减少闭包复杂度,提升可维护性。 * cleanup(config): 移除废弃的 get_qq_official_t2i_activity_histogram_enabled 向后兼容方法 删除 config_manager.py 中的旧名别名方法,简化 qq_official_markdown.py 中对应的 getattr 回退逻辑为直接方法调用,移除测试中专门验证旧名兼容性的 LegacyDisabledConfig 测试用例。该兼容层仅在迁移期间临时存在,现已完成过渡。 * fix(types): 修复 Pylance 类型告警 — platform_key 未绑定、int(object) 和 template.filename 可能为 None platform_group_registry.py: 将 platform_key 定义提前,消除 Pylance reportPossiblyUnboundVariable 告警。 qq_official_markdown.py: 将 int(value or 0) 改为 int(value) if value is not None else 0,消除 reportArgumentType 告警。 templates.py: 在使用 template.filename 前增加 None 检查,消除 str|None 不可分配给 str 的告警。 qq_official_adapter.py: 为 post_group_message 添加类型忽略注解,消除 await 不可等待对象的告警。 * fix(types): 修复残留的 Pylance 类型告警 platform_group_registry.py: 将 (platform_key, group_id) 改为 (str(platform_key), group_id),消除 str|None 不可分配给 str 的 reportArgumentType qq_official_markdown.py: int(value) 添加 # type: ignore[arg-type],消除 object 不可分配给 ConvertibleToInt qq_official_adapter.py: 移除无意义的中间变量,await 行直接添加 # type: ignore[arg-type] * docs(message): 更新 MessageProcessingService 的文档和注释,消除 Telegram 特殊性表述 类 docstring:移除 '维护 Telegram 群组注册表' 等过时描述,补充 QQ 官方去重职责。 group_registry.upsert 注释:改为泛化的跨平台描述,不再限定 Telegram。 _extract_event_timestamp / _reserve_event_id / _commit_event_id / _release_event_id:英文 docstring 统一为中文。 * docs(message): 修正类注释中只提 QQ 官方的问题,明示 Telegram 也由本服务处理 Telegram 和 QQ 官方消息都经过 MessageProcessingService.process_message()。修正前类 docstring 只提了 QQ 官方的事件去重,缺少 Telegram 作为主要调用者的说明。 * fix(types): 修正 _sanitize_analysis_result_for_export 返回类型注解 函数声明 -> dict 但 _sanitize_export_identity_text 可返回 str|dict|list,Pylance 报 reportReturnType。 改为 -> dict[str, Any] 准确表达实际返回类型,补充缺失的 from typing import Any。 * fix(types): 抑制 Pylance reportReturnType 误报 _sanitize_analysis_result_for_export 的 analysis_result 参数运行时始终为 dict,但 _to_plain_export_data 递归返回 Any 导致 Pylance 推导出 str|dict|list 联合类型。 添加 # type: ignore[return-type] 抑制此误报。 * fix(report): 渲染匿名模式下的未知引用 token 不再静默丢弃 当 hide_user_names=True 且 [id] 不在 known_ids 中时,之前返回空 Markup 导致 token 被静默移除,可能扭曲文本语义。改为返回转义后的原始 [id] 字符串,保留文本布局和语义,同时不泄露身份信息。 参考 Sourcery AI code review 建议。 * fix(import): 避免从 astrbot.core 直接导入 File,优先使用公开 API astrbot.core 是内部模块,不在公开 API 契约中,可能随版本变化。 改为 try 优先导入 astrbot.api.message_components.File(公开 API), 失败时回退到 astrbot.core.message.components.File(向后兼容)。 同时修复 main.py 和 qq_official_adapter.py 两处导入。 * fix(import): 回退 try/except 伪装,改为直接导入 + 风险注释 astrbot.api.message_components 不存在,try/except 永远走 except 分支,是无效代码。 改为直接 from astrbot.core.message.components import File,用注释说明这是内部 API 可能变化。 * fix(ruff): 代码质量 * docs(README): 调整文档说明,删除 lark 相关的描述和功能标识 * docs(desc): 更新 desc * fix(message): 将 TG 和 QQ 官方消息缓存成功日志降为 debug * perf(message): 将 _extract_event_timestamp 延迟到 QQ 官方分支内计算 该时间戳仅用于 QQ 官方消息的 history_content 元数据,但对所有平台都执行了深度 getattr 链。 改为只在 QQ 官方分支内延迟计算,消除 Telegram 等平台上每次消息的白算开销。 * chore(CHANGELOG) --------- Co-authored-by: SXP-Simon --- CHANGELOG.md | 14 +- README.md | 68 +-- _conf_schema.json | 19 +- main.py | 95 ++- metadata.yaml | 7 +- .../services/analysis_application_service.py | 1 - .../services/message_processing_service.py | 187 ++++-- src/domain/entities/__init__.py | 19 - src/domain/entities/analysis_result.py | 124 ---- src/domain/repositories/__init__.py | 2 + src/domain/repositories/report_repository.py | 2 + .../repositories/visualization_repository.py | 19 + src/domain/services/__init__.py | 23 - src/domain/services/golden_quote_analyzer.py | 131 ----- .../services/message_cleaner_service.py | 20 +- src/domain/services/report_generator.py | 245 -------- src/domain/services/statistics_calculator.py | 295 ---------- src/domain/services/statistics_service.py | 9 +- src/domain/services/topic_analyzer.py | 128 ---- src/domain/services/user_title_analyzer.py | 138 ----- src/domain/value_objects/__init__.py | 23 - src/domain/value_objects/golden_quote.py | 98 ---- .../value_objects/platform_capabilities.py | 27 + src/domain/value_objects/statistics.py | 327 ----------- src/domain/value_objects/topic.py | 96 --- src/domain/value_objects/user_title.py | 100 ---- .../analysis/analyzers/topic_analyzer.py | 15 +- src/infrastructure/analysis/llm_analyzer.py | 64 +- src/infrastructure/config/config_manager.py | 8 + .../persistence/platform_group_registry.py | 110 ++++ src/infrastructure/platform/__init__.py | 14 +- .../platform/adapters/__init__.py | 39 +- .../platform/adapters/qq_official_adapter.py | 525 +++++++++++++++++ src/infrastructure/platform/bot_manager.py | 4 + src/infrastructure/platform/factory.py | 8 + src/infrastructure/reporting/dispatcher.py | 30 +- src/infrastructure/reporting/generators.py | 287 ++++++++- .../qq_official/summary_dashboard.html | 173 ++++++ .../reporting/qq_official_markdown.py | 336 +++++++++++ src/infrastructure/reporting/templates.py | 26 + .../visualization/activity_charts.py | 3 +- tests/conftest.py | 25 + tests/test_avatar_only_reporting.py | 548 ++++++++++++++++++ tests/test_message_processing_service.py | 70 +++ tests/test_platform_group_registry.py | 46 ++ tests/test_qq_official_adapter.py | 189 ++++++ 46 files changed, 2793 insertions(+), 1944 deletions(-) delete mode 100644 src/domain/entities/analysis_result.py create mode 100644 src/domain/repositories/visualization_repository.py delete mode 100644 src/domain/services/golden_quote_analyzer.py delete mode 100644 src/domain/services/report_generator.py delete mode 100644 src/domain/services/statistics_calculator.py delete mode 100644 src/domain/services/topic_analyzer.py delete mode 100644 src/domain/services/user_title_analyzer.py delete mode 100644 src/domain/value_objects/golden_quote.py delete mode 100644 src/domain/value_objects/statistics.py delete mode 100644 src/domain/value_objects/topic.py delete mode 100644 src/domain/value_objects/user_title.py create mode 100644 src/infrastructure/persistence/platform_group_registry.py create mode 100644 src/infrastructure/platform/adapters/qq_official_adapter.py create mode 100644 src/infrastructure/reporting/platform_templates/qq_official/summary_dashboard.html create mode 100644 src/infrastructure/reporting/qq_official_markdown.py create mode 100644 tests/conftest.py create mode 100644 tests/test_avatar_only_reporting.py create mode 100644 tests/test_message_processing_service.py create mode 100644 tests/test_platform_group_registry.py create mode 100644 tests/test_qq_official_adapter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e80b867..09dbc90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,24 @@ # 更新日志 (CHANGELOG) -## [v4.10.9] - ✨ 新增模板 ”BlueArchive“ 蔚蓝档案 (@VanillaNahida) +## [v4.11.0] - ✨ 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145) + +插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。 + +* **🛠️配置注意事项 **: 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。 +* **🛠️ 适配范围**: 本次适配只覆盖普通 QQ 群,不包含频道或子频道。 +* **⚙️ 成员昵称**: 官方群事件不提供成员昵称,所以推荐使用 QQ 官方机器人的用户配置输出格式为 text (默认为 image)格式,获得更好的体验,图片格式下无法显示成员昵称。 +* **⚙️ QQ 官方 API 支持有限**: QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。 +* **✨ 分析名单配置**: 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置黑白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。 +* **✨ Markdown 报告**: QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。 +* **✨ Markdown 概览图**: Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。 ---
📋 点击查看历史更新日志 +## [v4.10.9] - ✨ 新增模板 ”BlueArchive“ 蔚蓝档案 (@VanillaNahida) + ## [v4.10.8] - 🛠️ snowluma 被禁言避免触发分析修复 (#191) ## [v4.10.7] - ✨ 被禁言避免触发分析功能浪费 token,重构并优化模型请求的重试与降级补偿机制 diff --git a/README.md b/README.md index f65649f..474faac 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ -_✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**、**Telegram**、**Discord**,未来支持更多平台。 [灵感来源](https://github.com/LSTM-Kirigaya/openmcp-tutorial/tree/main/qq-group-summary)。 ✨_ +_✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCat, LLOneBot, Snowluma)、**QQ 官方机器人**、**Telegram**、**Discord**,未来支持更多平台。 ✨_ count @@ -60,10 +60,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**

BlueArchive

BlueArchive - -

Simple

- simple - @@ -85,25 +81,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)** - **QQ群**: 支持上传到群相册和群文件,查阅黑历史友好 - **详细数据**: 包含消息统计、时间分布、关键词、金句等 -> [!warning] -> **实验性开发中**: -> - 多平台支持功能尚在开发中,当前仅支持QQ OneBot, Discord, Telegram。 -> - 旧版本稳定版在[QQ 分支](https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis/tree/QQ),仅 QQ 平台支持 - - -> [!CAUTION] -> **Discord 用户重点注意**: -> 如果机器人无法获取群列表或分析报 `403 Forbidden`,请检查 Discord 开发者面板中: -> 1. **Privileged Gateway Intents**: 开启 `Message Content Intent`。 -> 2. **频道权限**: 确保机器人所在的频道,对应的角色拥有 **“查看消息历史记录”** 权限。 - -> [!IMPORTANT] -> **Telegram 用户重点注意**: -> 1. 如果 TG Bot 不是群管理员,务必在拉入群前先关闭 BotFather 隐私模式。 -> 2. 如果 Bot 已经在群里且不是管理员,关闭隐私模式后必须先移除再重新拉入群,否则新设置不会生效。 -> -> 注:群聊隐私模式关闭流程:`@BotFather`→左下角Open→选择要调整的bot→Bot Settings→将`Group Privacy`关闭 - > [!TIP] > **图片生成失败/渲染超时的解决办法** @@ -118,7 +95,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)** > > ### 2. 使用备用 T2I 服务或自部署 >
-> 若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击展开): +> 若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击此行展开说明): > > - **Hugging Face 服务**: `https://huggingface.co/spaces/clown145/astrbot-t2i-service` > - **API 接口地址**: `https://clown145-astrbot-t2i-service.hf.space` @@ -134,26 +111,36 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)** > > **更换 T2I 端点或自部署 T2I 参考文档**:[docs.astrbot.app/others/self-host-t2i.html](https://docs.astrbot.app/others/self-host-t2i.html) +> [!warning] +> **实验性开发中**: +> - 多平台支持功能尚在开发中,当前仅支持 OneBot (NapCat, LLOneBot, Snowluma), QQ 官方机器人, Discord, Telegram。 + > [!IMPORTANT] -> -> Feishu / Lark (WIP) -> -> 尝试开发中 -> -> 通过 `LarkAdapter` 复用 AstrBot 已有的 `lark_oapi` 生态能力,在获得授权后即可对 Feishu 群聊执行完整的消息采集与分析。 -> -> - **一次性授权**:在飞书开放平台中为你的应用补齐如下权限(仅需在初次部署时授予): -> - `im:message:readonly`、`im:chat:readonly`(读取群消息/群信息) -> - `contact:contact.base:readonly`(拉取用户昵称/头像;缺少该权限会导致头像永远显示默认) -> - 发送需要的附加 scope(如 `im:message:send` / `im:message:receive_v1` / 上传相关的 `im:resource` 系列) -> - **用户头像保障**:插件在分析前会调用 `LarkAdapter.prepare_group_member_cache`,一次性批量拉取最多 100 名活跃成员的头像并在缓存中保留,让后续分析阶段不再遇到“未授权头像”。 -> - **令牌与长时运行**:飞书的 `tenant_access_token` 有 2 小时有效期,分析任务运行时间若超过此周期,请确保你的 Bot 框架会自动刷新令牌(通常是 SDK 默认行为)。 -> 读取 Feishu 配置后,按照上述授权顺序重新安装/刷新应用,就能在报告里面看到与 QQ/Telegram 一样的用户筛选、头像与 LLM 输出。 +> **QQ 官方机器人用户注意**: +> 插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。 +> - 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。 +> - QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。 +> - 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。 +> - 官方群事件不提供成员昵称。 +> - QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。 +> - Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。 +> - 本次适配只覆盖普通 QQ 群,不包含频道或子频道。 +> [!CAUTION] +> **Discord 用户重点注意**: +> 如果机器人无法获取群列表或分析报 `403 Forbidden`,请检查 Discord 开发者面板中: +> 1. **Privileged Gateway Intents**: 开启 `Message Content Intent`。 +> 2. **频道权限**: 确保机器人所在的频道,对应的角色拥有 **“查看消息历史记录”** 权限。 +> [!IMPORTANT] +> **Telegram 用户重点注意**: +> 1. 如果 TG Bot 不是群管理员,务必在拉入群前先关闭 BotFather 隐私模式。 +> 2. 如果 Bot 已经在群里且不是管理员,关闭隐私模式后必须先移除再重新拉入群,否则新设置不会生效。 +> +> 注:群聊隐私模式关闭流程:`@BotFather`→左下角Open→选择要调整的bot→Bot Settings→将`Group Privacy`关闭 ### 🛠️ 灵活配置 -- **多平台支持**: 自动识别并适配 OneBot, Discord, Telegram 等平台 +- **多平台支持**: 自动识别并适配 OneBot, QQ 官方机器人, Discord, Telegram 等平台 - **群组管理**: 支持指定特定群组启用功能(支持跨平台黑白名单) - **参数调节**: 可自定义分析天数、消息数量等参数 - **定时任务**: 支持设置每日自动分析时间 @@ -284,6 +271,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)** | 平台 | 适配器类型 | 特殊要求/说明 | |------|-----------|--------------| | **QQ** | OneBot v11 | 建议使用 NapCat/Lagrange。需注意消息分页拉取限制。 | +| **QQ 官方机器人** | QQ Bot API v2(WebSocket/Webhook) | 需开启群全量消息;只分析启用后实时缓存的消息;图片/HTML 仅显示头像,Markdown 文本使用成员艾特。 | | **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 | | **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 | diff --git a/_conf_schema.json b/_conf_schema.json index 4a8439f..0a9738b 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -19,7 +19,7 @@ "type": "list", "description": "群组白/黑名单列表", "default": [], - "hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号(如 123456)。新手建议优先填完整会话ID,更不容易填错。可用 /sid 获取当前会话ID。", + "hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号。QQ 官方机器人使用 group_openid,不是群号,务必优先通过 /sid 获取并填写完整 UMO。", "items": { "type": "string" } @@ -28,7 +28,7 @@ "type": "int", "description": "默认分析天数", "default": 1, - "hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况" + "hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况。QQ 官方 API 无历史拉取接口,只能分析插件启用后实时缓存到 AstrBot 本地库的消息。" }, "max_messages": { "type": "int", @@ -66,7 +66,7 @@ "html" ], "default": "image", - "hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)" + "hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方群事件没有昵称,图片/HTML 报告仅显示头像,文本报告由独立 Markdown 模块使用艾特展示身份;其他平台保持原有昵称文本格式。" }, "report_template": { "type": "string", @@ -140,6 +140,19 @@ } } }, + "qq_official": { + "description": "QQ 官方机器人", + "type": "object", + "hint": "仅作用于 QQ 官方机器人及 QQ 官方 Webhook 的报告展示。", + "items": { + "enable_t2i_activity_histogram": { + "type": "bool", + "description": "启用 T2I 群聊概览图", + "default": true, + "hint": "在 QQ 官方 Markdown 报告中嵌入透明背景的日期、基础统计与 24 小时直方图。渲染失败时自动回退为完整文字报告。" + } + } + }, "t2i_rendering": { "description": "图片渲染策略", "type": "object", diff --git a/main.py b/main.py index 8d4d1eb..81da738 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ import asyncio import os -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from pathlib import Path from astrbot.api import AstrBotConfig @@ -15,6 +15,8 @@ from astrbot.api import logger as astrbot_logger from astrbot.api.event import AstrMessageEvent, filter from astrbot.api.event.filter import PermissionType from astrbot.api.star import Context, Star, StarTools + +# File is only available via astrbot.core (internal API — may change). from astrbot.core.message.components import File from .src.application.commands.template_command_service import ( @@ -35,8 +37,8 @@ from .src.infrastructure.config.config_manager import ConfigManager from .src.infrastructure.messaging.message_sender import MessageSender from .src.infrastructure.persistence.history_manager import HistoryManager from .src.infrastructure.persistence.incremental_store import IncrementalStore -from .src.infrastructure.persistence.telegram_group_registry import ( - TelegramGroupRegistry, +from .src.infrastructure.persistence.platform_group_registry import ( + PlatformGroupRegistry, ) from .src.infrastructure.platform.bot_manager import BotManager from .src.infrastructure.platform.template_preview import ( @@ -45,6 +47,7 @@ from .src.infrastructure.platform.template_preview import ( ) from .src.infrastructure.reporting.generators import ReportGenerator from .src.infrastructure.scheduler.auto_scheduler import AutoScheduler +from .src.infrastructure.visualization.activity_charts import ActivityVisualizer from .src.shared.constants import PLUGIN_NAME from .src.shared.trace_context import TraceContext, TraceLogFilter from .src.utils.logger import logger @@ -60,7 +63,8 @@ class GroupDailyAnalysis(Star): bot_manager: BotManager history_manager: HistoryManager report_generator: ReportGenerator - telegram_group_registry: TelegramGroupRegistry + html_render: Callable + platform_group_registry: PlatformGroupRegistry statistics_service: StatisticsService analysis_domain_service: AnalysisDomainService llm_analyzer: LLMAnalyzer @@ -90,10 +94,11 @@ class GroupDailyAnalysis(Star): self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir) # Telegram 注册表 (持久层) - self.telegram_group_registry = TelegramGroupRegistry(self) + self.platform_group_registry = PlatformGroupRegistry(self) # 2. 领域层 - self.statistics_service = StatisticsService() + activity_visualizer = ActivityVisualizer() + self.statistics_service = StatisticsService(activity_visualizer) self.analysis_domain_service = AnalysisDomainService() # 3. 分析核心 (LLM Bridge) @@ -118,7 +123,7 @@ class GroupDailyAnalysis(Star): # 消息处理服务 self.message_processing_service = MessageProcessingService( - context, self.telegram_group_registry + context, self.platform_group_registry ) self.template_command_service = TemplateCommandService( plugin_root=os.path.dirname(__file__) @@ -287,11 +292,43 @@ class GroupDailyAnalysis(Star): except Exception as e: logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True) + @filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE) + @filter.platform_adapter_type( + filter.PlatformAdapterType.QQOFFICIAL + | filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK + ) + async def intercept_qq_official_messages(self, event: AstrMessageEvent): + """缓存 QQ 官方机器人群消息;频道消息不在本插件适配范围内。""" + raw_message = getattr(getattr(event, "message_obj", None), "raw_message", None) + if isinstance(raw_message, dict): + author = raw_message.get("author") or {} + group_openid = str(raw_message.get("group_openid", "") or "").strip() + member_openid = str( + author.get("member_openid", "") if isinstance(author, dict) else "" + ).strip() + else: + author = getattr(raw_message, "author", None) + group_openid = str(getattr(raw_message, "group_openid", "") or "").strip() + member_openid = str(getattr(author, "member_openid", "") or "").strip() + if not group_openid or not member_openid: + return + + try: + await self.message_processing_service.process_message(event) + except (ValueError, RuntimeError) as e: + logger.warning(f"[QQOfficial] 消息存储失败: {e}") + except Exception as e: + logger.error(f"[QQOfficial] 消息存储异常: {e}", exc_info=True) + async def get_telegram_seen_group_ids( self, platform_id: str | None = None ) -> list[str]: """读取 Telegram 已见群/话题列表(给调度器回退使用)。""" - return await self.telegram_group_registry.get_all_group_ids(platform_id) + return await self.platform_group_registry.get_all_group_ids(platform_id) + + async def get_seen_group_ids(self, platform_id: str | None = None) -> list[str]: + """读取任意事件驱动平台已经见过的群组。""" + return await self.platform_group_registry.get_all_group_ids(platform_id) def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None: """从消息事件中安全获取群组 ID""" @@ -517,7 +554,15 @@ class GroupDailyAnalysis(Star): # 表情回应 或 文本提示(二选一,由配置开关控制) adapter = self.bot_manager.get_adapter(platform_id) orig_msg_id = getattr(event.message_obj, "message_id", None) - use_text_reply = self.config_manager.get_enable_analysis_reply() + adapter_platform_name = ( + (adapter.get_platform_name() if adapter else "").strip().lower() + ) + # QQ 官方机器人 API v2 不支持本插件使用的表情回应接口, + # 因此始终沿用原有的文字进度提示,避免触发无效的 reaction 请求。 + use_text_reply = ( + adapter_platform_name in {"qq_official", "qq_official_webhook"} + or self.config_manager.get_enable_analysis_reply() + ) if use_text_reply: yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...") @@ -577,6 +622,7 @@ class GroupDailyAnalysis(Star): analysis_result = result["analysis_result"] adapter = result["adapter"] output_format = self.config_manager.get_output_format() + hide_user_names = adapter.get_platform_name() == "qq_official" # 定义获取回调 async def avatar_url_getter(user_id: str) -> str | None: @@ -599,6 +645,7 @@ class GroupDailyAnalysis(Star): avatar_url_getter=avatar_url_getter, nickname_getter=nickname_getter, avatar_cache_namespace=platform_id, + hide_user_names=hide_user_names, ) if image_url: @@ -610,8 +657,9 @@ class GroupDailyAnalysis(Star): # 如果图片生成或发送失败,直接回退到文本 logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}") - text_report = self.report_generator.generate_text_report(analysis_result) - await adapter.send_text_report(group_id, text_report) + await self._send_text_reports( + group_id, analysis_result, hide_user_names, adapter + ) return elif output_format == "html": @@ -621,6 +669,7 @@ class GroupDailyAnalysis(Star): avatar_url_getter=avatar_url_getter, nickname_getter=nickname_getter, avatar_cache_namespace=platform_id, + hide_user_names=hide_user_names, ) if html_path: is_only_url = self.config_manager.get_html_only_url() @@ -681,8 +730,28 @@ class GroupDailyAnalysis(Star): yield event.plain_result("⚠️ HTML 生成失败。") else: - text_report = self.report_generator.generate_text_report(analysis_result) - await adapter.send_text_report(group_id, text_report) + await self._send_text_reports( + group_id, analysis_result, hide_user_names, adapter + ) + + async def _generate_text_reports( + self, analysis_result: dict, hide_user_names: bool + ) -> tuple[str, str | None]: + """Generate text or QQ-official-markdown reports.""" + if hide_user_names: + return await self.report_generator.generate_qq_official_markdown_report( + analysis_result, self.html_render + ) + return self.report_generator.generate_text_report(analysis_result), None + + async def _send_text_reports( + self, group_id: str, analysis_result: dict, hide_user_names: bool, adapter + ) -> bool: + """Send text reports via platform adapter.""" + tr, fr = await self._generate_text_reports(analysis_result, hide_user_names) + if hide_user_names: + return await adapter.send_text_report(group_id, tr, fallback_content=fr) + return await adapter.send_text_report(group_id, tr) @filter.command("设置格式", alias={"set_format"}) @filter.permission_type(PermissionType.ADMIN) diff --git a/metadata.yaml b/metadata.yaml index 97f9048..ed62732 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,12 +1,13 @@ name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。 display_name: 群分析总结插件 # 插件的显示名称 -desc: "[多平台接入开发中] 群日常分析总结插件 - 支持 QQ (aiocqhttp)、Telegram、Discord 以及 Feishu (Lark);生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述 -version: v4.10.9 # 插件版本号。格式:v1.1.1 或者 v1.1 +desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述 +version: v4.11.0 # 插件版本号。格式:v1.1.1 或者 v1.1 author: SXP-Simon # 作者 astrbot_version: ">=4.16.0" support_platforms: - aiocqhttp - discord - telegram - - lark + - qq_official + - qq_official_webhook repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址 diff --git a/src/application/services/analysis_application_service.py b/src/application/services/analysis_application_service.py index 67bb8b5..32afe0d 100644 --- a/src/application/services/analysis_application_service.py +++ b/src/application/services/analysis_application_service.py @@ -216,7 +216,6 @@ class AnalysisApplicationService: ) # 4. 用户分析 (Domain Service) - bot_self_ids = self.config_manager.get_bot_self_ids() user_activity = await asyncio.to_thread( self.analysis_domain_service.analyze_user_activity, unified_messages, diff --git a/src/application/services/message_processing_service.py b/src/application/services/message_processing_service.py index 3dddeea..c87fba8 100644 --- a/src/application/services/message_processing_service.py +++ b/src/application/services/message_processing_service.py @@ -1,10 +1,10 @@ import re -from collections import Counter +from collections import Counter, OrderedDict from astrbot.api.event import AstrMessageEvent from astrbot.api.star import Context -from ...infrastructure.persistence.telegram_group_registry import TelegramGroupRegistry +from ...infrastructure.persistence.platform_group_registry import PlatformGroupRegistry from ...utils.logger import logger @@ -12,27 +12,36 @@ class MessageProcessingService: """ 消息处理服务 - 负责处理接收到的消息事件: + 解析收到的群消息事件,提取内容与发送者信息,持久化历史记录, + 并维护事件驱动平台(Telegram、QQ 官方等)的群组注册表。 + QQ 官方平台特有的重复消息去重逻辑也在本服务中处理。 + + 职责: 1. 解析消息内容(文本、图片、@提及等) - 2. 解析发送者信息(跨平台兼容) + 2. 解析发送者展示名(跨平台兼容) 3. 存储消息历史 - 4. 维护 Telegram 群组注册表(回退机制) + 4. 维护群组注册表,供调度器做群组发现(Telegram、QQ 官方等事件驱动平台) + 5. QQ 官方事件消息去重(按 message_id 预占 + 确认机制) """ - def __init__(self, context: Context, telegram_registry: TelegramGroupRegistry): + def __init__(self, context: Context, group_registry: PlatformGroupRegistry): self.context = context - self.telegram_registry = telegram_registry + self.group_registry = group_registry + self._seen_event_ids: OrderedDict[str, None] = OrderedDict() + self._inflight_event_ids: set[str] = set() + self._seen_event_ids_limit = 4096 async def process_message(self, event: AstrMessageEvent) -> None: """ 处理并在历史记录中存储消息。 + 被 main.py 的 Telegram 和 QQ 官方消息拦截器共同调用。 - Args: - event: AstrBot 消息事件 + Args: + event: AstrBot 消息事件 - Raises: - ValueError: 当必要数据无法获取时 - RuntimeError: 当消息内容为空时 + Raises: + ValueError: 当必要数据无法获取时 + RuntimeError: 当消息内容为空时 """ # 1. 获取群组 ID(必需) group_id = self._get_group_id_from_event(event) @@ -62,37 +71,63 @@ class MessageProcessingService: f"群 {group_id}: 消息内容为空 (sender={sender_name}),拒绝存储" ) - # 6. 提取事件消息 ID(用于 Telegram 已见群/话题记录) + # 6. 提取事件消息 ID 和事件时间 msg_obj = getattr(event, "message_obj", None) event_message_id = str(getattr(msg_obj, "message_id", "") or "") + platform_name = str(event.get_platform_name() or "").strip().lower() + reserved_event_id = False + if platform_name in {"qq_official", "qq_official_webhook"} and event_message_id: + reserved_event_id = self._reserve_event_id(event_message_id) + if not reserved_event_id: + logger.debug("[QQOfficial] 跳过重复消息事件: %s", event_message_id) + return + history_content = { + "type": "user", + "message": message_parts, + } + if platform_name in {"qq_official", "qq_official_webhook"}: + event_timestamp = self._extract_event_timestamp(msg_obj) + history_content["_qq_official"] = { + "message_id": event_message_id, + "timestamp": event_timestamp, + } + # 7. 存储到数据库 - await self.context.message_history_manager.insert( - platform_id=platform_id, - user_id=group_id, - content={"type": "user", "message": message_parts}, - sender_id=sender_id, - sender_name=sender_name, - ) + try: + await self.context.message_history_manager.insert( + platform_id=platform_id, + user_id=group_id, + content=history_content, + sender_id=sender_id, + sender_name=sender_name, + ) + except BaseException: + if reserved_event_id: + self._release_event_id(event_message_id) + raise + else: + if reserved_event_id: + self._commit_event_id(event_message_id) - # Telegram: 记录已见群/话题 - if self._is_telegram_event(event, platform_id): - try: - await self.telegram_registry.upsert( - platform_id=platform_id, - group_id=group_id, - sender_id=sender_id, - sender_name=sender_name, - event_message_id=event_message_id, - ) - except Exception as e: - logger.warning( - "[TGRegistry] Upsert failed: " - f"platform_id={platform_id} group_id={group_id} error={e}" - ) + # Register the group so the scheduler can discover platforms that + # do not provide a group-list API (Telegram, QQ Official, etc.). + try: + await self.group_registry.upsert( + platform_id=platform_id, + group_id=group_id, + sender_id=sender_id, + sender_name=sender_name, + event_message_id=event_message_id, + ) + except Exception as e: + logger.warning( + "[GroupRegistry] Upsert failed: " + f"platform_id={platform_id} group_id={group_id} error={e}" + ) - logger.info( - f"[Telegram] [{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})" + logger.debug( + f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})" ) def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None: @@ -208,6 +243,24 @@ class MessageProcessingService: } ) + elif seg_type in ("File", "file"): + url = getattr(seg, "url", None) or getattr(seg, "file_", None) + message_parts.append( + { + "type": "file", + "url": str(url or ""), + "name": str(getattr(seg, "name", "") or ""), + } + ) + + elif seg_type in ("Record", "record", "voice"): + url = getattr(seg, "url", None) or getattr(seg, "file", None) + message_parts.append({"type": "voice", "url": str(url or "")}) + + elif seg_type in ("Video", "video"): + url = getattr(seg, "url", None) or getattr(seg, "file", None) + message_parts.append({"type": "video", "url": str(url or "")}) + if not message_parts and event.message_str: message_parts.append({"type": "plain", "text": event.message_str}) @@ -261,9 +314,57 @@ class MessageProcessingService: return normalized == str(sender_id).strip() @staticmethod - def _is_telegram_event(event: AstrMessageEvent, platform_id: str) -> bool: - """判断当前事件是否为 Telegram 平台""" - platform_name = str(event.get_platform_name() or "").strip().lower() - if platform_name == "telegram": - return True - return str(platform_id or "").strip().lower().startswith("telegram") + def _extract_event_timestamp(message_obj: object) -> int: + """从消息对象中提取平台事件时间戳。""" + raw_message = getattr(message_obj, "raw_message", None) + if isinstance(raw_message, dict): + candidate = raw_message.get("timestamp") + if not candidate: + raw_data = raw_message.get("raw_data") + if isinstance(raw_data, dict): + candidate = raw_data.get("timestamp") + else: + raw_data = getattr(raw_message, "raw_data", None) + candidate = getattr(raw_message, "timestamp", None) + if not candidate and isinstance(raw_data, dict): + candidate = raw_data.get("timestamp") + if isinstance(candidate, (int, float)): + return int(candidate) + if candidate: + try: + from datetime import datetime + + return int( + datetime.fromisoformat( + str(candidate).replace("Z", "+00:00") + ).timestamp() + ) + except (TypeError, ValueError, OverflowError): + pass + return 0 + + def _reserve_event_id(self, event_message_id: str) -> bool: + """预占事件消息 ID:在历史记录持久化期间防止重复入库。""" + if ( + event_message_id in self._inflight_event_ids + or event_message_id in self._seen_event_ids + ): + if event_message_id in self._seen_event_ids: + self._seen_event_ids.move_to_end(event_message_id) + return False + self._inflight_event_ids.add(event_message_id) + return True + + def _commit_event_id(self, event_message_id: str) -> None: + """确认事件消息 ID:标记为已持久化,纳入后续去重。""" + self._inflight_event_ids.discard(event_message_id) + if event_message_id in self._seen_event_ids: + self._seen_event_ids.move_to_end(event_message_id) + else: + self._seen_event_ids[event_message_id] = None + if len(self._seen_event_ids) > self._seen_event_ids_limit: + self._seen_event_ids.popitem(last=False) + + def _release_event_id(self, event_message_id: str) -> None: + """释放事件消息 ID:持久化失败或取消时清理预占状态。""" + self._inflight_event_ids.discard(event_message_id) diff --git a/src/domain/entities/__init__.py b/src/domain/entities/__init__.py index df5dd89..80631e6 100644 --- a/src/domain/entities/__init__.py +++ b/src/domain/entities/__init__.py @@ -3,35 +3,16 @@ 该模块导出所有领域实体类,包括: - AnalysisTask: 分析任务聚合根 -- GroupAnalysisResult: 群聊分析结果实体 - IncrementalBatch: 增量分析独立批次实体 - IncrementalState: 增量分析聚合视图(报告时使用) """ -from .analysis_result import ( - ActivityVisualization, - EmojiStatistics, - GoldenQuote, - GroupAnalysisResult, - GroupStatistics, - SummaryTopic, - TokenUsage, - UserTitle, -) from .analysis_task import AnalysisTask, TaskStatus from .incremental_state import IncrementalBatch, IncrementalState __all__ = [ "AnalysisTask", "TaskStatus", - "GroupAnalysisResult", - "SummaryTopic", - "UserTitle", - "GoldenQuote", - "TokenUsage", - "EmojiStatistics", - "ActivityVisualization", - "GroupStatistics", "IncrementalBatch", "IncrementalState", ] diff --git a/src/domain/entities/analysis_result.py b/src/domain/entities/analysis_result.py deleted file mode 100644 index 605fcfd..0000000 --- a/src/domain/entities/analysis_result.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -群聊分析结果实体 -""" - -import time -import uuid -from dataclasses import dataclass, field - - -@dataclass -class SummaryTopic: - """话题摘要""" - - topic: str - contributors: list[str] - detail: str - - -@dataclass -class UserTitle: - """用户称号/画像""" - - name: str - user_id: str - title: str - mbti: str - reason: str - avatar_url: str | None = None - avatar_data: str | None = None - - -@dataclass -class GoldenQuote: - """金句""" - - content: str - sender: str - reason: str - user_id: str = "" - avatar_url: str | None = None - - -@dataclass -class TokenUsage: - """令牌使用统计""" - - prompt_tokens: int = 0 - completion_tokens: int = 0 - total_tokens: int = 0 - - -@dataclass -class EmojiStatistics: - """表情统计""" - - face_count: int = 0 - mface_count: int = 0 - bface_count: int = 0 - sface_count: int = 0 - other_emoji_count: int = 0 - face_details: dict = field(default_factory=dict) - - @property - def total_emoji_count(self) -> int: - return ( - self.face_count - + self.mface_count - + self.bface_count - + self.sface_count - + self.other_emoji_count - ) - - -@dataclass -class ActivityVisualization: - """活动可视化数据""" - - hourly_activity: dict = field(default_factory=dict) - daily_activity: dict = field(default_factory=dict) - user_activity_ranking: list = field(default_factory=list) - peak_hours: list = field(default_factory=list) - activity_heatmap_data: dict = field(default_factory=dict) - - -@dataclass -class GroupStatistics: - """群组统计""" - - message_count: int = 0 - total_characters: int = 0 - participant_count: int = 0 - most_active_period: str = "" - emoji_count: int = 0 - emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics) - activity_visualization: ActivityVisualization = field( - default_factory=ActivityVisualization - ) - - -@dataclass -class GroupAnalysisResult: - """群聊分析结果实体""" - - id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) - group_id: str = "" - group_name: str = "" - trace_id: str = "" - platform: str = "" - - # 分析结果 - message_count: int = 0 - statistics: GroupStatistics = field(default_factory=GroupStatistics) - topics: list[SummaryTopic] = field(default_factory=list) - user_titles: list[UserTitle] = field(default_factory=list) - golden_quotes: list[GoldenQuote] = field(default_factory=list) - - # 元数据 - token_usage: TokenUsage = field(default_factory=TokenUsage) - analysis_date: str = "" - created_at: float = field(default_factory=time.time) - - def has_content(self) -> bool: - """检查结果是否有分析内容""" - return bool(self.topics or self.user_titles or self.golden_quotes) diff --git a/src/domain/repositories/__init__.py b/src/domain/repositories/__init__.py index 3f83006..ea243c0 100644 --- a/src/domain/repositories/__init__.py +++ b/src/domain/repositories/__init__.py @@ -1,10 +1,12 @@ # 仓储接口 from .avatar_repository import IAvatarRepository from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender +from .visualization_repository import IActivityVisualizer __all__ = [ "IMessageRepository", "IMessageSender", "IGroupInfoRepository", "IAvatarRepository", + "IActivityVisualizer", ] diff --git a/src/domain/repositories/report_repository.py b/src/domain/repositories/report_repository.py index 1373e9f..9491aaa 100644 --- a/src/domain/repositories/report_repository.py +++ b/src/domain/repositories/report_repository.py @@ -21,6 +21,7 @@ class IReportGenerator(ABC): avatar_url_getter: Any = None, nickname_getter: Any = None, avatar_cache_namespace: str | None = None, + hide_user_names: bool = False, ) -> tuple[str | None, str | None]: """生成图片报告""" pass @@ -33,6 +34,7 @@ class IReportGenerator(ABC): avatar_url_getter: Any = None, nickname_getter: Any = None, avatar_cache_namespace: str | None = None, + hide_user_names: bool = False, ) -> tuple[str | None, str | None]: """生成 HTML 报告""" pass diff --git a/src/domain/repositories/visualization_repository.py b/src/domain/repositories/visualization_repository.py new file mode 100644 index 0000000..bfcb6a5 --- /dev/null +++ b/src/domain/repositories/visualization_repository.py @@ -0,0 +1,19 @@ +""" +可视化仓储接口 - 领域层 +定义活跃度可视化的抽象契约。 +""" + +from abc import ABC, abstractmethod + +from ..models.data_models import ActivityVisualization + + +class IActivityVisualizer(ABC): + """活跃度可视化接口 - 领域层抽象""" + + @abstractmethod + def generate_activity_visualization( + self, messages: list[dict] + ) -> ActivityVisualization: + """从消息列表生成活跃度可视化数据""" + pass diff --git a/src/domain/services/__init__.py b/src/domain/services/__init__.py index 8379c7e..1edbc6b 100644 --- a/src/domain/services/__init__.py +++ b/src/domain/services/__init__.py @@ -3,33 +3,10 @@ 该模块导出所有封装核心业务逻辑的领域服务, 用于分析群聊数据。这些服务是平台无关的。 - -服务分类: -- 分析器服务: 话题分析、用户称号分析、金句分析 -- 计算服务: 统计计算 -- 生成服务: 报告生成 """ -from .golden_quote_analyzer import GoldenQuoteAnalyzerAdapter, IGoldenQuoteAnalyzer from .incremental_merge_service import IncrementalMergeService -from .report_generator import ReportGenerator -from .statistics_calculator import StatisticsCalculator -from .topic_analyzer import ITopicAnalyzer, TopicAnalyzerAdapter -from .user_title_analyzer import IUserTitleAnalyzer, UserTitleAnalyzerAdapter __all__ = [ - # 统计与报告服务 - "StatisticsCalculator", - "ReportGenerator", - # 增量合并服务 "IncrementalMergeService", - # 话题分析服务 - "ITopicAnalyzer", - "TopicAnalyzerAdapter", - # 用户称号分析服务 - "IUserTitleAnalyzer", - "UserTitleAnalyzerAdapter", - # 金句分析服务 - "IGoldenQuoteAnalyzer", - "GoldenQuoteAnalyzerAdapter", ] diff --git a/src/domain/services/golden_quote_analyzer.py b/src/domain/services/golden_quote_analyzer.py deleted file mode 100644 index 6cd9a5f..0000000 --- a/src/domain/services/golden_quote_analyzer.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -金句分析领域服务 - -该模块提供平台无关的金句分析服务接口。 -实际分析逻辑委托给 infrastructure 层的具体实现。 - -架构说明: -- 本文件定义领域服务接口和数据转换逻辑 -- 具体的 LLM 调用和消息处理在 src/analysis/analyzers/golden_quote_analyzer.py 中实现 -- 采用渐进式迁移策略,保持与现有代码的兼容性 -""" - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -from ..value_objects.golden_quote import GoldenQuote -from ..value_objects.unified_message import UnifiedMessage - -if TYPE_CHECKING: - from ..value_objects.statistics import TokenUsage - - -class IGoldenQuoteAnalyzer(ABC): - """ - 金句分析服务接口 - - 定义平台无关的金句分析契约。 - 所有平台的金句分析都应该实现此接口。 - """ - - @abstractmethod - async def analyze( - self, - messages: list[UnifiedMessage], - unified_msg_origin: str = None, - ) -> tuple[list[GoldenQuote], "TokenUsage"]: - """ - 分析消息中的金句 - - 参数: - messages: 统一格式的消息列表 - unified_msg_origin: 消息来源标识,用于选择 LLM 提供商 - - 返回: - (金句列表, Token 使用统计) - """ - pass - - -class GoldenQuoteAnalyzerAdapter(IGoldenQuoteAnalyzer): - """ - 金句分析服务适配器 - - 将现有的 GoldenQuoteAnalyzer 实现适配为领域服务接口。 - 负责 UnifiedMessage 与原始消息格式之间的转换。 - """ - - def __init__(self, legacy_analyzer): - """ - 初始化适配器 - - 参数: - legacy_analyzer: 现有的 GoldenQuoteAnalyzer 实例 - """ - self._analyzer = legacy_analyzer - - async def analyze( - self, - messages: list[UnifiedMessage], - unified_msg_origin: str = None, - ) -> tuple[list[GoldenQuote], "TokenUsage"]: - """ - 分析消息中的金句 - - 将 UnifiedMessage 转换为原始格式,调用现有分析器, - 然后将结果转换为领域值对象。 - - 参数: - messages: 统一格式的消息列表 - unified_msg_origin: 消息来源标识 - - 返回: - (金句列表, Token 使用统计) - """ - # 将 UnifiedMessage 转换为原始消息格式 - raw_messages = [self._to_raw_message(msg) for msg in messages] - - # 调用现有分析器 - legacy_quotes, token_usage = await self._analyzer.analyze_golden_quotes( - raw_messages, unified_msg_origin - ) - - # 将结果转换为领域值对象 - quotes = [ - GoldenQuote( - content=q.content, - sender_name=q.sender, - sender_id=str(q.user_id) - if hasattr(q, "user_id") and q.user_id - else None, - reason=q.reason, - ) - for q in legacy_quotes - ] - - return quotes, token_usage - - def _to_raw_message(self, msg: UnifiedMessage) -> dict: - """ - 将 UnifiedMessage 转换为原始消息格式 - - 参数: - msg: 统一消息对象 - - 返回: - 原始消息字典 - """ - # 构建消息内容列表 - message_content = [] - if msg.text_content: - message_content.append({"type": "text", "data": {"text": msg.text_content}}) - - return { - "message_id": msg.message_id, - "time": int(msg.timestamp.timestamp()) if msg.timestamp else 0, - "sender": { - "user_id": msg.sender_id, - "nickname": msg.sender_name, - }, - "message": message_content, - } diff --git a/src/domain/services/message_cleaner_service.py b/src/domain/services/message_cleaner_service.py index 0d4f59f..156bac8 100644 --- a/src/domain/services/message_cleaner_service.py +++ b/src/domain/services/message_cleaner_service.py @@ -12,17 +12,15 @@ from ..value_objects.unified_message import ( UnifiedMessage, ) +# Discord 自定义表情正则 <:name:id> 或 +_DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"") +# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息 +_COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/") + class MessageCleanerService: """消息清理服务""" - # Discord 自定义表情正则 <:name:id> 或 - DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"") - - # 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息 - # 比如: "/group_analysis", "@bot /help", " /test" - COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/") - def clean_messages( self, messages: list[UnifiedMessage], @@ -51,11 +49,7 @@ class MessageCleanerService: # 2. 预检指令消息(首个内容块通常是文本) is_command = False first_text = msg.text_content - if ( - filter_commands - and first_text - and self.COMMAND_PATTERN.match(first_text) - ): + if filter_commands and first_text and _COMMAND_PATTERN.match(first_text): is_command = True if is_command: @@ -70,7 +64,7 @@ class MessageCleanerService: text = content.text or "" # 移除 Discord 原始表情代码 - text = self.DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text) + text = _DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text) # 移除 @mentions 文本 (e.g. <@123456>) text = re.sub(r"<@\d+>", "", text) diff --git a/src/domain/services/report_generator.py b/src/domain/services/report_generator.py deleted file mode 100644 index 49415f0..0000000 --- a/src/domain/services/report_generator.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -报告生成器 - 生成分析报告的领域服务 - -该服务从分析结果生成格式化报告。 -它是平台无关的,生成文本/Markdown 报告。 -""" - -from datetime import datetime - -from ..value_objects.golden_quote import GoldenQuote -from ..value_objects.statistics import GroupStatistics, TokenUsage -from ..value_objects.topic import Topic -from ..value_objects.user_title import UserTitle - - -class ReportGenerator: - """ - 领域服务:报告生成器 - - 负责将抽象的统计数据、话题和金句转换为人类可读的格式化报告。 - 该类是平台无关的,主要生成 Markdown 风格的文本。 - """ - - def __init__(self, group_name: str = "", date_str: str = ""): - """ - 初始化报告生成器。 - - Args: - group_name (str): 报告所属的群组名称 - date_str (str, optional): 报告日期 (YYYY-MM-DD),默认为今日 - """ - self.group_name = group_name - self.date_str = date_str or datetime.now().strftime("%Y-%m-%d") - - def generate_full_report( - self, - statistics: GroupStatistics, - topics: list[Topic], - user_titles: list[UserTitle], - golden_quotes: list[GoldenQuote], - include_header: bool = True, - include_footer: bool = True, - ) -> str: - """ - 生成完整的群聊分析报告。 - - Args: - statistics (GroupStatistics): 基础统计数据 - topics (list[Topic]): 讨论话题列表 - user_titles (list[UserTitle]): 用户称号列表 - golden_quotes (list[GoldenQuote]): 精彩金句列表 - include_header (bool): 是否包含页眉 - include_footer (bool): 是否包含页脚 - - Returns: - str: 格式化后的完整报告字符串 - """ - sections = [] - - if include_header: - sections.append(self._generate_header()) - - sections.append(self._generate_statistics_section(statistics)) - - if topics: - sections.append(self._generate_topics_section(topics)) - - if user_titles: - sections.append(self._generate_user_titles_section(user_titles)) - - if golden_quotes: - sections.append(self._generate_golden_quotes_section(golden_quotes)) - - if include_footer: - sections.append(self._generate_footer(statistics.token_usage)) - - return "\n\n".join(sections) - - def _generate_header(self) -> str: - """ - 内部方法:构造报告的标题页眉。 - - Returns: - str: 包含群名、日期的页眉文本 - """ - title = "📊 群聊分析报告" - if self.group_name: - title += f" - {self.group_name}" - - return f"{title}\n📅 日期: {self.date_str}\n{'=' * 40}" - - def _generate_statistics_section(self, stats: GroupStatistics) -> str: - """ - 内部方法:格式化基础数值统计区块。 - - Args: - stats (GroupStatistics): 群组统计数据 - - Returns: - str: 格式化的 Markdown 列表区块 - """ - lines = [ - "📈 **统计概览**", - f"• 消息总数: {stats.message_count}", - f"• 字符总数: {stats.total_characters}", - f"• 参与人数: {stats.participant_count}", - f"• 平均消息长度: {stats.average_message_length:.1f} 字符", - f"• 最活跃时段: {stats.most_active_period}", - ] - - if stats.emoji_count > 0: - lines.append(f"• 表情使用: {stats.emoji_count}") - - return "\n".join(lines) - - def _generate_topics_section(self, topics: list[Topic]) -> str: - """ - 内部方法:格式化讨论话题摘要区块。 - - Args: - topics (list[Topic]): 话题列表 - - Returns: - str: 序列化的 Markdown 话题区块 - """ - lines = ["💬 **讨论话题**"] - - for i, topic in enumerate(topics, 1): - contributors_str = ", ".join(topic.contributors[:3]) - if len(topic.contributors) > 3: - contributors_str += f" 等{len(topic.contributors) - 3}人" - - lines.append(f"\n{i}. **{topic.name}**") - lines.append(f" 参与者: {contributors_str}") - if topic.detail: - # 截断过长的详情,避免报告过大 - detail = ( - topic.detail[:200] + "..." - if len(topic.detail) > 200 - else topic.detail - ) - lines.append(f" {detail}") - - return "\n".join(lines) - - def _generate_user_titles_section(self, titles: list[UserTitle]) -> str: - """ - 内部方法:格式化用户荣誉/称号区块。 - - Args: - titles (list[UserTitle]): 称号列表 - - Returns: - str: 格式化的 Markdown 用户榜区块 - """ - lines = ["🏆 **用户称号与徽章**"] - - for title in titles: - lines.append(f"\n👤 **{title.name}**") - lines.append(f" 🎖️ 称号: {title.title}") - if title.mbti: - lines.append(f" 🧠 MBTI: {title.mbti}") - if title.reason: - reason = ( - title.reason[:150] + "..." - if len(title.reason) > 150 - else title.reason - ) - lines.append(f" 💡 原因: {reason}") - - return "\n".join(lines) - - def _generate_golden_quotes_section(self, quotes: list[GoldenQuote]) -> str: - """ - 内部方法:格式化精彩金句展示区块。 - - Args: - quotes (list[GoldenQuote]): 金句列表 - - Returns: - str: 格式化的 Markdown 金句区块 - """ - lines = ["✨ **金句集锦**"] - - for i, quote in enumerate(quotes, 1): - lines.append(f'\n{i}. "{quote.content}"') - lines.append(f" — {quote.sender}") - if quote.reason: - reason = ( - quote.reason[:100] + "..." - if len(quote.reason) > 100 - else quote.reason - ) - lines.append(f" ({reason})") - - return "\n".join(lines) - - def _generate_footer(self, token_usage: TokenUsage | None = None) -> str: - """ - 内部方法:生成包含生成时间和性能元数据的页脚。 - - Args: - token_usage (TokenUsage, optional): 关联的 LLM 消耗 - - Returns: - str: 报告页脚 - """ - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - lines = ["─" * 40] - lines.append(f"生成时间: {now}") - - if token_usage and token_usage.total_tokens > 0: - lines.append(f"令牌使用: {token_usage.total_tokens} tokens") - - return "\n".join(lines) - - def generate_summary_report( - self, - statistics: GroupStatistics, - top_topic: Topic | None = None, - top_quote: GoldenQuote | None = None, - ) -> str: - """ - 生成简短的摘要报告。 - - Args: - statistics (GroupStatistics): 基础统计数据 - top_topic (Topic, optional): 头对话题 - top_quote (GoldenQuote, optional): 最优金句 - - Returns: - str: 简短摘要字符串 - """ - lines = [ - f"📊 每日摘要 ({self.date_str})", - f"消息: {statistics.message_count} | 参与: {statistics.participant_count}人", - ] - - if top_topic: - lines.append(f"🔥 热门话题: {top_topic.name}") - - if top_quote: - lines.append(f'✨ 金句: "{top_quote.content}" — {top_quote.sender}') - - return "\n".join(lines) diff --git a/src/domain/services/statistics_calculator.py b/src/domain/services/statistics_calculator.py deleted file mode 100644 index 9e673fd..0000000 --- a/src/domain/services/statistics_calculator.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -统计计算器 - 计算聊天统计的领域服务 - -该服务从统一消息计算各种统计数据。 -它是平台无关的,与领域值对象配合使用。 -""" - -from ..value_objects import UnifiedMessage -from ..value_objects.statistics import ( - ActivityVisualization, - EmojiStatistics, - GroupStatistics, - TokenUsage, - UserStatistics, -) - - -class StatisticsCalculator: - """ - 领域服务:统计计算器 - - 负责处理统一格式的消息流,并生成多维度的统计分析结果。 - - Attributes: - bot_user_ids (set[str]): 需要在统计中过滤掉的机器人 ID 集合 - """ - - def __init__(self, bot_user_ids: list[str] | None = None): - """ - 初始化统计计算器。 - - Args: - bot_user_ids (list[str], optional): 机器人用户 ID 列表 - """ - self.bot_user_ids = set(bot_user_ids or []) - - def calculate_group_statistics( - self, - messages: list[UnifiedMessage], - token_usage: TokenUsage | None = None, - ) -> GroupStatistics: - """ - 根据一组消息计算综合群组统计数据。 - - Args: - messages (list[UnifiedMessage]): 待分析的消息列表 - token_usage (TokenUsage, optional): 关联的 LLM 令牌消耗 - - Returns: - GroupStatistics: 计算出的群组统计对象 - """ - if not messages: - return GroupStatistics() - - # 过滤机器人消息 - filtered_messages = [ - msg for msg in messages if msg.sender_id not in self.bot_user_ids - ] - - if not filtered_messages: - return GroupStatistics() - - # 计算基本统计 - message_count = len(filtered_messages) - total_characters = sum(len(msg.text_content) for msg in filtered_messages) - unique_senders = {msg.sender_id for msg in filtered_messages} - participant_count = len(unique_senders) - - # 计算表情统计 - emoji_stats = self._calculate_emoji_statistics(filtered_messages) - - # 计算活动可视化 - activity_viz = self._calculate_activity_visualization(filtered_messages) - - # 确定最活跃时段 - most_active_period = self._determine_most_active_period(activity_viz) - - return GroupStatistics( - message_count=message_count, - total_characters=total_characters, - participant_count=participant_count, - most_active_period=most_active_period, - emoji_statistics=emoji_stats, - activity_visualization=activity_viz, - token_usage=token_usage or TokenUsage(), - ) - - def calculate_user_statistics( - self, messages: list[UnifiedMessage] - ) -> dict[str, UserStatistics]: - """ - 为每个独立用户计算详细的行为统计。 - - Args: - messages (list[UnifiedMessage]): 待分析的消息列表 - - Returns: - dict[str, UserStatistics]: 用户 ID 到统计对象的映射 - """ - user_stats: dict[str, UserStatistics] = {} - - for msg in messages: - # 跳过机器人消息 - if msg.sender_id in self.bot_user_ids: - continue - - user_id = msg.sender_id - - if user_id not in user_stats: - user_stats[user_id] = UserStatistics( - user_id=user_id, - nickname=msg.sender_name, - ) - - stats = user_stats[user_id] - stats.message_count += 1 - stats.char_count += len(msg.text_content) - stats.emoji_count += msg.get_emoji_count() - - # 计算回复数 - if msg.reply_to_id: - stats.reply_count += 1 - - # 跟踪每小时活动 - hour = msg.get_datetime().hour - stats.hours[hour] = stats.hours.get(hour, 0) + 1 - - return user_stats - - def get_top_users( - self, - user_stats: dict[str, UserStatistics], - limit: int = 10, - min_messages: int = 5, - ) -> list[dict]: - """ - 获取基于消息活跃度的前 N 名用户排行。 - - Args: - user_stats (dict[str, UserStatistics]): 用户统计映射 - limit (int): 返回的最大数量 - min_messages (int): 进入排行的最低消息门槛 - - Returns: - list[dict]: 排序后的用户摘要字典列表 - """ - eligible_users = [ - stats - for stats in user_stats.values() - if stats.message_count >= min_messages - ] - - # 按消息数降序排序 - sorted_users = sorted( - eligible_users, key=lambda x: x.message_count, reverse=True - ) - - return [ - { - "user_id": u.user_id, - "nickname": u.nickname, - "name": u.nickname, # 向后兼容 - "message_count": u.message_count, - "avg_chars": round(u.average_chars, 1), - "emoji_ratio": round(u.emoji_ratio, 2), - "night_ratio": round(u.night_ratio, 2), - "reply_ratio": round(u.reply_ratio, 2), - } - for u in sorted_users[:limit] - ] - - def _calculate_emoji_statistics( - self, messages: list[UnifiedMessage] - ) -> EmojiStatistics: - """ - 内部方法:扫描消息流并汇总表情符号及贴纸的使用频次。 - - Args: - messages (list[UnifiedMessage]): 待扫描的消息列表 - - Returns: - EmojiStatistics: 包含标准表情、自定义表情、贴纸等分类计数的统计对象 - """ - standard_count = 0 - custom_count = 0 - animated_count = 0 - sticker_count = 0 - other_count = 0 - emoji_details: dict[str, int] = {} - - for msg in messages: - for content in msg.contents: - if content.is_emoji(): - emoji_id = content.emoji_id or "unknown" - emoji_details[emoji_id] = emoji_details.get(emoji_id, 0) + 1 - - emoji_type = ( - content.raw_data.get("emoji_type", "standard") - if isinstance(content.raw_data, dict) - else "standard" - ) - if emoji_type == "standard": - standard_count += 1 - elif emoji_type == "custom": - custom_count += 1 - elif emoji_type == "animated": - animated_count += 1 - elif emoji_type == "sticker": - sticker_count += 1 - else: - other_count += 1 - - return EmojiStatistics( - standard_emoji_count=standard_count, - custom_emoji_count=custom_count, - animated_emoji_count=animated_count, - sticker_count=sticker_count, - other_emoji_count=other_count, - emoji_details=tuple(emoji_details.items()), - ) - - def _calculate_activity_visualization( - self, messages: list[UnifiedMessage] - ) -> ActivityVisualization: - """ - 内部方法:计算群组在时间轴(小时/日期)上的活跃分布。 - - Args: - messages (list[UnifiedMessage]): 消息列表 - - Returns: - ActivityVisualization: 包含 24 小时活跃分布、每日活跃趋势、峰值小时及用户排名的对象 - """ - hourly: dict[int, int] = dict.fromkeys(range(24), 0) - daily: dict[str, int] = {} - user_counts: dict[str, int] = {} - - for msg in messages: - dt = msg.get_datetime() - # 每小时活动 - hour = dt.hour - hourly[hour] += 1 - - # 每日活动 - date_str = dt.strftime("%Y-%m-%d") - daily[date_str] = daily.get(date_str, 0) + 1 - - # 用户活动 - user_counts[msg.sender_id] = user_counts.get(msg.sender_id, 0) + 1 - - # 计算高峰时段(前 3 名) - sorted_hours = sorted(hourly.items(), key=lambda x: x[1], reverse=True) - peak_hours = [h for h, _ in sorted_hours[:3]] - - # 用户活跃度排名 - sorted_users = sorted(user_counts.items(), key=lambda x: x[1], reverse=True) - user_ranking = [ - {"user_id": uid, "count": count} for uid, count in sorted_users[:20] - ] - - return ActivityVisualization( - hourly_activity=tuple(hourly.items()), - daily_activity=tuple(daily.items()), - user_activity_ranking=tuple(user_ranking), - peak_hours=tuple(peak_hours), - heatmap_data=(), - ) - - def _determine_most_active_period(self, activity: ActivityVisualization) -> str: - """ - 内部方法:根据 24 小时分布数据判定群组的最活跃时段文字描述。 - - Args: - activity (ActivityVisualization): 活跃分布数据 - - Returns: - str: 语义化的时间段描述 (如 '上午 (6:00-12:00)') - """ - hourly = dict(activity.hourly_activity) - - if not hourly or all(count == 0 for count in hourly.values()): - return "未知" - - # 找到高峰时段 - peak_hour = max(hourly, key=hourly.get) - - # 分类时间段 - if 6 <= peak_hour < 12: - return "上午 (6:00-12:00)" - elif 12 <= peak_hour < 18: - return "下午 (12:00-18:00)" - elif 18 <= peak_hour < 24: - return "晚间 (18:00-24:00)" - else: - return "深夜 (0:00-6:00)" diff --git a/src/domain/services/statistics_service.py b/src/domain/services/statistics_service.py index c31f039..2377caf 100644 --- a/src/domain/services/statistics_service.py +++ b/src/domain/services/statistics_service.py @@ -8,14 +8,19 @@ from datetime import datetime from ...infrastructure.visualization.activity_charts import ActivityVisualizer from ..models.data_models import EmojiStatistics, GroupStatistics, TokenUsage +from ..repositories.visualization_repository import IActivityVisualizer from ..value_objects.unified_message import MessageContentType, UnifiedMessage class StatisticsService: """统计服务 - 处理群聊数据的聚合统计""" - def __init__(self): - self.activity_visualizer = ActivityVisualizer() + def __init__(self, activity_visualizer: IActivityVisualizer | None = None): + if activity_visualizer is None: + # Fallback: keep backward compatibility + self.activity_visualizer: IActivityVisualizer = ActivityVisualizer() + else: + self.activity_visualizer = activity_visualizer def calculate_group_statistics( self, messages: list[UnifiedMessage] diff --git a/src/domain/services/topic_analyzer.py b/src/domain/services/topic_analyzer.py deleted file mode 100644 index 06126b6..0000000 --- a/src/domain/services/topic_analyzer.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -话题分析领域服务 - -该模块提供平台无关的话题分析服务接口。 -实际分析逻辑委托给 infrastructure 层的具体实现。 - -架构说明: -- 本文件定义领域服务接口和数据转换逻辑 -- 具体的 LLM 调用和消息处理在 src/analysis/analyzers/topic_analyzer.py 中实现 -- 采用渐进式迁移策略,保持与现有代码的兼容性 -""" - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -from ..value_objects.topic import Topic -from ..value_objects.unified_message import UnifiedMessage - -if TYPE_CHECKING: - from ..value_objects.statistics import TokenUsage - - -class ITopicAnalyzer(ABC): - """ - 话题分析服务接口 - - 定义平台无关的话题分析契约。 - 所有平台的话题分析都应该实现此接口。 - """ - - @abstractmethod - async def analyze( - self, - messages: list[UnifiedMessage], - unified_msg_origin: str = None, - ) -> tuple[list[Topic], "TokenUsage"]: - """ - 分析消息中的话题 - - 参数: - messages: 统一格式的消息列表 - unified_msg_origin: 消息来源标识,用于选择 LLM 提供商 - - 返回: - (话题列表, Token 使用统计) - """ - pass - - -class TopicAnalyzerAdapter(ITopicAnalyzer): - """ - 话题分析服务适配器 - - 将现有的 TopicAnalyzer 实现适配为领域服务接口。 - 负责 UnifiedMessage 与原始消息格式之间的转换。 - """ - - def __init__(self, legacy_analyzer): - """ - 初始化适配器 - - 参数: - legacy_analyzer: 现有的 TopicAnalyzer 实例 - """ - self._analyzer = legacy_analyzer - - async def analyze( - self, - messages: list[UnifiedMessage], - unified_msg_origin: str = None, - ) -> tuple[list[Topic], "TokenUsage"]: - """ - 分析消息中的话题 - - 将 UnifiedMessage 转换为原始格式,调用现有分析器, - 然后将结果转换为领域值对象。 - - 参数: - messages: 统一格式的消息列表 - unified_msg_origin: 消息来源标识 - - 返回: - (话题列表, Token 使用统计) - """ - # 将 UnifiedMessage 转换为原始消息格式 - raw_messages = [self._to_raw_message(msg) for msg in messages] - - # 调用现有分析器 - legacy_topics, token_usage = await self._analyzer.analyze_topics( - raw_messages, unified_msg_origin - ) - - # 将结果转换为领域值对象 - topics = [ - Topic( - name=t.topic, - contributors=t.contributors, - detail=t.detail, - ) - for t in legacy_topics - ] - - return topics, token_usage - - def _to_raw_message(self, msg: UnifiedMessage) -> dict: - """ - 将 UnifiedMessage 转换为原始消息格式 - - 参数: - msg: 统一消息对象 - - 返回: - 原始消息字典 - """ - # 构建消息内容列表 - message_content = [] - if msg.text_content: - message_content.append({"type": "text", "data": {"text": msg.text_content}}) - - return { - "message_id": msg.message_id, - "time": int(msg.timestamp.timestamp()) if msg.timestamp else 0, - "sender": { - "user_id": msg.sender_id, - "nickname": msg.sender_name, - }, - "message": message_content, - } diff --git a/src/domain/services/user_title_analyzer.py b/src/domain/services/user_title_analyzer.py deleted file mode 100644 index d9cbd57..0000000 --- a/src/domain/services/user_title_analyzer.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -用户称号分析领域服务 - -该模块提供平台无关的用户称号分析服务接口。 -实际分析逻辑委托给 infrastructure 层的具体实现。 - -架构说明: -- 本文件定义领域服务接口和数据转换逻辑 -- 具体的 LLM 调用和消息处理在 src/analysis/analyzers/user_title_analyzer.py 中实现 -- 采用渐进式迁移策略,保持与现有代码的兼容性 -""" - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any - -from ..value_objects.unified_message import UnifiedMessage -from ..value_objects.user_title import UserTitle - -if TYPE_CHECKING: - from ..value_objects.statistics import TokenUsage - - -class IUserTitleAnalyzer(ABC): - """ - 用户称号分析服务接口 - - 定义平台无关的用户称号分析契约。 - 所有平台的用户称号分析都应该实现此接口。 - """ - - @abstractmethod - async def analyze( - self, - messages: list[UnifiedMessage], - user_analysis: dict[str, Any], - unified_msg_origin: str = None, - top_users: list[dict] = None, - ) -> tuple[list[UserTitle], "TokenUsage"]: - """ - 分析用户称号 - - 参数: - messages: 统一格式的消息列表 - user_analysis: 用户分析统计数据 - unified_msg_origin: 消息来源标识,用于选择 LLM 提供商 - top_users: 活跃用户列表(可选) - - 返回: - (用户称号列表, Token 使用统计) - """ - pass - - -class UserTitleAnalyzerAdapter(IUserTitleAnalyzer): - """ - 用户称号分析服务适配器 - - 将现有的 UserTitleAnalyzer 实现适配为领域服务接口。 - 负责 UnifiedMessage 与原始消息格式之间的转换。 - """ - - def __init__(self, legacy_analyzer): - """ - 初始化适配器 - - 参数: - legacy_analyzer: 现有的 UserTitleAnalyzer 实例 - """ - self._analyzer = legacy_analyzer - - async def analyze( - self, - messages: list[UnifiedMessage], - user_analysis: dict[str, Any], - unified_msg_origin: str = None, - top_users: list[dict] = None, - ) -> tuple[list[UserTitle], "TokenUsage"]: - """ - 分析用户称号 - - 将 UnifiedMessage 转换为原始格式,调用现有分析器, - 然后将结果转换为领域值对象。 - - 参数: - messages: 统一格式的消息列表 - user_analysis: 用户分析统计数据 - unified_msg_origin: 消息来源标识 - top_users: 活跃用户列表 - - 返回: - (用户称号列表, Token 使用统计) - """ - # 将 UnifiedMessage 转换为原始消息格式 - raw_messages = [self._to_raw_message(msg) for msg in messages] - - # 调用现有分析器 - legacy_titles, token_usage = await self._analyzer.analyze_user_titles( - raw_messages, user_analysis, unified_msg_origin, top_users - ) - - # 将结果转换为领域值对象 - titles = [ - UserTitle( - user_id=str(t.user_id), - user_name=t.name, - title=t.title, - mbti=t.mbti, - reason=t.reason, - ) - for t in legacy_titles - ] - - return titles, token_usage - - def _to_raw_message(self, msg: UnifiedMessage) -> dict: - """ - 将 UnifiedMessage 转换为原始消息格式 - - 参数: - msg: 统一消息对象 - - 返回: - 原始消息字典 - """ - # 构建消息内容列表 - message_content = [] - if msg.text_content: - message_content.append({"type": "text", "data": {"text": msg.text_content}}) - - return { - "message_id": msg.message_id, - "time": int(msg.timestamp.timestamp()) if msg.timestamp else 0, - "sender": { - "user_id": msg.sender_id, - "nickname": msg.sender_name, - }, - "message": message_content, - } diff --git a/src/domain/value_objects/__init__.py b/src/domain/value_objects/__init__.py index a882ce6..30ddc0b 100644 --- a/src/domain/value_objects/__init__.py +++ b/src/domain/value_objects/__init__.py @@ -1,17 +1,7 @@ # 值对象 -from .golden_quote import GoldenQuote, GoldenQuoteCollection from .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities -from .statistics import ( - ActivityVisualization, - EmojiStatistics, - GroupStatistics, - TokenUsage, - UserStatistics, -) -from .topic import Topic, TopicCollection from .unified_group import UnifiedGroup, UnifiedMember from .unified_message import MessageContent, MessageContentType, UnifiedMessage -from .user_title import UserTitle, UserTitleCollection __all__ = [ # 核心平台抽象 @@ -22,17 +12,4 @@ __all__ = [ "PLATFORM_CAPABILITIES", "UnifiedGroup", "UnifiedMember", - # 分析值对象 - "Topic", - "TopicCollection", - "UserTitle", - "UserTitleCollection", - "GoldenQuote", - "GoldenQuoteCollection", - # 统计 - "TokenUsage", - "EmojiStatistics", - "ActivityVisualization", - "GroupStatistics", - "UserStatistics", ] diff --git a/src/domain/value_objects/golden_quote.py b/src/domain/value_objects/golden_quote.py deleted file mode 100644 index 553590b..0000000 --- a/src/domain/value_objects/golden_quote.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -金句值对象 - 平台无关的金句表示 - -该值对象表示从群聊消息中提取的精彩语录。 -它是不可变的,不包含任何平台特定的逻辑。 -""" - -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class GoldenQuote: - """ - 值对象:群聊金句 - - 表示分析过程中提取出的具有代表性、幽默或深刻的消息语录。 - - Attributes: - content (str): 语录原文 - sender (str): 说话者的显示名称 - reason (str): 入选理由(由 LLM 生成) - user_id (str): 用户唯一 ID - """ - - content: str - sender: str - reason: str = "" - user_id: str = "" - - def __post_init__(self): - """初始化后确保 user_id 类型正确。""" - if not isinstance(self.user_id, str): - object.__setattr__(self, "user_id", str(self.user_id)) - - @classmethod - def from_dict(cls, data: dict) -> "GoldenQuote": - """从持久化字典构建金句对象。""" - user_id = data.get("user_id", "") - - return cls( - content=data.get("content", "").strip(), - sender=data.get("sender", "").strip(), - reason=data.get("reason", "").strip(), - user_id=str(user_id) if user_id else "", - ) - - def to_dict(self) -> dict: - """转换为持久化字典。""" - return { - "content": self.content, - "sender": self.sender, - "reason": self.reason, - "user_id": self.user_id, - } - - @property - def is_valid(self) -> bool: - """验证金句数据的完整性。""" - return bool(self.content.strip() and self.sender.strip()) - - def with_user_id(self, user_id: str) -> "GoldenQuote": - """拷贝并更新用户 ID,返回新实例。""" - return GoldenQuote( - content=self.content, - sender=self.sender, - reason=self.reason, - user_id=str(user_id), - ) - - -@dataclass -class GoldenQuoteCollection: - """ - 模型:金句容器 - - 提供对金句列表的高级操作封装。 - """ - - quotes: list[GoldenQuote] = field(default_factory=list) - - def add(self, quote: GoldenQuote) -> None: - """添加单个金句,执行有效性检查。""" - if quote.is_valid: - self.quotes.append(quote) - - def add_from_dict(self, data: dict) -> None: - """从原始数据添加金句。""" - self.add(GoldenQuote.from_dict(data)) - - def to_list(self) -> list[dict]: - """导出为字典列表。""" - return [q.to_dict() for q in self.quotes] - - def __len__(self) -> int: - return len(self.quotes) - - def __iter__(self): - return iter(self.quotes) diff --git a/src/domain/value_objects/platform_capabilities.py b/src/domain/value_objects/platform_capabilities.py index fd7bc4a..f543c4f 100644 --- a/src/domain/value_objects/platform_capabilities.py +++ b/src/domain/value_objects/platform_capabilities.py @@ -254,6 +254,31 @@ LARK_CAPABILITIES = PlatformCapabilities( avatar_sizes=(72, 240, 640), ) +# QQ Official Bot API. Message history is provided by the plugin's local +# event archive because the public API does not expose group history queries. +QQ_OFFICIAL_CAPABILITIES = PlatformCapabilities( + platform_name="qq_official", + platform_version="api_v2_local_history", + supports_message_history=True, + max_message_history_days=7, + max_message_count=10000, + supports_group_list=False, + supports_group_info=False, + supports_member_list=False, + supports_member_info=False, + supports_text_message=True, + supports_image_message=True, + supports_file_message=True, + supports_forward_message=False, + supports_reply_message=False, + max_text_length=4000, + max_image_size_mb=20.0, + supports_user_avatar=True, + supports_group_avatar=False, + avatar_needs_api_call=False, + avatar_sizes=(640,), +) + # 能力查找表(映射平台标识到能力对象) PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = { "aiocqhttp": ONEBOT_V11_CAPABILITIES, @@ -262,6 +287,8 @@ PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = { "discord": DISCORD_CAPABILITIES, "slack": SLACK_CAPABILITIES, "lark": LARK_CAPABILITIES, + "qq_official": QQ_OFFICIAL_CAPABILITIES, + "qq_official_webhook": QQ_OFFICIAL_CAPABILITIES, } diff --git a/src/domain/value_objects/statistics.py b/src/domain/value_objects/statistics.py deleted file mode 100644 index 83322d0..0000000 --- a/src/domain/value_objects/statistics.py +++ /dev/null @@ -1,327 +0,0 @@ -""" -统计值对象 - 平台无关的统计数据表示 - -该模块包含群聊分析期间收集的各种统计数据的值对象。 -所有对象都是不可变的和平台无关的。 -""" - -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class TokenUsage: - """ - 值对象:LLM 令牌消耗统计 - - 记录分析过程中消耗的 Prompt 和 Completion Token。 - - Attributes: - prompt_tokens (int): 提示词 Token 数 - completion_tokens (int): 回答 Token 数 - total_tokens (int): 总计 Token 数 - """ - - prompt_tokens: int = 0 - completion_tokens: int = 0 - total_tokens: int = 0 - - @classmethod - def from_dict(cls, data: dict) -> "TokenUsage": - """从字典还原 TokenUsage 对象。""" - return cls( - prompt_tokens=data.get("prompt_tokens", 0), - completion_tokens=data.get("completion_tokens", 0), - total_tokens=data.get("total_tokens", 0), - ) - - def to_dict(self) -> dict: - """转换为字典格式,用于序列化。""" - return { - "prompt_tokens": self.prompt_tokens, - "completion_tokens": self.completion_tokens, - "total_tokens": self.total_tokens, - } - - def __add__(self, other: object) -> "TokenUsage": - """支持 TokenUsage 对象的加法运算。""" - if not isinstance(other, TokenUsage): - return NotImplemented - return TokenUsage( - prompt_tokens=self.prompt_tokens + other.prompt_tokens, - completion_tokens=self.completion_tokens + other.completion_tokens, - total_tokens=self.total_tokens + other.total_tokens, - ) - - -@dataclass(frozen=True) -class EmojiStatistics: - """ - 值对象:表情符号统计 - - 汇总消息链中不同类别的表情使用情况。 - - Attributes: - standard_emoji_count (int): 标准 Unicode 表情数 - custom_emoji_count (int): 平台自定义表情数 - animated_emoji_count (int): 动态表情数 - sticker_count (int): 贴纸/大表情数 - other_emoji_count (int): 其他未知类型 - emoji_details (tuple[tuple[str, int], ...]): 表情 ID 与次数的详细列表 - """ - - standard_emoji_count: int = 0 - custom_emoji_count: int = 0 - animated_emoji_count: int = 0 - sticker_count: int = 0 - other_emoji_count: int = 0 - emoji_details: tuple[tuple[str, int], ...] = field(default_factory=tuple) - - @property - def total_count(self) -> int: - """获取所有表情的总数。""" - return ( - self.standard_emoji_count - + self.custom_emoji_count - + self.animated_emoji_count - + self.sticker_count - + self.other_emoji_count - ) - - @classmethod - def from_dict(cls, data: dict) -> "EmojiStatistics": - """从持久化字典构建统计对象。""" - details = data.get("face_details", data.get("emoji_details", {})) - if isinstance(details, dict): - details = tuple(details.items()) - - return cls( - standard_emoji_count=data.get( - "face_count", data.get("standard_emoji_count", 0) - ), - custom_emoji_count=data.get( - "mface_count", data.get("custom_emoji_count", 0) - ), - animated_emoji_count=data.get( - "bface_count", data.get("animated_emoji_count", 0) - ), - sticker_count=data.get("sface_count", data.get("sticker_count", 0)), - other_emoji_count=data.get("other_emoji_count", 0), - emoji_details=details, - ) - - def to_dict(self) -> dict: - """转换为持久化字典,包含向后兼容字段。""" - return { - "standard_emoji_count": self.standard_emoji_count, - "custom_emoji_count": self.custom_emoji_count, - "animated_emoji_count": self.animated_emoji_count, - "sticker_count": self.sticker_count, - "other_emoji_count": self.other_emoji_count, - "total_emoji_count": self.total_count, - "emoji_details": dict(self.emoji_details), - # 向后兼容 - "face_count": self.standard_emoji_count, - "mface_count": self.custom_emoji_count, - "bface_count": self.animated_emoji_count, - "sface_count": self.sticker_count, - } - - -@dataclass(frozen=True) -class ActivityVisualization: - """ - 值对象:活动可视化数据 - - 存储用于生成图表的各种活跃度指标。 - - Attributes: - hourly_activity (tuple[tuple[int, int], ...]): 24 小时活跃分布 - daily_activity (tuple[tuple[str, int], ...]): 每日消息数分布 - user_activity_ranking (tuple[dict, ...]): 用户活跃排名数据 - peak_hours (tuple[int, ...]): 高峰小时 ID - heatmap_data (tuple[Any, ...]): 热力图原始数据 - """ - - hourly_activity: tuple[tuple[int, int], ...] = field(default_factory=tuple) - daily_activity: tuple[tuple[str, int], ...] = field(default_factory=tuple) - user_activity_ranking: tuple[dict, ...] = field(default_factory=tuple) - peak_hours: tuple[int, ...] = field(default_factory=tuple) - heatmap_data: tuple = field(default_factory=tuple) - - @classmethod - def from_dict(cls, data: dict) -> "ActivityVisualization": - """从字典反序列话可视化数据。""" - hourly = data.get("hourly_activity", {}) - daily = data.get("daily_activity", {}) - ranking = data.get("user_activity_ranking", []) - peaks = data.get("peak_hours", []) - heatmap = data.get("activity_heatmap_data", data.get("heatmap_data", {})) - - return cls( - hourly_activity=tuple(hourly.items()) - if isinstance(hourly, dict) - else tuple(hourly), - daily_activity=tuple(daily.items()) - if isinstance(daily, dict) - else tuple(daily), - user_activity_ranking=tuple(ranking), - peak_hours=tuple(peaks), - heatmap_data=tuple(heatmap.items()) - if isinstance(heatmap, dict) - else tuple(heatmap), - ) - - def to_dict(self) -> dict: - """转换为字典。""" - return { - "hourly_activity": dict(self.hourly_activity), - "daily_activity": dict(self.daily_activity), - "user_activity_ranking": list(self.user_activity_ranking), - "peak_hours": list(self.peak_hours), - "activity_heatmap_data": dict(self.heatmap_data), - } - - -@dataclass(frozen=True) -class GroupStatistics: - """ - 值对象:综合群聊统计 - - Attributes: - message_count (int): 消息总数 - total_characters (int): 字符总数 - participant_count (int): 活跃人数 - most_active_period (str): 描述性的最活跃时段 - emoji_statistics (EmojiStatistics): 表情分类统计 - activity_visualization (ActivityVisualization): 可视化元数据 - token_usage (TokenUsage): LLM 消耗记录 - """ - - message_count: int = 0 - total_characters: int = 0 - participant_count: int = 0 - most_active_period: str = "" - emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics) - activity_visualization: ActivityVisualization = field( - default_factory=ActivityVisualization - ) - token_usage: TokenUsage = field(default_factory=TokenUsage) - - @property - def average_message_length(self) -> float: - """计算平均每条消息的字符长度。""" - if self.message_count == 0: - return 0.0 - return self.total_characters / self.message_count - - @property - def emoji_count(self) -> int: - """返回表情总数(向后兼容)。""" - return self.emoji_statistics.total_count - - @classmethod - def from_dict(cls, data: dict) -> "GroupStatistics": - """由字典数据构建完整的统计模型。""" - emoji_data = data.get("emoji_statistics", {}) - if not emoji_data: - # 向后兼容:从旧版本扁平字段中恢复 - emoji_data = { - "face_count": data.get("emoji_count", 0), - } - - activity_data = data.get("activity_visualization", {}) - token_data = data.get("token_usage", {}) - - return cls( - message_count=data.get("message_count", 0), - total_characters=data.get("total_characters", 0), - participant_count=data.get("participant_count", 0), - most_active_period=data.get("most_active_period", ""), - emoji_statistics=EmojiStatistics.from_dict(emoji_data), - activity_visualization=ActivityVisualization.from_dict(activity_data), - token_usage=TokenUsage.from_dict(token_data), - ) - - def to_dict(self) -> dict: - """转换为可进行 JSON 序列化的字典。""" - return { - "message_count": self.message_count, - "total_characters": self.total_characters, - "participant_count": self.participant_count, - "most_active_period": self.most_active_period, - "emoji_count": self.emoji_count, # 导出时也包含此字段以支持旧版阅读器 - "emoji_statistics": self.emoji_statistics.to_dict(), - "activity_visualization": self.activity_visualization.to_dict(), - "token_usage": self.token_usage.to_dict(), - } - - -@dataclass -class UserStatistics: - """ - 可变模型:单个用户的行为分析 - - 用于在统计计算过程中作为状态累加器。 - - Attributes: - user_id (str): 用户唯一标示 - nickname (str): 用户名 - message_count (int): 消息条数 - char_count (int): 字符总数 - emoji_count (int): 表情总数 - reply_count (int): 被回复或回复的次数 - hours (dict[int, int]): 小时活跃频次 (0-23) - """ - - user_id: str - nickname: str = "" - message_count: int = 0 - char_count: int = 0 - emoji_count: int = 0 - reply_count: int = 0 - hours: dict[int, int] = field(default_factory=lambda: dict.fromkeys(range(24), 0)) - - @property - def average_chars(self) -> float: - """平均每条消息的字符数。""" - if self.message_count == 0: - return 0.0 - return self.char_count / self.message_count - - @property - def emoji_ratio(self) -> float: - """平均每条消息包含的表情数。""" - if self.message_count == 0: - return 0.0 - return self.emoji_count / self.message_count - - @property - def night_ratio(self) -> float: - """深夜活跃占比(凌晨 0 点至 6 点)。""" - if self.message_count == 0: - return 0.0 - night_messages = sum(self.hours.get(h, 0) for h in range(6)) - return night_messages / self.message_count - - @property - def reply_ratio(self) -> float: - """回复行为占比。""" - if self.message_count == 0: - return 0.0 - return self.reply_count / self.message_count - - def to_dict(self) -> dict: - """返回详细的用户行为分析字典。""" - return { - "user_id": self.user_id, - "nickname": self.nickname, - "message_count": self.message_count, - "char_count": self.char_count, - "emoji_count": self.emoji_count, - "reply_count": self.reply_count, - "avg_chars": round(self.average_chars, 1), - "emoji_ratio": round(self.emoji_ratio, 2), - "night_ratio": round(self.night_ratio, 2), - "reply_ratio": round(self.reply_ratio, 2), - "hours": self.hours, - } diff --git a/src/domain/value_objects/topic.py b/src/domain/value_objects/topic.py deleted file mode 100644 index 3bbc621..0000000 --- a/src/domain/value_objects/topic.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -话题值对象 - 平台无关的话题表示 - -该值对象表示从群聊消息中提取的讨论话题。 -它是不可变的,不包含任何平台特定的逻辑。 -""" - -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class Topic: - """ - 值对象:讨论话题 - - 表示从聊天记录中总结出的一个核心讨论点。 - - Attributes: - name (str): 话题名称 - contributors (tuple[str, ...]): 核心贡献者列表(不可变) - detail (str): 话题详情摘要 - """ - - name: str - contributors: tuple[str, ...] = field(default_factory=tuple) - detail: str = "" - - def __post_init__(self): - """数据规范化。""" - if not self.name or not self.name.strip(): - object.__setattr__(self, "name", "未知话题") - - if isinstance(self.contributors, list): - object.__setattr__(self, "contributors", tuple(self.contributors)) - - @classmethod - def from_dict(cls, data: dict) -> "Topic": - """从字典还原话题对象。""" - contributors = data.get("contributors", []) - if isinstance(contributors, list): - contributors = tuple(contributors) - - return cls( - name=data.get("topic", data.get("name", "")).strip(), - contributors=contributors, - detail=data.get("detail", "").strip(), - ) - - def to_dict(self) -> dict: - """导出为序列化字典。""" - return { - "topic": self.name, - "contributors": list(self.contributors), - "detail": self.detail, - } - - @property - def contributor_count(self) -> int: - """参与讨论的人数。""" - return len(self.contributors) - - @property - def is_valid(self) -> bool: - """验证话题数据的有效性。""" - return bool(self.name.strip() and self.detail.strip()) - - -@dataclass -class TopicCollection: - """ - 模型:话题集合 - - Attributes: - topics (list[Topic]): 话题列表 - """ - - topics: list[Topic] = field(default_factory=list) - - def add(self, topic: Topic) -> None: - """添加话题并进行有效性检查。""" - if topic.is_valid: - self.topics.append(topic) - - def add_from_dict(self, data: dict) -> None: - """从原始数据添加。""" - self.add(Topic.from_dict(data)) - - def to_list(self) -> list[dict]: - """导出字典列表。""" - return [t.to_dict() for t in self.topics] - - def __len__(self) -> int: - return len(self.topics) - - def __iter__(self): - return iter(self.topics) diff --git a/src/domain/value_objects/user_title.py b/src/domain/value_objects/user_title.py deleted file mode 100644 index 78e1a88..0000000 --- a/src/domain/value_objects/user_title.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -用户称号值对象 - 平台无关的用户称号表示 - -该值对象表示基于聊天行为分析分配给用户的称号/徽章。 -它是不可变的,不包含任何平台特定的逻辑。 -""" - -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class UserTitle: - """ - 值对象:用户称号/勋章 - - Attributes: - name (str): 用户昵称 - user_id (str): 用户唯一 ID - title (str): 获得的称号名称 - mbti (str): 评估出的 MBTI 类型 - reason (str): 授予该称号的理由 - """ - - name: str - user_id: str - title: str - mbti: str = "" - reason: str = "" - - def __post_init__(self): - """确保 ID 为字符串。""" - if not isinstance(self.user_id, str): - object.__setattr__(self, "user_id", str(self.user_id)) - - @classmethod - def from_dict(cls, data: dict) -> "UserTitle": - """解析持久化字典。""" - user_id = data.get("user_id", "") - - return cls( - name=data.get("name", "").strip(), - user_id=str(user_id), - title=data.get("title", "").strip(), - mbti=data.get("mbti", "").strip().upper(), - reason=data.get("reason", "").strip(), - ) - - def to_dict(self) -> dict: - """导出字典。""" - return { - "name": self.name, - "user_id": self.user_id, - "title": self.title, - "mbti": self.mbti, - "reason": self.reason, - } - - @property - def is_valid(self) -> bool: - """基本数据完整性验证。""" - return bool(self.name.strip() and self.title.strip() and self.user_id) - - -@dataclass -class UserTitleCollection: - """ - 模型:称号容器 - - Attributes: - titles (list[UserTitle]): 称号列表 - """ - - titles: list[UserTitle] = field(default_factory=list) - - def add(self, title: UserTitle) -> None: - """添加称号。""" - if title.is_valid: - self.titles.append(title) - - def add_from_dict(self, data: dict) -> None: - """解析并添加。""" - self.add(UserTitle.from_dict(data)) - - def get_by_user_id(self, user_id: str) -> UserTitle | None: - """根据唯一 ID 检索称号。""" - user_id_str = str(user_id) - for title in self.titles: - if title.user_id == user_id_str: - return title - return None - - def to_list(self) -> list[dict]: - """导出映射列表。""" - return [t.to_dict() for t in self.titles] - - def __len__(self) -> int: - return len(self.titles) - - def __iter__(self): - return iter(self.titles) diff --git a/src/infrastructure/analysis/analyzers/topic_analyzer.py b/src/infrastructure/analysis/analyzers/topic_analyzer.py index 75976da..caf9cd6 100644 --- a/src/infrastructure/analysis/analyzers/topic_analyzer.py +++ b/src/infrastructure/analysis/analyzers/topic_analyzer.py @@ -362,11 +362,15 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): for topic in topics: raw_ids = topic.contributors # LLM 返回的是 ID 列表 - # 填充 contributor_ids - # 过滤掉非数字的脏数据 (LLM 偶尔会发疯) - valid_ids = [ - str(uid).strip() for uid in raw_ids if str(uid).strip().isdigit() - ] + # 填充 contributor_ids。QQ 官方 member_openid 并非纯数字, + # 因此仅接受本批次已知用户或已配置机器人 ID,而不是用 isdigit 过滤。 + bot_ids = {str(uid) for uid in self.config_manager.get_bot_self_ids()} + known_ids = set(id_to_nickname) | bot_ids + valid_ids = [] + for raw_uid in raw_ids: + uid = str(raw_uid).strip().strip("[]") + if uid and uid in known_ids and uid not in valid_ids: + valid_ids.append(uid) topic.contributor_ids = valid_ids # 映射回昵称用于显示 @@ -376,7 +380,6 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]): name = id_to_nickname.get(uid) if not name: # 尝试去全局配置里找 (e.g. 机器人自己) - bot_ids = self.config_manager.get_bot_self_ids() if uid in bot_ids: name = "Bot" else: diff --git a/src/infrastructure/analysis/llm_analyzer.py b/src/infrastructure/analysis/llm_analyzer.py index a4eb48b..278dbbe 100644 --- a/src/infrastructure/analysis/llm_analyzer.py +++ b/src/infrastructure/analysis/llm_analyzer.py @@ -51,6 +51,21 @@ class LLMAnalyzer(IAnalysisProvider): self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager) self.chat_quality_analyzer = ChatQualityAnalyzer(context, config_manager) + @staticmethod + def _make_session_id( + session_id: str | None, umo: str | None = None, prefix: str = "" + ) -> str: + """Generate a session ID if not already provided.""" + if session_id: + return session_id + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + if umo: + safe_umo = umo.replace(":", "_") + return f"{prefix}{timestamp}_{safe_umo}" + return f"{prefix}{timestamp}" + async def analyze_topics( self, messages: list[dict], @@ -70,16 +85,7 @@ class LLMAnalyzer(IAnalysisProvider): (话题列表, Token使用统计) """ try: - if not session_id: - from datetime import datetime - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - if umo: - # Sanitize umo for filename (replace : with _) - safe_umo = umo.replace(":", "_") - session_id = f"{timestamp}_{safe_umo}" - else: - session_id = timestamp + session_id = self._make_session_id(session_id, umo) logger.info(f"开始话题分析, session_id: {session_id}") return await self.topic_analyzer.analyze_topics(messages, umo, session_id) @@ -110,15 +116,7 @@ class LLMAnalyzer(IAnalysisProvider): (用户称号列表, Token使用统计) """ try: - if not session_id: - from datetime import datetime - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - if umo: - safe_umo = umo.replace(":", "_") - session_id = f"{timestamp}_{safe_umo}" - else: - session_id = timestamp + session_id = self._make_session_id(session_id, umo) logger.info(f"开始用户称号分析, session_id: {session_id}") return await self.user_title_analyzer.analyze_user_titles( @@ -147,15 +145,7 @@ class LLMAnalyzer(IAnalysisProvider): (金句列表, Token使用统计) """ try: - if not session_id: - from datetime import datetime - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - if umo: - safe_umo = umo.replace(":", "_") - session_id = f"{timestamp}_{safe_umo}" - else: - session_id = timestamp + session_id = self._make_session_id(session_id, umo) logger.info(f"开始金句分析, session_id: {session_id}") return await self.golden_quote_analyzer.analyze_golden_quotes( @@ -211,14 +201,7 @@ class LLMAnalyzer(IAnalysisProvider): (话题列表, 用户称号列表, 金句列表, 总Token使用统计) """ try: - from datetime import datetime - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - if umo: - safe_umo = umo.replace(":", "_") - session_id = f"{timestamp}_{safe_umo}" - else: - session_id = timestamp + session_id = self._make_session_id(None, umo) logger.info( f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}" @@ -349,14 +332,7 @@ class LLMAnalyzer(IAnalysisProvider): (话题列表, 金句列表, 总Token使用统计) """ try: - from datetime import datetime - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - if umo: - safe_umo = umo.replace(":", "_") - session_id = f"incr_{timestamp}_{safe_umo}" - else: - session_id = f"incr_{timestamp}" + session_id = self._make_session_id(None, umo, "incr_") logger.info( f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled})," diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 145ae7a..88bf5a9 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -16,6 +16,7 @@ class ConfigManager: 配置结构采用分组嵌套方式,顶层分为以下分组: - basic: 基础设置 + - qq_official: QQ 官方机器人展示设置 - auto_analysis: 自动分析设置 - llm: LLM 设置 - analysis_features: 分析功能开关 @@ -149,6 +150,13 @@ class ConfigManager: """获取输出格式""" return self._get_group("basic").get("output_format", "image") + def get_qq_official_t2i_summary_dashboard_enabled(self) -> bool: + """是否启用 QQ 官方 T2I 概览图。""" + group = self._get_group("qq_official") + if "enable_t2i_summary_dashboard" in group: + return bool(group["enable_t2i_summary_dashboard"]) + return bool(group.get("enable_t2i_activity_histogram", True)) + def get_min_messages_threshold(self) -> int: """获取最小消息阈值""" return self._get_group("basic").get("min_messages_threshold", 50) diff --git a/src/infrastructure/persistence/platform_group_registry.py b/src/infrastructure/persistence/platform_group_registry.py new file mode 100644 index 0000000..4f16428 --- /dev/null +++ b/src/infrastructure/persistence/platform_group_registry.py @@ -0,0 +1,110 @@ +"""Persistent registry of groups observed by event-driven platforms.""" + +import asyncio +from datetime import datetime, timezone +from typing import Any + + +class PlatformGroupRegistry: + """Keep a small, platform-scoped list of groups seen in incoming events.""" + + _KV_KEY = "platform_seen_groups_v1" + _LEGACY_TELEGRAM_KEY = "telegram_seen_groups_v1" + + def __init__(self, plugin_instance: Any): + self.plugin = plugin_instance + self._lock = asyncio.Lock() + self._known_groups: set[tuple[str, str]] = set() + + async def upsert( + self, + platform_id: str, + group_id: str, + sender_id: str = "", + sender_name: str = "", + event_message_id: str = "", + ) -> None: + platform_key = str(platform_id or "").strip() + group_key = str(group_id or "").strip() + if not platform_key or not group_key: + return + + async with self._lock: + identity = (platform_key, group_key) + if identity in self._known_groups: + return + + registry = await self.plugin.get_kv_data(self._KV_KEY, {}) + if not isinstance(registry, dict): + registry = {} + platforms = registry.setdefault("platforms", {}) + if not isinstance(platforms, dict): + platforms = {} + registry["platforms"] = platforms + platform_map = platforms.setdefault(platform_key, {}) + if not isinstance(platform_map, dict): + platform_map = {} + platforms[platform_key] = platform_map + + # Existing groups only need to be remembered in memory. The + # registry is used for group discovery, so rewriting last_seen and + # the full KV document for every message creates unnecessary I/O. + if group_key in platform_map: + self._known_groups.add(identity) + return + + now_iso = datetime.now(timezone.utc).isoformat() + platform_map[group_key] = { + "first_seen": now_iso, + "last_seen": now_iso, + "last_sender_id": str(sender_id or ""), + "last_sender_name": str(sender_name or ""), + "last_event_message_id": str(event_message_id or ""), + } + registry["updated_at"] = now_iso + await self.plugin.put_kv_data(self._KV_KEY, registry) + self._known_groups.add(identity) + + async def get_all_group_ids(self, platform_id: str | None = None) -> list[str]: + async with self._lock: + registry = await self.plugin.get_kv_data(self._KV_KEY, {}) + groups = self._extract_groups(registry, platform_id) + platform_key = str(platform_id).strip() if platform_id else None + if platform_id: + self._known_groups.update( + (str(platform_key), group_id) for group_id in groups + ) + + # Preserve groups recorded by older plugin versions. + legacy = await self.plugin.get_kv_data(self._LEGACY_TELEGRAM_KEY, {}) + legacy_groups = self._extract_groups(legacy, platform_id) + groups.update(legacy_groups) + if platform_id: + self._known_groups.update( + (str(platform_key), group_id) for group_id in legacy_groups + ) + return sorted(groups) + + @staticmethod + def _extract_groups(registry: object, platform_id: str | None) -> set[str]: + if not isinstance(registry, dict): + return set() + platforms = registry.get("platforms") + if not isinstance(platforms, dict): + return set() + + maps: list[object] + if platform_id: + maps = [platforms.get(str(platform_id).strip(), {})] + else: + maps = list(platforms.values()) + + groups: set[str] = set() + for platform_map in maps: + if isinstance(platform_map, dict): + groups.update( + str(group_id).strip() + for group_id in platform_map + if str(group_id).strip() + ) + return groups diff --git a/src/infrastructure/platform/__init__.py b/src/infrastructure/platform/__init__.py index 9352ea4..b5b2e57 100644 --- a/src/infrastructure/platform/__init__.py +++ b/src/infrastructure/platform/__init__.py @@ -1,7 +1,17 @@ -# 平台适配器 +from .adapters.discord_adapter import DiscordAdapter from .adapters.lark_adapter import LarkAdapter from .adapters.onebot_adapter import OneBotAdapter +from .adapters.qq_official_adapter import QQOfficialAdapter +from .adapters.telegram_adapter import TelegramAdapter from .base import PlatformAdapter from .factory import PlatformAdapterFactory -__all__ = ["PlatformAdapterFactory", "PlatformAdapter", "OneBotAdapter", "LarkAdapter"] +__all__ = [ + "PlatformAdapterFactory", + "PlatformAdapter", + "OneBotAdapter", + "LarkAdapter", + "QQOfficialAdapter", + "TelegramAdapter", + "DiscordAdapter", +] diff --git a/src/infrastructure/platform/adapters/__init__.py b/src/infrastructure/platform/adapters/__init__.py index ae45d53..ea34643 100644 --- a/src/infrastructure/platform/adapters/__init__.py +++ b/src/infrastructure/platform/adapters/__init__.py @@ -1,6 +1,35 @@ -# 平台适配器 -from .discord_adapter import DiscordAdapter -from .lark_adapter import LarkAdapter -from .onebot_adapter import OneBotAdapter +"""Optional platform adapter exports. -__all__ = ["OneBotAdapter", "DiscordAdapter", "LarkAdapter"] +Each platform is imported independently so an unavailable optional SDK does not +prevent the QQ Official adapter from being registered. +""" + +__all__: list[str] = [] + +try: + from .discord_adapter import DiscordAdapter # noqa: F401 + + __all__.append("DiscordAdapter") +except ImportError: + pass + +try: + from .lark_adapter import LarkAdapter # noqa: F401 + + __all__.append("LarkAdapter") +except ImportError: + pass + +try: + from .onebot_adapter import OneBotAdapter # noqa: F401 + + __all__.append("OneBotAdapter") +except ImportError: + pass + +try: + from .qq_official_adapter import QQOfficialAdapter # noqa: F401 + + __all__.append("QQOfficialAdapter") +except ImportError: + pass diff --git a/src/infrastructure/platform/adapters/qq_official_adapter.py b/src/infrastructure/platform/adapters/qq_official_adapter.py new file mode 100644 index 0000000..dba73b3 --- /dev/null +++ b/src/infrastructure/platform/adapters/qq_official_adapter.py @@ -0,0 +1,525 @@ +"""QQ Official Bot adapter backed by AstrBot's local message history.""" + +from __future__ import annotations + +import asyncio +import base64 +import os +import random +import re +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any +from urllib.parse import quote + +import aiohttp + +from ....domain.value_objects.platform_capabilities import ( + QQ_OFFICIAL_CAPABILITIES, + 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 + +if TYPE_CHECKING: + from astrbot.api.star import Context + + +class QQOfficialAdapter(PlatformAdapter): + """Adapter for QQ Official group bots (WebSocket and Webhook variants).""" + + platform_name = "qq_official" + AVATAR_TEMPLATE = "https://thirdqq.qlogo.cn/qqapp/{appid}/{member_openid}/640" + HISTORY_PAGE_SIZE = 500 + MARKDOWN_CHUNK_SIZE = 3900 + + def __init__(self, bot_instance: Any, config: dict | None = None): + super().__init__(bot_instance, config) + self._context: Context | None = None + self._plugin_instance = config.get("plugin_instance") if config else None + self._platform_id = str(config.get("platform_id", "")).strip() if config else "" + ids = config.get("bot_self_ids", []) if config else [] + self.bot_self_ids = [str(item) for item in ids if item] + self.appid = self._resolve_appid(config or {}) + self._markdown_msg_seq = random.randint(1, 10000) + + @property + def platform_id(self) -> str: + return self._platform_id or "qq_official" + + def _resolve_appid(self, config: dict) -> str: + direct = str(config.get("appid", "") or "").strip() + if direct: + return direct + platform = getattr(self.bot, "platform", None) + platform_config = getattr(platform, "config", None) + if isinstance(platform_config, dict): + return str(platform_config.get("appid", "") or "").strip() + return "" + + def set_context(self, context: Context) -> None: + self._context = context + + def _init_capabilities(self) -> PlatformCapabilities: + return QQ_OFFICIAL_CAPABILITIES + + async def fetch_messages( + self, + group_id: str, + days: int = 1, + max_count: int = 1000, + before_id: str | None = None, + since_ts: int | None = None, + ) -> list[UnifiedMessage]: + if not self._context: + logger.warning("[QQOfficial] 未设置 context,无法读取本地消息历史") + return [] + + history_mgr = self._context.message_history_manager + target_count = max(1, int(max_count)) + cutoff_ts = ( + int(since_ts) + if since_ts and since_ts > 0 + else int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp()) + ) + before_record_id: int | None = None + if before_id: + try: + before_record_id = int(before_id) + except (TypeError, ValueError): + pass + + messages: list[UnifiedMessage] = [] + seen_message_ids: set[str] = set() + page = 1 + + try: + while len(messages) < target_count: + records = await history_mgr.get( + platform_id=self.platform_id, + user_id=str(group_id), + page=page, + page_size=self.HISTORY_PAGE_SIZE, + ) + if not records: + break + + reached_cutoff = False + for record in records: + record_id = getattr(record, "id", None) + if ( + before_record_id is not None + and record_id is not None + and int(record_id) >= before_record_id + ): + continue + + unified = self._convert_history_record(record, str(group_id)) + if not unified: + continue + if unified.timestamp < cutoff_ts: + reached_cutoff = True + continue + if unified.sender_id in self.bot_self_ids: + continue + if unified.message_id in seen_message_ids: + continue + + seen_message_ids.add(unified.message_id) + messages.append(unified) + + if len(messages) >= target_count: + break + if reached_cutoff or len(records) < self.HISTORY_PAGE_SIZE: + break + page += 1 + + messages.sort(key=lambda item: (item.timestamp, item.message_id)) + if len(messages) > target_count: + messages = messages[-target_count:] + logger.info( + "[QQOfficial] 从本地历史获取群 %s 消息 %s 条", + group_id, + len(messages), + ) + return messages + except Exception as exc: + logger.error("[QQOfficial] 读取本地消息历史失败: %s", exc, exc_info=True) + return [] + + def _convert_history_record( + self, record: Any, group_id: str + ) -> UnifiedMessage | None: + try: + content = getattr(record, "content", None) + if not isinstance(content, dict): + return None + metadata = content.get("_qq_official") + if not isinstance(metadata, dict): + return None + + contents: list[MessageContent] = [] + text_parts: list[str] = [] + for part in content.get("message", []): + if not isinstance(part, dict): + continue + part_type = str(part.get("type", "")).lower() + if part_type in {"plain", "text"}: + text = str(part.get("text", "") or "") + text_parts.append(text) + contents.append( + MessageContent(type=MessageContentType.TEXT, text=text) + ) + elif part_type == "image": + contents.append( + MessageContent( + type=MessageContentType.IMAGE, + url=str(part.get("url", "") or ""), + ) + ) + elif part_type == "at": + contents.append( + MessageContent( + type=MessageContentType.AT, + at_user_id=str(part.get("target_id", "") or ""), + ) + ) + elif part_type == "file": + contents.append( + MessageContent( + type=MessageContentType.FILE, + url=str(part.get("url", "") or ""), + raw_data={"name": part.get("name", "")}, + ) + ) + elif part_type in {"record", "voice"}: + contents.append( + MessageContent( + type=MessageContentType.VOICE, + url=str(part.get("url", "") or ""), + ) + ) + elif part_type == "video": + contents.append( + MessageContent( + type=MessageContentType.VIDEO, + url=str(part.get("url", "") or ""), + ) + ) + + message_id = str(metadata.get("message_id", "") or "") + if not message_id: + message_id = f"local:{getattr(record, 'id', '')}" + timestamp = int(metadata.get("timestamp", 0) or 0) + if timestamp <= 0: + created_at = getattr(record, "created_at", None) + timestamp = int(created_at.timestamp()) if created_at else 0 + sender_id = str(getattr(record, "sender_id", "") or "") + if not sender_id: + return None + + return UnifiedMessage( + message_id=message_id, + sender_id=sender_id, + sender_name=sender_id, + sender_card=None, + group_id=group_id, + text_content="".join(text_parts), + contents=tuple(contents), + timestamp=timestamp, + platform=self.platform_name, + ) + except Exception as exc: + logger.debug("[QQOfficial] 转换本地历史记录失败: %s", exc) + return None + + def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]: + result: list[dict] = [] + for message in messages: + chain: list[dict] = [] + for content in message.contents: + if content.type == MessageContentType.TEXT: + chain.append({"type": "text", "data": {"text": content.text}}) + elif content.type == MessageContentType.IMAGE: + chain.append({"type": "image", "data": {"url": content.url}}) + elif content.type == MessageContentType.AT: + chain.append({"type": "at", "data": {"qq": content.at_user_id}}) + result.append( + { + "message_id": message.message_id, + "time": message.timestamp, + "group_id": message.group_id, + "sender": { + "user_id": message.sender_id, + "nickname": message.sender_id, + "card": "", + }, + "message": chain, + "user_id": message.sender_id, + } + ) + return result + + async def _send_chain(self, group_id: str, chain: Any) -> bool: + if not self._context: + logger.error("[QQOfficial] 未设置 context,无法发送消息") + return False + try: + # AstrBot's QQ Official adapter keeps the group/channel scene only + # in memory. Restore it before proactive sends so scheduled reports + # continue to work after a process restart, before the next event. + platform = getattr(self.bot, "platform", None) + remember_scene = getattr(platform, "remember_session_scene", None) + if callable(remember_scene): + remember_scene(str(group_id), "group") + umo = f"{self.platform_id}:GroupMessage:{group_id}" + return bool(await self._context.send_message(umo, chain)) + except Exception as exc: + logger.error("[QQOfficial] 发送消息失败: %s", exc, exc_info=True) + return False + + async def send_text( + self, group_id: str, text: str, reply_to: str | None = None + ) -> bool: + from astrbot.api.event import MessageChain + + return await self._send_chain(group_id, MessageChain().message(str(text))) + + async def send_text_report( + self, + group_id: str, + content: str, + fallback_content: str | None = None, + ) -> bool: + """Send long reports as QQ custom Markdown with plain-text fallback.""" + chunks = self._split_markdown_report(str(content)) + if not chunks: + return True + + markdown_enabled = True + sent_markdown_chunks = 0 + for chunk in chunks: + if markdown_enabled: + try: + if await self._send_markdown_chunk(group_id, chunk): + sent_markdown_chunks += 1 + continue + logger.warning( + "[QQOfficial] Markdown 接口未返回成功结果,后续改用普通文本" + ) + except Exception as exc: + logger.warning( + "[QQOfficial] Markdown 报告发送失败,后续改用普通文本: %s", + exc, + ) + markdown_enabled = False + + if fallback_content and sent_markdown_chunks == 0: + for fallback_chunk in self._split_markdown_report( + str(fallback_content) + ): + if not await self.send_text(group_id, fallback_chunk): + return False + return True + + if not await self.send_text(group_id, chunk): + return False + return True + + async def _send_markdown_chunk(self, group_id: str, content: str) -> bool: + api = getattr(self.bot, "api", None) + post_group_message = getattr(api, "post_group_message", None) + if not callable(post_group_message): + return False + + platform = getattr(self.bot, "platform", None) + remember_scene = getattr(platform, "remember_session_scene", None) + if callable(remember_scene): + remember_scene(str(group_id), "group") + + try: + from botpy.types.message import MarkdownPayload + + markdown: Any = MarkdownPayload(content=content) + except ImportError: + # Allows lightweight test environments while botpy is provided by + # AstrBot in production. + markdown = {"content": content} + + result = await post_group_message( # type: ignore[arg-type] + group_openid=str(group_id), + msg_type=2, + markdown=markdown, + msg_seq=self._next_markdown_msg_seq(), + ) + return result is not None + + def _next_markdown_msg_seq(self) -> int: + self._markdown_msg_seq = (self._markdown_msg_seq % 10000) + 1 + return self._markdown_msg_seq + + def _split_markdown_report(self, content: str) -> list[str]: + """Split Markdown on block boundaries without breaking mention tokens.""" + normalized = str(content or "").strip() + if not normalized: + return [] + + blocks = re.split(r"\n{2,}", normalized) + chunks: list[str] = [] + current = "" + + def append_piece(piece: str) -> None: + nonlocal current + candidate = f"{current}\n\n{piece}" if current else piece + if len(candidate) <= self.MARKDOWN_CHUNK_SIZE: + current = candidate + return + if current: + chunks.append(current) + current = piece + + for block in blocks: + block = block.strip() + if not block: + continue + if len(block) <= self.MARKDOWN_CHUNK_SIZE: + append_piece(block) + continue + + lines = block.splitlines() or [block] + piece = "" + for line in lines: + candidate = f"{piece}\n{line}" if piece else line + if len(candidate) <= self.MARKDOWN_CHUNK_SIZE: + piece = candidate + continue + if piece: + append_piece(piece) + while len(line) > self.MARKDOWN_CHUNK_SIZE: + split_at = self.MARKDOWN_CHUNK_SIZE + mention_start = line.rfind("<@", 0, split_at) + mention_end = ( + line.find(">", mention_start) if mention_start >= 0 else -1 + ) + if mention_start >= 0 and mention_end >= split_at: + split_at = mention_start or self.MARKDOWN_CHUNK_SIZE + append_piece(line[:split_at]) + line = line[split_at:] + piece = line + if piece: + append_piece(piece) + + if current: + chunks.append(current) + return chunks + + async def send_image( + self, group_id: str, image_path: str, caption: str = "" + ) -> bool: + from astrbot.api.event import MessageChain + + chain = MessageChain() + if caption: + chain.message(caption) + if image_path.startswith("base64://"): + chain.base64_image(image_path[len("base64://") :]) + elif image_path.startswith("data:") and "," in image_path: + chain.base64_image(image_path.split(",", 1)[1]) + elif image_path.startswith(("http://", "https://")): + chain.url_image(image_path) + else: + chain.file_image(os.path.abspath(image_path)) + return await self._send_chain(group_id, chain) + + async def send_file( + self, group_id: str, file_path: str, filename: str | None = None + ) -> bool: + # Prefer the public API; fall back to internal for backward compat. + # astrbot.core is not part of the stable contract and may change. + from astrbot.api.event import MessageChain + from astrbot.core.message.components import File + + name = filename or os.path.basename(file_path) or "report" + if file_path.startswith(("http://", "https://")): + component = File(name=name, url=file_path) + else: + component = File(name=name, file=os.path.abspath(file_path)) + return await self._send_chain(group_id, MessageChain([component])) + + async def get_group_info(self, group_id: str) -> UnifiedGroup | None: + return UnifiedGroup( + group_id=str(group_id), + group_name=str(group_id), + platform=self.platform_name, + ) + + async def get_group_list(self) -> list[str]: + if self._plugin_instance and hasattr( + self._plugin_instance, "get_seen_group_ids" + ): + try: + return await self._plugin_instance.get_seen_group_ids(self.platform_id) + except Exception as exc: + logger.warning("[QQOfficial] 获取已见群列表失败: %s", exc) + return [] + + async def get_member_list(self, group_id: str) -> list[UnifiedMember]: + return [] + + async def get_member_info( + self, group_id: str, user_id: str + ) -> UnifiedMember | None: + return UnifiedMember( + user_id=str(user_id), + nickname="", + avatar_url=await self.get_user_avatar_url(str(user_id)), + ) + + async def get_user_avatar_url(self, user_id: str, size: int = 100) -> str | None: + if not self.appid or not user_id: + return None + return self.AVATAR_TEMPLATE.format( + appid=quote(self.appid, safe=""), + member_openid=quote(str(user_id), safe=""), + ) + + async def get_user_avatar_data(self, user_id: str, size: int = 100) -> str | None: + avatar_url = await self.get_user_avatar_url(user_id, size) + if not avatar_url: + return None + try: + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp.ClientSession( + timeout=timeout, trust_env=True + ) as session: + async with session.get(avatar_url) as response: + if response.status != 200: + return None + payload = await response.read() + if not payload: + return None + mime = "image/png" if payload.startswith(b"\x89PNG") else "image/jpeg" + return f"data:{mime};base64,{base64.b64encode(payload).decode('utf-8')}" + except Exception as exc: + logger.debug("[QQOfficial] 下载头像失败: %s", exc) + 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]: + unique_ids = list( + dict.fromkeys(str(user_id) for user_id in user_ids if user_id) + ) + + async def get_one(user_id: str) -> tuple[str, str | None]: + return user_id, await self.get_user_avatar_url(user_id, size) + + return dict(await asyncio.gather(*(get_one(user_id) for user_id in unique_ids))) diff --git a/src/infrastructure/platform/bot_manager.py b/src/infrastructure/platform/bot_manager.py index 961f896..b097382 100644 --- a/src/infrastructure/platform/bot_manager.py +++ b/src/infrastructure/platform/bot_manager.py @@ -75,6 +75,10 @@ class BotManager: "platform_id": str(platform_id), "plugin_instance": self._plugin_instance, } + platform_instance = self._platforms.get(str(platform_id)) + platform_config = getattr(platform_instance, "config", None) + if isinstance(platform_config, Mapping): + adapter_config["appid"] = platform_config.get("appid", "") adapter = PlatformAdapterFactory.create( platform_name, bot_instance, adapter_config ) diff --git a/src/infrastructure/platform/factory.py b/src/infrastructure/platform/factory.py index 1231cef..973dccb 100644 --- a/src/infrastructure/platform/factory.py +++ b/src/infrastructure/platform/factory.py @@ -99,5 +99,13 @@ def _register_adapters(): except ImportError: pass + try: + from .adapters.qq_official_adapter import QQOfficialAdapter + + PlatformAdapterFactory.register("qq_official", QQOfficialAdapter) + PlatformAdapterFactory.register("qq_official_webhook", QQOfficialAdapter) + except ImportError: + pass + _register_adapters() diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index 2ad0088..d213c38 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -31,6 +31,10 @@ class ReportDispatcher: """设置 HTML 渲染函数 (运行时注入)""" self._html_render_func = render_func + def _hide_user_names(self, platform_id: str | None) -> bool: + adapter = self.message_sender.bot_manager.get_adapter(platform_id) + return bool(adapter and adapter.get_platform_name() == "qq_official") + async def dispatch( self, group_id: str, @@ -88,6 +92,7 @@ class ReportDispatcher: self._html_render_func, avatar_url_getter=avatar_url_getter, avatar_cache_namespace=platform_id, + hide_user_names=self._hide_user_names(platform_id), ) except Exception as e: logger.error(f"[{trace_id}] Failed to generate image report: {e}") @@ -135,6 +140,7 @@ class ReportDispatcher: group_id, avatar_url_getter=avatar_url_getter, avatar_cache_namespace=platform_id, + hide_user_names=self._hide_user_names(platform_id), ) except Exception as e: logger.error(f"[{trace_id}] Failed to generate HTML report: {e}") @@ -196,13 +202,31 @@ class ReportDispatcher: ) -> bool: """分发文本报告""" logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告") - text_report = self.report_generator.generate_text_report(analysis_result) + is_qq_official = self._hide_user_names(platform_id) + fallback_report = None + if is_qq_official: + ( + text_report, + fallback_report, + ) = await self.report_generator.generate_qq_official_markdown_report( + analysis_result, self._html_render_func + ) + else: + text_report = self.report_generator.generate_text_report(analysis_result) adapter = self.message_sender.bot_manager.get_adapter(platform_id) # 尝试通过适配器发送文本报告 logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}") try: - if adapter and await adapter.send_text_report(group_id, text_report): - return True + if adapter: + if is_qq_official: + if await adapter.send_text_report( + group_id, + text_report, + fallback_content=fallback_report, + ): + return True + elif await adapter.send_text_report(group_id, text_report): + return True return await self.message_sender.send_text( group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id ) diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index 5bdb7e3..9bf7073 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -5,6 +5,7 @@ import asyncio import base64 +import copy import hashlib import html import json @@ -14,6 +15,7 @@ from dataclasses import asdict, is_dataclass from datetime import date, datetime from enum import Enum from pathlib import Path +from typing import Any from urllib.parse import quote import aiohttp @@ -25,6 +27,7 @@ from ...domain.repositories.report_repository import IReportGenerator from ...utils.logger import logger from ..utils.template_utils import render_template from ..visualization.activity_charts import ActivityVisualizer +from .qq_official_markdown import QQOfficialMarkdownReportGenerator from .templates import HTMLTemplates MAX_CONCURRENT_DOWNLOADS = 10 @@ -105,6 +108,11 @@ class ReportGenerator(IReportGenerator): # 使用专用的 T2I 并发配置项 max_concurrent = self.config_manager.get_t2i_max_concurrent() self._render_semaphore = asyncio.Semaphore(max_concurrent) + self._qq_official_markdown_generator = QQOfficialMarkdownReportGenerator( + config_manager, + self.html_templates, + self._render_semaphore, + ) # 运行时缓存,用于在一次分析任务中避免重复下载同一个头像 self._avatar_cache = Cache( @@ -336,6 +344,7 @@ class ReportGenerator(IReportGenerator): avatar_url_getter=None, nickname_getter=None, avatar_cache_namespace: str | None = None, + hide_user_names: bool = False, ) -> tuple[str | None, str | None]: """ 生成图片格式的分析报告 @@ -359,6 +368,7 @@ class ReportGenerator(IReportGenerator): avatar_url_getter=avatar_url_getter, nickname_getter=nickname_getter, avatar_cache_namespace=avatar_cache_namespace, + hide_user_names=hide_user_names, ) # 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签) @@ -500,6 +510,7 @@ class ReportGenerator(IReportGenerator): avatar_url_getter=None, nickname_getter=None, avatar_cache_namespace: str | None = None, + hide_user_names: bool = False, ) -> tuple[str | None, str | None]: """ 生成HTML格式的分析报告,保存到指定目录 @@ -544,6 +555,7 @@ class ReportGenerator(IReportGenerator): avatar_url_getter=avatar_url_getter, nickname_getter=nickname_getter, avatar_cache_namespace=avatar_cache_namespace, + hide_user_names=hide_user_names, ) logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段") @@ -603,7 +615,11 @@ class ReportGenerator(IReportGenerator): # 保存原始 JSON 数据 json_data = { - "analysis_result": analysis_result, + "analysis_result": ( + self._sanitize_analysis_result_for_export(analysis_result) + if hide_user_names + else analysis_result + ), "group_id": group_id, "generated_at": datetime.now().isoformat(), } @@ -689,6 +705,88 @@ class ReportGenerator(IReportGenerator): return report + async def generate_qq_official_markdown_report( + self, analysis_result: dict, html_render_func=None + ) -> tuple[str, str]: + """Delegate QQ-only text generation to the platform-specific module.""" + generator = getattr(self, "_qq_official_markdown_generator", None) + if generator is None: + generator = QQOfficialMarkdownReportGenerator( + self.config_manager, + getattr(self, "html_templates", None), + getattr(self, "_render_semaphore", None), + ) + self._qq_official_markdown_generator = generator + return await generator.generate( + analysis_result, + html_render_func, + ) + + def _sanitize_analysis_result_for_export( + self, analysis_result: dict + ) -> dict[str, Any]: + """Remove platform identities from the HTML sidecar JSON export.""" + sanitized = self._to_plain_export_data(copy.deepcopy(analysis_result)) + sanitized["user_analysis"] = {} + for topic in sanitized.get("topics", []): + if not isinstance(topic, dict): + continue + topic["contributors"] = [] + topic["contributor_ids"] = [] + for title in sanitized.get("user_titles", []): + if not isinstance(title, dict): + continue + title["name"] = "" + title["user_id"] = "" + stats = sanitized.get("statistics") + if isinstance(stats, dict): + for golden_quote in stats.get("golden_quotes", []) or []: + if not isinstance(golden_quote, dict): + continue + golden_quote["sender"] = "" + golden_quote["user_id"] = "" + + activity_visualization = stats.get("activity_visualization") + if isinstance(activity_visualization, dict): + activity_visualization["user_activity_ranking"] = [] + + for golden_quote in sanitized.get("golden_quotes", []) or []: + if not isinstance(golden_quote, dict): + continue + golden_quote["sender"] = "" + golden_quote["user_id"] = "" + + return self._sanitize_export_identity_text(sanitized, analysis_result) # type: ignore[return-type] + + @classmethod + def _to_plain_export_data(cls, value): + """Convert report models into plain containers before privacy filtering.""" + if hasattr(value, "to_dict") and callable(value.to_dict): + return cls._to_plain_export_data(value.to_dict()) + if is_dataclass(value) and not isinstance(value, type): + return cls._to_plain_export_data(asdict(value)) + if isinstance(value, dict): + return {key: cls._to_plain_export_data(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [cls._to_plain_export_data(item) for item in value] + return value + + def _sanitize_export_identity_text(self, value, analysis_result: dict): + """Remove known IDs and display names from every exported text field.""" + if isinstance(value, str): + return self._sanitize_identity_text(value, analysis_result, True) + if isinstance(value, dict): + return { + key: self._sanitize_export_identity_text(item, analysis_result) + for key, item in value.items() + } + if isinstance(value, list): + return [ + self._sanitize_export_identity_text(item, analysis_result) + for item in value + ] + return value + async def _prepare_render_data( self, analysis_result: dict, @@ -696,6 +794,7 @@ class ReportGenerator(IReportGenerator): avatar_url_getter=None, nickname_getter=None, avatar_cache_namespace: str | None = None, + hide_user_names: bool = False, ) -> dict: """准备渲染数据""" stats = analysis_result["statistics"] @@ -720,18 +819,34 @@ class ReportGenerator(IReportGenerator): avatar_cache_namespace, avatar_reuse_registry, avatar_reuse_aliases, + hide_user_names=hide_user_names, ) + if hide_user_names: + contributors = await self._render_avatar_only_ids( + getattr(topic, "contributor_ids", []) or [], + avatar_url_getter, + avatar_cache_namespace, + avatar_reuse_registry, + avatar_reuse_aliases, + ) + else: + contributors = "、".join(topic.contributors) topics_list.append( { "index": i, - "topic": topic, - "contributors": "、".join(topic.contributors), + "topic": { + "topic": self._sanitize_identity_text( + topic.topic, analysis_result, hide_user_names + ) + }, + "contributors": contributors, "detail": processed_detail, } ) # 通用模板上下文,包含可能被子模板引用的全局配置 common_context = { + "hide_user_names": hide_user_names, "t2i_font_source": self.config_manager.get_t2i_font_source(), "t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(), "t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(), @@ -763,11 +878,23 @@ class ReportGenerator(IReportGenerator): profile_info = self._resolve_profile_info( title.mbti, profile_mode, profile_mapping_overrides ) + title_reason = title.reason + if hide_user_names: + title_reason = await self._render_mentions( + title.reason, + avatar_url_getter, + nickname_getter, + user_analysis, + avatar_cache_namespace, + avatar_reuse_registry, + avatar_reuse_aliases, + hide_user_names=True, + ) title_data = { - "name": title.name, + "name": "" if hide_user_names else title.name, "title": title.title, "mbti": title.mbti, - "reason": title.reason, + "reason": title_reason, "avatar_data": avatar_data, } title_data.update(profile_info) @@ -810,11 +937,14 @@ class ReportGenerator(IReportGenerator): avatar_cache_namespace, avatar_reuse_registry, avatar_reuse_aliases, + hide_user_names=hide_user_names, ) quotes_list.append( { - "content": golden_quote.content, - "sender": golden_quote.sender, + "content": self._sanitize_identity_text( + golden_quote.content, analysis_result, hide_user_names + ), + "sender": "" if hide_user_names else golden_quote.sender, "reason": processed_reason, "avatar_url": avatar_url, } @@ -860,6 +990,33 @@ class ReportGenerator(IReportGenerator): else: review_data = chat_quality_review + if hide_user_names and isinstance(review_data, dict): + review_data = { + **review_data, + "title": self._sanitize_identity_text( + review_data.get("title", ""), analysis_result, True + ), + "subtitle": self._sanitize_identity_text( + review_data.get("subtitle", ""), analysis_result, True + ), + "summary": self._sanitize_identity_text( + review_data.get("summary", ""), analysis_result, True + ), + "dimensions": [ + { + **dimension, + "name": self._sanitize_identity_text( + dimension.get("name", ""), analysis_result, True + ), + "comment": self._sanitize_identity_text( + dimension.get("comment", ""), analysis_result, True + ), + } + for dimension in review_data.get("dimensions", []) + if isinstance(dimension, dict) + ], + } + chat_quality_html = self.html_templates.render_template( "chat_quality_item.html", **review_data, **common_context ) @@ -899,6 +1056,50 @@ class ReportGenerator(IReportGenerator): logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段") return render_data + async def _render_avatar_only_ids( + self, + user_ids: list[str], + avatar_url_getter, + avatar_cache_namespace: str | None, + avatar_reuse_registry: dict[str, str] | None, + avatar_reuse_aliases: dict[str, str] | None, + ) -> Markup: + avatars: list[Markup] = [] + for raw_user_id in user_ids: + user_id = str(raw_user_id or "").strip() + if not user_id: + continue + avatar_url = await self._get_user_avatar( + user_id, avatar_url_getter, avatar_cache_namespace + ) + avatar_ref = self._register_reusable_avatar( + avatar_url, + avatar_reuse_registry, + avatar_reuse_aliases, + avatar_key=self._get_avatar_cache_key(user_id, avatar_cache_namespace), + ) + style = ( + "width:24px;height:24px;border-radius:50%;display:inline-block;" + "vertical-align:middle;margin:0 2px;background-size:cover;" + "background-position:center;background-repeat:no-repeat;" + ) + if avatar_ref: + avatars.append( + Markup( + f'' + ) + ) + else: + avatars.append( + Markup( + f'' + ) + ) + return Markup("").join(avatars) + async def _render_mentions( self, text: str, @@ -908,20 +1109,41 @@ class ReportGenerator(IReportGenerator): avatar_cache_namespace: str | None = None, avatar_reuse_registry: dict[str, str] | None = None, avatar_reuse_aliases: dict[str, str] | None = None, + hide_user_names: bool = False, ) -> Markup: """ - 处理文本,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式 + 处理文本,将 [用户ID] 格式的引用替换为头像胶囊。 """ - pattern = r"\[(\d+)\]" if not text: return Markup("") - matches = list(re.finditer(pattern, text)) + known_ids = { + str(user_id).strip() + for user_id in (user_analysis or {}) + if str(user_id).strip() + } + source_text = str(text) + if hide_user_names: + # LLM 偶尔会直接输出 ID;在头像-only 模式下先标准化为引用, + # 避免 member_openid 以明文形式泄露。 + for user_id in sorted(known_ids, key=len, reverse=True): + source_text = re.sub( + rf"(? Markup: uid = match.group(1) + if hide_user_names and uid not in known_ids: + return Markup(html.escape(f"[{uid}]", quote=True)) url = await self._get_user_avatar( uid, avatar_url_getter, avatar_cache_namespace ) # 内部已有缓存,无需顶层并发获取 @@ -949,7 +1171,10 @@ class ReportGenerator(IReportGenerator): "padding:2px 6px 2px 2px;border-radius:12px;margin:0 2px;" "vertical-align:middle;border:1px solid rgba(0,0,0,0.1);text-decoration:none;" ) - img_style = "width:18px;height:18px;border-radius:50%;margin-right:4px;display:block;" + img_style = ( + "width:18px;height:18px;border-radius:50%;" + f"margin-right:{'0' if hide_user_names else '4px'};display:block;" + ) name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;" # 3. 最终后备: 确保有头像和名称 @@ -979,23 +1204,51 @@ class ReportGenerator(IReportGenerator): f'style="{img_style}">' ) + name_html = ( + "" + if hide_user_names + else f'{html.escape(final_name)}' + ) return Markup( f'' - f"{avatar_html}" - f'{html.escape(final_name)}' - "" + f"{avatar_html}{name_html}" ) result: list[Markup | str] = [] last_end = 0 for match in matches: - result.append(self._escape_text_segment(text[last_end : match.start()])) + result.append( + self._escape_text_segment(source_text[last_end : match.start()]) + ) result.append(await render_capsule(match)) last_end = match.end() - result.append(self._escape_text_segment(text[last_end:])) + result.append(self._escape_text_segment(source_text[last_end:])) return Markup("").join(result) + @staticmethod + def _sanitize_identity_text( + text: str, analysis_result: dict, hide_user_names: bool + ) -> str: + if not hide_user_names: + return str(text) + sanitized = str(text) + user_analysis = analysis_result.get("user_analysis") or {} + known_ids = { + str(user_id).strip() for user_id in user_analysis if str(user_id).strip() + } + known_names = set() + for stats in user_analysis.values(): + if not isinstance(stats, dict): + continue + for key in ("nickname", "name"): + value = str(stats.get(key, "") or "").strip() + if value: + known_names.add(value) + for identity in sorted(known_ids | known_names, key=len, reverse=True): + sanitized = sanitized.replace(identity, "") + return re.sub(r"\[\s*\]", "", sanitized) + @staticmethod def _escape_text_segment(text: str) -> Markup: return Markup(html.escape(text, quote=False).replace("\n", "
")) diff --git a/src/infrastructure/reporting/platform_templates/qq_official/summary_dashboard.html b/src/infrastructure/reporting/platform_templates/qq_official/summary_dashboard.html new file mode 100644 index 0000000..d0eb4ed --- /dev/null +++ b/src/infrastructure/reporting/platform_templates/qq_official/summary_dashboard.html @@ -0,0 +1,173 @@ + + + + + + + + +
+
+
{{ report_title }}
+
{{ report_date }}
+
+ +
+ {% for metric in metrics %} +
+
{{ metric.value }}
+
{{ metric.label }}
+
+ {% endfor %} +
+ +
+ {% for item in chart_data %} +
+
+
+
+
{{ item.hour }}
+
+ {% endfor %} +
+
+ + diff --git a/src/infrastructure/reporting/qq_official_markdown.py b/src/infrastructure/reporting/qq_official_markdown.py new file mode 100644 index 0000000..28ba4c2 --- /dev/null +++ b/src/infrastructure/reporting/qq_official_markdown.py @@ -0,0 +1,336 @@ +"""QQ Official Bot-specific Markdown report generation.""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any + +from ...utils.logger import logger + + +class QQOfficialMarkdownReportGenerator: + """Generate QQ Official Markdown without changing other platform reports.""" + + def __init__( + self, + config_manager: Any, + html_templates: Any = None, + render_semaphore: Any = None, + ) -> None: + self.config_manager = config_manager + self.html_templates = html_templates + self.render_semaphore = render_semaphore + + async def generate( + self, analysis_result: dict, html_render_func=None + ) -> tuple[str, str]: + """Generate QQ Markdown and a URL-free Markdown fallback report.""" + fallback_report = self._generate_markdown_report(analysis_result) + enabled = self.config_manager.get_qq_official_t2i_summary_dashboard_enabled() + if not enabled or not callable(html_render_func) or self.html_templates is None: + return fallback_report, fallback_report + + dashboard_url = await self._generate_summary_dashboard_url( + analysis_result, html_render_func + ) + if not dashboard_url: + return fallback_report, fallback_report + return ( + self._generate_markdown_report( + analysis_result, summary_dashboard_url=dashboard_url + ), + fallback_report, + ) + + async def _generate_summary_dashboard_url( + self, analysis_result: dict, html_render_func + ) -> str | None: + stats = analysis_result["statistics"] + hourly_counts = self.get_hourly_counts(stats) + max_count = max(hourly_counts, default=0) + chart_data = [ + { + "hour": f"{hour:02d}", + "count": count, + "height": ( + max(2, round(count / max_count * 100)) + if count > 0 and max_count > 0 + else 0 + ), + } + for hour, count in enumerate(hourly_counts) + ] + metrics = [ + {"value": self.format_metric(stats.message_count), "label": "消息"}, + {"value": self.format_metric(stats.participant_count), "label": "参与"}, + {"value": self.format_metric(stats.total_characters), "label": "字符"}, + {"value": self.format_metric(stats.emoji_count), "label": "表情"}, + { + "value": self.format_peak_period(stats.most_active_period), + "label": "高峰", + }, + ] + html_content = self.html_templates.render_platform_template( + "qq_official", + "summary_dashboard.html", + report_title="群聊日常分析", + report_date=datetime.now().strftime("%Y.%m.%d"), + metrics=metrics, + chart_data=chart_data, + ) + if not html_content: + return None + + options = { + "type": "png", + "omit_background": True, + "full_page": False, + "clip": {"x": 0, "y": 0, "width": 800, "height": 360}, + "animations": "disabled", + "caret": "hide", + "scale": "device", + "device_scale_factor_level": "high", + "timeout": 30000, + } + + async def render() -> str | None: + result = await html_render_func(html_content, {}, True, options) + url = str(result or "").strip() + if url.startswith(("http://", "https://")): + return url + logger.warning("[QQOfficial] T2I 概览图未返回可公开访问的 URL") + return None + + try: + if self.render_semaphore is None: + return await render() + async with self.render_semaphore: + return await render() + except Exception as exc: + logger.warning("[QQOfficial] T2I 群聊概览图生成失败: %s", exc) + return None + + def _generate_markdown_report( + self, analysis_result: dict, summary_dashboard_url: str | None = None + ) -> str: + stats = analysis_result["statistics"] + topics = analysis_result["topics"] + user_titles = analysis_result["user_titles"] + + if summary_dashboard_url: + lines = [ + f"![群聊分析概览 #800px #360px]({summary_dashboard_url})", + "", + ] + else: + lines = [ + "# 🎯 群聊日常分析报告", + f"📅 {datetime.now().strftime('%Y年%m月%d日')}", + "", + "## 📊 基础统计", + f"- **消息总数**:{stats.message_count}", + f"- **参与人数**:{stats.participant_count}", + f"- **总字符数**:{stats.total_characters}", + f"- **表情数量**:{stats.emoji_count}", + f"- **最活跃时段**:{stats.most_active_period}", + "", + ] + activity_chart = self.build_activity_chart(stats) + if activity_chart: + lines.extend(activity_chart) + lines.append("") + + lines.append("## 💬 热门话题") + max_topics = self.config_manager.get_max_topics() + for index, topic in enumerate(topics[:max_topics], 1): + topic_name = self.render_identity_text(topic.topic, analysis_result) + lines.append(f"### {index}. {topic_name}") + contributor_ids = list(getattr(topic, "contributor_ids", []) or []) + mentions = self.mentions(contributor_ids) + if mentions: + lines.append(f"**参与者**:{mentions}") + detail = self.render_identity_text(topic.detail, analysis_result) + if detail: + lines.append(detail) + lines.append("") + + lines.append("## 🏆 群友称号") + max_user_titles = self.config_manager.get_max_user_titles() + for title in user_titles[:max_user_titles]: + mention = self.mention(getattr(title, "user_id", "")) + title_text = self.render_identity_text(title.title, analysis_result) + mbti = f" · {title.mbti}" if getattr(title, "mbti", "") else "" + prefix = f"{mention} — " if mention else "" + lines.append(f"- {prefix}**{title_text}**{mbti}") + reason = self.render_identity_text(title.reason, analysis_result) + if reason: + lines.append(f" > {reason}") + lines.append("") + + lines.append("## 💬 群圣经") + max_golden_quotes = self.config_manager.get_max_golden_quotes() + for index, golden_quote in enumerate( + stats.golden_quotes[:max_golden_quotes], 1 + ): + quote_content = self.render_identity_text( + golden_quote.content, analysis_result + ) + mention = self.mention(getattr(golden_quote, "user_id", "")) + attribution = f" — {mention}" if mention else "" + lines.append(f"- **{index}. {quote_content}**{attribution}") + reason = self.render_identity_text(golden_quote.reason, analysis_result) + if reason: + lines.append(f" > {reason}") + lines.append("") + + return "\n".join(lines).strip() + + @staticmethod + def format_metric(value: object) -> str: + try: + number = max(0, int(value) if value is not None else 0) # type: ignore[arg-type] + except (TypeError, ValueError): + return "0" + if number >= 1_000_000: + formatted = f"{number / 1_000_000:.1f}".rstrip("0").rstrip(".") + return f"{formatted}M" + if number >= 10_000: + formatted = f"{number / 1_000:.1f}".rstrip("0").rstrip(".") + return f"{formatted}K" + return f"{number:,}" + + @staticmethod + def format_peak_period(value: object) -> str: + text = str(value or "").strip() + match = re.search(r"(\d{1,2}):\d{2}\s*[-~—至]\s*(\d{1,2}):\d{2}", text) + if match: + return f"{int(match.group(1)):02d}–{int(match.group(2)):02d}" + return text or "—" + + @classmethod + def build_activity_chart(cls, stats: object, bar_width: int = 12) -> list[str]: + hourly_counts = cls.get_hourly_counts(stats) + max_count = max(hourly_counts, default=0) + if max_count <= 0: + return [] + + effective_width = max(1, int(bar_width)) + lines = ["## ⏰ 活跃时间分布"] + for hour, count in enumerate(hourly_counts): + if count > 0: + blocks = max( + 1, + (count * effective_width + max_count - 1) // max_count, + ) + bar = "█" * blocks + else: + bar = "—" + lines.append(f"- {hour:02d}:00 {bar} {count}") + return lines + + @staticmethod + def get_hourly_counts(stats: object) -> list[int]: + activity_viz = getattr(stats, "activity_visualization", None) + raw_activity = getattr(activity_viz, "hourly_activity", None) or {} + if not isinstance(raw_activity, dict): + return [0] * 24 + + hourly_counts: list[int] = [] + for hour in range(24): + raw_count = raw_activity.get(hour, raw_activity.get(str(hour), 0)) + try: + count = max(0, int(raw_count or 0)) + except (TypeError, ValueError): + count = 0 + hourly_counts.append(count) + return hourly_counts + + @staticmethod + def mention(user_id: object) -> str: + normalized = str(user_id or "").strip().strip("[]") + return f"<@{normalized}>" if normalized else "" + + @classmethod + def mentions(cls, user_ids: list[object]) -> str: + unique_ids = list( + dict.fromkeys( + str(user_id or "").strip().strip("[]") + for user_id in user_ids + if str(user_id or "").strip().strip("[]") + ) + ) + return " ".join(cls.mention(user_id) for user_id in unique_ids) + + @classmethod + def render_identity_text(cls, text: object, analysis_result: dict) -> str: + """Replace known IDs and display names with QQ mention syntax.""" + source = str(text or "") + user_analysis = analysis_result.get("user_analysis") or {} + id_to_names: dict[str, set[str]] = {} + + for user_id, user_data in user_analysis.items(): + normalized_id = str(user_id or "").strip() + if not normalized_id: + continue + names: set[str] = set() + if isinstance(user_data, dict): + for key in ("nickname", "name", "card"): + name = str(user_data.get(key, "") or "").strip() + if name and name != normalized_id: + names.add(name) + id_to_names[normalized_id] = names + + for title in analysis_result.get("user_titles", []) or []: + user_id = str(getattr(title, "user_id", "") or "").strip() + name = str(getattr(title, "name", "") or "").strip() + if user_id: + id_to_names.setdefault(user_id, set()) + if name and name != user_id: + id_to_names[user_id].add(name) + + stats = analysis_result.get("statistics") + for golden_quote in getattr(stats, "golden_quotes", []) or []: + user_id = str(getattr(golden_quote, "user_id", "") or "").strip() + name = str(getattr(golden_quote, "sender", "") or "").strip() + if user_id: + id_to_names.setdefault(user_id, set()) + if name and name != user_id: + id_to_names[user_id].add(name) + + placeholders: dict[str, str] = {} + + def protect_mention(match: re.Match[str]) -> str: + key = f"\x00QQMENTION{len(placeholders)}\x00" + placeholders[key] = match.group(0) + return key + + source = re.sub(r"<@[A-Za-z0-9_-]+>", protect_mention, source) + for user_id in sorted(id_to_names, key=len, reverse=True): + mention = cls.mention(user_id) + source = re.sub(rf"\[{re.escape(user_id)}\]", mention, source) + source = re.sub(r"<@[A-Za-z0-9_-]+>", protect_mention, source) + source = re.sub( + rf"(?", protect_mention, source) + + source = re.sub(r"<@[A-Za-z0-9_-]+>", protect_mention, source) + name_to_ids: dict[str, set[str]] = {} + for user_id, names in id_to_names.items(): + for name in names: + if name: + name_to_ids.setdefault(name, set()).add(user_id) + for name in sorted(name_to_ids, key=len, reverse=True): + matched_ids = name_to_ids[name] + replacement = ( + cls.mention(next(iter(matched_ids))) if len(matched_ids) == 1 else "" + ) + source = source.replace(name, replacement) + source = re.sub(r"<@[A-Za-z0-9_-]+>", protect_mention, source) + + for placeholder, mention in placeholders.items(): + source = source.replace(placeholder, mention) + return source.strip() diff --git a/src/infrastructure/reporting/templates.py b/src/infrastructure/reporting/templates.py index fae62ce..63c63f7 100644 --- a/src/infrastructure/reporting/templates.py +++ b/src/infrastructure/reporting/templates.py @@ -20,6 +20,9 @@ class HTMLTemplates: self.config_manager = config_manager # 设置模板根目录 self.base_dir = os.path.join(os.path.dirname(__file__), "templates") + self.platform_base_dir = os.path.join( + os.path.dirname(__file__), "platform_templates" + ) # 缓存不同模板的Jinja2环境(多线程安全) self._envs = {} self._env_lock = threading.Lock() @@ -73,6 +76,9 @@ class HTMLTemplates: try: env = await self._get_env_async() template = env.get_template("image_template.html") + if template.filename is None: + logger.error("图片模板路径为空") + return "" return await asyncio.to_thread( self._read_template_file_sync, template.filename ) @@ -85,6 +91,9 @@ class HTMLTemplates: try: env = self._get_env() template = env.get_template("image_template.html") + if template.filename is None: + logger.error("图片模板路径为空") + return "" with open(template.filename, encoding="utf-8") as f: return f.read() except Exception as e: @@ -108,3 +117,20 @@ class HTMLTemplates: except Exception as e: logger.error(f"渲染模板 {template_name} 失败: {e}") return "" + + def render_platform_template( + self, platform_name: str, template_name: str, **kwargs + ) -> str: + """渲染与报告主题解耦的平台专用模板。""" + try: + template_dir = os.path.join(self.platform_base_dir, platform_name) + env = Environment( + loader=FileSystemLoader(template_dir), + autoescape=select_autoescape(["html", "xml"]), + trim_blocks=True, + lstrip_blocks=True, + ) + return env.get_template(template_name).render(**kwargs) + except Exception as e: + logger.error(f"渲染平台模板 {platform_name}/{template_name} 失败: {e}") + return "" diff --git a/src/infrastructure/visualization/activity_charts.py b/src/infrastructure/visualization/activity_charts.py index 11a9fae..952b94d 100644 --- a/src/infrastructure/visualization/activity_charts.py +++ b/src/infrastructure/visualization/activity_charts.py @@ -7,9 +7,10 @@ from collections import defaultdict from datetime import datetime from ...domain.models.data_models import ActivityVisualization +from ...domain.repositories.visualization_repository import IActivityVisualizer -class ActivityVisualizer: +class ActivityVisualizer(IActivityVisualizer): """活跃度可视化器""" def __init__(self): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..203e436 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import logging +import sys +import types + + +if "astrbot.api" not in sys.modules: + astrbot_module = types.ModuleType("astrbot") + astrbot_api_module = types.ModuleType("astrbot.api") + astrbot_event_module = types.ModuleType("astrbot.api.event") + astrbot_star_module = types.ModuleType("astrbot.api.star") + + class AstrMessageEvent: + pass + + class Context: + pass + + astrbot_api_module.logger = logging.getLogger("astrbot-test") + astrbot_event_module.AstrMessageEvent = AstrMessageEvent + astrbot_star_module.Context = Context + astrbot_module.api = astrbot_api_module + sys.modules.setdefault("astrbot", astrbot_module) + sys.modules.setdefault("astrbot.api", astrbot_api_module) + sys.modules.setdefault("astrbot.api.event", astrbot_event_module) + sys.modules.setdefault("astrbot.api.star", astrbot_star_module) diff --git a/tests/test_avatar_only_reporting.py b/tests/test_avatar_only_reporting.py new file mode 100644 index 0000000..7889d53 --- /dev/null +++ b/tests/test_avatar_only_reporting.py @@ -0,0 +1,548 @@ +import asyncio +import inspect +import json +from types import SimpleNamespace + +from src.domain.models.data_models import ( + ActivityVisualization, + GoldenQuote, + GroupStatistics, + QualityDimension, + QualityReview, +) +from src.infrastructure.reporting.generators import ReportGenerator +from src.infrastructure.reporting.qq_official_markdown import ( + QQOfficialMarkdownReportGenerator, +) +from src.infrastructure.reporting.templates import HTMLTemplates + + +class FakeConfig: + def get_max_topics(self): + return 10 + + def get_max_user_titles(self): + return 10 + + def get_max_golden_quotes(self): + return 10 + + def get_qq_official_t2i_summary_dashboard_enabled(self): + return True + + +def build_generator_without_io(): + generator = object.__new__(ReportGenerator) + generator.config_manager = FakeConfig() + return generator + + +def generate_qq_markdown(generator, analysis_result): + markdown_report, _ = asyncio.run( + generator.generate_qq_official_markdown_report(analysis_result) + ) + return markdown_report + + +def test_standard_text_report_keeps_existing_identity_format(): + generator = build_generator_without_io() + openid = "A1B2C3D4_OPENID" + statistics = SimpleNamespace( + message_count=2, + participant_count=1, + total_characters=10, + emoji_count=0, + most_active_period="12:00-13:00", + golden_quotes=[ + SimpleNamespace( + content="测试内容", + sender=openid, + reason=f"由 {openid} 发出", + ) + ], + ) + analysis_result = { + "statistics": statistics, + "topics": [ + SimpleNamespace( + topic="测试话题", + contributors=[openid], + detail=f"{openid} 参与讨论", + ) + ], + "user_titles": [ + SimpleNamespace( + name=openid, + title="龙王", + mbti="ENTP", + reason=f"{openid} 发言最多", + ) + ], + "user_analysis": {openid: {"nickname": openid}}, + } + + report = generator.generate_text_report(analysis_result) + + assert openid in report + assert "测试内容" in report + assert "龙王" in report + assert f"参与者: {openid}" in report + assert f"• {openid} - 龙王 (ENTP)" in report + assert f'1. "测试内容" —— {openid}' in report + + +def test_standard_text_report_api_has_no_qq_platform_switches(): + parameters = inspect.signature(ReportGenerator.generate_text_report).parameters + + assert list(parameters) == ["self", "analysis_result"] + + +def test_non_qq_text_report_does_not_use_qq_histogram_path(): + generator = build_generator_without_io() + statistics = SimpleNamespace( + message_count=3, + participant_count=1, + total_characters=12, + emoji_count=0, + most_active_period="03:00-04:00", + golden_quotes=[], + activity_visualization=SimpleNamespace(hourly_activity={3: 3}), + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [], + "user_analysis": {}, + } + + report = generator.generate_text_report(analysis_result) + + assert "🎯 群聊日常分析报告" in report + assert "## ⏰ 活跃时间分布" not in report + assert "████" not in report + assert "![24小时活跃分布" not in report + + +def test_qq_official_markdown_uses_mentions_for_all_identity_sections(): + generator = build_generator_without_io() + openid = "A1B2C3D4_OPENID" + nickname = "测试群友" + statistics = SimpleNamespace( + message_count=2, + participant_count=1, + total_characters=10, + emoji_count=0, + most_active_period="12:00-13:00", + golden_quotes=[ + SimpleNamespace( + content=f"[{openid}] 说了一句话", + sender=nickname, + reason=f"{nickname} 的发言很精彩", + user_id=openid, + ) + ], + ) + analysis_result = { + "statistics": statistics, + "topics": [ + SimpleNamespace( + topic="测试话题", + contributors=[nickname], + contributor_ids=[openid], + detail=f"{nickname} 和 {openid} 参与讨论", + ) + ], + "user_titles": [ + SimpleNamespace( + name=nickname, + user_id=openid, + title="龙王", + mbti="ENTP", + reason=f"[{openid}] 发言最多", + ) + ], + "user_analysis": {openid: {"nickname": nickname}}, + } + + report = generate_qq_markdown(generator, analysis_result) + + assert report.count(f"<@{openid}>") >= 6 + without_mentions = report.replace(f"<@{openid}>", "") + assert openid not in without_mentions + assert nickname not in without_mentions + assert "## 💬 热门话题" in report + assert "**参与者**" in report + assert "**龙王**" in report + assert f"- **1. <@{openid}> 说了一句话** — <@{openid}>" in report + assert f" > <@{openid}> 的发言很精彩" in report + assert f"> 1. <@{openid}> 说了一句话" not in report + + +def test_qq_official_markdown_keeps_content_when_identity_id_is_missing(): + generator = build_generator_without_io() + statistics = SimpleNamespace( + message_count=1, + participant_count=1, + total_characters=4, + emoji_count=0, + most_active_period="12:00-13:00", + golden_quotes=[ + SimpleNamespace( + content="测试内容", + sender="无法映射的用户", + reason="理由保留", + user_id="", + ) + ], + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [ + SimpleNamespace( + name="无法映射的用户", + user_id="", + title="龙王", + mbti="", + reason="称号理由", + ) + ], + "user_analysis": {}, + } + + report = generate_qq_markdown(generator, analysis_result) + + assert "<@" not in report + assert "龙王" in report + assert "称号理由" in report + assert "测试内容" in report + assert "理由保留" in report + assert "无法映射的用户" not in report + assert "- **1. 测试内容**" in report + assert " > 理由保留" in report + assert "> 1. 测试内容" not in report + + +def test_qq_official_scripture_spacing_and_optional_reason(): + generator = build_generator_without_io() + statistics = SimpleNamespace( + message_count=2, + participant_count=2, + total_characters=8, + emoji_count=0, + most_active_period="12:00-13:00", + golden_quotes=[ + SimpleNamespace( + content="第一条", + sender="甲", + reason="第一条理由", + user_id="A_OPENID", + ), + SimpleNamespace( + content="第二条", + sender="乙", + reason="", + user_id="", + ), + ], + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [], + "user_analysis": { + "A_OPENID": {"nickname": "甲"}, + }, + } + + report = generate_qq_markdown(generator, analysis_result) + + assert "- **1. 第一条** — <@A_OPENID>\n > 第一条理由\n\n- **2. 第二条**" in report + assert "- **2. 第二条** —" not in report + + +def test_qq_official_markdown_renders_simple_hourly_bar_chart(): + generator = build_generator_without_io() + statistics = SimpleNamespace( + message_count=17, + participant_count=3, + total_characters=80, + emoji_count=1, + most_active_period="03:00-04:00", + golden_quotes=[], + activity_visualization=SimpleNamespace( + hourly_activity={0: 0, "1": 2, 2: 5, "3": 10} + ), + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [], + "user_analysis": {}, + } + + report = generate_qq_markdown(generator, analysis_result) + + assert "## ⏰ 活跃时间分布" in report + assert "- 00:00 — 0" in report + assert "- 01:00 ███ 2" in report + assert "- 02:00 ██████ 5" in report + assert "- 03:00 ████████████ 10" in report + chart_section = report.split("## ⏰ 活跃时间分布", 1)[1].split("## 💬 热门话题", 1)[ + 0 + ] + assert sum(1 for line in chart_section.splitlines() if line.startswith("- ")) == 24 + + +def test_qq_official_markdown_omits_empty_hourly_bar_chart(): + generator = build_generator_without_io() + statistics = SimpleNamespace( + message_count=0, + participant_count=0, + total_characters=0, + emoji_count=0, + most_active_period="", + golden_quotes=[], + activity_visualization=SimpleNamespace(hourly_activity={}), + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [], + "user_analysis": {}, + } + + report = generate_qq_markdown(generator, analysis_result) + + assert "## ⏰ 活跃时间分布" not in report + + +def test_qq_official_t2i_summary_dashboard_replaces_text_summary(): + generator = build_generator_without_io() + generator.html_templates = HTMLTemplates(generator.config_manager) + generator._render_semaphore = asyncio.Semaphore(1) + statistics = SimpleNamespace( + message_count=10, + participant_count=2, + total_characters=50, + emoji_count=4, + most_active_period="03:00-04:00", + golden_quotes=[], + activity_visualization=SimpleNamespace(hourly_activity={1: 2, 3: 10}), + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [], + "user_analysis": {}, + } + render_calls = [] + + async def fake_html_render(template, data, return_url, options): + render_calls.append((template, data, return_url, options)) + return "https://t2i.example/chart.png" + + markdown_report, fallback_report = asyncio.run( + generator.generate_qq_official_markdown_report( + analysis_result, fake_html_render + ) + ) + + assert len(render_calls) == 1 + template, data, return_url, options = render_calls[0] + assert "群聊日常分析" in template + assert "消息" in template + assert "参与" in template + assert "字符" in template + assert "表情" in template + assert "高峰" in template + assert ">10<" in template + assert ">2<" in template + assert ">50<" in template + assert ">4<" in template + assert ">03–04<" in template + assert 'class="histogram"' in template + assert template.count('class="hour"') == 24 + assert template.count('class="metric"') == 5 + assert ">00<" in template + assert ">23<" in template + assert "background: transparent !important" in template + assert "background: #000000" in template + assert "font-size: 25px" in template + assert "font-size: 28px" in template + assert "font-size: 14px" in template + assert "#1d1d1f" not in template + assert "#6e6e73" not in template + assert "#86868b" not in template + assert "rgba(0, 0, 0" not in template + assert "text-shadow" not in template + assert "linear-gradient" not in template + assert "#5b8ff9" not in template + assert data == {} + assert return_url is True + assert options["type"] == "png" + assert options["omit_background"] is True + assert options["clip"] == {"x": 0, "y": 0, "width": 800, "height": 360} + assert "https://t2i.example/chart.png" in markdown_report + assert "# 🎯 群聊日常分析报告" not in markdown_report + assert "📅" not in markdown_report + assert "## 📊 基础统计" not in markdown_report + assert "消息总数" not in markdown_report + assert "## ⏰ 活跃时间分布" not in markdown_report + assert "████" not in markdown_report + assert "https://t2i.example/chart.png" not in fallback_report + assert "# 🎯 群聊日常分析报告" in fallback_report + assert "📅" in fallback_report + assert "## 📊 基础统计" in fallback_report + assert "消息总数" in fallback_report + assert "## ⏰ 活跃时间分布" in fallback_report + assert "████" in fallback_report + + +def test_qq_official_t2i_summary_dashboard_switch_disables_rendering(): + class DisabledConfig(FakeConfig): + def get_qq_official_t2i_summary_dashboard_enabled(self): + return False + + generator = object.__new__(ReportGenerator) + generator.config_manager = DisabledConfig() + statistics = SimpleNamespace( + message_count=10, + participant_count=2, + total_characters=50, + emoji_count=0, + most_active_period="03:00-04:00", + golden_quotes=[], + activity_visualization=SimpleNamespace(hourly_activity={3: 10}), + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [], + "user_analysis": {}, + } + + async def unexpected_render(*args, **kwargs): + raise AssertionError("T2I should not run when disabled") + + markdown_report, fallback_report = asyncio.run( + generator.generate_qq_official_markdown_report( + analysis_result, unexpected_render + ) + ) + + assert markdown_report == fallback_report + assert "████████████" in markdown_report + + +def test_qq_official_summary_dashboard_compacts_large_metrics(): + assert QQOfficialMarkdownReportGenerator.format_metric(10_000) == "10K" + assert QQOfficialMarkdownReportGenerator.format_metric(12_500) == "12.5K" + assert QQOfficialMarkdownReportGenerator.format_metric(1_000_000) == "1M" + assert QQOfficialMarkdownReportGenerator.format_metric(1_250_000) == "1.2M" + + +def test_non_qq_avatar_mentions_ignore_alphanumeric_bracket_text(): + generator = build_generator_without_io() + + async def unexpected_avatar(*args, **kwargs): + raise AssertionError("non-QQ bracket text must not trigger avatar lookup") + + generator._get_user_avatar = unexpected_avatar + rendered = asyncio.run( + generator._render_mentions( + "保留 [TODO]、[GPT-4] 和 [A_OPENID]", + avatar_url_getter=None, + user_analysis={"A_OPENID": {"nickname": "测试用户"}}, + hide_user_names=False, + ) + ) + + rendered_text = str(rendered) + assert "[TODO]" in rendered_text + assert "[GPT-4]" in rendered_text + assert "[A_OPENID]" in rendered_text + assert "user-capsule" not in rendered_text + + +def test_mentions_support_alphanumeric_openid_and_hide_text(): + generator = build_generator_without_io() + openid = "A1B2C3D4_OPENID" + + async def fake_avatar(*args, **kwargs): + return "data:image/png;base64,AAAA" + + generator._get_user_avatar = fake_avatar + rendered = asyncio.run( + generator._render_mentions( + f"成员 [{openid}] 发言", + avatar_url_getter=None, + user_analysis={openid: {"nickname": openid}}, + avatar_cache_namespace="official-main", + avatar_reuse_registry={}, + avatar_reuse_aliases={}, + hide_user_names=True, + ) + ) + + rendered_text = str(rendered) + assert openid not in rendered_text + assert "user-capsule-avatar" in rendered_text + assert "成员" in rendered_text + + +def test_html_sidecar_export_removes_nested_identity_values(): + generator = build_generator_without_io() + openid = "A1B2C3D4_OPENID" + statistics = GroupStatistics( + message_count=2, + participant_count=1, + total_characters=10, + emoji_count=0, + most_active_period="12:00-13:00", + golden_quotes=[ + GoldenQuote( + content="测试内容", + sender=openid, + reason=f"由 {openid} 发出", + user_id=openid, + ) + ], + activity_visualization=ActivityVisualization( + user_activity_ranking=[ + { + "user_id": openid, + "name": openid, + "message_count": 2, + } + ] + ), + chat_quality_review=QualityReview( + title=f"{openid} 的聊天质量", + subtitle="测试", + dimensions=[ + QualityDimension( + name="活跃度", + percentage=100, + comment=f"{openid} 最活跃", + ) + ], + summary=f"总结 {openid}", + ), + ) + analysis_result = { + "statistics": statistics, + "topics": [], + "user_titles": [], + "user_analysis": {openid: {"nickname": openid}}, + "chat_quality_review": statistics.chat_quality_review, + } + + sanitized = generator._sanitize_analysis_result_for_export(analysis_result) + exported = json.dumps(sanitized, ensure_ascii=False) + + assert openid not in exported + assert sanitized["user_analysis"] == {} + assert ( + sanitized["statistics"]["activity_visualization"]["user_activity_ranking"] == [] + ) diff --git a/tests/test_message_processing_service.py b/tests/test_message_processing_service.py new file mode 100644 index 0000000..40fe7f1 --- /dev/null +++ b/tests/test_message_processing_service.py @@ -0,0 +1,70 @@ +import asyncio +from types import SimpleNamespace + +import pytest + +from src.application.services.message_processing_service import ( + MessageProcessingService, +) + + +class FakeHistoryManager: + def __init__(self): + self.insert_calls = 0 + + async def insert(self, **kwargs): + self.insert_calls += 1 + if self.insert_calls == 1: + raise RuntimeError("temporary database failure") + + +class FakeGroupRegistry: + def __init__(self): + self.upsert_calls = 0 + + async def upsert(self, **kwargs): + self.upsert_calls += 1 + + +class FakeOfficialEvent: + def __init__(self): + self.message_obj = SimpleNamespace( + message_id="OFFICIAL-MSG-1", + raw_message=SimpleNamespace(timestamp=1710000000), + sender=SimpleNamespace(nickname=""), + message=[SimpleNamespace(type="Plain", text="hello")], + ) + self.message_str = "hello" + + def get_group_id(self): + return "GROUP_OPENID" + + def get_sender_id(self): + return "MEMBER_OPENID" + + def get_sender_name(self): + return "" + + def get_platform_id(self): + return "official-main" + + def get_platform_name(self): + return "qq_official" + + +def test_failed_history_insert_releases_official_message_id(): + history_manager = FakeHistoryManager() + registry = FakeGroupRegistry() + service = MessageProcessingService( + SimpleNamespace(message_history_manager=history_manager), registry + ) + event = FakeOfficialEvent() + + with pytest.raises(RuntimeError, match="temporary database failure"): + asyncio.run(service.process_message(event)) + + asyncio.run(service.process_message(event)) + asyncio.run(service.process_message(event)) + + assert history_manager.insert_calls == 2 + assert registry.upsert_calls == 1 diff --git a/tests/test_platform_group_registry.py b/tests/test_platform_group_registry.py new file mode 100644 index 0000000..2467544 --- /dev/null +++ b/tests/test_platform_group_registry.py @@ -0,0 +1,46 @@ +import asyncio + +from src.infrastructure.persistence.platform_group_registry import PlatformGroupRegistry + + +class FakePlugin: + def __init__(self): + self.get_calls = 0 + self.put_calls = 0 + self.registry = {"platforms": {}} + + async def get_kv_data(self, key, default): + self.get_calls += 1 + registries = { + "platform_seen_groups_v1": self.registry, + "telegram_seen_groups_v1": { + "platforms": {"telegram-main": {"legacy-group": {}}} + }, + } + return registries.get(key, default) + + async def put_kv_data(self, key, value): + self.put_calls += 1 + self.registry = value + + +def test_new_and_legacy_group_registries_are_merged(): + plugin = FakePlugin() + plugin.registry = {"platforms": {"telegram-main": {"new-group": {}}}} + registry = PlatformGroupRegistry(plugin) + + group_ids = asyncio.run(registry.get_all_group_ids("telegram-main")) + + assert group_ids == ["legacy-group", "new-group"] + + +def test_repeated_messages_only_persist_a_new_group_once(): + plugin = FakePlugin() + registry = PlatformGroupRegistry(plugin) + + asyncio.run(registry.upsert("official-main", "GROUP_OPENID")) + first_get_calls = plugin.get_calls + asyncio.run(registry.upsert("official-main", "GROUP_OPENID")) + + assert plugin.put_calls == 1 + assert plugin.get_calls == first_get_calls diff --git a/tests/test_qq_official_adapter.py b/tests/test_qq_official_adapter.py new file mode 100644 index 0000000..b7aa843 --- /dev/null +++ b/tests/test_qq_official_adapter.py @@ -0,0 +1,189 @@ +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import Mock + +from src.infrastructure.platform.adapters.qq_official_adapter import QQOfficialAdapter +from src.infrastructure.platform.factory import PlatformAdapterFactory + + +class FakeHistoryManager: + def __init__(self, pages): + self.pages = pages + + async def get(self, platform_id, user_id, page, page_size): + assert platform_id == "official-main" + assert user_id == "GROUP_OPENID" + assert page_size == 500 + return self.pages.get(page, []) + + +def make_record(record_id, message_id, sender_id, timestamp, text): + return SimpleNamespace( + id=record_id, + sender_id=sender_id, + sender_name=sender_id, + created_at=datetime.fromtimestamp(timestamp, timezone.utc), + content={ + "type": "user", + "message": [{"type": "plain", "text": text}], + "_qq_official": { + "message_id": message_id, + "timestamp": timestamp, + }, + }, + ) + + +def make_adapter(): + platform = SimpleNamespace(config={"appid": "1029384756"}) + bot = SimpleNamespace(platform=platform) + return QQOfficialAdapter( + bot, + { + "platform_id": "official-main", + "bot_self_ids": ["BOT_OPENID"], + }, + ) + + +def test_avatar_url_uses_appid_and_member_openid(): + adapter = make_adapter() + assert asyncio.run(adapter.get_user_avatar_url("A1B2C3_OPENID")) == ( + "https://thirdqq.qlogo.cn/qqapp/1029384756/A1B2C3_OPENID/640" + ) + + +def test_local_history_is_deduplicated_filtered_and_sorted(): + adapter = make_adapter() + adapter.set_context( + SimpleNamespace( + message_history_manager=FakeHistoryManager( + { + 1: [ + make_record(1, "MSG-2", "B_OPENID", 200, "second"), + make_record(2, "MSG-1", "A_OPENID", 100, "first"), + make_record(3, "MSG-2", "B_OPENID", 200, "duplicate"), + make_record(4, "MSG-BOT", "BOT_OPENID", 300, "bot"), + ] + } + ) + ) + ) + + messages = asyncio.run( + adapter.fetch_messages("GROUP_OPENID", days=36500, max_count=20) + ) + + assert [message.message_id for message in messages] == ["MSG-1", "MSG-2"] + assert [message.sender_id for message in messages] == ["A_OPENID", "B_OPENID"] + assert [message.text_content for message in messages] == ["first", "second"] + + +def test_factory_registers_both_official_platform_types(): + assert PlatformAdapterFactory.is_supported("qq_official") + assert PlatformAdapterFactory.is_supported("qq_official_webhook") + + +def test_proactive_send_restores_group_scene_after_restart(): + remember_session_scene = Mock() + platform = SimpleNamespace( + config={"appid": "1029384756"}, + remember_session_scene=remember_session_scene, + ) + adapter = QQOfficialAdapter( + SimpleNamespace(platform=platform), + {"platform_id": "official-main"}, + ) + sent = [] + + async def send_message(umo, chain): + sent.append((umo, chain)) + return True + + adapter.set_context(SimpleNamespace(send_message=send_message)) + + assert asyncio.run(adapter._send_chain("GROUP_OPENID", object())) is True + remember_session_scene.assert_called_once_with("GROUP_OPENID", "group") + assert sent[0][0] == "official-main:GroupMessage:GROUP_OPENID" + + +def test_official_adapter_does_not_advertise_reply_support(): + assert make_adapter().get_capabilities().supports_reply_message is False + + +def test_markdown_report_posts_custom_markdown_with_unique_sequences(): + calls = [] + + class FakeAPI: + async def post_group_message(self, **kwargs): + calls.append(kwargs) + return {"id": f"MSG-{len(calls)}"} + + remember_session_scene = Mock() + platform = SimpleNamespace( + config={"appid": "1029384756"}, + remember_session_scene=remember_session_scene, + ) + bot = SimpleNamespace(platform=platform, api=FakeAPI()) + adapter = QQOfficialAdapter(bot, {"platform_id": "official-main"}) + adapter.MARKDOWN_CHUNK_SIZE = 35 + + assert asyncio.run( + adapter.send_text_report( + "GROUP_OPENID", + "# 报告\n\n第一段 <@A_OPENID>\n\n第二段 " + "x" * 40, + ) + ) + + assert len(calls) >= 2 + assert all(call["group_openid"] == "GROUP_OPENID" for call in calls) + assert all(call["msg_type"] == 2 for call in calls) + assert all("markdown" in call for call in calls) + assert len({call["msg_seq"] for call in calls}) == len(calls) + assert all(len(str(call["markdown"])) > 0 for call in calls) + remember_session_scene.assert_called_with("GROUP_OPENID", "group") + + +def test_markdown_report_falls_back_to_plain_text_after_api_failure(): + class FailingAPI: + async def post_group_message(self, **kwargs): + raise RuntimeError("markdown disabled") + + platform = SimpleNamespace( + config={"appid": "1029384756"}, + remember_session_scene=Mock(), + ) + adapter = QQOfficialAdapter( + SimpleNamespace(platform=platform, api=FailingAPI()), + {"platform_id": "official-main"}, + ) + sent = [] + + async def send_text(group_id, text, reply_to=None): + sent.append((group_id, text)) + return True + + adapter.send_text = send_text + + assert asyncio.run( + adapter.send_text_report( + "GROUP_OPENID", + "# 报告\n\n![图表](https://t2i.example/chart.png)", + fallback_content="# 报告\n\n<@A_OPENID> 获得称号", + ) + ) + assert len(sent) == 1 + assert sent[0][0] == "GROUP_OPENID" + assert "<@A_OPENID>" in sent[0][1] + assert "t2i.example" not in sent[0][1] + + +def test_markdown_split_does_not_break_mentions(): + adapter = make_adapter() + adapter.MARKDOWN_CHUNK_SIZE = 20 + chunks = adapter._split_markdown_report("x" * 17 + "<@A_OPENID>" + "tail") + + assert all(len(chunk) <= 20 for chunk in chunks) + assert "".join(chunks) == "x" * 17 + "<@A_OPENID>" + "tail" + assert any("<@A_OPENID>" in chunk for chunk in chunks)