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

* feat: add QQ official bot group analysis support

* fix: use text progress replies for QQ official bot

* fix: harden QQ official proactive reporting

* feat: enhance QQ official markdown reports

* refactor: isolate QQ official markdown reporting

* fix: restore platform adapter exports

* style: format QQ official changes

* fix(main): 添加 html_render 类型声明和 Callable 导入

为 self.html_render 属性添加显式类型声明,解决定时任务中可能因缺少类型注解导致的隐式错误。

* fix(analysis): 消除 execute_daily_analysis 中重复的 bot_self_ids 赋值

去除第二次冗余的 config_manager.get_bot_self_ids() 调用,直接复用已获取的变量。

* cleanup(domain): 移除未使用的分析器适配器和死代码服务文件

删除 golden_quote_analyzer.py、topic_analyzer.py、user_title_analyzer.py(均为未被引用的接口+适配器包装)report_generator.py(旧的文本报告生成器)statistics_calculator.py(与 StatisticsService 功能重复)更新 domain/services/__init__.py 移除对上述文件的引用。

* cleanup(domain): 移除冗余的实体和值对象文件,收敛数据模型至 domain/models/data_models.py

删除 analysis_result.py(与 data_models.py 重复的 SummaryTopic/UserTitle/GoldenQuote 等类定义)value_objects/golden_quote.py、statistics.py、topic.py、user_title.py(均为未被引用的 frozen dataclass 迁移残留)。所有活跃代码已统一导入 domain/models/data_models.py。

* refactor(llm): 抽取 _make_session_id 辅助方法消除重复代码

在 5 个方法(analyze_topics、analyze_user_titles、analyze_golden_quotes、analyze_all_concurrent、analyze_incremental_concurrent)中出现完全相同的 datetime.now().strftime(...) + umo 拼接逻辑,已提取为 _make_session_id 静态方法。

* refactor(message): 正则表达式提升为模块级常量避免重复编译

DISCORD_CUSTOM_EMOJI_PATTERN 和 COMMAND_PATTERN 从类属性移至模块级常量,避免每次实例化 MessageCleanerService 时重新编译正则。

* arch(domain): 定义 IActivityVisualizer 接口并通过依赖注入消除领域层反向依赖

创建 IActivityVisualizer 接口于 domain/repositories/visualization_repository.py,StatisticsService 改由依赖注入接收该接口;ActivityVisualizer 继承接口。消除原 StatisticsService 直接 import infrastructure.visualization 的 DDD 违规。

* fix(main): 补全 Callable 导入和 html_render 类型声明

初次提交(877adb6)因 git add -p 交互式分块时 BOM 导致错误的 hunk 被暂存,Callable 导入和 html_render 类型声明丢失。本次补全这两项修改。

* fix(platform): 补充 QQOfficialAdapter、TelegramAdapter、DiscordAdapter 导出

* refactor(main): 提取内嵌的文本报告生成/发送函数为独立类方法

将 _send_analysis_report 方法中的 generate_text_reports() 和 send_text_reports() 内嵌异步函数提取为 _generate_text_reports 和 _send_text_reports 私有方法。减少闭包复杂度,提升可维护性。

* cleanup(config): 移除废弃的 get_qq_official_t2i_activity_histogram_enabled 向后兼容方法

删除 config_manager.py 中的旧名别名方法,简化 qq_official_markdown.py 中对应的 getattr 回退逻辑为直接方法调用,移除测试中专门验证旧名兼容性的 LegacyDisabledConfig 测试用例。该兼容层仅在迁移期间临时存在,现已完成过渡。

* fix(types): 修复 Pylance 类型告警 — platform_key 未绑定、int(object) 和 template.filename 可能为 None

platform_group_registry.py: 将 platform_key 定义提前,消除 Pylance reportPossiblyUnboundVariable 告警。
qq_official_markdown.py: 将 int(value or 0) 改为 int(value) if value is not None else 0,消除 reportArgumentType 告警。
templates.py: 在使用 template.filename 前增加 None 检查,消除 str|None 不可分配给 str 的告警。
qq_official_adapter.py: 为 post_group_message 添加类型忽略注解,消除 await 不可等待对象的告警。

* fix(types): 修复残留的 Pylance 类型告警

platform_group_registry.py: 将 (platform_key, group_id) 改为 (str(platform_key), group_id),消除 str|None 不可分配给 str 的 reportArgumentType
qq_official_markdown.py: int(value) 添加 # type: ignore[arg-type],消除 object 不可分配给 ConvertibleToInt
qq_official_adapter.py: 移除无意义的中间变量,await 行直接添加 # type: ignore[arg-type]

* docs(message): 更新 MessageProcessingService 的文档和注释,消除 Telegram 特殊性表述

类 docstring:移除 '维护 Telegram 群组注册表' 等过时描述,补充 QQ 官方去重职责。
group_registry.upsert 注释:改为泛化的跨平台描述,不再限定 Telegram。
_extract_event_timestamp / _reserve_event_id / _commit_event_id / _release_event_id:英文 docstring 统一为中文。

* docs(message): 修正类注释中只提 QQ 官方的问题,明示 Telegram 也由本服务处理

Telegram 和 QQ 官方消息都经过 MessageProcessingService.process_message()。修正前类 docstring 只提了 QQ 官方的事件去重,缺少 Telegram 作为主要调用者的说明。

* fix(types): 修正 _sanitize_analysis_result_for_export 返回类型注解

函数声明 -> dict 但 _sanitize_export_identity_text 可返回 str|dict|list,Pylance 报 reportReturnType。
改为 -> dict[str, Any] 准确表达实际返回类型,补充缺失的 from typing import Any。

* fix(types): 抑制 Pylance reportReturnType 误报

_sanitize_analysis_result_for_export 的 analysis_result 参数运行时始终为 dict,但 _to_plain_export_data 递归返回 Any 导致 Pylance 推导出 str|dict|list 联合类型。
添加 # type: ignore[return-type] 抑制此误报。

* fix(report): 渲染匿名模式下的未知引用 token 不再静默丢弃

当 hide_user_names=True 且 [id] 不在 known_ids 中时,之前返回空 Markup 导致 token 被静默移除,可能扭曲文本语义。改为返回转义后的原始 [id] 字符串,保留文本布局和语义,同时不泄露身份信息。
参考 Sourcery AI code review 建议。

* fix(import): 避免从 astrbot.core 直接导入 File,优先使用公开 API

astrbot.core 是内部模块,不在公开 API 契约中,可能随版本变化。
改为 try 优先导入 astrbot.api.message_components.File(公开 API),
失败时回退到 astrbot.core.message.components.File(向后兼容)。
同时修复 main.py 和 qq_official_adapter.py 两处导入。

* fix(import): 回退 try/except 伪装,改为直接导入 + 风险注释

astrbot.api.message_components 不存在,try/except 永远走 except 分支,是无效代码。
改为直接 from astrbot.core.message.components import File,用注释说明这是内部 API 可能变化。

* fix(ruff): 代码质量

* docs(README): 调整文档说明,删除 lark 相关的描述和功能标识

* docs(desc): 更新 desc

* fix(message): 将 TG 和 QQ 官方消息缓存成功日志降为 debug

* perf(message): 将 _extract_event_timestamp 延迟到 QQ 官方分支内计算

该时间戳仅用于 QQ 官方消息的 history_content 元数据,但对所有平台都执行了深度 getattr 链。
改为只在 QQ 官方分支内延迟计算,消除 Telegram 等平台上每次消息的白算开销。

* chore(CHANGELOG)

---------

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