Merge remote-tracking branch 'origin/refactor/eba' into dev_4_11

# Conflicts:
#	pyproject.toml
#	src/langbot/pkg/pipeline/preproc/preproc.py
#	uv.lock
This commit is contained in:
huanghuoguoguo
2026-06-22 23:06:02 +08:00
174 changed files with 27232 additions and 3832 deletions
@@ -52,6 +52,23 @@ def _create_mock_result(items: list = None, first_item=None):
return result
def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
"""Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests.
adapter_webhook_flags maps adapter name -> whether its manifest declares a
webhook-url config item (mirrors _adapter_declares_webhook_url's lookup).
"""
components = []
for name, has_webhook in (adapter_webhook_flags or {}).items():
component = SimpleNamespace()
component.metadata = SimpleNamespace(name=name)
component.spec = {'config': ([{'name': 'webhook_url', 'type': 'webhook-url'}] if has_webhook else [])}
components.append(component)
discover = SimpleNamespace()
discover.get_components_by_kind = Mock(return_value=components)
return discover
class TestBotServiceGetBots:
"""Tests for get_bots method."""
@@ -219,6 +236,7 @@ class TestBotServiceGetRuntimeBotInfo:
}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
ap.discover = _create_mock_discover({'wecom': True})
bot_data = {
'uuid': 'wecom-uuid',
@@ -245,6 +263,7 @@ class TestBotServiceGetRuntimeBotInfo:
ap.instance_config.data = {'api': {}}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
ap.discover = _create_mock_discover({'telegram': False})
bot_data = {
'uuid': 'telegram-uuid',
@@ -276,6 +295,7 @@ class TestBotServiceGetRuntimeBotInfo:
runtime_bot.adapter = SimpleNamespace()
runtime_bot.adapter.bot_account_id = 'runtime-account-123'
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
ap.discover = _create_mock_discover({'telegram': False})
bot_data = {
'uuid': 'runtime-uuid',
@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.api.entities.builtin.platform import message as platform_message
def _conversation():
prompt = Mock()
prompt.messages = []
prompt.copy = Mock(return_value=Mock(messages=[]))
return SimpleNamespace(
uuid='conversation-uuid',
create_time=datetime.now(),
update_time=datetime.now(),
prompt=prompt,
messages=[],
)
def _prompt_preprocessing_context():
ctx = Mock()
ctx.event.default_prompt = []
ctx.event.prompt = []
return ctx
@pytest.mark.asyncio
async def test_preprocessor_keeps_image_placeholder_for_text_only_local_agent(mock_app, sample_query):
model = Mock()
model.model_entity.uuid = 'text-only-model'
model.model_entity.abilities = []
mock_app.model_mgr.get_model_by_uuid = AsyncMock(return_value=model)
mock_app.sess_mgr.get_session = AsyncMock(
return_value=SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
)
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=_conversation())
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
sample_query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'text-only-model', 'fallbacks': []}, 'prompt': []},
},
'trigger': {'misc': {'combine-quote-message': False}},
'output': {'misc': {'exception-handling': 'show-hint'}},
}
sample_query.message_chain = platform_message.MessageChain(
[platform_message.Image(base64='data:image/png;base64,AAAA')]
)
sample_query.messages = []
sample_query.variables = {}
from importlib import import_module
import_module('langbot.pkg.pipeline.pipelinemgr')
preproc_module = import_module('langbot.pkg.pipeline.preproc.preproc')
result = await preproc_module.PreProcessor(mock_app).process(sample_query, 'PreProcessor')
content = result.new_query.user_message.content
assert len(content) == 1
assert content[0].type == 'text'
assert content[0].text == '[Image]'
assert result.new_query.variables['user_message_text'] == '[Image]'
@@ -0,0 +1,551 @@
from __future__ import annotations
import pathlib
import datetime
from unittest.mock import AsyncMock
import aiocqhttp
import pytest
import yaml
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
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.aiocqhttp.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
def make_adapter() -> AiocqhttpAdapter:
return AiocqhttpAdapter({'host': '127.0.0.1', 'port': 2280, 'access-token': ''}, DummyLogger())
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'aiocqhttp'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def onebot_event(payload: dict) -> aiocqhttp.Event:
return aiocqhttp.Event.from_payload(payload)
def test_aiocqhttp_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_aiocqhttp_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_aiocqhttp_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_aiocqhttp_adapter_dispatches_most_specific_eba_listener():
adapter = make_adapter()
calls: list[str] = []
async def wildcard_listener(event, adapter):
calls.append('event')
async def eba_listener(event, adapter):
calls.append('eba')
async def message_listener(event, adapter):
calls.append('message')
adapter.register_listener(platform_events.Event, wildcard_listener)
adapter.register_listener(platform_events.EBAEvent, eba_listener)
adapter.register_listener(platform_events.MessageReceivedEvent, message_listener)
event = platform_events.MessageReceivedEvent(
message_id=1,
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
sender={'id': 1},
chat_id=1,
)
await adapter._dispatch_eba_event(event)
assert calls == ['message']
@pytest.mark.asyncio
async def test_aiocqhttp_message_converter_maps_chain_to_onebot_segments():
target, source_id, _ = await AiocqhttpMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Source(id=42, time=datetime.datetime.now()),
platform_message.Plain(text='hi '),
platform_message.At(target='10001'),
platform_message.AtAll(),
platform_message.Image(base64='data:image/png;base64,AAAA'),
platform_message.Face(face_id=14, face_name='微笑'),
]
)
)
assert source_id == 42
assert [segment.type for segment in target] == ['text', 'at', 'at', 'image', 'face']
assert target[3].data['file'] == 'base64://AAAA'
@pytest.mark.asyncio
async def test_aiocqhttp_message_converter_maps_media_file_quote_and_face_to_onebot_segments():
target, _, _ = await AiocqhttpMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Quote(id=123, origin=platform_message.MessageChain([])),
platform_message.Image(url='https://example.test/a.png'),
platform_message.Voice(base64='data:audio/silk;base64,BBBB'),
platform_message.File(name='doc.txt', url='https://example.test/doc.txt'),
platform_message.Face(face_type='rps', face_id=1, face_name='猜拳'),
platform_message.Face(face_type='dice', face_id=6, face_name='骰子'),
]
)
)
assert [segment.type for segment in target] == ['reply', 'image', 'record', 'file', 'rps', 'dice']
assert target[0].data['id'] == '123'
assert target[1].data['file'] == 'https://example.test/a.png'
assert target[2].data['file'] == 'base64://BBBB'
assert target[3].data['name'] == 'doc.txt'
@pytest.mark.asyncio
async def test_aiocqhttp_message_converter_flattens_forward_nodes():
target, _, _ = await AiocqhttpMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Forward(
node_list=[
platform_message.ForwardMessageNode(
sender_id='10001',
sender_name='Alice',
message_chain=platform_message.MessageChain([platform_message.Plain(text='node 1')]),
),
platform_message.ForwardMessageNode(
sender_id='10002',
sender_name='Bob',
message_chain=platform_message.MessageChain([platform_message.At(target='999')]),
),
]
)
]
)
)
assert [segment.type for segment in target] == ['text', 'at']
assert target[0].data['text'] == 'node 1'
@pytest.mark.asyncio
async def test_aiocqhttp_message_converter_maps_onebot_segments_to_chain():
chain = await AiocqhttpMessageConverter.target2yiri(
[
{'type': 'text', 'data': {'text': 'hello '}},
{'type': 'at', 'data': {'qq': 'all'}},
{'type': 'at', 'data': {'qq': '10001'}},
{'type': 'image', 'data': {'file': 'abc.image', 'url': 'https://example.test/a.png'}},
{'type': 'image', 'data': {'emoji_package_id': '14', 'summary': '微笑'}},
{'type': 'record', 'data': {'file': 'voice.silk', 'url': 'https://example.test/a.silk'}},
{'type': 'file', 'data': {'file_id': 'file-1', 'name': 'doc.txt', 'size': '5'}},
{'type': 'reply', 'data': {'id': '99'}},
{'type': 'face', 'data': {'id': '14', 'raw': {'faceText': '/微笑'}}},
{'type': 'rps', 'data': {'result': '2'}},
{'type': 'dice', 'data': {'result': '6'}},
{'type': 'json', 'data': {'data': '{}'}},
],
message_id=123,
timestamp=1710000000,
)
assert isinstance(chain[0], platform_message.Source)
assert isinstance(chain[1], platform_message.Plain)
assert isinstance(chain[2], platform_message.AtAll)
assert isinstance(chain[3], platform_message.At)
assert isinstance(chain[4], platform_message.Image)
assert chain[4].url == 'https://example.test/a.png'
assert isinstance(chain[5], platform_message.Face)
assert isinstance(chain[6], platform_message.Voice)
assert isinstance(chain[7], platform_message.File)
assert isinstance(chain[8], platform_message.Quote)
assert isinstance(chain[9], platform_message.Face)
assert isinstance(chain[10], platform_message.Face)
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)
@pytest.mark.asyncio
async def test_aiocqhttp_message_converter_fetches_reply_origin_when_bot_available():
bot = AsyncMock()
bot.get_msg.return_value = {
'message_id': 99,
'user_id': 10001,
'group_id': 20001,
'time': 1710000000,
'message': [{'type': 'text', 'data': {'text': 'origin'}}],
}
chain = await AiocqhttpMessageConverter.target2yiri(
[{'type': 'reply', 'data': {'id': '99'}}],
message_id=123,
bot=bot,
)
quote = chain[1]
assert isinstance(quote, platform_message.Quote)
assert quote.sender_id == 10001
assert quote.group_id == 20001
assert str(quote.origin) == 'origin'
@pytest.mark.asyncio
async def test_aiocqhttp_event_converter_maps_private_and_group_messages():
private = onebot_event(
{
'post_type': 'message',
'message_type': 'private',
'sub_type': 'friend',
'time': 1710000000,
'self_id': 999,
'message_id': 11,
'user_id': 10001,
'message': [{'type': 'text', 'data': {'text': 'hello'}}],
'raw_message': 'hello',
'sender': {'user_id': 10001, 'nickname': 'Alice'},
}
)
private_event = await AiocqhttpEventConverter.target2yiri(private)
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.type == 'message.received'
assert private_event.adapter_name == 'aiocqhttp'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 10001
assert private_event.sender.nickname == 'Alice'
group = onebot_event(
{
'post_type': 'message',
'message_type': 'group',
'sub_type': 'normal',
'time': 1710000000,
'self_id': 999,
'message_id': 12,
'group_id': 20001,
'user_id': 10001,
'message': [{'type': 'at', 'data': {'qq': '999'}}, {'type': 'text', 'data': {'text': ' ping'}}],
'raw_message': '[CQ:at,qq=999] ping',
'sender': {'user_id': 10001, 'nickname': 'Alice', 'card': 'Alice Card', 'role': 'member'},
}
)
group_event = await AiocqhttpEventConverter.target2yiri(group)
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 20001
assert group_event.group.id == 20001
assert isinstance(group_event.message_chain[1], platform_message.At)
def test_aiocqhttp_event_converter_maps_notice_and_request_events():
deleted = AiocqhttpEventConverter.notice_to_eba(
onebot_event(
{
'post_type': 'notice',
'notice_type': 'group_recall',
'time': 1710000000,
'self_id': 999,
'group_id': 20001,
'user_id': 10001,
'operator_id': 10002,
'message_id': 33,
}
)
)
assert isinstance(deleted, platform_events.MessageDeletedEvent)
assert deleted.message_id == 33
joined = AiocqhttpEventConverter.notice_to_eba(
onebot_event(
{
'post_type': 'notice',
'notice_type': 'group_increase',
'sub_type': 'invite',
'time': 1710000000,
'self_id': 999,
'group_id': 20001,
'operator_id': 10002,
'user_id': 10003,
}
),
bot_user_id=999,
)
assert isinstance(joined, platform_events.MemberJoinedEvent)
assert joined.join_type == 'invite'
bot_muted = AiocqhttpEventConverter.notice_to_eba(
onebot_event(
{
'post_type': 'notice',
'notice_type': 'group_ban',
'sub_type': 'ban',
'time': 1710000000,
'self_id': 999,
'group_id': 20001,
'operator_id': 10002,
'user_id': 999,
'duration': 60,
}
),
bot_user_id=999,
)
assert isinstance(bot_muted, platform_events.BotMutedEvent)
assert bot_muted.duration == 60
friend_request = AiocqhttpEventConverter.request_to_eba(
onebot_event(
{
'post_type': 'request',
'request_type': 'friend',
'time': 1710000000,
'self_id': 999,
'user_id': 10004,
'comment': 'please',
'flag': 'flag-1',
}
)
)
assert isinstance(friend_request, platform_events.FriendRequestReceivedEvent)
assert friend_request.request_id == 'flag-1'
group_invite = AiocqhttpEventConverter.request_to_eba(
onebot_event(
{
'post_type': 'request',
'request_type': 'group',
'sub_type': 'invite',
'time': 1710000000,
'self_id': 999,
'group_id': 20001,
'user_id': 10004,
'flag': 'group-flag',
}
)
)
assert isinstance(group_invite, platform_events.BotInvitedToGroupEvent)
assert group_invite.request_id == 'group-flag'
member_left = AiocqhttpEventConverter.notice_to_eba(
onebot_event(
{
'post_type': 'notice',
'notice_type': 'group_decrease',
'sub_type': 'kick',
'time': 1710000000,
'self_id': 999,
'group_id': 20001,
'operator_id': 10002,
'user_id': 10003,
}
),
bot_user_id=999,
)
assert isinstance(member_left, platform_events.MemberLeftEvent)
assert member_left.is_kicked is True
friend_added = AiocqhttpEventConverter.notice_to_eba(
onebot_event(
{
'post_type': 'notice',
'notice_type': 'friend_add',
'time': 1710000000,
'self_id': 999,
'user_id': 10003,
}
)
)
assert isinstance(friend_added, platform_events.FriendAddedEvent)
@pytest.mark.asyncio
async def test_aiocqhttp_send_reply_and_common_api_call_shapes():
adapter = make_adapter()
bot = AsyncMock()
bot.send_group_msg.return_value = {'message_id': 1}
bot.send_private_msg.return_value = {'message_id': 3}
bot.send.return_value = {'message_id': 2}
bot.delete_msg.return_value = {}
bot.get_msg.return_value = {
'message_id': 77,
'message_type': 'group',
'time': 1710000000,
'group_id': 20001,
'sender': {'user_id': 10001, 'nickname': 'Alice'},
'message': [{'type': 'text', 'data': {'text': 'fetched'}}],
}
bot.get_group_info.return_value = {'group_id': 20001, 'group_name': 'Group', 'member_count': 3}
bot.get_group_list.return_value = [{'group_id': 20001, 'group_name': 'Group', 'member_count': 3}]
bot.get_group_member_list.return_value = [
{
'group_id': 20001,
'user_id': 10001,
'nickname': 'Alice',
'card': 'Alice Card',
'role': 'admin',
'join_time': 1710000000,
}
]
bot.get_group_member_info.return_value = {
'group_id': 20001,
'user_id': 10001,
'nickname': 'Alice',
'card': 'Alice Card',
'role': 'admin',
'join_time': 1710000000,
}
bot.get_stranger_info.return_value = {'user_id': 10001, 'nickname': 'Alice'}
bot.get_friend_list.return_value = [{'user_id': 10001, 'nickname': 'Alice', 'remark': 'A'}]
object.__setattr__(adapter, 'bot', bot)
result = await adapter.send_message(
'group',
'20001',
platform_message.MessageChain([platform_message.Plain(text='hello')]),
)
assert result.message_id == 1
bot.send_group_msg.assert_awaited_once()
assert bot.send_group_msg.await_args.kwargs['group_id'] == 20001
source_event = onebot_event(
{
'post_type': 'message',
'message_type': 'group',
'sub_type': 'normal',
'time': 1710000000,
'self_id': 999,
'message_id': 12,
'group_id': 20001,
'user_id': 10001,
'message': [],
'raw_message': '',
'sender': {'user_id': 10001, 'nickname': 'Alice'},
}
)
eba_source = await AiocqhttpEventConverter.message_to_eba(source_event)
reply = await adapter.reply_message(
eba_source,
platform_message.MessageChain([platform_message.Plain(text='pong')]),
quote_origin=True,
)
assert reply.message_id == 2
assert bot.send.await_args.args[0] is source_event
await adapter.delete_message('group', 20001, 12)
bot.delete_msg.assert_awaited_once_with(message_id=12)
group = await adapter.get_group_info(20001)
assert group.name == 'Group'
groups = await adapter.get_group_list()
assert groups[0].id == 20001
members = await adapter.get_group_member_list(20001)
assert members[0].display_name == 'Alice Card'
member = await adapter.get_group_member_info(20001, 10001)
assert member.role == platform_entities.MemberRole.ADMIN
user = await adapter.get_user_info(10001)
assert user.nickname == 'Alice'
friends = await adapter.get_friend_list()
assert friends[0].remark == 'A'
fetched = await adapter.get_message('group', 20001, 77)
assert fetched.message_id == 77
assert str(fetched.message_chain) == 'fetched'
forwarded = await adapter.forward_message('group', 20001, 77, 'private', 10001)
assert forwarded.message_id == 3
bot.send_private_msg.assert_awaited_once()
await adapter.set_group_name(20001, 'New Name')
bot.set_group_name.assert_awaited_once_with(group_id=20001, group_name='New Name')
await adapter.mute_member(20001, 10001, 60)
bot.set_group_ban.assert_awaited_with(group_id=20001, user_id=10001, duration=60)
await adapter.unmute_member(20001, 10001)
bot.set_group_ban.assert_awaited_with(group_id=20001, user_id=10001, duration=0)
await adapter.kick_member(20001, 10001)
bot.set_group_kick.assert_awaited_once_with(group_id=20001, user_id=10001, reject_add_request=False)
await adapter.leave_group(20001)
bot.set_group_leave.assert_awaited_once_with(group_id=20001, is_dismiss=False)
await adapter.approve_friend_request('flag-1', True, 'Alice')
bot.set_friend_add_request.assert_awaited_once_with(flag='flag-1', approve=True, remark='Alice')
await adapter.approve_group_invite('flag-2', False)
bot.set_group_add_request.assert_awaited_once_with(flag='flag-2', sub_type='invite', approve=False, reason='')
with pytest.raises(NotSupportedError):
await adapter.upload_file(b'data', 'a.txt')
with pytest.raises(NotSupportedError):
await adapter.get_file_url('file-1')
@pytest.mark.asyncio
async def test_aiocqhttp_platform_specific_api_calls_all_declared_actions():
adapter = make_adapter()
bot = AsyncMock()
bot.call_action.return_value = {'ok': True}
object.__setattr__(adapter, 'bot', bot)
for action in PLATFORM_API_MAP:
result = await adapter.call_platform_api(action, {'x': 1})
assert result == {'ok': True}
called_actions = [call.args[0] for call in bot.call_action.await_args_list]
assert called_actions == list(PLATFORM_API_MAP)
with pytest.raises(NotSupportedError):
await adapter.call_platform_api('missing_action', {})
@@ -0,0 +1,254 @@
from __future__ import annotations
import pathlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.dingtalk_api.api import DingTalkClient
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
from langbot.pkg.platform.adapters.dingtalk.adapter import DingTalkAdapter
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyDingTalkClient(DingTalkClient):
def __init__(self, *args, **kwargs):
self._message_handlers = {}
self.markdown_card = kwargs.get('markdown_card', True)
self.access_token = ''
self.send_message = AsyncMock()
self.send_proactive_message_to_one = AsyncMock(return_value={'ok': True})
self.send_proactive_message_to_group = AsyncMock(return_value={'ok': True})
self.get_file_url = AsyncMock(return_value='https://example.test/file')
self.check_access_token = AsyncMock(return_value=True)
self.get_access_token = AsyncMock()
self.get_audio_url = AsyncMock(return_value='data:audio/ogg;base64,AAAA')
self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB')
self.create_and_card = AsyncMock(return_value=('card', 'card-id'))
self.send_card_message = AsyncMock()
self.start = AsyncMock()
self.stop = AsyncMock()
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'dingtalk'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter() -> DingTalkAdapter:
config = {
'client_id': 'client-id',
'client_secret': 'client-secret',
'robot_name': 'LangBot',
'robot_code': 'robot-code',
'markdown_card': True,
'enable-stream-reply': False,
'card_auto_layout': False,
'card_template_id': 'template-id',
}
with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient):
return DingTalkAdapter(config, DummyLogger())
def dingtalk_event(conversation='GroupMessage', **overrides) -> DingTalkEvent:
incoming = SimpleNamespace(
message_id=overrides.get('message_id', 'msg-1'),
create_at=1_714_000_000_000,
sender_staff_id=overrides.get('sender_staff_id', 'user-1'),
sender_nick=overrides.get('sender_nick', 'Alice'),
conversation_id=overrides.get('conversation_id', 'group-1'),
conversation_title=overrides.get('conversation_title', 'LangBot Team'),
chatbot_user_id='robot-dingtalk-id',
at_users=[SimpleNamespace(dingtalk_id='robot-dingtalk-id')],
)
payload = {
'IncomingMessage': incoming,
'conversation_type': conversation,
'Type': overrides.get('msg_type', 'text'),
'Content': overrides.get('content', '@LangBot hello'),
'Picture': overrides.get('picture', ''),
'Audio': overrides.get('audio', ''),
'File': overrides.get('file', ''),
'Name': overrides.get('name', ''),
'QuotedMessage': overrides.get('quoted_message'),
}
if 'rich_content' in overrides:
payload['Rich_Content'] = overrides['rich_content']
return DingTalkEvent.from_payload(payload)
def test_dingtalk_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_dingtalk_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_dingtalk_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_dingtalk_message_converter_maps_outbound_components():
content, at = await DingTalkMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi '),
platform_message.At(target='user-1', display='Alice'),
platform_message.AtAll(),
platform_message.Image(url='https://example.test/a.png'),
platform_message.File(name='doc.txt', url='https://example.test/doc.txt'),
platform_message.Quote(
id='origin', origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])
),
platform_message.Forward(
node_list=[
platform_message.ForwardMessageNode(
sender_id='user-2',
sender_name='Bob',
message_chain=platform_message.MessageChain([platform_message.Plain(text='node')]),
)
]
),
]
)
)
assert at is True
assert '@Alice' in content
assert '@所有人' in content
assert '![image](https://example.test/a.png)' in content
assert '[doc.txt](https://example.test/doc.txt)' in content
assert '[引用消息 origin]' in content
assert '[Bob] node' in content
@pytest.mark.asyncio
async def test_dingtalk_message_converter_maps_inbound_components():
event = dingtalk_event(
file='https://example.test/doc.txt',
name='doc.txt',
quoted_message={
'message_id': 'origin',
'msg_type': 'text',
'sender_id': 'user-2',
'content': 'quoted text',
},
)
chain = await DingTalkMessageConverter.target2yiri(event, 'LangBot')
assert isinstance(chain[0], platform_message.Source)
assert isinstance(chain[1], platform_message.At)
assert isinstance(chain[2], platform_message.Plain)
assert chain[2].text == ' hello'
assert isinstance(chain[3], platform_message.File)
assert isinstance(chain[4], platform_message.Quote)
assert str(chain[4].origin) == 'quoted text'
@pytest.mark.asyncio
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'
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'group-1'
assert group_event.group.name == 'LangBot Team'
assert group_event.sender.id == 'user-1'
private_event = await DingTalkEventConverter.target2yiri(
dingtalk_event('FriendMessage', content='hello'),
'LangBot',
)
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-1'
assert private_event.group is None
@pytest.mark.asyncio
async def test_dingtalk_adapter_dispatches_and_caches_message_event():
adapter = make_adapter()
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, listener)
event = dingtalk_event()
await adapter._handle_native_event(event)
assert len(calls) == 1
received = calls[0]
assert isinstance(received, platform_events.MessageReceivedEvent)
assert await adapter.get_message('group', 'group-1', 'msg-1') == received
assert (await adapter.get_group_info('group-1')).name == 'LangBot Team'
assert (await adapter.get_user_info('user-1')).nickname == 'Alice'
@pytest.mark.asyncio
async def test_dingtalk_send_reply_and_platform_api_use_underlying_client():
adapter = make_adapter()
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
sent = await adapter.send_message('group', 'group-1', message)
assert sent.raw == {'ok': True}
adapter.bot.send_proactive_message_to_group.assert_awaited_once()
source_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot')
replied = await adapter.reply_message(source_event, message)
assert replied.message_id == 'msg-1'
adapter.bot.send_message.assert_awaited_once()
token_status = await adapter.call_platform_api('check_access_token', {})
file_url = await adapter.call_platform_api('get_file_url', {'download_code': 'download-code'})
assert token_status == {'valid': True}
assert file_url == {'url': 'https://example.test/file'}
@@ -0,0 +1,283 @@
from __future__ import annotations
import pathlib
import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import yaml
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
def make_adapter() -> DiscordAdapter:
return DiscordAdapter({'client_id': '123', 'token': 'fake'}, DummyLogger())
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'discord'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def test_discord_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_discord_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_discord_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_discord_adapter_dispatches_most_specific_eba_listener():
adapter = make_adapter()
calls: list[str] = []
async def wildcard_listener(event, adapter):
calls.append('event')
async def eba_listener(event, adapter):
calls.append('eba')
async def message_listener(event, adapter):
calls.append('message')
adapter.register_listener(platform_events.Event, wildcard_listener)
adapter.register_listener(platform_events.EBAEvent, eba_listener)
adapter.register_listener(
platform_events.MessageReceivedEvent,
message_listener,
)
event = platform_events.MessageReceivedEvent(
message_id=1,
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
sender={'id': 1},
chat_id=1,
)
await adapter._dispatch_eba_event(event)
assert calls == ['message']
@pytest.mark.asyncio
async def test_discord_message_converter_maps_mentions_and_text_to_target():
content, files = await DiscordMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi '),
platform_message.At(target='123'),
platform_message.Plain(text=' all '),
platform_message.AtAll(),
]
)
)
assert content == 'hi <@123> all @everyone'
assert files == []
def test_discord_message_converter_splits_discord_mentions():
components = DiscordMessageConverter._text_components('hi <@123> and @everyone')
assert isinstance(components[0], platform_message.Plain)
assert components[0].text == 'hi '
assert isinstance(components[1], platform_message.At)
assert components[1].target == '123'
assert isinstance(components[3], platform_message.AtAll)
def fake_user(user_id=123, name='user', bot=False):
return SimpleNamespace(
id=user_id,
name=name,
display_name=name.title(),
bot=bot,
display_avatar=SimpleNamespace(url=f'https://cdn.example/{user_id}.png'),
)
def fake_guild(guild_id=456):
return SimpleNamespace(
id=guild_id,
name='Guild',
member_count=3,
icon=None,
owner_id=1,
)
def fake_channel(channel_id=789, guild=None):
return SimpleNamespace(
id=channel_id,
name='general',
guild=guild,
)
def fake_message(content='hello <@123>', *, guild=None, channel=None, author=None, message_id=999):
guild = guild if guild is not None else fake_guild()
channel = channel if channel is not None else fake_channel(guild=guild)
author = author if author is not None else fake_user()
return SimpleNamespace(
id=message_id,
content=content,
author=author,
channel=channel,
guild=guild,
attachments=[],
created_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC),
edited_at=datetime.datetime(2026, 5, 7, 0, 1, tzinfo=datetime.UTC),
)
@pytest.mark.asyncio
async def test_discord_converter_maps_message_edit_delete_and_reaction_events():
message = fake_message()
received = await DiscordEventConverter.message_to_eba(message)
assert isinstance(received, platform_events.MessageReceivedEvent)
assert received.type == 'message.received'
assert received.adapter_name == 'discord'
assert received.chat_type == platform_entities.ChatType.GROUP
assert received.chat_id == 789
assert received.group.id == 456
assert isinstance(received.message_chain[1], platform_message.Plain)
assert isinstance(received.message_chain[2], platform_message.At)
edited = await DiscordEventConverter.message_edit_to_eba(message, fake_message(content='edited', message_id=999))
assert isinstance(edited, platform_events.MessageEditedEvent)
assert edited.type == 'message.edited'
assert str(edited.new_content) == 'edited'
deleted = await DiscordEventConverter.message_delete_to_eba(message)
assert isinstance(deleted, platform_events.MessageDeletedEvent)
assert deleted.type == 'message.deleted'
assert deleted.message_id == 999
reaction = SimpleNamespace(message=message, emoji='👍')
reaction_event = DiscordEventConverter.reaction_to_eba(reaction, fake_user(321, 'reactor'), True)
assert isinstance(reaction_event, platform_events.MessageReactionEvent)
assert reaction_event.type == 'message.reaction'
assert reaction_event.reaction == '👍'
assert reaction_event.user.id == 321
@pytest.mark.asyncio
async def test_discord_converter_maps_uncached_raw_gateway_events():
raw_delete = SimpleNamespace(message_id=10, channel_id=20, guild_id=30)
deleted = await DiscordEventConverter.target2yiri(('raw_message_delete', raw_delete), bot_user_id=1)
assert isinstance(deleted, platform_events.MessageDeletedEvent)
assert deleted.message_id == 10
assert deleted.chat_id == 20
assert deleted.group.id == 30
raw_reaction = SimpleNamespace(
message_id=11,
channel_id=21,
guild_id=31,
user_id=41,
emoji='🔥',
member=fake_user(41, 'member'),
)
reaction = await DiscordEventConverter.target2yiri(('raw_reaction_add', raw_reaction), bot_user_id=1)
assert isinstance(reaction, platform_events.MessageReactionEvent)
assert reaction.reaction == '🔥'
assert reaction.is_add is True
assert reaction.user.id == 41
def test_discord_converter_maps_member_and_bot_guild_events():
guild = fake_guild()
member = SimpleNamespace(
**fake_user(123, 'member').__dict__,
guild=guild,
joined_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC),
)
joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=999)
assert isinstance(joined, platform_events.MemberJoinedEvent)
assert joined.join_type == 'direct'
bot_joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=123)
assert isinstance(bot_joined, platform_events.BotInvitedToGroupEvent)
bot_invited = DiscordEventConverter.guild_join_to_eba(guild)
bot_removed = DiscordEventConverter.guild_remove_to_eba(guild)
assert isinstance(bot_invited, platform_events.BotInvitedToGroupEvent)
assert isinstance(bot_removed, platform_events.BotRemovedFromGroupEvent)
@pytest.mark.asyncio
async def test_discord_send_and_reply_omit_empty_files_and_return_message_result(monkeypatch):
adapter = make_adapter()
sent = SimpleNamespace(id=111)
channel = SimpleNamespace(send=AsyncMock(return_value=sent))
object.__setattr__(adapter, '_get_channel', AsyncMock(return_value=channel))
result = await adapter.send_message(
'group',
'789',
platform_message.MessageChain([platform_message.Plain(text='hello')]),
)
assert result.message_id == 111
assert channel.send.await_args.kwargs == {'content': 'hello'}
monkeypatch.setattr(discord_adapter_module.discord, 'Message', SimpleNamespace)
source_message = SimpleNamespace(channel=channel)
source = platform_events.MessageReceivedEvent(
message_id=1,
source_platform_object=source_message,
)
result = await adapter.reply_message(
source,
platform_message.MessageChain([platform_message.Plain(text='reply')]),
quote_origin=True,
)
assert result.message_id == 111
assert channel.send.await_args.kwargs['content'] == 'reply'
assert channel.send.await_args.kwargs['reference'] is source_message
assert channel.send.await_args.kwargs['mention_author'] is False
@@ -0,0 +1,275 @@
from __future__ import annotations
import pathlib
from unittest.mock import AsyncMock
import pytest
import yaml
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP, get_gateway
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
def make_adapter() -> KookAdapter:
return KookAdapter({'token': 'fake', 'enable-stream-reply': False}, DummyLogger())
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'kook'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def fake_kook_message(**overrides):
event = {
'channel_type': 'GROUP',
'type': 9,
'author_id': 'u1',
'target_id': 'c1',
'msg_id': 'm1',
'msg_timestamp': 1_775_000_000_000,
'content': 'hi (met)u2(met) and (met)all(met)',
'extra': {
'channel_name': 'general',
'guild_id': 'g1',
'guild_name': 'Guild',
'author': {
'id': 'u1',
'username': 'alice',
'nickname': 'Alice',
'avatar': 'https://example/avatar.png',
},
'mention': ['u2'],
'mention_all': True,
},
}
event.update(overrides)
return event
def test_kook_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_kook_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_kook_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_kook_adapter_dispatches_most_specific_eba_listener():
adapter = make_adapter()
calls: list[str] = []
async def event_listener(event, adapter):
calls.append('event')
async def eba_listener(event, adapter):
calls.append('eba')
async def message_listener(event, adapter):
calls.append('message')
adapter.register_listener(platform_events.Event, event_listener)
adapter.register_listener(platform_events.EBAEvent, eba_listener)
adapter.register_listener(platform_events.MessageReceivedEvent, message_listener)
event = platform_events.MessageReceivedEvent(
message_id='m1',
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
sender=platform_entities.User(id='u1'),
chat_id='c1',
)
await adapter._dispatch_eba_event(event)
assert calls == ['message']
@pytest.mark.asyncio
async def test_kook_message_converter_maps_target_text_mentions_and_source():
chain = await KookMessageConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
assert isinstance(chain[0], platform_message.Source)
assert chain[0].id == 'm1'
assert isinstance(chain[1], platform_message.Plain)
assert chain[1].text == 'hi '
assert isinstance(chain[2], platform_message.At)
assert chain[2].target == 'u2'
assert isinstance(chain[4], platform_message.AtAll)
@pytest.mark.asyncio
async def test_kook_message_converter_maps_media_components():
image = await KookMessageConverter.target2yiri(fake_kook_message(type=2, content='https://example/image.png'))
assert isinstance(image[1], platform_message.Image)
assert image[1].url == 'https://example/image.png'
file_chain = await KookMessageConverter.target2yiri(
fake_kook_message(type=4, content='https://example/file.bin', extra={'attachments': {'name': 'file.bin'}})
)
assert isinstance(file_chain[1], platform_message.File)
assert file_chain[1].name == 'file.bin'
voice = await KookMessageConverter.target2yiri(fake_kook_message(type=8, content='https://example/voice.mp3'))
assert isinstance(voice[1], platform_message.Voice)
@pytest.mark.asyncio
async def test_kook_message_converter_maps_common_components_to_target():
content, msg_type = await KookMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi '),
platform_message.At(target='u1'),
platform_message.Plain(text=' all '),
platform_message.AtAll(),
]
)
)
assert content == 'hi (met)u1(met) all (met)all(met)'
assert msg_type == 1
@pytest.mark.asyncio
async def test_kook_event_converter_maps_group_private_and_platform_specific_events():
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.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'c1'
assert group_event.group.id == 'c1'
assert group_event.sender.id == 'u1'
private_event = await KookEventConverter.target2yiri(
fake_kook_message(channel_type='PERSON', target_id='u1', extra={'code': 'chat-code'}),
bot_account_id='bot',
)
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'chat-code'
assert private_event.group is None
specific = await KookEventConverter.target2yiri({'type': 255, 'target_id': 'raw'}, bot_account_id='bot')
assert isinstance(specific, platform_events.PlatformSpecificEvent)
assert specific.action == '255'
@pytest.mark.asyncio
async def test_kook_event_converter_maps_legacy_events():
legacy_group = await KookEventConverter.target2legacy(fake_kook_message(), bot_account_id='bot')
assert isinstance(legacy_group, platform_events.GroupMessage)
assert legacy_group.sender.group.id == 'c1'
legacy_private = await KookEventConverter.target2legacy(
fake_kook_message(channel_type='PERSON', target_id='u1'),
bot_account_id='bot',
)
assert isinstance(legacy_private, platform_events.FriendMessage)
@pytest.mark.asyncio
async def test_kook_send_and_reply_pass_expected_payloads():
adapter = make_adapter()
adapter._request = AsyncMock(return_value={'code': 0, 'data': {'msg_id': 'sent'}})
result = await adapter.send_message(
'group',
'c1',
platform_message.MessageChain([platform_message.Plain(text='hello')]),
)
assert result.message_id == 'sent'
adapter._request.assert_awaited_with(
'POST',
'/message/create',
json={'target_id': 'c1', 'content': 'hello', 'type': 1},
)
source = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
await adapter.reply_message(source, platform_message.MessageChain([platform_message.Plain(text='reply')]), True)
assert adapter._request.await_args_list[-1].args == ('POST', '/message/create')
payload = adapter._request.await_args_list[-1].kwargs['json']
assert payload['reply_msg_id'] == 'm1'
assert payload['quote'] == 'm1'
assert payload['content'] == 'reply'
@pytest.mark.asyncio
async def test_kook_get_gateway_redacts_token_in_platform_api_result():
adapter = make_adapter()
adapter._request = AsyncMock(
return_value={
'code': 0,
'data': {
'url': 'wss://example.invalid/gateway?compress=1&token=secret-token',
},
}
)
result = await get_gateway(adapter, {'compress': 1})
assert result['data']['url'] == 'wss://example.invalid/gateway?compress=1&token=%3Credacted%3E'
assert 'secret-token' not in result['data']['url']
@pytest.mark.asyncio
async def test_kook_handle_event_dispatches_eba_and_legacy_then_caches():
adapter = make_adapter()
adapter.bot_account_id = 'bot'
calls: list[str] = []
async def legacy_listener(event, adapter):
calls.append(type(event).__name__)
async def eba_listener(event, adapter):
calls.append(event.type)
adapter.register_listener(platform_events.GroupMessage, legacy_listener)
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
await adapter._handle_event(fake_kook_message(), 7)
assert calls == ['GroupMessage', 'message.received']
assert adapter.current_sn == 7
assert 'm1' in adapter._message_cache
assert 'u1' in adapter._user_cache
assert 'c1' in adapter._group_cache
@@ -0,0 +1,326 @@
from __future__ import annotations
import pathlib
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyResponse:
def __init__(self, data=None, ok=True):
self.data = data or SimpleNamespace(message_id='reply-msg')
self.code = 0 if ok else 1
self.msg = 'ok' if ok else 'failed'
self.raw = SimpleNamespace(content='{}', headers={'content-type': 'text/plain'})
self.file = SimpleNamespace(read=lambda: b'data')
self._ok = ok
def success(self):
return self._ok
def get_log_id(self):
return 'log-id'
def message_item(message_id='msg-remote'):
return SimpleNamespace(
message_id=message_id,
msg_type='text',
create_time=1_714_000_000_000,
body=SimpleNamespace(content='{"text":"remote"}'),
mentions=[],
chat_id='chat-1',
)
class DummyAPIClient:
def __init__(self):
self.im = SimpleNamespace(
v1=SimpleNamespace(
message=SimpleNamespace(
acreate=AsyncMock(return_value=DummyResponse()),
areply=AsyncMock(return_value=DummyResponse()),
aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))),
),
chat=SimpleNamespace(
aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(chat_id='chat-1', name='LangBot Team')))
),
image=SimpleNamespace(
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(image_key='img-key')))
),
file=SimpleNamespace(
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(file_key='file-key')))
),
message_resource=SimpleNamespace(aget=AsyncMock(return_value=DummyResponse())),
)
)
self.auth = SimpleNamespace(
v3=SimpleNamespace(
app_ticket=SimpleNamespace(resend=AsyncMock(return_value=DummyResponse())),
app_access_token=SimpleNamespace(create=AsyncMock(return_value=DummyResponse())),
tenant_access_token=SimpleNamespace(create=AsyncMock(return_value=DummyResponse())),
)
)
self.cardkit = SimpleNamespace(
v1=SimpleNamespace(
card=SimpleNamespace(create=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id')))),
card_element=SimpleNamespace(content=AsyncMock(return_value=DummyResponse())),
)
)
class DummyWSClient:
def __init__(self):
self._auto_reconnect = True
self._connect = AsyncMock()
self._disconnect = AsyncMock()
self._reconnect = AsyncMock()
def manifest() -> dict:
path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'lark'
/ 'manifest.yaml'
)
return yaml.safe_load(path.read_text())
def make_adapter(config: dict | None = None) -> LarkAdapter:
adapter = LarkAdapter(
{
'app_id': 'cli_xxx',
'app_secret': 'secret',
'bot_name': 'LangBotDev',
'enable-webhook': False,
'enable-stream-reply': False,
'app_type': 'self',
**(config or {}),
},
DummyLogger(),
)
adapter.api_client = DummyAPIClient()
adapter.bot = DummyWSClient()
return adapter
def lark_event(chat_type='group', message_type='text', content=None):
message = SimpleNamespace(
message_id='msg-1',
message_type=message_type,
content=content or '{"text":"hello @_user_1"}',
create_time=1_714_000_000_000,
mentions=[SimpleNamespace(key='@_user_1', id='user-mention', name='Alice')],
chat_type=chat_type,
chat_id='chat-1',
parent_id=None,
thread_id=None,
)
sender = SimpleNamespace(sender_id=SimpleNamespace(open_id='user-1', union_id='Alice Union'))
header = SimpleNamespace(tenant_key='tenant-1')
return SimpleNamespace(event=SimpleNamespace(message=message, sender=sender), header=header, schema='2.0')
def test_lark_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_lark_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_lark_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_lark_message_converter_maps_outbound_components():
with (
patch.object(LarkMessageConverter, 'upload_image_to_lark', AsyncMock(return_value='img-key')),
patch.object(LarkMessageConverter, 'upload_file_to_lark', AsyncMock(return_value='file-key')),
patch.object(LarkMessageConverter, '_get_component_bytes', AsyncMock(return_value=b'data')),
):
text_elements, media_items = await LarkMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hello\n\nsecond'),
platform_message.At(target='ou_user', display='Alice'),
platform_message.AtAll(),
platform_message.Image(base64='ZGF0YQ=='),
platform_message.Voice(base64='ZGF0YQ==', length=2),
platform_message.File(name='doc.txt', base64='ZGF0YQ=='),
platform_message.Quote(
id='origin', origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])
),
platform_message.Forward(
node_list=[
platform_message.ForwardMessageNode(
sender_id='user-2',
sender_name='Bob',
message_chain=platform_message.MessageChain([platform_message.Plain(text='node')]),
)
]
),
]
),
DummyAPIClient(),
)
assert any(ele.get('text') == 'hello' for paragraph in text_elements for ele in paragraph)
assert any(ele.get('user_id') == 'ou_user' for paragraph in text_elements for ele in paragraph)
assert any(ele.get('user_id') == 'all' for paragraph in text_elements for ele in paragraph)
assert {'msg_type': 'image', 'content': {'image_key': 'img-key'}} in media_items
assert {'msg_type': 'audio', 'content': {'file_key': 'file-key'}} in media_items
assert {'msg_type': 'file', 'content': {'file_key': 'file-key'}} in media_items
@pytest.mark.asyncio
async def test_lark_message_converter_maps_inbound_components():
with patch.object(
LarkMessageConverter,
'_download_resource',
AsyncMock(
return_value={
'url': 'file:///tmp/file',
'path': '/tmp/file',
'base64': 'data:text/plain;base64,ZGF0YQ==',
'size': 4,
}
),
):
text_chain = await LarkMessageConverter.target2yiri(lark_event().event.message, DummyAPIClient())
image_chain = await LarkMessageConverter.target2yiri(
lark_event(message_type='image', content='{"image_key":"img-key"}').event.message, DummyAPIClient()
)
file_chain = await LarkMessageConverter.target2yiri(
lark_event(message_type='file', content='{"file_key":"file-key","file_name":"doc.txt"}').event.message,
DummyAPIClient(),
)
assert isinstance(text_chain[0], platform_message.Source)
assert isinstance(text_chain[1], platform_message.Plain)
assert isinstance(text_chain[2], platform_message.At)
assert text_chain[2].target == 'user-mention'
assert isinstance(image_chain[1], platform_message.Image)
assert image_chain[1].image_id == 'img-key'
assert isinstance(file_chain[1], platform_message.File)
assert file_chain[1].name == 'doc.txt'
@pytest.mark.asyncio
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.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'chat-1'
assert group_event.group.id == 'chat-1'
assert group_event.sender.id == 'user-1'
private_event = await LarkEventConverter.target2yiri(
lark_event('p2p', content='{"text":"hello"}'),
DummyAPIClient(),
)
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-1'
assert private_event.group is None
@pytest.mark.asyncio
async def test_lark_adapter_dispatches_and_caches_message_event():
adapter = make_adapter()
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, listener)
await adapter._handle_message_event(lark_event())
assert len(calls) == 1
received = calls[0]
assert isinstance(received, platform_events.MessageReceivedEvent)
assert await adapter.get_message('group', 'chat-1', 'msg-1') == received
assert (await adapter.get_group_info('chat-1')).name == ''
assert (await adapter.get_user_info('user-1')).nickname == 'Alice Union'
@pytest.mark.asyncio
async def test_lark_get_message_fetches_uncached_message():
adapter = make_adapter()
adapter.api_client.im.v1.message.aget = AsyncMock(
return_value=DummyResponse(SimpleNamespace(items=[message_item('msg-remote')]))
)
event = await adapter.get_message('group', 'chat-1', 'msg-remote')
assert event.adapter_name == 'lark-eba'
assert event.message_id == 'msg-remote'
assert event.chat_type == platform_entities.ChatType.GROUP
assert isinstance(event.message_chain[1], platform_message.Plain)
assert event.message_chain[1].text == 'remote'
@pytest.mark.asyncio
async def test_lark_send_reply_platform_api_and_modes():
adapter = make_adapter()
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
sent = await adapter.send_message('group', 'chat-1', message)
assert sent.raw['message_ids'] == ['reply-msg']
adapter.api_client.im.v1.message.acreate.assert_awaited_once()
source_event = await LarkEventConverter.target2yiri(lark_event(), adapter.api_client)
replied = await adapter.reply_message(source_event, message)
assert replied.raw['message_ids'] == ['reply-msg']
adapter.api_client.im.v1.message.areply.assert_awaited_once()
assert await adapter.call_platform_api('check_tenant_access_token', {}) == {'ok': True}
await adapter.run_async()
adapter.bot._connect.assert_awaited_once()
webhook_adapter = make_adapter({'enable-webhook': True})
task = asyncio.create_task(webhook_adapter.run_async())
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
webhook_adapter.bot._connect.assert_not_awaited()
@@ -0,0 +1,213 @@
from __future__ import annotations
import pathlib
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.official_account_api.oaevent import OAEvent
from langbot.pkg.platform.adapters.officialaccount.adapter import OfficialAccountAdapter
from langbot.pkg.platform.adapters.officialaccount.errors import NotSupportedError
from langbot.pkg.platform.adapters.officialaccount.event_converter import OfficialAccountEventConverter
from langbot.pkg.platform.adapters.officialaccount.message_converter import OfficialAccountMessageConverter
from langbot.pkg.platform.adapters.officialaccount.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyOAClient:
def __init__(self, *args, **kwargs):
self.token = kwargs['token']
self.aes = kwargs['EncodingAESKey']
self.appid = kwargs['AppID']
self.appsecret = kwargs['Appsecret']
self.base_url = kwargs.get('api_base_url')
self._message_handlers = {}
self.generated_content = {}
self.handle_unified_webhook = AsyncMock(return_value='success')
self.set_message = AsyncMock(return_value=None)
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
class DummyLongerOAClient(DummyOAClient):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.loading_message = kwargs['LoadingMessage']
self.msg_queue = {}
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'officialaccount'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter(mode: str = 'drop') -> OfficialAccountAdapter:
config = {
'token': 'token',
'EncodingAESKey': 'encoding-key',
'AppSecret': 'secret',
'AppID': 'app-id',
'Mode': mode,
'LoadingMessage': 'loading',
}
with (
patch('langbot.pkg.platform.adapters.officialaccount.adapter.OAClient', DummyOAClient),
patch('langbot.pkg.platform.adapters.officialaccount.adapter.OAClientForLongerResponse', DummyLongerOAClient),
):
return OfficialAccountAdapter(config, DummyLogger())
def oa_event(**overrides) -> OAEvent:
payload = {
'ToUserName': overrides.get('to_user', 'gh_app'),
'FromUserName': overrides.get('from_user', 'openid-1'),
'CreateTime': overrides.get('timestamp', 1710000000),
'MsgType': overrides.get('msgtype', 'text'),
'Content': overrides.get('content', 'hello'),
'MsgId': overrides.get('message_id', 123),
}
if payload['MsgType'] == 'image':
payload.update({'PicUrl': 'https://example.test/a.jpg', 'MediaId': 'media-1', 'Content': None})
if payload['MsgType'] == 'voice':
payload.update({'MediaId': 'voice-1', 'Content': None})
if payload['MsgType'] == 'event':
payload.update({'Event': overrides.get('event', 'subscribe'), 'EventKey': 'qrscene_1', 'Content': None})
return OAEvent(payload)
def test_officialaccount_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_officialaccount_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_officialaccount_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_officialaccount_message_converter_maps_components_to_passive_text():
content = await OfficialAccountMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi'),
platform_message.Image(url='https://example.test/a.png'),
platform_message.File(name='a.txt', url='https://example.test/a.txt'),
platform_message.Quote(origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])),
]
)
)
assert 'hi' in content
assert '[Image]' in content
assert '[File: a.txt]' in content
assert 'quoted' in content
@pytest.mark.asyncio
async def test_officialaccount_event_converter_maps_text_image_voice_and_platform_event():
text_event = await OfficialAccountEventConverter().target2yiri(oa_event(content='hello'))
image_event = await OfficialAccountEventConverter().target2yiri(oa_event(msgtype='image'))
voice_event = await OfficialAccountEventConverter().target2yiri(oa_event(msgtype='voice'))
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.chat_type == platform_entities.ChatType.PRIVATE
assert text_event.chat_id == 'openid-1'
assert str(text_event.message_chain) == 'hello'
assert isinstance(image_event.message_chain[1], platform_message.Image)
assert image_event.message_chain[1].image_id == 'media-1'
assert isinstance(voice_event.message_chain[1], platform_message.Voice)
assert voice_event.message_chain[1].voice_id == 'voice-1'
assert isinstance(subscribe_event, platform_events.PlatformSpecificEvent)
assert subscribe_event.action == 'officialaccount.subscribe'
@pytest.mark.asyncio
async def test_officialaccount_adapter_dispatches_eba_and_legacy_and_caches_message_event():
adapter = make_adapter()
eba_calls: list[platform_events.Event] = []
legacy_calls: list[platform_events.Event] = []
async def eba_listener(event, adapter):
eba_calls.append(event)
async def legacy_listener(event, adapter):
legacy_calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
adapter.register_listener(platform_events.FriendMessage, legacy_listener)
await adapter._handle_native_event(oa_event())
assert len(eba_calls) == 1
assert len(legacy_calls) == 1
received = eba_calls[0]
assert isinstance(received, platform_events.MessageReceivedEvent)
assert await adapter.get_message('private', 'openid-1', 123) == received
assert (await adapter.get_user_info('openid-1')).nickname == 'openid-1'
@pytest.mark.asyncio
async def test_officialaccount_reply_platform_api_and_unsupported_send():
adapter = make_adapter()
source_event = await OfficialAccountEventConverter().target2yiri(oa_event())
message = platform_message.MessageChain([platform_message.Plain(text='reply')])
await adapter.reply_message(source_event, message)
adapter.bot.set_message.assert_awaited_once_with(123, 'reply')
assert await adapter.call_platform_api('get_mode', {}) == {'mode': 'drop', 'longer_response': False}
with pytest.raises(NotSupportedError):
await adapter.send_message('person', 'openid-1', message)
@pytest.mark.asyncio
async def test_officialaccount_passive_mode_reply_queues_by_user():
adapter = make_adapter(mode='passive')
source_event = await OfficialAccountEventConverter().target2yiri(oa_event())
await adapter.reply_message(source_event, platform_message.MessageChain([platform_message.Plain(text='reply')]))
adapter.bot.set_message.assert_awaited_once_with('openid-1', 123, 'reply')
@@ -0,0 +1,267 @@
from __future__ import annotations
import pathlib
import datetime
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyQQOfficialClient:
MEDIA_TYPE_IMAGE = 1
MEDIA_TYPE_VOICE = 3
MEDIA_TYPE_FILE = 4
def __init__(self, *args, **kwargs):
self.app_id = kwargs['app_id']
self.secret = kwargs['secret']
self.token = kwargs['token']
self.unified_mode = kwargs['unified_mode']
self._message_handlers = {}
self.sent = []
self.access_token = ''
self.access_token_expiry_time = None
self.handle_unified_webhook = AsyncMock(return_value='success')
self.connect_gateway_loop = AsyncMock()
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
async def check_access_token(self):
return bool(self.access_token)
async def get_access_token(self):
self.access_token = 'token'
self.access_token_expiry_time = 1710003600
async def get_gateway_url(self):
return 'wss://gateway.example.test'
async def send_private_text_msg(self, user_openid, content, msg_id=None):
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):
self.sent.append(('group_text', group_openid, content, msg_id))
return {'id': 'sent-group'}
async def send_channle_group_text_msg(self, channel_id, content, msg_id=None):
self.sent.append(('channel_text', channel_id, content, msg_id))
return {'id': 'sent-channel'}
async def send_channle_private_text_msg(self, guild_id, content, msg_id=None):
self.sent.append(('dm_text', guild_id, content, msg_id))
return {'id': 'sent-dm'}
async def send_image_msg(self, target_type, target_id, file_url=None, file_data=None, msg_id=None, content=None):
self.sent.append(('image', target_type, target_id, file_url, file_data, msg_id))
return {'id': 'sent-image'}
async def send_voice_msg(self, target_type, target_id, file_url=None, file_data=None, msg_id=None):
self.sent.append(('voice', target_type, target_id, file_url, file_data, msg_id))
return {'id': 'sent-voice'}
async def send_file_msg(self, target_type, target_id, file_url=None, file_data=None, file_name=None, msg_id=None):
self.sent.append(('file', target_type, target_id, file_url, file_data, file_name, msg_id))
return {'id': 'sent-file'}
async def send_stream_msg(self, **kwargs):
self.sent.append(('stream', kwargs))
return {'id': 'stream-1'}
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'qqofficial'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter(enable_webhook: bool = True) -> QQOfficialAdapter:
config = {
'appid': 'app-id',
'secret': 'secret',
'token': 'token',
'enable-webhook': enable_webhook,
'enable-stream-reply': True,
}
with patch('langbot.pkg.platform.adapters.qqofficial.adapter.QQOfficialClient', DummyQQOfficialClient):
return QQOfficialAdapter(config, DummyLogger())
def qq_event(event_type: str = 'C2C_MESSAGE_CREATE', **overrides) -> QQOfficialEvent:
payload = {
't': event_type,
'user_openid': overrides.get('user_openid', 'user-openid'),
'timestamp': overrides.get('timestamp', '2026-06-01T10:00:00+0800'),
'd_author_id': overrides.get('author_id', 'author-id'),
'content': overrides.get('content', 'hello'),
'd_id': overrides.get('message_id', 'msg-1'),
'id': overrides.get('event_id', 'event-1'),
'channel_id': overrides.get('channel_id', 'channel-1'),
'username': overrides.get('username', 'alice'),
'guild_id': overrides.get('guild_id', 'guild-1'),
'member_openid': overrides.get('member_openid', 'member-openid'),
'group_openid': overrides.get('group_openid', 'group-openid'),
'image_attachments': overrides.get('image_attachments'),
'content_type': overrides.get('content_type', 'image/png'),
}
return QQOfficialEvent(payload)
def test_qqofficial_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_qqofficial_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_qqofficial_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_qqofficial_message_converter_maps_common_components_to_send_payloads():
payload = await QQOfficialMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Source(id='msg-0', time=datetime.datetime.now()),
platform_message.Plain(text='hi'),
platform_message.At(target='user-1', display='Alice'),
platform_message.AtAll(),
platform_message.Image(url='https://example.test/a.png'),
platform_message.Voice(base64='data:audio/mpeg;base64,AAAA'),
platform_message.File(name='a.txt', url='https://example.test/a.txt'),
platform_message.Quote(origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])),
]
)
)
assert {'type': 'text', 'content': 'hi'} in payload
assert {'type': 'text', 'content': '@Alice'} in payload
assert {'type': 'text', 'content': '@all'} in payload
assert any(item['type'] == 'image' and item['url'] == 'https://example.test/a.png' for item in payload)
assert any(item['type'] == 'voice' and item['base64'].startswith('data:audio') for item in payload)
assert any(item['type'] == 'file' and item['name'] == 'a.txt' for item in payload)
assert {'type': 'text', 'content': 'quoted'} in payload
@pytest.mark.asyncio
async def test_qqofficial_event_converter_maps_private_group_and_platform_specific():
private_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE'))
group_event = await QQOfficialEventConverter().target2yiri(qq_event('GROUP_AT_MESSAGE_CREATE'))
channel_event = await QQOfficialEventConverter().target2yiri(qq_event('AT_MESSAGE_CREATE'))
platform_event = await QQOfficialEventConverter().target2yiri(qq_event('GUILD_CREATE'))
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.adapter_name == 'qqofficial-eba'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-openid'
assert str(private_event.message_chain) == 'hello'
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'group-openid'
assert isinstance(group_event.message_chain[1], platform_message.At)
assert channel_event.chat_id == 'channel-1'
assert isinstance(platform_event, platform_events.PlatformSpecificEvent)
assert platform_event.action == 'qqofficial.GUILD_CREATE'
@pytest.mark.asyncio
async def test_qqofficial_adapter_dispatches_eba_and_legacy_and_caches_group_event():
adapter = make_adapter()
eba_calls: list[platform_events.Event] = []
legacy_calls: list[platform_events.Event] = []
async def eba_listener(event, adapter):
eba_calls.append(event)
async def legacy_listener(event, adapter):
legacy_calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
adapter.register_listener(platform_events.GroupMessage, legacy_listener)
await adapter._handle_native_event(qq_event('GROUP_AT_MESSAGE_CREATE'))
assert len(eba_calls) == 1
assert len(legacy_calls) == 1
received = eba_calls[0]
assert await adapter.get_message('group', 'group-openid', 'msg-1') == received
assert (await adapter.get_group_info('group-openid')).id == 'group-openid'
assert (await adapter.get_group_member_info('group-openid', 'member-openid')).user.id == 'member-openid'
@pytest.mark.asyncio
async def test_qqofficial_send_reply_stream_platform_api_and_unsupported():
adapter = make_adapter()
message = platform_message.MessageChain(
[
platform_message.Plain(text='reply'),
platform_message.Image(url='https://example.test/a.png'),
]
)
source_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE'))
reply_result = await adapter.reply_message(source_event, message)
assert reply_result.message_id == 'msg-1'
assert ('private_text', 'user-openid', 'reply', 'msg-1') in adapter.bot.sent
assert any(call[0] == 'image' and call[1] == 'c2c' for call in adapter.bot.sent)
await adapter.send_message('group', 'group-openid', platform_message.MessageChain([platform_message.Plain(text='hello group')]))
assert ('group_text', 'group-openid', 'hello group', None) in adapter.bot.sent
assert await adapter.call_platform_api('get_mode', {}) == {
'webhook': True,
'stream_reply': True,
'bot_account_id': 'app-id',
}
await adapter.call_platform_api('refresh_access_token', {})
assert adapter.bot.access_token == 'token'
with pytest.raises(NotSupportedError):
await adapter.call_platform_api('missing', {})
@@ -0,0 +1,239 @@
from __future__ import annotations
import datetime
import pathlib
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.slack_api.slackevent import SlackEvent
from langbot.pkg.platform.adapters.slack.adapter import SlackAdapter
from langbot.pkg.platform.adapters.slack.errors import NotSupportedError
from langbot.pkg.platform.adapters.slack.event_converter import SlackEventConverter
from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter
from langbot.pkg.platform.adapters.slack.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummySlackWebClient:
async def auth_test(self):
return {'ok': True, 'user_id': 'B-1'}
class DummySlackClient:
def __init__(self, *args, **kwargs):
self.bot_token = kwargs['bot_token']
self.signing_secret = kwargs['signing_secret']
self.unified_mode = kwargs['unified_mode']
self._message_handlers = {}
self.client = DummySlackWebClient()
self.sent = []
self.handle_unified_webhook = AsyncMock(return_value='ok')
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
async def send_message_to_channel(self, text: str, channel_id: str):
self.sent.append(('channel', channel_id, text))
return {'ok': True, 'channel': channel_id, 'message': {'ts': '1.1'}}
async def send_message_to_one(self, text: str, user_id: str):
self.sent.append(('person', user_id, text))
return {'ok': True, 'channel': user_id, 'message': {'ts': '1.2'}}
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'slack'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter() -> SlackAdapter:
config = {
'bot_token': 'xoxb-token',
'signing_secret': 'signing-secret',
'bot_user_id': 'B-1',
}
with patch('langbot.pkg.platform.adapters.slack.adapter.SlackClient', DummySlackClient):
return SlackAdapter(config, DummyLogger())
def slack_event(channel_type: str = 'im', **overrides) -> SlackEvent:
text = overrides.get('text', 'hello')
payload = {
'event_id': overrides.get('event_id', 'evt-1'),
'event': {
'type': 'app_mention' if channel_type == 'channel' else 'message',
'channel_type': channel_type,
'user': overrides.get('user_id', 'U-1'),
'channel': overrides.get('channel_id', 'C-1'),
'ts': overrides.get('ts', '1710003600.000100'),
'event_ts': overrides.get('ts', '1710003600.000100'),
'blocks': [
{
'type': 'rich_text',
'elements': [
{
'type': 'rich_text_section',
'elements': [
{'type': 'text', 'text': text},
],
}
],
}
],
},
}
if channel_type == 'im':
payload['event']['blocks'] = [
{
'elements': [
{
'elements': [
{'type': 'text', 'text': text},
]
}
]
}
]
if overrides.get('pic_url'):
payload['event']['files'] = [{'url_private': overrides['pic_url']}]
return SlackEvent(payload)
def test_slack_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_slack_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_slack_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_slack_message_converter_maps_common_components_to_text():
text = await SlackMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Source(id='msg-0', time=datetime.datetime.now()),
platform_message.Plain(text='hi'),
platform_message.At(target='U-2'),
platform_message.AtAll(),
platform_message.Image(url='https://example.test/a.png'),
platform_message.File(name='a.txt'),
platform_message.Quote(origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])),
]
)
)
assert 'hi' in text
assert '<@U-2>' in text
assert '<!channel>' in text
assert 'https://example.test/a.png' in text
assert 'a.txt' in text
assert 'quoted' in text
@pytest.mark.asyncio
async def test_slack_event_converter_maps_private_group_and_platform_specific():
private_event = await SlackEventConverter().target2yiri(slack_event('im'))
group_event = await SlackEventConverter().target2yiri(slack_event('channel'))
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.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'U-1'
assert str(private_event.message_chain) == 'hello'
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'C-1'
assert isinstance(group_event.message_chain[1], platform_message.At)
assert isinstance(platform_event, platform_events.PlatformSpecificEvent)
assert platform_event.action == 'slack.file_share'
@pytest.mark.asyncio
async def test_slack_adapter_dispatches_eba_and_legacy_and_caches_group_event():
adapter = make_adapter()
eba_calls: list[platform_events.Event] = []
legacy_calls: list[platform_events.Event] = []
async def eba_listener(event, adapter):
eba_calls.append(event)
async def legacy_listener(event, adapter):
legacy_calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
adapter.register_listener(platform_events.GroupMessage, legacy_listener)
await adapter._handle_native_event(slack_event('channel'))
assert len(eba_calls) == 1
assert len(legacy_calls) == 1
received = eba_calls[0]
assert await adapter.get_message('group', 'C-1', 'evt-1') == received
assert (await adapter.get_group_info('C-1')).id == 'C-1'
assert (await adapter.get_group_member_info('C-1', 'U-1')).user.id == 'U-1'
@pytest.mark.asyncio
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')]))
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')]))
assert ('channel', 'C-1', 'hello channel') in adapter.bot.sent
assert await adapter.call_platform_api('get_mode', {}) == {
'webhook': True,
'bot_account_id': 'B-1',
}
assert await adapter.call_platform_api('auth_test', {}) == {'ok': True, 'user_id': 'B-1'}
with pytest.raises(NotSupportedError):
await adapter.call_platform_api('missing', {})
@@ -0,0 +1,458 @@
from __future__ import annotations
import base64
import datetime
import pathlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import yaml
import telegram
from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler, MessageReactionHandler
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities import events as plugin_events
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
def make_adapter() -> TelegramAdapter:
return TelegramAdapter(
{
'token': '123456:ABCDEF_fake_token_for_object_parsing',
'markdown_card': False,
'enable-stream-reply': False,
},
DummyLogger(),
)
def make_update(data: dict) -> telegram.Update:
payload = {'update_id': 1000, **data}
return telegram.Update.de_json(payload, make_adapter().bot)
def base_message_payload(**overrides):
payload = {
'message_id': 10,
'date': int(datetime.datetime.now(datetime.UTC).timestamp()),
'chat': {'id': 123, 'type': 'private', 'first_name': 'Chat User'},
'from': {'id': 456, 'is_bot': False, 'first_name': 'Sender', 'username': 'sender'},
'text': 'hello',
}
payload.update(overrides)
return payload
def test_telegram_adapter_registers_all_declared_update_handlers():
adapter = make_adapter()
handlers = adapter.application.handlers[0]
assert sum(isinstance(handler, MessageHandler) for handler in handlers) == 2
assert sum(isinstance(handler, ChatMemberHandler) for handler in handlers) == 2
assert any(isinstance(handler, CallbackQueryHandler) for handler in handlers)
assert any(isinstance(handler, MessageReactionHandler) for handler in handlers)
@pytest.mark.asyncio
async def test_telegram_adapter_dispatches_only_most_specific_eba_listener():
adapter = make_adapter()
calls: list[str] = []
async def wildcard_listener(event, adapter):
calls.append('event')
async def eba_listener(event, adapter):
calls.append('eba')
async def message_listener(event, adapter):
calls.append('message.received')
adapter.register_listener(platform_events.Event, wildcard_listener)
adapter.register_listener(platform_events.EBAEvent, eba_listener)
adapter.register_listener(platform_events.MessageReceivedEvent, message_listener)
event = platform_events.MessageReceivedEvent(
message_id=1,
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
sender=platform_entities.User(id=1),
chat_id=1,
)
await adapter._dispatch_eba_event(event)
assert calls == ['message.received']
@pytest.mark.asyncio
async def test_telegram_adapter_dispatch_falls_back_to_eba_then_event_listener():
adapter = make_adapter()
calls: list[str] = []
async def wildcard_listener(event, adapter):
calls.append('event')
async def eba_listener(event, adapter):
calls.append('eba')
adapter.register_listener(platform_events.Event, wildcard_listener)
adapter.register_listener(platform_events.EBAEvent, eba_listener)
event = platform_events.MessageEditedEvent(
message_id=1,
new_content=platform_message.MessageChain([platform_message.Plain(text='edited')]),
editor=platform_entities.User(id=1),
chat_id=1,
)
await adapter._dispatch_eba_event(event)
assert calls == ['eba']
adapter.unregister_listener(platform_events.EBAEvent, eba_listener)
await adapter._dispatch_eba_event(event)
assert calls == ['eba', 'event']
def test_telegram_supported_events_match_manifest():
adapter_events = make_adapter().get_supported_events()
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'telegram'
/ 'manifest.yaml'
)
manifest_events = yaml.safe_load(manifest_path.read_text())['spec']['supported_events']
assert adapter_events == manifest_events
assert 'message.deleted' not in adapter_events
assert 'group.info_updated' not in adapter_events
@pytest.mark.asyncio
async def test_telegram_converter_maps_message_and_edited_message_events():
update = make_update({'message': base_message_payload(text='hello @test_bot')})
event = await TelegramEventConverter.target2yiri(update, make_adapter().bot, 'test_bot')
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.type == 'message.received'
assert event.chat_type == platform_entities.ChatType.PRIVATE
assert event.chat_id == 123
assert event.sender.id == 456
assert platform_message.At in event.message_chain
assert isinstance(event.message_chain[0], platform_message.At)
assert isinstance(event.message_chain[1], platform_message.Plain)
assert event.message_chain[1].text == 'hello '
group_chat = {'id': -100123, 'type': 'supergroup', 'title': 'Test Group'}
edited_payload = base_message_payload(chat=group_chat, text='edited')
edited_payload['edit_date'] = edited_payload['date'] + 1
edited = make_update({'edited_message': edited_payload})
edited_event = await TelegramEventConverter.target2yiri(edited, make_adapter().bot, 'test_bot')
assert isinstance(edited_event, platform_events.MessageEditedEvent)
assert edited_event.type == 'message.edited'
assert edited_event.chat_type == platform_entities.ChatType.GROUP
assert edited_event.group.name == 'Test Group'
assert str(edited_event.new_content) == 'edited'
@pytest.mark.asyncio
async def test_telegram_converter_maps_non_message_updates():
chat_member = make_update(
{
'chat_member': {
'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'},
'from': {'id': 1, 'is_bot': False, 'first_name': 'Admin'},
'date': int(datetime.datetime.now(datetime.UTC).timestamp()),
'old_chat_member': {
'user': {'id': 2, 'is_bot': False, 'first_name': 'Member'},
'status': 'left',
},
'new_chat_member': {
'user': {'id': 2, 'is_bot': False, 'first_name': 'Member'},
'status': 'member',
},
}
}
)
joined = await TelegramEventConverter.target2yiri(chat_member, make_adapter().bot, 'test_bot')
assert isinstance(joined, platform_events.MemberJoinedEvent)
assert joined.type == 'group.member_joined'
callback = make_update(
{
'callback_query': {
'id': 'cb-1',
'from': {'id': 3, 'is_bot': False, 'first_name': 'Clicker'},
'chat_instance': 'ci',
'data': 'button-data',
'message': base_message_payload(message_id=77),
}
}
)
callback_event = await TelegramEventConverter.target2yiri(callback, make_adapter().bot, 'test_bot')
assert isinstance(callback_event, platform_events.PlatformSpecificEvent)
assert callback_event.action == 'callback_query'
assert callback_event.data['callback_query_id'] == 'cb-1'
assert callback_event.data['data'] == 'button-data'
reaction = make_update(
{
'message_reaction': {
'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'},
'message_id': 77,
'date': int(datetime.datetime.now(datetime.UTC).timestamp()),
'user': {'id': 3, 'is_bot': False, 'first_name': 'Reactor'},
'old_reaction': [],
'new_reaction': [{'type': 'emoji', 'emoji': '👍'}],
}
}
)
reaction_event = await TelegramEventConverter.target2yiri(reaction, make_adapter().bot, 'test_bot')
assert isinstance(reaction_event, platform_events.MessageReactionEvent)
assert reaction_event.reaction == '👍'
assert reaction_event.is_add is True
@pytest.mark.asyncio
async def test_telegram_converter_maps_bot_status_events():
base_member = {
'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'},
'from': {'id': 1, 'is_bot': False, 'first_name': 'Admin'},
'date': int(datetime.datetime.now(datetime.UTC).timestamp()),
}
restricted_member = {
'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'},
'status': 'restricted',
'is_member': True,
'can_send_messages': False,
'can_send_audios': False,
'can_send_documents': False,
'can_send_photos': False,
'can_send_videos': False,
'can_send_video_notes': False,
'can_send_voice_notes': False,
'can_send_polls': False,
'can_send_other_messages': False,
'can_add_web_page_previews': False,
'can_change_info': False,
'can_invite_users': False,
'can_pin_messages': False,
'can_manage_topics': False,
'until_date': 0,
}
invited = make_update(
{
'my_chat_member': {
**base_member,
'old_chat_member': {
'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'},
'status': 'left',
},
'new_chat_member': {
'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'},
'status': 'member',
},
}
}
)
invited_event = await TelegramEventConverter.target2yiri(invited, make_adapter().bot, 'test_bot')
assert isinstance(invited_event, platform_events.BotInvitedToGroupEvent)
muted = make_update(
{
'my_chat_member': {
**base_member,
'old_chat_member': {
'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'},
'status': 'member',
},
'new_chat_member': {
**restricted_member,
},
}
}
)
muted_event = await TelegramEventConverter.target2yiri(muted, make_adapter().bot, 'test_bot')
assert isinstance(muted_event, platform_events.BotMutedEvent)
unmuted = make_update(
{
'my_chat_member': {
**base_member,
'old_chat_member': {
**restricted_member,
},
'new_chat_member': {
'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'},
'status': 'member',
},
}
}
)
unmuted_event = await TelegramEventConverter.target2yiri(unmuted, make_adapter().bot, 'test_bot')
assert isinstance(unmuted_event, platform_events.BotUnmutedEvent)
@pytest.mark.asyncio
async def test_telegram_reply_message_sends_text_image_and_file_components():
adapter = make_adapter()
bot = SimpleNamespace(
send_message=AsyncMock(),
send_photo=AsyncMock(),
send_document=AsyncMock(),
)
object.__setattr__(adapter, 'bot', bot)
update = make_update({'message': base_message_payload(message_id=88)})
message_source = platform_events.MessageReceivedEvent(
message_id=88,
source_platform_object=update,
)
await adapter.reply_message(
message_source,
platform_message.MessageChain(
[
platform_message.Plain(text='reply text'),
platform_message.Image(
base64='data:image/png;base64,' + base64.b64encode(b'image-bytes').decode('utf-8')
),
platform_message.File(
name='test.txt',
size=4,
base64='data:text/plain;base64,' + base64.b64encode(b'test').decode('utf-8'),
),
]
),
quote_origin=True,
)
bot.send_message.assert_awaited_once()
bot.send_photo.assert_awaited_once()
bot.send_document.assert_awaited_once()
assert bot.send_message.await_args.kwargs['reply_to_message_id'] == 88
assert bot.send_photo.await_args.kwargs['reply_to_message_id'] == 88
assert bot.send_photo.await_args.kwargs['photo'].input_file_content == b'image-bytes'
assert bot.send_document.await_args.kwargs['document'].filename == 'test.txt'
assert bot.send_document.await_args.kwargs['document'].input_file_content == b'test'
@pytest.mark.asyncio
async def test_telegram_platform_apis_call_underlying_bot_methods():
bot = SimpleNamespace(
pin_chat_message=AsyncMock(),
unpin_chat_message=AsyncMock(),
unpin_all_chat_messages=AsyncMock(),
get_chat_administrators=AsyncMock(
return_value=[
SimpleNamespace(
user=SimpleNamespace(id=1, username='admin', first_name='Admin'),
status='administrator',
custom_title='Boss',
)
]
),
set_chat_title=AsyncMock(),
set_chat_description=AsyncMock(),
get_chat_member_count=AsyncMock(return_value=3),
send_chat_action=AsyncMock(),
create_chat_invite_link=AsyncMock(
return_value=SimpleNamespace(
invite_link='https://t.me/+abc',
name='invite',
is_primary=False,
is_revoked=False,
)
),
answer_callback_query=AsyncMock(),
)
assert await PLATFORM_API_MAP['pin_message'](bot, {'chat_id': 1, 'message_id': 2}) == {'ok': True}
assert await PLATFORM_API_MAP['unpin_message'](bot, {'chat_id': 1, 'message_id': 2}) == {'ok': True}
assert await PLATFORM_API_MAP['unpin_all_messages'](bot, {'chat_id': 1}) == {'ok': True}
admins = await PLATFORM_API_MAP['get_chat_administrators'](bot, {'chat_id': 1})
assert admins['administrators'][0]['user_id'] == 1
assert await PLATFORM_API_MAP['set_chat_title'](bot, {'chat_id': 1, 'title': 'New'}) == {'ok': True}
assert await PLATFORM_API_MAP['set_chat_description'](bot, {'chat_id': 1, 'description': 'Desc'}) == {'ok': True}
assert await PLATFORM_API_MAP['get_chat_member_count'](bot, {'chat_id': 1}) == {'count': 3}
assert await PLATFORM_API_MAP['send_chat_action'](bot, {'chat_id': 1, 'action': 'typing'}) == {'ok': True}
invite = await PLATFORM_API_MAP['create_chat_invite_link'](bot, {'chat_id': 1, 'name': 'invite'})
assert invite['invite_link'] == 'https://t.me/+abc'
assert await PLATFORM_API_MAP['answer_callback_query'](bot, {'callback_query_id': 'cb'}) == {'ok': True}
@pytest.mark.asyncio
async def test_telegram_unmute_member_uses_current_chat_permissions_fields():
adapter = make_adapter()
bot = SimpleNamespace(restrict_chat_member=AsyncMock())
object.__setattr__(adapter, 'bot', bot)
await adapter.unmute_member(group_id=-1001, user_id=123)
permissions = bot.restrict_chat_member.await_args.kwargs['permissions']
assert permissions.can_send_messages is True
assert permissions.can_send_photos is True
assert permissions.can_send_documents is True
def test_runtime_bot_maps_telegram_eba_events_to_plugin_events():
group = platform_entities.UserGroup(id='group-1', name='Group')
user = platform_entities.User(id='user-1', nickname='User')
cases = [
(
platform_events.MessageReceivedEvent(
message_id='msg',
message_chain=platform_message.MessageChain([platform_message.Plain(text='hi')]),
sender=user,
chat_id='user-1',
),
plugin_events.MessageReceived,
),
(
platform_events.MessageReactionEvent(message_id='msg', user=user, reaction='👍'),
plugin_events.MessageReactionReceived,
),
(
platform_events.MemberJoinedEvent(group=group, member=user),
plugin_events.GroupMemberJoined,
),
(
platform_events.BotUnmutedEvent(group=group, operator=user),
plugin_events.BotUnmuted,
),
(
platform_events.PlatformSpecificEvent(adapter_name='telegram', action='callback_query', data={'data': 'x'}),
plugin_events.PlatformSpecificEventReceived,
),
]
for platform_event, plugin_event_type in cases:
plugin_event = RuntimeBot._eba_event_to_plugin_event(platform_event)
assert isinstance(plugin_event, plugin_event_type)
assert plugin_event.model_dump()['event_name'] == plugin_event_type.__name__
@@ -0,0 +1,235 @@
from __future__ import annotations
import pathlib
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.wecom_api.api import WecomClient
from langbot.libs.wecom_api.wecomevent import WecomEvent
from langbot.pkg.platform.adapters.wecom.adapter import WecomAdapter
from langbot.pkg.platform.adapters.wecom.event_converter import WecomEventConverter
from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter, split_string_by_bytes
from langbot.pkg.platform.adapters.wecom.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyWecomClient(WecomClient):
def __init__(self, *args, **kwargs):
self.corpid = kwargs['corpid']
self.secret = kwargs['secret']
self.token = kwargs['token']
self.aes = kwargs['EncodingAESKey']
self.secret_for_contacts = kwargs.get('contacts_secret', '')
self.base_url = kwargs.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin')
self.logger = kwargs.get('logger')
self.access_token = ''
self._message_handlers = {}
self.get_media_id = AsyncMock(return_value='media-id')
self.send_private_msg = AsyncMock()
self.send_image = AsyncMock()
self.send_voice = AsyncMock()
self.send_file = AsyncMock()
self.get_user_info = AsyncMock(return_value={'userid': 'user-1', 'name': 'Alice', 'alias': 'alice'})
self.check_access_token = AsyncMock(return_value=True)
self.get_access_token = AsyncMock(return_value='access-token')
self.send_to_all = AsyncMock()
self.handle_unified_webhook = AsyncMock(return_value='success')
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'wecom'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter() -> WecomAdapter:
config = {
'corpid': 'corp-id',
'secret': 'secret',
'token': 'token',
'EncodingAESKey': 'encoding-key',
'contacts_secret': 'contacts-secret',
'api_base_url': 'https://qyapi.weixin.qq.com/cgi-bin',
}
with patch('langbot.pkg.platform.adapters.wecom.adapter.WecomClient', DummyWecomClient):
return WecomAdapter(config, DummyLogger())
def wecom_event(**overrides) -> WecomEvent:
payload = {
'ToUserName': overrides.get('to_user', 'corp-id'),
'FromUserName': overrides.get('from_user', 'user-1'),
'CreateTime': overrides.get('create_time', 1_714_000_000),
'MsgType': overrides.get('msg_type', 'text'),
'Content': overrides.get('content', 'hello'),
'MsgId': overrides.get('message_id', 12345),
'AgentID': overrides.get('agent_id', 1000002),
}
if payload['MsgType'] == 'image':
payload['MediaId'] = overrides.get('media_id', 'media-id')
payload['PicUrl'] = overrides.get('picurl', 'https://example.test/a.png')
return WecomEvent.from_payload(payload)
def test_wecom_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_wecom_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_wecom_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
def test_wecom_split_string_by_bytes_keeps_multibyte_boundaries():
parts = split_string_by_bytes('你好hello', limit=7)
assert ''.join(parts) == '你好hello'
assert all(len(part.encode('utf-8')) <= 7 for part in parts)
@pytest.mark.asyncio
async def test_wecom_message_converter_maps_outbound_components():
adapter = make_adapter()
content = await WecomMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi'),
platform_message.Image(base64='data:image/png;base64,AAAA'),
platform_message.Voice(base64='data:audio/mp3;base64,BBBB'),
platform_message.File(name='doc.txt', base64='Q0NDQw=='),
platform_message.Quote(
id='origin',
origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]),
),
]
),
adapter.bot,
)
assert content[0] == {'type': 'text', 'content': 'hi'}
assert {'type': 'image', 'media_id': 'media-id'} in content
assert {'type': 'voice', 'media_id': 'media-id'} in content
assert {'type': 'file', 'media_id': 'media-id'} in content
assert {'type': 'text', 'content': '[Quote origin] '} in content
assert {'type': 'text', 'content': 'quoted'} in content
@pytest.mark.asyncio
async def test_wecom_event_converter_maps_text_message_to_eba_and_legacy():
adapter = make_adapter()
event = await WecomEventConverter.target2yiri(wecom_event(), adapter.bot)
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'wecom-eba'
assert event.chat_type == platform_entities.ChatType.PRIVATE
assert event.chat_id == 'user-1|1000002'
assert event.sender.nickname == 'Alice'
assert str(event.message_chain) == 'hello'
legacy = await WecomEventConverter.target2legacy(wecom_event(), adapter.bot)
assert isinstance(legacy, platform_events.FriendMessage)
assert legacy.sender.id == 'user-1'
assert str(legacy.message_chain) == 'hello'
@pytest.mark.asyncio
async def test_wecom_event_converter_maps_image_message_to_eba():
adapter = make_adapter()
with patch(
'langbot.pkg.platform.adapters.wecom.message_converter.image.get_wecom_image_base64',
AsyncMock(return_value=('AAAA', 'png')),
):
event = await WecomEventConverter.target2yiri(
wecom_event(msg_type='image', content=None, picurl='https://example.test/a.png'),
adapter.bot,
)
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'wecom-eba'
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'
@pytest.mark.asyncio
async def test_wecom_adapter_dispatches_and_caches_message_event():
adapter = make_adapter()
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, listener)
await adapter._handle_native_event(wecom_event())
assert len(calls) == 1
received = calls[0]
assert isinstance(received, platform_events.MessageReceivedEvent)
assert adapter.bot_account_id == 'corp-id'
assert received.chat_id == 'user-1|1000002'
assert await adapter.get_message('private', 'user-1|1000002', 12345) == received
assert (await adapter.get_user_info('user-1')).nickname == 'Alice'
@pytest.mark.asyncio
async def test_wecom_send_reply_and_platform_api_use_underlying_client():
adapter = make_adapter()
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'user-1|1000002', message)
adapter.bot.send_private_msg.assert_awaited_once_with('user-1', 1000002, 'hello')
source_event = await WecomEventConverter.target2yiri(wecom_event(), adapter.bot)
await adapter.reply_message(source_event, message)
assert adapter.bot.send_private_msg.await_count == 2
token_status = await adapter.call_platform_api('check_access_token', {})
user_info = await adapter.call_platform_api('get_user_info', {'user_id': 'user-1'})
sent_all = await adapter.call_platform_api('send_to_all', {'content': 'notice', 'agent_id': 1000002})
assert token_status == {'valid': True}
assert user_info['name'] == 'Alice'
assert sent_all == {'ok': True}
@@ -0,0 +1,274 @@
from __future__ import annotations
import pathlib
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyWecomBotWsClient:
def __init__(self, *args, **kwargs):
self.bot_id = kwargs['bot_id']
self.secret = kwargs['secret']
self.encoding_aes_key = kwargs.get('encoding_aes_key', '')
self._message_handlers = {}
self.connect = AsyncMock()
self.disconnect = AsyncMock()
self.send_message = AsyncMock(return_value={'ok': True})
self.reply_text = AsyncMock(return_value={'reply': True})
self.push_stream_chunk = AsyncMock(return_value=True)
self.set_message = AsyncMock(return_value={'set': True})
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
def on_feedback(self):
def decorator(func):
self._message_handlers.setdefault('feedback', []).append(func)
return func
return decorator
class DummyWecomBotClient(DummyWecomBotWsClient):
def __init__(self, *args, **kwargs):
self.Token = kwargs['Token']
self.EnCodingAESKey = kwargs['EnCodingAESKey']
self.Corpid = kwargs['Corpid']
self._message_handlers = {}
self.handle_unified_webhook = AsyncMock(return_value='success')
self.push_stream_chunk = AsyncMock(return_value=True)
self.set_message = AsyncMock(return_value={'set': True})
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'wecombot'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter:
config = {
'BotId': 'bot-id',
'robot_name': 'EBA Bot',
'enable-webhook': enable_webhook,
'Secret': 'secret',
'Token': 'token',
'EncodingAESKey': 'encoding-key',
'Corpid': 'corp-id',
'enable-stream-reply': True,
}
with (
patch('langbot.pkg.platform.adapters.wecombot.adapter.WecomBotWsClient', DummyWecomBotWsClient),
patch('langbot.pkg.platform.adapters.wecombot.adapter.WecomBotClient', DummyWecomBotClient),
):
return WecomBotAdapter(config, DummyLogger())
def wecombot_event(**overrides) -> WecomBotEvent:
event_type = overrides.get('type', 'single')
payload = {
'type': event_type,
'msgtype': overrides.get('msgtype', 'text'),
'msgid': overrides.get('message_id', 'msg-1'),
'userid': overrides.get('userid', 'user-1'),
'username': overrides.get('username', 'Alice'),
'content': overrides.get('content', 'hello'),
'aibotid': overrides.get('aibotid', 'bot-id'),
'req_id': overrides.get('req_id', 'req-1'),
'stream_id': overrides.get('stream_id', 'stream-1'),
}
if event_type == 'group':
payload.update({'chatid': overrides.get('chatid', 'group-1'), 'chatname': overrides.get('chatname', 'Group')})
if payload['msgtype'] == 'image':
payload['images'] = overrides.get('images', ['data:image/png;base64,AAAA'])
payload['content'] = overrides.get('content', '')
if payload['msgtype'] == 'file':
payload['file'] = overrides.get('file', {'download_url': 'https://example.test/a.txt', 'filename': 'a.txt'})
payload['content'] = overrides.get('content', '')
if payload['msgtype'] == 'voice':
payload['voice'] = overrides.get('voice', {'base64': 'BBBB'})
payload['content'] = overrides.get('content', '')
if 'quote' in overrides:
payload['quote'] = overrides['quote']
return WecomBotEvent(payload)
def test_wecombot_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_wecombot_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_wecombot_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text():
content = await WecomBotMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi'),
platform_message.At(target='user-1', display='Alice'),
platform_message.Image(base64='data:image/png;base64,AAAA'),
platform_message.File(name='a.txt', url='https://example.test/a.txt'),
platform_message.Quote(
id='origin',
origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]),
),
]
)
)
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
@pytest.mark.asyncio
async def test_wecombot_event_converter_maps_private_and_group_messages_to_eba():
private_event = await WecomBotEventConverter(bot_name='EBA Bot').target2yiri(
wecombot_event(content='@EBA Bot hello')
)
group_event = await WecomBotEventConverter(bot_name='EBA Bot').target2yiri(
wecombot_event(type='group', content='@EBA Bot group hello')
)
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.adapter_name == 'wecombot-eba'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-1'
assert str(private_event.message_chain) == 'hello'
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'group-1'
assert group_event.group.name == 'Group'
assert isinstance(group_event.message_chain[1], platform_message.At)
@pytest.mark.asyncio
async def test_wecombot_event_converter_maps_media_and_quote_components():
event = await WecomBotEventConverter().target2yiri(
wecombot_event(
msgtype='image',
quote={
'content': 'quoted',
'file': {'download_url': 'https://example.test/q.txt', 'filename': 'q.txt'},
},
)
)
assert isinstance(event, platform_events.MessageReceivedEvent)
assert any(isinstance(component, platform_message.Image) for component in event.message_chain)
quote = next(component for component in event.message_chain if isinstance(component, platform_message.Quote))
assert any(isinstance(component, platform_message.File) for component in quote.origin)
@pytest.mark.asyncio
async def test_wecombot_adapter_dispatches_eba_and_legacy_and_caches_message_event():
adapter = make_adapter()
eba_calls: list[platform_events.Event] = []
legacy_calls: list[platform_events.Event] = []
async def eba_listener(event, adapter):
eba_calls.append(event)
async def legacy_listener(event, adapter):
legacy_calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
adapter.register_listener(platform_events.FriendMessage, legacy_listener)
await adapter._handle_native_event(wecombot_event())
assert len(eba_calls) == 1
assert len(legacy_calls) == 1
received = eba_calls[0]
assert isinstance(received, platform_events.MessageReceivedEvent)
assert await adapter.get_message('private', 'user-1', 'msg-1') == received
assert (await adapter.get_user_info('user-1')).nickname == 'Alice'
@pytest.mark.asyncio
async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_client():
adapter = make_adapter()
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'user-1', message)
adapter.bot.send_message.assert_awaited_once_with('user-1', 'hello')
source_event = await WecomBotEventConverter().target2yiri(wecombot_event())
await adapter.reply_message(source_event, message)
adapter.bot.reply_text.assert_awaited_once_with('req-1', 'hello')
await adapter.reply_message_chunk(source_event, None, message, is_final=True)
adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True)
platform_status = await adapter.call_platform_api('is_websocket_mode', {})
assert platform_status == {'websocket': True}
feedback_calls: list[platform_events.Event] = []
async def feedback_listener(event, adapter):
feedback_calls.append(event)
adapter.register_listener(platform_events.FeedbackReceivedEvent, feedback_listener)
await adapter._handle_feedback(feedback_id='fb-1', feedback_type=1, inaccurate_reasons=[1, 2], session=None)
assert isinstance(feedback_calls[0], platform_events.FeedbackReceivedEvent)
assert feedback_calls[0].inaccurate_reasons == ['1', '2']
@pytest.mark.asyncio
async def test_wecombot_webhook_mode_rejects_proactive_send():
adapter = make_adapter(enable_webhook=True)
with pytest.raises(NotSupportedError):
await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')]))
@@ -0,0 +1,260 @@
from __future__ import annotations
import pathlib
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
from langbot.libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent
from langbot.pkg.platform.adapters.wecomcs.adapter import WecomCSAdapter
from langbot.pkg.platform.adapters.wecomcs.event_converter import WecomCSEventConverter
from langbot.pkg.platform.adapters.wecomcs.message_converter import WecomCSMessageConverter, split_string_by_bytes
from langbot.pkg.platform.adapters.wecomcs.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyWecomCSClient(WecomCSClient):
def __init__(self, *args, **kwargs):
self.corpid = kwargs['corpid']
self.secret = kwargs['secret']
self.token = kwargs['token']
self.aes = kwargs['EncodingAESKey']
self.base_url = kwargs.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin')
self.logger = kwargs.get('logger')
self.access_token = ''
self._message_handlers = {}
self.get_media_id = AsyncMock(return_value='media-id')
self.send_text_msg = AsyncMock(return_value={'msgid': 'sent-text'})
self.send_image_msg = AsyncMock(return_value={'msgid': 'sent-image'})
self.get_customer_info = AsyncMock(
return_value={'external_userid': 'external-1', 'nickname': 'Alice', 'avatar': 'https://example.test/a.png'}
)
self.check_access_token = AsyncMock(return_value=True)
self.get_access_token = AsyncMock(return_value='access-token')
self.handle_unified_webhook = AsyncMock(return_value='success')
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'wecomcs'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter() -> WecomCSAdapter:
config = {
'corpid': 'corp-id',
'secret': 'secret',
'token': 'token',
'EncodingAESKey': 'encoding-key',
'api_base_url': 'https://qyapi.weixin.qq.com/cgi-bin',
}
with patch('langbot.pkg.platform.adapters.wecomcs.adapter.WecomCSClient', DummyWecomCSClient):
return WecomCSAdapter(config, DummyLogger())
def wecomcs_event(**overrides) -> WecomCSEvent:
msgtype = overrides.get('msgtype', 'text')
payload = {
'msgtype': msgtype,
'msgid': overrides.get('message_id', 'msg-1'),
'external_userid': overrides.get('external_userid', 'external-1'),
'open_kfid': overrides.get('open_kfid', 'kf-1'),
'send_time': overrides.get('send_time', 1_714_000_000),
}
if msgtype == 'text':
payload['text'] = {'content': overrides.get('content', 'hello')}
if msgtype == 'image':
payload['image'] = {'media_id': overrides.get('media_id', 'media-id')}
payload['picurl'] = overrides.get('picurl', 'data:image/png;base64,AAAA')
if msgtype == 'file':
payload['file'] = {'media_id': 'file-id', 'filename': 'a.txt', 'file_size': 12}
if msgtype == 'voice':
payload['voice'] = {'media_id': 'voice-id'}
return WecomCSEvent.from_payload(payload)
def test_wecomcs_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_wecomcs_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_wecomcs_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
def test_wecomcs_split_string_by_bytes_keeps_multibyte_boundaries():
parts = split_string_by_bytes('你好hello', limit=7)
assert ''.join(parts) == '你好hello'
assert all(len(part.encode('utf-8')) <= 7 for part in parts)
@pytest.mark.asyncio
async def test_wecomcs_message_converter_maps_outbound_components():
adapter = make_adapter()
content = await WecomCSMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi'),
platform_message.Image(base64='data:image/png;base64,AAAA'),
platform_message.Quote(
id='origin',
origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]),
),
platform_message.At(target='external-2', display='Bob'),
platform_message.AtAll(),
]
),
adapter.bot,
)
assert content[0] == {'type': 'text', 'content': 'hi'}
assert {'type': 'image', 'media_id': 'media-id'} in content
assert {'type': 'text', 'content': '[Quote origin] '} in content
assert {'type': 'text', 'content': 'quoted'} in content
assert {'type': 'text', 'content': '@Bob'} in content
assert {'type': 'text', 'content': '@all'} in content
@pytest.mark.asyncio
async def test_wecomcs_message_converter_rejects_unsupported_outbound_media():
adapter = make_adapter()
with pytest.raises(NotSupportedError):
await WecomCSMessageConverter.yiri2target(
platform_message.MessageChain([platform_message.Voice(base64='BBBB')]),
adapter.bot,
)
@pytest.mark.asyncio
async def test_wecomcs_event_converter_maps_text_message_to_eba_and_legacy():
adapter = make_adapter()
event = await WecomCSEventConverter.target2yiri(wecomcs_event(), adapter.bot)
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'wecomcs-eba'
assert event.chat_type == platform_entities.ChatType.PRIVATE
assert event.chat_id == 'external-1|kf-1'
assert event.sender.nickname == 'Alice'
assert str(event.message_chain) == 'hello'
legacy = await WecomCSEventConverter.target2legacy(wecomcs_event(), adapter.bot)
assert isinstance(legacy, platform_events.FriendMessage)
assert legacy.sender.id == 'external-1'
assert str(legacy.message_chain) == 'hello'
@pytest.mark.asyncio
async def test_wecomcs_event_converter_maps_media_and_unknown_messages():
image_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='image'), make_adapter().bot)
file_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='file'), make_adapter().bot)
voice_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='voice'), make_adapter().bot)
unknown_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='event'), make_adapter().bot)
assert isinstance(image_event.message_chain[1], platform_message.Image)
assert image_event.message_chain[1].base64 == 'data:image/png;base64,AAAA'
assert isinstance(file_event.message_chain[1], platform_message.File)
assert isinstance(voice_event.message_chain[1], platform_message.Voice)
assert isinstance(unknown_event, platform_events.PlatformSpecificEvent)
assert unknown_event.action == 'wecomcs.event'
@pytest.mark.asyncio
async def test_wecomcs_adapter_dispatches_eba_and_legacy_and_caches_message_event():
adapter = make_adapter()
eba_calls: list[platform_events.Event] = []
legacy_calls: list[platform_events.Event] = []
async def eba_listener(event, adapter):
eba_calls.append(event)
async def legacy_listener(event, adapter):
legacy_calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
adapter.register_listener(platform_events.FriendMessage, legacy_listener)
await adapter._handle_native_event(wecomcs_event())
assert len(eba_calls) == 1
assert len(legacy_calls) == 1
received = eba_calls[0]
assert isinstance(received, platform_events.MessageReceivedEvent)
assert adapter.bot_account_id == 'kf-1'
assert await adapter.get_message('private', 'external-1|kf-1', 'msg-1') == received
assert (await adapter.get_user_info('external-1')).nickname == 'Alice'
await adapter._handle_native_event(wecomcs_event())
assert len(eba_calls) == 1
assert len(legacy_calls) == 1
@pytest.mark.asyncio
async def test_wecomcs_send_reply_and_platform_api_use_underlying_client():
adapter = make_adapter()
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'external-1|kf-1', message)
adapter.bot.send_text_msg.assert_awaited_once()
open_kfid, external_userid, msgid, content = adapter.bot.send_text_msg.await_args.args
assert (open_kfid, external_userid, content) == ('kf-1', 'external-1', 'hello')
assert msgid.startswith('lb-')
image = platform_message.MessageChain([platform_message.Image(base64='data:image/png;base64,AAAA')])
await adapter.send_message('person', 'external-1|kf-1', image)
adapter.bot.send_image_msg.assert_awaited_once()
source_event = await WecomCSEventConverter.target2yiri(wecomcs_event(), adapter.bot)
await adapter.reply_message(source_event, message)
open_kfid, external_userid, reply_msgid, content = adapter.bot.send_text_msg.await_args.args
assert (open_kfid, external_userid, content) == ('kf-1', 'external-1', 'hello')
assert reply_msgid.startswith('lb-')
token_status = await adapter.call_platform_api('check_access_token', {})
customer_info = await adapter.call_platform_api('get_customer_info', {'external_userid': 'external-1'})
assert token_status == {'valid': True}
assert customer_info['nickname'] == 'Alice'