mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-23 04:46:07 +00:00
feat(agent): integrate structured runner interactions
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -13,6 +14,7 @@ from langbot.pkg.platform.adapters.dingtalk.adapter import DingTalkAdapter
|
||||
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.dingtalk.interaction import interaction_event_from_native
|
||||
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
|
||||
@@ -49,6 +51,7 @@ class DummyDingTalkClient(DingTalkClient):
|
||||
self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB')
|
||||
self.create_and_card = AsyncMock(return_value=('card', 'card-id'))
|
||||
self.send_card_message = AsyncMock()
|
||||
self.create_and_deliver_card = AsyncMock(return_value=True)
|
||||
self.start = AsyncMock()
|
||||
self.stop = AsyncMock()
|
||||
|
||||
@@ -71,7 +74,7 @@ def manifest() -> dict:
|
||||
/ 'dingtalk'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter() -> DingTalkAdapter:
|
||||
@@ -84,6 +87,7 @@ def make_adapter() -> DingTalkAdapter:
|
||||
'enable-stream-reply': False,
|
||||
'card_auto_layout': False,
|
||||
'card_template_id': 'template-id',
|
||||
'human_input_card_template_id': 'human-input-template-id',
|
||||
}
|
||||
with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient):
|
||||
return DingTalkAdapter(config, DummyLogger())
|
||||
@@ -157,6 +161,36 @@ def test_dingtalk_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_dingtalk_human_input_card_template_matches_interaction_contract():
|
||||
template_path = (
|
||||
pathlib.Path(__file__).parents[3] / 'src' / 'langbot' / 'templates' / 'dingtalk_human_input_card.json'
|
||||
)
|
||||
exported_template = json.loads(template_path.read_text(encoding='utf-8'))
|
||||
editor_data = json.loads(exported_template['editorData'])
|
||||
variable_ids = {item['id'] for item in editor_data['variableList']}
|
||||
serialized_editor = json.dumps(editor_data, ensure_ascii=False)
|
||||
|
||||
assert exported_template['type'] == 'im'
|
||||
assert exported_template['mode'] == 'card'
|
||||
assert {
|
||||
'content',
|
||||
'btns',
|
||||
'input_visible',
|
||||
'input_title',
|
||||
'input_placeholder',
|
||||
'input_value',
|
||||
'select_visible',
|
||||
'select_placeholder',
|
||||
'select_options',
|
||||
'select_index',
|
||||
'index_o',
|
||||
} <= variable_ids
|
||||
assert '"componentName": "Input"' in serialized_editor
|
||||
assert '"componentName": "SelectBlock"' in serialized_editor
|
||||
assert '__built_in_inputResult__' in serialized_editor
|
||||
assert '__built_in_selectResult__' in serialized_editor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_message_converter_maps_outbound_components():
|
||||
content, at = await DingTalkMessageConverter.yiri2target(
|
||||
@@ -313,3 +347,178 @@ async def test_dingtalk_send_reply_and_platform_api_use_underlying_client():
|
||||
|
||||
assert token_status == {'valid': True}
|
||||
assert file_url == {'url': 'https://example.test/file'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_interaction_delivery_and_callback_event():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result['rich'] is True
|
||||
kwargs = adapter.bot.create_and_deliver_card.await_args.kwargs
|
||||
assert kwargs['card_template_id'] == 'human-input-template-id'
|
||||
assert kwargs['open_space_id'] == 'dtv1.card//IM_GROUP.group-1'
|
||||
assert kwargs['card_param_map']['btns'][0]['event']['params']['actionId'] == 'lbi:callback-token:a:0'
|
||||
|
||||
native = dingtalk_card_callback_event(
|
||||
content={'actionId': 'lbi:callback-token:a:0'},
|
||||
space_id='dtv1.card//IM_GROUP.group-1',
|
||||
)
|
||||
event = interaction_event_from_native(native)
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
assert event.data['actor_id'] == 'user-1'
|
||||
assert event.data['target_id'] == 'group-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_native_input_uses_template_variables_and_submits_value():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Add context',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'comment',
|
||||
'label': 'Comment',
|
||||
'type': 'textarea',
|
||||
'required': True,
|
||||
'placeholder': 'Explain the decision',
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
'fallback_text': 'Reply with a comment.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
|
||||
assert params['input_visible'] == 'true'
|
||||
assert params['input_title'] == 'Comment'
|
||||
assert params['input_placeholder'] == 'Explain the decision'
|
||||
assert params['select_visible'] == ''
|
||||
assert params['btns'] == []
|
||||
assert result['message_id'] in adapter.interaction_callback_contexts
|
||||
|
||||
native = dingtalk_card_callback_event(
|
||||
content={
|
||||
'outTrackId': result['message_id'],
|
||||
'params': {'input': 'Looks good'},
|
||||
},
|
||||
space_id='dtv1.card//IM_GROUP.group-1',
|
||||
)
|
||||
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
|
||||
|
||||
assert event.data['callback_token'] == 'callback-token'
|
||||
assert event.data['values'] == {'comment': 'Looks good'}
|
||||
assert result['message_id'] not in adapter.interaction_callback_contexts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback():
|
||||
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': 'Priority',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'options': [
|
||||
{'value': 'normal', 'label': 'Normal'},
|
||||
{'value': 'urgent', 'label': 'Urgent'},
|
||||
],
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
'fallback_text': 'Choose a priority.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
|
||||
assert params['select_visible'] == 'true'
|
||||
assert [option['value'] for option in params['index_o']] == ['normal', 'urgent']
|
||||
assert params['input_visible'] == ''
|
||||
|
||||
native = dingtalk_card_callback_event(
|
||||
content={
|
||||
'out_track_id': result['message_id'],
|
||||
'params': {'select': '{"index": 1, "value": "urgent"}'},
|
||||
},
|
||||
space_id='dtv1.card//IM_ROBOT.user-1',
|
||||
)
|
||||
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
|
||||
|
||||
assert event.data['target_type'] == 'person'
|
||||
assert event.data['target_id'] == 'user-1'
|
||||
assert event.data['values'] == {'priority': 'urgent'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_adapter_dispatches_native_input_submission():
|
||||
adapter = make_adapter()
|
||||
calls: list[platform_events.Event] = []
|
||||
|
||||
async def listener(event, adapter):
|
||||
calls.append(event)
|
||||
|
||||
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Score',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'score',
|
||||
'label': 'Score',
|
||||
'type': 'number',
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
'fallback_text': 'Reply with a score.',
|
||||
},
|
||||
},
|
||||
)
|
||||
native = dingtalk_card_callback_event(
|
||||
content={
|
||||
'outTrackId': result['message_id'],
|
||||
'params': {'inputResult': '42.5'},
|
||||
},
|
||||
space_id='dtv1.card//IM_GROUP.group-1',
|
||||
)
|
||||
|
||||
await adapter._handle_native_event(native)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].action == 'interaction.submitted'
|
||||
assert calls[0].data['callback_token'] == 'callback-token'
|
||||
assert calls[0].data['values'] == {'score': 42.5}
|
||||
assert result['message_id'] not in adapter.interaction_callback_contexts
|
||||
|
||||
@@ -13,6 +13,11 @@ import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module
|
||||
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.discord.interaction import (
|
||||
build_interaction_view,
|
||||
interaction_event_from_component,
|
||||
parse_interaction_custom_id,
|
||||
)
|
||||
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
|
||||
@@ -48,7 +53,7 @@ def manifest() -> dict:
|
||||
/ 'discord'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def test_discord_supported_events_match_manifest():
|
||||
@@ -68,6 +73,64 @@ def test_discord_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_discord_interaction_custom_id_parser_uses_host_indexes():
|
||||
assert parse_interaction_custom_id('lbi:token:a:2') == {
|
||||
'callback_token': 'token',
|
||||
'action_ref': 2,
|
||||
}
|
||||
assert parse_interaction_custom_id('lbi:token:f:0:3') == {
|
||||
'callback_token': 'token',
|
||||
'field_ref': 0,
|
||||
'option_ref': 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_interaction_view_and_delivery():
|
||||
adapter = make_adapter()
|
||||
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=321)))
|
||||
adapter._get_channel = AsyncMock(return_value=channel)
|
||||
request = {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
}
|
||||
|
||||
view = build_interaction_view(request, 'callback-token')
|
||||
assert view.children[0].custom_id == 'lbi:callback-token:a:0'
|
||||
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'request': request,
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': '789'},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'message_id': 321, 'rich': True}
|
||||
assert channel.send.await_args.kwargs['view'].children[0].label == 'Approve'
|
||||
|
||||
|
||||
def test_discord_interaction_component_builds_scoped_host_event():
|
||||
interaction = SimpleNamespace(
|
||||
user=SimpleNamespace(id=123),
|
||||
channel=SimpleNamespace(id=789),
|
||||
guild=SimpleNamespace(id=456),
|
||||
)
|
||||
|
||||
event = interaction_event_from_component(
|
||||
interaction,
|
||||
{'callback_token': 'callback-token', 'action_ref': 0},
|
||||
)
|
||||
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['actor_id'] == '123'
|
||||
assert event.data['target_type'] == 'group'
|
||||
assert event.data['target_id'] == '789'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_adapter_dispatches_most_specific_eba_listener():
|
||||
adapter = make_adapter()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -12,6 +14,11 @@ 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
|
||||
@@ -66,6 +73,7 @@ class DummyAPIClient:
|
||||
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(
|
||||
@@ -89,8 +97,12 @@ class DummyAPIClient:
|
||||
)
|
||||
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())),
|
||||
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())),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -114,7 +126,7 @@ def manifest() -> dict:
|
||||
/ 'lark'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(path.read_text())
|
||||
return yaml.safe_load(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter(config: dict | None = None) -> LarkAdapter:
|
||||
@@ -324,3 +336,550 @@ async def test_lark_send_reply_platform_api_and_modes():
|
||||
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 == '✅ Comment:Looks 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 '✅ Question:123' 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 '✅ Action:or' in final_content
|
||||
assert '✅ Question:123' 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✅ xiala:1',
|
||||
]
|
||||
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 / 已提交'}}
|
||||
|
||||
@@ -13,6 +13,10 @@ from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
|
||||
from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter
|
||||
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
|
||||
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.qqofficial.interaction import (
|
||||
build_interaction_keyboard,
|
||||
interaction_event_from_payload,
|
||||
)
|
||||
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
|
||||
@@ -49,6 +53,9 @@ class DummyQQOfficialClient:
|
||||
self.access_token_expiry_time = None
|
||||
self.handle_unified_webhook = AsyncMock(return_value='success')
|
||||
self.connect_gateway_loop = AsyncMock()
|
||||
self.send_markdown_keyboard = AsyncMock(return_value={'id': 'keyboard-message'})
|
||||
self.ack_interaction = AsyncMock()
|
||||
self._interaction_handler = None
|
||||
|
||||
def on_message(self, msg_type: str):
|
||||
def decorator(func):
|
||||
@@ -57,6 +64,13 @@ class DummyQQOfficialClient:
|
||||
|
||||
return decorator
|
||||
|
||||
def on_interaction(self):
|
||||
def decorator(func):
|
||||
self._interaction_handler = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
async def check_access_token(self):
|
||||
return bool(self.access_token)
|
||||
|
||||
@@ -111,7 +125,7 @@ def manifest() -> dict:
|
||||
/ 'qqofficial'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter(enable_webhook: bool = True) -> QQOfficialAdapter:
|
||||
@@ -164,6 +178,54 @@ def test_qqofficial_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_qqofficial_interaction_keyboard_contains_only_host_token_and_index():
|
||||
keyboard = build_interaction_keyboard(
|
||||
{'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}]},
|
||||
'callback-token',
|
||||
)
|
||||
|
||||
button = keyboard['content']['rows'][0]['buttons'][0]
|
||||
assert button['action']['data'] == 'lbi:callback-token:a:0'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qqofficial_interaction_delivery_and_callback_event():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {
|
||||
'target_type': 'group',
|
||||
'target_id': 'group-openid',
|
||||
'message_id': 'msg-1',
|
||||
},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'message_id': 'keyboard-message', 'rich': True}
|
||||
assert adapter.bot.send_markdown_keyboard.await_args.kwargs['msg_id'] == 'msg-1'
|
||||
|
||||
event = interaction_event_from_payload(
|
||||
{
|
||||
'id': 'interaction-id',
|
||||
'chat_type': 1,
|
||||
'group_openid': 'group-openid',
|
||||
'member_openid': 'user-openid',
|
||||
'data': {'resolved': {'button_data': 'lbi:callback-token:a:0'}},
|
||||
}
|
||||
)
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
assert event.data['actor_id'] == 'user-openid'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qqofficial_message_converter_maps_common_components_to_send_payloads():
|
||||
payload = await QQOfficialMessageConverter.yiri2target(
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestEventRouteTrace:
|
||||
|
||||
captured_envelopes = []
|
||||
|
||||
async def fake_run(envelope, binding):
|
||||
async def fake_run(envelope, binding, adapter_context=None):
|
||||
captured_envelopes.append(envelope)
|
||||
yield provider_message.Message(role='assistant', content='test response')
|
||||
|
||||
@@ -157,7 +157,7 @@ class TestEventRouteTrace:
|
||||
}
|
||||
runner_calls = []
|
||||
|
||||
async def fake_run(envelope, binding):
|
||||
async def fake_run(envelope, binding, adapter_context=None):
|
||||
runner_calls.append((envelope, binding))
|
||||
if False:
|
||||
yield None
|
||||
@@ -378,6 +378,53 @@ class TestRuntimeBotLifecycle:
|
||||
bot.adapter.kill.assert_awaited_once()
|
||||
task_mgr.cancel_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_uses_only_eba_message_listener_for_eba_adapter(self):
|
||||
"""An EBA-native message must not also enter through the legacy compatibility listener."""
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
listeners = {}
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_events=Mock(return_value=['message.received']),
|
||||
register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)),
|
||||
)
|
||||
bot = RuntimeBot(
|
||||
ap=SimpleNamespace(),
|
||||
bot_entity=SimpleNamespace(enable=True),
|
||||
adapter=adapter,
|
||||
logger=Mock(),
|
||||
)
|
||||
|
||||
await bot.initialize()
|
||||
|
||||
assert platform_events.EBAEvent in listeners
|
||||
assert platform_events.FriendMessage not in listeners
|
||||
assert platform_events.GroupMessage not in listeners
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_keeps_legacy_message_listeners_for_legacy_adapter(self):
|
||||
"""Adapters without EBA message support retain the compatibility entry path."""
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
listeners = {}
|
||||
adapter = SimpleNamespace(
|
||||
register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)),
|
||||
)
|
||||
bot = RuntimeBot(
|
||||
ap=SimpleNamespace(),
|
||||
bot_entity=SimpleNamespace(enable=True),
|
||||
adapter=adapter,
|
||||
logger=Mock(),
|
||||
)
|
||||
|
||||
await bot.initialize()
|
||||
|
||||
assert platform_events.FriendMessage in listeners
|
||||
assert platform_events.GroupMessage in listeners
|
||||
assert platform_events.EBAEvent in listeners
|
||||
|
||||
|
||||
class TestEBAEventBindings:
|
||||
"""Test RuntimeBot EBA event binding helpers."""
|
||||
@@ -482,8 +529,11 @@ class TestEBAEventBindings:
|
||||
assert binding.resource_policy.allowed_tool_names is None
|
||||
assert binding.delivery_policy.enable_streaming is False
|
||||
assert binding.delivery_policy.enable_reply is True
|
||||
assert binding.delivery_policy.enable_interactions is True
|
||||
assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
|
||||
assert binding.agent_id == 'agent-1'
|
||||
assert binding.processor_type == 'agent'
|
||||
assert binding.processor_id == 'agent-1'
|
||||
|
||||
def test_agent_product_to_binding_projects_selected_tool_policy(self):
|
||||
"""Independent Agents use the same standard runner resource fields as Pipelines."""
|
||||
@@ -513,6 +563,174 @@ class TestEBAEventBindings:
|
||||
assert binding.resource_policy.allowed_tool_names == ['exec', 'plugin_tool']
|
||||
assert binding.resource_policy.allowed_kb_uuids == ['kb-1']
|
||||
|
||||
|
||||
class TestInteractionResumeRouting:
|
||||
"""Interaction callbacks resume the processor that created the request."""
|
||||
|
||||
@staticmethod
|
||||
def _event():
|
||||
from langbot_plugin.api.entities.builtin.platform.events import PlatformSpecificEvent
|
||||
|
||||
return PlatformSpecificEvent(
|
||||
action='interaction.submitted',
|
||||
timestamp=1234,
|
||||
data={
|
||||
'callback_token': 'callback-token',
|
||||
'interaction_id': 'form-1',
|
||||
'action_id': 'approve',
|
||||
'values': {'name': 'Alice'},
|
||||
'actor_id': 'user-1',
|
||||
'target_type': 'group',
|
||||
'target_id': 'chat-1',
|
||||
'message_id': 'card-message-1',
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record(processor_type: str):
|
||||
return {
|
||||
'id': 1,
|
||||
'interaction_id': 'form-1',
|
||||
'binding_id': 'binding-1',
|
||||
'runner_id': 'plugin:test/Dify/default',
|
||||
'processor_type': processor_type,
|
||||
'processor_id': f'{processor_type}-1',
|
||||
'workspace_id': None,
|
||||
'conversation_id': 'group_chat-1',
|
||||
'thread_id': None,
|
||||
'delivery_target': {'target_type': 'group', 'target_id': 'chat-1'},
|
||||
'submission': {
|
||||
'interaction_id': 'form-1',
|
||||
'action_id': 'approve',
|
||||
'values': {'name': 'Alice'},
|
||||
'submitted_at': 1234,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _make_bot(record):
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = SimpleNamespace(uuid='bot-1', name='Test', event_bindings=[])
|
||||
interaction_manager = SimpleNamespace(
|
||||
consume_callback=AsyncMock(return_value=record),
|
||||
acknowledge_submission=AsyncMock(),
|
||||
)
|
||||
bot.ap = SimpleNamespace(
|
||||
agent_run_orchestrator=SimpleNamespace(interaction_manager=interaction_manager),
|
||||
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
|
||||
pipeline_service=SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={
|
||||
'uuid': record['processor_id'],
|
||||
'config': {
|
||||
'ai': {
|
||||
'runner': {'id': record['runner_id']},
|
||||
'runner_config': {record['runner_id']: {}},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
return bot
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_callback_bypasses_route_table_and_targets_original_pipeline(self):
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
adapter = SimpleNamespace()
|
||||
|
||||
await bot._handle_interaction_submission(self._event(), adapter)
|
||||
|
||||
bot.ap.msg_aggregator.add_message.assert_awaited_once()
|
||||
kwargs = bot.ap.msg_aggregator.add_message.await_args.kwargs
|
||||
assert kwargs['pipeline_uuid'] == 'pipeline-1'
|
||||
assert kwargs['routed_by_rule'] is True
|
||||
assert kwargs['variables']['_interaction_submission']['interaction_id'] == 'form-1'
|
||||
assert kwargs['message_chain'].message_id == 'card-message-1'
|
||||
assert kwargs['message_event'].message_chain.message_id == 'card-message-1'
|
||||
manager = bot.ap.agent_run_orchestrator.interaction_manager
|
||||
manager.consume_callback.assert_awaited_once_with(
|
||||
callback_token='callback-token',
|
||||
submission={
|
||||
'interaction_id': 'form-1',
|
||||
'action_id': 'approve',
|
||||
'values': {'name': 'Alice'},
|
||||
'submitted_at': 1234,
|
||||
},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_forwards_compact_platform_references(self):
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
adapter = SimpleNamespace()
|
||||
event = self._event()
|
||||
event.data.pop('interaction_id')
|
||||
event.data.pop('action_id')
|
||||
event.data['action_ref'] = 1
|
||||
|
||||
await bot._handle_interaction_submission(event, adapter)
|
||||
|
||||
manager = bot.ap.agent_run_orchestrator.interaction_manager
|
||||
submission = manager.consume_callback.await_args.kwargs['submission']
|
||||
assert submission['action_ref'] == 1
|
||||
assert submission['interaction_id'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_callback_rejects_changed_runner(self):
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
bot.ap.pipeline_service.get_pipeline.return_value['config']['ai']['runner']['id'] = 'plugin:test/Other/default'
|
||||
|
||||
with pytest.raises(ValueError, match='Pipeline runner changed'):
|
||||
await bot._handle_interaction_submission(self._event(), SimpleNamespace())
|
||||
|
||||
bot.ap.msg_aggregator.add_message.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_callback_targets_original_agent_and_runner(self):
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
captured = []
|
||||
|
||||
async def fake_run(envelope, binding, adapter_context=None):
|
||||
captured.append((envelope, binding, adapter_context))
|
||||
yield provider_message.Message(role='assistant', content='approved')
|
||||
|
||||
bot = self._make_bot(self._record('agent'))
|
||||
bot.ap.agent_service = SimpleNamespace(
|
||||
get_agent=AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'agent-1',
|
||||
'kind': 'agent',
|
||||
'enabled': True,
|
||||
'config': {
|
||||
'runner': {'id': 'plugin:test/Dify/default'},
|
||||
'runner_config': {'plugin:test/Dify/default': {}},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
bot.ap.agent_run_orchestrator.run = fake_run
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=Mock(return_value=['send_message']),
|
||||
send_message=AsyncMock(),
|
||||
)
|
||||
|
||||
await bot._handle_interaction_submission(self._event(), adapter)
|
||||
|
||||
envelope, binding, adapter_context = captured[0]
|
||||
assert envelope.event_type == 'interaction.submitted'
|
||||
assert envelope.data['interaction']['action_id'] == 'approve'
|
||||
assert binding.binding_id == 'binding-1'
|
||||
assert binding.processor_type == 'agent'
|
||||
assert binding.processor_id == 'agent-1'
|
||||
assert adapter_context == {'_delivery_adapter': adapter}
|
||||
adapter.send_message.assert_awaited_once()
|
||||
|
||||
def test_agent_product_to_binding_does_not_fallback_to_component_ref(self):
|
||||
"""An empty config runner stays unconfigured even if component_ref is stale."""
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
@@ -14,6 +14,10 @@ from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler
|
||||
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter
|
||||
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||
from langbot.pkg.platform.adapters.telegram.interaction import (
|
||||
interaction_event_from_update,
|
||||
parse_interaction_callback,
|
||||
)
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -152,13 +156,159 @@ def test_telegram_supported_events_match_manifest():
|
||||
/ 'telegram'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
manifest_events = yaml.safe_load(manifest_path.read_text())['spec']['supported_events']
|
||||
manifest_events = yaml.safe_load(manifest_path.read_text(encoding='utf-8'))['spec']['supported_events']
|
||||
|
||||
assert adapter_events == manifest_events
|
||||
assert 'message.deleted' not in adapter_events
|
||||
assert 'group.info_updated' not in adapter_events
|
||||
|
||||
|
||||
def test_telegram_interaction_callback_parser_uses_compact_indexes():
|
||||
assert parse_interaction_callback('lbi:token:a:2') == {
|
||||
'callback_token': 'token',
|
||||
'action_ref': 2,
|
||||
}
|
||||
assert parse_interaction_callback('lbi:token:f:1:3') == {
|
||||
'callback_token': 'token',
|
||||
'field_ref': 1,
|
||||
'option_ref': 3,
|
||||
}
|
||||
assert parse_interaction_callback('ordinary-button') is None
|
||||
with pytest.raises(ValueError, match='invalid Telegram interaction callback'):
|
||||
parse_interaction_callback('lbi:token:a:not-an-index')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_interaction_request_renders_action_buttons_and_thread_target():
|
||||
adapter = make_adapter()
|
||||
sent = SimpleNamespace(message_id=321)
|
||||
bot = SimpleNamespace(send_message=AsyncMock(return_value=sent))
|
||||
object.__setattr__(adapter, 'bot', bot)
|
||||
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': '-1001#7'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'kind': 'choice',
|
||||
'title': 'Approve?',
|
||||
'actions': [
|
||||
{'id': 'approve', 'label': 'Approve', 'style': 'primary'},
|
||||
{'id': 'reject', 'label': 'Reject', 'style': 'danger'},
|
||||
],
|
||||
'fallback_text': 'Reply approve or reject.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'message_id': 321, 'rich': True}
|
||||
kwargs = bot.send_message.await_args.kwargs
|
||||
assert kwargs['chat_id'] == -1001
|
||||
assert kwargs['message_thread_id'] == 7
|
||||
buttons = kwargs['reply_markup'].inline_keyboard
|
||||
assert buttons[0][0].callback_data == 'lbi:callback-token:a:0'
|
||||
assert buttons[1][0].callback_data == 'lbi:callback-token:a:1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_interaction_request_renders_select_or_falls_back():
|
||||
adapter = make_adapter()
|
||||
bot = SimpleNamespace(send_message=AsyncMock(return_value=SimpleNamespace(message_id=1)))
|
||||
object.__setattr__(adapter, 'bot', bot)
|
||||
base_params = {
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'person', 'target_id': '123'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Choose priority',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'options': [{'value': 'urgent', 'label': 'Urgent'}],
|
||||
}
|
||||
],
|
||||
'fallback_text': 'Reply with a priority.',
|
||||
},
|
||||
}
|
||||
|
||||
result = await adapter.call_platform_api('interaction.request', base_params)
|
||||
assert result['rich'] is True
|
||||
button = bot.send_message.await_args.kwargs['reply_markup'].inline_keyboard[0][0]
|
||||
assert button.callback_data == 'lbi:callback-token:f:0:0'
|
||||
|
||||
base_params['request']['fields'][0]['type'] = 'text'
|
||||
result = await adapter.call_platform_api('interaction.request', base_params)
|
||||
assert result['rich'] is False
|
||||
kwargs = bot.send_message.await_args.kwargs
|
||||
assert 'reply_markup' not in kwargs
|
||||
assert 'Reply with a priority.' in kwargs['text']
|
||||
|
||||
|
||||
def test_telegram_interaction_callback_builds_scoped_host_event():
|
||||
update = SimpleNamespace(
|
||||
callback_query=SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
chat=SimpleNamespace(id=-1001, type='supergroup'),
|
||||
message_thread_id=7,
|
||||
),
|
||||
from_user=SimpleNamespace(id=456),
|
||||
)
|
||||
)
|
||||
|
||||
event = interaction_event_from_update(
|
||||
update,
|
||||
{'callback_token': 'callback-token', 'action_ref': 1},
|
||||
)
|
||||
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['callback_token'] == 'callback-token'
|
||||
assert event.data['action_ref'] == 1
|
||||
assert event.data['actor_id'] == '456'
|
||||
assert event.data['target_type'] == 'group'
|
||||
assert event.data['target_id'] == '-1001#7'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_interaction_callback_answers_and_dispatches_once():
|
||||
adapter = make_adapter()
|
||||
listener = AsyncMock()
|
||||
adapter.register_listener(platform_events.EBAEvent, listener)
|
||||
query = SimpleNamespace(
|
||||
data='lbi:callback-token:a:0',
|
||||
answer=AsyncMock(),
|
||||
edit_message_reply_markup=AsyncMock(),
|
||||
message=SimpleNamespace(
|
||||
chat=SimpleNamespace(id=123, type='private'),
|
||||
message_thread_id=None,
|
||||
),
|
||||
from_user=SimpleNamespace(id=456),
|
||||
)
|
||||
update = SimpleNamespace(
|
||||
message=None,
|
||||
edited_message=None,
|
||||
chat_member=None,
|
||||
my_chat_member=None,
|
||||
callback_query=query,
|
||||
message_reaction=None,
|
||||
)
|
||||
callback_handler = next(
|
||||
handler for handler in adapter.application.handlers[0] if isinstance(handler, CallbackQueryHandler)
|
||||
)
|
||||
|
||||
await callback_handler.callback(update, None)
|
||||
|
||||
query.answer.assert_awaited_once_with()
|
||||
query.edit_message_reply_markup.assert_awaited_once_with(reply_markup=None)
|
||||
listener.assert_awaited_once()
|
||||
event = listener.await_args.args[0]
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_converter_maps_message_and_edited_message_events():
|
||||
update = make_update({'message': base_message_payload(text='hello @test_bot')})
|
||||
@@ -187,6 +337,19 @@ async def test_telegram_converter_maps_message_and_edited_message_events():
|
||||
assert str(edited_event.new_content) == 'edited'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_legacy_group_converter_preserves_actor_identity():
|
||||
adapter = make_adapter()
|
||||
group_chat = {'id': -100123, 'type': 'supergroup', 'title': 'Test Group'}
|
||||
update = make_update({'message': base_message_payload(chat=group_chat)})
|
||||
|
||||
event = await adapter.legacy_event_converter.target2yiri(update, adapter.bot, 'test_bot')
|
||||
|
||||
assert isinstance(event, platform_events.GroupMessage)
|
||||
assert event.sender.id == 456
|
||||
assert event.sender.group.id == -100123
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_converter_maps_non_message_updates():
|
||||
chat_member = make_update(
|
||||
|
||||
@@ -11,6 +11,10 @@ from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter
|
||||
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.wecombot.interaction import (
|
||||
build_interaction_card,
|
||||
interaction_event_from_native,
|
||||
)
|
||||
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
|
||||
@@ -44,6 +48,7 @@ class DummyWecomBotWsClient:
|
||||
self.reply_text = AsyncMock(return_value={'reply': True})
|
||||
self.push_stream_chunk = AsyncMock(return_value=True)
|
||||
self.set_message = AsyncMock(return_value={'set': True})
|
||||
self.send_template_card = AsyncMock(return_value={'sent': True})
|
||||
|
||||
def on_message(self, msg_type: str):
|
||||
def decorator(func):
|
||||
@@ -82,7 +87,7 @@ def manifest() -> dict:
|
||||
/ 'wecombot'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter:
|
||||
@@ -149,6 +154,52 @@ def test_wecombot_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_wecombot_interaction_card_uses_host_callback_token_and_index():
|
||||
card = build_interaction_card(
|
||||
{'title': 'Approve?', 'actions': [{'id': 'approve', 'label': 'Approve'}]},
|
||||
'callback-token',
|
||||
)
|
||||
|
||||
button = card['template_card']['button_list'][0]
|
||||
assert button['key'] == 'lbi:callback-token:a:0'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wecombot_interaction_delivery_and_callback_event():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result['rich'] is True
|
||||
adapter.bot.send_template_card.assert_awaited_once()
|
||||
|
||||
native = wecombot_event(type='group', msgtype='event')
|
||||
native['event'] = {
|
||||
'eventtype': 'template_card_event',
|
||||
'template_card_event': {'EventKey': 'lbi:callback-token:a:0'},
|
||||
}
|
||||
event = interaction_event_from_native(native)
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
assert event.data['target_id'] == 'group-1'
|
||||
|
||||
native['event']['template_card_event'] = {'button': {'key': 'lbi:callback-token:f:0:2'}}
|
||||
nested_event = interaction_event_from_native(native)
|
||||
assert nested_event.data['field_ref'] == 0
|
||||
assert nested_event.data['option_ref'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text():
|
||||
content = await WecomBotMessageConverter.yiri2target(
|
||||
@@ -271,4 +322,6 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie
|
||||
async def test_wecombot_webhook_mode_rejects_proactive_send():
|
||||
adapter = make_adapter(enable_webhook=True)
|
||||
with pytest.raises(NotSupportedError):
|
||||
await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')]))
|
||||
await adapter.send_message(
|
||||
'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')])
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user