mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-10 07:46:02 +00:00
feat: add discord eba adapter
This commit is contained in:
420
tests/e2e/live_discord_eba_probe.py
Normal file
420
tests/e2e/live_discord_eba_probe.py
Normal file
@@ -0,0 +1,420 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||
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 errors as platform_errors
|
||||
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 ProbeLogger(AbstractEventLogger):
|
||||
async def info(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[info] {text}')
|
||||
|
||||
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[debug] {text}')
|
||||
|
||||
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[warning] {text}')
|
||||
|
||||
async def error(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[error] {text}')
|
||||
|
||||
|
||||
PNG_1X1 = base64.b64decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
||||
)
|
||||
|
||||
|
||||
def summarize_event(event: platform_events.EBAEvent) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
'type': event.type,
|
||||
'adapter_name': event.adapter_name,
|
||||
'timestamp': event.timestamp,
|
||||
}
|
||||
for field in ('message_id', 'chat_id', 'chat_type', 'reaction', 'is_add', 'action', 'data'):
|
||||
if hasattr(event, field):
|
||||
value = getattr(event, field)
|
||||
data[field] = value.value if hasattr(value, 'value') else value
|
||||
for field in ('sender', 'user', 'member', 'group', 'operator', 'inviter'):
|
||||
if hasattr(event, field):
|
||||
value = getattr(event, field)
|
||||
if value is not None and hasattr(value, 'model_dump'):
|
||||
data[field] = value.model_dump()
|
||||
return data
|
||||
|
||||
|
||||
def chat_type_value(chat_type: platform_entities.ChatType | str) -> str:
|
||||
return chat_type.value if hasattr(chat_type, 'value') else str(chat_type)
|
||||
|
||||
|
||||
def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None):
|
||||
entry: dict[str, Any] = {'name': name, 'ok': ok}
|
||||
if result is not None:
|
||||
entry['result'] = result
|
||||
if error is not None:
|
||||
entry['error'] = repr(error)
|
||||
results.append(entry)
|
||||
print('DISCORD_EBA_API', json.dumps(entry, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
async def run_api(results: list[dict[str, Any]], name: str, func):
|
||||
try:
|
||||
result = await func()
|
||||
record_api(results, name, True, result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
record_api(results, name, False, error=exc)
|
||||
return None
|
||||
|
||||
|
||||
async def run_expected_error(results: list[dict[str, Any]], name: str, func, error_type: type[Exception]):
|
||||
try:
|
||||
await func()
|
||||
except Exception as exc:
|
||||
if isinstance(exc, error_type):
|
||||
record_api(results, name, True, {'expected_error': type(exc).__name__})
|
||||
return
|
||||
record_api(results, name, False, error=exc)
|
||||
return
|
||||
record_api(results, name, False, error=RuntimeError(f'expected {error_type.__name__}'))
|
||||
|
||||
|
||||
async def wait_for_event(events: list[platform_events.EBAEvent], predicate, timeout: int) -> platform_events.EBAEvent:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
seen = 0
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
for event in events[seen:]:
|
||||
if predicate(event):
|
||||
return event
|
||||
seen = len(events)
|
||||
await asyncio.sleep(0.2)
|
||||
raise TimeoutError('event was not observed before timeout')
|
||||
|
||||
|
||||
async def run_probe(
|
||||
token: str,
|
||||
client_id: str,
|
||||
channel_id: str,
|
||||
log_path: Path,
|
||||
timeout: int,
|
||||
guild_id: str | None,
|
||||
moderation_user_id: str | None,
|
||||
kick_user_id: str | None,
|
||||
allow_invite: bool,
|
||||
allow_leave_group: bool,
|
||||
):
|
||||
adapter = DiscordAdapter({'client_id': client_id, 'token': token}, ProbeLogger())
|
||||
events: list[platform_events.EBAEvent] = []
|
||||
api_results: list[dict[str, Any]] = []
|
||||
first_message = asyncio.Event()
|
||||
|
||||
async def listener(event, adapter):
|
||||
events.append(event)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log_path.open('a', encoding='utf-8') as fp:
|
||||
fp.write(json.dumps(summarize_event(event), ensure_ascii=False, default=str) + '\n')
|
||||
print('DISCORD_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False, default=str))
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
first_message.set()
|
||||
|
||||
adapter.register_listener(platform_events.EBAEvent, listener)
|
||||
run_task = asyncio.create_task(adapter.run_async())
|
||||
|
||||
try:
|
||||
print('READY: send a message in the Discord test channel now.')
|
||||
await asyncio.wait_for(first_message.wait(), timeout=timeout)
|
||||
source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent))
|
||||
source_chat_type = chat_type_value(source.chat_type)
|
||||
target_chat_id = str(source.chat_id)
|
||||
guild_id = guild_id or (str(source.group.id) if source.group else None)
|
||||
actor_user_id = str(source.sender.id)
|
||||
|
||||
await run_api(
|
||||
api_results,
|
||||
'reply_message:text_image_file',
|
||||
lambda: adapter.reply_message(
|
||||
source,
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='Discord EBA live reply: text'),
|
||||
platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()),
|
||||
platform_message.File(
|
||||
name='discord-eba-live.txt',
|
||||
size=16,
|
||||
base64='data:text/plain;base64,' + base64.b64encode(b'discord-eba-live').decode(),
|
||||
),
|
||||
]
|
||||
),
|
||||
quote_origin=True,
|
||||
),
|
||||
)
|
||||
sent = await run_api(
|
||||
api_results,
|
||||
'send_message:text_image_file',
|
||||
lambda: adapter.send_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='Discord EBA live send_message OK'),
|
||||
platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()),
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
edit_probe = await run_api(
|
||||
api_results,
|
||||
'send_message:edit_delete_probe',
|
||||
lambda: adapter.send_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
platform_message.MessageChain([platform_message.Plain(text='Discord EBA edit/delete probe')]),
|
||||
),
|
||||
)
|
||||
if edit_probe:
|
||||
await run_api(
|
||||
api_results,
|
||||
'edit_message',
|
||||
lambda: adapter.edit_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
edit_probe.message_id,
|
||||
platform_message.MessageChain([platform_message.Plain(text='Discord EBA edit probe edited')]),
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'delete_message',
|
||||
lambda: adapter.delete_message(source_chat_type, target_chat_id, edit_probe.message_id),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'event_observed:message.edited',
|
||||
lambda: wait_for_event(
|
||||
events,
|
||||
lambda event: isinstance(event, platform_events.MessageEditedEvent)
|
||||
and str(event.message_id) == str(edit_probe.message_id),
|
||||
timeout=max(10, timeout // 3),
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'event_observed:message.deleted',
|
||||
lambda: wait_for_event(
|
||||
events,
|
||||
lambda event: isinstance(event, platform_events.MessageDeletedEvent)
|
||||
and str(event.message_id) == str(edit_probe.message_id),
|
||||
timeout=max(10, timeout // 3),
|
||||
),
|
||||
)
|
||||
|
||||
if sent:
|
||||
await run_api(
|
||||
api_results,
|
||||
'forward_message',
|
||||
lambda: adapter.forward_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
sent.message_id,
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:add_reaction',
|
||||
lambda: adapter.call_platform_api(
|
||||
'add_reaction',
|
||||
{'channel_id': target_chat_id, 'message_id': sent.message_id, 'emoji': '👍'},
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:remove_reaction',
|
||||
lambda: adapter.call_platform_api(
|
||||
'remove_reaction',
|
||||
{'channel_id': target_chat_id, 'message_id': sent.message_id, 'emoji': '👍'},
|
||||
),
|
||||
)
|
||||
|
||||
await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(actor_user_id))
|
||||
await run_expected_error(
|
||||
api_results,
|
||||
'upload_file:not_supported',
|
||||
lambda: adapter.upload_file(b'discord-eba-upload', 'discord-eba-upload.txt'),
|
||||
platform_errors.NotSupportedError,
|
||||
)
|
||||
await run_api(api_results, 'get_file_url', lambda: adapter.get_file_url('https://cdn.discordapp.com/file.txt'))
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_channel',
|
||||
lambda: adapter.call_platform_api('get_channel', {'channel_id': target_chat_id}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:typing',
|
||||
lambda: adapter.call_platform_api('typing', {'channel_id': target_chat_id}),
|
||||
)
|
||||
|
||||
pin_probe = await run_api(
|
||||
api_results,
|
||||
'send_message:pin_probe',
|
||||
lambda: adapter.send_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
platform_message.MessageChain([platform_message.Plain(text='Discord EBA pin probe')]),
|
||||
),
|
||||
)
|
||||
if pin_probe:
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:pin_message',
|
||||
lambda: adapter.call_platform_api(
|
||||
'pin_message',
|
||||
{'channel_id': target_chat_id, 'message_id': pin_probe.message_id},
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:unpin_message',
|
||||
lambda: adapter.call_platform_api(
|
||||
'unpin_message',
|
||||
{'channel_id': target_chat_id, 'message_id': pin_probe.message_id},
|
||||
),
|
||||
)
|
||||
|
||||
if guild_id:
|
||||
await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(guild_id))
|
||||
await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(guild_id))
|
||||
await run_api(
|
||||
api_results,
|
||||
'get_group_member_info',
|
||||
lambda: adapter.get_group_member_info(guild_id, actor_user_id),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_guild',
|
||||
lambda: adapter.call_platform_api('get_guild', {'guild_id': guild_id}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_guild_channels',
|
||||
lambda: adapter.call_platform_api('get_guild_channels', {'guild_id': guild_id}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_guild_roles',
|
||||
lambda: adapter.call_platform_api('get_guild_roles', {'guild_id': guild_id}),
|
||||
)
|
||||
if allow_invite:
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:create_invite',
|
||||
lambda: adapter.call_platform_api('create_invite', {'channel_id': target_chat_id, 'max_age': 300}),
|
||||
)
|
||||
else:
|
||||
record_api(api_results, 'call_platform_api:create_invite', False, error=RuntimeError('skipped'))
|
||||
|
||||
if moderation_user_id:
|
||||
await run_api(
|
||||
api_results,
|
||||
'mute_member',
|
||||
lambda: adapter.mute_member(guild_id, moderation_user_id, duration=30),
|
||||
)
|
||||
await run_api(api_results, 'unmute_member', lambda: adapter.unmute_member(guild_id, moderation_user_id))
|
||||
else:
|
||||
record_api(api_results, 'mute_member', False, error=RuntimeError('skipped'))
|
||||
record_api(api_results, 'unmute_member', False, error=RuntimeError('skipped'))
|
||||
|
||||
if kick_user_id:
|
||||
await run_api(api_results, 'kick_member', lambda: adapter.kick_member(guild_id, kick_user_id))
|
||||
else:
|
||||
record_api(api_results, 'kick_member', False, error=RuntimeError('skipped'))
|
||||
|
||||
if allow_leave_group:
|
||||
await run_api(api_results, 'leave_group', lambda: adapter.leave_group(guild_id))
|
||||
else:
|
||||
record_api(api_results, 'leave_group', False, error=RuntimeError('skipped'))
|
||||
else:
|
||||
for name in (
|
||||
'get_group_info',
|
||||
'get_group_member_list',
|
||||
'get_group_member_info',
|
||||
'call_platform_api:get_guild',
|
||||
'call_platform_api:get_guild_channels',
|
||||
'call_platform_api:get_guild_roles',
|
||||
'call_platform_api:create_invite',
|
||||
'mute_member',
|
||||
'unmute_member',
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
):
|
||||
record_api(api_results, name, False, error=RuntimeError('skipped: no guild id'))
|
||||
|
||||
await asyncio.sleep(3)
|
||||
finally:
|
||||
await adapter.kill()
|
||||
run_task.cancel()
|
||||
summary = {
|
||||
'events': [summarize_event(event) for event in events],
|
||||
'event_types': [event.type for event in events],
|
||||
'api_results': api_results,
|
||||
'api_passed': [result['name'] for result in api_results if result['ok']],
|
||||
'api_failed': [result for result in api_results if not result['ok']],
|
||||
}
|
||||
print('SUMMARY', json.dumps(summary, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--token', default=os.getenv('DISCORD_BOT_TOKEN', ''))
|
||||
parser.add_argument('--client-id', default=os.getenv('DISCORD_CLIENT_ID', ''))
|
||||
parser.add_argument('--channel-id', default=os.getenv('DISCORD_EBA_CHANNEL_ID', ''))
|
||||
parser.add_argument('--guild-id', default=os.getenv('DISCORD_EBA_GUILD_ID'))
|
||||
parser.add_argument('--moderation-user-id', default=os.getenv('DISCORD_EBA_MODERATION_USER_ID'))
|
||||
parser.add_argument('--kick-user-id', default=os.getenv('DISCORD_EBA_KICK_USER_ID'))
|
||||
parser.add_argument('--log', default='data/temp/live_discord_eba_probe.jsonl')
|
||||
parser.add_argument('--timeout', type=int, default=90)
|
||||
parser.add_argument('--allow-invite', action='store_true')
|
||||
parser.add_argument('--allow-leave-group', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.token:
|
||||
raise SystemExit('Set DISCORD_BOT_TOKEN or pass --token')
|
||||
if not args.client_id:
|
||||
raise SystemExit('Set DISCORD_CLIENT_ID or pass --client-id')
|
||||
if not args.channel_id:
|
||||
raise SystemExit('Set DISCORD_EBA_CHANNEL_ID or pass --channel-id')
|
||||
|
||||
log_path = Path(args.log)
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
asyncio.run(
|
||||
run_probe(
|
||||
args.token,
|
||||
args.client_id,
|
||||
args.channel_id,
|
||||
log_path,
|
||||
args.timeout,
|
||||
args.guild_id,
|
||||
args.moderation_user_id,
|
||||
args.kick_user_id,
|
||||
args.allow_invite,
|
||||
args.allow_leave_group,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
283
tests/unit_tests/platform/test_discord_eba_adapter.py
Normal file
283
tests/unit_tests/platform/test_discord_eba_adapter.py
Normal 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
|
||||
Reference in New Issue
Block a user