mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 05:31:52 +00:00
[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:
+13
-1
@@ -1,12 +1,24 @@
|
||||
# 更新日志 (CHANGELOG)
|
||||
|
||||
## [v4.10.9] - ✨ 新增模板 ”BlueArchive“ 蔚蓝档案 (@VanillaNahida)
|
||||
## [v4.11.0] - ✨ 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145)
|
||||
|
||||
插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。
|
||||
|
||||
* **🛠️配置注意事项 **: 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
||||
* **🛠️ 适配范围**: 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
||||
* **⚙️ 成员昵称**: 官方群事件不提供成员昵称,所以推荐使用 QQ 官方机器人的用户配置输出格式为 text (默认为 image)格式,获得更好的体验,图片格式下无法显示成员昵称。
|
||||
* **⚙️ QQ 官方 API 支持有限**: QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
||||
* **✨ 分析名单配置**: 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置黑白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
||||
* **✨ Markdown 报告**: QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
||||
* **✨ Markdown 概览图**: Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>📋 点击查看历史更新日志</summary>
|
||||
|
||||
## [v4.10.9] - ✨ 新增模板 ”BlueArchive“ 蔚蓝档案 (@VanillaNahida)
|
||||
|
||||
## [v4.10.8] - 🛠️ snowluma 被禁言避免触发分析修复 (#191)
|
||||
|
||||
## [v4.10.7] - ✨ 被禁言避免触发分析功能浪费 token,重构并优化模型请求的重试与降级补偿机制
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</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" />
|
||||
</div>
|
||||
@@ -60,10 +60,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
||||
<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%">
|
||||
</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>
|
||||
</table>
|
||||
|
||||
@@ -85,25 +81,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
||||
- **QQ群**: 支持上传到群相册和群文件,查阅黑历史友好
|
||||
- **详细数据**: 包含消息统计、时间分布、关键词、金句等
|
||||
|
||||
> [!warning]
|
||||
> **实验性开发中**:
|
||||
> - 多平台支持功能尚在开发中,当前仅支持QQ OneBot, Discord, Telegram。
|
||||
> - 旧版本稳定版在[QQ 分支](https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis/tree/QQ),仅 QQ 平台支持
|
||||
|
||||
|
||||
> [!CAUTION]
|
||||
> **Discord 用户重点注意**:
|
||||
> 如果机器人无法获取群列表或分析报 `403 Forbidden`,请检查 Discord 开发者面板中:
|
||||
> 1. **Privileged Gateway Intents**: 开启 `Message Content Intent`。
|
||||
> 2. **频道权限**: 确保机器人所在的频道,对应的角色拥有 **“查看消息历史记录”** 权限。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Telegram 用户重点注意**:
|
||||
> 1. 如果 TG Bot 不是群管理员,务必在拉入群前先关闭 BotFather 隐私模式。
|
||||
> 2. 如果 Bot 已经在群里且不是管理员,关闭隐私模式后必须先移除再重新拉入群,否则新设置不会生效。
|
||||
>
|
||||
> 注:群聊隐私模式关闭流程:`@BotFather`→左下角Open→选择要调整的bot→Bot Settings→将`Group Privacy`关闭
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> **图片生成失败/渲染超时的解决办法**
|
||||
@@ -118,7 +95,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
||||
>
|
||||
> ### 2. 使用备用 T2I 服务或自部署
|
||||
> <details>
|
||||
> <summary><b>若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击展开):</b></summary>
|
||||
> <summary><b>若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击此行展开说明):</b></summary>
|
||||
>
|
||||
> - **Hugging Face 服务**: `https://huggingface.co/spaces/clown145/astrbot-t2i-service`
|
||||
> - **API 接口地址**: `https://clown145-astrbot-t2i-service.hf.space`
|
||||
@@ -134,26 +111,36 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
||||
>
|
||||
> **更换 T2I 端点或自部署 T2I 参考文档**:[docs.astrbot.app/others/self-host-t2i.html](https://docs.astrbot.app/others/self-host-t2i.html)
|
||||
|
||||
> [!warning]
|
||||
> **实验性开发中**:
|
||||
> - 多平台支持功能尚在开发中,当前仅支持 OneBot (NapCat, LLOneBot, Snowluma), QQ 官方机器人, Discord, Telegram。
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Feishu / Lark (WIP)
|
||||
>
|
||||
> 尝试开发中
|
||||
>
|
||||
> 通过 `LarkAdapter` 复用 AstrBot 已有的 `lark_oapi` 生态能力,在获得授权后即可对 Feishu 群聊执行完整的消息采集与分析。
|
||||
>
|
||||
> - **一次性授权**:在飞书开放平台中为你的应用补齐如下权限(仅需在初次部署时授予):
|
||||
> - `im:message:readonly`、`im:chat:readonly`(读取群消息/群信息)
|
||||
> - `contact:contact.base:readonly`(拉取用户昵称/头像;缺少该权限会导致头像永远显示默认)
|
||||
> - 发送需要的附加 scope(如 `im:message:send` / `im:message:receive_v1` / 上传相关的 `im:resource` 系列)
|
||||
> - **用户头像保障**:插件在分析前会调用 `LarkAdapter.prepare_group_member_cache`,一次性批量拉取最多 100 名活跃成员的头像并在缓存中保留,让后续分析阶段不再遇到“未授权头像”。
|
||||
> - **令牌与长时运行**:飞书的 `tenant_access_token` 有 2 小时有效期,分析任务运行时间若超过此周期,请确保你的 Bot 框架会自动刷新令牌(通常是 SDK 默认行为)。
|
||||
> 读取 Feishu 配置后,按照上述授权顺序重新安装/刷新应用,就能在报告里面看到与 QQ/Telegram 一样的用户筛选、头像与 LLM 输出。
|
||||
> **QQ 官方机器人用户注意**:
|
||||
> 插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。
|
||||
> - 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
||||
> - QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
||||
> - 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
||||
> - 官方群事件不提供成员昵称。
|
||||
> - QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
||||
> - Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。
|
||||
> - 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
||||
|
||||
> [!CAUTION]
|
||||
> **Discord 用户重点注意**:
|
||||
> 如果机器人无法获取群列表或分析报 `403 Forbidden`,请检查 Discord 开发者面板中:
|
||||
> 1. **Privileged Gateway Intents**: 开启 `Message Content Intent`。
|
||||
> 2. **频道权限**: 确保机器人所在的频道,对应的角色拥有 **“查看消息历史记录”** 权限。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Telegram 用户重点注意**:
|
||||
> 1. 如果 TG Bot 不是群管理员,务必在拉入群前先关闭 BotFather 隐私模式。
|
||||
> 2. 如果 Bot 已经在群里且不是管理员,关闭隐私模式后必须先移除再重新拉入群,否则新设置不会生效。
|
||||
>
|
||||
> 注:群聊隐私模式关闭流程:`@BotFather`→左下角Open→选择要调整的bot→Bot Settings→将`Group Privacy`关闭
|
||||
|
||||
### 🛠️ 灵活配置
|
||||
- **多平台支持**: 自动识别并适配 OneBot, Discord, Telegram 等平台
|
||||
- **多平台支持**: 自动识别并适配 OneBot, QQ 官方机器人, Discord, Telegram 等平台
|
||||
- **群组管理**: 支持指定特定群组启用功能(支持跨平台黑白名单)
|
||||
- **参数调节**: 可自定义分析天数、消息数量等参数
|
||||
- **定时任务**: 支持设置每日自动分析时间
|
||||
@@ -284,6 +271,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
||||
| 平台 | 适配器类型 | 特殊要求/说明 |
|
||||
|------|-----------|--------------|
|
||||
| **QQ** | OneBot v11 | 建议使用 NapCat/Lagrange。需注意消息分页拉取限制。 |
|
||||
| **QQ 官方机器人** | QQ Bot API v2(WebSocket/Webhook) | 需开启群全量消息;只分析启用后实时缓存的消息;图片/HTML 仅显示头像,Markdown 文本使用成员艾特。 |
|
||||
| **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 |
|
||||
| **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 |
|
||||
|
||||
|
||||
+16
-3
@@ -19,7 +19,7 @@
|
||||
"type": "list",
|
||||
"description": "群组白/黑名单列表",
|
||||
"default": [],
|
||||
"hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号(如 123456)。新手建议优先填完整会话ID,更不容易填错。可用 /sid 获取当前会话ID。",
|
||||
"hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号。QQ 官方机器人使用 group_openid,不是群号,务必优先通过 /sid 获取并填写完整 UMO。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
"type": "int",
|
||||
"description": "默认分析天数",
|
||||
"default": 1,
|
||||
"hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况"
|
||||
"hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况。QQ 官方 API 无历史拉取接口,只能分析插件启用后实时缓存到 AstrBot 本地库的消息。"
|
||||
},
|
||||
"max_messages": {
|
||||
"type": "int",
|
||||
@@ -66,7 +66,7 @@
|
||||
"html"
|
||||
],
|
||||
"default": "image",
|
||||
"hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)"
|
||||
"hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方群事件没有昵称,图片/HTML 报告仅显示头像,文本报告由独立 Markdown 模块使用艾特展示身份;其他平台保持原有昵称文本格式。"
|
||||
},
|
||||
"report_template": {
|
||||
"type": "string",
|
||||
@@ -140,6 +140,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"qq_official": {
|
||||
"description": "QQ 官方机器人",
|
||||
"type": "object",
|
||||
"hint": "仅作用于 QQ 官方机器人及 QQ 官方 Webhook 的报告展示。",
|
||||
"items": {
|
||||
"enable_t2i_activity_histogram": {
|
||||
"type": "bool",
|
||||
"description": "启用 T2I 群聊概览图",
|
||||
"default": true,
|
||||
"hint": "在 QQ 官方 Markdown 报告中嵌入透明背景的日期、基础统计与 24 小时直方图。渲染失败时自动回退为完整文字报告。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"t2i_rendering": {
|
||||
"description": "图片渲染策略",
|
||||
"type": "object",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.api import AstrBotConfig
|
||||
@@ -15,6 +15,8 @@ from astrbot.api import logger as astrbot_logger
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.event.filter import PermissionType
|
||||
from astrbot.api.star import Context, Star, StarTools
|
||||
|
||||
# File is only available via astrbot.core (internal API — may change).
|
||||
from astrbot.core.message.components import File
|
||||
|
||||
from .src.application.commands.template_command_service import (
|
||||
@@ -35,8 +37,8 @@ from .src.infrastructure.config.config_manager import ConfigManager
|
||||
from .src.infrastructure.messaging.message_sender import MessageSender
|
||||
from .src.infrastructure.persistence.history_manager import HistoryManager
|
||||
from .src.infrastructure.persistence.incremental_store import IncrementalStore
|
||||
from .src.infrastructure.persistence.telegram_group_registry import (
|
||||
TelegramGroupRegistry,
|
||||
from .src.infrastructure.persistence.platform_group_registry import (
|
||||
PlatformGroupRegistry,
|
||||
)
|
||||
from .src.infrastructure.platform.bot_manager import BotManager
|
||||
from .src.infrastructure.platform.template_preview import (
|
||||
@@ -45,6 +47,7 @@ from .src.infrastructure.platform.template_preview import (
|
||||
)
|
||||
from .src.infrastructure.reporting.generators import ReportGenerator
|
||||
from .src.infrastructure.scheduler.auto_scheduler import AutoScheduler
|
||||
from .src.infrastructure.visualization.activity_charts import ActivityVisualizer
|
||||
from .src.shared.constants import PLUGIN_NAME
|
||||
from .src.shared.trace_context import TraceContext, TraceLogFilter
|
||||
from .src.utils.logger import logger
|
||||
@@ -60,7 +63,8 @@ class GroupDailyAnalysis(Star):
|
||||
bot_manager: BotManager
|
||||
history_manager: HistoryManager
|
||||
report_generator: ReportGenerator
|
||||
telegram_group_registry: TelegramGroupRegistry
|
||||
html_render: Callable
|
||||
platform_group_registry: PlatformGroupRegistry
|
||||
statistics_service: StatisticsService
|
||||
analysis_domain_service: AnalysisDomainService
|
||||
llm_analyzer: LLMAnalyzer
|
||||
@@ -90,10 +94,11 @@ class GroupDailyAnalysis(Star):
|
||||
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
|
||||
|
||||
# Telegram 注册表 (持久层)
|
||||
self.telegram_group_registry = TelegramGroupRegistry(self)
|
||||
self.platform_group_registry = PlatformGroupRegistry(self)
|
||||
|
||||
# 2. 领域层
|
||||
self.statistics_service = StatisticsService()
|
||||
activity_visualizer = ActivityVisualizer()
|
||||
self.statistics_service = StatisticsService(activity_visualizer)
|
||||
self.analysis_domain_service = AnalysisDomainService()
|
||||
|
||||
# 3. 分析核心 (LLM Bridge)
|
||||
@@ -118,7 +123,7 @@ class GroupDailyAnalysis(Star):
|
||||
|
||||
# 消息处理服务
|
||||
self.message_processing_service = MessageProcessingService(
|
||||
context, self.telegram_group_registry
|
||||
context, self.platform_group_registry
|
||||
)
|
||||
self.template_command_service = TemplateCommandService(
|
||||
plugin_root=os.path.dirname(__file__)
|
||||
@@ -287,11 +292,43 @@ class GroupDailyAnalysis(Star):
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True)
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
|
||||
@filter.platform_adapter_type(
|
||||
filter.PlatformAdapterType.QQOFFICIAL
|
||||
| filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK
|
||||
)
|
||||
async def intercept_qq_official_messages(self, event: AstrMessageEvent):
|
||||
"""缓存 QQ 官方机器人群消息;频道消息不在本插件适配范围内。"""
|
||||
raw_message = getattr(getattr(event, "message_obj", None), "raw_message", None)
|
||||
if isinstance(raw_message, dict):
|
||||
author = raw_message.get("author") or {}
|
||||
group_openid = str(raw_message.get("group_openid", "") or "").strip()
|
||||
member_openid = str(
|
||||
author.get("member_openid", "") if isinstance(author, dict) else ""
|
||||
).strip()
|
||||
else:
|
||||
author = getattr(raw_message, "author", None)
|
||||
group_openid = str(getattr(raw_message, "group_openid", "") or "").strip()
|
||||
member_openid = str(getattr(author, "member_openid", "") or "").strip()
|
||||
if not group_openid or not member_openid:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.message_processing_service.process_message(event)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.warning(f"[QQOfficial] 消息存储失败: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[QQOfficial] 消息存储异常: {e}", exc_info=True)
|
||||
|
||||
async def get_telegram_seen_group_ids(
|
||||
self, platform_id: str | None = None
|
||||
) -> list[str]:
|
||||
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。"""
|
||||
return await self.telegram_group_registry.get_all_group_ids(platform_id)
|
||||
return await self.platform_group_registry.get_all_group_ids(platform_id)
|
||||
|
||||
async def get_seen_group_ids(self, platform_id: str | None = None) -> list[str]:
|
||||
"""读取任意事件驱动平台已经见过的群组。"""
|
||||
return await self.platform_group_registry.get_all_group_ids(platform_id)
|
||||
|
||||
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
||||
"""从消息事件中安全获取群组 ID"""
|
||||
@@ -517,7 +554,15 @@ class GroupDailyAnalysis(Star):
|
||||
# 表情回应 或 文本提示(二选一,由配置开关控制)
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
orig_msg_id = getattr(event.message_obj, "message_id", None)
|
||||
use_text_reply = self.config_manager.get_enable_analysis_reply()
|
||||
adapter_platform_name = (
|
||||
(adapter.get_platform_name() if adapter else "").strip().lower()
|
||||
)
|
||||
# QQ 官方机器人 API v2 不支持本插件使用的表情回应接口,
|
||||
# 因此始终沿用原有的文字进度提示,避免触发无效的 reaction 请求。
|
||||
use_text_reply = (
|
||||
adapter_platform_name in {"qq_official", "qq_official_webhook"}
|
||||
or self.config_manager.get_enable_analysis_reply()
|
||||
)
|
||||
|
||||
if use_text_reply:
|
||||
yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
|
||||
@@ -577,6 +622,7 @@ class GroupDailyAnalysis(Star):
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
output_format = self.config_manager.get_output_format()
|
||||
hide_user_names = adapter.get_platform_name() == "qq_official"
|
||||
|
||||
# 定义获取回调
|
||||
async def avatar_url_getter(user_id: str) -> str | None:
|
||||
@@ -599,6 +645,7 @@ class GroupDailyAnalysis(Star):
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
|
||||
if image_url:
|
||||
@@ -610,8 +657,9 @@ class GroupDailyAnalysis(Star):
|
||||
|
||||
# 如果图片生成或发送失败,直接回退到文本
|
||||
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await adapter.send_text_report(group_id, text_report)
|
||||
await self._send_text_reports(
|
||||
group_id, analysis_result, hide_user_names, adapter
|
||||
)
|
||||
return
|
||||
|
||||
elif output_format == "html":
|
||||
@@ -621,6 +669,7 @@ class GroupDailyAnalysis(Star):
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
if html_path:
|
||||
is_only_url = self.config_manager.get_html_only_url()
|
||||
@@ -681,8 +730,28 @@ class GroupDailyAnalysis(Star):
|
||||
yield event.plain_result("⚠️ HTML 生成失败。")
|
||||
|
||||
else:
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await adapter.send_text_report(group_id, text_report)
|
||||
await self._send_text_reports(
|
||||
group_id, analysis_result, hide_user_names, adapter
|
||||
)
|
||||
|
||||
async def _generate_text_reports(
|
||||
self, analysis_result: dict, hide_user_names: bool
|
||||
) -> tuple[str, str | None]:
|
||||
"""Generate text or QQ-official-markdown reports."""
|
||||
if hide_user_names:
|
||||
return await self.report_generator.generate_qq_official_markdown_report(
|
||||
analysis_result, self.html_render
|
||||
)
|
||||
return self.report_generator.generate_text_report(analysis_result), None
|
||||
|
||||
async def _send_text_reports(
|
||||
self, group_id: str, analysis_result: dict, hide_user_names: bool, adapter
|
||||
) -> bool:
|
||||
"""Send text reports via platform adapter."""
|
||||
tr, fr = await self._generate_text_reports(analysis_result, hide_user_names)
|
||||
if hide_user_names:
|
||||
return await adapter.send_text_report(group_id, tr, fallback_content=fr)
|
||||
return await adapter.send_text_report(group_id, tr)
|
||||
|
||||
@filter.command("设置格式", alias={"set_format"})
|
||||
@filter.permission_type(PermissionType.ADMIN)
|
||||
|
||||
+4
-3
@@ -1,12 +1,13 @@
|
||||
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。
|
||||
display_name: 群分析总结插件 # 插件的显示名称
|
||||
desc: "[多平台接入开发中] 群日常分析总结插件 - 支持 QQ (aiocqhttp)、Telegram、Discord 以及 Feishu (Lark);生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
|
||||
version: v4.10.9 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
|
||||
version: v4.11.0 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
author: SXP-Simon # 作者
|
||||
astrbot_version: ">=4.16.0"
|
||||
support_platforms:
|
||||
- aiocqhttp
|
||||
- discord
|
||||
- telegram
|
||||
- lark
|
||||
- qq_official
|
||||
- qq_official_webhook
|
||||
repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址
|
||||
|
||||
@@ -216,7 +216,6 @@ class AnalysisApplicationService:
|
||||
)
|
||||
|
||||
# 4. 用户分析 (Domain Service)
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
user_activity = await asyncio.to_thread(
|
||||
self.analysis_domain_service.analyze_user_activity,
|
||||
unified_messages,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections import Counter, OrderedDict
|
||||
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
from astrbot.api.star import Context
|
||||
|
||||
from ...infrastructure.persistence.telegram_group_registry import TelegramGroupRegistry
|
||||
from ...infrastructure.persistence.platform_group_registry import PlatformGroupRegistry
|
||||
from ...utils.logger import logger
|
||||
|
||||
|
||||
@@ -12,27 +12,36 @@ class MessageProcessingService:
|
||||
"""
|
||||
消息处理服务
|
||||
|
||||
负责处理接收到的消息事件:
|
||||
解析收到的群消息事件,提取内容与发送者信息,持久化历史记录,
|
||||
并维护事件驱动平台(Telegram、QQ 官方等)的群组注册表。
|
||||
QQ 官方平台特有的重复消息去重逻辑也在本服务中处理。
|
||||
|
||||
职责:
|
||||
1. 解析消息内容(文本、图片、@提及等)
|
||||
2. 解析发送者信息(跨平台兼容)
|
||||
2. 解析发送者展示名(跨平台兼容)
|
||||
3. 存储消息历史
|
||||
4. 维护 Telegram 群组注册表(回退机制)
|
||||
4. 维护群组注册表,供调度器做群组发现(Telegram、QQ 官方等事件驱动平台)
|
||||
5. QQ 官方事件消息去重(按 message_id 预占 + 确认机制)
|
||||
"""
|
||||
|
||||
def __init__(self, context: Context, telegram_registry: TelegramGroupRegistry):
|
||||
def __init__(self, context: Context, group_registry: PlatformGroupRegistry):
|
||||
self.context = context
|
||||
self.telegram_registry = telegram_registry
|
||||
self.group_registry = group_registry
|
||||
self._seen_event_ids: OrderedDict[str, None] = OrderedDict()
|
||||
self._inflight_event_ids: set[str] = set()
|
||||
self._seen_event_ids_limit = 4096
|
||||
|
||||
async def process_message(self, event: AstrMessageEvent) -> None:
|
||||
"""
|
||||
处理并在历史记录中存储消息。
|
||||
被 main.py 的 Telegram 和 QQ 官方消息拦截器共同调用。
|
||||
|
||||
Args:
|
||||
event: AstrBot 消息事件
|
||||
Args:
|
||||
event: AstrBot 消息事件
|
||||
|
||||
Raises:
|
||||
ValueError: 当必要数据无法获取时
|
||||
RuntimeError: 当消息内容为空时
|
||||
Raises:
|
||||
ValueError: 当必要数据无法获取时
|
||||
RuntimeError: 当消息内容为空时
|
||||
"""
|
||||
# 1. 获取群组 ID(必需)
|
||||
group_id = self._get_group_id_from_event(event)
|
||||
@@ -62,37 +71,63 @@ class MessageProcessingService:
|
||||
f"群 {group_id}: 消息内容为空 (sender={sender_name}),拒绝存储"
|
||||
)
|
||||
|
||||
# 6. 提取事件消息 ID(用于 Telegram 已见群/话题记录)
|
||||
# 6. 提取事件消息 ID 和事件时间
|
||||
msg_obj = getattr(event, "message_obj", None)
|
||||
event_message_id = str(getattr(msg_obj, "message_id", "") or "")
|
||||
|
||||
platform_name = str(event.get_platform_name() or "").strip().lower()
|
||||
reserved_event_id = False
|
||||
if platform_name in {"qq_official", "qq_official_webhook"} and event_message_id:
|
||||
reserved_event_id = self._reserve_event_id(event_message_id)
|
||||
if not reserved_event_id:
|
||||
logger.debug("[QQOfficial] 跳过重复消息事件: %s", event_message_id)
|
||||
return
|
||||
history_content = {
|
||||
"type": "user",
|
||||
"message": message_parts,
|
||||
}
|
||||
if platform_name in {"qq_official", "qq_official_webhook"}:
|
||||
event_timestamp = self._extract_event_timestamp(msg_obj)
|
||||
history_content["_qq_official"] = {
|
||||
"message_id": event_message_id,
|
||||
"timestamp": event_timestamp,
|
||||
}
|
||||
|
||||
# 7. 存储到数据库
|
||||
await self.context.message_history_manager.insert(
|
||||
platform_id=platform_id,
|
||||
user_id=group_id,
|
||||
content={"type": "user", "message": message_parts},
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
try:
|
||||
await self.context.message_history_manager.insert(
|
||||
platform_id=platform_id,
|
||||
user_id=group_id,
|
||||
content=history_content,
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
except BaseException:
|
||||
if reserved_event_id:
|
||||
self._release_event_id(event_message_id)
|
||||
raise
|
||||
else:
|
||||
if reserved_event_id:
|
||||
self._commit_event_id(event_message_id)
|
||||
|
||||
# Telegram: 记录已见群/话题
|
||||
if self._is_telegram_event(event, platform_id):
|
||||
try:
|
||||
await self.telegram_registry.upsert(
|
||||
platform_id=platform_id,
|
||||
group_id=group_id,
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
event_message_id=event_message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[TGRegistry] Upsert failed: "
|
||||
f"platform_id={platform_id} group_id={group_id} error={e}"
|
||||
)
|
||||
# Register the group so the scheduler can discover platforms that
|
||||
# do not provide a group-list API (Telegram, QQ Official, etc.).
|
||||
try:
|
||||
await self.group_registry.upsert(
|
||||
platform_id=platform_id,
|
||||
group_id=group_id,
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
event_message_id=event_message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[GroupRegistry] Upsert failed: "
|
||||
f"platform_id={platform_id} group_id={group_id} error={e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[Telegram] [{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})"
|
||||
logger.debug(
|
||||
f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})"
|
||||
)
|
||||
|
||||
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
||||
@@ -208,6 +243,24 @@ class MessageProcessingService:
|
||||
}
|
||||
)
|
||||
|
||||
elif seg_type in ("File", "file"):
|
||||
url = getattr(seg, "url", None) or getattr(seg, "file_", None)
|
||||
message_parts.append(
|
||||
{
|
||||
"type": "file",
|
||||
"url": str(url or ""),
|
||||
"name": str(getattr(seg, "name", "") or ""),
|
||||
}
|
||||
)
|
||||
|
||||
elif seg_type in ("Record", "record", "voice"):
|
||||
url = getattr(seg, "url", None) or getattr(seg, "file", None)
|
||||
message_parts.append({"type": "voice", "url": str(url or "")})
|
||||
|
||||
elif seg_type in ("Video", "video"):
|
||||
url = getattr(seg, "url", None) or getattr(seg, "file", None)
|
||||
message_parts.append({"type": "video", "url": str(url or "")})
|
||||
|
||||
if not message_parts and event.message_str:
|
||||
message_parts.append({"type": "plain", "text": event.message_str})
|
||||
|
||||
@@ -261,9 +314,57 @@ class MessageProcessingService:
|
||||
return normalized == str(sender_id).strip()
|
||||
|
||||
@staticmethod
|
||||
def _is_telegram_event(event: AstrMessageEvent, platform_id: str) -> bool:
|
||||
"""判断当前事件是否为 Telegram 平台"""
|
||||
platform_name = str(event.get_platform_name() or "").strip().lower()
|
||||
if platform_name == "telegram":
|
||||
return True
|
||||
return str(platform_id or "").strip().lower().startswith("telegram")
|
||||
def _extract_event_timestamp(message_obj: object) -> int:
|
||||
"""从消息对象中提取平台事件时间戳。"""
|
||||
raw_message = getattr(message_obj, "raw_message", None)
|
||||
if isinstance(raw_message, dict):
|
||||
candidate = raw_message.get("timestamp")
|
||||
if not candidate:
|
||||
raw_data = raw_message.get("raw_data")
|
||||
if isinstance(raw_data, dict):
|
||||
candidate = raw_data.get("timestamp")
|
||||
else:
|
||||
raw_data = getattr(raw_message, "raw_data", None)
|
||||
candidate = getattr(raw_message, "timestamp", None)
|
||||
if not candidate and isinstance(raw_data, dict):
|
||||
candidate = raw_data.get("timestamp")
|
||||
if isinstance(candidate, (int, float)):
|
||||
return int(candidate)
|
||||
if candidate:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
return int(
|
||||
datetime.fromisoformat(
|
||||
str(candidate).replace("Z", "+00:00")
|
||||
).timestamp()
|
||||
)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
def _reserve_event_id(self, event_message_id: str) -> bool:
|
||||
"""预占事件消息 ID:在历史记录持久化期间防止重复入库。"""
|
||||
if (
|
||||
event_message_id in self._inflight_event_ids
|
||||
or event_message_id in self._seen_event_ids
|
||||
):
|
||||
if event_message_id in self._seen_event_ids:
|
||||
self._seen_event_ids.move_to_end(event_message_id)
|
||||
return False
|
||||
self._inflight_event_ids.add(event_message_id)
|
||||
return True
|
||||
|
||||
def _commit_event_id(self, event_message_id: str) -> None:
|
||||
"""确认事件消息 ID:标记为已持久化,纳入后续去重。"""
|
||||
self._inflight_event_ids.discard(event_message_id)
|
||||
if event_message_id in self._seen_event_ids:
|
||||
self._seen_event_ids.move_to_end(event_message_id)
|
||||
else:
|
||||
self._seen_event_ids[event_message_id] = None
|
||||
if len(self._seen_event_ids) > self._seen_event_ids_limit:
|
||||
self._seen_event_ids.popitem(last=False)
|
||||
|
||||
def _release_event_id(self, event_message_id: str) -> None:
|
||||
"""释放事件消息 ID:持久化失败或取消时清理预占状态。"""
|
||||
self._inflight_event_ids.discard(event_message_id)
|
||||
|
||||
@@ -3,35 +3,16 @@
|
||||
|
||||
该模块导出所有领域实体类,包括:
|
||||
- AnalysisTask: 分析任务聚合根
|
||||
- GroupAnalysisResult: 群聊分析结果实体
|
||||
- IncrementalBatch: 增量分析独立批次实体
|
||||
- IncrementalState: 增量分析聚合视图(报告时使用)
|
||||
"""
|
||||
|
||||
from .analysis_result import (
|
||||
ActivityVisualization,
|
||||
EmojiStatistics,
|
||||
GoldenQuote,
|
||||
GroupAnalysisResult,
|
||||
GroupStatistics,
|
||||
SummaryTopic,
|
||||
TokenUsage,
|
||||
UserTitle,
|
||||
)
|
||||
from .analysis_task import AnalysisTask, TaskStatus
|
||||
from .incremental_state import IncrementalBatch, IncrementalState
|
||||
|
||||
__all__ = [
|
||||
"AnalysisTask",
|
||||
"TaskStatus",
|
||||
"GroupAnalysisResult",
|
||||
"SummaryTopic",
|
||||
"UserTitle",
|
||||
"GoldenQuote",
|
||||
"TokenUsage",
|
||||
"EmojiStatistics",
|
||||
"ActivityVisualization",
|
||||
"GroupStatistics",
|
||||
"IncrementalBatch",
|
||||
"IncrementalState",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -1,10 +1,12 @@
|
||||
# 仓储接口
|
||||
from .avatar_repository import IAvatarRepository
|
||||
from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender
|
||||
from .visualization_repository import IActivityVisualizer
|
||||
|
||||
__all__ = [
|
||||
"IMessageRepository",
|
||||
"IMessageSender",
|
||||
"IGroupInfoRepository",
|
||||
"IAvatarRepository",
|
||||
"IActivityVisualizer",
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ class IReportGenerator(ABC):
|
||||
avatar_url_getter: Any = None,
|
||||
nickname_getter: Any = None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
hide_user_names: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""生成图片报告"""
|
||||
pass
|
||||
@@ -33,6 +34,7 @@ class IReportGenerator(ABC):
|
||||
avatar_url_getter: Any = None,
|
||||
nickname_getter: Any = None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
hide_user_names: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""生成 HTML 报告"""
|
||||
pass
|
||||
|
||||
@@ -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
|
||||
@@ -3,33 +3,10 @@
|
||||
|
||||
该模块导出所有封装核心业务逻辑的领域服务,
|
||||
用于分析群聊数据。这些服务是平台无关的。
|
||||
|
||||
服务分类:
|
||||
- 分析器服务: 话题分析、用户称号分析、金句分析
|
||||
- 计算服务: 统计计算
|
||||
- 生成服务: 报告生成
|
||||
"""
|
||||
|
||||
from .golden_quote_analyzer import GoldenQuoteAnalyzerAdapter, IGoldenQuoteAnalyzer
|
||||
from .incremental_merge_service import IncrementalMergeService
|
||||
from .report_generator import ReportGenerator
|
||||
from .statistics_calculator import StatisticsCalculator
|
||||
from .topic_analyzer import ITopicAnalyzer, TopicAnalyzerAdapter
|
||||
from .user_title_analyzer import IUserTitleAnalyzer, UserTitleAnalyzerAdapter
|
||||
|
||||
__all__ = [
|
||||
# 统计与报告服务
|
||||
"StatisticsCalculator",
|
||||
"ReportGenerator",
|
||||
# 增量合并服务
|
||||
"IncrementalMergeService",
|
||||
# 话题分析服务
|
||||
"ITopicAnalyzer",
|
||||
"TopicAnalyzerAdapter",
|
||||
# 用户称号分析服务
|
||||
"IUserTitleAnalyzer",
|
||||
"UserTitleAnalyzerAdapter",
|
||||
# 金句分析服务
|
||||
"IGoldenQuoteAnalyzer",
|
||||
"GoldenQuoteAnalyzerAdapter",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -12,17 +12,15 @@ from ..value_objects.unified_message import (
|
||||
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:
|
||||
"""消息清理服务"""
|
||||
|
||||
# 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(
|
||||
self,
|
||||
messages: list[UnifiedMessage],
|
||||
@@ -51,11 +49,7 @@ class MessageCleanerService:
|
||||
# 2. 预检指令消息(首个内容块通常是文本)
|
||||
is_command = False
|
||||
first_text = msg.text_content
|
||||
if (
|
||||
filter_commands
|
||||
and first_text
|
||||
and self.COMMAND_PATTERN.match(first_text)
|
||||
):
|
||||
if filter_commands and first_text and _COMMAND_PATTERN.match(first_text):
|
||||
is_command = True
|
||||
|
||||
if is_command:
|
||||
@@ -70,7 +64,7 @@ class MessageCleanerService:
|
||||
text = content.text or ""
|
||||
|
||||
# 移除 Discord 原始表情代码
|
||||
text = self.DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text)
|
||||
text = _DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text)
|
||||
|
||||
# 移除 @mentions 文本 (e.g. <@123456>)
|
||||
text = re.sub(r"<@\d+>", "", text)
|
||||
|
||||
@@ -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)"
|
||||
@@ -8,14 +8,19 @@ from datetime import datetime
|
||||
|
||||
from ...infrastructure.visualization.activity_charts import ActivityVisualizer
|
||||
from ..models.data_models import EmojiStatistics, GroupStatistics, TokenUsage
|
||||
from ..repositories.visualization_repository import IActivityVisualizer
|
||||
from ..value_objects.unified_message import MessageContentType, UnifiedMessage
|
||||
|
||||
|
||||
class StatisticsService:
|
||||
"""统计服务 - 处理群聊数据的聚合统计"""
|
||||
|
||||
def __init__(self):
|
||||
self.activity_visualizer = ActivityVisualizer()
|
||||
def __init__(self, activity_visualizer: IActivityVisualizer | None = None):
|
||||
if activity_visualizer is None:
|
||||
# Fallback: keep backward compatibility
|
||||
self.activity_visualizer: IActivityVisualizer = ActivityVisualizer()
|
||||
else:
|
||||
self.activity_visualizer = activity_visualizer
|
||||
|
||||
def calculate_group_statistics(
|
||||
self, messages: list[UnifiedMessage]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
# 值对象
|
||||
from .golden_quote import GoldenQuote, GoldenQuoteCollection
|
||||
from .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities
|
||||
from .statistics import (
|
||||
ActivityVisualization,
|
||||
EmojiStatistics,
|
||||
GroupStatistics,
|
||||
TokenUsage,
|
||||
UserStatistics,
|
||||
)
|
||||
from .topic import Topic, TopicCollection
|
||||
from .unified_group import UnifiedGroup, UnifiedMember
|
||||
from .unified_message import MessageContent, MessageContentType, UnifiedMessage
|
||||
from .user_title import UserTitle, UserTitleCollection
|
||||
|
||||
__all__ = [
|
||||
# 核心平台抽象
|
||||
@@ -22,17 +12,4 @@ __all__ = [
|
||||
"PLATFORM_CAPABILITIES",
|
||||
"UnifiedGroup",
|
||||
"UnifiedMember",
|
||||
# 分析值对象
|
||||
"Topic",
|
||||
"TopicCollection",
|
||||
"UserTitle",
|
||||
"UserTitleCollection",
|
||||
"GoldenQuote",
|
||||
"GoldenQuoteCollection",
|
||||
# 统计
|
||||
"TokenUsage",
|
||||
"EmojiStatistics",
|
||||
"ActivityVisualization",
|
||||
"GroupStatistics",
|
||||
"UserStatistics",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
# QQ Official Bot API. Message history is provided by the plugin's local
|
||||
# event archive because the public API does not expose group history queries.
|
||||
QQ_OFFICIAL_CAPABILITIES = PlatformCapabilities(
|
||||
platform_name="qq_official",
|
||||
platform_version="api_v2_local_history",
|
||||
supports_message_history=True,
|
||||
max_message_history_days=7,
|
||||
max_message_count=10000,
|
||||
supports_group_list=False,
|
||||
supports_group_info=False,
|
||||
supports_member_list=False,
|
||||
supports_member_info=False,
|
||||
supports_text_message=True,
|
||||
supports_image_message=True,
|
||||
supports_file_message=True,
|
||||
supports_forward_message=False,
|
||||
supports_reply_message=False,
|
||||
max_text_length=4000,
|
||||
max_image_size_mb=20.0,
|
||||
supports_user_avatar=True,
|
||||
supports_group_avatar=False,
|
||||
avatar_needs_api_call=False,
|
||||
avatar_sizes=(640,),
|
||||
)
|
||||
|
||||
# 能力查找表(映射平台标识到能力对象)
|
||||
PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
|
||||
"aiocqhttp": ONEBOT_V11_CAPABILITIES,
|
||||
@@ -262,6 +287,8 @@ PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
|
||||
"discord": DISCORD_CAPABILITIES,
|
||||
"slack": SLACK_CAPABILITIES,
|
||||
"lark": LARK_CAPABILITIES,
|
||||
"qq_official": QQ_OFFICIAL_CAPABILITIES,
|
||||
"qq_official_webhook": QQ_OFFICIAL_CAPABILITIES,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
raw_ids = topic.contributors # LLM 返回的是 ID 列表
|
||||
|
||||
# 填充 contributor_ids
|
||||
# 过滤掉非数字的脏数据 (LLM 偶尔会发疯)
|
||||
valid_ids = [
|
||||
str(uid).strip() for uid in raw_ids if str(uid).strip().isdigit()
|
||||
]
|
||||
# 填充 contributor_ids。QQ 官方 member_openid 并非纯数字,
|
||||
# 因此仅接受本批次已知用户或已配置机器人 ID,而不是用 isdigit 过滤。
|
||||
bot_ids = {str(uid) for uid in self.config_manager.get_bot_self_ids()}
|
||||
known_ids = set(id_to_nickname) | bot_ids
|
||||
valid_ids = []
|
||||
for raw_uid in raw_ids:
|
||||
uid = str(raw_uid).strip().strip("[]")
|
||||
if uid and uid in known_ids and uid not in valid_ids:
|
||||
valid_ids.append(uid)
|
||||
topic.contributor_ids = valid_ids
|
||||
|
||||
# 映射回昵称用于显示
|
||||
@@ -376,7 +380,6 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
||||
name = id_to_nickname.get(uid)
|
||||
if not name:
|
||||
# 尝试去全局配置里找 (e.g. 机器人自己)
|
||||
bot_ids = self.config_manager.get_bot_self_ids()
|
||||
if uid in bot_ids:
|
||||
name = "Bot"
|
||||
else:
|
||||
|
||||
@@ -51,6 +51,21 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
|
||||
self.chat_quality_analyzer = ChatQualityAnalyzer(context, config_manager)
|
||||
|
||||
@staticmethod
|
||||
def _make_session_id(
|
||||
session_id: str | None, umo: str | None = None, prefix: str = ""
|
||||
) -> str:
|
||||
"""Generate a session ID if not already provided."""
|
||||
if session_id:
|
||||
return session_id
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if umo:
|
||||
safe_umo = umo.replace(":", "_")
|
||||
return f"{prefix}{timestamp}_{safe_umo}"
|
||||
return f"{prefix}{timestamp}"
|
||||
|
||||
async def analyze_topics(
|
||||
self,
|
||||
messages: list[dict],
|
||||
@@ -70,16 +85,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
(话题列表, Token使用统计)
|
||||
"""
|
||||
try:
|
||||
if not session_id:
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if umo:
|
||||
# Sanitize umo for filename (replace : with _)
|
||||
safe_umo = umo.replace(":", "_")
|
||||
session_id = f"{timestamp}_{safe_umo}"
|
||||
else:
|
||||
session_id = timestamp
|
||||
session_id = self._make_session_id(session_id, umo)
|
||||
|
||||
logger.info(f"开始话题分析, session_id: {session_id}")
|
||||
return await self.topic_analyzer.analyze_topics(messages, umo, session_id)
|
||||
@@ -110,15 +116,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
(用户称号列表, Token使用统计)
|
||||
"""
|
||||
try:
|
||||
if not session_id:
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if umo:
|
||||
safe_umo = umo.replace(":", "_")
|
||||
session_id = f"{timestamp}_{safe_umo}"
|
||||
else:
|
||||
session_id = timestamp
|
||||
session_id = self._make_session_id(session_id, umo)
|
||||
|
||||
logger.info(f"开始用户称号分析, session_id: {session_id}")
|
||||
return await self.user_title_analyzer.analyze_user_titles(
|
||||
@@ -147,15 +145,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
(金句列表, Token使用统计)
|
||||
"""
|
||||
try:
|
||||
if not session_id:
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if umo:
|
||||
safe_umo = umo.replace(":", "_")
|
||||
session_id = f"{timestamp}_{safe_umo}"
|
||||
else:
|
||||
session_id = timestamp
|
||||
session_id = self._make_session_id(session_id, umo)
|
||||
|
||||
logger.info(f"开始金句分析, session_id: {session_id}")
|
||||
return await self.golden_quote_analyzer.analyze_golden_quotes(
|
||||
@@ -211,14 +201,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
(话题列表, 用户称号列表, 金句列表, 总Token使用统计)
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if umo:
|
||||
safe_umo = umo.replace(":", "_")
|
||||
session_id = f"{timestamp}_{safe_umo}"
|
||||
else:
|
||||
session_id = timestamp
|
||||
session_id = self._make_session_id(None, umo)
|
||||
|
||||
logger.info(
|
||||
f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}"
|
||||
@@ -349,14 +332,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
(话题列表, 金句列表, 总Token使用统计)
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if umo:
|
||||
safe_umo = umo.replace(":", "_")
|
||||
session_id = f"incr_{timestamp}_{safe_umo}"
|
||||
else:
|
||||
session_id = f"incr_{timestamp}"
|
||||
session_id = self._make_session_id(None, umo, "incr_")
|
||||
|
||||
logger.info(
|
||||
f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled}),"
|
||||
|
||||
@@ -16,6 +16,7 @@ class ConfigManager:
|
||||
|
||||
配置结构采用分组嵌套方式,顶层分为以下分组:
|
||||
- basic: 基础设置
|
||||
- qq_official: QQ 官方机器人展示设置
|
||||
- auto_analysis: 自动分析设置
|
||||
- llm: LLM 设置
|
||||
- analysis_features: 分析功能开关
|
||||
@@ -149,6 +150,13 @@ class ConfigManager:
|
||||
"""获取输出格式"""
|
||||
return self._get_group("basic").get("output_format", "image")
|
||||
|
||||
def get_qq_official_t2i_summary_dashboard_enabled(self) -> bool:
|
||||
"""是否启用 QQ 官方 T2I 概览图。"""
|
||||
group = self._get_group("qq_official")
|
||||
if "enable_t2i_summary_dashboard" in group:
|
||||
return bool(group["enable_t2i_summary_dashboard"])
|
||||
return bool(group.get("enable_t2i_activity_histogram", True))
|
||||
|
||||
def get_min_messages_threshold(self) -> int:
|
||||
"""获取最小消息阈值"""
|
||||
return self._get_group("basic").get("min_messages_threshold", 50)
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,17 @@
|
||||
# 平台适配器
|
||||
from .adapters.discord_adapter import DiscordAdapter
|
||||
from .adapters.lark_adapter import LarkAdapter
|
||||
from .adapters.onebot_adapter import OneBotAdapter
|
||||
from .adapters.qq_official_adapter import QQOfficialAdapter
|
||||
from .adapters.telegram_adapter import TelegramAdapter
|
||||
from .base import PlatformAdapter
|
||||
from .factory import PlatformAdapterFactory
|
||||
|
||||
__all__ = ["PlatformAdapterFactory", "PlatformAdapter", "OneBotAdapter", "LarkAdapter"]
|
||||
__all__ = [
|
||||
"PlatformAdapterFactory",
|
||||
"PlatformAdapter",
|
||||
"OneBotAdapter",
|
||||
"LarkAdapter",
|
||||
"QQOfficialAdapter",
|
||||
"TelegramAdapter",
|
||||
"DiscordAdapter",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
# 平台适配器
|
||||
from .discord_adapter import DiscordAdapter
|
||||
from .lark_adapter import LarkAdapter
|
||||
from .onebot_adapter import OneBotAdapter
|
||||
"""Optional platform adapter exports.
|
||||
|
||||
__all__ = ["OneBotAdapter", "DiscordAdapter", "LarkAdapter"]
|
||||
Each platform is imported independently so an unavailable optional SDK does not
|
||||
prevent the QQ Official adapter from being registered.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
try:
|
||||
from .discord_adapter import DiscordAdapter # noqa: F401
|
||||
|
||||
__all__.append("DiscordAdapter")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .lark_adapter import LarkAdapter # noqa: F401
|
||||
|
||||
__all__.append("LarkAdapter")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .onebot_adapter import OneBotAdapter # noqa: F401
|
||||
|
||||
__all__.append("OneBotAdapter")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .qq_official_adapter import QQOfficialAdapter # noqa: F401
|
||||
|
||||
__all__.append("QQOfficialAdapter")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -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),
|
||||
"plugin_instance": self._plugin_instance,
|
||||
}
|
||||
platform_instance = self._platforms.get(str(platform_id))
|
||||
platform_config = getattr(platform_instance, "config", None)
|
||||
if isinstance(platform_config, Mapping):
|
||||
adapter_config["appid"] = platform_config.get("appid", "")
|
||||
adapter = PlatformAdapterFactory.create(
|
||||
platform_name, bot_instance, adapter_config
|
||||
)
|
||||
|
||||
@@ -99,5 +99,13 @@ def _register_adapters():
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .adapters.qq_official_adapter import QQOfficialAdapter
|
||||
|
||||
PlatformAdapterFactory.register("qq_official", QQOfficialAdapter)
|
||||
PlatformAdapterFactory.register("qq_official_webhook", QQOfficialAdapter)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
_register_adapters()
|
||||
|
||||
@@ -31,6 +31,10 @@ class ReportDispatcher:
|
||||
"""设置 HTML 渲染函数 (运行时注入)"""
|
||||
self._html_render_func = render_func
|
||||
|
||||
def _hide_user_names(self, platform_id: str | None) -> bool:
|
||||
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||
return bool(adapter and adapter.get_platform_name() == "qq_official")
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
group_id: str,
|
||||
@@ -88,6 +92,7 @@ class ReportDispatcher:
|
||||
self._html_render_func,
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
hide_user_names=self._hide_user_names(platform_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[{trace_id}] Failed to generate image report: {e}")
|
||||
@@ -135,6 +140,7 @@ class ReportDispatcher:
|
||||
group_id,
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
hide_user_names=self._hide_user_names(platform_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
|
||||
@@ -196,13 +202,31 @@ class ReportDispatcher:
|
||||
) -> bool:
|
||||
"""分发文本报告"""
|
||||
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
is_qq_official = self._hide_user_names(platform_id)
|
||||
fallback_report = None
|
||||
if is_qq_official:
|
||||
(
|
||||
text_report,
|
||||
fallback_report,
|
||||
) = await self.report_generator.generate_qq_official_markdown_report(
|
||||
analysis_result, self._html_render_func
|
||||
)
|
||||
else:
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||
# 尝试通过适配器发送文本报告
|
||||
logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}")
|
||||
try:
|
||||
if adapter and await adapter.send_text_report(group_id, text_report):
|
||||
return True
|
||||
if adapter:
|
||||
if is_qq_official:
|
||||
if await adapter.send_text_report(
|
||||
group_id,
|
||||
text_report,
|
||||
fallback_content=fallback_report,
|
||||
):
|
||||
return True
|
||||
elif await adapter.send_text_report(group_id, text_report):
|
||||
return True
|
||||
return await self.message_sender.send_text(
|
||||
group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
@@ -14,6 +15,7 @@ from dataclasses import asdict, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
@@ -25,6 +27,7 @@ from ...domain.repositories.report_repository import IReportGenerator
|
||||
from ...utils.logger import logger
|
||||
from ..utils.template_utils import render_template
|
||||
from ..visualization.activity_charts import ActivityVisualizer
|
||||
from .qq_official_markdown import QQOfficialMarkdownReportGenerator
|
||||
from .templates import HTMLTemplates
|
||||
|
||||
MAX_CONCURRENT_DOWNLOADS = 10
|
||||
@@ -105,6 +108,11 @@ class ReportGenerator(IReportGenerator):
|
||||
# 使用专用的 T2I 并发配置项
|
||||
max_concurrent = self.config_manager.get_t2i_max_concurrent()
|
||||
self._render_semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._qq_official_markdown_generator = QQOfficialMarkdownReportGenerator(
|
||||
config_manager,
|
||||
self.html_templates,
|
||||
self._render_semaphore,
|
||||
)
|
||||
|
||||
# 运行时缓存,用于在一次分析任务中避免重复下载同一个头像
|
||||
self._avatar_cache = Cache(
|
||||
@@ -336,6 +344,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_url_getter=None,
|
||||
nickname_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
hide_user_names: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
生成图片格式的分析报告
|
||||
@@ -359,6 +368,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=avatar_cache_namespace,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
|
||||
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
||||
@@ -500,6 +510,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_url_getter=None,
|
||||
nickname_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
hide_user_names: bool = False,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
生成HTML格式的分析报告,保存到指定目录
|
||||
@@ -544,6 +555,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=avatar_cache_namespace,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
|
||||
@@ -603,7 +615,11 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
# 保存原始 JSON 数据
|
||||
json_data = {
|
||||
"analysis_result": analysis_result,
|
||||
"analysis_result": (
|
||||
self._sanitize_analysis_result_for_export(analysis_result)
|
||||
if hide_user_names
|
||||
else analysis_result
|
||||
),
|
||||
"group_id": group_id,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
@@ -689,6 +705,88 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
return report
|
||||
|
||||
async def generate_qq_official_markdown_report(
|
||||
self, analysis_result: dict, html_render_func=None
|
||||
) -> tuple[str, str]:
|
||||
"""Delegate QQ-only text generation to the platform-specific module."""
|
||||
generator = getattr(self, "_qq_official_markdown_generator", None)
|
||||
if generator is None:
|
||||
generator = QQOfficialMarkdownReportGenerator(
|
||||
self.config_manager,
|
||||
getattr(self, "html_templates", None),
|
||||
getattr(self, "_render_semaphore", None),
|
||||
)
|
||||
self._qq_official_markdown_generator = generator
|
||||
return await generator.generate(
|
||||
analysis_result,
|
||||
html_render_func,
|
||||
)
|
||||
|
||||
def _sanitize_analysis_result_for_export(
|
||||
self, analysis_result: dict
|
||||
) -> dict[str, Any]:
|
||||
"""Remove platform identities from the HTML sidecar JSON export."""
|
||||
sanitized = self._to_plain_export_data(copy.deepcopy(analysis_result))
|
||||
sanitized["user_analysis"] = {}
|
||||
for topic in sanitized.get("topics", []):
|
||||
if not isinstance(topic, dict):
|
||||
continue
|
||||
topic["contributors"] = []
|
||||
topic["contributor_ids"] = []
|
||||
for title in sanitized.get("user_titles", []):
|
||||
if not isinstance(title, dict):
|
||||
continue
|
||||
title["name"] = ""
|
||||
title["user_id"] = ""
|
||||
stats = sanitized.get("statistics")
|
||||
if isinstance(stats, dict):
|
||||
for golden_quote in stats.get("golden_quotes", []) or []:
|
||||
if not isinstance(golden_quote, dict):
|
||||
continue
|
||||
golden_quote["sender"] = ""
|
||||
golden_quote["user_id"] = ""
|
||||
|
||||
activity_visualization = stats.get("activity_visualization")
|
||||
if isinstance(activity_visualization, dict):
|
||||
activity_visualization["user_activity_ranking"] = []
|
||||
|
||||
for golden_quote in sanitized.get("golden_quotes", []) or []:
|
||||
if not isinstance(golden_quote, dict):
|
||||
continue
|
||||
golden_quote["sender"] = ""
|
||||
golden_quote["user_id"] = ""
|
||||
|
||||
return self._sanitize_export_identity_text(sanitized, analysis_result) # type: ignore[return-type]
|
||||
|
||||
@classmethod
|
||||
def _to_plain_export_data(cls, value):
|
||||
"""Convert report models into plain containers before privacy filtering."""
|
||||
if hasattr(value, "to_dict") and callable(value.to_dict):
|
||||
return cls._to_plain_export_data(value.to_dict())
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return cls._to_plain_export_data(asdict(value))
|
||||
if isinstance(value, dict):
|
||||
return {key: cls._to_plain_export_data(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [cls._to_plain_export_data(item) for item in value]
|
||||
return value
|
||||
|
||||
def _sanitize_export_identity_text(self, value, analysis_result: dict):
|
||||
"""Remove known IDs and display names from every exported text field."""
|
||||
if isinstance(value, str):
|
||||
return self._sanitize_identity_text(value, analysis_result, True)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: self._sanitize_export_identity_text(item, analysis_result)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
self._sanitize_export_identity_text(item, analysis_result)
|
||||
for item in value
|
||||
]
|
||||
return value
|
||||
|
||||
async def _prepare_render_data(
|
||||
self,
|
||||
analysis_result: dict,
|
||||
@@ -696,6 +794,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_url_getter=None,
|
||||
nickname_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
hide_user_names: bool = False,
|
||||
) -> dict:
|
||||
"""准备渲染数据"""
|
||||
stats = analysis_result["statistics"]
|
||||
@@ -720,18 +819,34 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_cache_namespace,
|
||||
avatar_reuse_registry,
|
||||
avatar_reuse_aliases,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
if hide_user_names:
|
||||
contributors = await self._render_avatar_only_ids(
|
||||
getattr(topic, "contributor_ids", []) or [],
|
||||
avatar_url_getter,
|
||||
avatar_cache_namespace,
|
||||
avatar_reuse_registry,
|
||||
avatar_reuse_aliases,
|
||||
)
|
||||
else:
|
||||
contributors = "、".join(topic.contributors)
|
||||
topics_list.append(
|
||||
{
|
||||
"index": i,
|
||||
"topic": topic,
|
||||
"contributors": "、".join(topic.contributors),
|
||||
"topic": {
|
||||
"topic": self._sanitize_identity_text(
|
||||
topic.topic, analysis_result, hide_user_names
|
||||
)
|
||||
},
|
||||
"contributors": contributors,
|
||||
"detail": processed_detail,
|
||||
}
|
||||
)
|
||||
|
||||
# 通用模板上下文,包含可能被子模板引用的全局配置
|
||||
common_context = {
|
||||
"hide_user_names": hide_user_names,
|
||||
"t2i_font_source": self.config_manager.get_t2i_font_source(),
|
||||
"t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(),
|
||||
"t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(),
|
||||
@@ -763,11 +878,23 @@ class ReportGenerator(IReportGenerator):
|
||||
profile_info = self._resolve_profile_info(
|
||||
title.mbti, profile_mode, profile_mapping_overrides
|
||||
)
|
||||
title_reason = title.reason
|
||||
if hide_user_names:
|
||||
title_reason = await self._render_mentions(
|
||||
title.reason,
|
||||
avatar_url_getter,
|
||||
nickname_getter,
|
||||
user_analysis,
|
||||
avatar_cache_namespace,
|
||||
avatar_reuse_registry,
|
||||
avatar_reuse_aliases,
|
||||
hide_user_names=True,
|
||||
)
|
||||
title_data = {
|
||||
"name": title.name,
|
||||
"name": "" if hide_user_names else title.name,
|
||||
"title": title.title,
|
||||
"mbti": title.mbti,
|
||||
"reason": title.reason,
|
||||
"reason": title_reason,
|
||||
"avatar_data": avatar_data,
|
||||
}
|
||||
title_data.update(profile_info)
|
||||
@@ -810,11 +937,14 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_cache_namespace,
|
||||
avatar_reuse_registry,
|
||||
avatar_reuse_aliases,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
quotes_list.append(
|
||||
{
|
||||
"content": golden_quote.content,
|
||||
"sender": golden_quote.sender,
|
||||
"content": self._sanitize_identity_text(
|
||||
golden_quote.content, analysis_result, hide_user_names
|
||||
),
|
||||
"sender": "" if hide_user_names else golden_quote.sender,
|
||||
"reason": processed_reason,
|
||||
"avatar_url": avatar_url,
|
||||
}
|
||||
@@ -860,6 +990,33 @@ class ReportGenerator(IReportGenerator):
|
||||
else:
|
||||
review_data = chat_quality_review
|
||||
|
||||
if hide_user_names and isinstance(review_data, dict):
|
||||
review_data = {
|
||||
**review_data,
|
||||
"title": self._sanitize_identity_text(
|
||||
review_data.get("title", ""), analysis_result, True
|
||||
),
|
||||
"subtitle": self._sanitize_identity_text(
|
||||
review_data.get("subtitle", ""), analysis_result, True
|
||||
),
|
||||
"summary": self._sanitize_identity_text(
|
||||
review_data.get("summary", ""), analysis_result, True
|
||||
),
|
||||
"dimensions": [
|
||||
{
|
||||
**dimension,
|
||||
"name": self._sanitize_identity_text(
|
||||
dimension.get("name", ""), analysis_result, True
|
||||
),
|
||||
"comment": self._sanitize_identity_text(
|
||||
dimension.get("comment", ""), analysis_result, True
|
||||
),
|
||||
}
|
||||
for dimension in review_data.get("dimensions", [])
|
||||
if isinstance(dimension, dict)
|
||||
],
|
||||
}
|
||||
|
||||
chat_quality_html = self.html_templates.render_template(
|
||||
"chat_quality_item.html", **review_data, **common_context
|
||||
)
|
||||
@@ -899,6 +1056,50 @@ class ReportGenerator(IReportGenerator):
|
||||
logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
return render_data
|
||||
|
||||
async def _render_avatar_only_ids(
|
||||
self,
|
||||
user_ids: list[str],
|
||||
avatar_url_getter,
|
||||
avatar_cache_namespace: str | None,
|
||||
avatar_reuse_registry: dict[str, str] | None,
|
||||
avatar_reuse_aliases: dict[str, str] | None,
|
||||
) -> Markup:
|
||||
avatars: list[Markup] = []
|
||||
for raw_user_id in user_ids:
|
||||
user_id = str(raw_user_id or "").strip()
|
||||
if not user_id:
|
||||
continue
|
||||
avatar_url = await self._get_user_avatar(
|
||||
user_id, avatar_url_getter, avatar_cache_namespace
|
||||
)
|
||||
avatar_ref = self._register_reusable_avatar(
|
||||
avatar_url,
|
||||
avatar_reuse_registry,
|
||||
avatar_reuse_aliases,
|
||||
avatar_key=self._get_avatar_cache_key(user_id, avatar_cache_namespace),
|
||||
)
|
||||
style = (
|
||||
"width:24px;height:24px;border-radius:50%;display:inline-block;"
|
||||
"vertical-align:middle;margin:0 2px;background-size:cover;"
|
||||
"background-position:center;background-repeat:no-repeat;"
|
||||
)
|
||||
if avatar_ref:
|
||||
avatars.append(
|
||||
Markup(
|
||||
f'<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(
|
||||
self,
|
||||
text: str,
|
||||
@@ -908,20 +1109,41 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_cache_namespace: str | None = None,
|
||||
avatar_reuse_registry: dict[str, str] | None = None,
|
||||
avatar_reuse_aliases: dict[str, str] | None = None,
|
||||
hide_user_names: bool = False,
|
||||
) -> Markup:
|
||||
"""
|
||||
处理文本,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式
|
||||
处理文本,将 [用户ID] 格式的引用替换为头像胶囊。
|
||||
"""
|
||||
pattern = r"\[(\d+)\]"
|
||||
if not text:
|
||||
return Markup("")
|
||||
|
||||
matches = list(re.finditer(pattern, text))
|
||||
known_ids = {
|
||||
str(user_id).strip()
|
||||
for user_id in (user_analysis or {})
|
||||
if str(user_id).strip()
|
||||
}
|
||||
source_text = str(text)
|
||||
if hide_user_names:
|
||||
# LLM 偶尔会直接输出 ID;在头像-only 模式下先标准化为引用,
|
||||
# 避免 member_openid 以明文形式泄露。
|
||||
for user_id in sorted(known_ids, key=len, reverse=True):
|
||||
source_text = re.sub(
|
||||
rf"(?<!\[)(?<![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:
|
||||
return self._escape_text_segment(text)
|
||||
return self._escape_text_segment(source_text)
|
||||
|
||||
async def render_capsule(match: re.Match[str]) -> Markup:
|
||||
uid = match.group(1)
|
||||
if hide_user_names and uid not in known_ids:
|
||||
return Markup(html.escape(f"[{uid}]", quote=True))
|
||||
url = await self._get_user_avatar(
|
||||
uid, avatar_url_getter, avatar_cache_namespace
|
||||
) # 内部已有缓存,无需顶层并发获取
|
||||
@@ -949,7 +1171,10 @@ class ReportGenerator(IReportGenerator):
|
||||
"padding:2px 6px 2px 2px;border-radius:12px;margin:0 2px;"
|
||||
"vertical-align:middle;border:1px solid rgba(0,0,0,0.1);text-decoration:none;"
|
||||
)
|
||||
img_style = "width:18px;height:18px;border-radius:50%;margin-right:4px;display:block;"
|
||||
img_style = (
|
||||
"width:18px;height:18px;border-radius:50%;"
|
||||
f"margin-right:{'0' if hide_user_names else '4px'};display:block;"
|
||||
)
|
||||
name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;"
|
||||
|
||||
# 3. 最终后备: 确保有头像和名称
|
||||
@@ -979,23 +1204,51 @@ class ReportGenerator(IReportGenerator):
|
||||
f'style="{img_style}">'
|
||||
)
|
||||
|
||||
name_html = (
|
||||
""
|
||||
if hide_user_names
|
||||
else f'<span style="{name_style}">{html.escape(final_name)}</span>'
|
||||
)
|
||||
return Markup(
|
||||
f'<span class="user-capsule" style="{capsule_style}">'
|
||||
f"{avatar_html}"
|
||||
f'<span style="{name_style}">{html.escape(final_name)}</span>'
|
||||
"</span>"
|
||||
f"{avatar_html}{name_html}</span>"
|
||||
)
|
||||
|
||||
result: list[Markup | str] = []
|
||||
last_end = 0
|
||||
for match in matches:
|
||||
result.append(self._escape_text_segment(text[last_end : match.start()]))
|
||||
result.append(
|
||||
self._escape_text_segment(source_text[last_end : match.start()])
|
||||
)
|
||||
result.append(await render_capsule(match))
|
||||
last_end = match.end()
|
||||
|
||||
result.append(self._escape_text_segment(text[last_end:]))
|
||||
result.append(self._escape_text_segment(source_text[last_end:]))
|
||||
return Markup("").join(result)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_identity_text(
|
||||
text: str, analysis_result: dict, hide_user_names: bool
|
||||
) -> str:
|
||||
if not hide_user_names:
|
||||
return str(text)
|
||||
sanitized = str(text)
|
||||
user_analysis = analysis_result.get("user_analysis") or {}
|
||||
known_ids = {
|
||||
str(user_id).strip() for user_id in user_analysis if str(user_id).strip()
|
||||
}
|
||||
known_names = set()
|
||||
for stats in user_analysis.values():
|
||||
if not isinstance(stats, dict):
|
||||
continue
|
||||
for key in ("nickname", "name"):
|
||||
value = str(stats.get(key, "") or "").strip()
|
||||
if value:
|
||||
known_names.add(value)
|
||||
for identity in sorted(known_ids | known_names, key=len, reverse=True):
|
||||
sanitized = sanitized.replace(identity, "")
|
||||
return re.sub(r"\[\s*\]", "", sanitized)
|
||||
|
||||
@staticmethod
|
||||
def _escape_text_segment(text: str) -> Markup:
|
||||
return Markup(html.escape(text, quote=False).replace("\n", "<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"",
|
||||
"",
|
||||
]
|
||||
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()
|
||||
@@ -20,6 +20,9 @@ class HTMLTemplates:
|
||||
self.config_manager = config_manager
|
||||
# 设置模板根目录
|
||||
self.base_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
self.platform_base_dir = os.path.join(
|
||||
os.path.dirname(__file__), "platform_templates"
|
||||
)
|
||||
# 缓存不同模板的Jinja2环境(多线程安全)
|
||||
self._envs = {}
|
||||
self._env_lock = threading.Lock()
|
||||
@@ -73,6 +76,9 @@ class HTMLTemplates:
|
||||
try:
|
||||
env = await self._get_env_async()
|
||||
template = env.get_template("image_template.html")
|
||||
if template.filename is None:
|
||||
logger.error("图片模板路径为空")
|
||||
return ""
|
||||
return await asyncio.to_thread(
|
||||
self._read_template_file_sync, template.filename
|
||||
)
|
||||
@@ -85,6 +91,9 @@ class HTMLTemplates:
|
||||
try:
|
||||
env = self._get_env()
|
||||
template = env.get_template("image_template.html")
|
||||
if template.filename is None:
|
||||
logger.error("图片模板路径为空")
|
||||
return ""
|
||||
with open(template.filename, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
@@ -108,3 +117,20 @@ class HTMLTemplates:
|
||||
except Exception as e:
|
||||
logger.error(f"渲染模板 {template_name} 失败: {e}")
|
||||
return ""
|
||||
|
||||
def render_platform_template(
|
||||
self, platform_name: str, template_name: str, **kwargs
|
||||
) -> str:
|
||||
"""渲染与报告主题解耦的平台专用模板。"""
|
||||
try:
|
||||
template_dir = os.path.join(self.platform_base_dir, platform_name)
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(template_dir),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
return env.get_template(template_name).render(**kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"渲染平台模板 {platform_name}/{template_name} 失败: {e}")
|
||||
return ""
|
||||
|
||||
@@ -7,9 +7,10 @@ from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from ...domain.models.data_models import ActivityVisualization
|
||||
from ...domain.repositories.visualization_repository import IActivityVisualizer
|
||||
|
||||
|
||||
class ActivityVisualizer:
|
||||
class ActivityVisualizer(IActivityVisualizer):
|
||||
"""活跃度可视化器"""
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,548 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.domain.models.data_models import (
|
||||
ActivityVisualization,
|
||||
GoldenQuote,
|
||||
GroupStatistics,
|
||||
QualityDimension,
|
||||
QualityReview,
|
||||
)
|
||||
from src.infrastructure.reporting.generators import ReportGenerator
|
||||
from src.infrastructure.reporting.qq_official_markdown import (
|
||||
QQOfficialMarkdownReportGenerator,
|
||||
)
|
||||
from src.infrastructure.reporting.templates import HTMLTemplates
|
||||
|
||||
|
||||
class FakeConfig:
|
||||
def get_max_topics(self):
|
||||
return 10
|
||||
|
||||
def get_max_user_titles(self):
|
||||
return 10
|
||||
|
||||
def get_max_golden_quotes(self):
|
||||
return 10
|
||||
|
||||
def get_qq_official_t2i_summary_dashboard_enabled(self):
|
||||
return True
|
||||
|
||||
|
||||
def build_generator_without_io():
|
||||
generator = object.__new__(ReportGenerator)
|
||||
generator.config_manager = FakeConfig()
|
||||
return generator
|
||||
|
||||
|
||||
def generate_qq_markdown(generator, analysis_result):
|
||||
markdown_report, _ = asyncio.run(
|
||||
generator.generate_qq_official_markdown_report(analysis_result)
|
||||
)
|
||||
return markdown_report
|
||||
|
||||
|
||||
def test_standard_text_report_keeps_existing_identity_format():
|
||||
generator = build_generator_without_io()
|
||||
openid = "A1B2C3D4_OPENID"
|
||||
statistics = SimpleNamespace(
|
||||
message_count=2,
|
||||
participant_count=1,
|
||||
total_characters=10,
|
||||
emoji_count=0,
|
||||
most_active_period="12:00-13:00",
|
||||
golden_quotes=[
|
||||
SimpleNamespace(
|
||||
content="测试内容",
|
||||
sender=openid,
|
||||
reason=f"由 {openid} 发出",
|
||||
)
|
||||
],
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [
|
||||
SimpleNamespace(
|
||||
topic="测试话题",
|
||||
contributors=[openid],
|
||||
detail=f"{openid} 参与讨论",
|
||||
)
|
||||
],
|
||||
"user_titles": [
|
||||
SimpleNamespace(
|
||||
name=openid,
|
||||
title="龙王",
|
||||
mbti="ENTP",
|
||||
reason=f"{openid} 发言最多",
|
||||
)
|
||||
],
|
||||
"user_analysis": {openid: {"nickname": openid}},
|
||||
}
|
||||
|
||||
report = generator.generate_text_report(analysis_result)
|
||||
|
||||
assert openid in report
|
||||
assert "测试内容" in report
|
||||
assert "龙王" in report
|
||||
assert f"参与者: {openid}" in report
|
||||
assert f"• {openid} - 龙王 (ENTP)" in report
|
||||
assert f'1. "测试内容" —— {openid}' in report
|
||||
|
||||
|
||||
def test_standard_text_report_api_has_no_qq_platform_switches():
|
||||
parameters = inspect.signature(ReportGenerator.generate_text_report).parameters
|
||||
|
||||
assert list(parameters) == ["self", "analysis_result"]
|
||||
|
||||
|
||||
def test_non_qq_text_report_does_not_use_qq_histogram_path():
|
||||
generator = build_generator_without_io()
|
||||
statistics = SimpleNamespace(
|
||||
message_count=3,
|
||||
participant_count=1,
|
||||
total_characters=12,
|
||||
emoji_count=0,
|
||||
most_active_period="03:00-04:00",
|
||||
golden_quotes=[],
|
||||
activity_visualization=SimpleNamespace(hourly_activity={3: 3}),
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [],
|
||||
"user_analysis": {},
|
||||
}
|
||||
|
||||
report = generator.generate_text_report(analysis_result)
|
||||
|
||||
assert "🎯 群聊日常分析报告" in report
|
||||
assert "## ⏰ 活跃时间分布" not in report
|
||||
assert "████" not in report
|
||||
assert "![24小时活跃分布" not in report
|
||||
|
||||
|
||||
def test_qq_official_markdown_uses_mentions_for_all_identity_sections():
|
||||
generator = build_generator_without_io()
|
||||
openid = "A1B2C3D4_OPENID"
|
||||
nickname = "测试群友"
|
||||
statistics = SimpleNamespace(
|
||||
message_count=2,
|
||||
participant_count=1,
|
||||
total_characters=10,
|
||||
emoji_count=0,
|
||||
most_active_period="12:00-13:00",
|
||||
golden_quotes=[
|
||||
SimpleNamespace(
|
||||
content=f"[{openid}] 说了一句话",
|
||||
sender=nickname,
|
||||
reason=f"{nickname} 的发言很精彩",
|
||||
user_id=openid,
|
||||
)
|
||||
],
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [
|
||||
SimpleNamespace(
|
||||
topic="测试话题",
|
||||
contributors=[nickname],
|
||||
contributor_ids=[openid],
|
||||
detail=f"{nickname} 和 {openid} 参与讨论",
|
||||
)
|
||||
],
|
||||
"user_titles": [
|
||||
SimpleNamespace(
|
||||
name=nickname,
|
||||
user_id=openid,
|
||||
title="龙王",
|
||||
mbti="ENTP",
|
||||
reason=f"[{openid}] 发言最多",
|
||||
)
|
||||
],
|
||||
"user_analysis": {openid: {"nickname": nickname}},
|
||||
}
|
||||
|
||||
report = generate_qq_markdown(generator, analysis_result)
|
||||
|
||||
assert report.count(f"<@{openid}>") >= 6
|
||||
without_mentions = report.replace(f"<@{openid}>", "")
|
||||
assert openid not in without_mentions
|
||||
assert nickname not in without_mentions
|
||||
assert "## 💬 热门话题" in report
|
||||
assert "**参与者**" in report
|
||||
assert "**龙王**" in report
|
||||
assert f"- **1. <@{openid}> 说了一句话** — <@{openid}>" in report
|
||||
assert f" > <@{openid}> 的发言很精彩" in report
|
||||
assert f"> 1. <@{openid}> 说了一句话" not in report
|
||||
|
||||
|
||||
def test_qq_official_markdown_keeps_content_when_identity_id_is_missing():
|
||||
generator = build_generator_without_io()
|
||||
statistics = SimpleNamespace(
|
||||
message_count=1,
|
||||
participant_count=1,
|
||||
total_characters=4,
|
||||
emoji_count=0,
|
||||
most_active_period="12:00-13:00",
|
||||
golden_quotes=[
|
||||
SimpleNamespace(
|
||||
content="测试内容",
|
||||
sender="无法映射的用户",
|
||||
reason="理由保留",
|
||||
user_id="",
|
||||
)
|
||||
],
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [
|
||||
SimpleNamespace(
|
||||
name="无法映射的用户",
|
||||
user_id="",
|
||||
title="龙王",
|
||||
mbti="",
|
||||
reason="称号理由",
|
||||
)
|
||||
],
|
||||
"user_analysis": {},
|
||||
}
|
||||
|
||||
report = generate_qq_markdown(generator, analysis_result)
|
||||
|
||||
assert "<@" not in report
|
||||
assert "龙王" in report
|
||||
assert "称号理由" in report
|
||||
assert "测试内容" in report
|
||||
assert "理由保留" in report
|
||||
assert "无法映射的用户" not in report
|
||||
assert "- **1. 测试内容**" in report
|
||||
assert " > 理由保留" in report
|
||||
assert "> 1. 测试内容" not in report
|
||||
|
||||
|
||||
def test_qq_official_scripture_spacing_and_optional_reason():
|
||||
generator = build_generator_without_io()
|
||||
statistics = SimpleNamespace(
|
||||
message_count=2,
|
||||
participant_count=2,
|
||||
total_characters=8,
|
||||
emoji_count=0,
|
||||
most_active_period="12:00-13:00",
|
||||
golden_quotes=[
|
||||
SimpleNamespace(
|
||||
content="第一条",
|
||||
sender="甲",
|
||||
reason="第一条理由",
|
||||
user_id="A_OPENID",
|
||||
),
|
||||
SimpleNamespace(
|
||||
content="第二条",
|
||||
sender="乙",
|
||||
reason="",
|
||||
user_id="",
|
||||
),
|
||||
],
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [],
|
||||
"user_analysis": {
|
||||
"A_OPENID": {"nickname": "甲"},
|
||||
},
|
||||
}
|
||||
|
||||
report = generate_qq_markdown(generator, analysis_result)
|
||||
|
||||
assert "- **1. 第一条** — <@A_OPENID>\n > 第一条理由\n\n- **2. 第二条**" in report
|
||||
assert "- **2. 第二条** —" not in report
|
||||
|
||||
|
||||
def test_qq_official_markdown_renders_simple_hourly_bar_chart():
|
||||
generator = build_generator_without_io()
|
||||
statistics = SimpleNamespace(
|
||||
message_count=17,
|
||||
participant_count=3,
|
||||
total_characters=80,
|
||||
emoji_count=1,
|
||||
most_active_period="03:00-04:00",
|
||||
golden_quotes=[],
|
||||
activity_visualization=SimpleNamespace(
|
||||
hourly_activity={0: 0, "1": 2, 2: 5, "3": 10}
|
||||
),
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [],
|
||||
"user_analysis": {},
|
||||
}
|
||||
|
||||
report = generate_qq_markdown(generator, analysis_result)
|
||||
|
||||
assert "## ⏰ 活跃时间分布" in report
|
||||
assert "- 00:00 — 0" in report
|
||||
assert "- 01:00 ███ 2" in report
|
||||
assert "- 02:00 ██████ 5" in report
|
||||
assert "- 03:00 ████████████ 10" in report
|
||||
chart_section = report.split("## ⏰ 活跃时间分布", 1)[1].split("## 💬 热门话题", 1)[
|
||||
0
|
||||
]
|
||||
assert sum(1 for line in chart_section.splitlines() if line.startswith("- ")) == 24
|
||||
|
||||
|
||||
def test_qq_official_markdown_omits_empty_hourly_bar_chart():
|
||||
generator = build_generator_without_io()
|
||||
statistics = SimpleNamespace(
|
||||
message_count=0,
|
||||
participant_count=0,
|
||||
total_characters=0,
|
||||
emoji_count=0,
|
||||
most_active_period="",
|
||||
golden_quotes=[],
|
||||
activity_visualization=SimpleNamespace(hourly_activity={}),
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [],
|
||||
"user_analysis": {},
|
||||
}
|
||||
|
||||
report = generate_qq_markdown(generator, analysis_result)
|
||||
|
||||
assert "## ⏰ 活跃时间分布" not in report
|
||||
|
||||
|
||||
def test_qq_official_t2i_summary_dashboard_replaces_text_summary():
|
||||
generator = build_generator_without_io()
|
||||
generator.html_templates = HTMLTemplates(generator.config_manager)
|
||||
generator._render_semaphore = asyncio.Semaphore(1)
|
||||
statistics = SimpleNamespace(
|
||||
message_count=10,
|
||||
participant_count=2,
|
||||
total_characters=50,
|
||||
emoji_count=4,
|
||||
most_active_period="03:00-04:00",
|
||||
golden_quotes=[],
|
||||
activity_visualization=SimpleNamespace(hourly_activity={1: 2, 3: 10}),
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [],
|
||||
"user_analysis": {},
|
||||
}
|
||||
render_calls = []
|
||||
|
||||
async def fake_html_render(template, data, return_url, options):
|
||||
render_calls.append((template, data, return_url, options))
|
||||
return "https://t2i.example/chart.png"
|
||||
|
||||
markdown_report, fallback_report = asyncio.run(
|
||||
generator.generate_qq_official_markdown_report(
|
||||
analysis_result, fake_html_render
|
||||
)
|
||||
)
|
||||
|
||||
assert len(render_calls) == 1
|
||||
template, data, return_url, options = render_calls[0]
|
||||
assert "群聊日常分析" in template
|
||||
assert "消息" in template
|
||||
assert "参与" in template
|
||||
assert "字符" in template
|
||||
assert "表情" in template
|
||||
assert "高峰" in template
|
||||
assert ">10<" in template
|
||||
assert ">2<" in template
|
||||
assert ">50<" in template
|
||||
assert ">4<" in template
|
||||
assert ">03–04<" in template
|
||||
assert 'class="histogram"' in template
|
||||
assert template.count('class="hour"') == 24
|
||||
assert template.count('class="metric"') == 5
|
||||
assert ">00<" in template
|
||||
assert ">23<" in template
|
||||
assert "background: transparent !important" in template
|
||||
assert "background: #000000" in template
|
||||
assert "font-size: 25px" in template
|
||||
assert "font-size: 28px" in template
|
||||
assert "font-size: 14px" in template
|
||||
assert "#1d1d1f" not in template
|
||||
assert "#6e6e73" not in template
|
||||
assert "#86868b" not in template
|
||||
assert "rgba(0, 0, 0" not in template
|
||||
assert "text-shadow" not in template
|
||||
assert "linear-gradient" not in template
|
||||
assert "#5b8ff9" not in template
|
||||
assert data == {}
|
||||
assert return_url is True
|
||||
assert options["type"] == "png"
|
||||
assert options["omit_background"] is True
|
||||
assert options["clip"] == {"x": 0, "y": 0, "width": 800, "height": 360}
|
||||
assert "https://t2i.example/chart.png" in markdown_report
|
||||
assert "# 🎯 群聊日常分析报告" not in markdown_report
|
||||
assert "📅" not in markdown_report
|
||||
assert "## 📊 基础统计" not in markdown_report
|
||||
assert "消息总数" not in markdown_report
|
||||
assert "## ⏰ 活跃时间分布" not in markdown_report
|
||||
assert "████" not in markdown_report
|
||||
assert "https://t2i.example/chart.png" not in fallback_report
|
||||
assert "# 🎯 群聊日常分析报告" in fallback_report
|
||||
assert "📅" in fallback_report
|
||||
assert "## 📊 基础统计" in fallback_report
|
||||
assert "消息总数" in fallback_report
|
||||
assert "## ⏰ 活跃时间分布" in fallback_report
|
||||
assert "████" in fallback_report
|
||||
|
||||
|
||||
def test_qq_official_t2i_summary_dashboard_switch_disables_rendering():
|
||||
class DisabledConfig(FakeConfig):
|
||||
def get_qq_official_t2i_summary_dashboard_enabled(self):
|
||||
return False
|
||||
|
||||
generator = object.__new__(ReportGenerator)
|
||||
generator.config_manager = DisabledConfig()
|
||||
statistics = SimpleNamespace(
|
||||
message_count=10,
|
||||
participant_count=2,
|
||||
total_characters=50,
|
||||
emoji_count=0,
|
||||
most_active_period="03:00-04:00",
|
||||
golden_quotes=[],
|
||||
activity_visualization=SimpleNamespace(hourly_activity={3: 10}),
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [],
|
||||
"user_analysis": {},
|
||||
}
|
||||
|
||||
async def unexpected_render(*args, **kwargs):
|
||||
raise AssertionError("T2I should not run when disabled")
|
||||
|
||||
markdown_report, fallback_report = asyncio.run(
|
||||
generator.generate_qq_official_markdown_report(
|
||||
analysis_result, unexpected_render
|
||||
)
|
||||
)
|
||||
|
||||
assert markdown_report == fallback_report
|
||||
assert "████████████" in markdown_report
|
||||
|
||||
|
||||
def test_qq_official_summary_dashboard_compacts_large_metrics():
|
||||
assert QQOfficialMarkdownReportGenerator.format_metric(10_000) == "10K"
|
||||
assert QQOfficialMarkdownReportGenerator.format_metric(12_500) == "12.5K"
|
||||
assert QQOfficialMarkdownReportGenerator.format_metric(1_000_000) == "1M"
|
||||
assert QQOfficialMarkdownReportGenerator.format_metric(1_250_000) == "1.2M"
|
||||
|
||||
|
||||
def test_non_qq_avatar_mentions_ignore_alphanumeric_bracket_text():
|
||||
generator = build_generator_without_io()
|
||||
|
||||
async def unexpected_avatar(*args, **kwargs):
|
||||
raise AssertionError("non-QQ bracket text must not trigger avatar lookup")
|
||||
|
||||
generator._get_user_avatar = unexpected_avatar
|
||||
rendered = asyncio.run(
|
||||
generator._render_mentions(
|
||||
"保留 [TODO]、[GPT-4] 和 [A_OPENID]",
|
||||
avatar_url_getter=None,
|
||||
user_analysis={"A_OPENID": {"nickname": "测试用户"}},
|
||||
hide_user_names=False,
|
||||
)
|
||||
)
|
||||
|
||||
rendered_text = str(rendered)
|
||||
assert "[TODO]" in rendered_text
|
||||
assert "[GPT-4]" in rendered_text
|
||||
assert "[A_OPENID]" in rendered_text
|
||||
assert "user-capsule" not in rendered_text
|
||||
|
||||
|
||||
def test_mentions_support_alphanumeric_openid_and_hide_text():
|
||||
generator = build_generator_without_io()
|
||||
openid = "A1B2C3D4_OPENID"
|
||||
|
||||
async def fake_avatar(*args, **kwargs):
|
||||
return "data:image/png;base64,AAAA"
|
||||
|
||||
generator._get_user_avatar = fake_avatar
|
||||
rendered = asyncio.run(
|
||||
generator._render_mentions(
|
||||
f"成员 [{openid}] 发言",
|
||||
avatar_url_getter=None,
|
||||
user_analysis={openid: {"nickname": openid}},
|
||||
avatar_cache_namespace="official-main",
|
||||
avatar_reuse_registry={},
|
||||
avatar_reuse_aliases={},
|
||||
hide_user_names=True,
|
||||
)
|
||||
)
|
||||
|
||||
rendered_text = str(rendered)
|
||||
assert openid not in rendered_text
|
||||
assert "user-capsule-avatar" in rendered_text
|
||||
assert "成员" in rendered_text
|
||||
|
||||
|
||||
def test_html_sidecar_export_removes_nested_identity_values():
|
||||
generator = build_generator_without_io()
|
||||
openid = "A1B2C3D4_OPENID"
|
||||
statistics = GroupStatistics(
|
||||
message_count=2,
|
||||
participant_count=1,
|
||||
total_characters=10,
|
||||
emoji_count=0,
|
||||
most_active_period="12:00-13:00",
|
||||
golden_quotes=[
|
||||
GoldenQuote(
|
||||
content="测试内容",
|
||||
sender=openid,
|
||||
reason=f"由 {openid} 发出",
|
||||
user_id=openid,
|
||||
)
|
||||
],
|
||||
activity_visualization=ActivityVisualization(
|
||||
user_activity_ranking=[
|
||||
{
|
||||
"user_id": openid,
|
||||
"name": openid,
|
||||
"message_count": 2,
|
||||
}
|
||||
]
|
||||
),
|
||||
chat_quality_review=QualityReview(
|
||||
title=f"{openid} 的聊天质量",
|
||||
subtitle="测试",
|
||||
dimensions=[
|
||||
QualityDimension(
|
||||
name="活跃度",
|
||||
percentage=100,
|
||||
comment=f"{openid} 最活跃",
|
||||
)
|
||||
],
|
||||
summary=f"总结 {openid}",
|
||||
),
|
||||
)
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": [],
|
||||
"user_titles": [],
|
||||
"user_analysis": {openid: {"nickname": openid}},
|
||||
"chat_quality_review": statistics.chat_quality_review,
|
||||
}
|
||||
|
||||
sanitized = generator._sanitize_analysis_result_for_export(analysis_result)
|
||||
exported = json.dumps(sanitized, ensure_ascii=False)
|
||||
|
||||
assert openid not in exported
|
||||
assert sanitized["user_analysis"] == {}
|
||||
assert (
|
||||
sanitized["statistics"]["activity_visualization"]["user_activity_ranking"] == []
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
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)
|
||||
Reference in New Issue
Block a user