[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:
clown145
2026-07-21 14:53:31 +08:00
committed by GitHub
co-authored by SXP-Simon
parent 20019baa2c
commit f2bcd6801e
13 changed files with 442 additions and 41 deletions
@@ -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:
@@ -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
@@ -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,
+4 -4
View File
@@ -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:
(
+19 -7
View File
@@ -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"(?<!\[)(?<![A-Za-z0-9_-]){re.escape(user_id)}"
@@ -1134,7 +1144,9 @@ class ReportGenerator(IReportGenerator):
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))
if not matches:
@@ -1142,7 +1154,7 @@ class ReportGenerator(IReportGenerator):
async def render_capsule(match: re.Match[str]) -> 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(