mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-15 00:46:07 +00:00
feat: Enhance Telegram and QQ Official adapters with select field handling and form action processing
- Added support for select fields in Telegram adapter, including option extraction and callback handling. - Implemented form action processing for Telegram callbacks, improving user interaction feedback. - Introduced new helper functions for building keyboards and resolving select button actions in QQ Official adapter. - Enhanced DifyServiceAPIRunner to handle cumulative streaming responses and improve error handling during workflow resumes. - Added unit tests for new functionalities in Telegram and QQ Official adapters, ensuring robust behavior for select fields and form actions.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
"""Tests for DingTalk adapter helper behavior."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources.dingtalk import (
|
||||
DingTalkAdapter,
|
||||
_dingtalk_clean_form_content,
|
||||
_dingtalk_completed_input_lines,
|
||||
_dingtalk_extract_component_inputs,
|
||||
@@ -102,3 +108,69 @@ def test_dingtalk_clean_form_content_uses_all_input_defs():
|
||||
)
|
||||
|
||||
assert content == 'Hello'
|
||||
|
||||
|
||||
def _build_card_action_adapter() -> DingTalkAdapter:
|
||||
adapter = DingTalkAdapter.model_construct(
|
||||
card_state={
|
||||
'card-1': {
|
||||
'session_key': 'group_group-1',
|
||||
'launcher_type': 'group',
|
||||
'launcher_id': 'group-1',
|
||||
'sender_user_id': 'initiator-1',
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
||||
'node_title': 'Review',
|
||||
'form_content': 'Please review',
|
||||
'input_defs': [],
|
||||
'inputs': {},
|
||||
}
|
||||
},
|
||||
active_turn_card={},
|
||||
active_turn_text={},
|
||||
)
|
||||
adapter.logger = AsyncMock()
|
||||
adapter.ap = MagicMock()
|
||||
adapter.ap.platform_mgr.bots = []
|
||||
adapter.ap.query_pool.add_query = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_group_card_action_uses_clicker_as_sender():
|
||||
adapter = _build_card_action_adapter()
|
||||
|
||||
with patch.object(DingTalkAdapter, '_mark_card_resolved', new=AsyncMock()) as mark_resolved:
|
||||
await adapter._on_card_action(
|
||||
{
|
||||
'out_track_id': 'card-1',
|
||||
'user_id': 'reviewer-2',
|
||||
'action_id': 'approve',
|
||||
'params': {},
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
call = adapter.ap.query_pool.add_query.await_args
|
||||
assert call.kwargs['launcher_id'] == 'group-1'
|
||||
assert call.kwargs['sender_id'] == 'reviewer-2'
|
||||
assert call.kwargs['message_event'].sender.id == 'reviewer-2'
|
||||
mark_resolved.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_unknown_card_action_is_rejected():
|
||||
adapter = _build_card_action_adapter()
|
||||
|
||||
await adapter._on_card_action(
|
||||
{
|
||||
'out_track_id': 'card-1',
|
||||
'user_id': 'reviewer-2',
|
||||
'action_id': 'not-on-card',
|
||||
'params': {},
|
||||
}
|
||||
)
|
||||
|
||||
adapter.ap.query_pool.add_query.assert_not_awaited()
|
||||
adapter.logger.warning.assert_awaited_once()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for QQ Official keyboard payload helpers."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.libs.qq_official_api.api import (
|
||||
QQ_SELECT_ACTION_PREFIX,
|
||||
build_keyboard_from_select_field,
|
||||
get_select_field_options,
|
||||
resolve_select_button_action,
|
||||
)
|
||||
|
||||
|
||||
def _select_form_data() -> dict:
|
||||
return {
|
||||
'_current_input_field': 'choice',
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'choice',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_qq_select_field_builds_callback_buttons():
|
||||
keyboard = build_keyboard_from_select_field(_select_form_data(), buttons_per_row=2)
|
||||
|
||||
rows = keyboard['content']['rows']
|
||||
assert [[button['render_data']['label'] for button in row['buttons']] for row in rows] == [
|
||||
['A', 'B'],
|
||||
['C'],
|
||||
]
|
||||
assert rows[0]['buttons'][0]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}0'
|
||||
assert rows[0]['buttons'][1]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}1'
|
||||
|
||||
|
||||
def test_qq_select_button_resolves_field_and_value():
|
||||
form_data = _select_form_data()
|
||||
|
||||
assert get_select_field_options(form_data) == ('choice', ['A', 'B', 'C'])
|
||||
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}1') == ('choice', 'B')
|
||||
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None
|
||||
|
||||
|
||||
def test_qq_select_keyboard_fits_twenty_five_options():
|
||||
form_data = _select_form_data()
|
||||
form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)]
|
||||
|
||||
rows = build_keyboard_from_select_field(form_data)['content']['rows']
|
||||
|
||||
assert len(rows) == 5
|
||||
assert all(len(row['buttons']) == 5 for row in rows)
|
||||
|
||||
|
||||
def test_qq_non_select_field_does_not_build_keyboard():
|
||||
form_data = {
|
||||
'_current_input_field': 'comment',
|
||||
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
|
||||
}
|
||||
|
||||
assert build_keyboard_from_select_field(form_data)['content']['rows'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qq_select_click_enqueues_input_progress_query():
|
||||
import langbot.pkg.core.app # noqa: F401
|
||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||
|
||||
adapter = QQOfficialAdapter.model_construct()
|
||||
adapter.logger = AsyncMock()
|
||||
adapter.bot = MagicMock()
|
||||
adapter.bot.ack_interaction = AsyncMock()
|
||||
adapter.ap = MagicMock()
|
||||
adapter.ap.platform_mgr.bots = []
|
||||
adapter.ap.query_pool.add_query = AsyncMock()
|
||||
adapter._pending_forms = {
|
||||
'group_group-1': {
|
||||
'form_data': {
|
||||
**_select_form_data(),
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'node_title': 'Review',
|
||||
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
||||
},
|
||||
'sender_id': 'initiator-1',
|
||||
'posted_at': time.time(),
|
||||
}
|
||||
}
|
||||
adapter._session_event_ids = {}
|
||||
adapter._anchor_msg_seq = {}
|
||||
|
||||
await adapter._handle_interaction_create(
|
||||
{
|
||||
'id': 'interaction-1',
|
||||
'chat_type': 1,
|
||||
'group_openid': 'group-1',
|
||||
'member_openid': 'reviewer-2',
|
||||
'data': {'resolved': {'button_data': f'{QQ_SELECT_ACTION_PREFIX}1'}},
|
||||
},
|
||||
ws_event_id='event-1',
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
call = adapter.ap.query_pool.add_query.await_args
|
||||
form_action = call.kwargs['variables']['_dify_form_action']
|
||||
assert call.kwargs['launcher_id'] == 'group-1'
|
||||
assert call.kwargs['sender_id'] == 'reviewer-2'
|
||||
assert form_action['action_id'] == ''
|
||||
assert form_action['inputs'] == {'select': 'B'}
|
||||
assert form_action['_current_input_field'] == 'choice'
|
||||
assert form_action['_input_progress'] is True
|
||||
adapter.bot.ack_interaction.assert_awaited_once_with('interaction-1', code=0)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for Telegram Dify form callback helpers."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from telegram import ForceReply
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.sources.telegram import (
|
||||
TelegramAdapter,
|
||||
_telegram_form_action_from_callback,
|
||||
_telegram_select_field_options,
|
||||
)
|
||||
|
||||
|
||||
def _select_form_data() -> dict:
|
||||
return {
|
||||
'_current_input_field': 'choice',
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'choice',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_telegram_select_field_options_are_extracted():
|
||||
assert _telegram_select_field_options(_select_form_data()) == ('choice', ['A', 'B', 'C'])
|
||||
|
||||
|
||||
def test_telegram_select_callback_becomes_input_progress():
|
||||
assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == {
|
||||
'action_id': '',
|
||||
'inputs': {'select': '1'},
|
||||
'_input_progress': True,
|
||||
}
|
||||
|
||||
|
||||
def test_telegram_action_callback_remains_final_action():
|
||||
assert _telegram_form_action_from_callback({'f': 1, 'a': 'approve'}) == {
|
||||
'action_id': 'approve',
|
||||
'inputs': {},
|
||||
}
|
||||
|
||||
|
||||
def test_telegram_invalid_select_callback_is_rejected():
|
||||
assert _telegram_form_action_from_callback({'f': 1, 'x': -1}) is None
|
||||
assert _telegram_form_action_from_callback({'f': 1, 'x': 'invalid'}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_select_field_sends_two_column_inline_keyboard():
|
||||
bot = MagicMock()
|
||||
bot.send_message = AsyncMock()
|
||||
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
||||
adapter._form_action_titles = {}
|
||||
|
||||
update = MagicMock()
|
||||
update.effective_chat.id = 123
|
||||
update.effective_message.message_thread_id = None
|
||||
event = platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
||||
message_chain=platform_message.MessageChain([]),
|
||||
source_platform_object=update,
|
||||
)
|
||||
form_data = {
|
||||
**_select_form_data(),
|
||||
'node_title': 'Review',
|
||||
'form_content': 'Choose one',
|
||||
'workflow_run_id': 'workflow-run-12345678',
|
||||
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
||||
}
|
||||
|
||||
await adapter._send_form_action_buttons(event, form_data)
|
||||
|
||||
args = bot.send_message.await_args.kwargs
|
||||
rows = args['reply_markup'].inline_keyboard
|
||||
assert [[button.text for button in row] for row in rows] == [['A', 'B'], ['C']]
|
||||
callback_data = rows[0][1].callback_data
|
||||
assert len(callback_data.encode('utf-8')) <= 64
|
||||
assert json.loads(callback_data)['x'] == 1
|
||||
assert callback_data in adapter._form_action_titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_text_field_does_not_show_action_buttons():
|
||||
bot = MagicMock()
|
||||
bot.send_message = AsyncMock()
|
||||
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
||||
adapter._form_action_titles = {}
|
||||
|
||||
update = MagicMock()
|
||||
update.effective_chat.id = 123
|
||||
update.effective_message.message_thread_id = None
|
||||
event = platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
||||
message_chain=platform_message.MessageChain([]),
|
||||
source_platform_object=update,
|
||||
)
|
||||
form_data = {
|
||||
'_current_input_field': 'us_input',
|
||||
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
|
||||
'node_title': '人工介入',
|
||||
'form_content': 'us_input (paragraph): reply "us_input: <value>"',
|
||||
'workflow_run_id': 'workflow-run-12345678',
|
||||
'actions': [{'id': 'yes', 'title': 'yes'}, {'id': 'no', 'title': 'no'}],
|
||||
}
|
||||
|
||||
await adapter._send_form_action_buttons(event, form_data)
|
||||
|
||||
args = bot.send_message.await_args.kwargs
|
||||
assert isinstance(args['reply_markup'], ForceReply)
|
||||
assert args['reply_markup'].selective is False
|
||||
assert 'Please reply with a value for us_input.' in args['text']
|
||||
assert adapter._form_action_titles == {}
|
||||
@@ -128,7 +128,7 @@ class TestDifyRunnerConfigValidation:
|
||||
|
||||
mock_app = MagicMock()
|
||||
|
||||
for app_type in ['chat', 'agent', 'workflow']:
|
||||
for app_type in ['chat', 'agent', 'workflow', 'chatflow']:
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'dify-service-api': {
|
||||
@@ -669,3 +669,205 @@ class TestDifyHumanInputForms:
|
||||
assert second['_partial'] is True
|
||||
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
|
||||
assert second['_form_data']['_action_select_only'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocking_resume_uses_chatflow_answer_node_output(self):
|
||||
runner = self._create_runner()
|
||||
|
||||
async def workflow_submit(**kwargs):
|
||||
del kwargs
|
||||
yield {'event': 'message', 'answer': 'partial'}
|
||||
yield {
|
||||
'event': 'node_finished',
|
||||
'data': {
|
||||
'node_type': 'answer',
|
||||
'outputs': {'answer': {'content': 'Final chatflow answer'}},
|
||||
},
|
||||
}
|
||||
yield {'event': 'workflow_finished', 'data': {'error': None, 'outputs': {}}}
|
||||
|
||||
runner.dify_client.workflow_submit = workflow_submit
|
||||
|
||||
messages = [
|
||||
message
|
||||
async for message in runner._submit_workflow_form_blocking(
|
||||
{
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'user': 'person_user-1',
|
||||
'action_id': 'yes',
|
||||
'inputs': {},
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
assert [message.content for message in messages] == ['Final chatflow answer']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_blocking_resume_clears_pending_form(self):
|
||||
from langbot.pkg.provider.runners import difysvapi
|
||||
|
||||
runner = self._create_runner()
|
||||
session_key = 'person_user-1'
|
||||
difysvapi._PENDING_FORMS.clear()
|
||||
difysvapi._set_pending_form(
|
||||
session_key,
|
||||
{
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||||
'inputs': {},
|
||||
'user': session_key,
|
||||
},
|
||||
)
|
||||
|
||||
async def workflow_submit(**kwargs):
|
||||
del kwargs
|
||||
yield {
|
||||
'event': 'workflow_finished',
|
||||
'data': {'error': None, 'outputs': {'summary': 'Completed'}},
|
||||
}
|
||||
|
||||
runner.dify_client.workflow_submit = workflow_submit
|
||||
query = MagicMock()
|
||||
query.session.launcher_type.value = 'person'
|
||||
query.session.launcher_id = 'user-1'
|
||||
query.variables = {
|
||||
'_dify_form_action': {
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'action_id': 'yes',
|
||||
'user': session_key,
|
||||
'inputs': {},
|
||||
}
|
||||
}
|
||||
|
||||
messages = [message async for message in runner._workflow_messages(query)]
|
||||
|
||||
assert [message.content for message in messages] == ['Completed']
|
||||
assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_blocking_resume_keeps_pending_form_for_retry(self):
|
||||
from langbot.libs.dify_service_api.v1.errors import DifyAPIError
|
||||
from langbot.pkg.provider.runners import difysvapi
|
||||
|
||||
runner = self._create_runner()
|
||||
session_key = 'person_user-1'
|
||||
difysvapi._PENDING_FORMS.clear()
|
||||
difysvapi._set_pending_form(
|
||||
session_key,
|
||||
{
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||||
'inputs': {},
|
||||
'user': session_key,
|
||||
},
|
||||
)
|
||||
|
||||
async def workflow_submit(**kwargs):
|
||||
del kwargs
|
||||
raise DifyAPIError('temporary failure')
|
||||
yield
|
||||
|
||||
runner.dify_client.workflow_submit = workflow_submit
|
||||
query = MagicMock()
|
||||
query.session.launcher_type.value = 'person'
|
||||
query.session.launcher_id = 'user-1'
|
||||
query.variables = {
|
||||
'_dify_form_action': {
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'action_id': 'yes',
|
||||
'user': session_key,
|
||||
'inputs': {},
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(DifyAPIError, match='temporary failure'):
|
||||
_ = [message async for message in runner._workflow_messages(query)]
|
||||
|
||||
assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is not None
|
||||
difysvapi._PENDING_FORMS.clear()
|
||||
|
||||
|
||||
class TestDifyCumulativeStreaming:
|
||||
def _create_runner(self, *, remove_think: bool = False):
|
||||
from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.logger = MagicMock()
|
||||
runner = DifyServiceAPIRunner(
|
||||
mock_app,
|
||||
{
|
||||
'ai': {
|
||||
'dify-service-api': {
|
||||
'app-type': 'chat',
|
||||
'api-key': 'test-key',
|
||||
'base-url': 'https://api.dify.ai',
|
||||
'base-prompt': '',
|
||||
}
|
||||
},
|
||||
'output': {'misc': {'remove-think': remove_think}},
|
||||
},
|
||||
)
|
||||
runner.dify_client = MagicMock()
|
||||
return runner
|
||||
|
||||
@staticmethod
|
||||
def _query():
|
||||
query = MagicMock()
|
||||
query.session.launcher_type.value = 'person'
|
||||
query.session.launcher_id = 'user-1'
|
||||
query.session.using_conversation.uuid = 'conversation-1'
|
||||
query.variables = {}
|
||||
query.user_message.content = 'hello'
|
||||
return query
|
||||
|
||||
def test_merge_stream_text_accepts_deltas_and_snapshots(self):
|
||||
from langbot.pkg.provider.runners.difysvapi import _merge_stream_text
|
||||
|
||||
assert _merge_stream_text('Hello', ' world') == 'Hello world'
|
||||
assert _merge_stream_text('<think>one', '<think>one two') == '<think>one two'
|
||||
assert _merge_stream_text('ha', 'ha') == 'haha'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_deduplicates_cumulative_snapshots(self):
|
||||
runner = self._create_runner()
|
||||
snapshots = [f'<think>Reasoning{"." * idx}' for idx in range(1, 10)]
|
||||
snapshots.append(f'{snapshots[-1]}</think>\nHello!')
|
||||
|
||||
async def chat_messages(**kwargs):
|
||||
del kwargs
|
||||
for snapshot in snapshots:
|
||||
yield {'event': 'message', 'answer': snapshot, 'conversation_id': 'conversation-2'}
|
||||
yield {'event': 'message_end', 'conversation_id': 'conversation-2'}
|
||||
|
||||
runner.dify_client.chat_messages = chat_messages
|
||||
|
||||
chunks = [chunk async for chunk in runner._chat_messages_chunk(self._query())]
|
||||
|
||||
assert chunks[-1].content == snapshots[-1]
|
||||
assert chunks[-1].content.count('<think>') == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_removes_think_from_cumulative_snapshots(self):
|
||||
runner = self._create_runner(remove_think=True)
|
||||
|
||||
async def chat_messages(**kwargs):
|
||||
del kwargs
|
||||
yield {'event': 'message', 'answer': '<think>Reasoning', 'conversation_id': 'conversation-2'}
|
||||
yield {
|
||||
'event': 'message',
|
||||
'answer': '<think>Reasoning complete</think>\nHello!',
|
||||
'conversation_id': 'conversation-2',
|
||||
}
|
||||
yield {'event': 'message_end', 'conversation_id': 'conversation-2'}
|
||||
|
||||
runner.dify_client.chat_messages = chat_messages
|
||||
|
||||
chunks = [chunk async for chunk in runner._chat_messages_chunk(self._query())]
|
||||
|
||||
assert chunks[-1].content == 'Hello!'
|
||||
assert all('<think>' not in chunk.content for chunk in chunks if isinstance(chunk.content, str))
|
||||
|
||||
Reference in New Issue
Block a user