mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-28 19:35:27 +08:00
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:
co-authored by
Chin Rock
multica-agent
parent
6b57a1074f
commit
0c99832298
@@ -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 |
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user