[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
+87
View File
@@ -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: "<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
+133 -5
View File
@@ -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> 怎么看"}]
+49 -4
View File
@@ -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")