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

886 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import pathlib
import asyncio
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, 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.pkg.platform.adapters.lark.interaction import (
build_interaction_card,
interaction_delivery_capabilities,
interaction_event_from_callback,
)
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()),
aupdate=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=MagicMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))),
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))),
aupdate=AsyncMock(return_value=DummyResponse()),
),
card_element=SimpleNamespace(content=MagicMock(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(encoding='utf-8'))
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()
def test_lark_interaction_card_uses_host_callback_token_and_indexes():
card = build_interaction_card(
{
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
},
'callback-token',
{'target_type': 'group', 'target_id': 'chat-1'},
)
assert card['schema'] == '2.0'
button = card['body']['elements'][-1]['columns'][0]['elements'][0]
assert button['behaviors'][0]['value'] == {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}
def test_lark_interaction_card_uses_native_text_input_form():
card = build_interaction_card(
{
'title': 'Add context',
'fields': [
{
'id': 'comment',
'label': 'Comment',
'type': 'textarea',
'required': True,
'placeholder': 'Explain the decision',
}
],
'actions': [],
},
'callback-token',
{'target_type': 'group', 'target_id': 'chat-1'},
)
form = next(element for element in card['body']['elements'] if element['tag'] == 'form')
field, submit = form['elements']
assert field['tag'] == 'input'
assert field['input_type'] == 'multiline_text'
assert field['required'] is True
assert submit['form_action_type'] == 'submit'
assert submit['behaviors'][0]['value'] == {
'lbi': 'callback-token',
't': 'group',
'ck': 1,
'fm': {'lbi_field_0': 'comment'},
'ft': {'comment': 'textarea'},
}
def test_lark_interaction_card_uses_native_select_and_declares_fields():
card = build_interaction_card(
{
'title': 'Priority',
'fields': [
{
'id': 'priority',
'label': 'Priority',
'type': 'select',
'required': True,
'options': [
{'value': 'normal', 'label': 'Normal'},
{'value': 'urgent', 'label': 'Urgent'},
],
}
],
'actions': [],
},
'callback-token',
{'target_type': 'person', 'target_id': 'user-1'},
)
form = next(element for element in card['body']['elements'] if element['tag'] == 'form')
label, select, submit = form['elements']
assert label == {'tag': 'markdown', 'content': '**Priority***'}
assert select['tag'] == 'select_static'
assert [option['value'] for option in select['options']] == ['normal', 'urgent']
assert submit['form_action_type'] == 'submit'
assert interaction_delivery_capabilities()['field_types'] == ['text', 'textarea', 'number', 'select']
assert interaction_delivery_capabilities()['supports_updates'] is True
@pytest.mark.asyncio
async def test_lark_interaction_request_sends_interactive_message():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve'}],
'fallback_text': 'Reply approve.',
},
},
)
assert result['rich'] is True
assert result['card_id'] == 'card-id'
adapter.api_client.cardkit.v1.card.acreate.assert_awaited_once()
request = adapter.api_client.im.v1.message.acreate.await_args.args[0]
assert request.receive_id_type == 'open_id'
assert request.request_body.msg_type == 'interactive'
assert json.loads(request.request_body.content) == {'type': 'card', 'data': {'card_id': 'card-id'}}
@pytest.mark.asyncio
async def test_lark_interaction_request_updates_existing_message():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'next-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'update_target': {
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 0,
'rich': True,
},
'request': {
'interaction_id': 'form-2',
'title': 'Choose action',
'actions': [{'id': 'approve', 'label': 'Approve'}],
'fallback_text': 'Reply approve.',
},
},
)
assert result == {
'ok': True,
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 1,
'rich': True,
'updated': True,
}
adapter.api_client.im.v1.message.acreate.assert_not_awaited()
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
assert update_request.card_id == 'card-id'
assert update_request.request_body.sequence == 1
card = json.loads(update_request.request_body.card.data)
assert card['header']['title']['content'] == 'Choose action'
button = card['body']['elements'][-1]['columns'][0]['elements'][0]
assert button['behaviors'][0]['value']['lbi'] == 'next-token'
@pytest.mark.asyncio
async def test_lark_interaction_acknowledge_replaces_controls_with_summary():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.acknowledge',
{
'update_target': {
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 0,
'rich': True,
},
'request': {
'title': 'Manual review',
'fields': [{'id': 'comment', 'label': 'Comment'}],
},
'submission': {'values': {'comment': 'Looks good'}},
},
)
assert result['updated'] is True
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
card = json.loads(update_request.request_body.card.data)
content = card['body']['elements'][-1]['content']
assert content == '✅ CommentLooks good'
@pytest.mark.asyncio
async def test_lark_interaction_keeps_prior_values_through_next_action_and_final_acknowledgement():
adapter = make_adapter()
update_target = {
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 1,
'rich': True,
'submitted_values': [{'label': 'Question', 'value': '123'}],
}
action_result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'next-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'update_target': update_target,
'request': {
'interaction_id': 'action-1',
'title': 'Manual review',
'actions': [{'id': 'or', 'label': 'or'}],
'fallback_text': 'Reply or.',
},
},
)
action_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
action_card = json.loads(action_update.request_body.card.data)
assert '✅ Question123' in action_card['body']['elements'][0]['content']
assert action_result['submitted_values'] == [{'label': 'Question', 'value': '123'}]
final_result = await adapter.call_platform_api(
'interaction.acknowledge',
{
'update_target': action_result,
'request': {
'title': 'Manual review',
'actions': [{'id': 'or', 'label': 'or'}],
},
'submission': {'action_id': 'or', 'values': {}},
},
)
final_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
final_card = json.loads(final_update.request_body.card.data)
final_content = final_card['body']['elements'][-1]['content']
assert '✅ Actionor' in final_content
assert '✅ Question123' in final_card['body']['elements'][0]['content']
assert final_result['submitted_values'] == [
{'label': 'Question', 'value': '123'},
{'label': 'Action', 'value': 'or'},
]
def test_lark_dify_layout_keeps_prompts_next_to_submitted_values():
prior_values = [
{
'description': '11\n请输入你的问题',
'label': 'us_input',
'value': '回复我你好',
}
]
select_card = build_interaction_card(
{
'title': '人工介入',
'description': '请选择你的答案',
'fields': [
{
'id': 'field_2',
'label': 'xiala',
'type': 'select',
'options': [
{'value': '1', 'label': '1'},
{'value': '2', 'label': '2'},
],
}
],
'actions': [],
},
'callback-token',
{'target_type': 'person', 'target_id': 'user-1'},
prior_values,
)
select_elements = select_card['body']['elements']
assert select_elements[0]['content'] == '11\n请输入你的问题\n✅ us_input:回复我你好'
assert select_elements[1]['content'] == '请选择你的答案'
assert select_elements[2]['tag'] == 'form'
action_card = build_interaction_card(
{
'title': '人工介入',
'description': None,
'fields': [],
'actions': [{'id': 'action_1', 'label': 'or'}],
},
'callback-token',
{'target_type': 'person', 'target_id': 'user-1'},
[
*prior_values,
{
'description': '请选择你的答案',
'label': 'xiala',
'value': '1',
},
],
)
action_elements = action_card['body']['elements']
assert [element['content'] for element in action_elements[:2]] == [
'11\n请输入你的问题\n✅ us_input:回复我你好',
'请选择你的答案\n✅ xiala1',
]
assert action_elements[2]['columns'][0]['elements'][0]['text']['content'] == 'or'
def test_lark_message_id_from_card_callback_source():
adapter = make_adapter()
callback_source = SimpleNamespace(
event=SimpleNamespace(context=SimpleNamespace(open_message_id='card-message-1')),
)
assert (
adapter._message_id_from_source(SimpleNamespace(message_id=None, source_platform_object=callback_source))
== 'card-message-1'
)
assert (
adapter._message_id_from_source(
SimpleNamespace(
message_id=None,
source_platform_object={'event': {'context': {'open_message_id': 'webhook-message-1'}}},
)
)
== 'webhook-message-1'
)
@pytest.mark.asyncio
async def test_lark_streaming_card_uses_strictly_increasing_sequences():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 0
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], []))
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='answer')])
await adapter.reply_message_chunk(source, bot_message, message)
first_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert first_request.request_body.sequence == 1
bot_message.msg_sequence = 2
await adapter.reply_message_chunk(source, bot_message, message)
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
bot_message.msg_sequence = 8
await adapter.reply_message_chunk(source, bot_message, message)
second_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert second_request.request_body.sequence == 2
bot_message.msg_sequence = 9
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
final_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert final_request.request_body.sequence == 3
assert 'response-1' not in adapter.card_id_dict
assert 'stream-card-1' not in adapter.card_sequence_dict
assert 'stream-card-1' not in adapter.card_last_update_dict
@pytest.mark.asyncio
async def test_lark_streaming_card_updates_sparse_chunks_without_waiting_for_eighth():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 1
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'new progress'}]], []))
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='new progress')])
await adapter.reply_message_chunk(source, bot_message, message)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert request.request_body.sequence == 2
@pytest.mark.asyncio
async def test_lark_streaming_card_uses_cumulative_runner_content():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 0
adapter.message_converter.yiri2target = AsyncMock(
return_value=([[{'tag': 'text', 'text': 'latest chunk only'}]], [])
)
bot_message = SimpleNamespace(
resp_message_id='response-1',
msg_sequence=2,
all_content='first chunk\n\nlatest chunk only',
tool_calls=None,
)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='latest chunk only')])
await adapter.reply_message_chunk(source, bot_message, message)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert request.request_body.content == 'first chunk\n\nlatest chunk only'
adapter.message_converter.yiri2target.assert_not_awaited()
@pytest.mark.asyncio
async def test_lark_streaming_card_first_real_runner_chunk_uses_sequence_one():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 0
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], []))
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='answer')])
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert request.request_body.sequence == 1
@pytest.mark.asyncio
async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_closes():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 1
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
closed_response = DummyResponse(ok=False)
closed_response.code = 300309
closed_response.msg = 'streaming mode is closed'
adapter.api_client.cardkit.v1.card_element.content.return_value = closed_response
adapter.message_converter.yiri2target = AsyncMock(
return_value=([[{'tag': 'text', 'text': 'continued progress'}]], [])
)
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='continued progress')])
await adapter.reply_message_chunk(source, bot_message, message)
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
updated_card = json.loads(update_request.request_body.card.data)
assert update_request.request_body.sequence == 3
assert updated_card['config'] == {'update_multi': True}
assert updated_card['body']['elements'][0]['content'] == 'continued progress'
assert 'stream-card-1' in adapter.closed_streaming_cards
bot_message.msg_sequence = 3
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
assert adapter.api_client.cardkit.v1.card.aupdate.await_count == 2
assert 'stream-card-1' not in adapter.closed_streaming_cards
def test_lark_interaction_callback_builds_scoped_host_event():
callback = SimpleNamespace(
event=SimpleNamespace(
action=SimpleNamespace(value={'lbi': 'callback-token', 't': 'group', 'a': 1}),
operator=SimpleNamespace(open_id='user-1'),
context=SimpleNamespace(open_chat_id='chat-1', open_message_id='card-message-1'),
)
)
event = interaction_event_from_callback(callback)
assert event.action == 'interaction.submitted'
assert event.data['callback_token'] == 'callback-token'
assert event.data['action_ref'] == 1
assert event.data['actor_id'] == 'user-1'
assert event.data['target_id'] == 'chat-1'
assert event.data['message_id'] == 'card-message-1'
def test_lark_native_form_callback_submits_typed_field_value():
callback = SimpleNamespace(
event=SimpleNamespace(
action=SimpleNamespace(
value={
'lbi': 'callback-token',
't': 'person',
'fm': {'lbi_field_0': 'score'},
'ft': {'score': 'number'},
},
form_value={'lbi_field_0': '42.5'},
),
operator=SimpleNamespace(open_id='user-1'),
context=SimpleNamespace(open_chat_id='chat-1'),
)
)
event = interaction_event_from_callback(callback)
assert event.data['callback_token'] == 'callback-token'
assert event.data['target_type'] == 'person'
assert event.data['target_id'] == 'user-1'
assert event.data['values'] == {'score': 42.5}
@pytest.mark.asyncio
async def test_lark_webhook_dispatches_native_form_submission():
adapter = make_adapter({'enable-webhook': True})
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
payload = {
'schema': '2.0',
'header': {'event_type': 'card.action.trigger'},
'event': {
'action': {
'value': {
'lbi': 'callback-token',
't': 'group',
'fm': {'lbi_field_0': 'comment'},
'ft': {'comment': 'textarea'},
},
'form_value': {'lbi_field_0': 'Looks good'},
},
'operator': {'open_id': 'user-1'},
'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'},
},
}
request = SimpleNamespace(json=asyncio.sleep(0, result=payload))
response = await adapter.handle_unified_webhook('bot-1', '', request)
assert response['toast'] == {'type': 'success', 'content': 'Submitted / 已提交'}
assert response['card']['type'] == 'raw'
assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted / 已提交**'
assert len(calls) == 1
assert calls[0].action == 'interaction.submitted'
assert calls[0].data['callback_token'] == 'callback-token'
assert calls[0].data['values'] == {'comment': 'Looks good'}
assert calls[0].data['message_id'] == 'card-message-1'
@pytest.mark.asyncio
async def test_lark_webhook_cardkit_submission_uses_async_card_update_only():
adapter = make_adapter({'enable-webhook': True})
async def listener(event, adapter):
pass
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
payload = {
'schema': '2.0',
'header': {'event_type': 'card.action.trigger'},
'event': {
'action': {'value': {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}},
'operator': {'open_id': 'user-1'},
'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'},
},
}
request = SimpleNamespace(json=asyncio.sleep(0, result=payload))
response = await adapter.handle_unified_webhook('bot-1', '', request)
assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}}