fix(onebot): validate WebSocket protocol with independent mock peer (#2653)

* feat(onebot): support main-port reverse and forward WebSocket connections

* fix(onebot): keep protocol metadata out of business events

* test(onebot): validate WS events and APIs against independent protocol peer

---------

Co-authored-by: Hyu <[email protected]>
This commit is contained in:
RockChinQ
2026-10-12 13:59:25 +08:00
committed by GitHub
co-authored by Hyu
parent 417c552694
commit ec021c3c41
5 changed files with 416 additions and 4 deletions
@@ -111,11 +111,17 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
type='message.deleted',
adapter_name='onebot-v11',
message_id=getattr(event, 'message_id', ''),
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
operator=AiocqhttpEventConverter.user(
getattr(event, 'user_id', None)
if notice_type == 'friend_recall'
else getattr(event, 'operator_id', None)
),
chat_type=platform_entities.ChatType.GROUP
if notice_type == 'group_recall'
else platform_entities.ChatType.PRIVATE,
chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')),
chat_id=getattr(event, 'group_id', '')
if notice_type == 'group_recall'
else getattr(event, 'user_id', ''),
group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None,
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
@@ -39,7 +39,7 @@ class OneBotConnection:
await self.validate()
await self.send(json.dumps({'action': action, 'params': params, 'echo': echo}))
result = await future
if result.get('status') not in ('ok', 'async') or result.get('retcode') not in (0, 1):
if (result.get('status'), result.get('retcode')) not in (('ok', 0), ('async', 1)):
raise ActionFailed(result)
return result.get('data')
finally:
+107
View File
@@ -0,0 +1,107 @@
"""Independent OneBot v11 wire peer; no LangBot transport/converter imports.
Contract: https://github.com/botuniverse/onebot-11 (api/public.md,
communication/{ws,ws-reverse,http}.md and event/*.md).
Only test data is served; no real QQ account or vendor service is contacted.
"""
import asyncio
import json
class OneBotMock:
def __init__(self):
self.ready = asyncio.Event()
self.requests = []
self.send = None
self.response = None
self.user = {'user_id': 10001, 'nickname': 'Alice', 'sex': 'unknown', 'age': 20}
self.group = {'group_id': 20001, 'group_name': 'Mock group', 'member_count': 2, 'max_member_count': 200}
self.member = self.user | {'group_id': 20001, 'card': 'Card', 'role': 'admin', 'title': 'Title', 'join_time': 1}
async def run(self, send, receive):
self.send = send
self.ready.set()
while True:
request = json.loads(await receive())
self.requests.append(request)
if self.response is not None:
response = dict(self.response)
else:
response = self.handle(request)
await send(json.dumps(response | {'echo': request.get('echo')}))
def handle(self, request):
action, params = request['action'], request.get('params', {})
for key in ('user_id', 'group_id', 'message_id', 'duration'):
if key in params:
assert type(params[key]) is int, (action, key, params[key])
data = None
if action in ('send_msg', 'send_private_msg', 'send_group_msg'):
assert isinstance(params['message'], (list, str))
data = {'message_id': 123}
elif action == 'get_msg':
data = self.message('private', 'friend')
elif action == 'get_login_info':
data = {'user_id': 42, 'nickname': 'Mock bot'}
elif action == 'get_stranger_info':
data = self.user
elif action == 'get_friend_list':
data = [self.user | {'remark': 'Friend'}]
elif action == 'get_group_info':
data = self.group
elif action == 'get_group_list':
data = [self.group]
elif action == 'get_group_member_info':
data = self.member
elif action == 'get_group_member_list':
data = [self.member]
elif action == 'get_status':
data = {'online': True, 'good': True}
elif action == 'get_version_info':
data = {'app_name': 'onebot-test-peer', 'app_version': '1', 'protocol_version': 'v11'}
elif action in ('can_send_image', 'can_send_record'):
data = {'yes': True}
elif action in ('get_image', 'get_record'):
data = {'file': '/mock/media'}
elif action == 'get_forward_msg':
data = {'message': []}
elif action == 'get_group_honor_info':
data = {'group_id': 20001}
elif action not in {
'delete_msg',
'set_group_name',
'set_group_ban',
'set_group_kick',
'set_group_leave',
'set_friend_add_request',
'set_group_add_request',
'set_group_card',
'set_group_special_title',
'set_group_admin',
'set_group_whole_ban',
}:
return {'status': 'failed', 'retcode': 1404, 'data': None}
return {'status': 'ok', 'retcode': 0, 'data': data}
async def emit(self, payload):
await self.ready.wait()
await self.send(json.dumps({'time': 1700000000, 'self_id': 42} | payload))
def message(self, kind, subtype, message=None):
result = {
'post_type': 'message',
'message_type': kind,
'sub_type': subtype,
'time': 1700000000,
'self_id': 42,
'message_id': 9,
'user_id': 10001,
'message': message if message is not None else [{'type': 'text', 'data': {'text': 'hello'}}],
'raw_message': 'hello',
'font': 0,
'sender': self.user,
}
if kind == 'group':
result.update(group_id=20001, anonymous=None, sender=self.member)
return result
@@ -39,7 +39,12 @@ def chain(text):
def test_omni_config_contains_mainline_options_and_help(name):
legacy = yaml.safe_load((PLATFORM / 'sources' / f'{name}.yaml').read_text())['spec']
omni = yaml.safe_load((PLATFORM / 'adapters' / name / 'manifest.yaml').read_text())['spec']
retired = {'bot_name'} if name == 'lark' else set()
retired = {'lark': {'bot_name'}, 'aiocqhttp': {'host', 'port'}}.get(name, set())
if name == 'aiocqhttp':
# The new adapter uses the Core HTTP port or an outbound WS URL.
fields = {c['name'] for c in omni['config']}
assert {'connection_mode', 'ws_url'} <= fields
assert not {'host', 'port'} & fields
assert {c['name'] for c in legacy['config']} - retired <= {c['name'] for c in omni['config']}
assert omni['help_links'] == legacy['help_links']
@@ -0,0 +1,294 @@
"""Wire-level conformance tests against an independent OneBot v11 peer."""
import asyncio
from contextlib import asynccontextmanager
import pytest
from quart import Quart
from websockets.asyncio.server import serve
from aiocqhttp.exceptions import ActionFailed
from langbot_plugin.api.entities.builtin.platform import events, message
from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
from tests.unit_tests.platform.onebot_mock import OneBotMock
from tests.unit_tests.platform.test_aiocqhttp_eba_adapter import make_adapter
from tests.unit_tests.platform.test_onebot_transport import bind
@asynccontextmanager
async def session(mode):
adapter, peer = make_adapter(), OneBotMock()
_, ap = bind(adapter)
tasks = []
try:
if mode == 'forward_ws':
async def endpoint(ws):
assert ws.request.headers['Authorization'] == 'Bearer secret'
task = asyncio.create_task(peer.run(ws.send, ws.recv))
tasks.append(task)
await asyncio.gather(task, return_exceptions=True)
async with serve(endpoint, '127.0.0.1', 0) as server:
adapter.config.update(
connection_mode=mode, ws_url=f'ws://127.0.0.1:{server.sockets[0].getsockname()[1]}'
)
tasks.append(asyncio.create_task(adapter.run_async()))
await asyncio.wait_for(peer.ready.wait(), 3)
# Establish account metadata and synchronize through a real API.
await peer.emit({'post_type': 'meta_event', 'meta_event_type': 'lifecycle', 'sub_type': 'connect'})
for _ in range(300):
if adapter.bot.connection:
break
await asyncio.sleep(0.01)
yield adapter, peer
await adapter.kill()
else:
app = Quart(__name__)
await WebhookRouterGroup(ap, app).initialize()
headers = {'Authorization': 'Bearer secret', 'X-Self-ID': '42', 'X-Client-Role': 'Universal'}
async with app.test_client().websocket('/bots/bot-a/onebot/v11/ws', headers=headers) as ws:
tasks.append(asyncio.create_task(peer.run(ws.send, ws.receive)))
await asyncio.wait_for(peer.ready.wait(), 3)
for _ in range(300):
if adapter.bot.connection:
break
await asyncio.sleep(0.01)
yield adapter, peer
finally:
await adapter.kill()
for task in tasks:
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, AssertionError):
raise result
@pytest.fixture(params=['forward_ws', 'reverse_ws'])
async def wire(request):
async with session(request.param) as connected:
async with asyncio.timeout(15):
yield connected
@pytest.mark.parametrize(
'kind,subtype',
[
('private', 'friend'),
('private', 'group'),
('private', 'other'),
('group', 'normal'),
('group', 'anonymous'),
('group', 'notice'),
],
)
@pytest.mark.parametrize('encoding', ['array', 'cq'])
async def test_messages_and_quoted_reply(wire, kind, subtype, encoding):
adapter, peer = wire
received = asyncio.Queue()
async def callback(event, source):
await source.reply_message(event, message.MessageChain([message.Plain(text='reply')]), quote_origin=True)
await received.put(event)
adapter.register_listener(events.MessageReceivedEvent, callback)
body = (
'hi &amp; &#91;x&#93;[CQ:at,qq=42]'
if encoding == 'cq'
else [
{'type': 'text', 'data': {'text': 'hi & [x]'}},
{'type': 'at', 'data': {'qq': '42'}},
]
)
await peer.emit(peer.message(kind, subtype, body))
event = await asyncio.wait_for(received.get(), 3)
assert str(event.chat_id) == ('20001' if kind == 'group' else '10001')
assert event.message_chain.get_first(message.Plain).text == 'hi & [x]'
assert str(event.message_chain.get_first(message.At).target) == '42'
request = peer.requests[-1]
assert request['action'] == 'send_msg'
assert request['params']['message_type'] == kind
assert request['params']['message'][0] == {'type': 'reply', 'data': {'id': '9'}}
NOTICES = [
({'notice_type': 'group_upload', 'file': {'id': 'f', 'name': 'a.txt', 'size': 1, 'busid': 0}}, 'platform.specific'),
({'notice_type': 'group_admin', 'sub_type': 'set'}, 'platform.specific'),
({'notice_type': 'group_admin', 'sub_type': 'unset'}, 'platform.specific'),
({'notice_type': 'group_increase', 'sub_type': 'approve'}, 'group.member_joined'),
({'notice_type': 'group_increase', 'sub_type': 'invite'}, 'group.member_joined'),
({'notice_type': 'group_increase', 'sub_type': 'invite', 'user_id': 42}, 'bot.invited_to_group'),
({'notice_type': 'group_decrease', 'sub_type': 'leave'}, 'group.member_left'),
({'notice_type': 'group_decrease', 'sub_type': 'kick'}, 'group.member_left'),
({'notice_type': 'group_decrease', 'sub_type': 'kick_me', 'user_id': 42}, 'bot.removed_from_group'),
({'notice_type': 'group_ban', 'sub_type': 'ban', 'duration': 60}, 'group.member_banned'),
({'notice_type': 'group_ban', 'sub_type': 'lift_ban', 'duration': 0}, 'platform.specific'),
({'notice_type': 'group_ban', 'sub_type': 'ban', 'duration': 60, 'user_id': 42}, 'bot.muted'),
({'notice_type': 'group_ban', 'sub_type': 'lift_ban', 'duration': 0, 'user_id': 42}, 'bot.unmuted'),
({'notice_type': 'friend_add'}, 'friend.added'),
({'notice_type': 'group_recall', 'message_id': 9}, 'message.deleted'),
({'notice_type': 'friend_recall', 'message_id': 9}, 'message.deleted'),
({'notice_type': 'notify', 'sub_type': 'poke', 'target_id': 42}, 'platform.specific'),
({'notice_type': 'notify', 'sub_type': 'lucky_king', 'target_id': 42}, 'platform.specific'),
({'notice_type': 'notify', 'sub_type': 'honor', 'honor_type': 'talkative'}, 'platform.specific'),
]
@pytest.mark.parametrize('payload,expected', NOTICES)
async def test_notices(wire, payload, expected):
adapter, peer = wire
received = asyncio.Queue()
async def callback(event, source):
await received.put(event)
adapter.register_listener(events.EBAEvent, callback)
base = {'post_type': 'notice', 'user_id': 10001}
if payload['notice_type'].startswith('group_') or payload['notice_type'] == 'notify':
base.update(group_id=20001, operator_id=10002)
await peer.emit(base | payload)
event = await asyncio.wait_for(received.get(), 3)
assert event.type == expected
assert event.timestamp == 1700000000
if payload['notice_type'] == 'friend_recall':
assert str(event.operator.id) == '10001'
assert str(event.chat_id) == '10001'
if expected == 'platform.specific':
assert event.data['notice_type'] == payload['notice_type']
@pytest.mark.parametrize(
'kind,subtype,expected',
[
('friend', None, 'friend.request_received'),
('group', 'invite', 'bot.invited_to_group'),
('group', 'add', 'platform.specific'),
],
)
async def test_requests(wire, kind, subtype, expected):
adapter, peer = wire
received = asyncio.Queue()
async def callback(event, source):
if kind == 'friend':
await source.approve_friend_request(event.request_id, False, 'no')
elif subtype == 'invite':
await source.approve_group_invite(event.request_id, False)
await received.put(event)
adapter.register_listener(events.EBAEvent, callback)
payload = {
'post_type': 'request',
'request_type': kind,
'user_id': 10001,
'comment': 'please',
'flag': 'opaque-flag',
}
if subtype:
payload.update(sub_type=subtype, group_id=20001)
await peer.emit(payload)
event = await asyncio.wait_for(received.get(), 3)
assert event.type == expected
if expected != 'platform.specific':
assert peer.requests[-1]['params']['flag'] == 'opaque-flag'
assert peer.requests[-1]['params']['approve'] is False
async def test_api_surface(wire):
adapter, peer = wire
chain = message.MessageChain([message.Plain(text='test')])
for target in ('person', 'private', 'group'):
assert str((await adapter.send_message(target, '10001', chain)).message_id) == '123'
await adapter.delete_message('person', 10001, 9)
assert str((await adapter.get_message('person', 10001, 9)).message_id) == '9'
for target in ('person', 'private', 'group'):
assert str((await adapter.forward_message('person', 10001, 9, target, 10001)).message_id) == '123'
assert (await adapter.get_group_info('20001')).name == 'Mock group'
assert len(await adapter.get_group_list()) == 1
assert len(await adapter.get_group_member_list('20001')) == 1
assert (await adapter.get_group_member_info('20001', '10001')).display_name == 'Card'
assert (await adapter.get_user_info('10001')).nickname == 'Alice'
assert len(await adapter.get_friend_list()) == 1
await adapter.set_group_name('20001', 'new')
await adapter.mute_member('20001', '10001', 60)
assert peer.requests[-1]['params']['duration'] == 60
await adapter.unmute_member('20001', '10001')
assert peer.requests[-1]['params']['duration'] == 0
await adapter.kick_member('20001', '10001')
await adapter.leave_group('20001')
await adapter.approve_friend_request('flag', True, 'remark')
await adapter.approve_group_invite('flag')
for action, params in [
('get_login_info', {}),
('get_status', {}),
('get_version_info', {}),
('get_group_honor_info', {'group_id': 20001, 'type': 'all'}),
('set_group_card', {'group_id': 20001, 'user_id': 10001, 'card': 'card'}),
('set_group_special_title', {'group_id': 20001, 'user_id': 10001, 'special_title': 't'}),
('set_group_admin', {'group_id': 20001, 'user_id': 10001, 'enable': True}),
('set_group_whole_ban', {'group_id': 20001, 'enable': True}),
('get_forward_msg', {'id': 'f'}),
('get_record', {'file': 'f', 'out_format': 'mp3'}),
('get_image', {'file': 'f'}),
('can_send_image', {}),
('can_send_record', {}),
]:
result = await adapter.call_platform_api(action, params)
assert isinstance(result, dict)
assert peer.requests[-1]['action'] == action
assert peer.requests[-1]['params'] == params
async def test_metadata_self_messages_and_recovery(wire):
adapter, peer = wire
received = asyncio.Queue()
async def callback(event, source):
await received.put(event)
adapter.register_listener(events.EBAEvent, callback)
for subtype in ('connect', 'enable', 'disable'):
await peer.emit({'post_type': 'meta_event', 'meta_event_type': 'lifecycle', 'sub_type': subtype})
await peer.emit(
{
'post_type': 'meta_event',
'meta_event_type': 'heartbeat',
'status': {'online': True, 'good': True},
'interval': 5000,
}
)
await peer.emit(peer.message('private', 'friend') | {'user_id': 42})
# An invalid business event must not tear down the transport or block later events.
await peer.emit(peer.message('private', 'friend', [{'type': 'face', 'data': {'id': 'invalid'}}]))
await peer.emit(peer.message('private', 'friend'))
event = await asyncio.wait_for(received.get(), 3)
assert event.type == 'message.received'
assert received.empty()
assert adapter.bot_account_id == '42'
results = await asyncio.gather(*(adapter.bot.get_login_info() for _ in range(32)))
assert all(item['user_id'] == 42 for item in results)
assert len({item['echo'] for item in peer.requests}) == len(peer.requests)
assert not adapter.bot.connection.pending
@pytest.mark.parametrize(
'status,code,valid',
[
('ok', 0, True),
('async', 1, True),
('failed', 100, False),
('failed', 1400, False),
('failed', 1404, False),
('ok', 1, False),
('async', 0, False),
],
)
async def test_response_contract(wire, status, code, valid):
adapter, peer = wire
peer.response = {'status': status, 'retcode': code, 'data': None}
if valid:
assert await adapter.bot.call_action('delete_msg', message_id=9) is None
else:
with pytest.raises(ActionFailed):
await adapter.bot.call_action('delete_msg', message_id=9)