mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-24 06:24:20 +00:00
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:
@@ -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 '' 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'
|
||||
Reference in New Issue
Block a user