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']