mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 22:36:02 +00:00
327 lines
12 KiB
Python
327 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
|
|
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
|
|
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
|
|
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
|
|
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
|
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
|
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
|
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
|
|
|
|
|
class DummyLogger(AbstractEventLogger):
|
|
async def info(self, text, images=None, message_session_id=None, no_throw=True):
|
|
pass
|
|
|
|
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
|
|
pass
|
|
|
|
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
|
|
pass
|
|
|
|
async def error(self, text, images=None, message_session_id=None, no_throw=True):
|
|
pass
|
|
|
|
|
|
class DummyResponse:
|
|
def __init__(self, data=None, ok=True):
|
|
self.data = data or SimpleNamespace(message_id='reply-msg')
|
|
self.code = 0 if ok else 1
|
|
self.msg = 'ok' if ok else 'failed'
|
|
self.raw = SimpleNamespace(content='{}', headers={'content-type': 'text/plain'})
|
|
self.file = SimpleNamespace(read=lambda: b'data')
|
|
self._ok = ok
|
|
|
|
def success(self):
|
|
return self._ok
|
|
|
|
def get_log_id(self):
|
|
return 'log-id'
|
|
|
|
|
|
def message_item(message_id='msg-remote'):
|
|
return SimpleNamespace(
|
|
message_id=message_id,
|
|
msg_type='text',
|
|
create_time=1_714_000_000_000,
|
|
body=SimpleNamespace(content='{"text":"remote"}'),
|
|
mentions=[],
|
|
chat_id='chat-1',
|
|
)
|
|
|
|
|
|
class DummyAPIClient:
|
|
def __init__(self):
|
|
self.im = SimpleNamespace(
|
|
v1=SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
acreate=AsyncMock(return_value=DummyResponse()),
|
|
areply=AsyncMock(return_value=DummyResponse()),
|
|
aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))),
|
|
),
|
|
chat=SimpleNamespace(
|
|
aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(chat_id='chat-1', name='LangBot Team')))
|
|
),
|
|
image=SimpleNamespace(
|
|
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(image_key='img-key')))
|
|
),
|
|
file=SimpleNamespace(
|
|
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(file_key='file-key')))
|
|
),
|
|
message_resource=SimpleNamespace(aget=AsyncMock(return_value=DummyResponse())),
|
|
)
|
|
)
|
|
self.auth = SimpleNamespace(
|
|
v3=SimpleNamespace(
|
|
app_ticket=SimpleNamespace(resend=AsyncMock(return_value=DummyResponse())),
|
|
app_access_token=SimpleNamespace(create=AsyncMock(return_value=DummyResponse())),
|
|
tenant_access_token=SimpleNamespace(create=AsyncMock(return_value=DummyResponse())),
|
|
)
|
|
)
|
|
self.cardkit = SimpleNamespace(
|
|
v1=SimpleNamespace(
|
|
card=SimpleNamespace(create=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id')))),
|
|
card_element=SimpleNamespace(content=AsyncMock(return_value=DummyResponse())),
|
|
)
|
|
)
|
|
|
|
|
|
class DummyWSClient:
|
|
def __init__(self):
|
|
self._auto_reconnect = True
|
|
self._connect = AsyncMock()
|
|
self._disconnect = AsyncMock()
|
|
self._reconnect = AsyncMock()
|
|
|
|
|
|
def manifest() -> dict:
|
|
path = (
|
|
pathlib.Path(__file__).parents[3]
|
|
/ 'src'
|
|
/ 'langbot'
|
|
/ 'pkg'
|
|
/ 'platform'
|
|
/ 'adapters'
|
|
/ 'lark'
|
|
/ 'manifest.yaml'
|
|
)
|
|
return yaml.safe_load(path.read_text())
|
|
|
|
|
|
def make_adapter(config: dict | None = None) -> LarkAdapter:
|
|
adapter = LarkAdapter(
|
|
{
|
|
'app_id': 'cli_xxx',
|
|
'app_secret': 'secret',
|
|
'bot_name': 'LangBotDev',
|
|
'enable-webhook': False,
|
|
'enable-stream-reply': False,
|
|
'app_type': 'self',
|
|
**(config or {}),
|
|
},
|
|
DummyLogger(),
|
|
)
|
|
adapter.api_client = DummyAPIClient()
|
|
adapter.bot = DummyWSClient()
|
|
return adapter
|
|
|
|
|
|
def lark_event(chat_type='group', message_type='text', content=None):
|
|
message = SimpleNamespace(
|
|
message_id='msg-1',
|
|
message_type=message_type,
|
|
content=content or '{"text":"hello @_user_1"}',
|
|
create_time=1_714_000_000_000,
|
|
mentions=[SimpleNamespace(key='@_user_1', id='user-mention', name='Alice')],
|
|
chat_type=chat_type,
|
|
chat_id='chat-1',
|
|
parent_id=None,
|
|
thread_id=None,
|
|
)
|
|
sender = SimpleNamespace(sender_id=SimpleNamespace(open_id='user-1', union_id='Alice Union'))
|
|
header = SimpleNamespace(tenant_key='tenant-1')
|
|
return SimpleNamespace(event=SimpleNamespace(message=message, sender=sender), header=header, schema='2.0')
|
|
|
|
|
|
def test_lark_supported_events_match_manifest():
|
|
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
|
|
|
|
|
|
def test_lark_supported_apis_match_manifest():
|
|
supported_apis = make_adapter().get_supported_apis()
|
|
manifest_apis = manifest()['spec']['supported_apis']
|
|
|
|
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
|
|
|
|
|
|
def test_lark_platform_api_map_matches_manifest():
|
|
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
|
|
|
|
assert set(PLATFORM_API_MAP) == manifest_actions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lark_message_converter_maps_outbound_components():
|
|
with (
|
|
patch.object(LarkMessageConverter, 'upload_image_to_lark', AsyncMock(return_value='img-key')),
|
|
patch.object(LarkMessageConverter, 'upload_file_to_lark', AsyncMock(return_value='file-key')),
|
|
patch.object(LarkMessageConverter, '_get_component_bytes', AsyncMock(return_value=b'data')),
|
|
):
|
|
text_elements, media_items = await LarkMessageConverter.yiri2target(
|
|
platform_message.MessageChain(
|
|
[
|
|
platform_message.Plain(text='hello\n\nsecond'),
|
|
platform_message.At(target='ou_user', display='Alice'),
|
|
platform_message.AtAll(),
|
|
platform_message.Image(base64='ZGF0YQ=='),
|
|
platform_message.Voice(base64='ZGF0YQ==', length=2),
|
|
platform_message.File(name='doc.txt', base64='ZGF0YQ=='),
|
|
platform_message.Quote(
|
|
id='origin', origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])
|
|
),
|
|
platform_message.Forward(
|
|
node_list=[
|
|
platform_message.ForwardMessageNode(
|
|
sender_id='user-2',
|
|
sender_name='Bob',
|
|
message_chain=platform_message.MessageChain([platform_message.Plain(text='node')]),
|
|
)
|
|
]
|
|
),
|
|
]
|
|
),
|
|
DummyAPIClient(),
|
|
)
|
|
|
|
assert any(ele.get('text') == 'hello' for paragraph in text_elements for ele in paragraph)
|
|
assert any(ele.get('user_id') == 'ou_user' for paragraph in text_elements for ele in paragraph)
|
|
assert any(ele.get('user_id') == 'all' for paragraph in text_elements for ele in paragraph)
|
|
assert {'msg_type': 'image', 'content': {'image_key': 'img-key'}} in media_items
|
|
assert {'msg_type': 'audio', 'content': {'file_key': 'file-key'}} in media_items
|
|
assert {'msg_type': 'file', 'content': {'file_key': 'file-key'}} in media_items
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lark_message_converter_maps_inbound_components():
|
|
with patch.object(
|
|
LarkMessageConverter,
|
|
'_download_resource',
|
|
AsyncMock(
|
|
return_value={
|
|
'url': 'file:///tmp/file',
|
|
'path': '/tmp/file',
|
|
'base64': 'data:text/plain;base64,ZGF0YQ==',
|
|
'size': 4,
|
|
}
|
|
),
|
|
):
|
|
text_chain = await LarkMessageConverter.target2yiri(lark_event().event.message, DummyAPIClient())
|
|
image_chain = await LarkMessageConverter.target2yiri(
|
|
lark_event(message_type='image', content='{"image_key":"img-key"}').event.message, DummyAPIClient()
|
|
)
|
|
file_chain = await LarkMessageConverter.target2yiri(
|
|
lark_event(message_type='file', content='{"file_key":"file-key","file_name":"doc.txt"}').event.message,
|
|
DummyAPIClient(),
|
|
)
|
|
|
|
assert isinstance(text_chain[0], platform_message.Source)
|
|
assert isinstance(text_chain[1], platform_message.Plain)
|
|
assert isinstance(text_chain[2], platform_message.At)
|
|
assert text_chain[2].target == 'user-mention'
|
|
assert isinstance(image_chain[1], platform_message.Image)
|
|
assert image_chain[1].image_id == 'img-key'
|
|
assert isinstance(file_chain[1], platform_message.File)
|
|
assert file_chain[1].name == 'doc.txt'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lark_event_converter_maps_group_and_private_message():
|
|
group_event = await LarkEventConverter.target2yiri(lark_event('group'), DummyAPIClient())
|
|
|
|
assert isinstance(group_event, platform_events.MessageReceivedEvent)
|
|
assert group_event.adapter_name == 'lark-eba'
|
|
assert group_event.chat_type == platform_entities.ChatType.GROUP
|
|
assert group_event.chat_id == 'chat-1'
|
|
assert group_event.group.id == 'chat-1'
|
|
assert group_event.sender.id == 'user-1'
|
|
|
|
private_event = await LarkEventConverter.target2yiri(
|
|
lark_event('p2p', content='{"text":"hello"}'),
|
|
DummyAPIClient(),
|
|
)
|
|
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
|
|
assert private_event.chat_id == 'user-1'
|
|
assert private_event.group is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lark_adapter_dispatches_and_caches_message_event():
|
|
adapter = make_adapter()
|
|
calls: list[platform_events.Event] = []
|
|
|
|
async def listener(event, adapter):
|
|
calls.append(event)
|
|
|
|
adapter.register_listener(platform_events.MessageReceivedEvent, listener)
|
|
await adapter._handle_message_event(lark_event())
|
|
|
|
assert len(calls) == 1
|
|
received = calls[0]
|
|
assert isinstance(received, platform_events.MessageReceivedEvent)
|
|
assert await adapter.get_message('group', 'chat-1', 'msg-1') == received
|
|
assert (await adapter.get_group_info('chat-1')).name == ''
|
|
assert (await adapter.get_user_info('user-1')).nickname == 'Alice Union'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lark_get_message_fetches_uncached_message():
|
|
adapter = make_adapter()
|
|
adapter.api_client.im.v1.message.aget = AsyncMock(
|
|
return_value=DummyResponse(SimpleNamespace(items=[message_item('msg-remote')]))
|
|
)
|
|
|
|
event = await adapter.get_message('group', 'chat-1', 'msg-remote')
|
|
|
|
assert event.adapter_name == 'lark-eba'
|
|
assert event.message_id == 'msg-remote'
|
|
assert event.chat_type == platform_entities.ChatType.GROUP
|
|
assert isinstance(event.message_chain[1], platform_message.Plain)
|
|
assert event.message_chain[1].text == 'remote'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lark_send_reply_platform_api_and_modes():
|
|
adapter = make_adapter()
|
|
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
|
|
|
sent = await adapter.send_message('group', 'chat-1', message)
|
|
assert sent.raw['message_ids'] == ['reply-msg']
|
|
adapter.api_client.im.v1.message.acreate.assert_awaited_once()
|
|
|
|
source_event = await LarkEventConverter.target2yiri(lark_event(), adapter.api_client)
|
|
replied = await adapter.reply_message(source_event, message)
|
|
assert replied.raw['message_ids'] == ['reply-msg']
|
|
adapter.api_client.im.v1.message.areply.assert_awaited_once()
|
|
|
|
assert await adapter.call_platform_api('check_tenant_access_token', {}) == {'ok': True}
|
|
|
|
await adapter.run_async()
|
|
adapter.bot._connect.assert_awaited_once()
|
|
|
|
webhook_adapter = make_adapter({'enable-webhook': True})
|
|
task = asyncio.create_task(webhook_adapter.run_async())
|
|
await asyncio.sleep(0)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
webhook_adapter.bot._connect.assert_not_awaited()
|