feat: add supports for dify hitl (#2226)

* 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
This commit is contained in:
Dongchuan Fu
2026-07-13 00:42:46 +08:00
committed by GitHub
parent 3ddebd26ae
commit 0755beebcd
43 changed files with 12320 additions and 221 deletions
@@ -0,0 +1,254 @@
"""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_card_markdown,
_dingtalk_clean_form_content,
_dingtalk_completed_input_lines,
_dingtalk_extract_component_inputs,
_dingtalk_form_component_params,
_dingtalk_missing_completed_input_lines,
_dingtalk_pending_input_defs,
)
def test_dingtalk_select_component_params_expose_options():
params = _dingtalk_form_component_params(
{
'_current_input_field': 'choice',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
}
)
assert params['select_visible'] == 'true'
assert params['select_placeholder'] == 'choice'
assert params['select_options'] == ['A', 'B']
assert [option['value'] for option in params['index_o']] == ['A', 'B']
assert [option['value'] for option in params['test_index']] == ['A', 'B']
assert params['index_o'][0]['text']['zh_CN'] == 'A'
assert params['index_o'][0]['text']['en_US'] == 'A'
assert params['select_index'] == -1
def test_dingtalk_extract_select_from_builtin_result_dict():
inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}})
assert inputs == {'select': 'B'}
def test_dingtalk_extract_select_from_template_param_string():
inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'})
assert inputs == {'select': '{"index": 1, "value": "B"}'}
def test_dingtalk_extract_input_and_select_together():
inputs = _dingtalk_extract_component_inputs(
{
'inputResult': {'value': 'looks good'},
'__built_in_selectResult__': {'index': 0, 'value': 'A'},
}
)
assert inputs == {'input': 'looks good', 'select': 'A'}
def test_dingtalk_extract_component_inputs_strips_card_line_endings():
inputs = _dingtalk_extract_component_inputs(
{
'inputResult': {'value': '回复我测试\r\n'},
'selectResult': {'value': '1\r'},
}
)
assert inputs == {'input': '回复我测试', 'select': '1'}
def test_dingtalk_pending_input_defs_includes_file_fields():
pending = _dingtalk_pending_input_defs(
{
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {'comment': 'ready'},
}
)
assert [field['output_variable_name'] for field in pending] == ['files']
def test_dingtalk_completed_input_lines_include_text_and_select_values():
lines = _dingtalk_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {'comment': 'looks good', 'choice': 'B'},
}
)
assert lines == ['✅ commentlooks good', '✅ choiceB']
def test_dingtalk_completed_inputs_are_not_repeated_when_already_interleaved():
form_data = {
'all_input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
],
'inputs': {'us_input': '回复我测试\r', 'xiala': '1'},
}
form_content = '你好\n请输入你的问题\n✅ us_input:回复我测试\n请选择你的答案\n✅ xiala1'
assert _dingtalk_missing_completed_input_lines(form_data, form_content) == []
def test_dingtalk_completed_inputs_are_appended_when_template_does_not_render_them():
form_data = {
'all_input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'inputs': {'comment': 'ready'},
}
assert _dingtalk_missing_completed_input_lines(form_data, 'Please review') == ['✅ commentready']
def test_dingtalk_clean_form_content_uses_all_input_defs():
content = _dingtalk_clean_form_content(
{
'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
'input_defs': [],
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
}
)
assert content == 'Hello'
def test_dingtalk_field_stage_keeps_prior_prompts_and_completed_values():
content = _dingtalk_clean_form_content(
{
'_current_input_field': 'choice',
'raw_form_content': ('Question\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
'form_content': 'Choose an answer',
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
'inputs': {'comment': 'hello'},
}
)
assert '{{#$output.' not in content
assert content.index('Question') < content.index('comment')
assert content.index('comment') < content.index('Choose an answer')
def test_dingtalk_final_action_stage_interleaves_prompts_and_completed_values():
content = _dingtalk_clean_form_content(
{
'_action_select_only': True,
'raw_form_content': ('11\nQuestion\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
'inputs': {'comment': 'hello', 'choice': 'B'},
}
)
assert '{{#$output.' not in content
assert content.startswith('11\nQuestion')
assert content.index('Question') < content.index('comment')
assert content.index('comment') < content.index('Choose an answer')
assert content.index('Choose an answer') < content.index('choice')
def test_dingtalk_card_markdown_preserves_internal_line_breaks():
assert _dingtalk_card_markdown('11\nQuestion\nCompleted') == '11<br>Question<br>Completed'
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,42 @@
"""Tests for DingTalk API payload helpers."""
import json
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
params = _stringify_card_param_map(
{
'content': 'Pick one',
'btns': json.dumps([{'text': 'OK'}], ensure_ascii=False),
'select_options': ['A', 'B'],
'index_o': [
{
'value': 'A',
'text': {'zh_CN': 'A', 'en_US': 'A'},
}
],
'test_index': [
{
'value': 'A',
'text': {'zh_CN': 'A', 'en_US': 'A'},
}
],
'select_index': -1,
}
)
assert params['content'] == 'Pick one'
assert params['btns'] == '[{"text": "OK"}]'
assert params['select_options'] == '["A", "B"]'
assert json.loads(params['index_o'])[0]['value'] == 'A'
assert json.loads(params['test_index'])[0]['value'] == 'A'
assert params['select_index'] == '-1'
def test_dingtalk_card_param_map_stringifies_unregistered_structures():
params = _stringify_card_param_map({'other': ['A'], 'empty': None})
assert params['other'] == '["A"]'
assert params['empty'] == ''
@@ -0,0 +1,196 @@
"""Tests for Lark adapter helper behavior."""
from langbot.pkg.platform.sources.lark import (
LarkAdapter,
_lark_clean_form_content,
_lark_completed_input_lines,
_lark_current_input_defs,
_lark_extract_action_form_inputs,
_lark_should_update_stream_element,
_lark_visible_form_content,
)
def test_lark_current_input_defs_only_returns_active_stage():
input_defs = [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
]
assert _lark_current_input_defs(
{
'_current_input_field': 'xiala',
'input_defs': input_defs,
}
) == [input_defs[1]]
assert (
_lark_current_input_defs(
{
'_action_select_only': True,
'input_defs': input_defs,
}
)
== []
)
def test_lark_form_field_elements_only_render_active_stage():
adapter = LarkAdapter.model_construct()
form_data = {
'_current_input_field': 'xiala',
'input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{
'output_variable_name': 'xiala',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['1', '2']},
},
],
}
elements, input_name_map, file_help_lines = adapter._build_lark_form_field_elements(form_data)
assert len(elements) == 1
assert elements[0]['tag'] == 'select_static'
assert elements[0]['label']['content'] == 'xiala'
assert list(input_name_map.values()) == ['xiala']
assert file_help_lines == []
def test_lark_form_stage_skips_closed_streaming_element_update():
assert not _lark_should_update_stream_element(
resume_from=False,
form_data={'_current_input_field': 'xiala'},
msg_seq=1,
is_final=True,
)
assert _lark_should_update_stream_element(
resume_from=False,
form_data=None,
msg_seq=1,
is_final=True,
)
def test_lark_final_action_stage_interleaves_prompts_and_completed_values():
form_content = _lark_visible_form_content(
{
'_action_select_only': True,
'raw_form_content': ('11\nQuestion\n{{#$output.us_input#}}\nChoose an answer\n{{#$output.xiala#}}\n'),
'all_input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
],
'inputs': {'us_input': 'hello', 'xiala': '2'},
}
)
assert '{{#$output.' not in form_content
assert form_content.startswith('11\nQuestion')
assert form_content.index('Question') < form_content.index('us_input')
assert form_content.index('us_input') < form_content.index('Choose an answer')
assert form_content.index('Choose an answer') < form_content.index('xiala')
def test_lark_completed_input_lines_include_text_select_and_files():
lines = _lark_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {
'us_input': '你好',
'xiala': 'or',
'files': [{'upload_file_id': 'file-1'}, {'upload_file_id': 'file-2'}],
},
}
)
assert lines == [
'✅ us_input:你好',
'✅ xialaor',
'✅ files2 file(s)',
]
def test_lark_clean_form_content_removes_all_input_placeholders():
content = _lark_clean_form_content(
'人工介入\n\n{{#$output.us_input#}}\n\n{{#$output.xiala#}}\n',
[
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
],
)
assert content == '人工介入'
def test_lark_extract_action_form_inputs_from_json_form_value():
class Action:
form_value = '{"Input_1_us_input_abcd12": "hello", "Select_2_xiala_abcd12": "B"}'
input_value = None
option = None
name = None
inputs = _lark_extract_action_form_inputs(
Action(),
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
'Select_2_xiala_abcd12': 'xiala',
}
},
)
assert inputs == {'us_input': 'hello', 'xiala': 'B'}
def test_lark_extract_action_form_inputs_from_webhook_dict_action():
inputs = _lark_extract_action_form_inputs(
{
'form_value': {
'Input_1_us_input_abcd12': 'hello',
'Select_2_xiala_abcd12': {'value': 'B', 'text': {'content': 'Option B'}},
}
},
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
'Select_2_xiala_abcd12': 'xiala',
}
},
)
assert inputs == {'us_input': 'hello', 'xiala': {'value': 'B', 'text': {'content': 'Option B'}}}
def test_lark_extract_action_form_inputs_maps_dotted_component_names():
inputs = _lark_extract_action_form_inputs(
{
'form_value': {
'Form_1_token_abcd12.Input_1_us_input_abcd12': 'hello',
}
},
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
}
},
)
assert inputs == {'us_input': 'hello'}
def test_lark_completed_input_lines_display_select_value_from_object():
lines = _lark_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'xiala', 'type': 'select'},
],
'inputs': {'xiala': {'value': 'B', 'text': {'content': 'Option B'}}},
}
)
assert lines == ['✅ xialaB']
@@ -0,0 +1,224 @@
"""Tests for QQ Official keyboard payload helpers."""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
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'] == []
def _stream_test_adapter():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
adapter = QQOfficialAdapter.model_construct()
adapter.logger = AsyncMock()
adapter.bot = MagicMock()
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
adapter.ap = None
adapter._stream_ctx = {}
adapter._stream_ctx_ts = {}
adapter._fallback_text = {}
adapter._fallback_text_ts = {}
return adapter
@pytest.mark.asyncio
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
adapter = _stream_test_adapter()
adapter._stream_ctx['message-1'] = {
'user_openid': 'user-1',
'msg_id': 'source-1',
'stream_msg_id': None,
'msg_seq': 1,
'index': 0,
'last_update_ts': 0,
'accumulated_text': '',
'sent_length': 0,
'session_started': False,
}
adapter._stream_ctx_ts['message-1'] = time.time()
source = MagicMock()
await adapter.reply_message_chunk(
source,
{'resp_message_id': 'message-1'},
platform_message.MessageChain([platform_message.Plain(text='<think>one')]),
)
await adapter.reply_message_chunk(
source,
{'resp_message_id': 'message-1'},
platform_message.MessageChain([platform_message.Plain(text='<think>one two')]),
is_final=True,
)
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
'<think>one',
' two',
]
@pytest.mark.asyncio
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
adapter = _stream_test_adapter()
source = MagicMock()
with patch.object(QQOfficialAdapter, 'reply_message', new=AsyncMock()) as reply_message:
await adapter.reply_message_chunk(
source,
{'resp_message_id': 'message-1'},
platform_message.MessageChain([platform_message.Plain(text='Hel')]),
)
await adapter.reply_message_chunk(
source,
{'resp_message_id': 'message-1'},
platform_message.MessageChain([platform_message.Plain(text='Hello')]),
is_final=True,
)
sent_chain = reply_message.await_args.args[1]
assert str(sent_chain) == 'Hello'
@pytest.mark.asyncio
async def test_qq_text_field_prompt_keeps_form_content():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
adapter = _stream_test_adapter()
adapter._pending_forms = {}
adapter._session_event_ids = {}
adapter._anchor_msg_seq = {}
source = MagicMock()
source.d_id = 'source-1'
source.t = 'C2C_MESSAGE_CREATE'
event = MagicMock()
event.source_platform_object = source
event.sender.id = 'user-1'
form_data = {
'_current_input_field': 'us_input',
'node_title': 'Manual input',
'form_content': '1234\nEnter your question',
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
'actions': [{'id': 'yes', 'title': 'yes'}],
}
with patch.object(QQOfficialAdapter, '_resolve_target_from_event', return_value=('c2c', 'user-1')):
await adapter._handle_form_chunk(event, platform_message.MessageChain([]), form_data)
send_call = adapter.bot.send_markdown_keyboard.await_args.kwargs
assert send_call['markdown_content'] == '### Manual input\n\n1234\nEnter your question'
assert send_call['keyboard'] is None
@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,158 @@
"""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': {'index': 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
def test_telegram_form_callback_cache_consumes_the_whole_form_group():
adapter = TelegramAdapter.model_construct()
adapter._form_action_titles = {}
adapter._cache_form_action_titles({'callback-a': 'A', 'callback-b': 'B'}, now=100.0)
assert adapter._take_form_action_title('callback-a', now=101.0) == 'A'
assert adapter._take_form_action_title('callback-a', now=101.0) is None
assert adapter._take_form_action_title('callback-b', now=101.0) is None
assert adapter._form_action_titles == {}
def test_telegram_form_callback_cache_prunes_expired_entries():
adapter = TelegramAdapter.model_construct()
adapter._form_action_titles = {}
adapter._cache_form_action_titles({'callback-a': 'A'}, now=100.0)
assert adapter._take_form_action_title('callback-a', now=100.0 + adapter._FORM_ACTION_CACHE_TTL) is None
assert adapter._form_action_titles == {}
def test_telegram_form_callback_cache_preserves_pipeline_uuid():
adapter = TelegramAdapter.model_construct()
adapter._form_action_titles = {}
adapter._cache_form_action_titles(
{'callback-a': 'Approve'},
pipeline_uuid='pipeline-routed',
now=100.0,
)
assert adapter._take_form_action_context('callback-a', now=101.0) == (
'Approve',
'pipeline-routed',
)
@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 args['reply_markup'].input_field_placeholder == 'us_input'
assert 'Please reply' not in args['text']
assert args['text'].startswith('[人工介入]')
assert 'us_input (paragraph)' in args['text']
assert adapter._form_action_titles == {}
@@ -0,0 +1,475 @@
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✅ choiceB'
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']
File diff suppressed because it is too large Load Diff