mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
399 lines
17 KiB
Python
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()
|