mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +00:00
0755beebcd
* feat: Implement workflow form handling for paused workflows - Added module-level storage for pending forms to manage state across sessions. - Introduced functions to set, get, and clear pending forms with expiration handling. - Enhanced DifyServiceAPIRunner to support resuming paused workflows via form actions. - Implemented logic to yield human input requests and display appropriate messages. - Updated workflow submission methods to handle paused states and resume actions. - Ensured proper merging of pending form actions with user inputs for seamless interaction. * feat: Add '_routed_by_rule' variable to form action in Lark and Telegram adapters * feat: Enhance Lark and Telegram adapters with new form handling for paused workflows * feat: Enhance TelegramAdapter to handle form action buttons and message threading * feat: Improve TelegramAdapter message handling with enhanced error management and draft message support * feat: Add the function for formatting human input text to support adapters without rich UI. * feat(dingtalk): implement human input card support and card action handling - Add a new module `card_callback.py` to handle card action button clicks from DingTalk. - Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters. - Update `DingTalkAdapter` to manage card state and handle form input through a single card template. - Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input. - Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons. * feat(dingtalk): enhance human input card functionality with streaming support and active turn management - Updated the DingTalk card template to enable streaming mode and multi-update configuration. - Removed the obsolete delete_card method from DingTalkClient to streamline card management. - Enhanced DingTalkAdapter to manage active turn cards and accumulated streaming text, ensuring a seamless user experience during human input prompts. - Modified the create_message_card method to utilize existing active cards for resumed workflows, preventing duplication. - Improved the _paint_form_on_card method to update existing cards with human input prompts and buttons dynamically. - Updated the dingtalk_human_input_card.json template to reflect the new streaming capabilities and configuration options. * feat(wecom): implement Dify human input pause handling with button interaction support * feat(qqofficial): implement Dify human input button interaction handling and markdown keyboard support * feat(qqofficial): implement one-click QR binding and enhance localization support * feat(discord): implement Discord form view with button interactions for Dify actions * fix(telegram): correct group chat type check and handle oversized callback data for Telegram actions fix(difysvapi): ensure safe access to remove-think configuration in pipeline settings * feat(dify): add support for chatflow app type and enhance human input handling * feat(telegram): add action title feedback for user selections in Telegram messages * feat(lark): enhance LarkAdapter to store form content for resume notices * feat(dingtalk): update display formatting for card content with HTML line breaks * feat(dingtalk): add feedback functionality to cards with 👍/👎 buttons - Implemented feedback state management for cards, allowing users to provide feedback via thumbs up/down buttons. - Enhanced card rendering to include feedback buttons when appropriate. - Registered feedback listeners to handle feedback events and update card states accordingly. - Updated the card template to support dynamic button rendering for feedback actions. - Improved error handling and logging for feedback actions and card updates. * fix: add Avatar component to dingtalk_human_input_card.json for enhanced user interaction * feat(wecom): add optional source block to interactive template cards for enhanced branding * feat(wecom): add functions for template card action extraction and update, enhance button interaction handling * feat(qqofficial): synchronize passive-reply counter with inbound message sequence * feat(qqofficial): add method to identify invisible form placeholder chunks in messages * feat(dingtalk): add download link for human input card template and enhance dynamic form configuration * feat(telegram): enhance message handling with group stream deletion and form placeholder detection * Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners - Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines. - Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines. - Add tests for WeComBot template card functionalities, including event extraction and payload building for human input. - Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction. * 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. * feat(lark): add functions for current input definitions and visible form content handling feat(qqofficial): update fallback text handling for non-streaming scenarios feat(difysvapi): enhance form content processing for interactive fields and actions test: add unit tests for Lark and QQ Official adapter functionalities * Add tests for DingTalk adapter content processing and markdown formatting - Updated the assertion in `test_dingtalk_completed_input_lines_include_text_and_select_values` to remove unnecessary markdown formatting. - Added new tests to verify that `_dingtalk_clean_form_content` maintains the order of prompts and completed values in various scenarios. - Introduced `test_dingtalk_card_markdown_preserves_internal_line_breaks` to ensure internal line breaks are correctly converted to HTML line breaks. * feat: Refactor input handling and feedback messages across multiple adapters * feat: Update the human-computer interaction template cards, and optimize the prompt information and content display. * feat: Refactor pending form handling to isolate by bot and pipeline * feat: Enhance error handling and caching for Dify and WeCom interactions * feat: Enhance select input handling and validation in Dify API runner and Telegram adapter * feat: Add missing completed input lines handling in DingTalk adapter * feat: Add pipeline_uuid handling across multiple adapters and update related tests
476 lines
16 KiB
Python
476 lines
16 KiB
Python
import sys
|
||
import types
|
||
|
||
import pytest
|
||
|
||
|
||
logger_module = types.ModuleType('langbot.pkg.platform.logger')
|
||
logger_module.EventLogger = object
|
||
sys.modules.setdefault('langbot.pkg.platform.logger', logger_module)
|
||
|
||
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
|
||
WecomBotClient,
|
||
build_button_interaction_payload,
|
||
build_human_input_template_card_payload,
|
||
build_button_interaction_update_card,
|
||
build_multiple_interaction_update_card,
|
||
extract_template_card_event_payload,
|
||
extract_template_card_selections,
|
||
extract_wecom_event_type,
|
||
extract_template_card_action,
|
||
build_human_input_text_prompt,
|
||
parse_select_button_action,
|
||
)
|
||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
|
||
|
||
|
||
def test_extract_template_card_action_supports_nested_button_key():
|
||
task_id, event_key, card_type = extract_template_card_action(
|
||
{
|
||
'taskId': 'task-1',
|
||
'cardType': 'button_interaction',
|
||
'button': {'key': 'approve'},
|
||
}
|
||
)
|
||
|
||
assert task_id == 'task-1'
|
||
assert event_key == 'approve'
|
||
assert card_type == 'button_interaction'
|
||
|
||
|
||
def test_extract_wecom_event_type_supports_top_level_template_card_event():
|
||
payload = {
|
||
'eventtype': 'template_card_event',
|
||
'template_card_event': {
|
||
'TaskId': 'task-1',
|
||
'CardType': 'multiple_interaction',
|
||
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
|
||
},
|
||
}
|
||
|
||
assert extract_wecom_event_type(payload) == 'template_card_event'
|
||
assert extract_template_card_event_payload(payload)['TaskId'] == 'task-1'
|
||
|
||
|
||
def test_extract_wecom_event_type_infers_template_card_event_from_top_level_card_fields():
|
||
payload = {
|
||
'TaskId': 'task-1',
|
||
'CardType': 'button_interaction',
|
||
'EventKey': 'approve',
|
||
}
|
||
|
||
assert extract_wecom_event_type(payload) == 'template_card_event'
|
||
assert extract_template_card_event_payload(payload)['EventKey'] == 'approve'
|
||
|
||
|
||
def test_build_button_interaction_update_card_marks_clicked_button():
|
||
card = build_button_interaction_update_card(
|
||
{
|
||
'node_title': 'Manual Review',
|
||
'form_content': 'Please choose one action.',
|
||
'actions': [
|
||
{'id': 'approve', 'title': 'Approve', 'button_style': 'primary'},
|
||
{'id': 'reject', 'title': 'Reject', 'button_style': 'danger'},
|
||
],
|
||
},
|
||
task_id='task-1',
|
||
action_id='reject',
|
||
source={'desc': 'LangBot'},
|
||
)
|
||
|
||
assert card['main_title'] == {'title': 'Manual Review'}
|
||
assert card['sub_title_text'] == 'Please choose one action.'
|
||
assert card['button_list'][0] == {'text': 'Approve', 'style': 2, 'key': 'approve'}
|
||
assert card['button_list'][1] == {
|
||
'text': '✅ Reject',
|
||
'style': 1,
|
||
'key': 'reject',
|
||
'replace_text': '✅ Reject',
|
||
}
|
||
assert card['source'] == {'desc': 'LangBot'}
|
||
|
||
|
||
def test_build_button_interaction_payload_uses_preselected_button_styles_before_click():
|
||
payload = build_button_interaction_payload(
|
||
{
|
||
'node_title': 'Manual Review',
|
||
'actions': [
|
||
{'id': 'approve', 'title': 'Approve', 'button_style': 'primary'},
|
||
{'id': 'reject', 'title': 'Reject', 'button_style': 'danger'},
|
||
],
|
||
},
|
||
task_id='task-1',
|
||
)
|
||
|
||
assert payload['template_card']['button_list'] == [
|
||
{'text': 'Approve', 'style': 2, 'key': 'approve'},
|
||
{'text': 'Reject', 'style': 2, 'key': 'reject'},
|
||
]
|
||
|
||
|
||
def test_build_payload_uses_multiple_interaction_for_pending_select_field():
|
||
payload = build_human_input_template_card_payload(
|
||
{
|
||
'node_title': 'Manual Review',
|
||
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'choice',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||
}
|
||
],
|
||
'inputs': {},
|
||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||
},
|
||
task_id='task-1',
|
||
source={'desc': 'LangBot'},
|
||
)
|
||
|
||
card = payload['template_card']
|
||
assert card['card_type'] == 'multiple_interaction'
|
||
assert card['source'] == {'desc': 'LangBot'}
|
||
assert card['select_list'] == [
|
||
{
|
||
'question_key': 'choice',
|
||
'title': 'choice',
|
||
'selected_id': 'opt_1',
|
||
'option_list': [
|
||
{'id': 'opt_1', 'text': 'A'},
|
||
{'id': 'opt_2', 'text': 'B'},
|
||
],
|
||
}
|
||
]
|
||
assert card['submit_button'] == {'text': 'Submit', 'key': 'submit_human_input'}
|
||
|
||
|
||
def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws():
|
||
form_data = {
|
||
'node_title': 'Manual Review',
|
||
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'choice',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||
}
|
||
],
|
||
'inputs': {},
|
||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||
}
|
||
payload = build_human_input_template_card_payload(
|
||
form_data,
|
||
task_id='task-1',
|
||
select_as_buttons=True,
|
||
)
|
||
|
||
card = payload['template_card']
|
||
assert card['card_type'] == 'button_interaction'
|
||
assert card['button_list'][0]['text'] == 'A'
|
||
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
|
||
|
||
|
||
def test_text_input_card_uses_current_stage_content_without_direct_reply_prompt():
|
||
payload = build_human_input_template_card_payload(
|
||
{
|
||
'node_title': '人工介入',
|
||
'form_content': '11\n请输入你的问题\n\n{{#$output.us_input#}}',
|
||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'us_input',
|
||
'type': 'paragraph',
|
||
'label': '请输入你的问题',
|
||
}
|
||
],
|
||
'inputs': {},
|
||
'actions': [{'id': 'yes', 'title': 'yes'}],
|
||
'_current_input_field': 'us_input',
|
||
},
|
||
task_id='task-1',
|
||
)
|
||
|
||
card = payload['template_card']
|
||
assert 'desc' not in card['main_title']
|
||
assert card['sub_title_text'] == '11\n请输入你的问题'
|
||
assert card['button_list'] == []
|
||
|
||
|
||
def test_build_human_input_text_prompt_for_current_text_field():
|
||
prompt = build_human_input_text_prompt(
|
||
{
|
||
'node_title': '人工介入',
|
||
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'us_input',
|
||
'type': 'paragraph',
|
||
'label': '请输入你的问题',
|
||
}
|
||
],
|
||
'_current_input_field': 'us_input',
|
||
}
|
||
)
|
||
|
||
assert prompt == '人工介入\n\n11\n请输入你的问题'
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
|
||
client = WecomBotWsClient('bot-id', 'secret', object())
|
||
client._stream_ids['msg-1'] = 'req-1|stream-1'
|
||
client._stream_sessions['msg-1'] = {'user_id': 'user-1'}
|
||
sent = []
|
||
|
||
async def fake_reply_text(req_id, content):
|
||
sent.append((req_id, content))
|
||
return {}
|
||
|
||
client.reply_text = fake_reply_text
|
||
|
||
ok, stream_id, task_id = await client.push_form_pause(
|
||
'msg-1',
|
||
{
|
||
'node_title': '人工介入',
|
||
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'us_input',
|
||
'type': 'paragraph',
|
||
'label': '请输入你的问题',
|
||
}
|
||
],
|
||
'_current_input_field': 'us_input',
|
||
},
|
||
)
|
||
|
||
assert ok is True
|
||
assert stream_id == 'stream-1'
|
||
assert task_id is None
|
||
assert sent == [('req-1', '人工介入\n\n11\n请输入你的问题')]
|
||
assert client._pending_forms_by_task == {}
|
||
assert 'msg-1' not in client._stream_ids
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ws_stream_sends_cumulative_snapshots_to_wecom():
|
||
client = WecomBotWsClient('bot-id', 'secret', object())
|
||
client._stream_ids['msg-1'] = 'req-1|stream-1'
|
||
client._stream_sessions['msg-1'] = {}
|
||
sent = []
|
||
|
||
async def fake_reply_stream(req_id, stream_id, content, finish=False, feedback_id=''):
|
||
sent.append((req_id, stream_id, content, finish))
|
||
return {}
|
||
|
||
client.reply_stream = fake_reply_stream
|
||
|
||
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
||
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||
|
||
assert sent == [
|
||
('req-1', 'stream-1', '你', False),
|
||
('req-1', 'stream-1', '你好', False),
|
||
('req-1', 'stream-1', '你好', True),
|
||
]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
|
||
client = WecomBotClient('', '', '', object(), unified_mode=True)
|
||
session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-1', 'chatid': '', 'from': {'userid': 'user-1'}})
|
||
|
||
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
||
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||
|
||
chunks = [
|
||
await client.stream_sessions.consume(session.stream_id),
|
||
await client.stream_sessions.consume(session.stream_id),
|
||
await client.stream_sessions.consume(session.stream_id),
|
||
]
|
||
assert [(chunk.content, chunk.is_final) for chunk in chunks] == [
|
||
('你', False),
|
||
('你好', False),
|
||
('你好', True),
|
||
]
|
||
|
||
|
||
def test_human_input_payload_keeps_action_select_stage_as_buttons():
|
||
payload = build_human_input_template_card_payload(
|
||
{
|
||
'node_title': 'Manual Review',
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'choice',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||
}
|
||
],
|
||
'inputs': {'choice': 'B'},
|
||
'actions': [
|
||
{'id': 'approve', 'title': 'Approve'},
|
||
{'id': 'reject', 'title': 'Reject'},
|
||
],
|
||
'_action_select_only': True,
|
||
},
|
||
task_id='task-1',
|
||
)
|
||
|
||
card = payload['template_card']
|
||
assert card['card_type'] == 'button_interaction'
|
||
assert card['button_list'] == [
|
||
{'text': 'Approve', 'style': 2, 'key': 'approve'},
|
||
{'text': 'Reject', 'style': 2, 'key': 'reject'},
|
||
]
|
||
|
||
|
||
def test_extract_template_card_selections_maps_selected_id_to_option_text():
|
||
selections = extract_template_card_selections(
|
||
{
|
||
'SelectedItems': [
|
||
{'QuestionKey': 'choice', 'SelectedId': 'opt_2'},
|
||
],
|
||
},
|
||
{
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'choice',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||
}
|
||
],
|
||
},
|
||
)
|
||
|
||
assert selections == {'choice': 'B'}
|
||
|
||
|
||
def test_extract_template_card_selections_reads_nested_response_data_json():
|
||
selections = extract_template_card_selections(
|
||
{
|
||
'CardType': 'multiple_interaction',
|
||
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
|
||
},
|
||
{
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'choice',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||
}
|
||
],
|
||
},
|
||
)
|
||
|
||
assert selections == {'choice': 'B'}
|
||
|
||
|
||
def test_extract_template_card_selections_reads_response_data_direct_mapping():
|
||
selections = extract_template_card_selections(
|
||
{
|
||
'CardType': 'multiple_interaction',
|
||
'EventKey': 'submit_human_input',
|
||
'ResponseData': '{"choice":"opt_2"}',
|
||
},
|
||
{
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'choice',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||
}
|
||
],
|
||
},
|
||
)
|
||
|
||
assert selections == {'choice': 'B'}
|
||
|
||
|
||
def test_build_multiple_interaction_update_card_disables_selected_value_without_submitted_text():
|
||
card = build_multiple_interaction_update_card(
|
||
{
|
||
'node_title': 'Manual Review',
|
||
'form_content': 'Choose a label\n{{#$output.choice#}}',
|
||
'raw_form_content': 'Choose a label\n{{#$output.choice#}}',
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'choice',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||
}
|
||
],
|
||
'_current_input_field': 'choice',
|
||
},
|
||
task_id='task-1',
|
||
selections={'choice': 'B'},
|
||
)
|
||
|
||
assert card['card_type'] == 'multiple_interaction'
|
||
assert card['main_title']['desc'] == 'Choose a label\n✅ choice:B'
|
||
assert card['submit_button']['text'] == '✅'
|
||
assert card['select_list'][0]['disable'] is True
|
||
assert card['select_list'][0]['selected_id'] == 'opt_2'
|
||
|
||
|
||
def test_select_stage_only_shows_current_prompt_in_a_separate_message():
|
||
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'
|
||
payload = build_human_input_template_card_payload(
|
||
{
|
||
'node_title': '人工介入',
|
||
'form_content': '请选择你的答案\n{{#$output.xiala#}}',
|
||
'raw_form_content': raw_content,
|
||
'input_defs': [
|
||
{
|
||
'output_variable_name': 'xiala',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||
}
|
||
],
|
||
'all_input_defs': [
|
||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||
{
|
||
'output_variable_name': 'xiala',
|
||
'type': 'select',
|
||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||
},
|
||
],
|
||
'inputs': {'us_input': '你叫啥'},
|
||
'_current_input_field': 'xiala',
|
||
},
|
||
task_id='task-1',
|
||
)
|
||
|
||
assert payload['template_card']['main_title']['desc'] == '请选择你的答案'
|
||
|
||
|
||
def test_action_stage_only_shows_content_after_fields_without_placeholders():
|
||
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n请选择操作'
|
||
payload = build_human_input_template_card_payload(
|
||
{
|
||
'node_title': '人工介入',
|
||
'form_content': raw_content,
|
||
'raw_form_content': raw_content,
|
||
'input_defs': [],
|
||
'all_input_defs': [
|
||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||
{'output_variable_name': 'xiala', 'type': 'select'},
|
||
],
|
||
'inputs': {'us_input': '你叫啥', 'xiala': '2'},
|
||
'actions': [
|
||
{'id': 'yes', 'title': 'yes'},
|
||
{'id': 'no', 'title': 'no'},
|
||
],
|
||
'_action_select_only': True,
|
||
},
|
||
task_id='task-1',
|
||
)
|
||
|
||
card = payload['template_card']
|
||
assert card['sub_title_text'] == '请选择操作'
|
||
assert '{{#$output.' not in card['sub_title_text']
|
||
assert [button['text'] for button in card['button_list']] == ['yes', 'no']
|