mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
[v4.11.1] - 🛠️ 修复 QQ 官方机器人昵称与提及清洗 (#207)
Fix QQ official nickname and mention handling
* fix: restore QQ official nicknames and sanitize mentions
* fix: protect QQ official identities and whitespace
* docs: update QQ official nickname guidance
* chore: bump version to v4.11.1
* docs: remove obsolete PDF guidance
* chore: finalize v4.11.1 release prep
* fix(qqofficial): 修复 _sanitize_qq_official_mentions .strip() 吞换行,补充 allow_alphanumeric_user_ids 注释
- .strip() → .strip(' \t'):仅清除首尾空白/制表符,保留用户创作的 \n 换行
- allow_alphanumeric_user_ids:在参数定义处补充注释,说明该标记还控制 ID 正则化和回退显示名
- 响应 Sourcery 代码审查 #207 指出的问题
* chore: 删除不必要的描述
---------
Co-authored-by: SXP-Simon <sxp20061207@163.com>
This commit is contained in:
+6
-1
@@ -1,12 +1,17 @@
|
|||||||
# 更新日志 (CHANGELOG)
|
# 更新日志 (CHANGELOG)
|
||||||
|
|
||||||
|
## [v4.11.1] - 🛠️ 修复 QQ 官方机器人昵称与提及清洗
|
||||||
|
|
||||||
|
* **🛠️ 成员昵称恢复**: QQ 官方群消息会保存并在图片、HTML 报告中展示事件提供的成员昵称;昵称缺失或旧历史记录仅保存 OpenID 时,使用群内稳定匿名名,避免直接展示 `member_openid`。
|
||||||
|
* **🛠️ 提及文本清洗**: 将消息中的成员提及转换为可读昵称,移除机器人自身提及,并保留用户消息原有的换行和段落格式。
|
||||||
|
|
||||||
## [v4.11.0] - ✨ 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145)
|
## [v4.11.0] - ✨ 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145)
|
||||||
|
|
||||||
插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。
|
插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。
|
||||||
|
|
||||||
* **🛠️配置注意事项 **: 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
* **🛠️配置注意事项 **: 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
||||||
* **🛠️ 适配范围**: 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
* **🛠️ 适配范围**: 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
||||||
* **⚙️ 成员昵称**: 官方群事件不提供成员昵称,所以推荐使用 QQ 官方机器人的用户配置输出格式为 text (默认为 image)格式,获得更好的体验,图片格式下无法显示成员昵称。
|
* **⚙️ 成员昵称**: 官方群事件提供有效昵称时,图片和 HTML 报告会正常显示昵称;昵称缺失或历史记录仅保存 OpenID 时,会使用群内稳定匿名名,避免直接展示 `member_openid`。文本报告继续使用成员艾特展示身份。
|
||||||
* **⚙️ QQ 官方 API 支持有限**: QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
* **⚙️ QQ 官方 API 支持有限**: QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
||||||
* **✨ 分析名单配置**: 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置黑白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
* **✨ 分析名单配置**: 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置黑白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
||||||
* **✨ Markdown 报告**: QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
* **✨ Markdown 报告**: QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# 群聊日常分析插件
|
# 群聊日常分析插件
|
||||||
|
|
||||||
[](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
|
[](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
|
||||||
[](https://github.com/AstrBotDevs/AstrBot)
|
[](https://github.com/AstrBotDevs/AstrBot)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://deepwiki.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis)
|
[](https://deepwiki.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis)
|
||||||
@@ -105,7 +105,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCa
|
|||||||
> 3. **稳定性**:上方地址为维护者提供 T2I 服务(国外网络环境),在一段时间内大概率稳定,但不保证长期有效,如果自己不想部署可以使用。
|
> 3. **稳定性**:上方地址为维护者提供 T2I 服务(国外网络环境),在一段时间内大概率稳定,但不保证长期有效,如果自己不想部署可以使用。
|
||||||
> 4. **休眠保活**:由于免费空间若长时间(约 48 小时)无人访问会进入休眠。可选择使用保活服务(如 [UptimeRobot](https://uptimerobot.com/))定期访问 API 地址以保持其处于唤醒状态。
|
> 4. **休眠保活**:由于免费空间若长时间(约 48 小时)无人访问会进入休眠。可选择使用保活服务(如 [UptimeRobot](https://uptimerobot.com/))定期访问 API 地址以保持其处于唤醒状态。
|
||||||
>
|
>
|
||||||
> - **国内加速 (CF 代理)**: `https://t2i.vercel.ciallo.de5.net`
|
> - **国内加速**: `https://t2i.vercel.ciallo.de5.net`
|
||||||
> - **说明**: 在国内直接访问原始域名下载图片可能较慢,可选择使用此代理域名。在一段时间内大概率稳定。
|
> - **说明**: 在国内直接访问原始域名下载图片可能较慢,可选择使用此代理域名。在一段时间内大概率稳定。
|
||||||
> </details>
|
> </details>
|
||||||
>
|
>
|
||||||
@@ -121,7 +121,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCa
|
|||||||
> - 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
> - 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。
|
||||||
> - QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
> - QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。
|
||||||
> - 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
> - 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。
|
||||||
> - 官方群事件不提供成员昵称。
|
> - 官方群事件提供有效昵称时会用于报告;昵称缺失时使用群内稳定匿名名,避免直接展示 `member_openid`。
|
||||||
> - QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
> - QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。
|
||||||
> - Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。
|
> - Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。
|
||||||
> - 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
> - 本次适配只覆盖普通 QQ 群,不包含频道或子频道。
|
||||||
@@ -270,7 +270,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCa
|
|||||||
| 平台 | 适配器类型 | 特殊要求/说明 |
|
| 平台 | 适配器类型 | 特殊要求/说明 |
|
||||||
|------|-----------|--------------|
|
|------|-----------|--------------|
|
||||||
| **QQ** | OneBot v11 | 建议使用 NapCat/Lagrange。需注意消息分页拉取限制。 |
|
| **QQ** | OneBot v11 | 建议使用 NapCat/Lagrange。需注意消息分页拉取限制。 |
|
||||||
| **QQ 官方机器人** | QQ Bot API v2(WebSocket/Webhook) | 需开启群全量消息;只分析启用后实时缓存的消息;图片/HTML 仅显示头像,Markdown 文本使用成员艾特。 |
|
| **QQ 官方机器人** | QQ Bot API v2(WebSocket/Webhook) | 需开启群全量消息;只分析启用后实时缓存的消息;图片/HTML 优先显示事件昵称,缺失时使用群内稳定匿名名;Markdown 文本使用成员艾特。 |
|
||||||
| **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 |
|
| **Discord** | Discord | **必须** 拥有 `Read Message History` (查看消息历史记录) 权限。 |
|
||||||
| **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 |
|
| **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@
|
|||||||
"html"
|
"html"
|
||||||
],
|
],
|
||||||
"default": "image",
|
"default": "image",
|
||||||
"hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方群事件没有昵称,图片/HTML 报告仅显示头像,文本报告由独立 Markdown 模块使用艾特展示身份;其他平台保持原有昵称文本格式。"
|
"hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方图片/HTML 报告优先显示事件昵称,昵称缺失时使用群内稳定匿名名;文本报告由独立 Markdown 模块使用成员艾特展示身份。其他平台保持原有昵称文本格式。"
|
||||||
},
|
},
|
||||||
"report_template": {
|
"report_template": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
analysis_result = result["analysis_result"]
|
analysis_result = result["analysis_result"]
|
||||||
adapter = result["adapter"]
|
adapter = result["adapter"]
|
||||||
output_format = self.config_manager.get_output_format()
|
output_format = self.config_manager.get_output_format()
|
||||||
hide_user_names = adapter.get_platform_name() == "qq_official"
|
is_qq_official = adapter.get_platform_name() == "qq_official"
|
||||||
|
|
||||||
# 定义获取回调
|
# 定义获取回调
|
||||||
async def avatar_url_getter(user_id: str) -> str | None:
|
async def avatar_url_getter(user_id: str) -> str | None:
|
||||||
@@ -645,7 +645,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
hide_user_names=hide_user_names,
|
allow_alphanumeric_user_ids=is_qq_official,
|
||||||
)
|
)
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
@@ -658,7 +658,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
# 如果图片生成或发送失败,直接回退到文本
|
# 如果图片生成或发送失败,直接回退到文本
|
||||||
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
|
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
|
||||||
await self._send_text_reports(
|
await self._send_text_reports(
|
||||||
group_id, analysis_result, hide_user_names, adapter
|
group_id, analysis_result, is_qq_official, adapter
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -669,7 +669,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
hide_user_names=hide_user_names,
|
allow_alphanumeric_user_ids=is_qq_official,
|
||||||
)
|
)
|
||||||
if html_path:
|
if html_path:
|
||||||
is_only_url = self.config_manager.get_html_only_url()
|
is_only_url = self.config_manager.get_html_only_url()
|
||||||
@@ -731,25 +731,31 @@ class GroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
await self._send_text_reports(
|
await self._send_text_reports(
|
||||||
group_id, analysis_result, hide_user_names, adapter
|
group_id, analysis_result, is_qq_official, adapter
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _generate_text_reports(
|
async def _generate_text_reports(
|
||||||
self, analysis_result: dict, hide_user_names: bool
|
self, analysis_result: dict, use_qq_official_markdown: bool
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[str, str | None]:
|
||||||
"""Generate text or QQ-official-markdown reports."""
|
"""Generate text or QQ-official-markdown reports."""
|
||||||
if hide_user_names:
|
if use_qq_official_markdown:
|
||||||
return await self.report_generator.generate_qq_official_markdown_report(
|
return await self.report_generator.generate_qq_official_markdown_report(
|
||||||
analysis_result, self.html_render
|
analysis_result, self.html_render
|
||||||
)
|
)
|
||||||
return self.report_generator.generate_text_report(analysis_result), None
|
return self.report_generator.generate_text_report(analysis_result), None
|
||||||
|
|
||||||
async def _send_text_reports(
|
async def _send_text_reports(
|
||||||
self, group_id: str, analysis_result: dict, hide_user_names: bool, adapter
|
self,
|
||||||
|
group_id: str,
|
||||||
|
analysis_result: dict,
|
||||||
|
use_qq_official_markdown: bool,
|
||||||
|
adapter,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send text reports via platform adapter."""
|
"""Send text reports via platform adapter."""
|
||||||
tr, fr = await self._generate_text_reports(analysis_result, hide_user_names)
|
tr, fr = await self._generate_text_reports(
|
||||||
if hide_user_names:
|
analysis_result, use_qq_official_markdown
|
||||||
|
)
|
||||||
|
if use_qq_official_markdown:
|
||||||
return await adapter.send_text_report(group_id, tr, fallback_content=fr)
|
return await adapter.send_text_report(group_id, tr, fallback_content=fr)
|
||||||
return await adapter.send_text_report(group_id, tr)
|
return await adapter.send_text_report(group_id, tr)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。
|
name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。
|
||||||
display_name: 群分析总结插件 # 插件的显示名称
|
display_name: 群分析总结插件 # 插件的显示名称
|
||||||
desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
|
desc: "群日常分析总结插件 - 支持 OneBot (NapCat, LLOneBot, Snowluma)、QQ 官方机器人、Telegram、Discord;生成精美的群聊分析报告,支持话题分析、用户形象、群聊圣经等功能" # 插件简短描述
|
||||||
version: v4.11.0 # 插件版本号。格式:v1.1.1 或者 v1.1
|
version: v4.11.1 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||||
author: SXP-Simon # 作者
|
author: SXP-Simon # 作者
|
||||||
astrbot_version: ">=4.16.0"
|
astrbot_version: ">=4.16.0"
|
||||||
support_platforms:
|
support_platforms:
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ from astrbot.api.star import Context
|
|||||||
from ...infrastructure.persistence.platform_group_registry import PlatformGroupRegistry
|
from ...infrastructure.persistence.platform_group_registry import PlatformGroupRegistry
|
||||||
from ...utils.logger import logger
|
from ...utils.logger import logger
|
||||||
|
|
||||||
|
_QQ_OFFICIAL_PLATFORM_NAMES = frozenset({"qq_official", "qq_official_webhook"})
|
||||||
|
_QQ_OFFICIAL_MENTION_PATTERN = re.compile(r"<@!?([A-Za-z0-9_-]+)>")
|
||||||
|
|
||||||
|
|
||||||
class MessageProcessingService:
|
class MessageProcessingService:
|
||||||
"""
|
"""
|
||||||
@@ -187,6 +190,12 @@ class MessageProcessingService:
|
|||||||
"""从事件中提取消息内容"""
|
"""从事件中提取消息内容"""
|
||||||
message_parts = []
|
message_parts = []
|
||||||
message = event.message_obj
|
message = event.message_obj
|
||||||
|
platform_name = str(event.get_platform_name() or "").strip().lower()
|
||||||
|
qq_mention_replacements = (
|
||||||
|
self._extract_qq_official_mention_replacements(event)
|
||||||
|
if platform_name in _QQ_OFFICIAL_PLATFORM_NAMES
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
# 收集 @ 标记
|
# 收集 @ 标记
|
||||||
pending_mentions: Counter[str] = Counter()
|
pending_mentions: Counter[str] = Counter()
|
||||||
@@ -221,6 +230,10 @@ class MessageProcessingService:
|
|||||||
text = seg.data.get("text")
|
text = seg.data.get("text")
|
||||||
if text:
|
if text:
|
||||||
text = self._strip_known_mentions(text, pending_mentions)
|
text = self._strip_known_mentions(text, pending_mentions)
|
||||||
|
if qq_mention_replacements is not None:
|
||||||
|
text = self._sanitize_qq_official_mentions(
|
||||||
|
text, qq_mention_replacements
|
||||||
|
)
|
||||||
message_parts.append({"type": "plain", "text": text})
|
message_parts.append({"type": "plain", "text": text})
|
||||||
|
|
||||||
elif seg_type in ("Image", "image"):
|
elif seg_type in ("Image", "image"):
|
||||||
@@ -262,7 +275,12 @@ class MessageProcessingService:
|
|||||||
message_parts.append({"type": "video", "url": str(url or "")})
|
message_parts.append({"type": "video", "url": str(url or "")})
|
||||||
|
|
||||||
if not message_parts and event.message_str:
|
if not message_parts and event.message_str:
|
||||||
message_parts.append({"type": "plain", "text": event.message_str})
|
fallback_text = str(event.message_str)
|
||||||
|
if qq_mention_replacements is not None:
|
||||||
|
fallback_text = self._sanitize_qq_official_mentions(
|
||||||
|
fallback_text, qq_mention_replacements
|
||||||
|
)
|
||||||
|
message_parts.append({"type": "plain", "text": fallback_text})
|
||||||
|
|
||||||
# 清理空文本段
|
# 清理空文本段
|
||||||
message_parts = [
|
message_parts = [
|
||||||
@@ -275,6 +293,81 @@ class MessageProcessingService:
|
|||||||
|
|
||||||
return message_parts
|
return message_parts
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_qq_official_mention_replacements(
|
||||||
|
cls, event: AstrMessageEvent
|
||||||
|
) -> dict[str, str]:
|
||||||
|
message_obj = getattr(event, "message_obj", None)
|
||||||
|
raw_message = getattr(message_obj, "raw_message", None)
|
||||||
|
raw_candidates = [raw_message]
|
||||||
|
nested_message = cls._read_field(raw_message, "message")
|
||||||
|
if nested_message is not None and nested_message is not raw_message:
|
||||||
|
raw_candidates.insert(0, nested_message)
|
||||||
|
|
||||||
|
mentions = None
|
||||||
|
for candidate in raw_candidates:
|
||||||
|
mentions = cls._read_field(candidate, "mentions")
|
||||||
|
if mentions is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
replacements: dict[str, str] = {}
|
||||||
|
if not isinstance(mentions, (list, tuple)):
|
||||||
|
return replacements
|
||||||
|
|
||||||
|
for mention in mentions:
|
||||||
|
mention_id = str(
|
||||||
|
cls._read_field(
|
||||||
|
mention,
|
||||||
|
"id",
|
||||||
|
"member_openid",
|
||||||
|
"memberopenid",
|
||||||
|
"user_openid",
|
||||||
|
"useropenid",
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not mention_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cls._read_field(mention, "is_you") is True:
|
||||||
|
replacements[mention_id] = ""
|
||||||
|
continue
|
||||||
|
|
||||||
|
display_name = str(
|
||||||
|
cls._read_field(mention, "username", "name", "nickname") or ""
|
||||||
|
).strip()
|
||||||
|
display_name = display_name.lstrip("@").strip()
|
||||||
|
if cls._is_placeholder_sender_name(display_name, mention_id):
|
||||||
|
display_name = "群友"
|
||||||
|
replacements[mention_id] = f"@{display_name}"
|
||||||
|
|
||||||
|
return replacements
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_qq_official_mentions(text: str, replacements: dict[str, str]) -> str:
|
||||||
|
def replace_mention(match: re.Match[str]) -> str:
|
||||||
|
mention_id = match.group(1)
|
||||||
|
if mention_id.lower() in {"all", "everyone"}:
|
||||||
|
return "@全体成员"
|
||||||
|
return replacements.get(mention_id, "@群友")
|
||||||
|
|
||||||
|
cleaned = _QQ_OFFICIAL_MENTION_PATTERN.sub(replace_mention, str(text))
|
||||||
|
return re.sub(r"[^\S\r\n]{2,}", " ", cleaned).strip(" \t")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_field(source: object, *names: str) -> object | None:
|
||||||
|
if isinstance(source, dict):
|
||||||
|
for name in names:
|
||||||
|
if name in source:
|
||||||
|
return source[name]
|
||||||
|
return None
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
value = getattr(source, name, None)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_known_mentions(text: str, pending_mentions: Counter[str]) -> str:
|
def _strip_known_mentions(text: str, pending_mentions: Counter[str]) -> str:
|
||||||
"""从文本中移除已识别的 @ 提及"""
|
"""从文本中移除已识别的 @ 提及"""
|
||||||
@@ -299,7 +392,7 @@ class MessageProcessingService:
|
|||||||
if pending_mentions[mention] <= 0:
|
if pending_mentions[mention] <= 0:
|
||||||
pending_mentions.pop(mention, None)
|
pending_mentions.pop(mention, None)
|
||||||
|
|
||||||
return re.sub(r"\s{2,}", " ", cleaned).strip()
|
return re.sub(r"[^\S\r\n]{2,}", " ", cleaned).strip()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool:
|
def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class IReportGenerator(ABC):
|
|||||||
nickname_getter: Any = None,
|
nickname_getter: Any = None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
hide_user_names: bool = False,
|
hide_user_names: bool = False,
|
||||||
|
allow_alphanumeric_user_ids: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""生成图片报告"""
|
"""生成图片报告"""
|
||||||
pass
|
pass
|
||||||
@@ -35,6 +36,7 @@ class IReportGenerator(ABC):
|
|||||||
nickname_getter: Any = None,
|
nickname_getter: Any = None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
hide_user_names: bool = False,
|
hide_user_names: bool = False,
|
||||||
|
allow_alphanumeric_user_ids: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""生成 HTML 报告"""
|
"""生成 HTML 报告"""
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
@@ -62,6 +63,25 @@ class QQOfficialAdapter(PlatformAdapter):
|
|||||||
return str(platform_config.get("appid", "") or "").strip()
|
return str(platform_config.get("appid", "") or "").strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool:
|
||||||
|
normalized = str(name or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return True
|
||||||
|
if normalized.lower() in {"unknown", "none", "null", "nil", "undefined"}:
|
||||||
|
return True
|
||||||
|
return normalized == str(sender_id).strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_history_sender_name(
|
||||||
|
cls, sender_name: str | None, sender_id: str, group_id: str
|
||||||
|
) -> str:
|
||||||
|
normalized = str(sender_name or "").strip()
|
||||||
|
if not cls._is_placeholder_sender_name(normalized, sender_id):
|
||||||
|
return normalized
|
||||||
|
digest = hashlib.sha256(f"{group_id}\0{sender_id}".encode()).hexdigest()[:8]
|
||||||
|
return f"群友-{digest.upper()}"
|
||||||
|
|
||||||
def set_context(self, context: Context) -> None:
|
def set_context(self, context: Context) -> None:
|
||||||
self._context = context
|
self._context = context
|
||||||
|
|
||||||
@@ -222,11 +242,14 @@ class QQOfficialAdapter(PlatformAdapter):
|
|||||||
sender_id = str(getattr(record, "sender_id", "") or "")
|
sender_id = str(getattr(record, "sender_id", "") or "")
|
||||||
if not sender_id:
|
if not sender_id:
|
||||||
return None
|
return None
|
||||||
|
sender_name = self._resolve_history_sender_name(
|
||||||
|
getattr(record, "sender_name", None), sender_id, group_id
|
||||||
|
)
|
||||||
|
|
||||||
return UnifiedMessage(
|
return UnifiedMessage(
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
sender_name=sender_id,
|
sender_name=sender_name,
|
||||||
sender_card=None,
|
sender_card=None,
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
text_content="".join(text_parts),
|
text_content="".join(text_parts),
|
||||||
@@ -256,7 +279,7 @@ class QQOfficialAdapter(PlatformAdapter):
|
|||||||
"group_id": message.group_id,
|
"group_id": message.group_id,
|
||||||
"sender": {
|
"sender": {
|
||||||
"user_id": message.sender_id,
|
"user_id": message.sender_id,
|
||||||
"nickname": message.sender_id,
|
"nickname": message.sender_name or message.sender_id,
|
||||||
"card": "",
|
"card": "",
|
||||||
},
|
},
|
||||||
"message": chain,
|
"message": chain,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ReportDispatcher:
|
|||||||
"""设置 HTML 渲染函数 (运行时注入)"""
|
"""设置 HTML 渲染函数 (运行时注入)"""
|
||||||
self._html_render_func = render_func
|
self._html_render_func = render_func
|
||||||
|
|
||||||
def _hide_user_names(self, platform_id: str | None) -> bool:
|
def _is_qq_official(self, platform_id: str | None) -> bool:
|
||||||
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||||
return bool(adapter and adapter.get_platform_name() == "qq_official")
|
return bool(adapter and adapter.get_platform_name() == "qq_official")
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ class ReportDispatcher:
|
|||||||
self._html_render_func,
|
self._html_render_func,
|
||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
hide_user_names=self._hide_user_names(platform_id),
|
allow_alphanumeric_user_ids=self._is_qq_official(platform_id),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{trace_id}] Failed to generate image report: {e}")
|
logger.error(f"[{trace_id}] Failed to generate image report: {e}")
|
||||||
@@ -140,7 +140,7 @@ class ReportDispatcher:
|
|||||||
group_id,
|
group_id,
|
||||||
avatar_url_getter=avatar_url_getter,
|
avatar_url_getter=avatar_url_getter,
|
||||||
avatar_cache_namespace=platform_id,
|
avatar_cache_namespace=platform_id,
|
||||||
hide_user_names=self._hide_user_names(platform_id),
|
allow_alphanumeric_user_ids=self._is_qq_official(platform_id),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
|
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
|
||||||
@@ -202,7 +202,7 @@ class ReportDispatcher:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""分发文本报告"""
|
"""分发文本报告"""
|
||||||
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告")
|
logger.info(f"[分发器] 正在向群组 {group_id} 分发文本报告")
|
||||||
is_qq_official = self._hide_user_names(platform_id)
|
is_qq_official = self._is_qq_official(platform_id)
|
||||||
fallback_report = None
|
fallback_report = None
|
||||||
if is_qq_official:
|
if is_qq_official:
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -345,6 +345,8 @@ class ReportGenerator(IReportGenerator):
|
|||||||
nickname_getter=None,
|
nickname_getter=None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
hide_user_names: bool = False,
|
hide_user_names: bool = False,
|
||||||
|
# Also controls ID normalization and fallback display name ("群友").
|
||||||
|
allow_alphanumeric_user_ids: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
生成图片格式的分析报告
|
生成图片格式的分析报告
|
||||||
@@ -369,6 +371,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=avatar_cache_namespace,
|
avatar_cache_namespace=avatar_cache_namespace,
|
||||||
hide_user_names=hide_user_names,
|
hide_user_names=hide_user_names,
|
||||||
|
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
||||||
@@ -511,6 +514,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
nickname_getter=None,
|
nickname_getter=None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
hide_user_names: bool = False,
|
hide_user_names: bool = False,
|
||||||
|
allow_alphanumeric_user_ids: bool = False,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
生成HTML格式的分析报告,保存到指定目录
|
生成HTML格式的分析报告,保存到指定目录
|
||||||
@@ -556,6 +560,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
nickname_getter=nickname_getter,
|
nickname_getter=nickname_getter,
|
||||||
avatar_cache_namespace=avatar_cache_namespace,
|
avatar_cache_namespace=avatar_cache_namespace,
|
||||||
hide_user_names=hide_user_names,
|
hide_user_names=hide_user_names,
|
||||||
|
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
|
||||||
)
|
)
|
||||||
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||||
|
|
||||||
@@ -617,7 +622,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
json_data = {
|
json_data = {
|
||||||
"analysis_result": (
|
"analysis_result": (
|
||||||
self._sanitize_analysis_result_for_export(analysis_result)
|
self._sanitize_analysis_result_for_export(analysis_result)
|
||||||
if hide_user_names
|
if hide_user_names or allow_alphanumeric_user_ids
|
||||||
else analysis_result
|
else analysis_result
|
||||||
),
|
),
|
||||||
"group_id": group_id,
|
"group_id": group_id,
|
||||||
@@ -795,6 +800,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
nickname_getter=None,
|
nickname_getter=None,
|
||||||
avatar_cache_namespace: str | None = None,
|
avatar_cache_namespace: str | None = None,
|
||||||
hide_user_names: bool = False,
|
hide_user_names: bool = False,
|
||||||
|
allow_alphanumeric_user_ids: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""准备渲染数据"""
|
"""准备渲染数据"""
|
||||||
stats = analysis_result["statistics"]
|
stats = analysis_result["statistics"]
|
||||||
@@ -820,6 +826,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_reuse_registry,
|
avatar_reuse_registry,
|
||||||
avatar_reuse_aliases,
|
avatar_reuse_aliases,
|
||||||
hide_user_names=hide_user_names,
|
hide_user_names=hide_user_names,
|
||||||
|
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
|
||||||
)
|
)
|
||||||
if hide_user_names:
|
if hide_user_names:
|
||||||
contributors = await self._render_avatar_only_ids(
|
contributors = await self._render_avatar_only_ids(
|
||||||
@@ -889,6 +896,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_reuse_registry,
|
avatar_reuse_registry,
|
||||||
avatar_reuse_aliases,
|
avatar_reuse_aliases,
|
||||||
hide_user_names=True,
|
hide_user_names=True,
|
||||||
|
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
|
||||||
)
|
)
|
||||||
title_data = {
|
title_data = {
|
||||||
"name": "" if hide_user_names else title.name,
|
"name": "" if hide_user_names else title.name,
|
||||||
@@ -938,6 +946,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_reuse_registry,
|
avatar_reuse_registry,
|
||||||
avatar_reuse_aliases,
|
avatar_reuse_aliases,
|
||||||
hide_user_names=hide_user_names,
|
hide_user_names=hide_user_names,
|
||||||
|
allow_alphanumeric_user_ids=allow_alphanumeric_user_ids,
|
||||||
)
|
)
|
||||||
quotes_list.append(
|
quotes_list.append(
|
||||||
{
|
{
|
||||||
@@ -1110,6 +1119,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
avatar_reuse_registry: dict[str, str] | None = None,
|
avatar_reuse_registry: dict[str, str] | None = None,
|
||||||
avatar_reuse_aliases: dict[str, str] | None = None,
|
avatar_reuse_aliases: dict[str, str] | None = None,
|
||||||
hide_user_names: bool = False,
|
hide_user_names: bool = False,
|
||||||
|
allow_alphanumeric_user_ids: bool = False,
|
||||||
) -> Markup:
|
) -> Markup:
|
||||||
"""
|
"""
|
||||||
处理文本,将 [用户ID] 格式的引用替换为头像胶囊。
|
处理文本,将 [用户ID] 格式的引用替换为头像胶囊。
|
||||||
@@ -1123,9 +1133,9 @@ class ReportGenerator(IReportGenerator):
|
|||||||
if str(user_id).strip()
|
if str(user_id).strip()
|
||||||
}
|
}
|
||||||
source_text = str(text)
|
source_text = str(text)
|
||||||
if hide_user_names:
|
supports_extended_ids = hide_user_names or allow_alphanumeric_user_ids
|
||||||
# LLM 偶尔会直接输出 ID;在头像-only 模式下先标准化为引用,
|
if supports_extended_ids:
|
||||||
# 避免 member_openid 以明文形式泄露。
|
# LLM 偶尔会直接输出 ID;先标准化为引用,避免 OpenID 以明文形式显示。
|
||||||
for user_id in sorted(known_ids, key=len, reverse=True):
|
for user_id in sorted(known_ids, key=len, reverse=True):
|
||||||
source_text = re.sub(
|
source_text = re.sub(
|
||||||
rf"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}"
|
rf"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}"
|
||||||
@@ -1134,7 +1144,9 @@ class ReportGenerator(IReportGenerator):
|
|||||||
source_text,
|
source_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
pattern = r"\[([A-Za-z0-9_-]{1,128})\]" if hide_user_names else r"\[(\d+)\]"
|
pattern = (
|
||||||
|
r"\[([A-Za-z0-9_-]{1,128})\]" if supports_extended_ids else r"\[(\d+)\]"
|
||||||
|
)
|
||||||
|
|
||||||
matches = list(re.finditer(pattern, source_text))
|
matches = list(re.finditer(pattern, source_text))
|
||||||
if not matches:
|
if not matches:
|
||||||
@@ -1142,7 +1154,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
|
|
||||||
async def render_capsule(match: re.Match[str]) -> Markup:
|
async def render_capsule(match: re.Match[str]) -> Markup:
|
||||||
uid = match.group(1)
|
uid = match.group(1)
|
||||||
if hide_user_names and uid not in known_ids:
|
if supports_extended_ids and uid not in known_ids:
|
||||||
return Markup(html.escape(f"[{uid}]", quote=True))
|
return Markup(html.escape(f"[{uid}]", quote=True))
|
||||||
url = await self._get_user_avatar(
|
url = await self._get_user_avatar(
|
||||||
uid, avatar_url_getter, avatar_cache_namespace
|
uid, avatar_url_getter, avatar_cache_namespace
|
||||||
@@ -1182,7 +1194,7 @@ class ReportGenerator(IReportGenerator):
|
|||||||
final_name = (
|
final_name = (
|
||||||
name
|
name
|
||||||
if (name and not self._is_placeholder_display_name(name, uid))
|
if (name and not self._is_placeholder_display_name(name, uid))
|
||||||
else str(uid)
|
else ("群友" if allow_alphanumeric_user_ids else str(uid))
|
||||||
)
|
)
|
||||||
|
|
||||||
avatar_ref = self._register_reusable_avatar(
|
avatar_ref = self._register_reusable_avatar(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from src.domain.models.data_models import (
|
from src.domain.models.data_models import (
|
||||||
@@ -465,6 +466,48 @@ def test_non_qq_avatar_mentions_ignore_alphanumeric_bracket_text():
|
|||||||
assert "user-capsule" not in rendered_text
|
assert "user-capsule" not in rendered_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_official_avatar_mentions_render_alphanumeric_id_with_nickname():
|
||||||
|
generator = build_generator_without_io()
|
||||||
|
|
||||||
|
async def fake_avatar(*args, **kwargs):
|
||||||
|
return "data:image/png;base64,AAAA"
|
||||||
|
|
||||||
|
generator._get_user_avatar = fake_avatar
|
||||||
|
rendered = asyncio.run(
|
||||||
|
generator._render_mentions(
|
||||||
|
"成员 [A_OPENID] 发言",
|
||||||
|
avatar_url_getter=None,
|
||||||
|
user_analysis={"A_OPENID": {"nickname": "测试用户"}},
|
||||||
|
allow_alphanumeric_user_ids=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered_text = str(rendered)
|
||||||
|
assert "测试用户" in rendered_text
|
||||||
|
assert "[A_OPENID]" not in rendered_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_official_avatar_mentions_hide_placeholder_openid():
|
||||||
|
generator = build_generator_without_io()
|
||||||
|
|
||||||
|
async def fake_avatar(*args, **kwargs):
|
||||||
|
return "data:image/png;base64,AAAA"
|
||||||
|
|
||||||
|
generator._get_user_avatar = fake_avatar
|
||||||
|
rendered = asyncio.run(
|
||||||
|
generator._render_mentions(
|
||||||
|
"成员 A_OPENID 发言",
|
||||||
|
avatar_url_getter=None,
|
||||||
|
user_analysis={"A_OPENID": {"nickname": "A_OPENID"}},
|
||||||
|
allow_alphanumeric_user_ids=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered_text = str(rendered)
|
||||||
|
assert "A_OPENID" not in rendered_text
|
||||||
|
assert "群友" in rendered_text
|
||||||
|
|
||||||
|
|
||||||
def test_mentions_support_alphanumeric_openid_and_hide_text():
|
def test_mentions_support_alphanumeric_openid_and_hide_text():
|
||||||
generator = build_generator_without_io()
|
generator = build_generator_without_io()
|
||||||
openid = "A1B2C3D4_OPENID"
|
openid = "A1B2C3D4_OPENID"
|
||||||
@@ -546,3 +589,47 @@ def test_html_sidecar_export_removes_nested_identity_values():
|
|||||||
assert (
|
assert (
|
||||||
sanitized["statistics"]["activity_visualization"]["user_activity_ranking"] == []
|
sanitized["statistics"]["activity_visualization"]["user_activity_ranking"] == []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_official_html_sidecar_uses_identity_sanitizer(tmp_path):
|
||||||
|
class HtmlConfig(FakeConfig):
|
||||||
|
def get_html_output_dir(self):
|
||||||
|
return str(tmp_path)
|
||||||
|
|
||||||
|
def get_html_filename_format(self):
|
||||||
|
return "report.html"
|
||||||
|
|
||||||
|
generator = build_generator_without_io()
|
||||||
|
generator.config_manager = HtmlConfig()
|
||||||
|
generator.html_templates = SimpleNamespace(
|
||||||
|
render_template=lambda *args, **kwargs: "<html>safe</html>"
|
||||||
|
)
|
||||||
|
generator._reuse_avatars_in_final_html = lambda html_content, *args: html_content
|
||||||
|
|
||||||
|
async def fake_prepare_render_data(*args, **kwargs):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
generator._prepare_render_data = fake_prepare_render_data
|
||||||
|
openid = "A1B2C3D4_OPENID"
|
||||||
|
analysis_result = {
|
||||||
|
"statistics": {
|
||||||
|
"golden_quotes": [],
|
||||||
|
"activity_visualization": {"user_activity_ranking": [{"user_id": openid}]},
|
||||||
|
},
|
||||||
|
"topics": [],
|
||||||
|
"user_titles": [],
|
||||||
|
"user_analysis": {openid: {"nickname": "测试群友"}},
|
||||||
|
"summary": f"{openid} 最活跃",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, json_path = asyncio.run(
|
||||||
|
generator.generate_html_report(
|
||||||
|
analysis_result,
|
||||||
|
"GROUP_OPENID",
|
||||||
|
allow_alphanumeric_user_ids=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json_path is not None
|
||||||
|
exported = Path(json_path).read_text(encoding="utf-8")
|
||||||
|
assert openid not in exported
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class FakeHistoryManager:
|
|||||||
raise RuntimeError("temporary database failure")
|
raise RuntimeError("temporary database failure")
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingHistoryManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def insert(self, **kwargs):
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
|
||||||
|
|
||||||
class FakeGroupRegistry:
|
class FakeGroupRegistry:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.upsert_calls = 0
|
self.upsert_calls = 0
|
||||||
@@ -27,14 +35,28 @@ class FakeGroupRegistry:
|
|||||||
|
|
||||||
|
|
||||||
class FakeOfficialEvent:
|
class FakeOfficialEvent:
|
||||||
def __init__(self):
|
def __init__(
|
||||||
|
self,
|
||||||
|
text="hello",
|
||||||
|
mentions=None,
|
||||||
|
platform_name="qq_official",
|
||||||
|
at_target=None,
|
||||||
|
):
|
||||||
|
self.platform_name = platform_name
|
||||||
|
message = []
|
||||||
|
if at_target:
|
||||||
|
message.append(SimpleNamespace(type="At", qq=at_target, name=""))
|
||||||
|
message.append(SimpleNamespace(type="Plain", text=text))
|
||||||
self.message_obj = SimpleNamespace(
|
self.message_obj = SimpleNamespace(
|
||||||
message_id="OFFICIAL-MSG-1",
|
message_id="OFFICIAL-MSG-1",
|
||||||
raw_message=SimpleNamespace(timestamp=1710000000),
|
raw_message=SimpleNamespace(
|
||||||
|
timestamp=1710000000,
|
||||||
|
mentions=list(mentions or []),
|
||||||
|
),
|
||||||
sender=SimpleNamespace(nickname=""),
|
sender=SimpleNamespace(nickname=""),
|
||||||
message=[SimpleNamespace(type="Plain", text="hello")],
|
message=message,
|
||||||
)
|
)
|
||||||
self.message_str = "hello"
|
self.message_str = text
|
||||||
|
|
||||||
def get_group_id(self):
|
def get_group_id(self):
|
||||||
return "GROUP_OPENID"
|
return "GROUP_OPENID"
|
||||||
@@ -49,7 +71,7 @@ class FakeOfficialEvent:
|
|||||||
return "official-main"
|
return "official-main"
|
||||||
|
|
||||||
def get_platform_name(self):
|
def get_platform_name(self):
|
||||||
return "qq_official"
|
return self.platform_name
|
||||||
|
|
||||||
|
|
||||||
def test_failed_history_insert_releases_official_message_id():
|
def test_failed_history_insert_releases_official_message_id():
|
||||||
@@ -68,3 +90,109 @@ def test_failed_history_insert_releases_official_message_id():
|
|||||||
|
|
||||||
assert history_manager.insert_calls == 2
|
assert history_manager.insert_calls == 2
|
||||||
assert registry.upsert_calls == 1
|
assert registry.upsert_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_qq_official_message_replaces_mentions_before_storage():
|
||||||
|
history_manager = RecordingHistoryManager()
|
||||||
|
registry = FakeGroupRegistry()
|
||||||
|
service = MessageProcessingService(
|
||||||
|
SimpleNamespace(message_history_manager=history_manager), registry
|
||||||
|
)
|
||||||
|
event = FakeOfficialEvent(
|
||||||
|
text="请问 <@KNOWN_OPENID> 和 <@!UNKNOWN_OPENID> 怎么看",
|
||||||
|
mentions=[
|
||||||
|
SimpleNamespace(
|
||||||
|
id="KNOWN_OPENID",
|
||||||
|
username="随风潜入夜",
|
||||||
|
is_you=False,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.process_message(event))
|
||||||
|
|
||||||
|
stored_parts = history_manager.calls[0]["content"]["message"]
|
||||||
|
assert stored_parts == [
|
||||||
|
{"type": "plain", "text": "请问 @随风潜入夜 和 @群友 怎么看"}
|
||||||
|
]
|
||||||
|
assert "KNOWN_OPENID" not in stored_parts[0]["text"]
|
||||||
|
assert "UNKNOWN_OPENID" not in stored_parts[0]["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_official_bot_mention_is_removed_before_storage():
|
||||||
|
history_manager = RecordingHistoryManager()
|
||||||
|
service = MessageProcessingService(
|
||||||
|
SimpleNamespace(message_history_manager=history_manager), FakeGroupRegistry()
|
||||||
|
)
|
||||||
|
event = FakeOfficialEvent(
|
||||||
|
text="<@BOT_OPENID> 帮我问问 <@MEMBER_OPENID>",
|
||||||
|
mentions=[
|
||||||
|
SimpleNamespace(id="BOT_OPENID", username="机器人", is_you=True),
|
||||||
|
SimpleNamespace(
|
||||||
|
id="MEMBER_OPENID",
|
||||||
|
username="群友甲",
|
||||||
|
is_you=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.process_message(event))
|
||||||
|
|
||||||
|
stored_parts = history_manager.calls[0]["content"]["message"]
|
||||||
|
assert stored_parts == [{"type": "plain", "text": "帮我问问 @群友甲"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_official_message_preserves_internal_line_breaks():
|
||||||
|
history_manager = RecordingHistoryManager()
|
||||||
|
service = MessageProcessingService(
|
||||||
|
SimpleNamespace(message_history_manager=history_manager), FakeGroupRegistry()
|
||||||
|
)
|
||||||
|
event = FakeOfficialEvent(text="第一行\n\n第二行\t\t结尾")
|
||||||
|
|
||||||
|
asyncio.run(service.process_message(event))
|
||||||
|
|
||||||
|
stored_parts = history_manager.calls[0]["content"]["message"]
|
||||||
|
assert stored_parts == [{"type": "plain", "text": "第一行\n\n第二行 结尾"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_official_at_component_preserves_internal_line_breaks():
|
||||||
|
history_manager = RecordingHistoryManager()
|
||||||
|
service = MessageProcessingService(
|
||||||
|
SimpleNamespace(message_history_manager=history_manager), FakeGroupRegistry()
|
||||||
|
)
|
||||||
|
event = FakeOfficialEvent(
|
||||||
|
text="第一行\n\n第二行",
|
||||||
|
mentions=[SimpleNamespace(id="BOT_OPENID", username="机器人", is_you=True)],
|
||||||
|
at_target="BOT_OPENID",
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.process_message(event))
|
||||||
|
|
||||||
|
stored_parts = history_manager.calls[0]["content"]["message"]
|
||||||
|
assert stored_parts == [
|
||||||
|
{"type": "at", "target_id": "BOT_OPENID", "name": ""},
|
||||||
|
{"type": "plain", "text": "第一行\n\n第二行"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_qq_message_keeps_platform_mention_syntax_unchanged():
|
||||||
|
history_manager = RecordingHistoryManager()
|
||||||
|
service = MessageProcessingService(
|
||||||
|
SimpleNamespace(message_history_manager=history_manager), FakeGroupRegistry()
|
||||||
|
)
|
||||||
|
event = FakeOfficialEvent(
|
||||||
|
text="请问 <@DISCORD_USER_ID> 怎么看",
|
||||||
|
mentions=[
|
||||||
|
SimpleNamespace(
|
||||||
|
id="DISCORD_USER_ID",
|
||||||
|
username="Discord 用户",
|
||||||
|
is_you=False,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
platform_name="discord",
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.process_message(event))
|
||||||
|
|
||||||
|
stored_parts = history_manager.calls[0]["content"]["message"]
|
||||||
|
assert stored_parts == [{"type": "plain", "text": "请问 <@DISCORD_USER_ID> 怎么看"}]
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ class FakeHistoryManager:
|
|||||||
return self.pages.get(page, [])
|
return self.pages.get(page, [])
|
||||||
|
|
||||||
|
|
||||||
def make_record(record_id, message_id, sender_id, timestamp, text):
|
def make_record(record_id, message_id, sender_id, timestamp, text, sender_name=None):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
id=record_id,
|
id=record_id,
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
sender_name=sender_id,
|
sender_name=sender_id if sender_name is None else sender_name,
|
||||||
created_at=datetime.fromtimestamp(timestamp, timezone.utc),
|
created_at=datetime.fromtimestamp(timestamp, timezone.utc),
|
||||||
content={
|
content={
|
||||||
"type": "user",
|
"type": "user",
|
||||||
@@ -61,8 +61,8 @@ def test_local_history_is_deduplicated_filtered_and_sorted():
|
|||||||
message_history_manager=FakeHistoryManager(
|
message_history_manager=FakeHistoryManager(
|
||||||
{
|
{
|
||||||
1: [
|
1: [
|
||||||
make_record(1, "MSG-2", "B_OPENID", 200, "second"),
|
make_record(1, "MSG-2", "B_OPENID", 200, "second", "用户乙"),
|
||||||
make_record(2, "MSG-1", "A_OPENID", 100, "first"),
|
make_record(2, "MSG-1", "A_OPENID", 100, "first", "用户甲"),
|
||||||
make_record(3, "MSG-2", "B_OPENID", 200, "duplicate"),
|
make_record(3, "MSG-2", "B_OPENID", 200, "duplicate"),
|
||||||
make_record(4, "MSG-BOT", "BOT_OPENID", 300, "bot"),
|
make_record(4, "MSG-BOT", "BOT_OPENID", 300, "bot"),
|
||||||
]
|
]
|
||||||
@@ -77,8 +77,53 @@ def test_local_history_is_deduplicated_filtered_and_sorted():
|
|||||||
|
|
||||||
assert [message.message_id for message in messages] == ["MSG-1", "MSG-2"]
|
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.sender_id for message in messages] == ["A_OPENID", "B_OPENID"]
|
||||||
|
assert [message.sender_name for message in messages] == ["用户甲", "用户乙"]
|
||||||
assert [message.text_content for message in messages] == ["first", "second"]
|
assert [message.text_content for message in messages] == ["first", "second"]
|
||||||
|
|
||||||
|
raw_messages = adapter.convert_to_raw_format(messages)
|
||||||
|
assert [message["sender"]["nickname"] for message in raw_messages] == [
|
||||||
|
"用户甲",
|
||||||
|
"用户乙",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_history_sender_name_uses_group_scoped_alias_for_placeholder():
|
||||||
|
adapter = make_adapter()
|
||||||
|
empty_name_record = make_record(
|
||||||
|
1,
|
||||||
|
"MSG-1",
|
||||||
|
"A_OPENID",
|
||||||
|
100,
|
||||||
|
"first",
|
||||||
|
sender_name="",
|
||||||
|
)
|
||||||
|
openid_name_record = make_record(
|
||||||
|
2,
|
||||||
|
"MSG-2",
|
||||||
|
"A_OPENID",
|
||||||
|
101,
|
||||||
|
"second",
|
||||||
|
sender_name="A_OPENID",
|
||||||
|
)
|
||||||
|
|
||||||
|
empty_name_message = adapter._convert_history_record(
|
||||||
|
empty_name_record, "GROUP_OPENID"
|
||||||
|
)
|
||||||
|
openid_name_message = adapter._convert_history_record(
|
||||||
|
openid_name_record, "GROUP_OPENID"
|
||||||
|
)
|
||||||
|
other_group_message = adapter._convert_history_record(
|
||||||
|
openid_name_record, "OTHER_GROUP_OPENID"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert empty_name_message is not None
|
||||||
|
assert openid_name_message is not None
|
||||||
|
assert other_group_message is not None
|
||||||
|
assert empty_name_message.sender_name.startswith("群友-")
|
||||||
|
assert "A_OPENID" not in empty_name_message.sender_name
|
||||||
|
assert openid_name_message.sender_name == empty_name_message.sender_name
|
||||||
|
assert other_group_message.sender_name != empty_name_message.sender_name
|
||||||
|
|
||||||
|
|
||||||
def test_factory_registers_both_official_platform_types():
|
def test_factory_registers_both_official_platform_types():
|
||||||
assert PlatformAdapterFactory.is_supported("qq_official")
|
assert PlatformAdapterFactory.is_supported("qq_official")
|
||||||
|
|||||||
Reference in New Issue
Block a user