From cc7a13158e1af1817b0b701c50a50ea1e1d00659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=BC=E6=96=B9?= <93891533+BiFangKNT@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:00:47 +0800 Subject: [PATCH] fix(aiocqhttp): resolve group member metadata (#2298) * fix(aiocqhttp): resolve group member metadata * style: ruff format --- src/langbot/pkg/platform/sources/aiocqhttp.py | 116 ++++- .../test_aiocqhttp_message_converter.py | 435 +++++++++++++++++- 2 files changed, 544 insertions(+), 7 deletions(-) diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.py b/src/langbot/pkg/platform/sources/aiocqhttp.py index fc57a5661..abe1ca95b 100644 --- a/src/langbot/pkg/platform/sources/aiocqhttp.py +++ b/src/langbot/pkg/platform/sources/aiocqhttp.py @@ -4,6 +4,7 @@ import asyncio import traceback import datetime import json +import time import aiocqhttp import pydantic @@ -16,6 +17,14 @@ from ...utils import image import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +_GROUP_NAME_CACHE_TTL_SECONDS = 3600 +_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60 +_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2 +_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400 +_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600 +_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2 + + def _normalize_base64_payload(value: str) -> str: if value.startswith('base64://'): return value.removeprefix('base64://') @@ -24,6 +33,21 @@ def _normalize_base64_payload(value: str) -> str: return value +def _get_field(data: dict, key: str, default: str = '') -> str: + value = data.get(key) + if value is None: + return default + return str(value) + + +def _get_group_member_name(sender: dict) -> str: + return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id') + + +def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str: + return f'Group {group_id}' + + class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target( @@ -335,16 +359,96 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): + def __init__(self): + self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {} + self._group_name_negative_cache: dict[typing.Union[int, str], float] = {} + self._group_member_info_cache: dict[ + tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float] + ] = {} + self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {} + @staticmethod async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int): return event.source_platform_object - @staticmethod - async def target2yiri(event: aiocqhttp.Event, bot=None): + async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str: + now = time.monotonic() + if group_id in self._group_name_cache: + group_name, expires_at = self._group_name_cache[group_id] + if expires_at > now: + return group_name + del self._group_name_cache[group_id] + if group_id in self._group_name_negative_cache: + expires_at = self._group_name_negative_cache[group_id] + if expires_at > now: + return '' + del self._group_name_negative_cache[group_id] + if bot is None: + return '' + try: + group_info = await asyncio.wait_for( + bot.get_group_info(group_id=group_id), + timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return '' + group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else '' + if group_name: + self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS) + self._group_name_negative_cache.pop(group_id, None) + else: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return group_name + + async def _get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + bot=None, + ) -> dict: + now = time.monotonic() + cache_key = (group_id, user_id) + if cache_key in self._group_member_info_cache: + member_info, expires_at = self._group_member_info_cache[cache_key] + if expires_at > now: + return member_info + del self._group_member_info_cache[cache_key] + if cache_key in self._group_member_info_negative_cache: + expires_at = self._group_member_info_negative_cache[cache_key] + if expires_at > now: + return {} + del self._group_member_info_negative_cache[cache_key] + if bot is None: + return {} + try: + member_info = await asyncio.wait_for( + bot.get_group_member_info(group_id=group_id, user_id=user_id), + timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + if isinstance(member_info, dict) and member_info: + self._group_member_info_cache[cache_key] = ( + member_info, + now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS, + ) + self._group_member_info_negative_cache.pop(cache_key, None) + return member_info + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + + async def target2yiri(self, event: aiocqhttp.Event, bot=None): yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot) if event.message_type == 'group': permission = 'MEMBER' + group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id) + special_title = _get_field(event.sender, 'title') + if not special_title: + member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot) + special_title = _get_field(member_info, 'title') if 'role' in event.sender: if event.sender['role'] == 'admin': @@ -354,14 +458,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): converted_event = platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.sender['user_id'], # message_seq 放哪? - member_name=event.sender['nickname'], + member_name=_get_group_member_name(event.sender), permission=permission, group=platform_entities.Group( id=event.group_id, - name=event.sender['nickname'], + name=group_name, permission=platform_entities.Permission.Member, ), - special_title=event.sender['title'] if 'title' in event.sender else '', + special_title=special_title, ), message_chain=yiri_chain, time=event.time, @@ -385,7 +489,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp) message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() - event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() + event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter) on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = [] diff --git a/tests/unit_tests/platform/test_aiocqhttp_message_converter.py b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py index 55f835c97..2f97176ec 100644 --- a/tests/unit_tests/platform/test_aiocqhttp_message_converter.py +++ b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py @@ -1,7 +1,12 @@ import pytest +import aiocqhttp import langbot_plugin.api.entities.builtin.platform.message as platform_message -from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpAdapter, AiocqhttpMessageConverter +from langbot.pkg.platform.sources.aiocqhttp import ( + AiocqhttpAdapter, + AiocqhttpEventConverter, + AiocqhttpMessageConverter, +) async def _convert_single(component: platform_message.MessageComponent): @@ -103,3 +108,431 @@ async def test_forward_image_base64_payload_is_normalized(): 'type': 'image', 'data': {'file': 'base64://raw-forward-image'}, } + + +@pytest.mark.asyncio +async def test_group_message_member_name_prefers_group_card(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': 'Special Title', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + assert group_id == 2000 + return {'group_id': group_id, 'group_name': 'Test Group'} + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.member_name == 'Group Card' + assert converted.sender.group.id == 2000 + assert converted.sender.group.name == 'Test Group' + assert converted.sender.special_title == 'Special Title' + + +@pytest.mark.asyncio +async def test_group_message_member_name_falls_back_to_nickname(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': '', + 'role': 'member', + }, + } + ) + + converted = await AiocqhttpEventConverter().target2yiri(event) + + assert converted.sender.member_name == 'QQ Nickname' + + +@pytest.mark.asyncio +async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + assert group_id == 2000 + assert user_id == 3000 + return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'} + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.special_title == 'Member Title' + + +@pytest.mark.asyncio +async def test_group_message_special_title_does_not_lookup_when_sender_title_exists(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': 'Event Title', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + raise AssertionError('get_group_member_info should not be called') + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.special_title == 'Event Title' + + +@pytest.mark.asyncio +async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + raise RuntimeError('api unavailable') + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + second = await converter.target2yiri(event, bot) + + assert first.sender.special_title == '' + assert second.sender.special_title == '' + assert bot.member_info_calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_special_title_member_info_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + return { + 'group_id': group_id, + 'user_id': user_id, + 'title': f'Member Title {self.member_info_calls}', + } + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + now = 87401.0 + second = await converter.target2yiri(event, bot) + + assert first.sender.special_title == 'Member Title 1' + assert second.sender.special_title == 'Member Title 2' + assert bot.member_info_calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + if self.member_info_calls == 1: + raise RuntimeError('api unavailable') + return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + failed = await converter.target2yiri(event, bot) + now = 1601.0 + recovered = await converter.target2yiri(event, bot) + + assert failed.sender.special_title == '' + assert recovered.sender.special_title == 'Recovered Title' + assert bot.member_info_calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_group_name_is_cached(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + assert group_id == 2000 + return {'group_id': group_id, 'group_name': 'Cached Group'} + + monotonic = 1000.0 + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + second = await converter.target2yiri(event, bot) + + assert first.sender.group.name == 'Cached Group' + assert second.sender.group.name == 'Cached Group' + assert bot.calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_group_name_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + now = 4601.0 + second = await converter.target2yiri(event, bot) + + assert first.sender.group.name == 'Group Name 1' + assert second.sender.group.name == 'Group Name 2' + assert bot.calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + raise RuntimeError('api unavailable') + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + converted = await converter.target2yiri(event, bot) + cached_failure = await converter.target2yiri(event, bot) + + assert converted.sender.group.name == 'Group 2000' + assert cached_failure.sender.group.name == 'Group 2000' + assert bot.calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + if self.calls == 1: + raise RuntimeError('api unavailable') + return {'group_id': group_id, 'group_name': 'Recovered Group'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + failed = await converter.target2yiri(event, bot) + now = 1061.0 + recovered = await converter.target2yiri(event, bot) + + assert failed.sender.group.name == 'Group 2000' + assert recovered.sender.group.name == 'Recovered Group' + assert bot.calls == 2