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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
JERRY
2026-10-28 09:57:00 +08:00
committed by GitHub
co-authored by Chin Rock multica-agent
parent 6b57a1074f
commit 0c99832298
9 changed files with 338 additions and 13 deletions
@@ -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 |
+20 -1
View File
@@ -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 = [
@@ -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:
@@ -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
@@ -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 ''
@@ -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:
@@ -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
@@ -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'
@@ -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'