mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-05 07:12:02 +08:00
Drop intermediate stream frames that land inside the pacing window so a fast
runner cannot flood the WeCom wire. The first frame of a stream is released
immediately because there is no prior timestamp to compare against, and final
frames always bypass the throttle.
Frames carry complete snapshots since a2b7f6b7, so dropping an intermediate
frame loses no content: the next frame already carries everything.
707 lines
24 KiB
Python
707 lines
24 KiB
Python
import sys
|
||
import types
|
||
from unittest.mock import Mock
|
||
|
||
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 import ws_client # noqa: E402
|
||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
|
||
|
||
|
||
def test_ws_callback_tasks_are_bounded():
|
||
client = WecomBotWsClient('bot-id', 'secret', object())
|
||
client._callback_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
|
||
|
||
async def callback():
|
||
raise AssertionError('rejected callback must not run')
|
||
|
||
assert client._start_callback_task(callback()) is False
|
||
assert len(client._callback_tasks) == 100
|
||
|
||
|
||
def test_webhook_dispatch_tasks_are_bounded():
|
||
client = WecomBotClient('', '', '', object(), unified_mode=True)
|
||
client._dispatch_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
|
||
|
||
assert client._start_dispatch_task(Mock()) is False
|
||
assert len(client._dispatch_tasks) == 100
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
|
||
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
|
||
|
||
order = []
|
||
logger = types.SimpleNamespace(
|
||
debug=Mock(),
|
||
error=Mock(),
|
||
warning=Mock(),
|
||
)
|
||
client = WecomBotWsClient('bot-id', 'secret', logger)
|
||
|
||
async def parse_message(*args, **kwargs):
|
||
del args, kwargs
|
||
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
|
||
|
||
async def reply_stream(*args, **kwargs):
|
||
del args, kwargs
|
||
order.append('initial-frame')
|
||
return {}
|
||
|
||
async def dispatch_event(event):
|
||
del event
|
||
order.append('pipeline-dispatch')
|
||
|
||
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
|
||
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
|
||
client.reply_stream = reply_stream
|
||
client._dispatch_event = dispatch_event
|
||
|
||
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
|
||
|
||
assert order == ['initial-frame', 'pipeline-dispatch']
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
|
||
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
|
||
|
||
dispatched = []
|
||
|
||
class Logger:
|
||
def __init__(self):
|
||
self.warnings = []
|
||
|
||
async def debug(self, message):
|
||
del message
|
||
|
||
async def error(self, message):
|
||
raise AssertionError(message)
|
||
|
||
async def warning(self, message):
|
||
self.warnings.append(message)
|
||
|
||
logger = Logger()
|
||
client = WecomBotWsClient('bot-id', 'secret', logger)
|
||
|
||
async def parse_message(*args, **kwargs):
|
||
del args, kwargs
|
||
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
|
||
|
||
async def reply_stream(*args, **kwargs):
|
||
del args, kwargs
|
||
raise ConnectionError('simulated reply failure')
|
||
|
||
async def dispatch_event(event):
|
||
dispatched.append(event)
|
||
|
||
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
|
||
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
|
||
client.reply_stream = reply_stream
|
||
client._dispatch_event = dispatch_event
|
||
|
||
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
|
||
|
||
assert len(dispatched) == 1
|
||
assert len(logger.warnings) == 1
|
||
assert 'simulated reply failure' in logger.warnings[0]
|
||
|
||
|
||
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(monkeypatch):
|
||
# Frame pacing has its own coverage in test_wecombot_stream_throttle.py.
|
||
# Disable it here so this test keeps asserting what it is named for: that
|
||
# every frame carries the cumulative snapshot rather than a delta.
|
||
monkeypatch.setattr(ws_client, '_STREAM_PUSH_MIN_INTERVAL', 0.0)
|
||
|
||
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', '<think>test</think>你好', 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', '<think>test</think>你好', 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', '<think>test</think>你好', is_final=False)
|
||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||
|
||
assert session.queue.qsize() == 1
|
||
chunk = await client.stream_sessions.consume(session.stream_id)
|
||
assert (chunk.content, chunk.is_final) == ('你好', 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']
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_webhook_enter_chat_returns_welcome_without_message_stream():
|
||
from unittest.mock import AsyncMock
|
||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||
|
||
logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock())
|
||
client = WecomBotClient('', '', '', logger, unified_mode=True)
|
||
client._encrypt_and_reply = AsyncMock(side_effect=lambda reply, nonce: reply)
|
||
ordinary = AsyncMock()
|
||
client.on_message('single')(ordinary)
|
||
|
||
async def welcome(event):
|
||
assert isinstance(event, WecomBotEvent)
|
||
await client.reply_welcome(event.message_id, 'Welcome')
|
||
|
||
client.on_message('event')(welcome)
|
||
result = await client._handle_enter_chat(
|
||
{
|
||
'msgid': 'entry-1',
|
||
'msgtype': 'event',
|
||
'chattype': 'single',
|
||
'from': {'userid': 'visitor'},
|
||
'event': {'eventtype': 'enter_chat'},
|
||
},
|
||
'nonce',
|
||
)
|
||
assert result == {'msgtype': 'text', 'text': {'content': 'Welcome'}}
|
||
ordinary.assert_not_awaited()
|
||
assert not client._welcome_replies
|
||
with pytest.raises(ValueError, match='expired'):
|
||
await client.reply_welcome('entry-1', 'too late')
|
||
await client.close()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_webhook_unmanaged_card_dispatches_but_expired_dify_does_not():
|
||
import asyncio
|
||
from unittest.mock import AsyncMock
|
||
|
||
logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock())
|
||
client = WecomBotClient('', '', '', logger, unified_mode=True)
|
||
client._encrypt_and_reply = AsyncMock(return_value={})
|
||
handler = AsyncMock()
|
||
client.on_message('template_card_event')(handler)
|
||
for task_id in ['business-card', 'dify-expired']:
|
||
await client._handle_template_card_event(
|
||
{
|
||
'msgid': task_id,
|
||
'msgtype': 'event',
|
||
'chattype': 'single',
|
||
'from': {'userid': 'visitor'},
|
||
'event': {
|
||
'eventtype': 'template_card_event',
|
||
'template_card_event': {
|
||
'task_id': task_id,
|
||
'event_key': 'approve',
|
||
},
|
||
},
|
||
},
|
||
'nonce',
|
||
)
|
||
await asyncio.gather(*client._dispatch_tasks)
|
||
handler.assert_awaited_once()
|
||
assert handler.await_args.args[0].userid == 'visitor'
|
||
await client.close()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ws_welcome_uses_dedicated_command():
|
||
from unittest.mock import AsyncMock
|
||
|
||
client = WecomBotWsClient('bot-id', 'secret', object())
|
||
client._send_reply = AsyncMock(return_value={})
|
||
await client.reply_welcome('req-1', 'Welcome')
|
||
client._send_reply.assert_awaited_once_with(
|
||
'req-1', {'msgtype': 'text', 'text': {'content': 'Welcome'}}, cmd='aibot_respond_welcome_msg'
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_expired_custom_managed_card_never_becomes_business_event():
|
||
from unittest.mock import AsyncMock
|
||
|
||
logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock())
|
||
client = WecomBotWsClient('bot-id', 'secret', logger)
|
||
handler = AsyncMock()
|
||
client.on_message('template_card_event')(handler)
|
||
client._drop_pending_form_task('custom-managed-task', {})
|
||
body = {
|
||
'msgtype': 'event',
|
||
'event': {
|
||
'eventtype': 'template_card_event',
|
||
'template_card_event': {
|
||
'task_id': 'custom-managed-task',
|
||
'event_key': 'approve',
|
||
},
|
||
},
|
||
}
|
||
await client._handle_template_card_event_frame({'headers': {'req_id': 'r1'}}, body)
|
||
handler.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_webhook_welcome_timeout_cleans_pending_reply(monkeypatch):
|
||
import asyncio
|
||
from unittest.mock import AsyncMock
|
||
|
||
logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock())
|
||
client = WecomBotClient('', '', '', logger, unified_mode=True)
|
||
client._encrypt_and_reply = AsyncMock(side_effect=lambda reply, nonce: reply)
|
||
|
||
async def timeout(awaitable, timeout):
|
||
awaitable.cancel()
|
||
raise asyncio.TimeoutError
|
||
|
||
monkeypatch.setattr(asyncio, 'wait_for', timeout)
|
||
result = await client._handle_enter_chat(
|
||
{
|
||
'msgid': 'no-reply',
|
||
'msgtype': 'event',
|
||
'event': {'eventtype': 'enter_chat'},
|
||
},
|
||
'nonce',
|
||
)
|
||
assert result == {}
|
||
assert not client._welcome_replies
|
||
await client.close()
|