diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dbc90..652971d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,17 @@ # 更新日志 (CHANGELOG) +## [v4.11.1] - 🛠️ 修复 QQ 官方机器人昵称与提及清洗 + +* **🛠️ 成员昵称恢复**: QQ 官方群消息会保存并在图片、HTML 报告中展示事件提供的成员昵称;昵称缺失或旧历史记录仅保存 OpenID 时,使用群内稳定匿名名,避免直接展示 `member_openid`。 +* **🛠️ 提及文本清洗**: 将消息中的成员提及转换为可读昵称,移除机器人自身提及,并保留用户消息原有的换行和段落格式。 + ## [v4.11.0] - ✨ 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145) 插件同时支持 AstrBot 的 `qq_official` 与 `qq_official_webhook` 平台。 * **🛠️配置注意事项 **: 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。 * **🛠️ 适配范围**: 本次适配只覆盖普通 QQ 群,不包含频道或子频道。 -* **⚙️ 成员昵称**: 官方群事件不提供成员昵称,所以推荐使用 QQ 官方机器人的用户配置输出格式为 text (默认为 image)格式,获得更好的体验,图片格式下无法显示成员昵称。 +* **⚙️ 成员昵称**: 官方群事件提供有效昵称时,图片和 HTML 报告会正常显示昵称;昵称缺失或历史记录仅保存 OpenID 时,会使用群内稳定匿名名,避免直接展示 `member_openid`。文本报告继续使用成员艾特展示身份。 * **⚙️ QQ 官方 API 支持有限**: QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。 * **✨ 分析名单配置**: 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置黑白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。 * **✨ Markdown 报告**: QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。 diff --git a/README.md b/README.md index b2eb45c..09bba35 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # 群聊日常分析插件 -[![Plugin Version](https://img.shields.io/badge/Latest_Version-v4.10.9-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis) +[![Plugin Version](https://img.shields.io/badge/Latest_Version-v4.11.1-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis) [![AstrBot](https://img.shields.io/badge/AstrBot-Plugin-ff69b4?style=for-the-badge)](https://github.com/AstrBotDevs/AstrBot) [![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis) @@ -105,7 +105,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCa > 3. **稳定性**:上方地址为维护者提供 T2I 服务(国外网络环境),在一段时间内大概率稳定,但不保证长期有效,如果自己不想部署可以使用。 > 4. **休眠保活**:由于免费空间若长时间(约 48 小时)无人访问会进入休眠。可选择使用保活服务(如 [UptimeRobot](https://uptimerobot.com/))定期访问 API 地址以保持其处于唤醒状态。 > -> - **国内加速 (CF 代理)**: `https://t2i.vercel.ciallo.de5.net` +> - **国内加速**: `https://t2i.vercel.ciallo.de5.net` > - **说明**: 在国内直接访问原始域名下载图片可能较慢,可选择使用此代理域名。在一段时间内大概率稳定。 > > @@ -121,7 +121,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCa > - 在群聊中需要由群主允许机器人接收群内全部消息,使 AstrBot 能收到 `GROUP_MESSAGE_CREATE` 事件;只开放 @ 消息时,报告只能覆盖 @ 机器人的聊天。 > - QQ 官方 API 不提供“按群拉取历史消息”的接口。插件会从启用后开始实时保存消息,并从 AstrBot 本地消息历史库分页读取;启用前的群聊无法自动回填。 > - 官方群和成员使用 `group_openid` / `member_openid`,不是群号或 QQ 号。配置白名单、定时任务时建议先在群内执行 `/sid`,填写完整 UMO。 -> - 官方群事件不提供成员昵称。 +> - 官方群事件提供有效昵称时会用于报告;昵称缺失时使用群内稳定匿名名,避免直接展示 `member_openid`。 > - QQ 官方文本报告使用自定义 Markdown,并默认通过 AstrBot T2I 生成透明背景的群聊概览图,将日期、基础统计和 24 小时竖向直方图合并为紧凑布局。可在 `QQ 官方机器人` 配置组关闭;渲染失败时自动回退为包含文字条形图的完整文本报告。 > - Markdown 概览图直接使用 AstrBot T2I 返回的公网 URL。请确保当前 T2I 端点域名已加入 QQ 开放平台的消息 URL 配置。 > - 本次适配只覆盖普通 QQ 群,不包含频道或子频道。 @@ -270,7 +270,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **OneBot** (NapCa | 平台 | 适配器类型 | 特殊要求/说明 | |------|-----------|--------------| | **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` (查看消息历史记录) 权限。 | | **Telegram** | Telegram Bot API | 若机器人不是群管理员,入群前需先在 BotFather 关闭隐私模式 (`/setprivacy` -> `Disable`)。若机器人已在群内且非管理员,关闭后需要先移出机器人再重新拉入,设置才会生效。 | diff --git a/_conf_schema.json b/_conf_schema.json index 0a9738b..85b4084 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -66,7 +66,7 @@ "html" ], "default": "image", - "hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方群事件没有昵称,图片/HTML 报告仅显示头像,文本报告由独立 Markdown 模块使用艾特展示身份;其他平台保持原有昵称文本格式。" + "hint": "选择分析报告的输出方式:image(图片)、text(纯文本摘要)、html(可交互式网页文件)。QQ 官方图片/HTML 报告优先显示事件昵称,昵称缺失时使用群内稳定匿名名;文本报告由独立 Markdown 模块使用成员艾特展示身份。其他平台保持原有昵称文本格式。" }, "report_template": { "type": "string", diff --git a/main.py b/main.py index 81da738..800e498 100644 --- a/main.py +++ b/main.py @@ -622,7 +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" + is_qq_official = adapter.get_platform_name() == "qq_official" # 定义获取回调 async def avatar_url_getter(user_id: str) -> str | None: @@ -645,7 +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, + allow_alphanumeric_user_ids=is_qq_official, ) if image_url: @@ -658,7 +658,7 @@ class GroupDailyAnalysis(Star): # 如果图片生成或发送失败,直接回退到文本 logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}") await self._send_text_reports( - group_id, analysis_result, hide_user_names, adapter + group_id, analysis_result, is_qq_official, adapter ) return @@ -669,7 +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, + allow_alphanumeric_user_ids=is_qq_official, ) if html_path: is_only_url = self.config_manager.get_html_only_url() @@ -731,25 +731,31 @@ class GroupDailyAnalysis(Star): else: 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( - self, analysis_result: dict, hide_user_names: bool + self, analysis_result: dict, use_qq_official_markdown: bool ) -> tuple[str, str | None]: """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( 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 + self, + group_id: str, + analysis_result: dict, + use_qq_official_markdown: 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: + tr, fr = await self._generate_text_reports( + 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) diff --git a/metadata.yaml b/metadata.yaml index ed62732..8d97f56 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,7 +1,7 @@ name: astrbot_plugin_qq_group_daily_analysis # 这是你的插件的唯一识别名。 display_name: 群分析总结插件 # 插件的显示名称 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 # 作者 astrbot_version: ">=4.16.0" support_platforms: diff --git a/src/application/services/message_processing_service.py b/src/application/services/message_processing_service.py index c87fba8..929388f 100644 --- a/src/application/services/message_processing_service.py +++ b/src/application/services/message_processing_service.py @@ -7,6 +7,9 @@ from astrbot.api.star import Context from ...infrastructure.persistence.platform_group_registry import PlatformGroupRegistry 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: """ @@ -187,6 +190,12 @@ class MessageProcessingService: """从事件中提取消息内容""" message_parts = [] 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() @@ -221,6 +230,10 @@ class MessageProcessingService: text = seg.data.get("text") if text: 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}) elif seg_type in ("Image", "image"): @@ -262,7 +275,12 @@ class MessageProcessingService: 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}) + 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 = [ @@ -275,6 +293,81 @@ class MessageProcessingService: 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 def _strip_known_mentions(text: str, pending_mentions: Counter[str]) -> str: """从文本中移除已识别的 @ 提及""" @@ -299,7 +392,7 @@ class MessageProcessingService: if pending_mentions[mention] <= 0: pending_mentions.pop(mention, None) - return re.sub(r"\s{2,}", " ", cleaned).strip() + return re.sub(r"[^\S\r\n]{2,}", " ", cleaned).strip() @staticmethod def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool: diff --git a/src/domain/repositories/report_repository.py b/src/domain/repositories/report_repository.py index 9491aaa..60930bf 100644 --- a/src/domain/repositories/report_repository.py +++ b/src/domain/repositories/report_repository.py @@ -22,6 +22,7 @@ class IReportGenerator(ABC): nickname_getter: Any = None, avatar_cache_namespace: str | None = None, hide_user_names: bool = False, + allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: """生成图片报告""" pass @@ -35,6 +36,7 @@ class IReportGenerator(ABC): nickname_getter: Any = None, avatar_cache_namespace: str | None = None, hide_user_names: bool = False, + allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: """生成 HTML 报告""" pass diff --git a/src/infrastructure/platform/adapters/qq_official_adapter.py b/src/infrastructure/platform/adapters/qq_official_adapter.py index dba73b3..9659f54 100644 --- a/src/infrastructure/platform/adapters/qq_official_adapter.py +++ b/src/infrastructure/platform/adapters/qq_official_adapter.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import base64 +import hashlib import os import random import re @@ -62,6 +63,25 @@ class QQOfficialAdapter(PlatformAdapter): return str(platform_config.get("appid", "") or "").strip() 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: self._context = context @@ -222,11 +242,14 @@ class QQOfficialAdapter(PlatformAdapter): sender_id = str(getattr(record, "sender_id", "") or "") if not sender_id: return None + sender_name = self._resolve_history_sender_name( + getattr(record, "sender_name", None), sender_id, group_id + ) return UnifiedMessage( message_id=message_id, sender_id=sender_id, - sender_name=sender_id, + sender_name=sender_name, sender_card=None, group_id=group_id, text_content="".join(text_parts), @@ -256,7 +279,7 @@ class QQOfficialAdapter(PlatformAdapter): "group_id": message.group_id, "sender": { "user_id": message.sender_id, - "nickname": message.sender_id, + "nickname": message.sender_name or message.sender_id, "card": "", }, "message": chain, diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index d213c38..0bd9522 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -31,7 +31,7 @@ class ReportDispatcher: """设置 HTML 渲染函数 (运行时注入)""" 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) return bool(adapter and adapter.get_platform_name() == "qq_official") @@ -92,7 +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), + allow_alphanumeric_user_ids=self._is_qq_official(platform_id), ) except Exception as e: logger.error(f"[{trace_id}] Failed to generate image report: {e}") @@ -140,7 +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), + allow_alphanumeric_user_ids=self._is_qq_official(platform_id), ) except Exception as e: logger.error(f"[{trace_id}] Failed to generate HTML report: {e}") @@ -202,7 +202,7 @@ class ReportDispatcher: ) -> bool: """分发文本报告""" 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 if is_qq_official: ( diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index 9bf7073..bd01977 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -345,6 +345,8 @@ class ReportGenerator(IReportGenerator): nickname_getter=None, avatar_cache_namespace: str | None = None, hide_user_names: bool = False, + # Also controls ID normalization and fallback display name ("群友"). + allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: """ 生成图片格式的分析报告 @@ -369,6 +371,7 @@ class ReportGenerator(IReportGenerator): nickname_getter=nickname_getter, avatar_cache_namespace=avatar_cache_namespace, hide_user_names=hide_user_names, + allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, ) # 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签) @@ -511,6 +514,7 @@ class ReportGenerator(IReportGenerator): nickname_getter=None, avatar_cache_namespace: str | None = None, hide_user_names: bool = False, + allow_alphanumeric_user_ids: bool = False, ) -> tuple[str | None, str | None]: """ 生成HTML格式的分析报告,保存到指定目录 @@ -556,6 +560,7 @@ class ReportGenerator(IReportGenerator): nickname_getter=nickname_getter, avatar_cache_namespace=avatar_cache_namespace, hide_user_names=hide_user_names, + allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, ) logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段") @@ -617,7 +622,7 @@ class ReportGenerator(IReportGenerator): json_data = { "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 ), "group_id": group_id, @@ -795,6 +800,7 @@ class ReportGenerator(IReportGenerator): nickname_getter=None, avatar_cache_namespace: str | None = None, hide_user_names: bool = False, + allow_alphanumeric_user_ids: bool = False, ) -> dict: """准备渲染数据""" stats = analysis_result["statistics"] @@ -820,6 +826,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_registry, avatar_reuse_aliases, hide_user_names=hide_user_names, + allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, ) if hide_user_names: contributors = await self._render_avatar_only_ids( @@ -889,6 +896,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_registry, avatar_reuse_aliases, hide_user_names=True, + allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, ) title_data = { "name": "" if hide_user_names else title.name, @@ -938,6 +946,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_registry, avatar_reuse_aliases, hide_user_names=hide_user_names, + allow_alphanumeric_user_ids=allow_alphanumeric_user_ids, ) quotes_list.append( { @@ -1110,6 +1119,7 @@ class ReportGenerator(IReportGenerator): avatar_reuse_registry: dict[str, str] | None = None, avatar_reuse_aliases: dict[str, str] | None = None, hide_user_names: bool = False, + allow_alphanumeric_user_ids: bool = False, ) -> Markup: """ 处理文本,将 [用户ID] 格式的引用替换为头像胶囊。 @@ -1123,9 +1133,9 @@ class ReportGenerator(IReportGenerator): if str(user_id).strip() } source_text = str(text) - if hide_user_names: - # LLM 偶尔会直接输出 ID;在头像-only 模式下先标准化为引用, - # 避免 member_openid 以明文形式泄露。 + supports_extended_ids = hide_user_names or allow_alphanumeric_user_ids + if supports_extended_ids: + # LLM 偶尔会直接输出 ID;先标准化为引用,避免 OpenID 以明文形式显示。 for user_id in sorted(known_ids, key=len, reverse=True): source_text = re.sub( rf"(? Markup: 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)) url = await self._get_user_avatar( uid, avatar_url_getter, avatar_cache_namespace @@ -1182,7 +1194,7 @@ class ReportGenerator(IReportGenerator): final_name = ( name 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( diff --git a/tests/test_avatar_only_reporting.py b/tests/test_avatar_only_reporting.py index 7889d53..0253040 100644 --- a/tests/test_avatar_only_reporting.py +++ b/tests/test_avatar_only_reporting.py @@ -1,6 +1,7 @@ import asyncio import inspect import json +from pathlib import Path from types import SimpleNamespace 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 +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(): generator = build_generator_without_io() openid = "A1B2C3D4_OPENID" @@ -546,3 +589,47 @@ def test_html_sidecar_export_removes_nested_identity_values(): assert ( 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: "safe" + ) + 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 diff --git a/tests/test_message_processing_service.py b/tests/test_message_processing_service.py index 40fe7f1..3b83ab3 100644 --- a/tests/test_message_processing_service.py +++ b/tests/test_message_processing_service.py @@ -18,6 +18,14 @@ class FakeHistoryManager: raise RuntimeError("temporary database failure") +class RecordingHistoryManager: + def __init__(self): + self.calls = [] + + async def insert(self, **kwargs): + self.calls.append(kwargs) + + class FakeGroupRegistry: def __init__(self): self.upsert_calls = 0 @@ -27,14 +35,28 @@ class FakeGroupRegistry: 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( message_id="OFFICIAL-MSG-1", - raw_message=SimpleNamespace(timestamp=1710000000), + raw_message=SimpleNamespace( + timestamp=1710000000, + mentions=list(mentions or []), + ), sender=SimpleNamespace(nickname=""), - message=[SimpleNamespace(type="Plain", text="hello")], + message=message, ) - self.message_str = "hello" + self.message_str = text def get_group_id(self): return "GROUP_OPENID" @@ -49,7 +71,7 @@ class FakeOfficialEvent: return "official-main" def get_platform_name(self): - return "qq_official" + return self.platform_name 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 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> 怎么看"}] diff --git a/tests/test_qq_official_adapter.py b/tests/test_qq_official_adapter.py index b7aa843..a114e10 100644 --- a/tests/test_qq_official_adapter.py +++ b/tests/test_qq_official_adapter.py @@ -18,11 +18,11 @@ class FakeHistoryManager: 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( id=record_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), content={ "type": "user", @@ -61,8 +61,8 @@ def test_local_history_is_deduplicated_filtered_and_sorted(): 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(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"), ] @@ -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.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"] + 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(): assert PlatformAdapterFactory.is_supported("qq_official")