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:
@@ -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
|
||||
|
||||
@@ -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> 怎么看"}]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user