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)
|
# 更新日志 (CHANGELOG)
|
||||||
|
|
||||||
## [v4.10.9] - ✨ 新增模板 ”BlueArchive“ 蔚蓝档案 (@VanillaNahida)
|
## [v4.11.0] - ✨ 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145)
|
||||||
|
|
||||||
|
插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。
|
||||||
|
|
||||||
|
* **🛠️配置注意事项 **: 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
||||||
|
* **🛠️ 适配范围**: 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
||||||
|
* **⚙️ 成员昵称**: 官方群事件不提供成员昵称,所以推荐使用 QQ 官方机器人的用户配置输出格式为 text (默认为 image)格式,获得更好的体验,图片格式下无法显示成员昵称。
|
||||||
|
* **⚙️ QQ 官方 API 支持有限**: QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
||||||
|
* **✨ 分析名单配置**: 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置黑白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
||||||
|
* **✨ Markdown 报告**: QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
||||||
|
* **✨ Markdown 概览图**: Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>📋 点击查看历史更新日志</summary>
|
<summary>📋 点击查看历史更新日志</summary>
|
||||||
|
|
||||||
|
## [v4.10.9] - ✨ 新增模板 ”BlueArchive“ 蔚蓝档案 (@VanillaNahida)
|
||||||
|
|
||||||
## [v4.10.8] - 🛠️ snowluma 被禁言避免触发分析修复 (#191)
|
## [v4.10.8] - 🛠️ snowluma 被禁言避免触发分析修复 (#191)
|
||||||
|
|
||||||
## [v4.10.7] - ✨ 被禁言避免触发分析功能浪费 token,重构并优化模型请求的重试与降级补偿机制
|
## [v4.10.7] - ✨ 被禁言避免触发分析功能浪费 token,重构并优化模型请求的重试与降级补偿机制
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
_✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**、**Telegram**、**Discord**,未来支持更多平台。 [灵感来源](https://github.com/LSTM-Kirigaya/openmcp-tutorial/tree/main/qq-group-summary)。 ✨_
|
_✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCat, LLOneBot, Snowluma)、**QQ 官方机器人**、**Telegram**、**Discord**,未来支持更多平台。 ✨_
|
||||||
|
|
||||||
<img src="https://count.getloli.com/@astrbot-qq-group-daily-analysis?name=astrbot-qq-group-daily-analysis&theme=booru-jaypee&padding=6&offset=0&align=top&scale=1&pixelated=1&darkmode=auto" alt="count" />
|
<img src="https://count.getloli.com/@astrbot-qq-group-daily-analysis?name=astrbot-qq-group-daily-analysis&theme=booru-jaypee&padding=6&offset=0&align=top&scale=1&pixelated=1&darkmode=auto" alt="count" />
|
||||||
</div>
|
</div>
|
||||||
@@ -60,10 +60,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
|||||||
<p><b>BlueArchive</b></p>
|
<p><b>BlueArchive</b></p>
|
||||||
<img src="https://fastly.jsdelivr.net/gh/VanillaNahida/astrbot_plugin_qq_group_daily_analysis@main/assets/BlueArchive-demo.jpg" alt="BlueArchive" width="100%">
|
<img src="https://fastly.jsdelivr.net/gh/VanillaNahida/astrbot_plugin_qq_group_daily_analysis@main/assets/BlueArchive-demo.jpg" alt="BlueArchive" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td align="center" width="33.3%" valign="top">
|
|
||||||
<p><b>Simple</b></p>
|
|
||||||
<img src="https://fastly.jsdelivr.net/gh/SXP-Simon/astrbot_plugin_qq_group_daily_analysis@main/assets/format-demo.jpg" alt="simple" width="100%">
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -85,25 +81,6 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
|||||||
- **QQ群**: 支持上传到群相册和群文件,查阅黑历史友好
|
- **QQ群**: 支持上传到群相册和群文件,查阅黑历史友好
|
||||||
- **详细数据**: 包含消息统计、时间分布、关键词、金句等
|
- **详细数据**: 包含消息统计、时间分布、关键词、金句等
|
||||||
|
|
||||||
> [!warning]
|
|
||||||
> **实验性开发中**:
|
|
||||||
> - 多平台支持功能尚在开发中,当前仅支持QQ OneBot, Discord, Telegram。
|
|
||||||
> - 旧版本稳定版在[QQ 分支](https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis/tree/QQ),仅 QQ 平台支持
|
|
||||||
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> **Discord 用户重点注意**:
|
|
||||||
> 如果机器人无法获取群列表或分析报 `403 Forbidden`,请检查 Discord 开发者面板中:
|
|
||||||
> 1. **Privileged Gateway Intents**: 开启 `Message Content Intent`。
|
|
||||||
> 2. **频道权限**: 确保机器人所在的频道,对应的角色拥有 **“查看消息历史记录”** 权限。
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> **Telegram 用户重点注意**:
|
|
||||||
> 1. 如果 TG Bot 不是群管理员,务必在拉入群前先关闭 BotFather 隐私模式。
|
|
||||||
> 2. 如果 Bot 已经在群里且不是管理员,关闭隐私模式后必须先移除再重新拉入群,否则新设置不会生效。
|
|
||||||
>
|
|
||||||
> 注:群聊隐私模式关闭流程:`@BotFather`→左下角Open→选择要调整的bot→Bot Settings→将`Group Privacy`关闭
|
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **图片生成失败/渲染超时的解决办法**
|
> **图片生成失败/渲染超时的解决办法**
|
||||||
@@ -118,7 +95,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
|||||||
>
|
>
|
||||||
> ### 2. 使用备用 T2I 服务或自部署
|
> ### 2. 使用备用 T2I 服务或自部署
|
||||||
> <details>
|
> <details>
|
||||||
> <summary><b>若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击展开):</b></summary>
|
> <summary><b>若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击此行展开说明):</b></summary>
|
||||||
>
|
>
|
||||||
> - **Hugging Face 服务**: `https://huggingface.co/spaces/clown145/astrbot-t2i-service`
|
> - **Hugging Face 服务**: `https://huggingface.co/spaces/clown145/astrbot-t2i-service`
|
||||||
> - **API 接口地址**: `https://clown145-astrbot-t2i-service.hf.space`
|
> - **API 接口地址**: `https://clown145-astrbot-t2i-service.hf.space`
|
||||||
@@ -134,26 +111,36 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
|||||||
>
|
>
|
||||||
> **更换 T2I 端点或自部署 T2I 参考文档**:[docs.astrbot.app/others/self-host-t2i.html](https://docs.astrbot.app/others/self-host-t2i.html)
|
> **更换 T2I 端点或自部署 T2I 参考文档**:[docs.astrbot.app/others/self-host-t2i.html](https://docs.astrbot.app/others/self-host-t2i.html)
|
||||||
|
|
||||||
|
> [!warning]
|
||||||
|
> **实验性开发中**:
|
||||||
|
> - 多平台支持功能尚在开发中,当前仅支持 OneBot (NapCat, LLOneBot, Snowluma), QQ 官方机器人, Discord, Telegram。
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
> **QQ 官方机器人用户注意**:
|
||||||
> Feishu / Lark (WIP)
|
> 插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。
|
||||||
>
|
> - 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
||||||
> 尝试开发中
|
> - QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
||||||
>
|
> - 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
||||||
> 通过 `LarkAdapter` 复用 AstrBot 已有的 `lark_oapi` 生态能力,在获得授权后即可对 Feishu 群聊执行完整的消息采集与分析。
|
> - 官方群事件不提供成员昵称。
|
||||||
>
|
> - QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
||||||
> - **一次性授权**:在飞书开放平台中为你的应用补齐如下权限(仅需在初次部署时授予):
|
> - Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。
|
||||||
> - `im:message:readonly`、`im:chat:readonly`(读取群消息/群信息)
|
> - 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
||||||
> - `contact:contact.base:readonly`(拉取用户昵称/头像;缺少该权限会导致头像永远显示默认)
|
|
||||||
> - 发送需要的附加 scope(如 `im:message:send` / `im:message:receive_v1` / 上传相关的 `im:resource` 系列)
|
|
||||||
> - **用户头像保障**:插件在分析前会调用 `LarkAdapter.prepare_group_member_cache`,一次性批量拉取最多 100 名活跃成员的头像并在缓存中保留,让后续分析阶段不再遇到“未授权头像”。
|
|
||||||
> - **令牌与长时运行**:飞书的 `tenant_access_token` 有 2 小时有效期,分析任务运行时间若超过此周期,请确保你的 Bot 框架会自动刷新令牌(通常是 SDK 默认行为)。
|
|
||||||
> 读取 Feishu 配置后,按照上述授权顺序重新安装/刷新应用,就能在报告里面看到与 QQ/Telegram 一样的用户筛选、头像与 LLM 输出。
|
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Discord 用户重点注意**:
|
||||||
|
> 如果机器人无法获取群列表或分析报 `403 Forbidden`,请检查 Discord 开发者面板中:
|
||||||
|
> 1. **Privileged Gateway Intents**: 开启 `Message Content Intent`。
|
||||||
|
> 2. **频道权限**: 确保机器人所在的频道,对应的角色拥有 **“查看消息历史记录”** 权限。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Telegram 用户重点注意**:
|
||||||
|
> 1. 如果 TG Bot 不是群管理员,务必在拉入群前先关闭 BotFather 隐私模式。
|
||||||
|
> 2. 如果 Bot 已经在群里且不是管理员,关闭隐私模式后必须先移除再重新拉入群,否则新设置不会生效。
|
||||||
|
>
|
||||||
|
> 注:群聊隐私模式关闭流程:`@BotFather`→左下角Open→选择要调整的bot→Bot Settings→将`Group Privacy`关闭
|
||||||
|
|
||||||
### 🛠️ 灵活配置
|
### 🛠️ 灵活配置
|
||||||
- **多平台支持**: 自动识别并适配 OneBot, Discord, Telegram 等平台
|
- **多平台支持**: 自动识别并适配 OneBot, QQ 官方机器人, Discord, Telegram 等平台
|
||||||
- **群组管理**: 支持指定特定群组启用功能(支持跨平台黑白名单)
|
- **群组管理**: 支持指定特定群组启用功能(支持跨平台黑白名单)
|
||||||
- **参数调节**: 可自定义分析天数、消息数量等参数
|
- **参数调节**: 可自定义分析天数、消息数量等参数
|
||||||
- **定时任务**: 支持设置每日自动分析时间
|
- **定时任务**: 支持设置每日自动分析时间
|
||||||
@@ -284,6 +271,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)**
|
|||||||
| 平台 | 适配器类型 | 特殊要求/说明 |
|
| 平台 | 适配器类型 | 特殊要求/说明 |
|
||||||
|------|-----------|--------------|
|
|------|-----------|--------------|
|
||||||
| **QQ** | OneBot v11 | 建议使用 NapCat/Lagrange。需注意消息分页拉取限制。 |
|
| **QQ** | OneBot v11 | 建议使用 NapCat/Lagrange。需注意消息分页拉取限制。 |
|
||||||
|
| **QQ 官方机器人** | QQ Bot API v2(WebSocket/Webhook) | 需开启群全量消息;只分析启用后实时缓存的消息;图片/HTML 仅显示头像,Markdown 文本使用成员艾特。 |
|
||||||
| **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 |
|
| **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 |
|
||||||
| **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 |
|
| **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 |
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -19,7 +19,7 @@
|
|||||||
"type": "list",
|
"type": "list",
|
||||||
"description": "群组白/黑名单列表",
|
"description": "群组白/黑名单列表",
|
||||||
"default": [],
|
"default": [],
|
||||||
"hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号(如 123456)。新手建议优先填完整会话ID,更不容易填错。可用 /sid 获取当前会话ID。",
|
"hint": "要填的群列表。可以填完整会话ID(如 onebot:GroupMessage:123456)或只填群号。QQ 官方机器人使用 group_openid,不是群号,务必优先通过 /sid 获取并填写完整 UMO。",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"type": "int",
|
"type": "int",
|
||||||
"description": "默认分析天数",
|
"description": "默认分析天数",
|
||||||
"default": 1,
|
"default": 1,
|
||||||
"hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况"
|
"hint": "默认分析最近几天的消息,增量分析也基于此数据保存分析情况。QQ 官方 API 无历史拉取接口,只能分析插件启用后实时缓存到 AstrBot 本地库的消息。"
|
||||||
},
|
},
|
||||||
"max_messages": {
|
"max_messages": {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"html"
|
"html"
|
||||||
],
|
],
|
||||||
"default": "image",
|
"default": "image",
|
||||||
"hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)"
|
"hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方群事件没有昵称,图片/HTML 报告仅显示头像,文本报告由独立 Markdown 模块使用艾特展示身份;其他平台保持原有昵称文本格式。"
|
||||||
},
|
},
|
||||||
"report_template": {
|
"report_template": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -140,6 +140,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"qq_official": {
|
||||||
|
"description": "QQ 官方机器人",
|
||||||
|
"type": "object",
|
||||||
|
"hint": "仅作用于 QQ 官方机器人及 QQ 官方 Webhook 的报告展示。",
|
||||||
|
"items": {
|
||||||
|
"enable_t2i_activity_histogram": {
|
||||||
|
"type": "bool",
|
||||||
|
"description": "启用 T2I 群聊概览图",
|
||||||
|
"default": true,
|
||||||
|
"hint": "在 QQ 官方 Markdown 报告中嵌入透明背景的日期、基础统计与 24 小时直方图。渲染失败时自动回退为完整文字报告。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"t2i_rendering": {
|
"t2i_rendering": {
|
||||||
"description": "图片渲染策略",
|
"description": "图片渲染策略",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from astrbot.api import AstrBotConfig
|
from astrbot.api import AstrBotConfig
|
||||||
@@ -15,6 +15,8 @@ from astrbot.api import logger as astrbot_logger
|
|||||||
from astrbot.api.event import AstrMessageEvent, filter
|
from astrbot.api.event import AstrMessageEvent, filter
|
||||||
from astrbot.api.event.filter import PermissionType
|
from astrbot.api.event.filter import PermissionType
|
||||||
from astrbot.api.star import Context, Star, StarTools
|
from astrbot.api.star import Context, Star, StarTools
|
||||||
|
|
||||||
|
# File is only available via astrbot.core (internal API — may change).
|
||||||
from astrbot.core.message.components import File
|
from astrbot.core.message.components import File
|
||||||
|
|
||||||
from .src.application.commands.template_command_service import (
|
from .src.application.commands.template_command_service import (
|
||||||
@@ -35,8 +37,8 @@ from .src.infrastructure.config.config_manager import ConfigManager
|
|||||||
from .src.infrastructure.messaging.message_sender import MessageSender
|
from .src.infrastructure.messaging.message_sender import MessageSender
|
||||||
from .src.infrastructure.persistence.history_manager import HistoryManager
|
from .src.infrastructure.persistence.history_manager import HistoryManager
|
||||||
from .src.infrastructure.persistence.incremental_store import IncrementalStore
|
from .src.infrastructure.persistence.incremental_store import IncrementalStore
|
||||||
from .src.infrastructure.persistence.telegram_group_registry import (
|
from .src.infrastructure.persistence.platform_group_registry import (
|
||||||
TelegramGroupRegistry,
|
PlatformGroupRegistry,
|
||||||
)
|
)
|
||||||
from .src.infrastructure.platform.bot_manager import BotManager
|
from .src.infrastructure.platform.bot_manager import BotManager
|
||||||
from .src.infrastructure.platform.template_preview import (
|
from .src.infrastructure.platform.template_preview import (
|
||||||
@@ -45,6 +47,7 @@ from .src.infrastructure.platform.template_preview import (
|
|||||||
)
|
)
|
||||||
from .src.infrastructure.reporting.generators import ReportGenerator
|
from .src.infrastructure.reporting.generators import ReportGenerator
|
||||||
from .src.infrastructure.scheduler.auto_scheduler import AutoScheduler
|
from .src.infrastructure.scheduler.auto_scheduler import AutoScheduler
|
||||||
|
from .src.infrastructure.visualization.activity_charts import ActivityVisualizer
|
||||||
from .src.shared.constants import PLUGIN_NAME
|
from .src.shared.constants import PLUGIN_NAME
|
||||||
from .src.shared.trace_context import TraceContext, TraceLogFilter
|
from .src.shared.trace_context import TraceContext, TraceLogFilter
|
||||||
from .src.utils.logger import logger
|
from .src.utils.logger import logger
|
||||||
@@ -60,7 +63,8 @@ class GroupDailyAnalysis(Star):
|
|||||||
bot_manager: BotManager
|
bot_manager: BotManager
|
||||||
history_manager: HistoryManager
|
history_manager: HistoryManager
|
||||||
report_generator: ReportGenerator
|
report_generator: ReportGenerator
|
||||||
telegram_group_registry: TelegramGroupRegistry
|
html_render: Callable
|
||||||
|
platform_group_registry: PlatformGroupRegistry
|
||||||
statistics_service: StatisticsService
|
statistics_service: StatisticsService
|
||||||
analysis_domain_service: AnalysisDomainService
|
analysis_domain_service: AnalysisDomainService
|
||||||
llm_analyzer: LLMAnalyzer
|
llm_analyzer: LLMAnalyzer
|
||||||
@@ -90,10 +94,11 @@ class GroupDailyAnalysis(Star):
|
|||||||
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
|
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
|
||||||
|
|
||||||
# Telegram 注册表 (持久层)
|
# Telegram 注册表 (持久层)
|
||||||
self.telegram_group_registry = TelegramGroupRegistry(self)
|
self.platform_group_registry = PlatformGroupRegistry(self)
|
||||||
|
|
||||||
# 2. 领域层
|
# 2. 领域层
|
||||||
self.statistics_service = StatisticsService()
|
activity_visualizer = ActivityVisualizer()
|
||||||
|
self.statistics_service = StatisticsService(activity_visualizer)
|
||||||
self.analysis_domain_service = AnalysisDomainService()
|
self.analysis_domain_service = AnalysisDomainService()
|
||||||
|
|
||||||
# 3. 分析核心 (LLM Bridge)
|
# 3. 分析核心 (LLM Bridge)
|
||||||
@@ -118,7 +123,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
# 消息处理服务
|
# 消息处理服务
|
||||||
self.message_processing_service = MessageProcessingService(
|
self.message_processing_service = MessageProcessingService(
|
||||||
context, self.telegram_group_registry
|
context, self.platform_group_registry
|
||||||
)
|
)
|
||||||
self.template_command_service = TemplateCommandService(
|
self.template_command_service = TemplateCommandService(
|
||||||
plugin_root=os.path.dirname(__file__)
|
plugin_root=os.path.dirname(__file__)
|
||||||
@@ -287,11 +292,43 @@ class GroupDailyAnalysis(Star):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True)
|
logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True)
|
||||||
|
|
||||||
|
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
|
||||||
|
@filter.platform_adapter_type(
|
||||||
|
filter.PlatformAdapterType.QQOFFICIAL
|
||||||
|
| filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK
|
||||||
|
)
|
||||||
|
async def intercept_qq_official_messages(self, event: AstrMessageEvent):
|
||||||
|
"""缓存 QQ 官方机器人群消息;频道消息不在本插件适配范围内。"""
|
||||||
|
raw_message = getattr(getattr(event, "message_obj", None), "raw_message", None)
|
||||||
|
if isinstance(raw_message, dict):
|
||||||
|
author = raw_message.get("author") or {}
|
||||||
|
group_openid = str(raw_message.get("group_openid", "") or "").strip()
|
||||||
|
member_openid = str(
|
||||||
|
author.get("member_openid", "") if isinstance(author, dict) else ""
|
||||||
|
).strip()
|
||||||
|
else:
|
||||||
|
author = getattr(raw_message, "author", None)
|
||||||
|
group_openid = str(getattr(raw_message, "group_openid", "") or "").strip()
|
||||||
|
member_openid = str(getattr(author, "member_openid", "") or "").strip()
|
||||||
|
if not group_openid or not member_openid:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.message_processing_service.process_message(event)
|
||||||
|
except (ValueError, RuntimeError) as e:
|
||||||
|
logger.warning(f"[QQOfficial] 消息存储失败: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQOfficial] 消息存储异常: {e}", exc_info=True)
|
||||||
|
|
||||||
async def get_telegram_seen_group_ids(
|
async def get_telegram_seen_group_ids(
|
||||||
self, platform_id: str | None = None
|
self, platform_id: str | None = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。"""
|
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。"""
|
||||||
return await self.telegram_group_registry.get_all_group_ids(platform_id)
|
return await self.platform_group_registry.get_all_group_ids(platform_id)
|
||||||
|
|
||||||
|
async def get_seen_group_ids(self, platform_id: str | None = None) -> list[str]:
|
||||||
|
"""读取任意事件驱动平台已经见过的群组。"""
|
||||||
|
return await self.platform_group_registry.get_all_group_ids(platform_id)
|
||||||
|
|
||||||
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
||||||
"""从消息事件中安全获取群组 ID"""
|
"""从消息事件中安全获取群组 ID"""
|
||||||
@@ -517,7 +554,15 @@ class GroupDailyAnalysis(Star):
|
|||||||
# 表情回应 或 文本提示(二选一,由配置开关控制)
|
# 表情回应 或 文本提示(二选一,由配置开关控制)
|
||||||
adapter = self.bot_manager.get_adapter(platform_id)
|
adapter = self.bot_manager.get_adapter(platform_id)
|
||||||
orig_msg_id = getattr(event.message_obj, "message_id", None)
|
orig_msg_id = getattr(event.message_obj, "message_id", None)
|
||||||
use_text_reply = self.config_manager.get_enable_analysis_reply()
|
adapter_platform_name = (
|
||||||
|
(adapter.get_platform_name() if adapter else "").strip().lower()
|
||||||
|
)
|
||||||
|
# QQ 官方机器人 API v2 不支持本插件使用的表情回应接口,
|
||||||
|
# 因此始终沿用原有的文字进度提示,避免触发无效的 reaction 请求。
|
||||||
|
use_text_reply = (
|
||||||
|
adapter_platform_name in {"qq_official", "qq_official_webhook"}
|
||||||
|
or self.config_manager.get_enable_analysis_reply()
|
||||||
|
)
|
||||||
|
|
||||||
if use_text_reply:
|
if use_text_reply:
|
||||||
yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
|
yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
|
||||||
@@ -577,6 +622,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
analysis_result = result["analysis_result"]
|
analysis_result = result["analysis_result"]
|
||||||
adapter = result["adapter"]
|
adapter = result["adapter"]
|
||||||
output_format = self.config_manager.get_output_format()
|
output_format = self.config_manager.get_output_format()
|
||||||
|
hide_user_names = adapter.get_platform_name() == "qq_official"
|
||||||
|
|
||||||
# 定义获取回调
|
# 定义获取回调
|
||||||
async def avatar_url_getter(user_id: str) -> str | None:
|
async def avatar_url_getter(user_id: str) -> str | None:
|
||||||
@@ -599,6 +645,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
|
hide_user_names=hide_user_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
@@ -610,8 +657,9 @@ class GroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
# 如果图片生成或发送失败,直接回退到文本
|
# 如果图片生成或发送失败,直接回退到文本
|
||||||
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
|
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
await self._send_text_reports(
|
||||||
await adapter.send_text_report(group_id, text_report)
|
group_id, analysis_result, hide_user_names, adapter
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
elif output_format == "html":
|
elif output_format == "html":
|
||||||
@@ -621,6 +669,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
|
hide_user_names=hide_user_names,
|
||||||
)
|
)
|
||||||
if html_path:
|
if html_path:
|
||||||
is_only_url = self.config_manager.get_html_only_url()
|
is_only_url = self.config_manager.get_html_only_url()
|
||||||
@@ -681,8 +730,28 @@ class GroupDailyAnalysis(Star):
|
|||||||
yield event.plain_result("⚠️ HTML 生成失败。")
|
yield event.plain_result("⚠️ HTML 生成失败。")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
await self._send_text_reports(
|
||||||
await adapter.send_text_report(group_id, text_report)
|
group_id, analysis_result, hide_user_names, adapter
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_text_reports(
|
||||||
|
self, analysis_result: dict, hide_user_names: bool
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
"""Generate text or QQ-official-markdown reports."""
|
||||||
|
if hide_user_names:
|
||||||
|
return await self.report_generator.generate_qq_official_markdown_report(
|
||||||
|
analysis_result, self.html_render
|
||||||
|
)
|
||||||
|
return self.report_generator.generate_text_report(analysis_result), None
|
||||||
|
|
||||||
|
async def _send_text_reports(
|
||||||
|
self, group_id: str, analysis_result: dict, hide_user_names: bool, adapter
|
||||||
|
) -> bool:
|
||||||
|
"""Send text reports via platform adapter."""
|
||||||
|
tr, fr = await self._generate_text_reports(analysis_result, hide_user_names)
|
||||||
|
if hide_user_names:
|
||||||
|
return await adapter.send_text_report(group_id, tr, fallback_content=fr)
|
||||||
|
return await adapter.send_text_report(group_id, tr)
|
||||||
|
|
||||||
@filter.command("设置格式", alias={"set_format"})
|
@filter.command("设置格式", alias={"set_format"})
|
||||||
@filter.permission_type(PermissionType.ADMIN)
|
@filter.permission_type(PermissionType.ADMIN)
|
||||||
|
|||||||
+4
-3
@@ -1,12 +1,13 @@
|
|||||||
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。
|
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。
|
||||||
display_name: 群分析总结插件 # 插件的显示名称
|
display_name: 群分析总结插件 # 插件的显示名称
|
||||||
desc: "[多平台接入开发中] 群日常分析总结插件 - 支持 QQ (aiocqhttp)、Telegram、Discord 以及 Feishu (Lark);生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
|
desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
|
||||||
version: v4.10.9 # 插件版本号。格式:v1.1.1 或者 v1.1
|
version: v4.11.0 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||||
author: SXP-Simon # 作者
|
author: SXP-Simon # 作者
|
||||||
astrbot_version: ">=4.16.0"
|
astrbot_version: ">=4.16.0"
|
||||||
support_platforms:
|
support_platforms:
|
||||||
- aiocqhttp
|
- aiocqhttp
|
||||||
- discord
|
- discord
|
||||||
- telegram
|
- telegram
|
||||||
- lark
|
- qq_official
|
||||||
|
- qq_official_webhook
|
||||||
repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址
|
repo: https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis # 插件的仓库地址
|
||||||
|
|||||||
@@ -216,7 +216,6 @@ class AnalysisApplicationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 4. 用户分析 (Domain Service)
|
# 4. 用户分析 (Domain Service)
|
||||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
|
||||||
user_activity = await asyncio.to_thread(
|
user_activity = await asyncio.to_thread(
|
||||||
self.analysis_domain_service.analyze_user_activity,
|
self.analysis_domain_service.analyze_user_activity,
|
||||||
unified_messages,
|
unified_messages,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import re
|
import re
|
||||||
from collections import Counter
|
from collections import Counter, OrderedDict
|
||||||
|
|
||||||
from astrbot.api.event import AstrMessageEvent
|
from astrbot.api.event import AstrMessageEvent
|
||||||
from astrbot.api.star import Context
|
from astrbot.api.star import Context
|
||||||
|
|
||||||
from ...infrastructure.persistence.telegram_group_registry import TelegramGroupRegistry
|
from ...infrastructure.persistence.platform_group_registry import PlatformGroupRegistry
|
||||||
from ...utils.logger import logger
|
from ...utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -12,27 +12,36 @@ class MessageProcessingService:
|
|||||||
"""
|
"""
|
||||||
消息处理服务
|
消息处理服务
|
||||||
|
|
||||||
负责处理接收到的消息事件:
|
解析收到的群消息事件,提取内容与发送者信息,持久化历史记录,
|
||||||
|
并维护事件驱动平台(Telegram、QQ 官方等)的群组注册表。
|
||||||
|
QQ 官方平台特有的重复消息去重逻辑也在本服务中处理。
|
||||||
|
|
||||||
|
职责:
|
||||||
1. 解析消息内容(文本、图片、@提及等)
|
1. 解析消息内容(文本、图片、@提及等)
|
||||||
2. 解析发送者信息(跨平台兼容)
|
2. 解析发送者展示名(跨平台兼容)
|
||||||
3. 存储消息历史
|
3. 存储消息历史
|
||||||
4. 维护 Telegram 群组注册表(回退机制)
|
4. 维护群组注册表,供调度器做群组发现(Telegram、QQ 官方等事件驱动平台)
|
||||||
|
5. QQ 官方事件消息去重(按 message_id 预占 + 确认机制)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, context: Context, telegram_registry: TelegramGroupRegistry):
|
def __init__(self, context: Context, group_registry: PlatformGroupRegistry):
|
||||||
self.context = context
|
self.context = context
|
||||||
self.telegram_registry = telegram_registry
|
self.group_registry = group_registry
|
||||||
|
self._seen_event_ids: OrderedDict[str, None] = OrderedDict()
|
||||||
|
self._inflight_event_ids: set[str] = set()
|
||||||
|
self._seen_event_ids_limit = 4096
|
||||||
|
|
||||||
async def process_message(self, event: AstrMessageEvent) -> None:
|
async def process_message(self, event: AstrMessageEvent) -> None:
|
||||||
"""
|
"""
|
||||||
处理并在历史记录中存储消息。
|
处理并在历史记录中存储消息。
|
||||||
|
被 main.py 的 Telegram 和 QQ 官方消息拦截器共同调用。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event: AstrBot 消息事件
|
event: AstrBot 消息事件
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: 当必要数据无法获取时
|
ValueError: 当必要数据无法获取时
|
||||||
RuntimeError: 当消息内容为空时
|
RuntimeError: 当消息内容为空时
|
||||||
"""
|
"""
|
||||||
# 1. 获取群组 ID(必需)
|
# 1. 获取群组 ID(必需)
|
||||||
group_id = self._get_group_id_from_event(event)
|
group_id = self._get_group_id_from_event(event)
|
||||||
@@ -62,37 +71,63 @@ class MessageProcessingService:
|
|||||||
f"群 {group_id}: 消息内容为空 (sender={sender_name}),拒绝存储"
|
f"群 {group_id}: 消息内容为空 (sender={sender_name}),拒绝存储"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. 提取事件消息 ID(用于 Telegram 已见群/话题记录)
|
# 6. 提取事件消息 ID 和事件时间
|
||||||
msg_obj = getattr(event, "message_obj", None)
|
msg_obj = getattr(event, "message_obj", None)
|
||||||
event_message_id = str(getattr(msg_obj, "message_id", "") or "")
|
event_message_id = str(getattr(msg_obj, "message_id", "") or "")
|
||||||
|
|
||||||
|
platform_name = str(event.get_platform_name() or "").strip().lower()
|
||||||
|
reserved_event_id = False
|
||||||
|
if platform_name in {"qq_official", "qq_official_webhook"} and event_message_id:
|
||||||
|
reserved_event_id = self._reserve_event_id(event_message_id)
|
||||||
|
if not reserved_event_id:
|
||||||
|
logger.debug("[QQOfficial] 跳过重复消息事件: %s", event_message_id)
|
||||||
|
return
|
||||||
|
history_content = {
|
||||||
|
"type": "user",
|
||||||
|
"message": message_parts,
|
||||||
|
}
|
||||||
|
if platform_name in {"qq_official", "qq_official_webhook"}:
|
||||||
|
event_timestamp = self._extract_event_timestamp(msg_obj)
|
||||||
|
history_content["_qq_official"] = {
|
||||||
|
"message_id": event_message_id,
|
||||||
|
"timestamp": event_timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
# 7. 存储到数据库
|
# 7. 存储到数据库
|
||||||
await self.context.message_history_manager.insert(
|
try:
|
||||||
platform_id=platform_id,
|
await self.context.message_history_manager.insert(
|
||||||
user_id=group_id,
|
platform_id=platform_id,
|
||||||
content={"type": "user", "message": message_parts},
|
user_id=group_id,
|
||||||
sender_id=sender_id,
|
content=history_content,
|
||||||
sender_name=sender_name,
|
sender_id=sender_id,
|
||||||
)
|
sender_name=sender_name,
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
if reserved_event_id:
|
||||||
|
self._release_event_id(event_message_id)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if reserved_event_id:
|
||||||
|
self._commit_event_id(event_message_id)
|
||||||
|
|
||||||
# Telegram: 记录已见群/话题
|
# Register the group so the scheduler can discover platforms that
|
||||||
if self._is_telegram_event(event, platform_id):
|
# do not provide a group-list API (Telegram, QQ Official, etc.).
|
||||||
try:
|
try:
|
||||||
await self.telegram_registry.upsert(
|
await self.group_registry.upsert(
|
||||||
platform_id=platform_id,
|
platform_id=platform_id,
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
event_message_id=event_message_id,
|
event_message_id=event_message_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[TGRegistry] Upsert failed: "
|
"[GroupRegistry] Upsert failed: "
|
||||||
f"platform_id={platform_id} group_id={group_id} error={e}"
|
f"platform_id={platform_id} group_id={group_id} error={e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"[Telegram] [{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})"
|
f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
|
||||||
@@ -208,6 +243,24 @@ class MessageProcessingService:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif seg_type in ("File", "file"):
|
||||||
|
url = getattr(seg, "url", None) or getattr(seg, "file_", None)
|
||||||
|
message_parts.append(
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"url": str(url or ""),
|
||||||
|
"name": str(getattr(seg, "name", "") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif seg_type in ("Record", "record", "voice"):
|
||||||
|
url = getattr(seg, "url", None) or getattr(seg, "file", None)
|
||||||
|
message_parts.append({"type": "voice", "url": str(url or "")})
|
||||||
|
|
||||||
|
elif seg_type in ("Video", "video"):
|
||||||
|
url = getattr(seg, "url", None) or getattr(seg, "file", None)
|
||||||
|
message_parts.append({"type": "video", "url": str(url or "")})
|
||||||
|
|
||||||
if not message_parts and event.message_str:
|
if not message_parts and event.message_str:
|
||||||
message_parts.append({"type": "plain", "text": event.message_str})
|
message_parts.append({"type": "plain", "text": event.message_str})
|
||||||
|
|
||||||
@@ -261,9 +314,57 @@ class MessageProcessingService:
|
|||||||
return normalized == str(sender_id).strip()
|
return normalized == str(sender_id).strip()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_telegram_event(event: AstrMessageEvent, platform_id: str) -> bool:
|
def _extract_event_timestamp(message_obj: object) -> int:
|
||||||
"""判断当前事件是否为 Telegram 平台"""
|
"""从消息对象中提取平台事件时间戳。"""
|
||||||
platform_name = str(event.get_platform_name() or "").strip().lower()
|
raw_message = getattr(message_obj, "raw_message", None)
|
||||||
if platform_name == "telegram":
|
if isinstance(raw_message, dict):
|
||||||
return True
|
candidate = raw_message.get("timestamp")
|
||||||
return str(platform_id or "").strip().lower().startswith("telegram")
|
if not candidate:
|
||||||
|
raw_data = raw_message.get("raw_data")
|
||||||
|
if isinstance(raw_data, dict):
|
||||||
|
candidate = raw_data.get("timestamp")
|
||||||
|
else:
|
||||||
|
raw_data = getattr(raw_message, "raw_data", None)
|
||||||
|
candidate = getattr(raw_message, "timestamp", None)
|
||||||
|
if not candidate and isinstance(raw_data, dict):
|
||||||
|
candidate = raw_data.get("timestamp")
|
||||||
|
if isinstance(candidate, (int, float)):
|
||||||
|
return int(candidate)
|
||||||
|
if candidate:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
return int(
|
||||||
|
datetime.fromisoformat(
|
||||||
|
str(candidate).replace("Z", "+00:00")
|
||||||
|
).timestamp()
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _reserve_event_id(self, event_message_id: str) -> bool:
|
||||||
|
"""预占事件消息 ID:在历史记录持久化期间防止重复入库。"""
|
||||||
|
if (
|
||||||
|
event_message_id in self._inflight_event_ids
|
||||||
|
or event_message_id in self._seen_event_ids
|
||||||
|
):
|
||||||
|
if event_message_id in self._seen_event_ids:
|
||||||
|
self._seen_event_ids.move_to_end(event_message_id)
|
||||||
|
return False
|
||||||
|
self._inflight_event_ids.add(event_message_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _commit_event_id(self, event_message_id: str) -> None:
|
||||||
|
"""确认事件消息 ID:标记为已持久化,纳入后续去重。"""
|
||||||
|
self._inflight_event_ids.discard(event_message_id)
|
||||||
|
if event_message_id in self._seen_event_ids:
|
||||||
|
self._seen_event_ids.move_to_end(event_message_id)
|
||||||
|
else:
|
||||||
|
self._seen_event_ids[event_message_id] = None
|
||||||
|
if len(self._seen_event_ids) > self._seen_event_ids_limit:
|
||||||
|
self._seen_event_ids.popitem(last=False)
|
||||||
|
|
||||||
|
def _release_event_id(self, event_message_id: str) -> None:
|
||||||
|
"""释放事件消息 ID:持久化失败或取消时清理预占状态。"""
|
||||||
|
self._inflight_event_ids.discard(event_message_id)
|
||||||
|
|||||||
@@ -3,35 +3,16 @@
|
|||||||
|
|
||||||
该模块导出所有领域实体类,包括:
|
该模块导出所有领域实体类,包括:
|
||||||
- AnalysisTask: 分析任务聚合根
|
- AnalysisTask: 分析任务聚合根
|
||||||
- GroupAnalysisResult: 群聊分析结果实体
|
|
||||||
- IncrementalBatch: 增量分析独立批次实体
|
- IncrementalBatch: 增量分析独立批次实体
|
||||||
- IncrementalState: 增量分析聚合视图(报告时使用)
|
- IncrementalState: 增量分析聚合视图(报告时使用)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .analysis_result import (
|
|
||||||
ActivityVisualization,
|
|
||||||
EmojiStatistics,
|
|
||||||
GoldenQuote,
|
|
||||||
GroupAnalysisResult,
|
|
||||||
GroupStatistics,
|
|
||||||
SummaryTopic,
|
|
||||||
TokenUsage,
|
|
||||||
UserTitle,
|
|
||||||
)
|
|
||||||
from .analysis_task import AnalysisTask, TaskStatus
|
from .analysis_task import AnalysisTask, TaskStatus
|
||||||
from .incremental_state import IncrementalBatch, IncrementalState
|
from .incremental_state import IncrementalBatch, IncrementalState
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AnalysisTask",
|
"AnalysisTask",
|
||||||
"TaskStatus",
|
"TaskStatus",
|
||||||
"GroupAnalysisResult",
|
|
||||||
"SummaryTopic",
|
|
||||||
"UserTitle",
|
|
||||||
"GoldenQuote",
|
|
||||||
"TokenUsage",
|
|
||||||
"EmojiStatistics",
|
|
||||||
"ActivityVisualization",
|
|
||||||
"GroupStatistics",
|
|
||||||
"IncrementalBatch",
|
"IncrementalBatch",
|
||||||
"IncrementalState",
|
"IncrementalState",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 .avatar_repository import IAvatarRepository
|
||||||
from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender
|
from .message_repository import IGroupInfoRepository, IMessageRepository, IMessageSender
|
||||||
|
from .visualization_repository import IActivityVisualizer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"IMessageRepository",
|
"IMessageRepository",
|
||||||
"IMessageSender",
|
"IMessageSender",
|
||||||
"IGroupInfoRepository",
|
"IGroupInfoRepository",
|
||||||
"IAvatarRepository",
|
"IAvatarRepository",
|
||||||
|
"IActivityVisualizer",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class IReportGenerator(ABC):
|
|||||||
avatar_url_getter: Any = None,
|
avatar_url_getter: Any = None,
|
||||||
nickname_getter: Any = None,
|
nickname_getter: Any = None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
|
hide_user_names: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""生成图片报告"""
|
"""生成图片报告"""
|
||||||
pass
|
pass
|
||||||
@@ -33,6 +34,7 @@ class IReportGenerator(ABC):
|
|||||||
avatar_url_getter: Any = None,
|
avatar_url_getter: Any = None,
|
||||||
nickname_getter: Any = None,
|
nickname_getter: Any = None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
|
hide_user_names: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""生成 HTML 报告"""
|
"""生成 HTML 报告"""
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
可视化仓储接口 - 领域层
|
||||||
|
定义活跃度可视化的抽象契约。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from ..models.data_models import ActivityVisualization
|
||||||
|
|
||||||
|
|
||||||
|
class IActivityVisualizer(ABC):
|
||||||
|
"""活跃度可视化接口 - 领域层抽象"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate_activity_visualization(
|
||||||
|
self, messages: list[dict]
|
||||||
|
) -> ActivityVisualization:
|
||||||
|
"""从消息列表生成活跃度可视化数据"""
|
||||||
|
pass
|
||||||
@@ -3,33 +3,10 @@
|
|||||||
|
|
||||||
该模块导出所有封装核心业务逻辑的领域服务,
|
该模块导出所有封装核心业务逻辑的领域服务,
|
||||||
用于分析群聊数据。这些服务是平台无关的。
|
用于分析群聊数据。这些服务是平台无关的。
|
||||||
|
|
||||||
服务分类:
|
|
||||||
- 分析器服务: 话题分析、用户称号分析、金句分析
|
|
||||||
- 计算服务: 统计计算
|
|
||||||
- 生成服务: 报告生成
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .golden_quote_analyzer import GoldenQuoteAnalyzerAdapter, IGoldenQuoteAnalyzer
|
|
||||||
from .incremental_merge_service import IncrementalMergeService
|
from .incremental_merge_service import IncrementalMergeService
|
||||||
from .report_generator import ReportGenerator
|
|
||||||
from .statistics_calculator import StatisticsCalculator
|
|
||||||
from .topic_analyzer import ITopicAnalyzer, TopicAnalyzerAdapter
|
|
||||||
from .user_title_analyzer import IUserTitleAnalyzer, UserTitleAnalyzerAdapter
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# 统计与报告服务
|
|
||||||
"StatisticsCalculator",
|
|
||||||
"ReportGenerator",
|
|
||||||
# 增量合并服务
|
|
||||||
"IncrementalMergeService",
|
"IncrementalMergeService",
|
||||||
# 话题分析服务
|
|
||||||
"ITopicAnalyzer",
|
|
||||||
"TopicAnalyzerAdapter",
|
|
||||||
# 用户称号分析服务
|
|
||||||
"IUserTitleAnalyzer",
|
|
||||||
"UserTitleAnalyzerAdapter",
|
|
||||||
# 金句分析服务
|
|
||||||
"IGoldenQuoteAnalyzer",
|
|
||||||
"GoldenQuoteAnalyzerAdapter",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
"""
|
|
||||||
金句分析领域服务
|
|
||||||
|
|
||||||
该模块提供平台无关的金句分析服务接口。
|
|
||||||
实际分析逻辑委托给 infrastructure 层的具体实现。
|
|
||||||
|
|
||||||
架构说明:
|
|
||||||
- 本文件定义领域服务接口和数据转换逻辑
|
|
||||||
- 具体的 LLM 调用和消息处理在 src/analysis/analyzers/golden_quote_analyzer.py 中实现
|
|
||||||
- 采用渐进式迁移策略,保持与现有代码的兼容性
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ..value_objects.golden_quote import GoldenQuote
|
|
||||||
from ..value_objects.unified_message import UnifiedMessage
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..value_objects.statistics import TokenUsage
|
|
||||||
|
|
||||||
|
|
||||||
class IGoldenQuoteAnalyzer(ABC):
|
|
||||||
"""
|
|
||||||
金句分析服务接口
|
|
||||||
|
|
||||||
定义平台无关的金句分析契约。
|
|
||||||
所有平台的金句分析都应该实现此接口。
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def analyze(
|
|
||||||
self,
|
|
||||||
messages: list[UnifiedMessage],
|
|
||||||
unified_msg_origin: str = None,
|
|
||||||
) -> tuple[list[GoldenQuote], "TokenUsage"]:
|
|
||||||
"""
|
|
||||||
分析消息中的金句
|
|
||||||
|
|
||||||
参数:
|
|
||||||
messages: 统一格式的消息列表
|
|
||||||
unified_msg_origin: 消息来源标识,用于选择 LLM 提供商
|
|
||||||
|
|
||||||
返回:
|
|
||||||
(金句列表, Token 使用统计)
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class GoldenQuoteAnalyzerAdapter(IGoldenQuoteAnalyzer):
|
|
||||||
"""
|
|
||||||
金句分析服务适配器
|
|
||||||
|
|
||||||
将现有的 GoldenQuoteAnalyzer 实现适配为领域服务接口。
|
|
||||||
负责 UnifiedMessage 与原始消息格式之间的转换。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, legacy_analyzer):
|
|
||||||
"""
|
|
||||||
初始化适配器
|
|
||||||
|
|
||||||
参数:
|
|
||||||
legacy_analyzer: 现有的 GoldenQuoteAnalyzer 实例
|
|
||||||
"""
|
|
||||||
self._analyzer = legacy_analyzer
|
|
||||||
|
|
||||||
async def analyze(
|
|
||||||
self,
|
|
||||||
messages: list[UnifiedMessage],
|
|
||||||
unified_msg_origin: str = None,
|
|
||||||
) -> tuple[list[GoldenQuote], "TokenUsage"]:
|
|
||||||
"""
|
|
||||||
分析消息中的金句
|
|
||||||
|
|
||||||
将 UnifiedMessage 转换为原始格式,调用现有分析器,
|
|
||||||
然后将结果转换为领域值对象。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
messages: 统一格式的消息列表
|
|
||||||
unified_msg_origin: 消息来源标识
|
|
||||||
|
|
||||||
返回:
|
|
||||||
(金句列表, Token 使用统计)
|
|
||||||
"""
|
|
||||||
# 将 UnifiedMessage 转换为原始消息格式
|
|
||||||
raw_messages = [self._to_raw_message(msg) for msg in messages]
|
|
||||||
|
|
||||||
# 调用现有分析器
|
|
||||||
legacy_quotes, token_usage = await self._analyzer.analyze_golden_quotes(
|
|
||||||
raw_messages, unified_msg_origin
|
|
||||||
)
|
|
||||||
|
|
||||||
# 将结果转换为领域值对象
|
|
||||||
quotes = [
|
|
||||||
GoldenQuote(
|
|
||||||
content=q.content,
|
|
||||||
sender_name=q.sender,
|
|
||||||
sender_id=str(q.user_id)
|
|
||||||
if hasattr(q, "user_id") and q.user_id
|
|
||||||
else None,
|
|
||||||
reason=q.reason,
|
|
||||||
)
|
|
||||||
for q in legacy_quotes
|
|
||||||
]
|
|
||||||
|
|
||||||
return quotes, token_usage
|
|
||||||
|
|
||||||
def _to_raw_message(self, msg: UnifiedMessage) -> dict:
|
|
||||||
"""
|
|
||||||
将 UnifiedMessage 转换为原始消息格式
|
|
||||||
|
|
||||||
参数:
|
|
||||||
msg: 统一消息对象
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始消息字典
|
|
||||||
"""
|
|
||||||
# 构建消息内容列表
|
|
||||||
message_content = []
|
|
||||||
if msg.text_content:
|
|
||||||
message_content.append({"type": "text", "data": {"text": msg.text_content}})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message_id": msg.message_id,
|
|
||||||
"time": int(msg.timestamp.timestamp()) if msg.timestamp else 0,
|
|
||||||
"sender": {
|
|
||||||
"user_id": msg.sender_id,
|
|
||||||
"nickname": msg.sender_name,
|
|
||||||
},
|
|
||||||
"message": message_content,
|
|
||||||
}
|
|
||||||
@@ -12,17 +12,15 @@ from ..value_objects.unified_message import (
|
|||||||
UnifiedMessage,
|
UnifiedMessage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Discord 自定义表情正则 <:name:id> 或 <a:name:id>
|
||||||
|
_DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"<a?:.+?:\d+>")
|
||||||
|
# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息
|
||||||
|
_COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/")
|
||||||
|
|
||||||
|
|
||||||
class MessageCleanerService:
|
class MessageCleanerService:
|
||||||
"""消息清理服务"""
|
"""消息清理服务"""
|
||||||
|
|
||||||
# Discord 自定义表情正则 <:name:id> 或 <a:name:id>
|
|
||||||
DISCORD_CUSTOM_EMOJI_PATTERN = re.compile(r"<a?:.+?:\d+>")
|
|
||||||
|
|
||||||
# 指令匹配正则:匹配以 / 开头,或者以 @某人 / 开头的消息
|
|
||||||
# 比如: "/group_analysis", "@bot /help", " /test"
|
|
||||||
COMMAND_PATTERN = re.compile(r"^\s*(?:<@\d+>\s+)?/")
|
|
||||||
|
|
||||||
def clean_messages(
|
def clean_messages(
|
||||||
self,
|
self,
|
||||||
messages: list[UnifiedMessage],
|
messages: list[UnifiedMessage],
|
||||||
@@ -51,11 +49,7 @@ class MessageCleanerService:
|
|||||||
# 2. 预检指令消息(首个内容块通常是文本)
|
# 2. 预检指令消息(首个内容块通常是文本)
|
||||||
is_command = False
|
is_command = False
|
||||||
first_text = msg.text_content
|
first_text = msg.text_content
|
||||||
if (
|
if filter_commands and first_text and _COMMAND_PATTERN.match(first_text):
|
||||||
filter_commands
|
|
||||||
and first_text
|
|
||||||
and self.COMMAND_PATTERN.match(first_text)
|
|
||||||
):
|
|
||||||
is_command = True
|
is_command = True
|
||||||
|
|
||||||
if is_command:
|
if is_command:
|
||||||
@@ -70,7 +64,7 @@ class MessageCleanerService:
|
|||||||
text = content.text or ""
|
text = content.text or ""
|
||||||
|
|
||||||
# 移除 Discord 原始表情代码
|
# 移除 Discord 原始表情代码
|
||||||
text = self.DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text)
|
text = _DISCORD_CUSTOM_EMOJI_PATTERN.sub("", text)
|
||||||
|
|
||||||
# 移除 @mentions 文本 (e.g. <@123456>)
|
# 移除 @mentions 文本 (e.g. <@123456>)
|
||||||
text = re.sub(r"<@\d+>", "", text)
|
text = re.sub(r"<@\d+>", "", text)
|
||||||
|
|||||||
@@ -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 ...infrastructure.visualization.activity_charts import ActivityVisualizer
|
||||||
from ..models.data_models import EmojiStatistics, GroupStatistics, TokenUsage
|
from ..models.data_models import EmojiStatistics, GroupStatistics, TokenUsage
|
||||||
|
from ..repositories.visualization_repository import IActivityVisualizer
|
||||||
from ..value_objects.unified_message import MessageContentType, UnifiedMessage
|
from ..value_objects.unified_message import MessageContentType, UnifiedMessage
|
||||||
|
|
||||||
|
|
||||||
class StatisticsService:
|
class StatisticsService:
|
||||||
"""统计服务 - 处理群聊数据的聚合统计"""
|
"""统计服务 - 处理群聊数据的聚合统计"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, activity_visualizer: IActivityVisualizer | None = None):
|
||||||
self.activity_visualizer = ActivityVisualizer()
|
if activity_visualizer is None:
|
||||||
|
# Fallback: keep backward compatibility
|
||||||
|
self.activity_visualizer: IActivityVisualizer = ActivityVisualizer()
|
||||||
|
else:
|
||||||
|
self.activity_visualizer = activity_visualizer
|
||||||
|
|
||||||
def calculate_group_statistics(
|
def calculate_group_statistics(
|
||||||
self, messages: list[UnifiedMessage]
|
self, messages: list[UnifiedMessage]
|
||||||
|
|||||||
@@ -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 .platform_capabilities import PLATFORM_CAPABILITIES, PlatformCapabilities
|
||||||
from .statistics import (
|
|
||||||
ActivityVisualization,
|
|
||||||
EmojiStatistics,
|
|
||||||
GroupStatistics,
|
|
||||||
TokenUsage,
|
|
||||||
UserStatistics,
|
|
||||||
)
|
|
||||||
from .topic import Topic, TopicCollection
|
|
||||||
from .unified_group import UnifiedGroup, UnifiedMember
|
from .unified_group import UnifiedGroup, UnifiedMember
|
||||||
from .unified_message import MessageContent, MessageContentType, UnifiedMessage
|
from .unified_message import MessageContent, MessageContentType, UnifiedMessage
|
||||||
from .user_title import UserTitle, UserTitleCollection
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# 核心平台抽象
|
# 核心平台抽象
|
||||||
@@ -22,17 +12,4 @@ __all__ = [
|
|||||||
"PLATFORM_CAPABILITIES",
|
"PLATFORM_CAPABILITIES",
|
||||||
"UnifiedGroup",
|
"UnifiedGroup",
|
||||||
"UnifiedMember",
|
"UnifiedMember",
|
||||||
# 分析值对象
|
|
||||||
"Topic",
|
|
||||||
"TopicCollection",
|
|
||||||
"UserTitle",
|
|
||||||
"UserTitleCollection",
|
|
||||||
"GoldenQuote",
|
|
||||||
"GoldenQuoteCollection",
|
|
||||||
# 统计
|
|
||||||
"TokenUsage",
|
|
||||||
"EmojiStatistics",
|
|
||||||
"ActivityVisualization",
|
|
||||||
"GroupStatistics",
|
|
||||||
"UserStatistics",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
"""
|
|
||||||
金句值对象 - 平台无关的金句表示
|
|
||||||
|
|
||||||
该值对象表示从群聊消息中提取的精彩语录。
|
|
||||||
它是不可变的,不包含任何平台特定的逻辑。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GoldenQuote:
|
|
||||||
"""
|
|
||||||
值对象:群聊金句
|
|
||||||
|
|
||||||
表示分析过程中提取出的具有代表性、幽默或深刻的消息语录。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
content (str): 语录原文
|
|
||||||
sender (str): 说话者的显示名称
|
|
||||||
reason (str): 入选理由(由 LLM 生成)
|
|
||||||
user_id (str): 用户唯一 ID
|
|
||||||
"""
|
|
||||||
|
|
||||||
content: str
|
|
||||||
sender: str
|
|
||||||
reason: str = ""
|
|
||||||
user_id: str = ""
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""初始化后确保 user_id 类型正确。"""
|
|
||||||
if not isinstance(self.user_id, str):
|
|
||||||
object.__setattr__(self, "user_id", str(self.user_id))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict) -> "GoldenQuote":
|
|
||||||
"""从持久化字典构建金句对象。"""
|
|
||||||
user_id = data.get("user_id", "")
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
content=data.get("content", "").strip(),
|
|
||||||
sender=data.get("sender", "").strip(),
|
|
||||||
reason=data.get("reason", "").strip(),
|
|
||||||
user_id=str(user_id) if user_id else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""转换为持久化字典。"""
|
|
||||||
return {
|
|
||||||
"content": self.content,
|
|
||||||
"sender": self.sender,
|
|
||||||
"reason": self.reason,
|
|
||||||
"user_id": self.user_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_valid(self) -> bool:
|
|
||||||
"""验证金句数据的完整性。"""
|
|
||||||
return bool(self.content.strip() and self.sender.strip())
|
|
||||||
|
|
||||||
def with_user_id(self, user_id: str) -> "GoldenQuote":
|
|
||||||
"""拷贝并更新用户 ID,返回新实例。"""
|
|
||||||
return GoldenQuote(
|
|
||||||
content=self.content,
|
|
||||||
sender=self.sender,
|
|
||||||
reason=self.reason,
|
|
||||||
user_id=str(user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GoldenQuoteCollection:
|
|
||||||
"""
|
|
||||||
模型:金句容器
|
|
||||||
|
|
||||||
提供对金句列表的高级操作封装。
|
|
||||||
"""
|
|
||||||
|
|
||||||
quotes: list[GoldenQuote] = field(default_factory=list)
|
|
||||||
|
|
||||||
def add(self, quote: GoldenQuote) -> None:
|
|
||||||
"""添加单个金句,执行有效性检查。"""
|
|
||||||
if quote.is_valid:
|
|
||||||
self.quotes.append(quote)
|
|
||||||
|
|
||||||
def add_from_dict(self, data: dict) -> None:
|
|
||||||
"""从原始数据添加金句。"""
|
|
||||||
self.add(GoldenQuote.from_dict(data))
|
|
||||||
|
|
||||||
def to_list(self) -> list[dict]:
|
|
||||||
"""导出为字典列表。"""
|
|
||||||
return [q.to_dict() for q in self.quotes]
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self.quotes)
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return iter(self.quotes)
|
|
||||||
@@ -254,6 +254,31 @@ LARK_CAPABILITIES = PlatformCapabilities(
|
|||||||
avatar_sizes=(72, 240, 640),
|
avatar_sizes=(72, 240, 640),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# QQ Official Bot API. Message history is provided by the plugin's local
|
||||||
|
# event archive because the public API does not expose group history queries.
|
||||||
|
QQ_OFFICIAL_CAPABILITIES = PlatformCapabilities(
|
||||||
|
platform_name="qq_official",
|
||||||
|
platform_version="api_v2_local_history",
|
||||||
|
supports_message_history=True,
|
||||||
|
max_message_history_days=7,
|
||||||
|
max_message_count=10000,
|
||||||
|
supports_group_list=False,
|
||||||
|
supports_group_info=False,
|
||||||
|
supports_member_list=False,
|
||||||
|
supports_member_info=False,
|
||||||
|
supports_text_message=True,
|
||||||
|
supports_image_message=True,
|
||||||
|
supports_file_message=True,
|
||||||
|
supports_forward_message=False,
|
||||||
|
supports_reply_message=False,
|
||||||
|
max_text_length=4000,
|
||||||
|
max_image_size_mb=20.0,
|
||||||
|
supports_user_avatar=True,
|
||||||
|
supports_group_avatar=False,
|
||||||
|
avatar_needs_api_call=False,
|
||||||
|
avatar_sizes=(640,),
|
||||||
|
)
|
||||||
|
|
||||||
# 能力查找表(映射平台标识到能力对象)
|
# 能力查找表(映射平台标识到能力对象)
|
||||||
PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
|
PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
|
||||||
"aiocqhttp": ONEBOT_V11_CAPABILITIES,
|
"aiocqhttp": ONEBOT_V11_CAPABILITIES,
|
||||||
@@ -262,6 +287,8 @@ PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
|
|||||||
"discord": DISCORD_CAPABILITIES,
|
"discord": DISCORD_CAPABILITIES,
|
||||||
"slack": SLACK_CAPABILITIES,
|
"slack": SLACK_CAPABILITIES,
|
||||||
"lark": LARK_CAPABILITIES,
|
"lark": LARK_CAPABILITIES,
|
||||||
|
"qq_official": QQ_OFFICIAL_CAPABILITIES,
|
||||||
|
"qq_official_webhook": QQ_OFFICIAL_CAPABILITIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
for topic in topics:
|
||||||
raw_ids = topic.contributors # LLM 返回的是 ID 列表
|
raw_ids = topic.contributors # LLM 返回的是 ID 列表
|
||||||
|
|
||||||
# 填充 contributor_ids
|
# 填充 contributor_ids。QQ 官方 member_openid 并非纯数字,
|
||||||
# 过滤掉非数字的脏数据 (LLM 偶尔会发疯)
|
# 因此仅接受本批次已知用户或已配置机器人 ID,而不是用 isdigit 过滤。
|
||||||
valid_ids = [
|
bot_ids = {str(uid) for uid in self.config_manager.get_bot_self_ids()}
|
||||||
str(uid).strip() for uid in raw_ids if str(uid).strip().isdigit()
|
known_ids = set(id_to_nickname) | bot_ids
|
||||||
]
|
valid_ids = []
|
||||||
|
for raw_uid in raw_ids:
|
||||||
|
uid = str(raw_uid).strip().strip("[]")
|
||||||
|
if uid and uid in known_ids and uid not in valid_ids:
|
||||||
|
valid_ids.append(uid)
|
||||||
topic.contributor_ids = valid_ids
|
topic.contributor_ids = valid_ids
|
||||||
|
|
||||||
# 映射回昵称用于显示
|
# 映射回昵称用于显示
|
||||||
@@ -376,7 +380,6 @@ class TopicAnalyzer(BaseAnalyzer[SummaryTopic, list[dict]]):
|
|||||||
name = id_to_nickname.get(uid)
|
name = id_to_nickname.get(uid)
|
||||||
if not name:
|
if not name:
|
||||||
# 尝试去全局配置里找 (e.g. 机器人自己)
|
# 尝试去全局配置里找 (e.g. 机器人自己)
|
||||||
bot_ids = self.config_manager.get_bot_self_ids()
|
|
||||||
if uid in bot_ids:
|
if uid in bot_ids:
|
||||||
name = "Bot"
|
name = "Bot"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ class LLMAnalyzer(IAnalysisProvider):
|
|||||||
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
|
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
|
||||||
self.chat_quality_analyzer = ChatQualityAnalyzer(context, config_manager)
|
self.chat_quality_analyzer = ChatQualityAnalyzer(context, config_manager)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_session_id(
|
||||||
|
session_id: str | None, umo: str | None = None, prefix: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""Generate a session ID if not already provided."""
|
||||||
|
if session_id:
|
||||||
|
return session_id
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
if umo:
|
||||||
|
safe_umo = umo.replace(":", "_")
|
||||||
|
return f"{prefix}{timestamp}_{safe_umo}"
|
||||||
|
return f"{prefix}{timestamp}"
|
||||||
|
|
||||||
async def analyze_topics(
|
async def analyze_topics(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
@@ -70,16 +85,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
|||||||
(话题列表, Token使用统计)
|
(话题列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not session_id:
|
session_id = self._make_session_id(session_id, umo)
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
if umo:
|
|
||||||
# Sanitize umo for filename (replace : with _)
|
|
||||||
safe_umo = umo.replace(":", "_")
|
|
||||||
session_id = f"{timestamp}_{safe_umo}"
|
|
||||||
else:
|
|
||||||
session_id = timestamp
|
|
||||||
|
|
||||||
logger.info(f"开始话题分析, session_id: {session_id}")
|
logger.info(f"开始话题分析, session_id: {session_id}")
|
||||||
return await self.topic_analyzer.analyze_topics(messages, umo, session_id)
|
return await self.topic_analyzer.analyze_topics(messages, umo, session_id)
|
||||||
@@ -110,15 +116,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
|||||||
(用户称号列表, Token使用统计)
|
(用户称号列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not session_id:
|
session_id = self._make_session_id(session_id, umo)
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
if umo:
|
|
||||||
safe_umo = umo.replace(":", "_")
|
|
||||||
session_id = f"{timestamp}_{safe_umo}"
|
|
||||||
else:
|
|
||||||
session_id = timestamp
|
|
||||||
|
|
||||||
logger.info(f"开始用户称号分析, session_id: {session_id}")
|
logger.info(f"开始用户称号分析, session_id: {session_id}")
|
||||||
return await self.user_title_analyzer.analyze_user_titles(
|
return await self.user_title_analyzer.analyze_user_titles(
|
||||||
@@ -147,15 +145,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
|||||||
(金句列表, Token使用统计)
|
(金句列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not session_id:
|
session_id = self._make_session_id(session_id, umo)
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
if umo:
|
|
||||||
safe_umo = umo.replace(":", "_")
|
|
||||||
session_id = f"{timestamp}_{safe_umo}"
|
|
||||||
else:
|
|
||||||
session_id = timestamp
|
|
||||||
|
|
||||||
logger.info(f"开始金句分析, session_id: {session_id}")
|
logger.info(f"开始金句分析, session_id: {session_id}")
|
||||||
return await self.golden_quote_analyzer.analyze_golden_quotes(
|
return await self.golden_quote_analyzer.analyze_golden_quotes(
|
||||||
@@ -211,14 +201,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
|||||||
(话题列表, 用户称号列表, 金句列表, 总Token使用统计)
|
(话题列表, 用户称号列表, 金句列表, 总Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
session_id = self._make_session_id(None, umo)
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
if umo:
|
|
||||||
safe_umo = umo.replace(":", "_")
|
|
||||||
session_id = f"{timestamp}_{safe_umo}"
|
|
||||||
else:
|
|
||||||
session_id = timestamp
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}"
|
f"开始并发执行分析任务 (话题:{topic_enabled}, 称号:{user_title_enabled}, 金句:{golden_quote_enabled}),会话ID: {session_id}"
|
||||||
@@ -349,14 +332,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
|||||||
(话题列表, 金句列表, 总Token使用统计)
|
(话题列表, 金句列表, 总Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
session_id = self._make_session_id(None, umo, "incr_")
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
if umo:
|
|
||||||
safe_umo = umo.replace(":", "_")
|
|
||||||
session_id = f"incr_{timestamp}_{safe_umo}"
|
|
||||||
else:
|
|
||||||
session_id = f"incr_{timestamp}"
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled}),"
|
f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled}),"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ConfigManager:
|
|||||||
|
|
||||||
配置结构采用分组嵌套方式,顶层分为以下分组:
|
配置结构采用分组嵌套方式,顶层分为以下分组:
|
||||||
- basic: 基础设置
|
- basic: 基础设置
|
||||||
|
- qq_official: QQ 官方机器人展示设置
|
||||||
- auto_analysis: 自动分析设置
|
- auto_analysis: 自动分析设置
|
||||||
- llm: LLM 设置
|
- llm: LLM 设置
|
||||||
- analysis_features: 分析功能开关
|
- analysis_features: 分析功能开关
|
||||||
@@ -149,6 +150,13 @@ class ConfigManager:
|
|||||||
"""获取输出格式"""
|
"""获取输出格式"""
|
||||||
return self._get_group("basic").get("output_format", "image")
|
return self._get_group("basic").get("output_format", "image")
|
||||||
|
|
||||||
|
def get_qq_official_t2i_summary_dashboard_enabled(self) -> bool:
|
||||||
|
"""是否启用 QQ 官方 T2I 概览图。"""
|
||||||
|
group = self._get_group("qq_official")
|
||||||
|
if "enable_t2i_summary_dashboard" in group:
|
||||||
|
return bool(group["enable_t2i_summary_dashboard"])
|
||||||
|
return bool(group.get("enable_t2i_activity_histogram", True))
|
||||||
|
|
||||||
def get_min_messages_threshold(self) -> int:
|
def get_min_messages_threshold(self) -> int:
|
||||||
"""获取最小消息阈值"""
|
"""获取最小消息阈值"""
|
||||||
return self._get_group("basic").get("min_messages_threshold", 50)
|
return self._get_group("basic").get("min_messages_threshold", 50)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Persistent registry of groups observed by event-driven platforms."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformGroupRegistry:
|
||||||
|
"""Keep a small, platform-scoped list of groups seen in incoming events."""
|
||||||
|
|
||||||
|
_KV_KEY = "platform_seen_groups_v1"
|
||||||
|
_LEGACY_TELEGRAM_KEY = "telegram_seen_groups_v1"
|
||||||
|
|
||||||
|
def __init__(self, plugin_instance: Any):
|
||||||
|
self.plugin = plugin_instance
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._known_groups: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
async def upsert(
|
||||||
|
self,
|
||||||
|
platform_id: str,
|
||||||
|
group_id: str,
|
||||||
|
sender_id: str = "",
|
||||||
|
sender_name: str = "",
|
||||||
|
event_message_id: str = "",
|
||||||
|
) -> None:
|
||||||
|
platform_key = str(platform_id or "").strip()
|
||||||
|
group_key = str(group_id or "").strip()
|
||||||
|
if not platform_key or not group_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
identity = (platform_key, group_key)
|
||||||
|
if identity in self._known_groups:
|
||||||
|
return
|
||||||
|
|
||||||
|
registry = await self.plugin.get_kv_data(self._KV_KEY, {})
|
||||||
|
if not isinstance(registry, dict):
|
||||||
|
registry = {}
|
||||||
|
platforms = registry.setdefault("platforms", {})
|
||||||
|
if not isinstance(platforms, dict):
|
||||||
|
platforms = {}
|
||||||
|
registry["platforms"] = platforms
|
||||||
|
platform_map = platforms.setdefault(platform_key, {})
|
||||||
|
if not isinstance(platform_map, dict):
|
||||||
|
platform_map = {}
|
||||||
|
platforms[platform_key] = platform_map
|
||||||
|
|
||||||
|
# Existing groups only need to be remembered in memory. The
|
||||||
|
# registry is used for group discovery, so rewriting last_seen and
|
||||||
|
# the full KV document for every message creates unnecessary I/O.
|
||||||
|
if group_key in platform_map:
|
||||||
|
self._known_groups.add(identity)
|
||||||
|
return
|
||||||
|
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
platform_map[group_key] = {
|
||||||
|
"first_seen": now_iso,
|
||||||
|
"last_seen": now_iso,
|
||||||
|
"last_sender_id": str(sender_id or ""),
|
||||||
|
"last_sender_name": str(sender_name or ""),
|
||||||
|
"last_event_message_id": str(event_message_id or ""),
|
||||||
|
}
|
||||||
|
registry["updated_at"] = now_iso
|
||||||
|
await self.plugin.put_kv_data(self._KV_KEY, registry)
|
||||||
|
self._known_groups.add(identity)
|
||||||
|
|
||||||
|
async def get_all_group_ids(self, platform_id: str | None = None) -> list[str]:
|
||||||
|
async with self._lock:
|
||||||
|
registry = await self.plugin.get_kv_data(self._KV_KEY, {})
|
||||||
|
groups = self._extract_groups(registry, platform_id)
|
||||||
|
platform_key = str(platform_id).strip() if platform_id else None
|
||||||
|
if platform_id:
|
||||||
|
self._known_groups.update(
|
||||||
|
(str(platform_key), group_id) for group_id in groups
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preserve groups recorded by older plugin versions.
|
||||||
|
legacy = await self.plugin.get_kv_data(self._LEGACY_TELEGRAM_KEY, {})
|
||||||
|
legacy_groups = self._extract_groups(legacy, platform_id)
|
||||||
|
groups.update(legacy_groups)
|
||||||
|
if platform_id:
|
||||||
|
self._known_groups.update(
|
||||||
|
(str(platform_key), group_id) for group_id in legacy_groups
|
||||||
|
)
|
||||||
|
return sorted(groups)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_groups(registry: object, platform_id: str | None) -> set[str]:
|
||||||
|
if not isinstance(registry, dict):
|
||||||
|
return set()
|
||||||
|
platforms = registry.get("platforms")
|
||||||
|
if not isinstance(platforms, dict):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
maps: list[object]
|
||||||
|
if platform_id:
|
||||||
|
maps = [platforms.get(str(platform_id).strip(), {})]
|
||||||
|
else:
|
||||||
|
maps = list(platforms.values())
|
||||||
|
|
||||||
|
groups: set[str] = set()
|
||||||
|
for platform_map in maps:
|
||||||
|
if isinstance(platform_map, dict):
|
||||||
|
groups.update(
|
||||||
|
str(group_id).strip()
|
||||||
|
for group_id in platform_map
|
||||||
|
if str(group_id).strip()
|
||||||
|
)
|
||||||
|
return groups
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
# 平台适配器
|
from .adapters.discord_adapter import DiscordAdapter
|
||||||
from .adapters.lark_adapter import LarkAdapter
|
from .adapters.lark_adapter import LarkAdapter
|
||||||
from .adapters.onebot_adapter import OneBotAdapter
|
from .adapters.onebot_adapter import OneBotAdapter
|
||||||
|
from .adapters.qq_official_adapter import QQOfficialAdapter
|
||||||
|
from .adapters.telegram_adapter import TelegramAdapter
|
||||||
from .base import PlatformAdapter
|
from .base import PlatformAdapter
|
||||||
from .factory import PlatformAdapterFactory
|
from .factory import PlatformAdapterFactory
|
||||||
|
|
||||||
__all__ = ["PlatformAdapterFactory", "PlatformAdapter", "OneBotAdapter", "LarkAdapter"]
|
__all__ = [
|
||||||
|
"PlatformAdapterFactory",
|
||||||
|
"PlatformAdapter",
|
||||||
|
"OneBotAdapter",
|
||||||
|
"LarkAdapter",
|
||||||
|
"QQOfficialAdapter",
|
||||||
|
"TelegramAdapter",
|
||||||
|
"DiscordAdapter",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
# 平台适配器
|
"""Optional platform adapter exports.
|
||||||
from .discord_adapter import DiscordAdapter
|
|
||||||
from .lark_adapter import LarkAdapter
|
|
||||||
from .onebot_adapter import OneBotAdapter
|
|
||||||
|
|
||||||
__all__ = ["OneBotAdapter", "DiscordAdapter", "LarkAdapter"]
|
Each platform is imported independently so an unavailable optional SDK does not
|
||||||
|
prevent the QQ Official adapter from being registered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .discord_adapter import DiscordAdapter # noqa: F401
|
||||||
|
|
||||||
|
__all__.append("DiscordAdapter")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .lark_adapter import LarkAdapter # noqa: F401
|
||||||
|
|
||||||
|
__all__.append("LarkAdapter")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .onebot_adapter import OneBotAdapter # noqa: F401
|
||||||
|
|
||||||
|
__all__.append("OneBotAdapter")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .qq_official_adapter import QQOfficialAdapter # noqa: F401
|
||||||
|
|
||||||
|
__all__.append("QQOfficialAdapter")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"""QQ Official Bot adapter backed by AstrBot's local message history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from ....domain.value_objects.platform_capabilities import (
|
||||||
|
QQ_OFFICIAL_CAPABILITIES,
|
||||||
|
PlatformCapabilities,
|
||||||
|
)
|
||||||
|
from ....domain.value_objects.unified_group import UnifiedGroup, UnifiedMember
|
||||||
|
from ....domain.value_objects.unified_message import (
|
||||||
|
MessageContent,
|
||||||
|
MessageContentType,
|
||||||
|
UnifiedMessage,
|
||||||
|
)
|
||||||
|
from ....utils.logger import logger
|
||||||
|
from ..base import PlatformAdapter
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from astrbot.api.star import Context
|
||||||
|
|
||||||
|
|
||||||
|
class QQOfficialAdapter(PlatformAdapter):
|
||||||
|
"""Adapter for QQ Official group bots (WebSocket and Webhook variants)."""
|
||||||
|
|
||||||
|
platform_name = "qq_official"
|
||||||
|
AVATAR_TEMPLATE = "https://thirdqq.qlogo.cn/qqapp/{appid}/{member_openid}/640"
|
||||||
|
HISTORY_PAGE_SIZE = 500
|
||||||
|
MARKDOWN_CHUNK_SIZE = 3900
|
||||||
|
|
||||||
|
def __init__(self, bot_instance: Any, config: dict | None = None):
|
||||||
|
super().__init__(bot_instance, config)
|
||||||
|
self._context: Context | None = None
|
||||||
|
self._plugin_instance = config.get("plugin_instance") if config else None
|
||||||
|
self._platform_id = str(config.get("platform_id", "")).strip() if config else ""
|
||||||
|
ids = config.get("bot_self_ids", []) if config else []
|
||||||
|
self.bot_self_ids = [str(item) for item in ids if item]
|
||||||
|
self.appid = self._resolve_appid(config or {})
|
||||||
|
self._markdown_msg_seq = random.randint(1, 10000)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def platform_id(self) -> str:
|
||||||
|
return self._platform_id or "qq_official"
|
||||||
|
|
||||||
|
def _resolve_appid(self, config: dict) -> str:
|
||||||
|
direct = str(config.get("appid", "") or "").strip()
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
platform = getattr(self.bot, "platform", None)
|
||||||
|
platform_config = getattr(platform, "config", None)
|
||||||
|
if isinstance(platform_config, dict):
|
||||||
|
return str(platform_config.get("appid", "") or "").strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def set_context(self, context: Context) -> None:
|
||||||
|
self._context = context
|
||||||
|
|
||||||
|
def _init_capabilities(self) -> PlatformCapabilities:
|
||||||
|
return QQ_OFFICIAL_CAPABILITIES
|
||||||
|
|
||||||
|
async def fetch_messages(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
days: int = 1,
|
||||||
|
max_count: int = 1000,
|
||||||
|
before_id: str | None = None,
|
||||||
|
since_ts: int | None = None,
|
||||||
|
) -> list[UnifiedMessage]:
|
||||||
|
if not self._context:
|
||||||
|
logger.warning("[QQOfficial] 未设置 context,无法读取本地消息历史")
|
||||||
|
return []
|
||||||
|
|
||||||
|
history_mgr = self._context.message_history_manager
|
||||||
|
target_count = max(1, int(max_count))
|
||||||
|
cutoff_ts = (
|
||||||
|
int(since_ts)
|
||||||
|
if since_ts and since_ts > 0
|
||||||
|
else int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
|
||||||
|
)
|
||||||
|
before_record_id: int | None = None
|
||||||
|
if before_id:
|
||||||
|
try:
|
||||||
|
before_record_id = int(before_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
messages: list[UnifiedMessage] = []
|
||||||
|
seen_message_ids: set[str] = set()
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
while len(messages) < target_count:
|
||||||
|
records = await history_mgr.get(
|
||||||
|
platform_id=self.platform_id,
|
||||||
|
user_id=str(group_id),
|
||||||
|
page=page,
|
||||||
|
page_size=self.HISTORY_PAGE_SIZE,
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
break
|
||||||
|
|
||||||
|
reached_cutoff = False
|
||||||
|
for record in records:
|
||||||
|
record_id = getattr(record, "id", None)
|
||||||
|
if (
|
||||||
|
before_record_id is not None
|
||||||
|
and record_id is not None
|
||||||
|
and int(record_id) >= before_record_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
unified = self._convert_history_record(record, str(group_id))
|
||||||
|
if not unified:
|
||||||
|
continue
|
||||||
|
if unified.timestamp < cutoff_ts:
|
||||||
|
reached_cutoff = True
|
||||||
|
continue
|
||||||
|
if unified.sender_id in self.bot_self_ids:
|
||||||
|
continue
|
||||||
|
if unified.message_id in seen_message_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_message_ids.add(unified.message_id)
|
||||||
|
messages.append(unified)
|
||||||
|
|
||||||
|
if len(messages) >= target_count:
|
||||||
|
break
|
||||||
|
if reached_cutoff or len(records) < self.HISTORY_PAGE_SIZE:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
messages.sort(key=lambda item: (item.timestamp, item.message_id))
|
||||||
|
if len(messages) > target_count:
|
||||||
|
messages = messages[-target_count:]
|
||||||
|
logger.info(
|
||||||
|
"[QQOfficial] 从本地历史获取群 %s 消息 %s 条",
|
||||||
|
group_id,
|
||||||
|
len(messages),
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[QQOfficial] 读取本地消息历史失败: %s", exc, exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _convert_history_record(
|
||||||
|
self, record: Any, group_id: str
|
||||||
|
) -> UnifiedMessage | None:
|
||||||
|
try:
|
||||||
|
content = getattr(record, "content", None)
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
return None
|
||||||
|
metadata = content.get("_qq_official")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
contents: list[MessageContent] = []
|
||||||
|
text_parts: list[str] = []
|
||||||
|
for part in content.get("message", []):
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
part_type = str(part.get("type", "")).lower()
|
||||||
|
if part_type in {"plain", "text"}:
|
||||||
|
text = str(part.get("text", "") or "")
|
||||||
|
text_parts.append(text)
|
||||||
|
contents.append(
|
||||||
|
MessageContent(type=MessageContentType.TEXT, text=text)
|
||||||
|
)
|
||||||
|
elif part_type == "image":
|
||||||
|
contents.append(
|
||||||
|
MessageContent(
|
||||||
|
type=MessageContentType.IMAGE,
|
||||||
|
url=str(part.get("url", "") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif part_type == "at":
|
||||||
|
contents.append(
|
||||||
|
MessageContent(
|
||||||
|
type=MessageContentType.AT,
|
||||||
|
at_user_id=str(part.get("target_id", "") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif part_type == "file":
|
||||||
|
contents.append(
|
||||||
|
MessageContent(
|
||||||
|
type=MessageContentType.FILE,
|
||||||
|
url=str(part.get("url", "") or ""),
|
||||||
|
raw_data={"name": part.get("name", "")},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif part_type in {"record", "voice"}:
|
||||||
|
contents.append(
|
||||||
|
MessageContent(
|
||||||
|
type=MessageContentType.VOICE,
|
||||||
|
url=str(part.get("url", "") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif part_type == "video":
|
||||||
|
contents.append(
|
||||||
|
MessageContent(
|
||||||
|
type=MessageContentType.VIDEO,
|
||||||
|
url=str(part.get("url", "") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
message_id = str(metadata.get("message_id", "") or "")
|
||||||
|
if not message_id:
|
||||||
|
message_id = f"local:{getattr(record, 'id', '')}"
|
||||||
|
timestamp = int(metadata.get("timestamp", 0) or 0)
|
||||||
|
if timestamp <= 0:
|
||||||
|
created_at = getattr(record, "created_at", None)
|
||||||
|
timestamp = int(created_at.timestamp()) if created_at else 0
|
||||||
|
sender_id = str(getattr(record, "sender_id", "") or "")
|
||||||
|
if not sender_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UnifiedMessage(
|
||||||
|
message_id=message_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_name=sender_id,
|
||||||
|
sender_card=None,
|
||||||
|
group_id=group_id,
|
||||||
|
text_content="".join(text_parts),
|
||||||
|
contents=tuple(contents),
|
||||||
|
timestamp=timestamp,
|
||||||
|
platform=self.platform_name,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("[QQOfficial] 转换本地历史记录失败: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||||
|
result: list[dict] = []
|
||||||
|
for message in messages:
|
||||||
|
chain: list[dict] = []
|
||||||
|
for content in message.contents:
|
||||||
|
if content.type == MessageContentType.TEXT:
|
||||||
|
chain.append({"type": "text", "data": {"text": content.text}})
|
||||||
|
elif content.type == MessageContentType.IMAGE:
|
||||||
|
chain.append({"type": "image", "data": {"url": content.url}})
|
||||||
|
elif content.type == MessageContentType.AT:
|
||||||
|
chain.append({"type": "at", "data": {"qq": content.at_user_id}})
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"time": message.timestamp,
|
||||||
|
"group_id": message.group_id,
|
||||||
|
"sender": {
|
||||||
|
"user_id": message.sender_id,
|
||||||
|
"nickname": message.sender_id,
|
||||||
|
"card": "",
|
||||||
|
},
|
||||||
|
"message": chain,
|
||||||
|
"user_id": message.sender_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _send_chain(self, group_id: str, chain: Any) -> bool:
|
||||||
|
if not self._context:
|
||||||
|
logger.error("[QQOfficial] 未设置 context,无法发送消息")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
# AstrBot's QQ Official adapter keeps the group/channel scene only
|
||||||
|
# in memory. Restore it before proactive sends so scheduled reports
|
||||||
|
# continue to work after a process restart, before the next event.
|
||||||
|
platform = getattr(self.bot, "platform", None)
|
||||||
|
remember_scene = getattr(platform, "remember_session_scene", None)
|
||||||
|
if callable(remember_scene):
|
||||||
|
remember_scene(str(group_id), "group")
|
||||||
|
umo = f"{self.platform_id}:GroupMessage:{group_id}"
|
||||||
|
return bool(await self._context.send_message(umo, chain))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[QQOfficial] 发送消息失败: %s", exc, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_text(
|
||||||
|
self, group_id: str, text: str, reply_to: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
from astrbot.api.event import MessageChain
|
||||||
|
|
||||||
|
return await self._send_chain(group_id, MessageChain().message(str(text)))
|
||||||
|
|
||||||
|
async def send_text_report(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
content: str,
|
||||||
|
fallback_content: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Send long reports as QQ custom Markdown with plain-text fallback."""
|
||||||
|
chunks = self._split_markdown_report(str(content))
|
||||||
|
if not chunks:
|
||||||
|
return True
|
||||||
|
|
||||||
|
markdown_enabled = True
|
||||||
|
sent_markdown_chunks = 0
|
||||||
|
for chunk in chunks:
|
||||||
|
if markdown_enabled:
|
||||||
|
try:
|
||||||
|
if await self._send_markdown_chunk(group_id, chunk):
|
||||||
|
sent_markdown_chunks += 1
|
||||||
|
continue
|
||||||
|
logger.warning(
|
||||||
|
"[QQOfficial] Markdown 接口未返回成功结果,后续改用普通文本"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[QQOfficial] Markdown 报告发送失败,后续改用普通文本: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
markdown_enabled = False
|
||||||
|
|
||||||
|
if fallback_content and sent_markdown_chunks == 0:
|
||||||
|
for fallback_chunk in self._split_markdown_report(
|
||||||
|
str(fallback_content)
|
||||||
|
):
|
||||||
|
if not await self.send_text(group_id, fallback_chunk):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not await self.send_text(group_id, chunk):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _send_markdown_chunk(self, group_id: str, content: str) -> bool:
|
||||||
|
api = getattr(self.bot, "api", None)
|
||||||
|
post_group_message = getattr(api, "post_group_message", None)
|
||||||
|
if not callable(post_group_message):
|
||||||
|
return False
|
||||||
|
|
||||||
|
platform = getattr(self.bot, "platform", None)
|
||||||
|
remember_scene = getattr(platform, "remember_session_scene", None)
|
||||||
|
if callable(remember_scene):
|
||||||
|
remember_scene(str(group_id), "group")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from botpy.types.message import MarkdownPayload
|
||||||
|
|
||||||
|
markdown: Any = MarkdownPayload(content=content)
|
||||||
|
except ImportError:
|
||||||
|
# Allows lightweight test environments while botpy is provided by
|
||||||
|
# AstrBot in production.
|
||||||
|
markdown = {"content": content}
|
||||||
|
|
||||||
|
result = await post_group_message( # type: ignore[arg-type]
|
||||||
|
group_openid=str(group_id),
|
||||||
|
msg_type=2,
|
||||||
|
markdown=markdown,
|
||||||
|
msg_seq=self._next_markdown_msg_seq(),
|
||||||
|
)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
def _next_markdown_msg_seq(self) -> int:
|
||||||
|
self._markdown_msg_seq = (self._markdown_msg_seq % 10000) + 1
|
||||||
|
return self._markdown_msg_seq
|
||||||
|
|
||||||
|
def _split_markdown_report(self, content: str) -> list[str]:
|
||||||
|
"""Split Markdown on block boundaries without breaking mention tokens."""
|
||||||
|
normalized = str(content or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
|
||||||
|
blocks = re.split(r"\n{2,}", normalized)
|
||||||
|
chunks: list[str] = []
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
def append_piece(piece: str) -> None:
|
||||||
|
nonlocal current
|
||||||
|
candidate = f"{current}\n\n{piece}" if current else piece
|
||||||
|
if len(candidate) <= self.MARKDOWN_CHUNK_SIZE:
|
||||||
|
current = candidate
|
||||||
|
return
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = piece
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
block = block.strip()
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
if len(block) <= self.MARKDOWN_CHUNK_SIZE:
|
||||||
|
append_piece(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = block.splitlines() or [block]
|
||||||
|
piece = ""
|
||||||
|
for line in lines:
|
||||||
|
candidate = f"{piece}\n{line}" if piece else line
|
||||||
|
if len(candidate) <= self.MARKDOWN_CHUNK_SIZE:
|
||||||
|
piece = candidate
|
||||||
|
continue
|
||||||
|
if piece:
|
||||||
|
append_piece(piece)
|
||||||
|
while len(line) > self.MARKDOWN_CHUNK_SIZE:
|
||||||
|
split_at = self.MARKDOWN_CHUNK_SIZE
|
||||||
|
mention_start = line.rfind("<@", 0, split_at)
|
||||||
|
mention_end = (
|
||||||
|
line.find(">", mention_start) if mention_start >= 0 else -1
|
||||||
|
)
|
||||||
|
if mention_start >= 0 and mention_end >= split_at:
|
||||||
|
split_at = mention_start or self.MARKDOWN_CHUNK_SIZE
|
||||||
|
append_piece(line[:split_at])
|
||||||
|
line = line[split_at:]
|
||||||
|
piece = line
|
||||||
|
if piece:
|
||||||
|
append_piece(piece)
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
async def send_image(
|
||||||
|
self, group_id: str, image_path: str, caption: str = ""
|
||||||
|
) -> bool:
|
||||||
|
from astrbot.api.event import MessageChain
|
||||||
|
|
||||||
|
chain = MessageChain()
|
||||||
|
if caption:
|
||||||
|
chain.message(caption)
|
||||||
|
if image_path.startswith("base64://"):
|
||||||
|
chain.base64_image(image_path[len("base64://") :])
|
||||||
|
elif image_path.startswith("data:") and "," in image_path:
|
||||||
|
chain.base64_image(image_path.split(",", 1)[1])
|
||||||
|
elif image_path.startswith(("http://", "https://")):
|
||||||
|
chain.url_image(image_path)
|
||||||
|
else:
|
||||||
|
chain.file_image(os.path.abspath(image_path))
|
||||||
|
return await self._send_chain(group_id, chain)
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self, group_id: str, file_path: str, filename: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
# Prefer the public API; fall back to internal for backward compat.
|
||||||
|
# astrbot.core is not part of the stable contract and may change.
|
||||||
|
from astrbot.api.event import MessageChain
|
||||||
|
from astrbot.core.message.components import File
|
||||||
|
|
||||||
|
name = filename or os.path.basename(file_path) or "report"
|
||||||
|
if file_path.startswith(("http://", "https://")):
|
||||||
|
component = File(name=name, url=file_path)
|
||||||
|
else:
|
||||||
|
component = File(name=name, file=os.path.abspath(file_path))
|
||||||
|
return await self._send_chain(group_id, MessageChain([component]))
|
||||||
|
|
||||||
|
async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
|
||||||
|
return UnifiedGroup(
|
||||||
|
group_id=str(group_id),
|
||||||
|
group_name=str(group_id),
|
||||||
|
platform=self.platform_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_group_list(self) -> list[str]:
|
||||||
|
if self._plugin_instance and hasattr(
|
||||||
|
self._plugin_instance, "get_seen_group_ids"
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await self._plugin_instance.get_seen_group_ids(self.platform_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[QQOfficial] 获取已见群列表失败: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_member_info(
|
||||||
|
self, group_id: str, user_id: str
|
||||||
|
) -> UnifiedMember | None:
|
||||||
|
return UnifiedMember(
|
||||||
|
user_id=str(user_id),
|
||||||
|
nickname="",
|
||||||
|
avatar_url=await self.get_user_avatar_url(str(user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_avatar_url(self, user_id: str, size: int = 100) -> str | None:
|
||||||
|
if not self.appid or not user_id:
|
||||||
|
return None
|
||||||
|
return self.AVATAR_TEMPLATE.format(
|
||||||
|
appid=quote(self.appid, safe=""),
|
||||||
|
member_openid=quote(str(user_id), safe=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_avatar_data(self, user_id: str, size: int = 100) -> str | None:
|
||||||
|
avatar_url = await self.get_user_avatar_url(user_id, size)
|
||||||
|
if not avatar_url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
timeout=timeout, trust_env=True
|
||||||
|
) as session:
|
||||||
|
async with session.get(avatar_url) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
return None
|
||||||
|
payload = await response.read()
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
mime = "image/png" if payload.startswith(b"\x89PNG") else "image/jpeg"
|
||||||
|
return f"data:{mime};base64,{base64.b64encode(payload).decode('utf-8')}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("[QQOfficial] 下载头像失败: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_group_avatar_url(self, group_id: str, size: int = 100) -> str | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def batch_get_avatar_urls(
|
||||||
|
self, user_ids: list[str], size: int = 100
|
||||||
|
) -> dict[str, str | None]:
|
||||||
|
unique_ids = list(
|
||||||
|
dict.fromkeys(str(user_id) for user_id in user_ids if user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_one(user_id: str) -> tuple[str, str | None]:
|
||||||
|
return user_id, await self.get_user_avatar_url(user_id, size)
|
||||||
|
|
||||||
|
return dict(await asyncio.gather(*(get_one(user_id) for user_id in unique_ids)))
|
||||||
@@ -75,6 +75,10 @@ class BotManager:
|
|||||||
"platform_id": str(platform_id),
|
"platform_id": str(platform_id),
|
||||||
"plugin_instance": self._plugin_instance,
|
"plugin_instance": self._plugin_instance,
|
||||||
}
|
}
|
||||||
|
platform_instance = self._platforms.get(str(platform_id))
|
||||||
|
platform_config = getattr(platform_instance, "config", None)
|
||||||
|
if isinstance(platform_config, Mapping):
|
||||||
|
adapter_config["appid"] = platform_config.get("appid", "")
|
||||||
adapter = PlatformAdapterFactory.create(
|
adapter = PlatformAdapterFactory.create(
|
||||||
platform_name, bot_instance, adapter_config
|
platform_name, bot_instance, adapter_config
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,5 +99,13 @@ def _register_adapters():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .adapters.qq_official_adapter import QQOfficialAdapter
|
||||||
|
|
||||||
|
PlatformAdapterFactory.register("qq_official", QQOfficialAdapter)
|
||||||
|
PlatformAdapterFactory.register("qq_official_webhook", QQOfficialAdapter)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
_register_adapters()
|
_register_adapters()
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class ReportDispatcher:
|
|||||||
"""设置 HTML 渲染函数 (运行时注入)"""
|
"""设置 HTML 渲染函数 (运行时注入)"""
|
||||||
self._html_render_func = render_func
|
self._html_render_func = render_func
|
||||||
|
|
||||||
|
def _hide_user_names(self, platform_id: str | None) -> bool:
|
||||||
|
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||||
|
return bool(adapter and adapter.get_platform_name() == "qq_official")
|
||||||
|
|
||||||
async def dispatch(
|
async def dispatch(
|
||||||
self,
|
self,
|
||||||
group_id: str,
|
group_id: str,
|
||||||
@@ -88,6 +92,7 @@ class ReportDispatcher:
|
|||||||
self._html_render_func,
|
self._html_render_func,
|
||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
|
hide_user_names=self._hide_user_names(platform_id),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{trace_id}] Failed to generate image report: {e}")
|
logger.error(f"[{trace_id}] Failed to generate image report: {e}")
|
||||||
@@ -135,6 +140,7 @@ class ReportDispatcher:
|
|||||||
group_id,
|
group_id,
|
||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
|
hide_user_names=self._hide_user_names(platform_id),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
|
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
|
||||||
@@ -196,13 +202,31 @@ class ReportDispatcher:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""分发文本报告"""
|
"""分发文本报告"""
|
||||||
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告")
|
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告")
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
is_qq_official = self._hide_user_names(platform_id)
|
||||||
|
fallback_report = None
|
||||||
|
if is_qq_official:
|
||||||
|
(
|
||||||
|
text_report,
|
||||||
|
fallback_report,
|
||||||
|
) = await self.report_generator.generate_qq_official_markdown_report(
|
||||||
|
analysis_result, self._html_render_func
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||||
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||||
# 尝试通过适配器发送文本报告
|
# 尝试通过适配器发送文本报告
|
||||||
logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}")
|
logger.info(f"[分发器] 正在尝试通过适配器发送文本报告。群: {group_id}")
|
||||||
try:
|
try:
|
||||||
if adapter and await adapter.send_text_report(group_id, text_report):
|
if adapter:
|
||||||
return True
|
if is_qq_official:
|
||||||
|
if await adapter.send_text_report(
|
||||||
|
group_id,
|
||||||
|
text_report,
|
||||||
|
fallback_content=fallback_report,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
elif await adapter.send_text_report(group_id, text_report):
|
||||||
|
return True
|
||||||
return await self.message_sender.send_text(
|
return await self.message_sender.send_text(
|
||||||
group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}", platform_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
@@ -14,6 +15,7 @@ from dataclasses import asdict, is_dataclass
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -25,6 +27,7 @@ from ...domain.repositories.report_repository import IReportGenerator
|
|||||||
from ...utils.logger import logger
|
from ...utils.logger import logger
|
||||||
from ..utils.template_utils import render_template
|
from ..utils.template_utils import render_template
|
||||||
from ..visualization.activity_charts import ActivityVisualizer
|
from ..visualization.activity_charts import ActivityVisualizer
|
||||||
|
from .qq_official_markdown import QQOfficialMarkdownReportGenerator
|
||||||
from .templates import HTMLTemplates
|
from .templates import HTMLTemplates
|
||||||
|
|
||||||
MAX_CONCURRENT_DOWNLOADS = 10
|
MAX_CONCURRENT_DOWNLOADS = 10
|
||||||
@@ -105,6 +108,11 @@ class ReportGenerator(IReportGenerator):
|
|||||||
# 使用专用的 T2I 并发配置项
|
# 使用专用的 T2I 并发配置项
|
||||||
max_concurrent = self.config_manager.get_t2i_max_concurrent()
|
max_concurrent = self.config_manager.get_t2i_max_concurrent()
|
||||||
self._render_semaphore = asyncio.Semaphore(max_concurrent)
|
self._render_semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
self._qq_official_markdown_generator = QQOfficialMarkdownReportGenerator(
|
||||||
|
config_manager,
|
||||||
|
self.html_templates,
|
||||||
|
self._render_semaphore,
|
||||||
|
)
|
||||||
|
|
||||||
# 运行时缓存,用于在一次分析任务中避免重复下载同一个头像
|
# 运行时缓存,用于在一次分析任务中避免重复下载同一个头像
|
||||||
self._avatar_cache = Cache(
|
self._avatar_cache = Cache(
|
||||||
@@ -336,6 +344,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_url_getter=None,
|
avatar_url_getter=None,
|
||||||
nickname_getter=None,
|
nickname_getter=None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
|
hide_user_names: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
生成图片格式的分析报告
|
生成图片格式的分析报告
|
||||||
@@ -359,6 +368,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=avatar_cache_namespace,
|
avatar_cache_namespace=avatar_cache_namespace,
|
||||||
|
hide_user_names=hide_user_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
||||||
@@ -500,6 +510,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_url_getter=None,
|
avatar_url_getter=None,
|
||||||
nickname_getter=None,
|
nickname_getter=None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
|
hide_user_names: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
生成HTML格式的分析报告,保存到指定目录
|
生成HTML格式的分析报告,保存到指定目录
|
||||||
@@ -544,6 +555,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=avatar_cache_namespace,
|
avatar_cache_namespace=avatar_cache_namespace,
|
||||||
|
hide_user_names=hide_user_names,
|
||||||
)
|
)
|
||||||
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||||
|
|
||||||
@@ -603,7 +615,11 @@ class ReportGenerator(IReportGenerator):
|
|||||||
|
|
||||||
# 保存原始 JSON 数据
|
# 保存原始 JSON 数据
|
||||||
json_data = {
|
json_data = {
|
||||||
"analysis_result": analysis_result,
|
"analysis_result": (
|
||||||
|
self._sanitize_analysis_result_for_export(analysis_result)
|
||||||
|
if hide_user_names
|
||||||
|
else analysis_result
|
||||||
|
),
|
||||||
"group_id": group_id,
|
"group_id": group_id,
|
||||||
"generated_at": datetime.now().isoformat(),
|
"generated_at": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
@@ -689,6 +705,88 @@ class ReportGenerator(IReportGenerator):
|
|||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
async def generate_qq_official_markdown_report(
|
||||||
|
self, analysis_result: dict, html_render_func=None
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Delegate QQ-only text generation to the platform-specific module."""
|
||||||
|
generator = getattr(self, "_qq_official_markdown_generator", None)
|
||||||
|
if generator is None:
|
||||||
|
generator = QQOfficialMarkdownReportGenerator(
|
||||||
|
self.config_manager,
|
||||||
|
getattr(self, "html_templates", None),
|
||||||
|
getattr(self, "_render_semaphore", None),
|
||||||
|
)
|
||||||
|
self._qq_official_markdown_generator = generator
|
||||||
|
return await generator.generate(
|
||||||
|
analysis_result,
|
||||||
|
html_render_func,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sanitize_analysis_result_for_export(
|
||||||
|
self, analysis_result: dict
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Remove platform identities from the HTML sidecar JSON export."""
|
||||||
|
sanitized = self._to_plain_export_data(copy.deepcopy(analysis_result))
|
||||||
|
sanitized["user_analysis"] = {}
|
||||||
|
for topic in sanitized.get("topics", []):
|
||||||
|
if not isinstance(topic, dict):
|
||||||
|
continue
|
||||||
|
topic["contributors"] = []
|
||||||
|
topic["contributor_ids"] = []
|
||||||
|
for title in sanitized.get("user_titles", []):
|
||||||
|
if not isinstance(title, dict):
|
||||||
|
continue
|
||||||
|
title["name"] = ""
|
||||||
|
title["user_id"] = ""
|
||||||
|
stats = sanitized.get("statistics")
|
||||||
|
if isinstance(stats, dict):
|
||||||
|
for golden_quote in stats.get("golden_quotes", []) or []:
|
||||||
|
if not isinstance(golden_quote, dict):
|
||||||
|
continue
|
||||||
|
golden_quote["sender"] = ""
|
||||||
|
golden_quote["user_id"] = ""
|
||||||
|
|
||||||
|
activity_visualization = stats.get("activity_visualization")
|
||||||
|
if isinstance(activity_visualization, dict):
|
||||||
|
activity_visualization["user_activity_ranking"] = []
|
||||||
|
|
||||||
|
for golden_quote in sanitized.get("golden_quotes", []) or []:
|
||||||
|
if not isinstance(golden_quote, dict):
|
||||||
|
continue
|
||||||
|
golden_quote["sender"] = ""
|
||||||
|
golden_quote["user_id"] = ""
|
||||||
|
|
||||||
|
return self._sanitize_export_identity_text(sanitized, analysis_result) # type: ignore[return-type]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _to_plain_export_data(cls, value):
|
||||||
|
"""Convert report models into plain containers before privacy filtering."""
|
||||||
|
if hasattr(value, "to_dict") and callable(value.to_dict):
|
||||||
|
return cls._to_plain_export_data(value.to_dict())
|
||||||
|
if is_dataclass(value) and not isinstance(value, type):
|
||||||
|
return cls._to_plain_export_data(asdict(value))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: cls._to_plain_export_data(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [cls._to_plain_export_data(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _sanitize_export_identity_text(self, value, analysis_result: dict):
|
||||||
|
"""Remove known IDs and display names from every exported text field."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return self._sanitize_identity_text(value, analysis_result, True)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: self._sanitize_export_identity_text(item, analysis_result)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [
|
||||||
|
self._sanitize_export_identity_text(item, analysis_result)
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
return value
|
||||||
|
|
||||||
async def _prepare_render_data(
|
async def _prepare_render_data(
|
||||||
self,
|
self,
|
||||||
analysis_result: dict,
|
analysis_result: dict,
|
||||||
@@ -696,6 +794,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_url_getter=None,
|
avatar_url_getter=None,
|
||||||
nickname_getter=None,
|
nickname_getter=None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
|
hide_user_names: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""准备渲染数据"""
|
"""准备渲染数据"""
|
||||||
stats = analysis_result["statistics"]
|
stats = analysis_result["statistics"]
|
||||||
@@ -720,18 +819,34 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_cache_namespace,
|
avatar_cache_namespace,
|
||||||
avatar_reuse_registry,
|
avatar_reuse_registry,
|
||||||
avatar_reuse_aliases,
|
avatar_reuse_aliases,
|
||||||
|
hide_user_names=hide_user_names,
|
||||||
)
|
)
|
||||||
|
if hide_user_names:
|
||||||
|
contributors = await self._render_avatar_only_ids(
|
||||||
|
getattr(topic, "contributor_ids", []) or [],
|
||||||
|
avatar_url_getter,
|
||||||
|
avatar_cache_namespace,
|
||||||
|
avatar_reuse_registry,
|
||||||
|
avatar_reuse_aliases,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
contributors = "、".join(topic.contributors)
|
||||||
topics_list.append(
|
topics_list.append(
|
||||||
{
|
{
|
||||||
"index": i,
|
"index": i,
|
||||||
"topic": topic,
|
"topic": {
|
||||||
"contributors": "、".join(topic.contributors),
|
"topic": self._sanitize_identity_text(
|
||||||
|
topic.topic, analysis_result, hide_user_names
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"contributors": contributors,
|
||||||
"detail": processed_detail,
|
"detail": processed_detail,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 通用模板上下文,包含可能被子模板引用的全局配置
|
# 通用模板上下文,包含可能被子模板引用的全局配置
|
||||||
common_context = {
|
common_context = {
|
||||||
|
"hide_user_names": hide_user_names,
|
||||||
"t2i_font_source": self.config_manager.get_t2i_font_source(),
|
"t2i_font_source": self.config_manager.get_t2i_font_source(),
|
||||||
"t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(),
|
"t2i_google_fonts_mirror": self.config_manager.get_t2i_google_fonts_mirror(),
|
||||||
"t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(),
|
"t2i_gstatic_mirror": self.config_manager.get_t2i_gstatic_mirror(),
|
||||||
@@ -763,11 +878,23 @@ class ReportGenerator(IReportGenerator):
|
|||||||
profile_info = self._resolve_profile_info(
|
profile_info = self._resolve_profile_info(
|
||||||
title.mbti, profile_mode, profile_mapping_overrides
|
title.mbti, profile_mode, profile_mapping_overrides
|
||||||
)
|
)
|
||||||
|
title_reason = title.reason
|
||||||
|
if hide_user_names:
|
||||||
|
title_reason = await self._render_mentions(
|
||||||
|
title.reason,
|
||||||
|
avatar_url_getter,
|
||||||
|
nickname_getter,
|
||||||
|
user_analysis,
|
||||||
|
avatar_cache_namespace,
|
||||||
|
avatar_reuse_registry,
|
||||||
|
avatar_reuse_aliases,
|
||||||
|
hide_user_names=True,
|
||||||
|
)
|
||||||
title_data = {
|
title_data = {
|
||||||
"name": title.name,
|
"name": "" if hide_user_names else title.name,
|
||||||
"title": title.title,
|
"title": title.title,
|
||||||
"mbti": title.mbti,
|
"mbti": title.mbti,
|
||||||
"reason": title.reason,
|
"reason": title_reason,
|
||||||
"avatar_data": avatar_data,
|
"avatar_data": avatar_data,
|
||||||
}
|
}
|
||||||
title_data.update(profile_info)
|
title_data.update(profile_info)
|
||||||
@@ -810,11 +937,14 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_cache_namespace,
|
avatar_cache_namespace,
|
||||||
avatar_reuse_registry,
|
avatar_reuse_registry,
|
||||||
avatar_reuse_aliases,
|
avatar_reuse_aliases,
|
||||||
|
hide_user_names=hide_user_names,
|
||||||
)
|
)
|
||||||
quotes_list.append(
|
quotes_list.append(
|
||||||
{
|
{
|
||||||
"content": golden_quote.content,
|
"content": self._sanitize_identity_text(
|
||||||
"sender": golden_quote.sender,
|
golden_quote.content, analysis_result, hide_user_names
|
||||||
|
),
|
||||||
|
"sender": "" if hide_user_names else golden_quote.sender,
|
||||||
"reason": processed_reason,
|
"reason": processed_reason,
|
||||||
"avatar_url": avatar_url,
|
"avatar_url": avatar_url,
|
||||||
}
|
}
|
||||||
@@ -860,6 +990,33 @@ class ReportGenerator(IReportGenerator):
|
|||||||
else:
|
else:
|
||||||
review_data = chat_quality_review
|
review_data = chat_quality_review
|
||||||
|
|
||||||
|
if hide_user_names and isinstance(review_data, dict):
|
||||||
|
review_data = {
|
||||||
|
**review_data,
|
||||||
|
"title": self._sanitize_identity_text(
|
||||||
|
review_data.get("title", ""), analysis_result, True
|
||||||
|
),
|
||||||
|
"subtitle": self._sanitize_identity_text(
|
||||||
|
review_data.get("subtitle", ""), analysis_result, True
|
||||||
|
),
|
||||||
|
"summary": self._sanitize_identity_text(
|
||||||
|
review_data.get("summary", ""), analysis_result, True
|
||||||
|
),
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
**dimension,
|
||||||
|
"name": self._sanitize_identity_text(
|
||||||
|
dimension.get("name", ""), analysis_result, True
|
||||||
|
),
|
||||||
|
"comment": self._sanitize_identity_text(
|
||||||
|
dimension.get("comment", ""), analysis_result, True
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for dimension in review_data.get("dimensions", [])
|
||||||
|
if isinstance(dimension, dict)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
chat_quality_html = self.html_templates.render_template(
|
chat_quality_html = self.html_templates.render_template(
|
||||||
"chat_quality_item.html", **review_data, **common_context
|
"chat_quality_item.html", **review_data, **common_context
|
||||||
)
|
)
|
||||||
@@ -899,6 +1056,50 @@ class ReportGenerator(IReportGenerator):
|
|||||||
logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段")
|
logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||||
return render_data
|
return render_data
|
||||||
|
|
||||||
|
async def _render_avatar_only_ids(
|
||||||
|
self,
|
||||||
|
user_ids: list[str],
|
||||||
|
avatar_url_getter,
|
||||||
|
avatar_cache_namespace: str | None,
|
||||||
|
avatar_reuse_registry: dict[str, str] | None,
|
||||||
|
avatar_reuse_aliases: dict[str, str] | None,
|
||||||
|
) -> Markup:
|
||||||
|
avatars: list[Markup] = []
|
||||||
|
for raw_user_id in user_ids:
|
||||||
|
user_id = str(raw_user_id or "").strip()
|
||||||
|
if not user_id:
|
||||||
|
continue
|
||||||
|
avatar_url = await self._get_user_avatar(
|
||||||
|
user_id, avatar_url_getter, avatar_cache_namespace
|
||||||
|
)
|
||||||
|
avatar_ref = self._register_reusable_avatar(
|
||||||
|
avatar_url,
|
||||||
|
avatar_reuse_registry,
|
||||||
|
avatar_reuse_aliases,
|
||||||
|
avatar_key=self._get_avatar_cache_key(user_id, avatar_cache_namespace),
|
||||||
|
)
|
||||||
|
style = (
|
||||||
|
"width:24px;height:24px;border-radius:50%;display:inline-block;"
|
||||||
|
"vertical-align:middle;margin:0 2px;background-size:cover;"
|
||||||
|
"background-position:center;background-repeat:no-repeat;"
|
||||||
|
)
|
||||||
|
if avatar_ref:
|
||||||
|
avatars.append(
|
||||||
|
Markup(
|
||||||
|
f'<span class="user-capsule-avatar" '
|
||||||
|
f'data-avatar-ref="{html.escape(avatar_ref, quote=True)}" '
|
||||||
|
f'style="{style}"></span>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
avatars.append(
|
||||||
|
Markup(
|
||||||
|
f'<img src="{html.escape(avatar_url, quote=True)}" '
|
||||||
|
f'style="{style}">'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Markup("").join(avatars)
|
||||||
|
|
||||||
async def _render_mentions(
|
async def _render_mentions(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -908,20 +1109,41 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
avatar_reuse_registry: dict[str, str] | None = None,
|
avatar_reuse_registry: dict[str, str] | None = None,
|
||||||
avatar_reuse_aliases: dict[str, str] | None = None,
|
avatar_reuse_aliases: dict[str, str] | None = None,
|
||||||
|
hide_user_names: bool = False,
|
||||||
) -> Markup:
|
) -> Markup:
|
||||||
"""
|
"""
|
||||||
处理文本,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式
|
处理文本,将 [用户ID] 格式的引用替换为头像胶囊。
|
||||||
"""
|
"""
|
||||||
pattern = r"\[(\d+)\]"
|
|
||||||
if not text:
|
if not text:
|
||||||
return Markup("")
|
return Markup("")
|
||||||
|
|
||||||
matches = list(re.finditer(pattern, text))
|
known_ids = {
|
||||||
|
str(user_id).strip()
|
||||||
|
for user_id in (user_analysis or {})
|
||||||
|
if str(user_id).strip()
|
||||||
|
}
|
||||||
|
source_text = str(text)
|
||||||
|
if hide_user_names:
|
||||||
|
# LLM 偶尔会直接输出 ID;在头像-only 模式下先标准化为引用,
|
||||||
|
# 避免 member_openid 以明文形式泄露。
|
||||||
|
for user_id in sorted(known_ids, key=len, reverse=True):
|
||||||
|
source_text = re.sub(
|
||||||
|
rf"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}"
|
||||||
|
rf"(?![A-Za-z0-9_-])(?!\])",
|
||||||
|
f"[{user_id}]",
|
||||||
|
source_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
pattern = r"\[([A-Za-z0-9_-]{1,128})\]" if hide_user_names else r"\[(\d+)\]"
|
||||||
|
|
||||||
|
matches = list(re.finditer(pattern, source_text))
|
||||||
if not matches:
|
if not matches:
|
||||||
return self._escape_text_segment(text)
|
return self._escape_text_segment(source_text)
|
||||||
|
|
||||||
async def render_capsule(match: re.Match[str]) -> Markup:
|
async def render_capsule(match: re.Match[str]) -> Markup:
|
||||||
uid = match.group(1)
|
uid = match.group(1)
|
||||||
|
if hide_user_names and uid not in known_ids:
|
||||||
|
return Markup(html.escape(f"[{uid}]", quote=True))
|
||||||
url = await self._get_user_avatar(
|
url = await self._get_user_avatar(
|
||||||
uid, avatar_url_getter, avatar_cache_namespace
|
uid, avatar_url_getter, avatar_cache_namespace
|
||||||
) # 内部已有缓存,无需顶层并发获取
|
) # 内部已有缓存,无需顶层并发获取
|
||||||
@@ -949,7 +1171,10 @@ class ReportGenerator(IReportGenerator):
|
|||||||
"padding:2px 6px 2px 2px;border-radius:12px;margin:0 2px;"
|
"padding:2px 6px 2px 2px;border-radius:12px;margin:0 2px;"
|
||||||
"vertical-align:middle;border:1px solid rgba(0,0,0,0.1);text-decoration:none;"
|
"vertical-align:middle;border:1px solid rgba(0,0,0,0.1);text-decoration:none;"
|
||||||
)
|
)
|
||||||
img_style = "width:18px;height:18px;border-radius:50%;margin-right:4px;display:block;"
|
img_style = (
|
||||||
|
"width:18px;height:18px;border-radius:50%;"
|
||||||
|
f"margin-right:{'0' if hide_user_names else '4px'};display:block;"
|
||||||
|
)
|
||||||
name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;"
|
name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;"
|
||||||
|
|
||||||
# 3. 最终后备: 确保有头像和名称
|
# 3. 最终后备: 确保有头像和名称
|
||||||
@@ -979,23 +1204,51 @@ class ReportGenerator(IReportGenerator):
|
|||||||
f'style="{img_style}">'
|
f'style="{img_style}">'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
name_html = (
|
||||||
|
""
|
||||||
|
if hide_user_names
|
||||||
|
else f'<span style="{name_style}">{html.escape(final_name)}</span>'
|
||||||
|
)
|
||||||
return Markup(
|
return Markup(
|
||||||
f'<span class="user-capsule" style="{capsule_style}">'
|
f'<span class="user-capsule" style="{capsule_style}">'
|
||||||
f"{avatar_html}"
|
f"{avatar_html}{name_html}</span>"
|
||||||
f'<span style="{name_style}">{html.escape(final_name)}</span>'
|
|
||||||
"</span>"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result: list[Markup | str] = []
|
result: list[Markup | str] = []
|
||||||
last_end = 0
|
last_end = 0
|
||||||
for match in matches:
|
for match in matches:
|
||||||
result.append(self._escape_text_segment(text[last_end : match.start()]))
|
result.append(
|
||||||
|
self._escape_text_segment(source_text[last_end : match.start()])
|
||||||
|
)
|
||||||
result.append(await render_capsule(match))
|
result.append(await render_capsule(match))
|
||||||
last_end = match.end()
|
last_end = match.end()
|
||||||
|
|
||||||
result.append(self._escape_text_segment(text[last_end:]))
|
result.append(self._escape_text_segment(source_text[last_end:]))
|
||||||
return Markup("").join(result)
|
return Markup("").join(result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_identity_text(
|
||||||
|
text: str, analysis_result: dict, hide_user_names: bool
|
||||||
|
) -> str:
|
||||||
|
if not hide_user_names:
|
||||||
|
return str(text)
|
||||||
|
sanitized = str(text)
|
||||||
|
user_analysis = analysis_result.get("user_analysis") or {}
|
||||||
|
known_ids = {
|
||||||
|
str(user_id).strip() for user_id in user_analysis if str(user_id).strip()
|
||||||
|
}
|
||||||
|
known_names = set()
|
||||||
|
for stats in user_analysis.values():
|
||||||
|
if not isinstance(stats, dict):
|
||||||
|
continue
|
||||||
|
for key in ("nickname", "name"):
|
||||||
|
value = str(stats.get(key, "") or "").strip()
|
||||||
|
if value:
|
||||||
|
known_names.add(value)
|
||||||
|
for identity in sorted(known_ids | known_names, key=len, reverse=True):
|
||||||
|
sanitized = sanitized.replace(identity, "")
|
||||||
|
return re.sub(r"\[\s*\]", "", sanitized)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _escape_text_segment(text: str) -> Markup:
|
def _escape_text_segment(text: str) -> Markup:
|
||||||
return Markup(html.escape(text, quote=False).replace("\n", "<br>"))
|
return Markup(html.escape(text, quote=False).replace("\n", "<br>"))
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=800, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 800px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Helvetica Neue", sans-serif;
|
||||||
|
color: #000000;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
width: 800px;
|
||||||
|
height: 360px;
|
||||||
|
padding: 18px 22px 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 36px 78px 204px;
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #000000;
|
||||||
|
font-size: 25px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: #000000;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid #000000;
|
||||||
|
border-bottom: 1px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-width: 0;
|
||||||
|
height: 58px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric + .metric {
|
||||||
|
border-left: 1px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
max-width: 100%;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 31px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: #000000;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(24, minmax(0, 1fr));
|
||||||
|
column-gap: 5px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 180px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-area {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 1px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: min(16px, 72%);
|
||||||
|
height: var(--height);
|
||||||
|
min-height: var(--min-height);
|
||||||
|
border-radius: 3px 3px 1px 1px;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 17px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="header">
|
||||||
|
<div class="title">{{ report_title }}</div>
|
||||||
|
<div class="date">{{ report_date }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics">
|
||||||
|
{% for metric in metrics %}
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value">{{ metric.value }}</div>
|
||||||
|
<div class="metric-label">{{ metric.label }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="histogram">
|
||||||
|
{% for item in chart_data %}
|
||||||
|
<div class="hour">
|
||||||
|
<div class="bar-area">
|
||||||
|
<div
|
||||||
|
class="bar"
|
||||||
|
style="--height: {{ item.height }}%; --min-height: {{ '3px' if item.count > 0 else '0' }};"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="hour-label">{{ item.hour }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""QQ Official Bot-specific Markdown report generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ...utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class QQOfficialMarkdownReportGenerator:
|
||||||
|
"""Generate QQ Official Markdown without changing other platform reports."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config_manager: Any,
|
||||||
|
html_templates: Any = None,
|
||||||
|
render_semaphore: Any = None,
|
||||||
|
) -> None:
|
||||||
|
self.config_manager = config_manager
|
||||||
|
self.html_templates = html_templates
|
||||||
|
self.render_semaphore = render_semaphore
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self, analysis_result: dict, html_render_func=None
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Generate QQ Markdown and a URL-free Markdown fallback report."""
|
||||||
|
fallback_report = self._generate_markdown_report(analysis_result)
|
||||||
|
enabled = self.config_manager.get_qq_official_t2i_summary_dashboard_enabled()
|
||||||
|
if not enabled or not callable(html_render_func) or self.html_templates is None:
|
||||||
|
return fallback_report, fallback_report
|
||||||
|
|
||||||
|
dashboard_url = await self._generate_summary_dashboard_url(
|
||||||
|
analysis_result, html_render_func
|
||||||
|
)
|
||||||
|
if not dashboard_url:
|
||||||
|
return fallback_report, fallback_report
|
||||||
|
return (
|
||||||
|
self._generate_markdown_report(
|
||||||
|
analysis_result, summary_dashboard_url=dashboard_url
|
||||||
|
),
|
||||||
|
fallback_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_summary_dashboard_url(
|
||||||
|
self, analysis_result: dict, html_render_func
|
||||||
|
) -> str | None:
|
||||||
|
stats = analysis_result["statistics"]
|
||||||
|
hourly_counts = self.get_hourly_counts(stats)
|
||||||
|
max_count = max(hourly_counts, default=0)
|
||||||
|
chart_data = [
|
||||||
|
{
|
||||||
|
"hour": f"{hour:02d}",
|
||||||
|
"count": count,
|
||||||
|
"height": (
|
||||||
|
max(2, round(count / max_count * 100))
|
||||||
|
if count > 0 and max_count > 0
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for hour, count in enumerate(hourly_counts)
|
||||||
|
]
|
||||||
|
metrics = [
|
||||||
|
{"value": self.format_metric(stats.message_count), "label": "消息"},
|
||||||
|
{"value": self.format_metric(stats.participant_count), "label": "参与"},
|
||||||
|
{"value": self.format_metric(stats.total_characters), "label": "字符"},
|
||||||
|
{"value": self.format_metric(stats.emoji_count), "label": "表情"},
|
||||||
|
{
|
||||||
|
"value": self.format_peak_period(stats.most_active_period),
|
||||||
|
"label": "高峰",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
html_content = self.html_templates.render_platform_template(
|
||||||
|
"qq_official",
|
||||||
|
"summary_dashboard.html",
|
||||||
|
report_title="群聊日常分析",
|
||||||
|
report_date=datetime.now().strftime("%Y.%m.%d"),
|
||||||
|
metrics=metrics,
|
||||||
|
chart_data=chart_data,
|
||||||
|
)
|
||||||
|
if not html_content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"type": "png",
|
||||||
|
"omit_background": True,
|
||||||
|
"full_page": False,
|
||||||
|
"clip": {"x": 0, "y": 0, "width": 800, "height": 360},
|
||||||
|
"animations": "disabled",
|
||||||
|
"caret": "hide",
|
||||||
|
"scale": "device",
|
||||||
|
"device_scale_factor_level": "high",
|
||||||
|
"timeout": 30000,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def render() -> str | None:
|
||||||
|
result = await html_render_func(html_content, {}, True, options)
|
||||||
|
url = str(result or "").strip()
|
||||||
|
if url.startswith(("http://", "https://")):
|
||||||
|
return url
|
||||||
|
logger.warning("[QQOfficial] T2I 概览图未返回可公开访问的 URL")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.render_semaphore is None:
|
||||||
|
return await render()
|
||||||
|
async with self.render_semaphore:
|
||||||
|
return await render()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[QQOfficial] T2I 群聊概览图生成失败: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _generate_markdown_report(
|
||||||
|
self, analysis_result: dict, summary_dashboard_url: str | None = None
|
||||||
|
) -> str:
|
||||||
|
stats = analysis_result["statistics"]
|
||||||
|
topics = analysis_result["topics"]
|
||||||
|
user_titles = analysis_result["user_titles"]
|
||||||
|
|
||||||
|
if summary_dashboard_url:
|
||||||
|
lines = [
|
||||||
|
f"",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
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.config_manager = config_manager
|
||||||
# 设置模板根目录
|
# 设置模板根目录
|
||||||
self.base_dir = os.path.join(os.path.dirname(__file__), "templates")
|
self.base_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||||
|
self.platform_base_dir = os.path.join(
|
||||||
|
os.path.dirname(__file__), "platform_templates"
|
||||||
|
)
|
||||||
# 缓存不同模板的Jinja2环境(多线程安全)
|
# 缓存不同模板的Jinja2环境(多线程安全)
|
||||||
self._envs = {}
|
self._envs = {}
|
||||||
self._env_lock = threading.Lock()
|
self._env_lock = threading.Lock()
|
||||||
@@ -73,6 +76,9 @@ class HTMLTemplates:
|
|||||||
try:
|
try:
|
||||||
env = await self._get_env_async()
|
env = await self._get_env_async()
|
||||||
template = env.get_template("image_template.html")
|
template = env.get_template("image_template.html")
|
||||||
|
if template.filename is None:
|
||||||
|
logger.error("图片模板路径为空")
|
||||||
|
return ""
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
self._read_template_file_sync, template.filename
|
self._read_template_file_sync, template.filename
|
||||||
)
|
)
|
||||||
@@ -85,6 +91,9 @@ class HTMLTemplates:
|
|||||||
try:
|
try:
|
||||||
env = self._get_env()
|
env = self._get_env()
|
||||||
template = env.get_template("image_template.html")
|
template = env.get_template("image_template.html")
|
||||||
|
if template.filename is None:
|
||||||
|
logger.error("图片模板路径为空")
|
||||||
|
return ""
|
||||||
with open(template.filename, encoding="utf-8") as f:
|
with open(template.filename, encoding="utf-8") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -108,3 +117,20 @@ class HTMLTemplates:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"渲染模板 {template_name} 失败: {e}")
|
logger.error(f"渲染模板 {template_name} 失败: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def render_platform_template(
|
||||||
|
self, platform_name: str, template_name: str, **kwargs
|
||||||
|
) -> str:
|
||||||
|
"""渲染与报告主题解耦的平台专用模板。"""
|
||||||
|
try:
|
||||||
|
template_dir = os.path.join(self.platform_base_dir, platform_name)
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(template_dir),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
return env.get_template(template_name).render(**kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"渲染平台模板 {platform_name}/{template_name} 失败: {e}")
|
||||||
|
return ""
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from collections import defaultdict
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ...domain.models.data_models import ActivityVisualization
|
from ...domain.models.data_models import ActivityVisualization
|
||||||
|
from ...domain.repositories.visualization_repository import IActivityVisualizer
|
||||||
|
|
||||||
|
|
||||||
class ActivityVisualizer:
|
class ActivityVisualizer(IActivityVisualizer):
|
||||||
"""活跃度可视化器"""
|
"""活跃度可视化器"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -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