[v4.11.0] - 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145 #206)

* 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 <sxp20061207@163.com>
This commit is contained in:
clown145
2026-07-20 03:24:50 +08:00
committed by GitHub
co-authored by SXP-Simon
parent 6b2d4b1dc3
commit c18224cb82
46 changed files with 2793 additions and 1944 deletions
+13 -1
View File
@@ -1,12 +1,24 @@
# 更新日志 (CHANGELOG) # 更新日志 (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 配置。
--- ---
<details> <details>
<summary>📋 点击查看历史更新日志</summary> <summary>📋 点击查看历史更新日志</summary>
## [v4.10.9] - ✨ 新增模板 ”BlueArchive“ 蔚蓝档案 (@VanillaNahida)
## [v4.10.8] - 🛠️ snowluma 被禁言避免触发分析修复 (#191) ## [v4.10.8] - 🛠️ snowluma 被禁言避免触发分析修复 (#191)
## [v4.10.7] - ✨ 被禁言避免触发分析功能浪费 token,重构并优化模型请求的重试与降级补偿机制 ## [v4.10.7] - ✨ 被禁言避免触发分析功能浪费 token,重构并优化模型请求的重试与降级补偿机制
+28 -40
View File
@@ -23,7 +23,7 @@
</table> </table>
_✨ 一个基于 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**,未来支持更多平台。 ✨_
<img src="https://count.getloli.com/@astrbot-qq-group-daily-analysis?name=astrbot-qq-group-daily-analysis&theme=booru-jaypee&padding=6&offset=0&align=top&scale=1&pixelated=1&darkmode=auto" alt="count" /> <img src="https://count.getloli.com/@astrbot-qq-group-daily-analysis?name=astrbot-qq-group-daily-analysis&theme=booru-jaypee&padding=6&offset=0&align=top&scale=1&pixelated=1&darkmode=auto" alt="count" />
</div> </div>
@@ -60,10 +60,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
<p><b>BlueArchive</b></p> <p><b>BlueArchive</b></p>
<img src="https://fastly.jsdelivr.net/gh/VanillaNahida/astrbot_plugin_qq_group_daily_analysis@main/assets/BlueArchive-demo.jpg" alt="BlueArchive" width="100%"> <img src="https://fastly.jsdelivr.net/gh/VanillaNahida/astrbot_plugin_qq_group_daily_analysis@main/assets/BlueArchive-demo.jpg" alt="BlueArchive" width="100%">
</td> </td>
<td align="center" width="33.3%" valign="top">
<p><b>Simple</b></p>
<img src="https://fastly.jsdelivr.net/gh/SXP-Simon/astrbot_plugin_qq_group_daily_analysis@main/assets/format-demo.jpg" alt="simple" width="100%">
</td>
</tr> </tr>
</table> </table>
@@ -85,25 +81,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
- **QQ群**: 支持上传到群相册和群文件,查阅黑历史友好 - **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] > [!TIP]
> **图片生成失败/渲染超时的解决办法** > **图片生成失败/渲染超时的解决办法**
@@ -118,7 +95,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
> >
> ### 2. 使用备用 T2I 服务或自部署 > ### 2. 使用备用 T2I 服务或自部署
> <details> > <details>
> <summary><b>若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击展开):</b></summary> > <summary><b>若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击此行展开说明):</b></summary>
> >
> - **Hugging Face 服务**: `https://huggingface.co/spaces/clown145/astrbot-t2i-service` > - **Hugging Face 服务**: `https://huggingface.co/spaces/clown145/astrbot-t2i-service`
> - **API 接口地址**: `https://clown145-astrbot-t2i-service.hf.space` > - **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) > **更换 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] > [!IMPORTANT]
> > **QQ 官方机器人用户注意**
> Feishu / Lark (WIP) > 插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。
> > - 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
> 尝试开发中 > - QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
> > - 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
> 通过 `LarkAdapter` 复用 AstrBot 已有的 `lark_oapi` 生态能力,在获得授权后即可对 Feishu 群聊执行完整的消息采集与分析 > - 官方群事件不提供成员昵称
> > - QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
> - **一次性授权**:在飞书开放平台中为你的应用补齐如下权限(仅需在初次部署时授予): > - Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。
> - `im:message:readonly`、`im:chat:readonly`(读取群消息/群信息) > - 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
> - `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 输出。
> [!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** | OneBot v11 | 建议使用 NapCat/Lagrange。需注意消息分页拉取限制。 |
| **QQ 官方机器人** | QQ Bot API v2WebSocket/Webhook | 需开启群全量消息;只分析启用后实时缓存的消息;图片/HTML 仅显示头像,Markdown 文本使用成员艾特。 |
| **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 | | **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 |
| **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 | | **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 |
+16 -3
View File
@@ -19,7 +19,7 @@
"type": "list", "type": "list",
"description": "群组白/黑名单列表", "description": "群组白/黑名单列表",
"default": [], "default": [],
"hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号(如 123456)。新手建议优先填完整会话ID,更不容易填错。可用 /sid 获取当前会话ID。", "hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号。QQ 官方机器人使用 group_openid,不是群号,务必优先通过 /sid 获取并填写完整 UMO。",
"items": { "items": {
"type": "string" "type": "string"
} }
@@ -28,7 +28,7 @@
"type": "int", "type": "int",
"description": "默认分析天数", "description": "默认分析天数",
"default": 1, "default": 1,
"hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况" "hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况。QQ 官方 API 无历史拉取接口,只能分析插件启用后实时缓存到 AstrBot 本地库的消息。"
}, },
"max_messages": { "max_messages": {
"type": "int", "type": "int",
@@ -66,7 +66,7 @@
"html" "html"
], ],
"default": "image", "default": "image",
"hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)" "hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方群事件没有昵称,图片/HTML 报告仅显示头像,文本报告由独立 Markdown 模块使用艾特展示身份;其他平台保持原有昵称文本格式。"
}, },
"report_template": { "report_template": {
"type": "string", "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": { "t2i_rendering": {
"description": "图片渲染策略", "description": "图片渲染策略",
"type": "object", "type": "object",
+82 -13
View File
@@ -7,7 +7,7 @@
import asyncio import asyncio
import os import os
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Callable
from pathlib import Path from pathlib import Path
from astrbot.api import AstrBotConfig 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 import AstrMessageEvent, filter
from astrbot.api.event.filter import PermissionType from astrbot.api.event.filter import PermissionType
from astrbot.api.star import Context, Star, StarTools 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 astrbot.core.message.components import File
from .src.application.commands.template_command_service import ( 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.messaging.message_sender import MessageSender
from .src.infrastructure.persistence.history_manager import HistoryManager from .src.infrastructure.persistence.history_manager import HistoryManager
from .src.infrastructure.persistence.incremental_store import IncrementalStore from .src.infrastructure.persistence.incremental_store import IncrementalStore
from .src.infrastructure.persistence.telegram_group_registry import ( from .src.infrastructure.persistence.platform_group_registry import (
TelegramGroupRegistry, PlatformGroupRegistry,
) )
from .src.infrastructure.platform.bot_manager import BotManager from .src.infrastructure.platform.bot_manager import BotManager
from .src.infrastructure.platform.template_preview import ( 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.reporting.generators import ReportGenerator
from .src.infrastructure.scheduler.auto_scheduler import AutoScheduler 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.constants import PLUGIN_NAME
from .src.shared.trace_context import TraceContext, TraceLogFilter from .src.shared.trace_context import TraceContext, TraceLogFilter
from .src.utils.logger import logger from .src.utils.logger import logger
@@ -60,7 +63,8 @@ class GroupDailyAnalysis(Star):
bot_manager: BotManager bot_manager: BotManager
history_manager: HistoryManager history_manager: HistoryManager
report_generator: ReportGenerator report_generator: ReportGenerator
telegram_group_registry: TelegramGroupRegistry html_render: Callable
platform_group_registry: PlatformGroupRegistry
statistics_service: StatisticsService statistics_service: StatisticsService
analysis_domain_service: AnalysisDomainService analysis_domain_service: AnalysisDomainService
llm_analyzer: LLMAnalyzer llm_analyzer: LLMAnalyzer
@@ -90,10 +94,11 @@ class GroupDailyAnalysis(Star):
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir) self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
# Telegram 注册表 (持久层) # Telegram 注册表 (持久层)
self.telegram_group_registry = TelegramGroupRegistry(self) self.platform_group_registry = PlatformGroupRegistry(self)
# 2. 领域层 # 2. 领域层
self.statistics_service = StatisticsService() activity_visualizer = ActivityVisualizer()
self.statistics_service = StatisticsService(activity_visualizer)
self.analysis_domain_service = AnalysisDomainService() self.analysis_domain_service = AnalysisDomainService()
# 3. 分析核心 (LLM Bridge) # 3. 分析核心 (LLM Bridge)
@@ -118,7 +123,7 @@ class GroupDailyAnalysis(Star):
# 消息处理服务 # 消息处理服务
self.message_processing_service = MessageProcessingService( self.message_processing_service = MessageProcessingService(
context, self.telegram_group_registry context, self.platform_group_registry
) )
self.template_command_service = TemplateCommandService( self.template_command_service = TemplateCommandService(
plugin_root=os.path.dirname(__file__) plugin_root=os.path.dirname(__file__)
@@ -287,11 +292,43 @@ class GroupDailyAnalysis(Star):
except Exception as e: except Exception as e:
logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True) 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( async def get_telegram_seen_group_ids(
self, platform_id: str | None = None self, platform_id: str | None = None
) -> list[str]: ) -> list[str]:
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。""" """读取 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: def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
"""从消息事件中安全获取群组 ID""" """从消息事件中安全获取群组 ID"""
@@ -517,7 +554,15 @@ class GroupDailyAnalysis(Star):
# 表情回应 或 文本提示(二选一,由配置开关控制) # 表情回应 或 文本提示(二选一,由配置开关控制)
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
orig_msg_id = getattr(event.message_obj, "message_id", None) 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: if use_text_reply:
yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...") yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
@@ -577,6 +622,7 @@ class GroupDailyAnalysis(Star):
analysis_result = result["analysis_result"] analysis_result = result["analysis_result"]
adapter = result["adapter"] adapter = result["adapter"]
output_format = self.config_manager.get_output_format() 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: async def avatar_url_getter(user_id: str) -> str | None:
@@ -599,6 +645,7 @@ class GroupDailyAnalysis(Star):
avatar_url_getter=avatar_url_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
avatar_cache_namespace=platform_id, avatar_cache_namespace=platform_id,
hide_user_names=hide_user_names,
) )
if image_url: if image_url:
@@ -610,8 +657,9 @@ class GroupDailyAnalysis(Star):
# 如果图片生成或发送失败,直接回退到文本 # 如果图片生成或发送失败,直接回退到文本
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}") logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
text_report = self.report_generator.generate_text_report(analysis_result) await self._send_text_reports(
await adapter.send_text_report(group_id, text_report) group_id, analysis_result, hide_user_names, adapter
)
return return
elif output_format == "html": elif output_format == "html":
@@ -621,6 +669,7 @@ class GroupDailyAnalysis(Star):
avatar_url_getter=avatar_url_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
avatar_cache_namespace=platform_id, avatar_cache_namespace=platform_id,
hide_user_names=hide_user_names,
) )
if html_path: if html_path:
is_only_url = self.config_manager.get_html_only_url() is_only_url = self.config_manager.get_html_only_url()
@@ -681,8 +730,28 @@ class GroupDailyAnalysis(Star):
yield event.plain_result("⚠️ HTML 生成失败。") yield event.plain_result("⚠️ HTML 生成失败。")
else: else:
text_report = self.report_generator.generate_text_report(analysis_result) await self._send_text_reports(
await adapter.send_text_report(group_id, text_report) 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.command("设置格式", alias={"set_format"})
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
+4 -3
View File
@@ -1,12 +1,13 @@
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。 name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。
display_name: 群分析总结插件 # 插件的显示名称 display_name: 群分析总结插件 # 插件的显示名称
desc: "[多平台接入开发中] 群日常分析总结插件 - 支持 QQ (aiocqhttp)、Telegram、Discord 以及 Feishu (Lark);生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述 desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
version: v4.10.9 # 插件版本号。格式:v1.1.1 或者 v1.1 version: v4.11.0 # 插件版本号。格式:v1.1.1 或者 v1.1
author: SXP-Simon # 作者 author: SXP-Simon # 作者
astrbot_version: ">=4.16.0" astrbot_version: ">=4.16.0"
support_platforms: support_platforms:
- aiocqhttp - aiocqhttp
- discord - discord
- telegram - telegram
- lark - qq_official
- qq_official_webhook
repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址 repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址
@@ -216,7 +216,6 @@ class AnalysisApplicationService:
) )
# 4. 用户分析 (Domain Service) # 4. 用户分析 (Domain Service)
bot_self_ids = self.config_manager.get_bot_self_ids()
user_activity = await asyncio.to_thread( user_activity = await asyncio.to_thread(
self.analysis_domain_service.analyze_user_activity, self.analysis_domain_service.analyze_user_activity,
unified_messages, unified_messages,
@@ -1,10 +1,10 @@
import re import re
from collections import Counter from collections import Counter, OrderedDict
from astrbot.api.event import AstrMessageEvent from astrbot.api.event import AstrMessageEvent
from astrbot.api.star import Context 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 from ...utils.logger import logger
@@ -12,27 +12,36 @@ class MessageProcessingService:
""" """
消息处理服务 消息处理服务
负责处理接收到的消息事件 解析收到的消息事件,提取内容与发送者信息,持久化历史记录,
并维护事件驱动平台(Telegram、QQ 官方等)的群组注册表。
QQ 官方平台特有的重复消息去重逻辑也在本服务中处理。
职责:
1. 解析消息内容(文本、图片、@提及等) 1. 解析消息内容(文本、图片、@提及等)
2. 解析发送者信息(跨平台兼容) 2. 解析发送者展示名(跨平台兼容)
3. 存储消息历史 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.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: async def process_message(self, event: AstrMessageEvent) -> None:
""" """
处理并在历史记录中存储消息。 处理并在历史记录中存储消息。
被 main.py 的 Telegram 和 QQ 官方消息拦截器共同调用。
Args: Args:
event: AstrBot 消息事件 event: AstrBot 消息事件
Raises: Raises:
ValueError: 当必要数据无法获取时 ValueError: 当必要数据无法获取时
RuntimeError: 当消息内容为空时 RuntimeError: 当消息内容为空时
""" """
# 1. 获取群组 ID(必需) # 1. 获取群组 ID(必需)
group_id = self._get_group_id_from_event(event) group_id = self._get_group_id_from_event(event)
@@ -62,37 +71,63 @@ class MessageProcessingService:
f"{group_id}: 消息内容为空 (sender={sender_name}),拒绝存储" f"{group_id}: 消息内容为空 (sender={sender_name}),拒绝存储"
) )
# 6. 提取事件消息 ID(用于 Telegram 已见群/话题记录) # 6. 提取事件消息 ID 和事件时间
msg_obj = getattr(event, "message_obj", None) msg_obj = getattr(event, "message_obj", None)
event_message_id = str(getattr(msg_obj, "message_id", "") or "") 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. 存储到数据库 # 7. 存储到数据库
await self.context.message_history_manager.insert( try:
platform_id=platform_id, await self.context.message_history_manager.insert(
user_id=group_id, platform_id=platform_id,
content={"type": "user", "message": message_parts}, user_id=group_id,
sender_id=sender_id, content=history_content,
sender_name=sender_name, 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: 记录已见群/话题 # Register the group so the scheduler can discover platforms that
if self._is_telegram_event(event, platform_id): # do not provide a group-list API (Telegram, QQ Official, etc.).
try: try:
await self.telegram_registry.upsert( await self.group_registry.upsert(
platform_id=platform_id, platform_id=platform_id,
group_id=group_id, group_id=group_id,
sender_id=sender_id, sender_id=sender_id,
sender_name=sender_name, sender_name=sender_name,
event_message_id=event_message_id, event_message_id=event_message_id,
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"[TGRegistry] Upsert failed: " "[GroupRegistry] Upsert failed: "
f"platform_id={platform_id} group_id={group_id} error={e}" f"platform_id={platform_id} group_id={group_id} error={e}"
) )
logger.info( logger.debug(
f"[Telegram] [{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})" f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})"
) )
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None: 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: if not message_parts and event.message_str:
message_parts.append({"type": "plain", "text": event.message_str}) message_parts.append({"type": "plain", "text": event.message_str})
@@ -261,9 +314,57 @@ class MessageProcessingService:
return normalized == str(sender_id).strip() return normalized == str(sender_id).strip()
@staticmethod @staticmethod
def _is_telegram_event(event: AstrMessageEvent, platform_id: str) -> bool: def _extract_event_timestamp(message_obj: object) -> int:
"""判断当前事件是否为 Telegram 平台""" """从消息对象中提取平台事件时间戳。"""
platform_name = str(event.get_platform_name() or "").strip().lower() raw_message = getattr(message_obj, "raw_message", None)
if platform_name == "telegram": if isinstance(raw_message, dict):
return True candidate = raw_message.get("timestamp")
return str(platform_id or "").strip().lower().startswith("telegram") 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)
-19
View File
@@ -3,35 +3,16 @@
该模块导出所有领域实体类,包括: 该模块导出所有领域实体类,包括:
- AnalysisTask: 分析任务聚合根 - AnalysisTask: 分析任务聚合根
- GroupAnalysisResult: 群聊分析结果实体
- IncrementalBatch: 增量分析独立批次实体 - IncrementalBatch: 增量分析独立批次实体
- IncrementalState: 增量分析聚合视图(报告时使用) - IncrementalState: 增量分析聚合视图(报告时使用)
""" """
from .analysis_result import (
ActivityVisualization,
EmojiStatistics,
GoldenQuote,
GroupAnalysisResult,
GroupStatistics,
SummaryTopic,
TokenUsage,
UserTitle,
)
from .analysis_task import AnalysisTask, TaskStatus from .analysis_task import AnalysisTask, TaskStatus
from .incremental_state import IncrementalBatch, IncrementalState from .incremental_state import IncrementalBatch, IncrementalState
__all__ = [ __all__ = [
"AnalysisTask", "AnalysisTask",
"TaskStatus", "TaskStatus",
"GroupAnalysisResult",
"SummaryTopic",
"UserTitle",
"GoldenQuote",
"TokenUsage",
"EmojiStatistics",
"ActivityVisualization",
"GroupStatistics",
"IncrementalBatch", "IncrementalBatch",
"IncrementalState", "IncrementalState",
] ]
-124
View File
@@ -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)
+2
View File
@@ -1,10 +1,12 @@
# 仓储接口 # 仓储接口
from .avatar_repository import IAvatarRepository from .avatar_repository import IAvatarRepository
from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender
from .visualization_repository import IActivityVisualizer
__all__ = [ __all__ = [
"IMessageRepository", "IMessageRepository",
"IMessageSender", "IMessageSender",
"IGroupInfoRepository", "IGroupInfoRepository",
"IAvatarRepository", "IAvatarRepository",
"IActivityVisualizer",
] ]
@@ -21,6 +21,7 @@ class IReportGenerator(ABC):
avatar_url_getter: Any = None, avatar_url_getter: Any = None,
nickname_getter: Any = None, nickname_getter: Any = None,
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
hide_user_names: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""生成图片报告""" """生成图片报告"""
pass pass
@@ -33,6 +34,7 @@ class IReportGenerator(ABC):
avatar_url_getter: Any = None, avatar_url_getter: Any = None,
nickname_getter: Any = None, nickname_getter: Any = None,
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
hide_user_names: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""生成 HTML 报告""" """生成 HTML 报告"""
pass pass
@@ -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
-23
View File
@@ -3,33 +3,10 @@
该模块导出所有封装核心业务逻辑的领域服务, 该模块导出所有封装核心业务逻辑的领域服务,
用于分析群聊数据。这些服务是平台无关的。 用于分析群聊数据。这些服务是平台无关的。
服务分类:
- 分析器服务: 话题分析、用户称号分析、金句分析
- 计算服务: 统计计算
- 生成服务: 报告生成
""" """
from .golden_quote_analyzer import GoldenQuoteAnalyzerAdapter, IGoldenQuoteAnalyzer
from .incremental_merge_service import IncrementalMergeService 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__ = [ __all__ = [
# 统计与报告服务
"StatisticsCalculator",
"ReportGenerator",
# 增量合并服务
"IncrementalMergeService", "IncrementalMergeService",
# 话题分析服务
"ITopicAnalyzer",
"TopicAnalyzerAdapter",
# 用户称号分析服务
"IUserTitleAnalyzer",
"UserTitleAnalyzerAdapter",
# 金句分析服务
"IGoldenQuoteAnalyzer",
"GoldenQuoteAnalyzerAdapter",
] ]
@@ -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,
}
+7 -13
View File
@@ -12,17 +12,15 @@ from ..value_objects.unified_message import (
UnifiedMessage, UnifiedMessage,
) )
# Discord 自定义表情正则 <:name:id> 或 <a:name:id>
_DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"<a?:.+?:\d+>")
# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息
_COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/")
class MessageCleanerService: class MessageCleanerService:
"""消息清理服务""" """消息清理服务"""
# Discord 自定义表情正则 <:name:id> 或 <a:name:id>
DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"<a?:.+?:\d+>")
# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息
# 比如: "/group_analysis", "@bot /help", " /test"
COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/")
def clean_messages( def clean_messages(
self, self,
messages: list[UnifiedMessage], messages: list[UnifiedMessage],
@@ -51,11 +49,7 @@ class MessageCleanerService:
# 2. 预检指令消息(首个内容块通常是文本) # 2. 预检指令消息(首个内容块通常是文本)
is_command = False is_command = False
first_text = msg.text_content first_text = msg.text_content
if ( if filter_commands and first_text and _COMMAND_PATTERN.match(first_text):
filter_commands
and first_text
and self.COMMAND_PATTERN.match(first_text)
):
is_command = True is_command = True
if is_command: if is_command:
@@ -70,7 +64,7 @@ class MessageCleanerService:
text = content.text or "" text = content.text or ""
# 移除 Discord 原始表情代码 # 移除 Discord 原始表情代码
text = self.DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text) text = _DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text)
# 移除 @mentions 文本 (e.g. <@123456>) # 移除 @mentions 文本 (e.g. <@123456>)
text = re.sub(r"<@\d+>", "", text) text = re.sub(r"<@\d+>", "", text)
-245
View File
@@ -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)
@@ -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)"
+7 -2
View File
@@ -8,14 +8,19 @@ from datetime import datetime
from ...infrastructure.visualization.activity_charts import ActivityVisualizer from ...infrastructure.visualization.activity_charts import ActivityVisualizer
from ..models.data_models import EmojiStatistics, GroupStatistics, TokenUsage from ..models.data_models import EmojiStatistics, GroupStatistics, TokenUsage
from ..repositories.visualization_repository import IActivityVisualizer
from ..value_objects.unified_message import MessageContentType, UnifiedMessage from ..value_objects.unified_message import MessageContentType, UnifiedMessage
class StatisticsService: class StatisticsService:
"""统计服务 - 处理群聊数据的聚合统计""" """统计服务 - 处理群聊数据的聚合统计"""
def __init__(self): def __init__(self, activity_visualizer: IActivityVisualizer | None = None):
self.activity_visualizer = ActivityVisualizer() if activity_visualizer is None:
# Fallback: keep backward compatibility
self.activity_visualizer: IActivityVisualizer = ActivityVisualizer()
else:
self.activity_visualizer = activity_visualizer
def calculate_group_statistics( def calculate_group_statistics(
self, messages: list[UnifiedMessage] self, messages: list[UnifiedMessage]
-128
View File
@@ -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,
}
-138
View File
@@ -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,
}
-23
View File
@@ -1,17 +1,7 @@
# 值对象 # 值对象
from .golden_quote import GoldenQuote, GoldenQuoteCollection
from .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities 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_group import UnifiedGroup, UnifiedMember
from .unified_message import MessageContent, MessageContentType, UnifiedMessage from .unified_message import MessageContent, MessageContentType, UnifiedMessage
from .user_title import UserTitle, UserTitleCollection
__all__ = [ __all__ = [
# 核心平台抽象 # 核心平台抽象
@@ -22,17 +12,4 @@ __all__ = [
"PLATFORM_CAPABILITIES", "PLATFORM_CAPABILITIES",
"UnifiedGroup", "UnifiedGroup",
"UnifiedMember", "UnifiedMember",
# 分析值对象
"Topic",
"TopicCollection",
"UserTitle",
"UserTitleCollection",
"GoldenQuote",
"GoldenQuoteCollection",
# 统计
"TokenUsage",
"EmojiStatistics",
"ActivityVisualization",
"GroupStatistics",
"UserStatistics",
] ]
-98
View File
@@ -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)
@@ -254,6 +254,31 @@ LARK_CAPABILITIES = PlatformCapabilities(
avatar_sizes=(72, 240, 640), 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] = { PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
"aiocqhttp": ONEBOT_V11_CAPABILITIES, "aiocqhttp": ONEBOT_V11_CAPABILITIES,
@@ -262,6 +287,8 @@ PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
"discord": DISCORD_CAPABILITIES, "discord": DISCORD_CAPABILITIES,
"slack": SLACK_CAPABILITIES, "slack": SLACK_CAPABILITIES,
"lark": LARK_CAPABILITIES, "lark": LARK_CAPABILITIES,
"qq_official": QQ_OFFICIAL_CAPABILITIES,
"qq_official_webhook": QQ_OFFICIAL_CAPABILITIES,
} }
-327
View File
@@ -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,
}
-96
View File
@@ -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)
-100
View File
@@ -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)
@@ -362,11 +362,15 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
for topic in topics: for topic in topics:
raw_ids = topic.contributors # LLM 返回的是 ID 列表 raw_ids = topic.contributors # LLM 返回的是 ID 列表
# 填充 contributor_ids # 填充 contributor_ids。QQ 官方 member_openid 并非纯数字,
# 过滤掉非数字的脏数据 (LLM 偶尔会发疯) # 因此仅接受本批次已知用户或已配置机器人 ID,而不是用 isdigit 过滤。
valid_ids = [ bot_ids = {str(uid) for uid in self.config_manager.get_bot_self_ids()}
str(uid).strip() for uid in raw_ids if str(uid).strip().isdigit() 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 topic.contributor_ids = valid_ids
# 映射回昵称用于显示 # 映射回昵称用于显示
@@ -376,7 +380,6 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
name = id_to_nickname.get(uid) name = id_to_nickname.get(uid)
if not name: if not name:
# 尝试去全局配置里找 (e.g. 机器人自己) # 尝试去全局配置里找 (e.g. 机器人自己)
bot_ids = self.config_manager.get_bot_self_ids()
if uid in bot_ids: if uid in bot_ids:
name = "Bot" name = "Bot"
else: else:
+20 -44
View File
@@ -51,6 +51,21 @@ class LLMAnalyzer(IAnalysisProvider):
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager) self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
self.chat_quality_analyzer = ChatQualityAnalyzer(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( async def analyze_topics(
self, self,
messages: list[dict], messages: list[dict],
@@ -70,16 +85,7 @@ class LLMAnalyzer(IAnalysisProvider):
(话题列表, Token使用统计) (话题列表, Token使用统计)
""" """
try: try:
if not session_id: session_id = self._make_session_id(session_id, umo)
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
logger.info(f"开始话题分析, session_id: {session_id}") logger.info(f"开始话题分析, session_id: {session_id}")
return await self.topic_analyzer.analyze_topics(messages, umo, session_id) return await self.topic_analyzer.analyze_topics(messages, umo, session_id)
@@ -110,15 +116,7 @@ class LLMAnalyzer(IAnalysisProvider):
(用户称号列表, Token使用统计) (用户称号列表, Token使用统计)
""" """
try: try:
if not session_id: session_id = self._make_session_id(session_id, umo)
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
logger.info(f"开始用户称号分析, session_id: {session_id}") logger.info(f"开始用户称号分析, session_id: {session_id}")
return await self.user_title_analyzer.analyze_user_titles( return await self.user_title_analyzer.analyze_user_titles(
@@ -147,15 +145,7 @@ class LLMAnalyzer(IAnalysisProvider):
(金句列表, Token使用统计) (金句列表, Token使用统计)
""" """
try: try:
if not session_id: session_id = self._make_session_id(session_id, umo)
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
logger.info(f"开始金句分析, session_id: {session_id}") logger.info(f"开始金句分析, session_id: {session_id}")
return await self.golden_quote_analyzer.analyze_golden_quotes( return await self.golden_quote_analyzer.analyze_golden_quotes(
@@ -211,14 +201,7 @@ class LLMAnalyzer(IAnalysisProvider):
(话题列表, 用户称号列表, 金句列表, 总Token使用统计) (话题列表, 用户称号列表, 金句列表, 总Token使用统计)
""" """
try: try:
from datetime import datetime session_id = self._make_session_id(None, umo)
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
logger.info( logger.info(
f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}" f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}"
@@ -349,14 +332,7 @@ class LLMAnalyzer(IAnalysisProvider):
(话题列表, 金句列表, 总Token使用统计) (话题列表, 金句列表, 总Token使用统计)
""" """
try: try:
from datetime import datetime session_id = self._make_session_id(None, umo, "incr_")
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}"
logger.info( logger.info(
f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled})" f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled})"
@@ -16,6 +16,7 @@ class ConfigManager:
配置结构采用分组嵌套方式,顶层分为以下分组: 配置结构采用分组嵌套方式,顶层分为以下分组:
- basic: 基础设置 - basic: 基础设置
- qq_official: QQ 官方机器人展示设置
- auto_analysis: 自动分析设置 - auto_analysis: 自动分析设置
- llm: LLM 设置 - llm: LLM 设置
- analysis_features: 分析功能开关 - analysis_features: 分析功能开关
@@ -149,6 +150,13 @@ class ConfigManager:
"""获取输出格式""" """获取输出格式"""
return self._get_group("basic").get("output_format", "image") 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: def get_min_messages_threshold(self) -> int:
"""获取最小消息阈值""" """获取最小消息阈值"""
return self._get_group("basic").get("min_messages_threshold", 50) return self._get_group("basic").get("min_messages_threshold", 50)
@@ -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
+12 -2
View File
@@ -1,7 +1,17 @@
# 平台适配器 from .adapters.discord_adapter import DiscordAdapter
from .adapters.lark_adapter import LarkAdapter from .adapters.lark_adapter import LarkAdapter
from .adapters.onebot_adapter import OneBotAdapter from .adapters.onebot_adapter import OneBotAdapter
from .adapters.qq_official_adapter import QQOfficialAdapter
from .adapters.telegram_adapter import TelegramAdapter
from .base import PlatformAdapter from .base import PlatformAdapter
from .factory import PlatformAdapterFactory from .factory import PlatformAdapterFactory
__all__ = ["PlatformAdapterFactory", "PlatformAdapter", "OneBotAdapter", "LarkAdapter"] __all__ = [
"PlatformAdapterFactory",
"PlatformAdapter",
"OneBotAdapter",
"LarkAdapter",
"QQOfficialAdapter",
"TelegramAdapter",
"DiscordAdapter",
]
@@ -1,6 +1,35 @@
# 平台适配器 """Optional platform adapter exports.
from .discord_adapter import DiscordAdapter
from .lark_adapter import LarkAdapter
from .onebot_adapter import OneBotAdapter
__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
@@ -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)))
@@ -75,6 +75,10 @@ class BotManager:
"platform_id": str(platform_id), "platform_id": str(platform_id),
"plugin_instance": self._plugin_instance, "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( adapter = PlatformAdapterFactory.create(
platform_name, bot_instance, adapter_config platform_name, bot_instance, adapter_config
) )
+8
View File
@@ -99,5 +99,13 @@ def _register_adapters():
except ImportError: except ImportError:
pass 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() _register_adapters()
+27 -3
View File
@@ -31,6 +31,10 @@ class ReportDispatcher:
"""设置 HTML 渲染函数 (运行时注入)""" """设置 HTML 渲染函数 (运行时注入)"""
self._html_render_func = render_func 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( async def dispatch(
self, self,
group_id: str, group_id: str,
@@ -88,6 +92,7 @@ class ReportDispatcher:
self._html_render_func, self._html_render_func,
avatar_url_getter=avatar_url_getter, avatar_url_getter=avatar_url_getter,
avatar_cache_namespace=platform_id, avatar_cache_namespace=platform_id,
hide_user_names=self._hide_user_names(platform_id),
) )
except Exception as e: except Exception as e:
logger.error(f"[{trace_id}] Failed to generate image report: {e}") logger.error(f"[{trace_id}] Failed to generate image report: {e}")
@@ -135,6 +140,7 @@ class ReportDispatcher:
group_id, group_id,
avatar_url_getter=avatar_url_getter, avatar_url_getter=avatar_url_getter,
avatar_cache_namespace=platform_id, avatar_cache_namespace=platform_id,
hide_user_names=self._hide_user_names(platform_id),
) )
except Exception as e: except Exception as e:
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}") logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
@@ -196,13 +202,31 @@ class ReportDispatcher:
) -> bool: ) -> bool:
"""分发文本报告""" """分发文本报告"""
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告") 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) adapter = self.message_sender.bot_manager.get_adapter(platform_id)
# 尝试通过适配器发送文本报告 # 尝试通过适配器发送文本报告
logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}") logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}")
try: try:
if adapter and await adapter.send_text_report(group_id, text_report): if adapter:
return True 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( return await self.message_sender.send_text(
group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id
) )
+270 -17
View File
@@ -5,6 +5,7 @@
import asyncio import asyncio
import base64 import base64
import copy
import hashlib import hashlib
import html import html
import json import json
@@ -14,6 +15,7 @@ from dataclasses import asdict, is_dataclass
from datetime import date, datetime from datetime import date, datetime
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any
from urllib.parse import quote from urllib.parse import quote
import aiohttp import aiohttp
@@ -25,6 +27,7 @@ from ...domain.repositories.report_repository import IReportGenerator
from ...utils.logger import logger from ...utils.logger import logger
from ..utils.template_utils import render_template from ..utils.template_utils import render_template
from ..visualization.activity_charts import ActivityVisualizer from ..visualization.activity_charts import ActivityVisualizer
from .qq_official_markdown import QQOfficialMarkdownReportGenerator
from .templates import HTMLTemplates from .templates import HTMLTemplates
MAX_CONCURRENT_DOWNLOADS = 10 MAX_CONCURRENT_DOWNLOADS = 10
@@ -105,6 +108,11 @@ class ReportGenerator(IReportGenerator):
# 使用专用的 T2I 并发配置项 # 使用专用的 T2I 并发配置项
max_concurrent = self.config_manager.get_t2i_max_concurrent() max_concurrent = self.config_manager.get_t2i_max_concurrent()
self._render_semaphore = asyncio.Semaphore(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( self._avatar_cache = Cache(
@@ -336,6 +344,7 @@ class ReportGenerator(IReportGenerator):
avatar_url_getter=None, avatar_url_getter=None,
nickname_getter=None, nickname_getter=None,
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
hide_user_names: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
""" """
生成图片格式的分析报告 生成图片格式的分析报告
@@ -359,6 +368,7 @@ class ReportGenerator(IReportGenerator):
avatar_url_getter=avatar_url_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
avatar_cache_namespace=avatar_cache_namespace, avatar_cache_namespace=avatar_cache_namespace,
hide_user_names=hide_user_names,
) )
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签) # 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
@@ -500,6 +510,7 @@ class ReportGenerator(IReportGenerator):
avatar_url_getter=None, avatar_url_getter=None,
nickname_getter=None, nickname_getter=None,
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
hide_user_names: bool = False,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
""" """
生成HTML格式的分析报告,保存到指定目录 生成HTML格式的分析报告,保存到指定目录
@@ -544,6 +555,7 @@ class ReportGenerator(IReportGenerator):
avatar_url_getter=avatar_url_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
avatar_cache_namespace=avatar_cache_namespace, avatar_cache_namespace=avatar_cache_namespace,
hide_user_names=hide_user_names,
) )
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段") logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
@@ -603,7 +615,11 @@ class ReportGenerator(IReportGenerator):
# 保存原始 JSON 数据 # 保存原始 JSON 数据
json_data = { 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, "group_id": group_id,
"generated_at": datetime.now().isoformat(), "generated_at": datetime.now().isoformat(),
} }
@@ -689,6 +705,88 @@ class ReportGenerator(IReportGenerator):
return report 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( async def _prepare_render_data(
self, self,
analysis_result: dict, analysis_result: dict,
@@ -696,6 +794,7 @@ class ReportGenerator(IReportGenerator):
avatar_url_getter=None, avatar_url_getter=None,
nickname_getter=None, nickname_getter=None,
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
hide_user_names: bool = False,
) -> dict: ) -> dict:
"""准备渲染数据""" """准备渲染数据"""
stats = analysis_result["statistics"] stats = analysis_result["statistics"]
@@ -720,18 +819,34 @@ class ReportGenerator(IReportGenerator):
avatar_cache_namespace, avatar_cache_namespace,
avatar_reuse_registry, avatar_reuse_registry,
avatar_reuse_aliases, 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( topics_list.append(
{ {
"index": i, "index": i,
"topic": topic, "topic": {
"contributors": "".join(topic.contributors), "topic": self._sanitize_identity_text(
topic.topic, analysis_result, hide_user_names
)
},
"contributors": contributors,
"detail": processed_detail, "detail": processed_detail,
} }
) )
# 通用模板上下文,包含可能被子模板引用的全局配置 # 通用模板上下文,包含可能被子模板引用的全局配置
common_context = { common_context = {
"hide_user_names": hide_user_names,
"t2i_font_source": self.config_manager.get_t2i_font_source(), "t2i_font_source": self.config_manager.get_t2i_font_source(),
"t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(), "t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(),
"t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(), "t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(),
@@ -763,11 +878,23 @@ class ReportGenerator(IReportGenerator):
profile_info = self._resolve_profile_info( profile_info = self._resolve_profile_info(
title.mbti, profile_mode, profile_mapping_overrides 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 = { title_data = {
"name": title.name, "name": "" if hide_user_names else title.name,
"title": title.title, "title": title.title,
"mbti": title.mbti, "mbti": title.mbti,
"reason": title.reason, "reason": title_reason,
"avatar_data": avatar_data, "avatar_data": avatar_data,
} }
title_data.update(profile_info) title_data.update(profile_info)
@@ -810,11 +937,14 @@ class ReportGenerator(IReportGenerator):
avatar_cache_namespace, avatar_cache_namespace,
avatar_reuse_registry, avatar_reuse_registry,
avatar_reuse_aliases, avatar_reuse_aliases,
hide_user_names=hide_user_names,
) )
quotes_list.append( quotes_list.append(
{ {
"content": golden_quote.content, "content": self._sanitize_identity_text(
"sender": golden_quote.sender, golden_quote.content, analysis_result, hide_user_names
),
"sender": "" if hide_user_names else golden_quote.sender,
"reason": processed_reason, "reason": processed_reason,
"avatar_url": avatar_url, "avatar_url": avatar_url,
} }
@@ -860,6 +990,33 @@ class ReportGenerator(IReportGenerator):
else: else:
review_data = chat_quality_review 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_html = self.html_templates.render_template(
"chat_quality_item.html", **review_data, **common_context "chat_quality_item.html", **review_data, **common_context
) )
@@ -899,6 +1056,50 @@ class ReportGenerator(IReportGenerator):
logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段") logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段")
return 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'<span class="user-capsule-avatar" '
f'data-avatar-ref="{html.escape(avatar_ref, quote=True)}" '
f'style="{style}"></span>'
)
)
else:
avatars.append(
Markup(
f'<img src="{html.escape(avatar_url, quote=True)}" '
f'style="{style}">'
)
)
return Markup("").join(avatars)
async def _render_mentions( async def _render_mentions(
self, self,
text: str, text: str,
@@ -908,20 +1109,41 @@ class ReportGenerator(IReportGenerator):
avatar_cache_namespace: str | None = None, avatar_cache_namespace: str | None = None,
avatar_reuse_registry: dict[str, str] | None = None, avatar_reuse_registry: dict[str, str] | None = None,
avatar_reuse_aliases: dict[str, str] | None = None, avatar_reuse_aliases: dict[str, str] | None = None,
hide_user_names: bool = False,
) -> Markup: ) -> Markup:
""" """
处理文本,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式 处理文本,将 [用户ID] 格式的引用替换为头像胶囊。
""" """
pattern = r"\[(\d+)\]"
if not text: if not text:
return Markup("") 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"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}"
rf"(?![A-Za-z0-9_-])(?!\])",
f"[{user_id}]",
source_text,
)
pattern = r"\[([A-Za-z0-9_-]{1,128})\]" if hide_user_names else r"\[(\d+)\]"
matches = list(re.finditer(pattern, source_text))
if not matches: if not matches:
return self._escape_text_segment(text) return self._escape_text_segment(source_text)
async def render_capsule(match: re.Match[str]) -> Markup: async def render_capsule(match: re.Match[str]) -> Markup:
uid = match.group(1) 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( url = await self._get_user_avatar(
uid, avatar_url_getter, avatar_cache_namespace 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;" "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;" "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;" name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;"
# 3. 最终后备: 确保有头像和名称 # 3. 最终后备: 确保有头像和名称
@@ -979,23 +1204,51 @@ class ReportGenerator(IReportGenerator):
f'style="{img_style}">' f'style="{img_style}">'
) )
name_html = (
""
if hide_user_names
else f'<span style="{name_style}">{html.escape(final_name)}</span>'
)
return Markup( return Markup(
f'<span class="user-capsule" style="{capsule_style}">' f'<span class="user-capsule" style="{capsule_style}">'
f"{avatar_html}" f"{avatar_html}{name_html}</span>"
f'<span style="{name_style}">{html.escape(final_name)}</span>'
"</span>"
) )
result: list[Markup | str] = [] result: list[Markup | str] = []
last_end = 0 last_end = 0
for match in matches: 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)) result.append(await render_capsule(match))
last_end = match.end() 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) 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 @staticmethod
def _escape_text_segment(text: str) -> Markup: def _escape_text_segment(text: str) -> Markup:
return Markup(html.escape(text, quote=False).replace("\n", "<br>")) return Markup(html.escape(text, quote=False).replace("\n", "<br>"))
@@ -0,0 +1,173 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=800, initial-scale=1">
<style>
* {
box-sizing: border-box;
}
html,
body {
width: 800px;
height: 360px;
margin: 0;
padding: 0;
overflow: hidden;
background: transparent !important;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Helvetica Neue", sans-serif;
color: #000000;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
.dashboard {
width: 800px;
height: 360px;
padding: 18px 22px 12px;
display: grid;
grid-template-rows: 36px 78px 204px;
row-gap: 6px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px;
}
.title {
color: #000000;
font-size: 25px;
font-weight: 650;
letter-spacing: 0.6px;
}
.date {
color: #000000;
font-size: 17px;
font-weight: 600;
letter-spacing: 0.6px;
}
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: center;
border-top: 1px solid #000000;
border-bottom: 1px solid #000000;
}
.metric {
min-width: 0;
height: 58px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
}
.metric + .metric {
border-left: 1px solid #000000;
}
.metric-value {
max-width: 100%;
color: #000000;
font-size: 28px;
font-weight: 650;
line-height: 31px;
letter-spacing: 0.2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metric-label {
color: #000000;
font-size: 14px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.8px;
}
.histogram {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
column-gap: 5px;
align-items: stretch;
}
.hour {
min-width: 0;
display: grid;
grid-template-rows: 180px 20px;
}
.bar-area {
min-width: 0;
display: flex;
align-items: flex-end;
justify-content: center;
border-bottom: 1px solid #000000;
}
.bar {
width: min(16px, 72%);
height: var(--height);
min-height: var(--min-height);
border-radius: 3px 3px 1px 1px;
background: #000000;
}
.hour-label {
display: flex;
align-items: flex-end;
justify-content: center;
color: #000000;
font-size: 12px;
font-weight: 600;
line-height: 17px;
letter-spacing: -0.2px;
}
</style>
</head>
<body>
<div class="dashboard">
<div class="header">
<div class="title">{{ report_title }}</div>
<div class="date">{{ report_date }}</div>
</div>
<div class="metrics">
{% for metric in metrics %}
<div class="metric">
<div class="metric-value">{{ metric.value }}</div>
<div class="metric-label">{{ metric.label }}</div>
</div>
{% endfor %}
</div>
<div class="histogram">
{% for item in chart_data %}
<div class="hour">
<div class="bar-area">
<div
class="bar"
style="--height: {{ item.height }}%; --min-height: {{ '3px' if item.count > 0 else '0' }};"
></div>
</div>
<div class="hour-label">{{ item.hour }}</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>
@@ -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"(?<![A-Za-z0-9_-]){re.escape(user_id)}(?![A-Za-z0-9_-])",
mention,
source,
)
source = re.sub(r"<@[A-Za-z0-9_-]+>", 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()
+26
View File
@@ -20,6 +20,9 @@ class HTMLTemplates:
self.config_manager = config_manager self.config_manager = config_manager
# 设置模板根目录 # 设置模板根目录
self.base_dir = os.path.join(os.path.dirname(__file__), "templates") 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环境(多线程安全) # 缓存不同模板的Jinja2环境(多线程安全)
self._envs = {} self._envs = {}
self._env_lock = threading.Lock() self._env_lock = threading.Lock()
@@ -73,6 +76,9 @@ class HTMLTemplates:
try: try:
env = await self._get_env_async() env = await self._get_env_async()
template = env.get_template("image_template.html") template = env.get_template("image_template.html")
if template.filename is None:
logger.error("图片模板路径为空")
return ""
return await asyncio.to_thread( return await asyncio.to_thread(
self._read_template_file_sync, template.filename self._read_template_file_sync, template.filename
) )
@@ -85,6 +91,9 @@ class HTMLTemplates:
try: try:
env = self._get_env() env = self._get_env()
template = env.get_template("image_template.html") template = env.get_template("image_template.html")
if template.filename is None:
logger.error("图片模板路径为空")
return ""
with open(template.filename, encoding="utf-8") as f: with open(template.filename, encoding="utf-8") as f:
return f.read() return f.read()
except Exception as e: except Exception as e:
@@ -108,3 +117,20 @@ class HTMLTemplates:
except Exception as e: except Exception as e:
logger.error(f"渲染模板 {template_name} 失败: {e}") logger.error(f"渲染模板 {template_name} 失败: {e}")
return "" 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 ""
@@ -7,9 +7,10 @@ from collections import defaultdict
from datetime import datetime from datetime import datetime
from ...domain.models.data_models import ActivityVisualization from ...domain.models.data_models import ActivityVisualization
from ...domain.repositories.visualization_repository import IActivityVisualizer
class ActivityVisualizer: class ActivityVisualizer(IActivityVisualizer):
"""活跃度可视化器""" """活跃度可视化器"""
def __init__(self): def __init__(self):
+25
View File
@@ -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)
+548
View File
@@ -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 ">0304<" 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"] == []
)
+70
View File
@@ -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
+46
View File
@@ -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
+189
View File
@@ -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)