fix(4.11): align omni adapters and EventProcessor marketplace support

This commit is contained in:
RockChinQ
2026-09-10 15:06:57 +08:00
parent 5ba25c39af
commit d1c79a35e8
137 changed files with 2653 additions and 657 deletions
@@ -207,7 +207,8 @@ async def test_aiocqhttp_message_converter_maps_onebot_segments_to_chain():
assert chain[10].face_type == 'rps'
assert isinstance(chain[11], platform_message.Face)
assert chain[11].face_type == 'dice'
assert isinstance(chain[12], platform_message.Unknown)
assert isinstance(chain[12], platform_message.Plain)
assert chain[12].text == '[]'
@pytest.mark.asyncio
@@ -254,7 +255,7 @@ async def test_aiocqhttp_event_converter_maps_private_and_group_messages():
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.type == 'message.received'
assert private_event.adapter_name == 'aiocqhttp'
assert private_event.adapter_name == 'aiocqhttp-omni'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 10001
assert private_event.sender.nickname == 'Alice'
@@ -11,6 +11,7 @@ from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.entity.persistence.bot import Bot
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
from langbot.pkg.platform.adapter_names import OMNI_ADAPTER_NAMES
from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
from langbot.pkg.workspace.errors import WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -240,7 +241,11 @@ async def test_reload_stops_and_drops_existing_platform_runtimes():
@pytest.mark.asyncio
async def test_cloud_startup_reuses_validated_platform_binding():
@pytest.mark.parametrize(
('saved_adapter', 'registered_adapter'),
[('probe', 'probe')] + [(f'{name}-eba', f'{name}-omni') for name in sorted(OMNI_ADAPTER_NAMES)],
)
async def test_cloud_startup_reuses_validated_platform_binding(saved_adapter, registered_adapter):
class TenantUow:
async def __aenter__(self):
return self
@@ -270,7 +275,7 @@ async def test_cloud_startup_reuses_validated_platform_binding():
workspace_uuid=WORKSPACE_A,
name='Probe',
description='',
adapter='probe',
adapter=saved_adapter,
adapter_config={},
enable=False,
event_bindings=[],
@@ -295,11 +300,12 @@ async def test_cloud_startup_reuses_validated_platform_binding():
workspace_service=workspace_service,
)
manager = PlatformManager(application)
manager.adapter_dict = {'probe': ProbeAdapter}
manager.adapter_dict = {registered_adapter: ProbeAdapter}
await manager.load_bots_from_db()
assert len(manager.bots) == 1
assert manager.bots[0].bot_entity.adapter == registered_adapter
workspace_service.get_execution_binding.assert_not_awaited()
+42 -1
View File
@@ -1,8 +1,11 @@
"""Tests for DingTalk API payload helpers."""
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map
from langbot.pkg.utils import httpclient
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
@@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures():
assert params['other'] == '["A"]'
assert params['empty'] == ''
async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch):
response = type('Response', (), {'status_code': 200})()
post = AsyncMock(return_value=response)
@asynccontextmanager
async def client_context():
yield type('HttpClient', (), {'post': post})()
client = object.__new__(DingTalkClient)
client.access_token = 'access-token'
client.robot_code = 'robot-code'
client.key = 'client-id'
client.logger = None
client.check_access_token = AsyncMock(return_value=True)
client._http_client_context = client_context
monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}'))
original_params = {'content': 'hello'}
delivered = await client.create_and_deliver_card(
card_template_id='template-id',
out_track_id='track-id',
open_space_id='dtv1.card//IM_ROBOT.user-id',
is_group=False,
card_param_map=original_params,
card_data_config={'autoLayout': True},
)
request_body = post.await_args.kwargs['json']
assert delivered is True
assert request_body['cardData'] == {
'cardParamMap': {
'content': 'hello',
'config': '{"autoLayout": true}',
}
}
assert original_params == {'content': 'hello'}
@@ -254,7 +254,7 @@ async def test_dingtalk_event_converter_maps_group_and_private_message():
group_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot')
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.adapter_name == 'dingtalk-eba'
assert group_event.adapter_name == 'dingtalk-omni'
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'group-1'
assert group_event.group.name == 'LangBot Team'
@@ -280,7 +280,7 @@ async def test_dingtalk_event_converter_maps_card_feedback():
)
assert isinstance(feedback, platform_events.FeedbackReceivedEvent)
assert feedback.adapter_name == 'dingtalk-eba'
assert feedback.adapter_name == 'dingtalk-omni'
assert feedback.feedback_id == 'feedback-1'
assert feedback.feedback_type == 1
assert feedback.user_id == 'user-1'
@@ -242,7 +242,7 @@ async def test_discord_converter_maps_message_edit_delete_and_reaction_events():
assert isinstance(received, platform_events.MessageReceivedEvent)
assert received.type == 'message.received'
assert received.adapter_name == 'discord'
assert received.adapter_name == 'discord-omni'
assert received.chat_type == platform_entities.ChatType.GROUP
assert received.chat_id == 789
assert received.group.id == 456
@@ -173,7 +173,7 @@ async def test_kook_event_converter_maps_group_private_and_platform_specific_eve
group_event = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.type == 'message.received'
assert group_event.adapter_name == 'kook'
assert group_event.adapter_name == 'kook-omni'
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'c1'
assert group_event.group.id == 'c1'
+122 -1
View File
@@ -1,7 +1,7 @@
"""Tests for Lark adapter helper behavior."""
import threading
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import (
_lark_completed_input_lines,
_lark_current_input_defs,
_lark_extract_action_form_inputs,
_lark_final_layout_texts,
_lark_should_update_stream_element,
_lark_visible_form_content,
)
@@ -221,3 +222,123 @@ def test_lark_completed_input_lines_display_select_value_from_object():
)
assert lines == ['✅ xialaB']
def test_lark_final_layout_texts_normal_round_drops_resume_placeholder():
"""Non-resume final chunk: the reply must land in the main element only.
Regression: rendering the resume placeholder too duplicated the reply,
because the accumulated streaming text equals the final text on a normal
round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card).
"""
main_text, resume_text = _lark_final_layout_texts(
resume_from=False,
text_message='It is Sep 1, 2026, 15:09:15.',
pre_pause_cached=None,
resume_cached='It is Sep 1, 2026, 15:09:15.',
)
assert main_text == 'It is Sep 1, 2026, 15:09:15.'
assert resume_text == ''
def test_lark_final_layout_texts_resume_round_keeps_both_segments():
"""Dify HITL resume final chunk: pre-pause text and resumed text differ,
both segments stay visible."""
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='resumed answer',
pre_pause_cached='partial answer before pause',
resume_cached='resumed answer',
)
assert main_text == 'partial answer before pause'
assert resume_text == 'resumed answer'
def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back():
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='answer',
pre_pause_cached=None,
resume_cached='answer',
)
assert main_text == 'answer'
assert resume_text == 'answer'
def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty():
"""Dify paused before emitting any text: the pre-pause cache is a valid
empty string and must NOT be treated as a cache miss.
Regression: `pre_pause_cached or text_message` fell back to the full
text, so the final card rendered ('resumed answer', 'resumed answer')
and duplicated the reply.
"""
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='resumed answer',
pre_pause_cached='',
resume_cached='resumed answer',
)
assert main_text == ''
assert resume_text == 'resumed answer'
def _build_resume_final_chunk_adapter(message_text: str):
"""Build a LarkAdapter whose card state mimics a Dify HITL round that
paused before emitting any text, then resumed and completed."""
adapter = LarkAdapter.model_construct(
api_client=MagicMock(),
message_converter=MagicMock(
yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))
),
)
adapter.config = {'app_type': 'self'}
LarkAdapter.get_app_access_token = lambda self: None
LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None
adapter.card_id_dict = {'msg-1': 'card-1'}
adapter.card_streaming_text = {'card-1': message_text}
adapter.card_pre_pause_text = {'card-1': ''}
adapter.card_resume_transitioned = {'card-1'}
adapter.card_sequence_dict = {}
adapter.card_last_accessed = {}
adapter.card_cleanup_at = 0.0
adapter.card_id_to_source_ids = {}
adapter.reply_message_card_ids = {}
adapter.card_form_content = {}
adapter.card_form_input_defs = {}
adapter.card_form_inputs = {}
adapter._update_card_layout = AsyncMock()
return adapter
@pytest.mark.asyncio
async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty():
"""End-to-end regression via reply_message_chunk: Dify paused before any
text, so the pre-pause cache is ''. The final card update must render the
resumed answer only once (empty main text + resume placeholder), not
twice as ('resumed answer', 'resumed answer')."""
adapter = _build_resume_final_chunk_adapter('resumed answer')
bot_message = MagicMock(
resp_message_id='msg-1',
msg_sequence=1,
spec=['resp_message_id', 'msg_sequence', '_resume_from_form'],
)
bot_message._resume_from_form = True
message_source = MagicMock(source_platform_object=None)
await adapter.reply_message_chunk(
message_source,
bot_message,
MagicMock(),
is_final=True,
)
adapter._update_card_layout.assert_awaited_once()
layout_kwargs = adapter._update_card_layout.await_args.kwargs
assert layout_kwargs['text_message'] == ''
assert layout_kwargs['resume_placeholder_text'] == 'resumed answer'
@@ -102,7 +102,7 @@ class DummyAPIClient:
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))),
aupdate=AsyncMock(return_value=DummyResponse()),
),
card_element=SimpleNamespace(content=MagicMock(return_value=DummyResponse())),
card_element=SimpleNamespace(acontent=AsyncMock(return_value=DummyResponse())),
)
)
@@ -283,7 +283,7 @@ async def test_lark_event_converter_maps_group_and_private_message():
group_event = await LarkEventConverter.target2yiri(lark_event('group'), DummyAPIClient())
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.adapter_name == 'lark-eba'
assert group_event.adapter_name == 'lark-omni'
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'chat-1'
assert group_event.group.id == 'chat-1'
@@ -326,7 +326,7 @@ async def test_lark_get_message_fetches_uncached_message():
event = await adapter.get_message('group', 'chat-1', 'msg-remote')
assert event.adapter_name == 'lark-eba'
assert event.adapter_name == 'lark-omni'
assert event.message_id == 'msg-remote'
assert event.chat_type == platform_entities.ChatType.GROUP
assert isinstance(event.message_chain[1], platform_message.Plain)
@@ -687,22 +687,25 @@ async def test_lark_streaming_card_uses_strictly_increasing_sequences():
message = platform_message.MessageChain([platform_message.Plain(text='answer')])
await adapter.reply_message_chunk(source, bot_message, message)
first_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
first_request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0]
assert first_request.request_body.sequence == 1
bot_message.msg_sequence = 2
await adapter.reply_message_chunk(source, bot_message, message)
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
assert adapter.api_client.cardkit.v1.card_element.acontent.call_count == 1
bot_message.msg_sequence = 8
await adapter.reply_message_chunk(source, bot_message, message)
second_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
second_request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0]
assert second_request.request_body.sequence == 2
bot_message.msg_sequence = 9
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
final_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
final_request = adapter.api_client.cardkit.v1.card.aupdate.call_args.args[0]
assert final_request.request_body.sequence == 3
final_card = json.loads(final_request.request_body.card.data)
assert final_card['body']['elements'] == [{'tag': 'markdown', 'content': 'answer'}]
assert not final_card['config'].get('streaming_mode', False)
assert 'response-1' not in adapter.card_id_dict
assert 'stream-card-1' not in adapter.card_sequence_dict
assert 'stream-card-1' not in adapter.card_last_update_dict
@@ -721,7 +724,7 @@ async def test_lark_streaming_card_updates_sparse_chunks_without_waiting_for_eig
await adapter.reply_message_chunk(source, bot_message, message)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0]
assert request.request_body.sequence == 2
@@ -744,7 +747,7 @@ async def test_lark_streaming_card_uses_cumulative_runner_content():
await adapter.reply_message_chunk(source, bot_message, message)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0]
assert request.request_body.content == 'first chunk\n\nlatest chunk only'
adapter.message_converter.yiri2target.assert_not_awaited()
@@ -761,7 +764,7 @@ async def test_lark_streaming_card_first_real_runner_chunk_uses_sequence_one():
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
request = adapter.api_client.cardkit.v1.card.aupdate.call_args.args[0]
assert request.request_body.sequence == 1
@@ -774,7 +777,7 @@ async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_cl
closed_response = DummyResponse(ok=False)
closed_response.code = 300309
closed_response.msg = 'streaming mode is closed'
adapter.api_client.cardkit.v1.card_element.content.return_value = closed_response
adapter.api_client.cardkit.v1.card_element.acontent.return_value = closed_response
adapter.message_converter.yiri2target = AsyncMock(
return_value=([[{'tag': 'text', 'text': 'continued progress'}]], [])
)
@@ -795,7 +798,7 @@ async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_cl
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
assert adapter.api_client.cardkit.v1.card_element.acontent.call_count == 1
assert adapter.api_client.cardkit.v1.card.aupdate.await_count == 2
assert 'stream-card-1' not in adapter.closed_streaming_cards
@@ -0,0 +1,171 @@
from types import SimpleNamespace
import pytest
from langbot.pkg.platform.sources.mattermost import (
MattermostAdapter,
MattermostEventConverter,
MattermostMessageConverter,
_normalize_server_url,
_websocket_url,
)
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class StubLogger:
async def info(self, *_args, **_kwargs):
pass
async def error(self, *_args, **_kwargs):
pass
def _adapter() -> MattermostAdapter:
return MattermostAdapter.model_construct(
config={'enable_stream_reply': True},
logger=StubLogger(),
server_url='https://mattermost.example.com',
access_token='secret',
bot_account_id='bot-id',
bot_username='langbot',
session=None,
listeners={},
channel_cache={},
stream_post_ids={},
_running=False,
)
def test_server_and_websocket_urls_preserve_subpath():
server_url = _normalize_server_url('https://example.com/chat/')
assert server_url == 'https://example.com/chat'
assert _websocket_url(server_url) == 'wss://example.com/chat/api/v4/websocket'
with pytest.raises(ValueError, match='absolute HTTP'):
_normalize_server_url('mattermost.example.com')
@pytest.mark.asyncio
async def test_converter_marks_and_removes_bot_mention():
chain = await MattermostMessageConverter.target2yiri(
{'id': 'post-1', 'create_at': 1_000, 'message': '@langbot hello'},
'langbot',
)
assert any(isinstance(item, platform_message.At) for item in chain)
assert any(isinstance(item, platform_message.Plain) and item.text == 'hello' for item in chain)
@pytest.mark.asyncio
async def test_event_converter_distinguishes_direct_and_group_channels():
post = {'id': 'post-1', 'channel_id': 'channel-1', 'user_id': 'user-1', 'message': 'hello', 'create_at': 1_000}
direct = await MattermostEventConverter.target2yiri(post, {'type': 'D'}, 'alice', 'langbot')
group = await MattermostEventConverter.target2yiri(
post,
{'type': 'O', 'display_name': 'General'},
'alice',
'langbot',
)
assert isinstance(direct, platform_events.FriendMessage)
assert isinstance(group, platform_events.GroupMessage)
assert group.sender.group.name == 'General'
@pytest.mark.asyncio
async def test_send_to_person_creates_or_reuses_direct_channel(monkeypatch):
adapter = _adapter()
requests = []
posted = []
async def api_request(method, path, *, payload=None):
requests.append((method, path, payload))
return {'id': 'direct-channel', 'type': 'D'}
async def post_message(channel_id, text, root_id=''):
posted.append((channel_id, text, root_id))
return {'id': 'post-1'}
monkeypatch.setattr(adapter, '_api_request', api_request)
monkeypatch.setattr(adapter, '_post_message', post_message)
await adapter.send_message(
'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hello')])
)
assert requests == [('POST', '/channels/direct', {'user_ids': ['bot-id', 'user-1']})]
assert posted == [('direct-channel', 'hello', '')]
@pytest.mark.asyncio
async def test_reply_keeps_existing_thread(monkeypatch):
adapter = _adapter()
posted = []
async def post_message(channel_id, text, root_id=''):
posted.append((channel_id, text, root_id))
return {'id': 'reply'}
monkeypatch.setattr(adapter, '_post_message', post_message)
event = platform_events.GroupMessage.model_construct(
source_platform_object={
'post': {'id': 'post-1', 'channel_id': 'channel-1', 'root_id': 'thread-root'},
'channel': {'type': 'O'},
}
)
await adapter.reply_message(event, platform_message.MessageChain([platform_message.Plain(text='reply')]))
assert posted == [('channel-1', 'reply', 'thread-root')]
@pytest.mark.asyncio
async def test_stream_reply_updates_existing_post(monkeypatch):
adapter = _adapter()
adapter.stream_post_ids['response-1'] = 'post-1'
requests = []
async def api_request(method, path, *, payload=None):
requests.append((method, path, payload))
return {'id': 'post-1'}
monkeypatch.setattr(adapter, '_api_request', api_request)
message = SimpleNamespace(resp_message_id='response-1', tool_calls=None)
await adapter.reply_message_chunk(
SimpleNamespace(),
message,
platform_message.MessageChain([platform_message.Plain(text='complete')]),
is_final=True,
)
assert requests == [('PUT', '/posts/post-1', {'id': 'post-1', 'message': 'complete'})]
assert 'response-1' not in adapter.stream_post_ids
@pytest.mark.asyncio
async def test_posted_event_dispatches_listener(monkeypatch):
adapter = _adapter()
received = []
async def get_channel(_channel_id):
return {'type': 'D'}
async def listener(event, _adapter):
received.append(event)
monkeypatch.setattr(adapter, '_get_channel', get_channel)
adapter.register_listener(platform_events.FriendMessage, listener)
await adapter._dispatch_post(
{
'data': {
'sender_name': 'alice',
'post': '{"id":"post-1","channel_id":"channel-1","user_id":"user-1","message":"hello","create_at":1000}',
}
}
)
assert len(received) == 1
assert received[0].sender.nickname == 'alice'
@@ -151,7 +151,7 @@ async def test_officialaccount_event_converter_maps_text_image_voice_and_platfor
subscribe_event = await OfficialAccountEventConverter().target2yiri(oa_event(msgtype='event', event='subscribe'))
assert isinstance(text_event, platform_events.MessageReceivedEvent)
assert text_event.adapter_name == 'officialaccount-eba'
assert text_event.adapter_name == 'officialaccount-omni'
assert text_event.chat_type == platform_entities.ChatType.PRIVATE
assert text_event.chat_id == 'openid-1'
assert str(text_event.message_chain) == 'hello'
@@ -0,0 +1,70 @@
"""Saved adapter IDs remain usable while public metadata exposes Omni IDs."""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import yaml
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.platform.adapter_names import OMNI_ADAPTER_NAMES, canonical_adapter_name
from langbot.pkg.platform.botmgr import PlatformManager
@pytest.mark.asyncio
@pytest.mark.parametrize('name', sorted(OMNI_ADAPTER_NAMES))
async def test_saved_adapter_ids_are_normalized_across_bot_api(name):
saved = {'uuid': 'bot', 'adapter': f'{name}-eba', 'adapter_config': {}}
component = SimpleNamespace(
metadata=SimpleNamespace(name=f'{name}-omni'),
spec={'config': [{'type': 'webhook-url'}]},
to_plain_dict=lambda: {'metadata': {'name': f'{name}-omni'}},
)
result = SimpleNamespace(all=lambda: [saved], first=lambda: saved)
application = SimpleNamespace(
discover=SimpleNamespace(get_components_by_kind=lambda _: [component]),
persistence_mgr=SimpleNamespace(
execute_async=AsyncMock(return_value=result),
serialize_model=Mock(side_effect=lambda _model, row, _masked: row.copy()),
),
)
service = BotService(application)
assert (await service.get_bots('workspace'))[0]['adapter'] == f'{name}-omni'
assert (await service.get_bot('workspace', 'bot'))['adapter'] == f'{name}-omni'
assert service._adapter_declares_webhook_url(saved['adapter'])
assert (await service._prepare_bot_data('workspace', saved, include_uuid=True))['adapter'] == f'{name}-omni'
assert saved['adapter'] == f'{name}-eba'
manager = PlatformManager(application)
manager.adapter_components = [component]
assert manager.get_available_adapter_manifest_by_name(saved['adapter']) is component
assert manager.get_available_adapter_info_by_name(saved['adapter'])['metadata']['name'] == f'{name}-omni'
@pytest.mark.parametrize('name', ['telegram', 'custom-eba', 'my-telegram-eba', 'telegram-omni', 'websocket'])
def test_legacy_and_custom_adapter_ids_are_unchanged(name):
assert canonical_adapter_name(name) == name
def test_discovered_manifests_expose_only_omni_names():
root = Path(__file__).resolve().parents[3] / 'src/langbot/pkg/platform/adapters'
for name in OMNI_ADAPTER_NAMES:
metadata = yaml.safe_load((root / name / 'manifest.yaml').read_text())['metadata']
assert metadata['name'] == f'{name}-omni'
@pytest.mark.asyncio
@pytest.mark.parametrize('disabled_name', ['telegram-eba', 'telegram-omni'])
async def test_old_disabled_adapter_config_still_hides_omni(disabled_name):
component = SimpleNamespace(metadata=SimpleNamespace(name='telegram-omni'))
application = SimpleNamespace(
storage_mgr=SimpleNamespace(storage_provider=SimpleNamespace(delete_dir_recursive=AsyncMock())),
instance_config=SimpleNamespace(data={'system': {'disabled_adapters': [disabled_name]}}),
discover=SimpleNamespace(get_components_by_kind=lambda _: [component]),
workspace_service=SimpleNamespace(get_execution_binding=AsyncMock(side_effect=ValueError('no workspace'))),
)
manager = PlatformManager(application)
manager.load_bots_from_db = AsyncMock()
await manager.initialize()
assert manager.adapter_dict == {}
assert manager.adapter_components == []
@@ -0,0 +1,398 @@
"""Regression coverage for master fixes carried into the Omni adapter path."""
import asyncio
import base64
import importlib
import json
import time
import zlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import aiocqhttp
import pytest
import yaml
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpEventConverter as LegacyOneBotConverter
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import message as pm
PLATFORM = Path(__file__).parents[3] / 'src/langbot/pkg/platform'
OMNI_NAMES = sorted(p.parent.name for p in (PLATFORM / 'adapters').glob('*/manifest.yaml'))
def chain(text):
return pm.MessageChain([pm.Plain(text=text)])
@pytest.mark.parametrize('name', OMNI_NAMES)
def test_omni_config_contains_mainline_options_and_help(name):
legacy = yaml.safe_load((PLATFORM / 'sources' / f'{name}.yaml').read_text())['spec']
omni = yaml.safe_load((PLATFORM / 'adapters' / name / 'manifest.yaml').read_text())['spec']
assert {c['name'] for c in legacy['config']} <= {c['name'] for c in omni['config']}
assert omni['help_links'] == legacy['help_links']
@pytest.mark.asyncio
@pytest.mark.parametrize('prefix', ['', 'base64://', 'data:image/png;base64,'])
@pytest.mark.parametrize('kind', [pm.Image, pm.Voice, pm.File])
async def test_onebot_media_base64_is_normalized_once(prefix, kind):
component = kind(base64=prefix + 'YWJj')
output, _, _ = await AiocqhttpMessageConverter.yiri2target(pm.MessageChain([component]))
assert output[0].data['file'] == 'base64://YWJj'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'raw,expected',
[
(
{
'app': 'com.tencent.structmsg',
'meta': {'detail_1': {'desc': 'Article', 'qqdocurl': 'https://example.test/a'}},
},
'Article',
),
({'app': 'music', 'meta': {'music': {'title': 'Song', 'jumpUrl': 'https://example.test/song'}}}, 'Song'),
('{invalid', '[收到一张JSON卡片]'),
],
)
async def test_onebot_json_cards_are_readable(raw, expected):
payload = json.dumps(raw) if isinstance(raw, dict) else raw
result = await AiocqhttpMessageConverter.target2yiri([{'type': 'json', 'data': {'data': payload}}])
assert expected in result[1].text
@pytest.mark.asyncio
async def test_onebot_metadata_lookup_is_cached_and_survives_legacy_conversion():
bot = SimpleNamespace(
get_group_info=AsyncMock(return_value={'group_name': 'Team'}),
get_group_member_info=AsyncMock(return_value={'title': 'Maintainer'}),
)
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1,
'time': 1,
'group_id': 2,
'user_id': 3,
'message': 'hello',
'sender': {'user_id': 3, 'nickname': 'Alice', 'role': 'admin'},
}
)
lookup = LegacyOneBotConverter()
first = await AiocqhttpEventConverter.target2yiri(event, bot, lookup=lookup)
second = await AiocqhttpEventConverter.target2legacy(event, bot, lookup=lookup)
assert first.group.name == second.group.name == 'Team'
assert first.sender_member.title == second.sender.special_title == 'Maintainer'
bot.get_group_info.assert_awaited_once()
bot.get_group_member_info.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', [pm.Image, pm.Voice, pm.File])
async def test_discord_outbound_base64_obeys_mainline_limit(monkeypatch, kind):
legacy = importlib.import_module('langbot.pkg.platform.sources.discord')
monkeypatch.setattr(legacy, '_MAX_DISCORD_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='exceeds'):
await DiscordMessageConverter.yiri2target(pm.MessageChain([kind(base64=base64.b64encode(b'12345').decode())]))
@pytest.mark.asyncio
async def test_lark_component_loading_obeys_mainline_limit(monkeypatch, tmp_path):
legacy = importlib.import_module('langbot.pkg.platform.sources.lark')
monkeypatch.setattr(legacy, '_MAX_LARK_MEDIA_BYTES', 4)
file = tmp_path / 'oversized.txt'
file.write_bytes(b'12345')
assert await LarkMessageConverter._get_component_bytes(pm.File(path=str(file))) is None
assert await LarkMessageConverter._get_component_bytes(pm.File(url=file.as_uri())) is None
assert await LarkMessageConverter._get_component_bytes(pm.File(base64=base64.b64encode(b'12345').decode())) is None
@pytest.mark.asyncio
async def test_lark_callbacks_are_bounded_and_cancelled_on_shutdown():
bot = SimpleNamespace(_auto_reconnect=True, _disconnect=AsyncMock())
adapter = LarkAdapter.model_construct(config={}, bot=bot)
completed = []
async def work():
try:
await asyncio.Event().wait()
finally:
completed.append(True)
for _ in range(105):
adapter._submit_coro(work())
assert len(adapter.inbound_event_tasks) == 100
await asyncio.sleep(0)
await adapter.kill()
assert len(completed) == 100
assert not adapter.inbound_event_tasks
bot._disconnect.assert_awaited_once()
def test_lark_domains_and_markdown_table_rendering():
adapter = LarkAdapter.model_construct(config={})
for domain in ['https://open.larksuite.com', 'https://open.feishu.cn']:
assert adapter.build_api_client({'app_id': 'a', 'app_secret': 'b', 'domain': domain})._config.domain == domain
table = '| Name |\n| --- |\n| Alice |'
payloads = adapter._outbound_payloads([[{'tag': 'md', 'text': table}]], [])
assert payloads[0][0] == 'interactive'
assert payloads[0][1]['body']['elements'] == [{'tag': 'markdown', 'content': table}]
@pytest.mark.asyncio
async def test_qq_optional_token(monkeypatch):
module = importlib.import_module('langbot.pkg.platform.adapters.qqofficial.adapter')
client = MagicMock()
factory = MagicMock(return_value=client)
monkeypatch.setattr(module, 'QQOfficialClient', factory)
QQOfficialAdapter(config={'appid': 'app', 'secret': 'secret'}, logger=MagicMock(spec=AbstractEventLogger))
assert factory.call_args.kwargs['token'] == ''
@pytest.mark.asyncio
@pytest.mark.parametrize('target', ['c2c', 'group'])
@pytest.mark.parametrize('markdown', [False, True])
async def test_qq_markdown_configuration_reaches_send_api(target, markdown):
bot = SimpleNamespace(
**{
name: AsyncMock()
for name in [
'send_private_text_msg',
'send_private_markdown_msg',
'send_group_text_msg',
'send_group_markdown_msg',
]
}
)
adapter = QQOfficialAdapter.model_construct(config={'enable-markdown-rendering': markdown}, bot=bot)
await adapter._send_content_list(target, 'target', [{'type': 'text', 'content': '**hello**'}], msg_id='anchor')
name = f'send_{"private" if target == "c2c" else "group"}_{"markdown" if markdown else "text"}_msg'
assert getattr(bot, name).await_args.kwargs['content'] == '**hello**'
assert getattr(bot, name).await_args.kwargs['msg_id'] == 'anchor'
assert sum(m.await_count for m in vars(bot).values()) == 1
@pytest.mark.asyncio
async def test_qq_stream_sends_snapshots_and_cleans_final_state():
bot = SimpleNamespace(send_stream_msg=AsyncMock(return_value={'id': 'stream'}))
adapter = QQOfficialAdapter.model_construct(config={}, bot=bot)
adapter._stream_ctx['response'] = {
'user_openid': 'user',
'msg_id': 'anchor',
'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['response'] = time.time()
for text, final in [('Hello', False), ('Hello world', True)]:
await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain(text), is_final=final)
assert [c.kwargs['content'] for c in bot.send_stream_msg.await_args_list] == ['Hello', 'Hello world']
assert bot.send_stream_msg.await_args.kwargs['input_state'] == 10
assert not adapter._stream_ctx and not adapter._stream_ctx_ts
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['image', 'voice', 'file'])
@pytest.mark.parametrize('stream', [False, True])
async def test_wecombot_reply_uploads_media_instead_of_placeholder(kind, stream):
bot = SimpleNamespace(
reply_text=AsyncMock(),
push_stream_chunk=AsyncMock(return_value=True),
upload_media=AsyncMock(return_value={'media_id': 'media'}),
reply_image=AsyncMock(),
reply_voice=AsyncMock(),
reply_file=AsyncMock(),
)
adapter = WecomBotAdapter.model_construct(config={}, bot=bot)
event = WecomBotEvent({'message_id': 'message', 'req_id': 'request'})
source = SimpleNamespace(source_platform_object=event)
part = {'image': pm.Image, 'voice': pm.Voice, 'file': pm.File}[kind](base64=base64.b64encode(b'payload').decode())
message = pm.MessageChain([pm.Plain(text='hello'), part])
if stream:
await adapter.reply_message_chunk(source, {}, message, is_final=False)
bot.upload_media.assert_not_awaited()
await adapter.reply_message_chunk(source, {}, message, is_final=True)
else:
await adapter.reply_message(source, message)
assert bot.upload_media.await_args.args[0] == b'payload'
assert bot.upload_media.await_args.kwargs['media_type'] == kind
getattr(bot, f'reply_{kind}').assert_awaited_once_with('request', 'media')
@pytest.mark.asyncio
@pytest.mark.parametrize(
'name,cls', [('wecom', 'WecomAdapter'), ('wecomcs', 'WecomCSAdapter'), ('qqofficial', 'QQOfficialAdapter')]
)
async def test_shutdown_closes_client(name, cls):
module = importlib.import_module(f'langbot.pkg.platform.adapters.{name}.adapter')
bot = SimpleNamespace(close=AsyncMock(), clear=MagicMock())
adapter = getattr(module, cls).model_construct(bot=bot, config={})
await adapter.kill()
bot.close.assert_awaited_once()
def test_kook_compressed_gateway_limit(monkeypatch):
source = importlib.import_module('langbot.pkg.platform.sources.kook')
omni = importlib.import_module('langbot.pkg.platform.adapters.kook.adapter')
monkeypatch.setattr(source, '_KOOK_MAX_GATEWAY_MESSAGE_BYTES', 32)
assert json.loads(omni.KookAdapter._decode_ws_message(zlib.compress(b'{"s": 1}'))) == {'s': 1}
with pytest.raises(ValueError, match='size limit'):
omni.KookAdapter._decode_ws_message(zlib.compress(b' ' * 1000))
@pytest.mark.asyncio
async def test_discord_stream_edits_one_message_with_complete_text():
sent = SimpleNamespace(content='first', edit=AsyncMock())
channel = SimpleNamespace(send=AsyncMock(return_value=sent))
adapter = DiscordAdapter.model_construct(config={}, bot=None)
adapter._stream_buffer['response'] = {
'channel': channel,
'sent_message': None,
'last_content': '',
'chunk_count': 0,
}
await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain('first'))
await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain('first second'), is_final=True)
channel.send.assert_awaited_once_with('first')
sent.edit.assert_awaited_once_with(content='first second')
assert not adapter._stream_buffer
@pytest.mark.parametrize('name', ['domain', 'custom_domain'])
def test_lark_legacy_and_omni_have_identical_region_fields(name):
legacy = yaml.safe_load((PLATFORM / 'sources/lark.yaml').read_text())['spec']['config']
omni = yaml.safe_load((PLATFORM / 'adapters/lark/manifest.yaml').read_text())['spec']['config']
assert next(c for c in legacy if c['name'] == name) == next(c for c in omni if c['name'] == name)
@pytest.mark.asyncio
@pytest.mark.parametrize('variant', ['sources.lark', 'adapters.lark.adapter'])
@pytest.mark.parametrize(
'region,expected',
[
({}, 'https://open.feishu.cn'),
({'domain': 'https://open.feishu.cn'}, 'https://open.feishu.cn'),
({'domain': 'https://open.larksuite.com'}, 'https://open.larksuite.com'),
({'domain': 'custom', 'custom_domain': 'https://open.example.test/'}, 'https://open.example.test'),
],
)
async def test_lark_http_and_websocket_use_selected_region(monkeypatch, variant, region, expected):
import lark_oapi
module = importlib.import_module(f'langbot.pkg.platform.{variant}')
ws = MagicMock(spec=lark_oapi.ws.Client)
factory = MagicMock(return_value=ws)
monkeypatch.setattr(module, 'NonBlockingLarkWSClient', factory)
adapter = module.LarkAdapter(
config={'app_id': 'app', 'app_secret': 'secret', 'bot_name': 'bot', **region},
logger=MagicMock(spec=AbstractEventLogger),
)
assert factory.call_args.kwargs['domain'] == expected
assert adapter.api_client._config.domain == expected
@pytest.mark.asyncio
async def test_lark_resource_download_rejects_oversized_platform_response(monkeypatch):
import io
legacy = importlib.import_module('langbot.pkg.platform.sources.lark')
monkeypatch.setattr(legacy, '_MAX_LARK_MEDIA_BYTES', 4)
response = SimpleNamespace(success=lambda: True, raw=SimpleNamespace(headers={}), file=io.BytesIO(b'12345'))
client = SimpleNamespace(
im=SimpleNamespace(v1=SimpleNamespace(message_resource=SimpleNamespace(aget=AsyncMock(return_value=response))))
)
with pytest.raises(ValueError, match='exceeds'):
await LarkMessageConverter._download_resource(client, 'message', 'key', 'image')
@pytest.mark.asyncio
async def test_qq_non_stream_fallback_replaces_snapshot(monkeypatch):
reply = AsyncMock()
monkeypatch.setattr(QQOfficialAdapter, 'reply_message', reply)
adapter = QQOfficialAdapter.model_construct(config={}, bot=None)
for text, final in [('A', False), ('AB', False), ('ABC', True)]:
await adapter.reply_message_chunk(None, {'resp_message_id': 'fallback'}, chain(text), is_final=final)
reply.assert_awaited_once()
assert reply.await_args.args[1][0].text == 'ABC'
assert not adapter._fallback_text and not adapter._fallback_text_ts
@pytest.mark.asyncio
async def test_telegram_stream_reuses_persistent_message():
import telegram
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
update = MagicMock(spec=telegram.Update)
update.effective_chat = SimpleNamespace(id=123, type='private')
update.effective_message = SimpleNamespace(message_thread_id=None)
bot = SimpleNamespace(
send_message=AsyncMock(return_value=SimpleNamespace(message_id=456)), edit_message_text=AsyncMock()
)
adapter = TelegramAdapter.model_construct(config={}, bot=bot, msg_stream_id={}, seq=1)
source = SimpleNamespace(source_platform_object=update)
await adapter.create_message_card('response', source)
await adapter.reply_message_chunk(
source, SimpleNamespace(resp_message_id='response', msg_sequence=1, tool_calls=None), chain('Hello')
)
await adapter.reply_message_chunk(
source,
SimpleNamespace(resp_message_id='response', msg_sequence=2, tool_calls=None),
chain('Hello world'),
is_final=True,
)
bot.send_message.assert_awaited_once()
assert [call.kwargs['message_id'] for call in bot.edit_message_text.await_args_list] == [456, 456]
assert bot.edit_message_text.await_args.kwargs['text'] == 'Hello world'
assert not adapter.msg_stream_id
@pytest.mark.asyncio
async def test_telegram_inbound_image_does_not_expose_token_url(monkeypatch):
import datetime
from langbot.pkg.platform.sources import telegram as source
from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter
download = MagicMock()
download.__aenter__ = AsyncMock(return_value=SimpleNamespace())
download.__aexit__ = AsyncMock()
monkeypatch.setattr(source.httpclient, 'get_session', lambda **kwargs: SimpleNamespace(get=lambda url: download))
monkeypatch.setattr(source.httpclient, 'read_limited', AsyncMock(return_value=b'image'))
message = SimpleNamespace(
message_id=1,
date=datetime.datetime.now(),
text='',
caption='',
photo=[
SimpleNamespace(
get_file=AsyncMock(
return_value=SimpleNamespace(file_path='https://api.telegram.org/file/botSECRET/photo')
)
)
],
voice=None,
document=None,
)
bot = SimpleNamespace(
get_file=AsyncMock(return_value=SimpleNamespace(file_path='https://api.telegram.org/file/botSECRET/photo'))
)
converted = await TelegramMessageConverter.target2yiri(message, bot, 'bot')
image = next(p for p in converted if isinstance(p, pm.Image))
assert image.base64 and not image.url
assert 'SECRET' not in image.model_dump_json()
@@ -81,11 +81,11 @@ class DummyQQOfficialClient:
async def get_gateway_url(self):
return 'wss://gateway.example.test'
async def send_private_text_msg(self, user_openid, content, msg_id=None):
async def send_private_text_msg(self, user_openid, content, msg_id=None, event_id=None, msg_seq=1):
self.sent.append(('private_text', user_openid, content, msg_id))
return {'id': 'sent-private'}
async def send_group_text_msg(self, group_openid, content, msg_id=None):
async def send_group_text_msg(self, group_openid, content, msg_id=None, event_id=None, msg_seq=1):
self.sent.append(('group_text', group_openid, content, msg_id))
return {'id': 'sent-group'}
@@ -260,7 +260,7 @@ async def test_qqofficial_event_converter_maps_private_group_and_platform_specif
platform_event = await QQOfficialEventConverter().target2yiri(qq_event('UNKNOWN_EVENT'))
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.adapter_name == 'qqofficial-eba'
assert private_event.adapter_name == 'qqofficial-omni'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-openid'
assert str(private_event.message_chain) == 'hello'
@@ -179,7 +179,7 @@ async def test_slack_event_converter_maps_private_group_and_platform_specific():
platform_event = await SlackEventConverter().target2yiri(slack_event('file_share'))
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.adapter_name == 'slack-eba'
assert private_event.adapter_name == 'slack-omni'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'U-1'
assert str(private_event.message_chain) == 'hello'
@@ -222,11 +222,15 @@ async def test_slack_send_reply_platform_api_and_unsupported():
adapter = make_adapter()
source_event = await SlackEventConverter().target2yiri(slack_event('im'))
reply_result = await adapter.reply_message(source_event, platform_message.MessageChain([platform_message.Plain(text='reply')]))
reply_result = await adapter.reply_message(
source_event, platform_message.MessageChain([platform_message.Plain(text='reply')])
)
assert reply_result.message_id == 'evt-1'
assert ('person', 'U-1', 'reply') in adapter.bot.sent
await adapter.send_message('group', 'C-1', platform_message.MessageChain([platform_message.Plain(text='hello channel')]))
await adapter.send_message(
'group', 'C-1', platform_message.MessageChain([platform_message.Plain(text='hello channel')])
)
assert ('channel', 'C-1', 'hello channel') in adapter.bot.sent
assert await adapter.call_platform_api('get_mode', {}) == {
@@ -162,7 +162,7 @@ async def test_wecom_event_converter_maps_text_message_to_eba_and_legacy():
event = await WecomEventConverter.target2yiri(wecom_event(), adapter.bot)
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'wecom-eba'
assert event.adapter_name == 'wecom-omni'
assert event.chat_type == platform_entities.ChatType.PRIVATE
assert event.chat_id == 'user-1|1000002'
assert event.sender.nickname == 'Alice'
@@ -188,7 +188,7 @@ async def test_wecom_event_converter_maps_image_message_to_eba():
)
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'wecom-eba'
assert event.adapter_name == 'wecom-omni'
assert event.message_id == 12345
assert isinstance(event.message_chain[1], platform_message.Image)
assert event.message_chain[1].base64 == 'data:image/png;base64,AAAA'
@@ -0,0 +1,59 @@
"""Tests for WecomAdapter.send_message content-key handling."""
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.wecom import WecomAdapter
class StubWecomClient:
def __init__(self):
self.calls = []
async def get_media_id(self, msg):
return 'MEDIA_ID_123'
async def send_private_msg(self, user_id, agent_id, text):
self.calls.append(('text', user_id, agent_id, text))
async def send_image(self, user_id, agent_id, media_id):
self.calls.append(('image', user_id, agent_id, media_id))
async def send_voice(self, user_id, agent_id, media_id):
self.calls.append(('voice', user_id, agent_id, media_id))
async def send_file(self, user_id, agent_id, media_id):
self.calls.append(('file', user_id, agent_id, media_id))
def _make_adapter():
adapter = WecomAdapter.model_construct(bot=StubWecomClient())
return adapter
@pytest.mark.asyncio
@pytest.mark.parametrize(
('part', 'expected_type'),
[
(platform_message.Image(url='https://example.com/x.jpg'), 'image'),
(platform_message.Voice(url='https://example.com/x.amr'), 'voice'),
(platform_message.File(url='https://example.com/x.pdf', name='x.pdf'), 'file'),
],
)
async def test_send_message_dispatches_media_by_id(part, expected_type):
adapter = _make_adapter()
chain = platform_message.MessageChain([part])
await adapter.send_message('person', 'USER1|1000001', chain)
assert adapter.bot.calls == [(expected_type, 'USER1', 1000001, 'MEDIA_ID_123')]
@pytest.mark.asyncio
async def test_send_message_text_still_works():
adapter = _make_adapter()
chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'USER1|1000001', chain)
assert adapter.bot.calls == [('text', 'USER1', 1000001, 'hello')]
@@ -201,7 +201,7 @@ async def test_wecombot_interaction_delivery_and_callback_event():
@pytest.mark.asyncio
async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text():
async def test_wecombot_message_converter_preserves_outbound_media():
content = await WecomBotMessageConverter.yiri2target(
platform_message.MessageChain(
[
@@ -217,12 +217,15 @@ async def test_wecombot_message_converter_maps_outbound_components_to_markdown_t
)
)
assert 'hi' in content
assert '@Alice' in content
assert '[Image]' in content
assert '[File: a.txt]' in content
assert '[Quote origin]' in content
assert 'quoted' in content
assert [item['text'] for item in content if item['type'] == 'text'] == [
'hi',
'@Alice',
'[Quote origin]',
'quoted',
]
assert content[2] == {'type': 'image', 'base64': 'data:image/png;base64,AAAA', 'name': ''}
assert content[3]['type'] == 'file'
assert content[3]['name'] == 'a.txt'
@pytest.mark.asyncio
@@ -235,7 +238,7 @@ async def test_wecombot_event_converter_maps_private_and_group_messages_to_eba()
)
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.adapter_name == 'wecombot-eba'
assert private_event.adapter_name == 'wecombot-omni'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-1'
assert str(private_event.message_chain) == 'hello'
@@ -176,7 +176,7 @@ async def test_wecomcs_event_converter_maps_text_message_to_eba_and_legacy():
event = await WecomCSEventConverter.target2yiri(wecomcs_event(), adapter.bot)
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'wecomcs-eba'
assert event.adapter_name == 'wecomcs-omni'
assert event.chat_type == platform_entities.ChatType.PRIVATE
assert event.chat_id == 'external-1|kf-1'
assert event.sender.nickname == 'Alice'