mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(4.11): align omni adapters and EventProcessor marketplace support
This commit is contained in:
@@ -149,9 +149,9 @@ async def run_probe(
|
||||
platform_message.Plain(text='Discord EBA live reply: text'),
|
||||
platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()),
|
||||
platform_message.File(
|
||||
name='discord-eba-live.txt',
|
||||
name='discord-omni-live.txt',
|
||||
size=16,
|
||||
base64='data:text/plain;base64,' + base64.b64encode(b'discord-eba-live').decode(),
|
||||
base64='data:text/plain;base64,' + base64.b64encode(b'discord-omni-live').decode(),
|
||||
),
|
||||
]
|
||||
),
|
||||
@@ -252,7 +252,7 @@ async def run_probe(
|
||||
await run_expected_error(
|
||||
api_results,
|
||||
'upload_file:not_supported',
|
||||
lambda: adapter.upload_file(b'discord-eba-upload', 'discord-eba-upload.txt'),
|
||||
lambda: adapter.upload_file(b'discord-omni-upload', 'discord-omni-upload.txt'),
|
||||
platform_errors.NotSupportedError,
|
||||
)
|
||||
await run_api(api_results, 'get_file_url', lambda: adapter.get_file_url('https://cdn.discordapp.com/file.txt'))
|
||||
|
||||
@@ -16,8 +16,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
|
||||
|
||||
TINY_PNG = (
|
||||
'data:image/png;base64,'
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
||||
)
|
||||
|
||||
|
||||
@@ -178,7 +177,11 @@ async def run_probe(args: argparse.Namespace):
|
||||
)
|
||||
await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id))
|
||||
await run_api(api_results, 'get_friend_list', lambda: adapter.get_friend_list())
|
||||
await run_api(api_results, 'call_platform_api:check_access_token', lambda: adapter.call_platform_api('check_access_token', {}))
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:check_access_token',
|
||||
lambda: adapter.call_platform_api('check_access_token', {}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_user_info',
|
||||
@@ -203,7 +206,7 @@ def main():
|
||||
parser.add_argument('--port', type=int, default=5312)
|
||||
parser.add_argument('--path', default='/wecom/callback')
|
||||
parser.add_argument('--timeout', type=int, default=180)
|
||||
parser.add_argument('--bot-uuid', default='wecom-eba-live-probe')
|
||||
parser.add_argument('--bot-uuid', default='wecom-omni-live-probe')
|
||||
parser.add_argument('--log', default='data/temp/wecom_eba_live_probe.jsonl')
|
||||
parser.add_argument('--skip-api', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -32,7 +32,9 @@ class ProbeLogger(AbstractEventLogger):
|
||||
def redact(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: '<redacted>' if key.lower() in {'secret', 'token', 'encodingaeskey', 'encrypt', 'aeskey'} else redact(item)
|
||||
key: '<redacted>'
|
||||
if key.lower() in {'secret', 'token', 'encodingaeskey', 'encrypt', 'aeskey'}
|
||||
else redact(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
@@ -157,12 +159,22 @@ async def run_probe(args: argparse.Namespace):
|
||||
platform_message.MessageChain([platform_message.Plain(text='WeComBot EBA probe send')]),
|
||||
),
|
||||
)
|
||||
await run_api(api_results, 'get_message', lambda: adapter.get_message(source.chat_type.value, source.chat_id, source.message_id))
|
||||
await run_api(
|
||||
api_results,
|
||||
'get_message',
|
||||
lambda: adapter.get_message(source.chat_type.value, source.chat_id, source.message_id),
|
||||
)
|
||||
await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id))
|
||||
if source.group:
|
||||
await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(source.group.id))
|
||||
await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(source.group.id))
|
||||
await run_api(api_results, 'call_platform_api:is_websocket_mode', lambda: adapter.call_platform_api('is_websocket_mode', {}))
|
||||
await run_api(
|
||||
api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(source.group.id)
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:is_websocket_mode',
|
||||
lambda: adapter.call_platform_api('is_websocket_mode', {}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_stream_session_status',
|
||||
@@ -187,12 +199,14 @@ async def run_probe(args: argparse.Namespace):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Live WeComBot EBA adapter probe.')
|
||||
parser.add_argument('--webhook', action='store_true', help='Use webhook mode. Default is WebSocket long connection mode.')
|
||||
parser.add_argument(
|
||||
'--webhook', action='store_true', help='Use webhook mode. Default is WebSocket long connection mode.'
|
||||
)
|
||||
parser.add_argument('--host', default='0.0.0.0')
|
||||
parser.add_argument('--port', type=int, default=5313)
|
||||
parser.add_argument('--path', default='/wecombot/callback')
|
||||
parser.add_argument('--timeout', type=int, default=180)
|
||||
parser.add_argument('--bot-uuid', default='wecombot-eba-live-probe')
|
||||
parser.add_argument('--bot-uuid', default='wecombot-omni-live-probe')
|
||||
parser.add_argument('--log', default='data/temp/wecombot_eba_live_probe.jsonl')
|
||||
parser.add_argument('--skip-api', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -16,8 +16,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
|
||||
|
||||
TINY_PNG = (
|
||||
'data:image/png;base64,'
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
||||
)
|
||||
|
||||
|
||||
@@ -168,7 +167,9 @@ async def run_probe(args: argparse.Namespace):
|
||||
),
|
||||
),
|
||||
)
|
||||
await run_api(api_results, 'get_message', lambda: adapter.get_message('private', source.chat_id, source.message_id))
|
||||
await run_api(
|
||||
api_results, 'get_message', lambda: adapter.get_message('private', source.chat_id, source.message_id)
|
||||
)
|
||||
await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id))
|
||||
await run_api(api_results, 'get_friend_list', lambda: adapter.get_friend_list())
|
||||
await run_api(
|
||||
@@ -200,7 +201,7 @@ def main():
|
||||
parser.add_argument('--port', type=int, default=5313)
|
||||
parser.add_argument('--path', default='/wecomcs/callback')
|
||||
parser.add_argument('--timeout', type=int, default=180)
|
||||
parser.add_argument('--bot-uuid', default='wecomcs-eba-live-probe')
|
||||
parser.add_argument('--bot-uuid', default='wecomcs-omni-live-probe')
|
||||
parser.add_argument('--log', default='data/temp/wecomcs_eba_live_probe.jsonl')
|
||||
parser.add_argument('--skip-api', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -35,8 +35,9 @@ class _PersistenceManager:
|
||||
|
||||
async def test_update_bot_copies_input_before_filtering_legacy_routing_fields():
|
||||
persistence_mgr = _PersistenceManager()
|
||||
runtime_bot = SimpleNamespace(enable=False)
|
||||
runtime_bot = SimpleNamespace(enable=False, bot_entity=SimpleNamespace(name='Test Bot'))
|
||||
platform_mgr = SimpleNamespace(
|
||||
get_bot_by_uuid=AsyncMock(return_value=runtime_bot),
|
||||
remove_bot=AsyncMock(),
|
||||
load_bot=AsyncMock(return_value=runtime_bot),
|
||||
)
|
||||
|
||||
@@ -351,7 +351,9 @@ class TestBotServiceCreateBot:
|
||||
bot2 = _create_mock_bot(bot_uuid='uuid-2')
|
||||
mock_result = _create_mock_result([bot1, bot2])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Bot 1'})
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'uuid-1', 'name': 'Bot 1', 'adapter': 'telegram'}
|
||||
)
|
||||
|
||||
service = BotService(ap)
|
||||
|
||||
@@ -435,6 +437,7 @@ class TestBotServiceCreateBot:
|
||||
return_value={
|
||||
'uuid': 'new-uuid',
|
||||
'name': 'New Bot',
|
||||
'adapter': 'telegram',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 == ['✅ xiala:B']
|
||||
|
||||
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user