From 0c99832298002576957e75d0b66d657726a2deb2 Mon Sep 17 00:00:00 2001 From: JERRY <86050806+jry21223@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:57:00 +0800 Subject: [PATCH] fix(qqofficial): receive full-group messages and preserve bot mentions (#2769) * fix(qqofficial): support full-group messages and native bot mentions * test(qqofficial): cover full-group form reply targets --------- Co-authored-by: Chin Rock Co-authored-by: multica-agent --- .../event-based-agents/adapters/qqofficial.md | 29 ++- src/langbot/libs/qq_official_api/api.py | 21 +- .../libs/qq_official_api/qqofficialevent.py | 9 +- .../platform/adapters/qqofficial/adapter.py | 2 +- .../adapters/qqofficial/event_converter.py | 5 +- .../adapters/qqofficial/message_converter.py | 4 +- .../pkg/platform/sources/qqofficial.py | 13 +- .../test_qqofficial_full_group_forms.py | 36 +++ .../test_qqofficial_full_group_messages.py | 232 ++++++++++++++++++ 9 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 tests/unit_tests/platform/test_qqofficial_full_group_forms.py create mode 100644 tests/unit_tests/platform/test_qqofficial_full_group_messages.py diff --git a/dev-docs/event-based-agents/adapters/qqofficial.md b/dev-docs/event-based-agents/adapters/qqofficial.md index bf1e657a5..0206bb327 100644 --- a/dev-docs/event-based-agents/adapters/qqofficial.md +++ b/dev-docs/event-based-agents/adapters/qqofficial.md @@ -21,7 +21,7 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach | Event | Evidence | Notes | | --- | --- | --- | -| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. | +| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, `GROUP_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. Full-group mode has protocol regression coverage; the earlier live probe below covered the original message path. | | `message.reaction` | unit | `MESSAGE_REACTION_ADD` and `MESSAGE_REACTION_REMOVE` map to common `MessageReactionEvent`. Live gateway evidence is pending. | | `group.member_joined` | unit | `GUILD_MEMBER_ADD` and `GROUP_MEMBER_ADD` map to common `MemberJoinedEvent` when the gateway payload carries a group/guild/channel ID and member openid. | | `group.member_left` | unit | `GUILD_MEMBER_REMOVE` and `GROUP_MEMBER_REMOVE` map to common `MemberLeftEvent`. Live gateway evidence is pending. | @@ -29,6 +29,33 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach | `bot.removed_from_group` | unit | `GUILD_DELETE` and `GROUP_DEL_ROBOT` map to common `BotRemovedFromGroupEvent`. | | `platform.specific` | unit | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. | +## Full-group messages + +After a group owner enables QQ's "receive all messages" permission, QQ delivers +`GROUP_MESSAGE_CREATE`, including messages that mention the bot. Both the EBA +and legacy adapters accept this event over WebSocket and unified Webhook, retain +the sender's `member_openid` (with the older `openid` alias as fallback), and use +the original message ID for passive group replies. + +Ordinary messages and images do not receive a bot `At` component. A full-group +message receives that component only when QQ supplies a boolean `mentions[].is_you` +equal to `true` and a matching native `<@ID>` or `<@!ID>` token in its text. Only +the bot's own token is removed; mentions of other members remain text. Nicknames, +`bot: true` alone, and a text that looks like a mention do not authorize a reply. +The EBA component targets the adapter's configured bot account so existing +`is_at_bot` route filters continue to match. + +Default EBA group routes requiring `is_at_bot` still exclude unmentioned group +messages. Custom event routes or plugin subscriptions may opt into ordinary +messages. This change does not alter QQ permissions, existing bot configuration, +event bindings, or plugin subscriptions. + +`tests/unit_tests/platform/test_qqofficial_full_group_messages.py` exercises the +real adapters and shared QQ parser with external gateway/download/send transports +substituted. It covers text, images, self/other/unverified mentions, sender aliases, +legacy @-only mode, private messages, and passive reply targets. This is protocol +regression coverage, not a claim of a fresh live QQ probe of the whole upstream build. + ## Common APIs | API | Evidence | Notes | diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index e9982dd83..4fedae5ca 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -352,6 +352,7 @@ class QQOfficialClient: return {} message_data = { 't': msg.get('t', {}), + 'bot_account_id': self.app_id, 'user_openid': d.get('author', {}).get('user_openid', {}), 'timestamp': d.get('timestamp', {}), 'd_author_id': d.get('author', {}).get('id', {}), @@ -361,9 +362,27 @@ class QQOfficialClient: 'channel_id': d.get('channel_id', {}), 'username': d.get('author', {}).get('username', {}), 'guild_id': d.get('guild_id', {}), - 'member_openid': d.get('author', {}).get('openid', {}), + 'member_openid': d.get('author', {}).get('member_openid') or d.get('author', {}).get('openid') or '', 'group_openid': d.get('group_openid', {}), } + # Full-group mode delivers native bot mentions through this event too. + # Only QQ's explicit self marker and a matching token authorize an At. + if msg.get('t') == 'GROUP_MESSAGE_CREATE': + content = d.get('content') + mentions = d.get('mentions') + if isinstance(content, str) and isinstance(mentions, list): + for mention in mentions: + if not isinstance(mention, dict) or mention.get('is_you') is not True: + continue + for key in ('id', 'member_openid', 'openid'): + bot_id = mention.get(key) + if not isinstance(bot_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', bot_id): + continue + for token in (f'<@{bot_id}>', f'<@!{bot_id}>'): + if token in content: + content = content.replace(token, '') + message_data['is_at_bot'] = True + message_data['content'] = content attachments = d.get('attachments', []) image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)] image_attachments_type = [ diff --git a/src/langbot/libs/qq_official_api/qqofficialevent.py b/src/langbot/libs/qq_official_api/qqofficialevent.py index 7c29b9d8f..852f814fe 100644 --- a/src/langbot/libs/qq_official_api/qqofficialevent.py +++ b/src/langbot/libs/qq_official_api/qqofficialevent.py @@ -17,6 +17,13 @@ class QQOfficialEvent(dict): """ return self.get('t', '') + @property + def is_at_bot(self) -> bool: + """Whether QQ identified a native mention of the current bot.""" + return self.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'} or ( + self.t == 'GROUP_MESSAGE_CREATE' and self.get('is_at_bot') is True + ) + @property def user_openid(self) -> str: """ @@ -85,7 +92,7 @@ class QQOfficialEvent(dict): """ 成员openid """ - return self.get('openid', {}) + return self.get('member_openid') or self.get('openid') or '' @property def attachments(self) -> str: diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py index 7703a5935..d8dcad0c9 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py @@ -439,7 +439,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl def _reply_target(event: QQOfficialEvent) -> tuple[str, str]: if event.t == 'C2C_MESSAGE_CREATE': return 'person', event.user_openid - if event.t == 'GROUP_AT_MESSAGE_CREATE': + if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}: return 'group', event.group_openid if event.t == 'AT_MESSAGE_CREATE': return 'channel', event.channel_id diff --git a/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py index 66b550418..98344dc39 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py @@ -16,6 +16,7 @@ MESSAGE_EVENT_TYPES = { 'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', + 'GROUP_MESSAGE_CREATE', 'AT_MESSAGE_CREATE', } REACTION_ADD_EVENT_TYPES = {'MESSAGE_REACTION_ADD'} @@ -89,7 +90,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter) chat_type = platform_entities.ChatType.PRIVATE chat_id = self._private_chat_id(event) group = None - if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: + if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: chat_type = platform_entities.ChatType.GROUP chat_id = event.channel_id if event.t == 'AT_MESSAGE_CREATE' else event.group_openid chat_id = chat_id or event.group_openid or event.channel_id or '' @@ -205,7 +206,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter) @staticmethod def _sender_id(event: QQOfficialEvent) -> str: member_openid = event.member_openid or event.get('member_openid', '') - if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: + if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: return member_openid or event.user_openid or event.d_author_id or '' return event.user_openid or member_openid or event.d_author_id or event.guild_id or event.group_openid or '' diff --git a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py index 943068e60..545046f1a 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py @@ -70,8 +70,8 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver platform_message.Source(id=event.d_id or event.id or '', time=_parse_timestamp(event.timestamp)), ] - if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: - components.append(platform_message.At(target='justbot')) + if event.is_at_bot: + components.append(platform_message.At(target=event.get('bot_account_id') or 'justbot')) if event.attachments: try: diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index f012b07f9..fcc4c1a93 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -146,11 +146,12 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter) remark='', ) return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, source_platform_object=event) - if event.t == 'GROUP_AT_MESSAGE_CREATE': - yiri_chain.insert(0, platform_message.At(target='justbot')) + if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}: + if event.is_at_bot: + yiri_chain.insert(0, platform_message.At(target='justbot')) sender = platform_entities.GroupMember( - id=event.group_openid, + id=event.member_openid, member_name=event.t, permission='MEMBER', group=platform_entities.Group( @@ -300,7 +301,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter if qq_official_event.t == 'C2C_MESSAGE_CREATE': target_type = 'c2c' target_id = qq_official_event.user_openid - elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE': + elif qq_official_event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}: target_type = 'group' target_id = qq_official_event.group_openid elif qq_official_event.t == 'AT_MESSAGE_CREATE': @@ -430,6 +431,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter self.bot.on_message('C2C_MESSAGE_CREATE')(on_message) elif event_type == platform_events.GroupMessage: self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message) + self.bot.on_message('GROUP_MESSAGE_CREATE')(on_message) self.bot.on_message('AT_MESSAGE_CREATE')(on_message) def set_bot_uuid(self, bot_uuid: str): @@ -476,6 +478,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter 'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', + 'GROUP_MESSAGE_CREATE', 'AT_MESSAGE_CREATE', } if event_type not in message_event_types: @@ -823,7 +826,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter return None if source.t == 'C2C_MESSAGE_CREATE': return 'c2c', source.user_openid - if source.t == 'GROUP_AT_MESSAGE_CREATE': + if source.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}: return 'group', source.group_openid if source.t == 'AT_MESSAGE_CREATE': return 'channel', source.channel_id diff --git a/tests/unit_tests/platform/test_qqofficial_full_group_forms.py b/tests/unit_tests/platform/test_qqofficial_full_group_forms.py new file mode 100644 index 000000000..6b9af4d29 --- /dev/null +++ b/tests/unit_tests/platform/test_qqofficial_full_group_forms.py @@ -0,0 +1,36 @@ +"""QQ group form replies preserve their native target and passive-reply anchor.""" + +import pytest + +import langbot.pkg.core.app # noqa: F401 -- initialize legacy platform registration +from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter +from tests.unit_tests.platform.test_qqofficial_full_group_messages import group_payload, receive_message + + +@pytest.mark.asyncio +@pytest.mark.parametrize('transport', ['gateway', 'webhook']) +@pytest.mark.parametrize('event_type', ['GROUP_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE']) +async def test_group_form_reply_preserves_buttons_and_passive_reply_target(monkeypatch, transport, event_type): + received, outbound = await receive_message( + monkeypatch, + QQOfficialAdapter, + transport, + event_type, + group_payload(), + form_data={ + 'node_title': 'Choose an option', + 'form_content': 'Confirm this request', + 'actions': [{'id': 'confirm', 'title': 'Confirm'}], + }, + ) + + assert len(received) == 1 + assert len(outbound) == 1 + path, payload = outbound[0] + assert path == '/v2/groups/group-one/messages' + assert payload['msg_type'] == 2 + assert payload['msg_id'] == 'message-one' + assert payload['markdown']['content'] == '### Choose an option\n\nConfirm this request' + button = payload['keyboard']['content']['rows'][0]['buttons'][0] + assert button['render_data']['label'] == 'Confirm' + assert button['action']['data'] == 'confirm' diff --git a/tests/unit_tests/platform/test_qqofficial_full_group_messages.py b/tests/unit_tests/platform/test_qqofficial_full_group_messages.py new file mode 100644 index 000000000..8d5bb3751 --- /dev/null +++ b/tests/unit_tests/platform/test_qqofficial_full_group_messages.py @@ -0,0 +1,232 @@ +"""Full-group gateway messages retain group identity and passive replies.""" + +import base64 +import json +import time +from types import SimpleNamespace +from unittest.mock import Mock + +import httpx +import pytest +from quart import Quart, request + +import langbot.pkg.core.app # noqa: F401 -- initialize legacy platform registration +from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter +from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter as LegacyQQOfficialAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities, events, message +from langbot.pkg.utils import httpclient + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.parametrize('adapter_class', [QQOfficialAdapter, LegacyQQOfficialAdapter]), + pytest.mark.parametrize('transport', ['gateway', 'webhook']), +] +PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=') + + +def group_payload(content='hello'): + return { + 'id': 'message-one', + 'group_openid': 'group-one', + 'timestamp': '2026-10-24T12:00:00+0800', + 'content': content, + 'author': {'member_openid': 'member-one'}, + 'attachments': [], + } + + +async def receive_message(monkeypatch, adapter_class, transport, event_type, payload, *, form_data=None): + received, outbound = [], [] + adapter = adapter_class( + {'appid': 'fixture-app', 'secret': 'fixture-secret', 'token': '', 'enable-webhook': transport == 'webhook'}, + Mock(spec=AbstractEventLogger), + ) + adapter.bot.access_token = 'fixture-token' + adapter.bot.access_token_expiry_time = time.time() + 3600 + + async def gateway(on_event, on_ready, on_error): + await on_event(event_type, payload) + + monkeypatch.setattr(adapter.bot, 'connect_gateway_loop', gateway) + real_client = httpx.AsyncClient + + async def respond(request): + outbound.append((request.url.path, json.loads(request.content))) + return httpx.Response(200, json={'id': 'reply-one'}, request=request) + + def client(*args, **kwargs): + return real_client(*args, **kwargs, transport=httpx.MockTransport(respond)) + + monkeypatch.setattr(httpx, 'AsyncClient', client) + + async def listener(event, active_adapter): + received.append(event) + reply = message.MessageChain([message.Plain(text='fixture reply')]) + if form_data is None: + await active_adapter.reply_message(event, reply) + else: + await active_adapter.reply_message_chunk(event, SimpleNamespace(_form_data=form_data), reply, is_final=True) + + listener_type = events.MessageReceivedEvent + if adapter_class is LegacyQQOfficialAdapter: + listener_type = events.FriendMessage if event_type == 'C2C_MESSAGE_CREATE' else events.GroupMessage + adapter.register_listener(listener_type, listener) + try: + if transport == 'gateway': + await adapter.run_async() + else: + app = Quart(__name__) + + @app.post('/qq') + async def callback(): + return await adapter.handle_unified_webhook('fixture-bot', '', request) + + response = await app.test_client().post('/qq', json={'op': 0, 't': event_type, 'd': payload}) + assert response.status_code == 200 + finally: + await adapter.kill() + return received, outbound + + +async def test_unmentioned_full_group_message_retains_identity_and_passive_reply(monkeypatch, adapter_class, transport): + received, outbound = await receive_message( + monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', group_payload() + ) + assert len(received) == 1 + event = received[0] + assert event.sender.id == 'member-one' + assert event.group.id == 'group-one' + if isinstance(event, events.MessageReceivedEvent): + assert event.chat_type == entities.ChatType.GROUP + assert event.chat_id == 'group-one' + assert event.source_platform_object.t == 'GROUP_MESSAGE_CREATE' + assert not any(isinstance(component, message.At) for component in event.message_chain) + assert [component.text for component in event.message_chain if isinstance(component, message.Plain)] == ['hello'] + assert len(outbound) == 1 + assert outbound[0][0] == '/v2/groups/group-one/messages' + assert outbound[0][1]['msg_id'] == 'message-one' + + +@pytest.mark.parametrize( + 'key,content,expected', + [ + ('id', '<@bot-one> /help', ' /help'), + ('member_openid', '/help <@!bot-one>', '/help '), + ('openid', '<@other-one> <@bot-one> help', '<@other-one> help'), + ], +) +async def test_native_self_mention_in_full_group_mode_still_marks_bot_and_strips_token( + monkeypatch, adapter_class, transport, key, content, expected +): + payload = group_payload(content) + payload['mentions'] = [{key: 'bot-one', 'is_you': True}] + received, outbound = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload) + assert len(received) == 1 + chain = received[0].message_chain + expected_target = 'fixture-app' if adapter_class is QQOfficialAdapter else 'justbot' + assert [component.target for component in chain if isinstance(component, message.At)] == [expected_target] + assert [component.text for component in chain if isinstance(component, message.Plain)] == [expected] + assert received[0].source_platform_object.t == 'GROUP_MESSAGE_CREATE' + assert len(outbound) == 1 + + +@pytest.mark.parametrize( + 'mentions', + [ + None, + {'id': 'bot-one', 'is_you': True}, + [None, 'bot-one'], + [{'id': 'bot-one', 'bot': True}], + [{'id': 'bot-one', 'is_you': False}], + [{'id': 'bot-one', 'is_you': 1}], + [{'id': 'bot-one', 'is_you': 'true'}], + [{'id': 'other-one', 'is_you': True}], + [{'id': '<@all>', 'is_you': True}], + [{'id': 'x' * 129, 'is_you': True}], + ], +) +async def test_other_or_unverified_mentions_do_not_authorize_bot_response( + monkeypatch, adapter_class, transport, mentions +): + content = '<@bot-one> @fixture-app /help' + payload = group_payload(content) + payload.update(mentions=mentions, is_at_bot=True, bot_account_id='untrusted') + received, _ = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload) + assert len(received) == 1 + assert not any(isinstance(component, message.At) for component in received[0].message_chain) + assert [component.text for component in received[0].message_chain if isinstance(component, message.Plain)] == [ + content + ] + + +async def test_group_image_without_mention_remains_an_image_without_at(monkeypatch, adapter_class, transport): + class Download: + headers = {'Content-Length': str(len(PNG))} + + def raise_for_status(self): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def iter_chunked(self, size): + yield PNG + + @property + def content(self): + return self + + downloads = [] + + def get(url): + downloads.append(url) + return Download() + + monkeypatch.setattr(httpclient, 'get_session', lambda: SimpleNamespace(get=get)) + payload = group_payload('') + payload['attachments'] = [{'url': 'https://images.example/image.png', 'content_type': 'image/png'}] + received, outbound = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload) + assert len(received) == 1 + assert not any(isinstance(component, message.At) for component in received[0].message_chain) + images = [component for component in received[0].message_chain if isinstance(component, message.Image)] + assert len(images) == 1 and images[0].url == 'https://images.example/image.png' + assert base64.b64decode(images[0].base64.split(',')[-1]) == PNG + assert downloads == ['https://images.example/image.png'] + assert len(outbound) == 1 and outbound[0][1]['msg_id'] == 'message-one' + + +@pytest.mark.parametrize('author', [{'openid': 'old-member'}, {'member_openid': 'member-one', 'openid': 'old-member'}]) +async def test_member_id_supports_legacy_alias_and_prefers_canonical_field( + monkeypatch, adapter_class, transport, author +): + payload = group_payload() + payload['author'] = author + received, _ = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload) + assert received[0].sender.id == author.get('member_openid', 'old-member') + + +async def test_existing_at_only_mode_still_marks_the_bot(monkeypatch, adapter_class, transport): + received, outbound = await receive_message( + monkeypatch, adapter_class, transport, 'GROUP_AT_MESSAGE_CREATE', group_payload('/help') + ) + expected_target = 'fixture-app' if adapter_class is QQOfficialAdapter else 'justbot' + assert [component.target for component in received[0].message_chain if isinstance(component, message.At)] == [ + expected_target + ] + assert len(outbound) == 1 and outbound[0][1]['msg_id'] == 'message-one' + + +async def test_private_message_still_uses_its_original_reply_target(monkeypatch, adapter_class, transport): + payload = group_payload() + payload['author'] = {'user_openid': 'private-one'} + payload.pop('group_openid') + received, outbound = await receive_message(monkeypatch, adapter_class, transport, 'C2C_MESSAGE_CREATE', payload) + assert received[0].sender.id == 'private-one' + assert not any(isinstance(component, message.At) for component in received[0].message_chain) + assert outbound[0][0] == '/v2/users/private-one/messages' + assert outbound[0][1]['msg_id'] == 'message-one'