Files
LangBot/tests/unit_tests/platform/test_omni_mainline_parity.py
T

399 lines
17 KiB
Python

"""Regression coverage for master fixes carried into the Omni adapter path."""
import asyncio
import base64
import importlib
import json
import time
import zlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import aiocqhttp
import pytest
import yaml
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpEventConverter as LegacyOneBotConverter
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import message as pm
PLATFORM = Path(__file__).parents[3] / 'src/langbot/pkg/platform'
OMNI_NAMES = sorted(p.parent.name for p in (PLATFORM / 'adapters').glob('*/manifest.yaml'))
def chain(text):
return pm.MessageChain([pm.Plain(text=text)])
@pytest.mark.parametrize('name', OMNI_NAMES)
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']
assert {c['name'] for c in legacy['config']} <= {c['name'] for c in omni['config']}
assert omni['help_links'] == legacy['help_links']
@pytest.mark.asyncio
@pytest.mark.parametrize('prefix', ['', 'base64://', 'data:image/png;base64,'])
@pytest.mark.parametrize('kind', [pm.Image, pm.Voice, pm.File])
async def test_onebot_media_base64_is_normalized_once(prefix, kind):
component = kind(base64=prefix + 'YWJj')
output, _, _ = await AiocqhttpMessageConverter.yiri2target(pm.MessageChain([component]))
assert output[0].data['file'] == 'base64://YWJj'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'raw,expected',
[
(
{
'app': 'com.tencent.structmsg',
'meta': {'detail_1': {'desc': 'Article', 'qqdocurl': 'https://example.test/a'}},
},
'Article',
),
({'app': 'music', 'meta': {'music': {'title': 'Song', 'jumpUrl': 'https://example.test/song'}}}, 'Song'),
('{invalid', '[收到一张JSON卡片]'),
],
)
async def test_onebot_json_cards_are_readable(raw, expected):
payload = json.dumps(raw) if isinstance(raw, dict) else raw
result = await AiocqhttpMessageConverter.target2yiri([{'type': 'json', 'data': {'data': payload}}])
assert expected in result[1].text
@pytest.mark.asyncio
async def test_onebot_metadata_lookup_is_cached_and_survives_legacy_conversion():
bot = SimpleNamespace(
get_group_info=AsyncMock(return_value={'group_name': 'Team'}),
get_group_member_info=AsyncMock(return_value={'title': 'Maintainer'}),
)
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1,
'time': 1,
'group_id': 2,
'user_id': 3,
'message': 'hello',
'sender': {'user_id': 3, 'nickname': 'Alice', 'role': 'admin'},
}
)
lookup = LegacyOneBotConverter()
first = await AiocqhttpEventConverter.target2yiri(event, bot, lookup=lookup)
second = await AiocqhttpEventConverter.target2legacy(event, bot, lookup=lookup)
assert first.group.name == second.group.name == 'Team'
assert first.sender_member.title == second.sender.special_title == 'Maintainer'
bot.get_group_info.assert_awaited_once()
bot.get_group_member_info.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', [pm.Image, pm.Voice, pm.File])
async def test_discord_outbound_base64_obeys_mainline_limit(monkeypatch, kind):
legacy = importlib.import_module('langbot.pkg.platform.sources.discord')
monkeypatch.setattr(legacy, '_MAX_DISCORD_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='exceeds'):
await DiscordMessageConverter.yiri2target(pm.MessageChain([kind(base64=base64.b64encode(b'12345').decode())]))
@pytest.mark.asyncio
async def test_lark_component_loading_obeys_mainline_limit(monkeypatch, tmp_path):
legacy = importlib.import_module('langbot.pkg.platform.sources.lark')
monkeypatch.setattr(legacy, '_MAX_LARK_MEDIA_BYTES', 4)
file = tmp_path / 'oversized.txt'
file.write_bytes(b'12345')
assert await LarkMessageConverter._get_component_bytes(pm.File(path=str(file))) is None
assert await LarkMessageConverter._get_component_bytes(pm.File(url=file.as_uri())) is None
assert await LarkMessageConverter._get_component_bytes(pm.File(base64=base64.b64encode(b'12345').decode())) is None
@pytest.mark.asyncio
async def test_lark_callbacks_are_bounded_and_cancelled_on_shutdown():
bot = SimpleNamespace(_auto_reconnect=True, _disconnect=AsyncMock())
adapter = LarkAdapter.model_construct(config={}, bot=bot)
completed = []
async def work():
try:
await asyncio.Event().wait()
finally:
completed.append(True)
for _ in range(105):
adapter._submit_coro(work())
assert len(adapter.inbound_event_tasks) == 100
await asyncio.sleep(0)
await adapter.kill()
assert len(completed) == 100
assert not adapter.inbound_event_tasks
bot._disconnect.assert_awaited_once()
def test_lark_domains_and_markdown_table_rendering():
adapter = LarkAdapter.model_construct(config={})
for domain in ['https://open.larksuite.com', 'https://open.feishu.cn']:
assert adapter.build_api_client({'app_id': 'a', 'app_secret': 'b', 'domain': domain})._config.domain == domain
table = '| Name |\n| --- |\n| Alice |'
payloads = adapter._outbound_payloads([[{'tag': 'md', 'text': table}]], [])
assert payloads[0][0] == 'interactive'
assert payloads[0][1]['body']['elements'] == [{'tag': 'markdown', 'content': table}]
@pytest.mark.asyncio
async def test_qq_optional_token(monkeypatch):
module = importlib.import_module('langbot.pkg.platform.adapters.qqofficial.adapter')
client = MagicMock()
factory = MagicMock(return_value=client)
monkeypatch.setattr(module, 'QQOfficialClient', factory)
QQOfficialAdapter(config={'appid': 'app', 'secret': 'secret'}, logger=MagicMock(spec=AbstractEventLogger))
assert factory.call_args.kwargs['token'] == ''
@pytest.mark.asyncio
@pytest.mark.parametrize('target', ['c2c', 'group'])
@pytest.mark.parametrize('markdown', [False, True])
async def test_qq_markdown_configuration_reaches_send_api(target, markdown):
bot = SimpleNamespace(
**{
name: AsyncMock()
for name in [
'send_private_text_msg',
'send_private_markdown_msg',
'send_group_text_msg',
'send_group_markdown_msg',
]
}
)
adapter = QQOfficialAdapter.model_construct(config={'enable-markdown-rendering': markdown}, bot=bot)
await adapter._send_content_list(target, 'target', [{'type': 'text', 'content': '**hello**'}], msg_id='anchor')
name = f'send_{"private" if target == "c2c" else "group"}_{"markdown" if markdown else "text"}_msg'
assert getattr(bot, name).await_args.kwargs['content'] == '**hello**'
assert getattr(bot, name).await_args.kwargs['msg_id'] == 'anchor'
assert sum(m.await_count for m in vars(bot).values()) == 1
@pytest.mark.asyncio
async def test_qq_stream_sends_snapshots_and_cleans_final_state():
bot = SimpleNamespace(send_stream_msg=AsyncMock(return_value={'id': 'stream'}))
adapter = QQOfficialAdapter.model_construct(config={}, bot=bot)
adapter._stream_ctx['response'] = {
'user_openid': 'user',
'msg_id': 'anchor',
'stream_msg_id': None,
'msg_seq': 1,
'index': 0,
'last_update_ts': 0,
'accumulated_text': '',
'sent_length': 0,
'session_started': False,
}
adapter._stream_ctx_ts['response'] = time.time()
for text, final in [('Hello', False), ('Hello world', True)]:
await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain(text), is_final=final)
assert [c.kwargs['content'] for c in bot.send_stream_msg.await_args_list] == ['Hello', 'Hello world']
assert bot.send_stream_msg.await_args.kwargs['input_state'] == 10
assert not adapter._stream_ctx and not adapter._stream_ctx_ts
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['image', 'voice', 'file'])
@pytest.mark.parametrize('stream', [False, True])
async def test_wecombot_reply_uploads_media_instead_of_placeholder(kind, stream):
bot = SimpleNamespace(
reply_text=AsyncMock(),
push_stream_chunk=AsyncMock(return_value=True),
upload_media=AsyncMock(return_value={'media_id': 'media'}),
reply_image=AsyncMock(),
reply_voice=AsyncMock(),
reply_file=AsyncMock(),
)
adapter = WecomBotAdapter.model_construct(config={}, bot=bot)
event = WecomBotEvent({'message_id': 'message', 'req_id': 'request'})
source = SimpleNamespace(source_platform_object=event)
part = {'image': pm.Image, 'voice': pm.Voice, 'file': pm.File}[kind](base64=base64.b64encode(b'payload').decode())
message = pm.MessageChain([pm.Plain(text='hello'), part])
if stream:
await adapter.reply_message_chunk(source, {}, message, is_final=False)
bot.upload_media.assert_not_awaited()
await adapter.reply_message_chunk(source, {}, message, is_final=True)
else:
await adapter.reply_message(source, message)
assert bot.upload_media.await_args.args[0] == b'payload'
assert bot.upload_media.await_args.kwargs['media_type'] == kind
getattr(bot, f'reply_{kind}').assert_awaited_once_with('request', 'media')
@pytest.mark.asyncio
@pytest.mark.parametrize(
'name,cls', [('wecom', 'WecomAdapter'), ('wecomcs', 'WecomCSAdapter'), ('qqofficial', 'QQOfficialAdapter')]
)
async def test_shutdown_closes_client(name, cls):
module = importlib.import_module(f'langbot.pkg.platform.adapters.{name}.adapter')
bot = SimpleNamespace(close=AsyncMock(), clear=MagicMock())
adapter = getattr(module, cls).model_construct(bot=bot, config={})
await adapter.kill()
bot.close.assert_awaited_once()
def test_kook_compressed_gateway_limit(monkeypatch):
source = importlib.import_module('langbot.pkg.platform.sources.kook')
omni = importlib.import_module('langbot.pkg.platform.adapters.kook.adapter')
monkeypatch.setattr(source, '_KOOK_MAX_GATEWAY_MESSAGE_BYTES', 32)
assert json.loads(omni.KookAdapter._decode_ws_message(zlib.compress(b'{"s": 1}'))) == {'s': 1}
with pytest.raises(ValueError, match='size limit'):
omni.KookAdapter._decode_ws_message(zlib.compress(b' ' * 1000))
@pytest.mark.asyncio
async def test_discord_stream_edits_one_message_with_complete_text():
sent = SimpleNamespace(content='first', edit=AsyncMock())
channel = SimpleNamespace(send=AsyncMock(return_value=sent))
adapter = DiscordAdapter.model_construct(config={}, bot=None)
adapter._stream_buffer['response'] = {
'channel': channel,
'sent_message': None,
'last_content': '',
'chunk_count': 0,
}
await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain('first'))
await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain('first second'), is_final=True)
channel.send.assert_awaited_once_with('first')
sent.edit.assert_awaited_once_with(content='first second')
assert not adapter._stream_buffer
@pytest.mark.parametrize('name', ['domain', 'custom_domain'])
def test_lark_legacy_and_omni_have_identical_region_fields(name):
legacy = yaml.safe_load((PLATFORM / 'sources/lark.yaml').read_text())['spec']['config']
omni = yaml.safe_load((PLATFORM / 'adapters/lark/manifest.yaml').read_text())['spec']['config']
assert next(c for c in legacy if c['name'] == name) == next(c for c in omni if c['name'] == name)
@pytest.mark.asyncio
@pytest.mark.parametrize('variant', ['sources.lark', 'adapters.lark.adapter'])
@pytest.mark.parametrize(
'region,expected',
[
({}, 'https://open.feishu.cn'),
({'domain': 'https://open.feishu.cn'}, 'https://open.feishu.cn'),
({'domain': 'https://open.larksuite.com'}, 'https://open.larksuite.com'),
({'domain': 'custom', 'custom_domain': 'https://open.example.test/'}, 'https://open.example.test'),
],
)
async def test_lark_http_and_websocket_use_selected_region(monkeypatch, variant, region, expected):
import lark_oapi
module = importlib.import_module(f'langbot.pkg.platform.{variant}')
ws = MagicMock(spec=lark_oapi.ws.Client)
factory = MagicMock(return_value=ws)
monkeypatch.setattr(module, 'NonBlockingLarkWSClient', factory)
adapter = module.LarkAdapter(
config={'app_id': 'app', 'app_secret': 'secret', 'bot_name': 'bot', **region},
logger=MagicMock(spec=AbstractEventLogger),
)
assert factory.call_args.kwargs['domain'] == expected
assert adapter.api_client._config.domain == expected
@pytest.mark.asyncio
async def test_lark_resource_download_rejects_oversized_platform_response(monkeypatch):
import io
legacy = importlib.import_module('langbot.pkg.platform.sources.lark')
monkeypatch.setattr(legacy, '_MAX_LARK_MEDIA_BYTES', 4)
response = SimpleNamespace(success=lambda: True, raw=SimpleNamespace(headers={}), file=io.BytesIO(b'12345'))
client = SimpleNamespace(
im=SimpleNamespace(v1=SimpleNamespace(message_resource=SimpleNamespace(aget=AsyncMock(return_value=response))))
)
with pytest.raises(ValueError, match='exceeds'):
await LarkMessageConverter._download_resource(client, 'message', 'key', 'image')
@pytest.mark.asyncio
async def test_qq_non_stream_fallback_replaces_snapshot(monkeypatch):
reply = AsyncMock()
monkeypatch.setattr(QQOfficialAdapter, 'reply_message', reply)
adapter = QQOfficialAdapter.model_construct(config={}, bot=None)
for text, final in [('A', False), ('AB', False), ('ABC', True)]:
await adapter.reply_message_chunk(None, {'resp_message_id': 'fallback'}, chain(text), is_final=final)
reply.assert_awaited_once()
assert reply.await_args.args[1][0].text == 'ABC'
assert not adapter._fallback_text and not adapter._fallback_text_ts
@pytest.mark.asyncio
async def test_telegram_stream_reuses_persistent_message():
import telegram
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
update = MagicMock(spec=telegram.Update)
update.effective_chat = SimpleNamespace(id=123, type='private')
update.effective_message = SimpleNamespace(message_thread_id=None)
bot = SimpleNamespace(
send_message=AsyncMock(return_value=SimpleNamespace(message_id=456)), edit_message_text=AsyncMock()
)
adapter = TelegramAdapter.model_construct(config={}, bot=bot, msg_stream_id={}, seq=1)
source = SimpleNamespace(source_platform_object=update)
await adapter.create_message_card('response', source)
await adapter.reply_message_chunk(
source, SimpleNamespace(resp_message_id='response', msg_sequence=1, tool_calls=None), chain('Hello')
)
await adapter.reply_message_chunk(
source,
SimpleNamespace(resp_message_id='response', msg_sequence=2, tool_calls=None),
chain('Hello world'),
is_final=True,
)
bot.send_message.assert_awaited_once()
assert [call.kwargs['message_id'] for call in bot.edit_message_text.await_args_list] == [456, 456]
assert bot.edit_message_text.await_args.kwargs['text'] == 'Hello world'
assert not adapter.msg_stream_id
@pytest.mark.asyncio
async def test_telegram_inbound_image_does_not_expose_token_url(monkeypatch):
import datetime
from langbot.pkg.platform.sources import telegram as source
from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter
download = MagicMock()
download.__aenter__ = AsyncMock(return_value=SimpleNamespace())
download.__aexit__ = AsyncMock()
monkeypatch.setattr(source.httpclient, 'get_session', lambda **kwargs: SimpleNamespace(get=lambda url: download))
monkeypatch.setattr(source.httpclient, 'read_limited', AsyncMock(return_value=b'image'))
message = SimpleNamespace(
message_id=1,
date=datetime.datetime.now(),
text='',
caption='',
photo=[
SimpleNamespace(
get_file=AsyncMock(
return_value=SimpleNamespace(file_path='https://api.telegram.org/file/botSECRET/photo')
)
)
],
voice=None,
document=None,
)
bot = SimpleNamespace(
get_file=AsyncMock(return_value=SimpleNamespace(file_path='https://api.telegram.org/file/botSECRET/photo'))
)
converted = await TelegramMessageConverter.target2yiri(message, bot, 'bot')
image = next(p for p in converted if isinstance(p, pm.Image))
assert image.base64 and not image.url
assert 'SECRET' not in image.model_dump_json()