mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
fix(wecombot): improve event routing and sandbox attachment delivery
This commit is contained in:
@@ -21,3 +21,122 @@ async def test_host_without_openat_uses_sandbox_attachment_channel(monkeypatch,
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
service._read_outbox_via_exec.assert_awaited_once_with(query)
|
||||
service._clear_outbox.assert_awaited_once_with(query, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbox_exec_preserves_workspace_and_full_attachment(monkeypatch, tmp_path):
|
||||
import base64
|
||||
import json
|
||||
from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus
|
||||
|
||||
monkeypatch.setattr('os.supports_dir_fd', set())
|
||||
client = Mock(spec=BoxRuntimeClient)
|
||||
content = base64.b64encode(b'file content' * 1000).decode()
|
||||
client.execute = AsyncMock(
|
||||
return_value=BoxExecutionResult(
|
||||
session_id='person_test_user',
|
||||
backend_name='docker',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout=json.dumps([{'name': 'report.txt', 'b64': content}]),
|
||||
stderr='',
|
||||
duration_ms=1,
|
||||
)
|
||||
)
|
||||
service = BoxService(make_app(Mock(), [str(tmp_path)]), client=client, output_limit_chars=100)
|
||||
service._available = True
|
||||
service._tenant_workspace = Mock(return_value=str(tmp_path))
|
||||
service._clear_outbox = AsyncMock()
|
||||
query = make_query()
|
||||
attachments = await service.collect_outbound_attachments(query)
|
||||
assert attachments == [{'type': 'File', 'name': 'report.txt', 'base64': content}]
|
||||
call = client.execute.await_args
|
||||
assert call.kwargs['action_context'].workspace_uuid == query.workspace_uuid
|
||||
assert call.kwargs['action_context'].instance_uuid == query.instance_uuid
|
||||
assert call.args[0].host_path == str(tmp_path)
|
||||
assert call.args[0].session_id == 'person_test_user'
|
||||
service._clear_outbox.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', ['rpc', 'command', 'invalid_json'])
|
||||
async def test_outbox_read_failure_keeps_files(monkeypatch, failure):
|
||||
from langbot_plugin.box.errors import BoxValidationError
|
||||
from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus
|
||||
|
||||
monkeypatch.setattr('os.supports_dir_fd', set())
|
||||
client = Mock(spec=BoxRuntimeClient)
|
||||
client.execute = AsyncMock(
|
||||
return_value=BoxExecutionResult(
|
||||
session_id='person_test_user',
|
||||
backend_name='docker',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=1 if failure == 'command' else 0,
|
||||
stdout='not json',
|
||||
stderr='',
|
||||
duration_ms=1,
|
||||
)
|
||||
)
|
||||
if failure == 'rpc':
|
||||
client.execute.side_effect = BoxValidationError('missing context')
|
||||
service = BoxService(make_app(Mock()), client=client)
|
||||
service._available = True
|
||||
service._clear_outbox = AsyncMock()
|
||||
with pytest.raises(BoxValidationError):
|
||||
await service.collect_outbound_attachments(make_query())
|
||||
service._clear_outbox.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('oversized', [False, True])
|
||||
async def test_real_outbox_reader_handles_image_above_old_256k_limit(tmp_path, monkeypatch, oversized):
|
||||
import asyncio
|
||||
import base64
|
||||
import sys
|
||||
from langbot_plugin.box.errors import BoxValidationError
|
||||
from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus
|
||||
|
||||
monkeypatch.setattr('os.supports_dir_fd', set())
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
service._available = True
|
||||
service.OUTBOX_MOUNT_DIR = tmp_path.as_posix()
|
||||
if oversized:
|
||||
service._ATTACHMENT_MAX_BYTES = 300000
|
||||
folder = tmp_path / 'query-42'
|
||||
folder.mkdir()
|
||||
payload = b'\x89PNG\r\n\x1a\n' + b'x' * (343154 - 8)
|
||||
(folder / 'sunset.png').write_bytes(payload)
|
||||
|
||||
async def execute_script(spec, query):
|
||||
script = spec['cmd'].split('\n', 1)[1].rsplit('\nLBPY', 1)[0]
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
'-c',
|
||||
script,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
return BoxExecutionResult(
|
||||
session_id='person_test_user',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=process.returncode,
|
||||
stdout=stdout.decode(),
|
||||
stderr=stderr.decode(),
|
||||
duration_ms=1,
|
||||
)
|
||||
|
||||
service._execute_spec_payload_result = execute_script
|
||||
service._clear_outbox = AsyncMock()
|
||||
if oversized:
|
||||
with pytest.raises(BoxValidationError):
|
||||
await service.collect_outbound_attachments(make_query())
|
||||
service._clear_outbox.assert_not_awaited()
|
||||
assert (folder / 'sunset.png').exists()
|
||||
else:
|
||||
attachments = await service.collect_outbound_attachments(make_query())
|
||||
assert len(attachments) == 1
|
||||
assert attachments[0]['type'] == 'Image'
|
||||
assert base64.b64decode(attachments[0]['base64'].split(',', 1)[1]) == payload
|
||||
service._clear_outbox.assert_awaited_once()
|
||||
|
||||
@@ -934,20 +934,21 @@ async def test_truncate_stderr_independently():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_profile_default_provides_defaults():
|
||||
@pytest.mark.parametrize('profile_name,network', [('default', BoxNetworkMode.ON), ('offline', BoxNetworkMode.OFF)])
|
||||
async def test_profile_default_provides_defaults(profile_name, network):
|
||||
"""When tool call omits network/image, profile defaults are used."""
|
||||
logger = Mock()
|
||||
backend = FakeBackend(logger)
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
service = BoxService(make_app(logger, profile=profile_name), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
await service.initialize()
|
||||
|
||||
result = await service.execute_tool({'command': 'echo hi'}, make_query(30))
|
||||
|
||||
assert result['ok'] is True
|
||||
spec = backend.start_specs[0]
|
||||
profile = BUILTIN_PROFILES['default']
|
||||
assert spec.network == BoxNetworkMode.OFF
|
||||
profile = BUILTIN_PROFILES[profile_name]
|
||||
assert spec.network == network
|
||||
assert spec.image == profile.image
|
||||
assert spec.timeout_sec == profile.timeout_sec
|
||||
|
||||
@@ -962,14 +963,14 @@ async def test_profile_unlocked_field_can_be_overridden():
|
||||
await service.initialize()
|
||||
|
||||
result = await service.execute_spec_payload(
|
||||
{'cmd': 'echo hi', 'timeout_sec': 60, 'network': 'on', 'session_id': 'person_test_user'},
|
||||
{'cmd': 'echo hi', 'timeout_sec': 60, 'network': 'off', 'session_id': 'person_test_user'},
|
||||
make_query(31),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
spec = backend.start_specs[0]
|
||||
assert spec.timeout_sec == 60
|
||||
assert spec.network == BoxNetworkMode.ON
|
||||
assert spec.network == BoxNetworkMode.OFF
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1037,10 +1038,15 @@ def test_unknown_profile_raises_error():
|
||||
def test_builtin_profiles_are_consistent():
|
||||
"""Basic sanity check on all built-in profiles."""
|
||||
assert 'default' in BUILTIN_PROFILES
|
||||
assert 'offline' in BUILTIN_PROFILES
|
||||
assert 'offline_readonly' in BUILTIN_PROFILES
|
||||
assert 'network_basic' in BUILTIN_PROFILES
|
||||
assert 'network_extended' in BUILTIN_PROFILES
|
||||
|
||||
default = BUILTIN_PROFILES['default']
|
||||
assert default.network == BoxNetworkMode.ON
|
||||
assert BUILTIN_PROFILES['offline'] == default.model_copy(update={'name': 'offline', 'network': BoxNetworkMode.OFF})
|
||||
|
||||
offline = BUILTIN_PROFILES['offline_readonly']
|
||||
assert offline.network == BoxNetworkMode.OFF
|
||||
assert offline.host_path_mode == BoxHostMountMode.READ_ONLY
|
||||
@@ -2025,7 +2031,8 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_client_execute(spec):
|
||||
async def fake_client_execute(spec, *, action_context=None):
|
||||
assert action_context.workspace_uuid == query.workspace_uuid
|
||||
cmd = spec.cmd
|
||||
calls.append(cmd)
|
||||
if 'os.scandir' in cmd:
|
||||
@@ -2068,7 +2075,8 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_client_execute(spec):
|
||||
async def fake_client_execute(spec, *, action_context=None):
|
||||
assert action_context.workspace_uuid == query.workspace_uuid
|
||||
cmd = spec.cmd
|
||||
calls.append(cmd)
|
||||
if 'os.scandir' in cmd:
|
||||
|
||||
@@ -89,3 +89,24 @@ async def test_cumulative_stream_ends_with_last_snapshot_even_when_final_is_blan
|
||||
]
|
||||
assert [item.all_content for item in results] == ['hel', 'hello', 'hello']
|
||||
assert results[-1].is_final
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_round_completion_keeps_one_transcript_and_attachments():
|
||||
first = '<think>plan</think>I will create a file.'
|
||||
last = '<think>file created</think>Here is your file.'
|
||||
completed = Message(role='assistant', content=last)
|
||||
completed.attachments = MessageChain([File(name='report.txt', base64='aGVsbG8=')])
|
||||
results = [
|
||||
item
|
||||
async for item in coalesce_stream_results(
|
||||
source(
|
||||
MessageChunk(role='assistant', content=first, is_final=False),
|
||||
MessageChunk(role='assistant', content=first + last, is_final=True),
|
||||
completed,
|
||||
)
|
||||
)
|
||||
]
|
||||
assert len(results) == 2
|
||||
assert results[-1].content == first + last
|
||||
assert results[-1].attachments is completed.attachments
|
||||
|
||||
@@ -330,3 +330,170 @@ async def test_wecombot_webhook_mode_rejects_proactive_send():
|
||||
await adapter.send_message(
|
||||
'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')])
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('kind', ['enter_chat', 'template_card_event'])
|
||||
async def test_business_events_preserve_identity_payload_and_deduplicate(kind):
|
||||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
||||
|
||||
adapter = make_adapter()
|
||||
listener = AsyncMock()
|
||||
adapter.listeners[platform_events.EBAEvent] = listener
|
||||
client = WecomBotWsClient('bot-id', 'secret', DummyLogger())
|
||||
client.on_message('event')(adapter._handle_native_event)
|
||||
client.on_message('template_card_event')(adapter._handle_interaction_event)
|
||||
body = {
|
||||
'msgid': 'business-event-1',
|
||||
'msgtype': 'event',
|
||||
'chattype': 'single',
|
||||
'from': {'userid': 'visitor-1', 'alias': 'Visitor'},
|
||||
'create_time': 1700000000,
|
||||
'event': {
|
||||
'eventtype': kind,
|
||||
'template_card_event': {
|
||||
'task_id': 'business-card',
|
||||
'event_key': 'approve',
|
||||
'card_type': 'button_interaction',
|
||||
'selected_items': [{'question_key': 'choice', 'option_ids': ['yes']}],
|
||||
},
|
||||
},
|
||||
}
|
||||
frame = {'headers': {'req_id': 'callback-1'}, 'body': body}
|
||||
await client._handle_event_callback(frame)
|
||||
await client._handle_event_callback(frame)
|
||||
listener.assert_awaited_once()
|
||||
event = listener.await_args.args[0]
|
||||
assert event.type == 'wecombot.' + kind
|
||||
assert event.user.id == 'visitor-1'
|
||||
assert event.chat_id == 'visitor-1'
|
||||
assert event.timestamp == 1700000000
|
||||
assert event.data['req_id'] == 'callback-1'
|
||||
assert event.data['event'] == body['event']
|
||||
assert type(event).model_validate(event.model_dump()).type == event.type
|
||||
if kind == 'template_card_event':
|
||||
assert event.event_key == 'approve'
|
||||
assert event.selected_items[0]['option_ids'] == ['yes']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_managed_card_callback_does_not_start_business_event():
|
||||
adapter = make_adapter()
|
||||
listener = AsyncMock()
|
||||
adapter.listeners[platform_events.EBAEvent] = listener
|
||||
await adapter._handle_interaction_event(
|
||||
WecomBotEvent(
|
||||
{
|
||||
'type': 'single',
|
||||
'msgtype': 'event',
|
||||
'userid': 'user-1',
|
||||
'event': {
|
||||
'eventtype': 'template_card_event',
|
||||
'template_card_event': {
|
||||
'task_id': 'lbi-managed',
|
||||
'event_key': 'lbi:token:a:0',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
listener.assert_awaited_once()
|
||||
event = listener.await_args.args[0]
|
||||
assert event.type == 'platform.specific'
|
||||
assert event.action == 'interaction.submitted'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('webhook', [False, True])
|
||||
async def test_enter_chat_reply_uses_welcome_callback_and_checks_deadline(webhook):
|
||||
import time
|
||||
|
||||
adapter = make_adapter(webhook)
|
||||
adapter.bot.reply_welcome = AsyncMock(return_value={'ok': True})
|
||||
context = {
|
||||
'event_type': 'wecombot.enter_chat',
|
||||
'data': {
|
||||
'timestamp': time.time(),
|
||||
'message_id': 'entry-1',
|
||||
'data': {'req_id': 'request-1'},
|
||||
},
|
||||
}
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='Welcome')])
|
||||
await adapter.reply_to_event(context=context, target_type='person', target_id='user-1', message=message)
|
||||
adapter.bot.reply_welcome.assert_awaited_once_with('entry-1' if webhook else 'request-1', 'Welcome')
|
||||
context['data']['timestamp'] -= 10
|
||||
with pytest.raises(ValueError, match='5 seconds'):
|
||||
await adapter.reply_to_event(context=context, target_type='person', target_id='user-1', message=message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_attachment_is_uploaded_and_delivered_after_stream():
|
||||
adapter = make_adapter()
|
||||
adapter.bot.upload_media = AsyncMock(return_value={'media_id': 'file-1'})
|
||||
adapter.bot.reply_file = AsyncMock(return_value={'errcode': 0})
|
||||
source = await adapter.event_converter.target2yiri(wecombot_event())
|
||||
message = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='Here is your file'),
|
||||
platform_message.File(name='report.txt', base64='aGVsbG8='),
|
||||
]
|
||||
)
|
||||
await adapter.reply_message_chunk(source, None, message, is_final=True)
|
||||
adapter.bot.upload_media.assert_awaited_once_with(b'hello', 'report.txt', media_type='file')
|
||||
adapter.bot.reply_file.assert_awaited_once_with('req-1', 'file-1')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', ['upload', 'reply', 'timeout'])
|
||||
async def test_file_delivery_errors_are_not_silent(failure):
|
||||
adapter = make_adapter()
|
||||
adapter.bot.upload_media = AsyncMock(return_value=None if failure == 'upload' else {'media_id': 'file-1'})
|
||||
adapter.bot.reply_file = AsyncMock(return_value=None if failure == 'timeout' else {'errcode': 40001})
|
||||
source = await adapter.event_converter.target2yiri(wecombot_event())
|
||||
with pytest.raises(RuntimeError, match='WeCom file'):
|
||||
await adapter.reply_message(
|
||||
source,
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.File(name='report.txt', base64='aGVsbG8='),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_round_file_completion_finalizes_once_without_text_fallback():
|
||||
from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
|
||||
adapter = make_adapter()
|
||||
adapter.bot.upload_media = AsyncMock(return_value={'media_id': 'file-1'})
|
||||
adapter.bot.reply_file = AsyncMock(return_value={'errcode': 0})
|
||||
source = await adapter.event_converter.target2yiri(wecombot_event())
|
||||
prefix = '<think>plan</think>Creating your file.'
|
||||
final = '<think>done</think>Your file is ready.'
|
||||
completed = Message(role='assistant', content=final)
|
||||
completed.attachments = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.File(name='report.txt', base64='aGVsbG8='),
|
||||
]
|
||||
)
|
||||
|
||||
async def results():
|
||||
yield MessageChunk(role='assistant', content=prefix)
|
||||
yield MessageChunk(role='assistant', content=prefix + final, tool_calls=[], is_final=True)
|
||||
yield completed
|
||||
|
||||
async for result in coalesce_stream_results(results()):
|
||||
await adapter.reply_message_chunk(
|
||||
source,
|
||||
None,
|
||||
result.get_content_platform_message_chain(),
|
||||
is_final=isinstance(result, Message) or result.is_final,
|
||||
)
|
||||
assert sum(call.kwargs['is_final'] for call in adapter.bot.push_stream_chunk.await_args_list) == 1
|
||||
adapter.bot.reply_text.assert_not_awaited()
|
||||
adapter.bot.reply_file.assert_awaited_once_with('req-1', 'file-1')
|
||||
final_text = adapter.bot.push_stream_chunk.await_args_list[-1].args[1]
|
||||
assert final_text.count('<think>') == 1
|
||||
assert final_text.count('Your file is ready.') == 1
|
||||
|
||||
@@ -569,3 +569,132 @@ def test_action_stage_only_shows_content_after_fields_without_placeholders():
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user