From 57f2e85388542e5811d9b33711fb3f74008cf541 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 23:05:38 +0800 Subject: [PATCH] feat: add discord eba adapter --- docs/event-based-agents/adapters/00-index.md | 2 +- docs/event-based-agents/adapters/discord.md | 76 ++-- pyproject.toml | 3 +- .../pkg/platform/adapters/discord/__init__.py | 5 + .../pkg/platform/adapters/discord/adapter.py | 253 +++++++++++ .../pkg/platform/adapters/discord/api_impl.py | 153 +++++++ .../pkg/platform/adapters/discord/discord.svg | 7 + .../adapters/discord/event_converter.py | 296 ++++++++++++ .../platform/adapters/discord/manifest.yaml | 89 ++++ .../adapters/discord/message_converter.py | 162 +++++++ .../platform/adapters/discord/platform_api.py | 95 ++++ .../pkg/platform/adapters/discord/types.py | 12 + .../pkg/platform/adapters/discord/voice.py | 5 + .../platform/adapters/telegram/telegram.svg | 1 + tests/e2e/live_discord_eba_probe.py | 420 ++++++++++++++++++ .../platform/test_discord_eba_adapter.py | 283 ++++++++++++ 16 files changed, 1827 insertions(+), 35 deletions(-) create mode 100644 src/langbot/pkg/platform/adapters/discord/__init__.py create mode 100644 src/langbot/pkg/platform/adapters/discord/adapter.py create mode 100644 src/langbot/pkg/platform/adapters/discord/api_impl.py create mode 100644 src/langbot/pkg/platform/adapters/discord/discord.svg create mode 100644 src/langbot/pkg/platform/adapters/discord/event_converter.py create mode 100644 src/langbot/pkg/platform/adapters/discord/manifest.yaml create mode 100644 src/langbot/pkg/platform/adapters/discord/message_converter.py create mode 100644 src/langbot/pkg/platform/adapters/discord/platform_api.py create mode 100644 src/langbot/pkg/platform/adapters/discord/types.py create mode 100644 src/langbot/pkg/platform/adapters/discord/voice.py create mode 100644 src/langbot/pkg/platform/adapters/telegram/telegram.svg create mode 100644 tests/e2e/live_discord_eba_probe.py create mode 100644 tests/unit_tests/platform/test_discord_eba_adapter.py diff --git a/docs/event-based-agents/adapters/00-index.md b/docs/event-based-agents/adapters/00-index.md index 24417b0a..0ce72291 100644 --- a/docs/event-based-agents/adapters/00-index.md +++ b/docs/event-based-agents/adapters/00-index.md @@ -12,7 +12,7 @@ This directory records adapter-level migration details for the Event-Based Agent | Adapter | Status | Document | |---------|--------|----------| | Telegram | Migrated and live-tested | [Telegram](./telegram.md) | -| Discord | In progress | [Discord](./discord.md) | +| Discord | Migrated and live-tested | [Discord](./discord.md) | ## Documentation Checklist diff --git a/docs/event-based-agents/adapters/discord.md b/docs/event-based-agents/adapters/discord.md index 971dbd57..ea78c085 100644 --- a/docs/event-based-agents/adapters/discord.md +++ b/docs/event-based-agents/adapters/discord.md @@ -2,14 +2,14 @@ ## Status -Discord is currently being migrated from the legacy source adapter: +Discord has been migrated from the legacy source adapter: ```text src/langbot/pkg/platform/sources/discord.py src/langbot/pkg/platform/sources/discord.yaml ``` -Target EBA directory: +EBA adapter directory: ```text src/langbot/pkg/platform/adapters/discord/ @@ -23,6 +23,8 @@ src/langbot/pkg/platform/adapters/discord/ └── voice.py ``` +The adapter is registered as `discord-eba`. + ## Configuration | Field | Required | Default | Description | @@ -30,11 +32,11 @@ src/langbot/pkg/platform/adapters/discord/ | `client_id` | Yes | `""` | Discord application client ID. | | `token` | Yes | `""` | Discord bot token. | -The bot needs gateway permissions and intents for the target test server. Message content intent is required for message-based EBA events. +The bot needs gateway permissions and intents for the target test server. Message Content intent is required for message bodies, Server Members intent is required for member APIs/events, and reaction events require the Reactions intent and channel permissions. -## Planned Events +## Events -Initial EBA migration should support: +Discord declares these EBA events: - `message.received` - `message.edited` @@ -42,36 +44,37 @@ Initial EBA migration should support: - `message.reaction` - `group.member_joined` - `group.member_left` +- `group.member_banned` - `bot.invited_to_group` - `bot.removed_from_group` - `platform.specific` Discord-specific events that do not map cleanly to common events should be surfaced as `platform.specific`. -## Planned Common APIs +## Common APIs -| API | Expected Status | Notes | +| API | Status | Notes | |-----|-----------------|-------| -| `send_message` | Supported | Text plus attachment files. | -| `reply_message` | Supported | Uses Discord message references. | -| `edit_message` | Supported | Bot can edit its own messages. | +| `send_message` | Supported | Supports text, image, file, and mixed message chains through Discord messages and attachments. | +| `reply_message` | Supported | Uses Discord message references when replying to a received EBA message event. | +| `edit_message` | Supported | Bot can edit its own messages. File edits are implemented by clearing old attachments and sending replacement files when needed. | | `delete_message` | Supported | Requires message management permissions for non-bot messages. | -| `forward_message` | Emulated | Discord has no native forward API; copy content and attachments when possible. | -| `get_group_info` | Supported | Maps Discord guild/channel metadata into EBA group info depending on target ID. | -| `get_group_member_list` | Supported | Requires member cache or guild member fetch permissions. | +| `forward_message` | Emulated | Discord has no native forward API; the adapter copies content and attachments. | +| `get_group_info` | Supported | Maps Discord guild metadata to EBA group info. | +| `get_group_member_list` | Supported | Requires member cache or the Server Members intent/fetch permission. | | `get_group_member_info` | Supported | Maps Discord roles/permissions into EBA member roles. | | `get_user_info` | Supported | Uses Discord user fetch/cache. | -| `upload_file` | Not standalone | Discord uploads files as message attachments; standalone upload should raise `NotSupportedError` unless a storage-backed design is added. | -| `get_file_url` | Supported for attachment URLs | Discord attachment URLs are already downloadable URLs. | -| `mute_member` | Supported where possible | Prefer Discord timeout API for guild members. | -| `unmute_member` | Supported where possible | Clears timeout. | +| `upload_file` | Not supported | Discord uploads files as message attachments; standalone upload raises `NotSupportedError`. | +| `get_file_url` | Supported | Discord attachment URLs are already downloadable URLs, so the adapter returns the input URL. | +| `mute_member` | Supported where possible | Uses Discord timeout API and requires guild moderation permission. | +| `unmute_member` | Supported where possible | Clears timeout and requires guild moderation permission. | | `kick_member` | Supported | Destructive; test only with a disposable account/bot. | | `leave_group` | Supported | Bot leaves a guild; destructive and should run last. | | `call_platform_api` | Supported | Discord-specific actions live here. | -## Planned Platform-Specific APIs +## Platform-Specific APIs -Initial actions to expose through `call_platform_api`: +`call_platform_api(action, params)` supports: - `get_channel` - `get_guild` @@ -84,7 +87,7 @@ Initial actions to expose through `call_platform_api`: - `remove_reaction` - `typing` -Voice actions should stay Discord-specific: +Voice helpers are intentionally kept Discord-specific: - `join_voice_channel` - `leave_voice_channel` @@ -92,17 +95,26 @@ Voice actions should stay Discord-specific: - `list_active_voice_connections` - `get_voice_channel_info` -## Live Test Plan +## Live Test Record -Use the LangBot Discord server debug channel for end-to-end verification: +The live probe is: -1. Create or reuse a Discord bot application. -2. Invite it to the LangBot server with message, member, reaction, and moderation permissions needed by the test. -3. Run the Discord EBA adapter in standalone test mode. -4. Send a message in the debug channel and verify `message.received`. -5. Verify send/reply/edit/delete/forward message APIs. -6. Verify attachment/file URL behavior. -7. Verify guild/channel/member info APIs. -8. Verify platform-specific APIs such as typing, pin/unpin, invite, reaction. -9. Verify moderation APIs against a disposable target member if available. -10. Run destructive `leave_group` only at the very end or skip it when preserving the server membership matters. +```bash +uv run python tests/e2e/live_discord_eba_probe.py --help +``` + +Verified on May 7, 2026 with a newly created Discord application/bot named `LangBot EBA Test 0507`, the LangBot Discord server, and the `#🐞-debugging` channel: + +- SDK standalone runtime started with WebSocket control/debug ports, and the `EBAEventProbe` plugin connected through `lbp run`. +- Plugin runtime received real Discord events through LangBot: `BotInvitedToGroup`, `MessageReceived`, `MessageReactionReceived` add/remove, `MessageEdited`, and `MessageDeleted`. +- Plugin runtime API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage APIs, workspace storage APIs, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`. +- Direct live adapter probe observed `message.received`, `message.edited`, `message.deleted`, and `bot.removed_from_group`. +- Message APIs verified: send, reply, edit, delete, forward, text/image/file mixed message chains. +- User and guild APIs verified: `get_user_info`, `get_group_info`, `get_group_member_list`, `get_group_member_info`. +- Platform-specific APIs verified: `get_channel`, `get_guild`, `get_guild_channels`, `get_guild_roles`, `create_invite`, `typing`, `pin_message`, `unpin_message`, `add_reaction`, `remove_reaction`. +- Unsupported API behavior verified: `upload_file` raises `NotSupportedError`. +- Destructive API verified at the end: `leave_group`, which emitted `bot.removed_from_group`. + +Not verified in the shared LangBot server live run: `mute_member`, `unmute_member`, and `kick_member`, because the run did not use a disposable target member. They are implemented through Discord timeout/kick APIs and should only be exercised against a disposable account or bot. + +The test fixed one real test-fixture issue: `EBAEventProbe` previously assumed `get_bots()` returned UUID strings. The current standalone runtime returns bot dictionaries, so the probe now selects an enabled bot dictionary and passes its `uuid` to `get_bot_info` and `send_message`. The probe also now subscribes to `MessageDeleted`. diff --git a/pyproject.toml b/pyproject.toml index 62cdd473..026f3776 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] -package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] } +package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] } [dependency-groups] dev = [ @@ -221,4 +221,3 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" - diff --git a/src/langbot/pkg/platform/adapters/discord/__init__.py b/src/langbot/pkg/platform/adapters/discord/__init__.py new file mode 100644 index 00000000..233ca239 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter + +__all__ = ['DiscordAdapter'] diff --git a/src/langbot/pkg/platform/adapters/discord/adapter.py b/src/langbot/pkg/platform/adapters/discord/adapter.py new file mode 100644 index 00000000..6295145f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/adapter.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import os +import traceback +import typing + +import discord +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin +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.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: discord.Client = pydantic.Field(exclude=True) + + message_converter: DiscordMessageConverter = DiscordMessageConverter() + event_converter: DiscordEventConverter = DiscordEventConverter() + + config: dict + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + adapter_self = self + + class LangBotDiscordClient(discord.Client): + async def on_ready(self: discord.Client): + adapter_self.bot_account_id = str(self.user.id) if self.user else '' + await adapter_self.logger.info(f'Discord adapter running as {self.user}') + + async def on_message(self: discord.Client, message: discord.Message): + if self.user and message.author.id == self.user.id: + return + if message.author.bot: + return + try: + if ( + platform_events.FriendMessage in adapter_self.listeners + or platform_events.GroupMessage in adapter_self.listeners + ): + legacy_event = await adapter_self.event_converter.target2legacy(message) + callback = adapter_self.listeners.get(type(legacy_event)) + if callback: + await callback(legacy_event, adapter_self) + + eba_event = await adapter_self.event_converter.target2yiri( + message, self.user.id if self.user else None + ) + if eba_event: + await adapter_self._dispatch_eba_event(eba_event) + except Exception: + await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}') + + async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message): + await adapter_self._dispatch_gateway_tuple( + 'message_edit', (before, after), self.user.id if self.user else None + ) + + async def on_message_delete(self: discord.Client, message: discord.Message): + await adapter_self._dispatch_gateway_tuple( + 'message_delete', message, self.user.id if self.user else None + ) + + async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent): + await adapter_self._dispatch_gateway_tuple( + 'raw_message_delete', + payload, + self.user.id if self.user else None, + ) + + async def on_reaction_add( + self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member + ): + if self.user and user.id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'reaction_add', (reaction, user), self.user.id if self.user else None + ) + + async def on_reaction_remove( + self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member + ): + if self.user and user.id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'reaction_remove', (reaction, user), self.user.id if self.user else None + ) + + async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent): + if self.user and payload.user_id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'raw_reaction_add', + payload, + self.user.id if self.user else None, + ) + + async def on_raw_reaction_remove(self: discord.Client, payload: discord.RawReactionActionEvent): + if self.user and payload.user_id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'raw_reaction_remove', + payload, + self.user.id if self.user else None, + ) + + async def on_member_join(self: discord.Client, member: discord.Member): + await adapter_self._dispatch_gateway_tuple('member_join', member, self.user.id if self.user else None) + + async def on_member_remove(self: discord.Client, member: discord.Member): + await adapter_self._dispatch_gateway_tuple('member_remove', member, self.user.id if self.user else None) + + async def on_guild_join(self: discord.Client, guild: discord.Guild): + await adapter_self._dispatch_gateway_tuple('guild_join', guild, self.user.id if self.user else None) + + async def on_guild_remove(self: discord.Client, guild: discord.Guild): + await adapter_self._dispatch_gateway_tuple('guild_remove', guild, self.user.id if self.user else None) + + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + intents.reactions = True + + args = {} + if os.getenv('http_proxy'): + args['proxy'] = os.getenv('http_proxy') + bot = LangBotDiscordClient(intents=intents, **args) + + super().__init__( + config=config, + logger=logger, + bot_account_id=config.get('client_id', ''), + listeners={}, + bot=bot, + ) + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'message.edited', + 'message.deleted', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'bot.invited_to_group', + 'bot.removed_from_group', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'edit_message', + 'delete_message', + 'forward_message', + 'get_group_info', + 'get_group_member_list', + 'get_group_member_info', + 'get_user_info', + 'get_file_url', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + 'call_platform_api', + ] + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + content, files = await self.message_converter.yiri2target(message) + channel = await self._get_channel(target_id) + kwargs = {'content': content} + if files: + kwargs['files'] = files + sent = await channel.send(**kwargs) + return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + assert isinstance(message_source.source_platform_object, discord.Message) + content, files = await self.message_converter.yiri2target(message) + kwargs = {'content': content} + if files: + kwargs['files'] = files + if quote_origin: + kwargs['reference'] = message_source.source_platform_object + kwargs['mention_author'] = any(isinstance(component, platform_message.At) for component in message.root) + sent = await message_source.source_platform_object.channel.send(**kwargs) + return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id}) + + async def _dispatch_gateway_tuple(self, kind: str, payload, bot_user_id: int | None): + try: + event = await self.event_converter.target2yiri((kind, payload), bot_user_id) + if event: + await self._dispatch_eba_event(event) + except Exception: + await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners.pop(event_type, None) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + async def run_async(self): + await self.bot.start(self.config['token'], reconnect=True) + + async def kill(self) -> bool: + await self.bot.close() + return True diff --git a/src/langbot/pkg/platform/adapters/discord/api_impl.py b/src/langbot/pkg/platform/adapters/discord/api_impl.py new file mode 100644 index 00000000..7a308ec9 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/api_impl.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import datetime +import typing + +import discord + +from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter +from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter +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 DiscordAPIMixin: + bot: discord.Client + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + channel = await self._get_channel(chat_id) + message = await channel.fetch_message(int(message_id)) + content, files = await DiscordMessageConverter.yiri2target(new_content) + if files: + await message.edit(content=content, attachments=[]) + await channel.send(content=content, files=files) + return + await message.edit(content=content) + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + channel = await self._get_channel(chat_id) + message = await channel.fetch_message(int(message_id)) + await message.delete() + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + from_channel = await self._get_channel(from_chat_id) + to_channel = await self._get_channel(to_chat_id) + message = await from_channel.fetch_message(int(message_id)) + files = [await attachment.to_file() for attachment in message.attachments] + sent = await to_channel.send(content=message.content, files=files) + return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id}) + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + guild = await self._get_guild(group_id) + return DiscordEventConverter.group_from_guild(guild) + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + guild = await self._get_guild(group_id) + members = guild.members or [member async for member in guild.fetch_members(limit=None)] + return [self._member_to_entity(member) for member in members] + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + return self._member_to_entity(member) + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user(int(user_id)) + return DiscordEventConverter.user_from_author(user) + + async def upload_file(self, file_data: bytes, filename: str) -> str: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + return file_id + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + until = None + if duration > 0: + until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration) + await member.timeout(until, reason='LangBot EBA mute_member') + + async def unmute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + await member.timeout(None, reason='LangBot EBA unmute_member') + + async def kick_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + await member.kick(reason='LangBot EBA kick_member') + + async def leave_group(self, group_id: typing.Union[int, str]) -> None: + guild = await self._get_guild(group_id) + await guild.leave() + + async def _get_channel(self, channel_id: typing.Union[int, str]) -> discord.abc.Messageable: + channel = self.bot.get_channel(int(channel_id)) + if channel is None: + channel = await self.bot.fetch_channel(int(channel_id)) + return channel + + async def _get_guild(self, guild_id: typing.Union[int, str]) -> discord.Guild: + guild = self.bot.get_guild(int(guild_id)) + if guild is None: + guild = await self.bot.fetch_guild(int(guild_id)) + return guild + + @staticmethod + def _member_to_entity(member: discord.Member) -> platform_entities.UserGroupMember: + role = platform_entities.MemberRole.MEMBER + if member.guild.owner_id == member.id: + role = platform_entities.MemberRole.OWNER + elif member.guild_permissions.administrator or member.guild_permissions.manage_guild: + role = platform_entities.MemberRole.ADMIN + return platform_entities.UserGroupMember( + user=DiscordEventConverter.user_from_author(member), + group_id=member.guild.id, + role=role, + display_name=member.display_name, + joined_at=member.joined_at.timestamp() if member.joined_at else None, + title=member.top_role.name if member.top_role else None, + ) diff --git a/src/langbot/pkg/platform/adapters/discord/discord.svg b/src/langbot/pkg/platform/adapters/discord/discord.svg new file mode 100644 index 00000000..177a0591 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/discord.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/langbot/pkg/platform/adapters/discord/event_converter.py b/src/langbot/pkg/platform/adapters/discord/event_converter.py new file mode 100644 index 00000000..045488a4 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/event_converter.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import typing + +import discord + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event) -> discord.Message: + raise NotImplementedError + + @staticmethod + async def target2yiri(event: typing.Any, bot_user_id: int | None = None) -> platform_events.Event | None: + if isinstance(event, discord.Message): + return await DiscordEventConverter.message_to_eba(event) + if isinstance(event, tuple) and len(event) == 2: + kind, payload = event + if kind == 'message_edit': + before, after = payload + return await DiscordEventConverter.message_edit_to_eba(before, after) + if kind == 'message_delete': + return await DiscordEventConverter.message_delete_to_eba(payload) + if kind == 'raw_message_delete': + return DiscordEventConverter.raw_message_delete_to_eba(payload) + if kind == 'reaction_add': + reaction, user = payload + return DiscordEventConverter.reaction_to_eba(reaction, user, True) + if kind == 'reaction_remove': + reaction, user = payload + return DiscordEventConverter.reaction_to_eba(reaction, user, False) + if kind == 'raw_reaction_add': + return DiscordEventConverter.raw_reaction_to_eba(payload, True) + if kind == 'raw_reaction_remove': + return DiscordEventConverter.raw_reaction_to_eba(payload, False) + if kind == 'member_join': + return DiscordEventConverter.member_join_to_eba(payload, bot_user_id) + if kind == 'member_remove': + return DiscordEventConverter.member_left_to_eba(payload, bot_user_id) + if kind == 'guild_join': + return DiscordEventConverter.guild_join_to_eba(payload) + if kind == 'guild_remove': + return DiscordEventConverter.guild_remove_to_eba(payload) + return None + + @staticmethod + async def message_to_eba(message: discord.Message) -> platform_events.MessageReceivedEvent: + message_chain = await DiscordMessageConverter.target2yiri(message) + group = DiscordEventConverter.group_from_message(message) + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name='discord', + message_id=message.id, + message_chain=message_chain, + sender=DiscordEventConverter.user_from_author(message.author), + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(message.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=message.channel.id, + group=group, + timestamp=message.created_at.timestamp(), + source_platform_object=message, + ) + + @staticmethod + async def message_edit_to_eba( + before: discord.Message, after: discord.Message + ) -> platform_events.MessageEditedEvent: + return platform_events.MessageEditedEvent( + type='message.edited', + adapter_name='discord', + message_id=after.id, + new_content=await DiscordMessageConverter.target2yiri(after), + editor=DiscordEventConverter.user_from_author(after.author), + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(after.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=after.channel.id, + group=DiscordEventConverter.group_from_message(after), + timestamp=after.edited_at.timestamp() if after.edited_at else after.created_at.timestamp(), + source_platform_object=after, + ) + + @staticmethod + async def message_delete_to_eba(message: discord.Message) -> platform_events.MessageDeletedEvent: + return platform_events.MessageDeletedEvent( + type='message.deleted', + adapter_name='discord', + message_id=message.id, + operator=None, + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(message.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=message.channel.id, + group=DiscordEventConverter.group_from_message(message), + timestamp=message.created_at.timestamp() if message.created_at else 0.0, + source_platform_object=message, + ) + + @staticmethod + def raw_message_delete_to_eba(payload: discord.RawMessageDeleteEvent) -> platform_events.MessageDeletedEvent: + return platform_events.MessageDeletedEvent( + type='message.deleted', + adapter_name='discord', + message_id=payload.message_id, + operator=None, + chat_type=platform_entities.ChatType.PRIVATE + if payload.guild_id is None + else platform_entities.ChatType.GROUP, + chat_id=payload.channel_id, + group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None, + source_platform_object=payload, + ) + + @staticmethod + def reaction_to_eba( + reaction: discord.Reaction, + user: discord.User | discord.Member, + is_add: bool, + ) -> platform_events.MessageReactionEvent: + message = reaction.message + return platform_events.MessageReactionEvent( + type='message.reaction', + adapter_name='discord', + message_id=message.id, + user=DiscordEventConverter.user_from_author(user), + reaction=str(reaction.emoji), + is_add=is_add, + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(message.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=message.channel.id, + group=DiscordEventConverter.group_from_message(message), + source_platform_object=reaction, + ) + + @staticmethod + def raw_reaction_to_eba( + payload: discord.RawReactionActionEvent, + is_add: bool, + ) -> platform_events.MessageReactionEvent: + member = getattr(payload, 'member', None) + user = member or getattr(payload, 'user', None) + if user is None: + user = platform_entities.User(id=payload.user_id) + else: + user = DiscordEventConverter.user_from_author(user) + return platform_events.MessageReactionEvent( + type='message.reaction', + adapter_name='discord', + message_id=payload.message_id, + user=user, + reaction=str(payload.emoji), + is_add=is_add, + chat_type=platform_entities.ChatType.PRIVATE + if payload.guild_id is None + else platform_entities.ChatType.GROUP, + chat_id=payload.channel_id, + group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None, + source_platform_object=payload, + ) + + @staticmethod + def member_join_to_eba( + member: discord.Member, + bot_user_id: int | None, + ) -> platform_events.BotInvitedToGroupEvent | platform_events.MemberJoinedEvent: + group = DiscordEventConverter.group_from_guild(member.guild) + user = DiscordEventConverter.user_from_author(member) + if bot_user_id is not None and member.id == bot_user_id: + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name='discord', + group=group, + inviter=None, + timestamp=member.joined_at.timestamp() if member.joined_at else 0.0, + source_platform_object=member, + ) + return platform_events.MemberJoinedEvent( + type='group.member_joined', + adapter_name='discord', + group=group, + member=user, + inviter=None, + join_type='direct', + timestamp=member.joined_at.timestamp() if member.joined_at else 0.0, + source_platform_object=member, + ) + + @staticmethod + def member_left_to_eba( + member: discord.Member, + bot_user_id: int | None, + ) -> platform_events.BotRemovedFromGroupEvent | platform_events.MemberLeftEvent: + group = DiscordEventConverter.group_from_guild(member.guild) + user = DiscordEventConverter.user_from_author(member) + if bot_user_id is not None and member.id == bot_user_id: + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + adapter_name='discord', + group=group, + operator=None, + source_platform_object=member, + ) + return platform_events.MemberLeftEvent( + type='group.member_left', + adapter_name='discord', + group=group, + member=user, + is_kicked=False, + operator=None, + source_platform_object=member, + ) + + @staticmethod + def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent: + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name='discord', + group=DiscordEventConverter.group_from_guild(guild), + inviter=None, + source_platform_object=guild, + ) + + @staticmethod + def guild_remove_to_eba(guild: discord.Guild) -> platform_events.BotRemovedFromGroupEvent: + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + adapter_name='discord', + group=DiscordEventConverter.group_from_guild(guild), + operator=None, + source_platform_object=guild, + ) + + @staticmethod + async def target2legacy(message: discord.Message) -> platform_events.FriendMessage | platform_events.GroupMessage: + message_chain = await DiscordMessageConverter.target2yiri(message) + if isinstance(message.channel, discord.DMChannel): + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=message.author.id, + nickname=message.author.name, + remark=str(message.channel.id), + ), + message_chain=message_chain, + time=message.created_at.timestamp(), + source_platform_object=message, + ) + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=message.author.id, + member_name=message.author.display_name, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=message.channel.id, + name=message.channel.name, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=message.created_at.timestamp(), + source_platform_object=message, + ) + + @staticmethod + def user_from_author(author: discord.User | discord.Member) -> platform_entities.User: + return platform_entities.User( + id=author.id, + nickname=getattr(author, 'display_name', None) or author.name, + avatar_url=str(author.display_avatar.url) if getattr(author, 'display_avatar', None) else None, + is_bot=author.bot, + username=author.name, + ) + + @staticmethod + def group_from_message(message: discord.Message) -> platform_entities.UserGroup | None: + guild = getattr(message, 'guild', None) + if guild is None: + return None + return DiscordEventConverter.group_from_guild(guild) + + @staticmethod + def group_from_guild(guild: discord.Guild) -> platform_entities.UserGroup: + return platform_entities.UserGroup( + id=guild.id, + name=guild.name, + member_count=guild.member_count, + avatar_url=str(guild.icon.url) if guild.icon else None, + owner_id=guild.owner_id, + ) diff --git a/src/langbot/pkg/platform/adapters/discord/manifest.yaml b/src/langbot/pkg/platform/adapters/discord/manifest.yaml new file mode 100644 index 00000000..67030c01 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/manifest.yaml @@ -0,0 +1,89 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: discord-eba + label: + en_US: Discord (EBA) + zh_Hans: Discord (EBA) + description: + en_US: Discord adapter (EBA architecture) + zh_Hans: Discord 适配器(EBA 架构版本) + icon: discord.svg + +spec: + categories: + - popular + - global + config: + - name: client_id + label: + en_US: Client ID + zh_Hans: 客户端 ID + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + type: string + required: true + default: "" + + supported_events: + - message.received + - message.edited + - message.deleted + - message.reaction + - group.member_joined + - group.member_left + - bot.invited_to_group + - bot.removed_from_group + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - edit_message + - delete_message + - forward_message + - get_group_info + - get_group_member_list + - get_group_member_info + - get_user_info + - get_file_url + - mute_member + - unmute_member + - kick_member + - leave_group + - call_platform_api + + platform_specific_apis: + - action: get_channel + description: { en_US: "Get channel information", zh_Hans: "获取频道信息" } + - action: get_guild + description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" } + - action: get_guild_channels + description: { en_US: "Get guild channels", zh_Hans: "获取服务器频道列表" } + - action: get_guild_roles + description: { en_US: "Get guild roles", zh_Hans: "获取服务器角色列表" } + - action: create_invite + description: { en_US: "Create channel invite", zh_Hans: "创建频道邀请链接" } + - action: pin_message + description: { en_US: "Pin a message", zh_Hans: "置顶消息" } + - action: unpin_message + description: { en_US: "Unpin a message", zh_Hans: "取消置顶消息" } + - action: add_reaction + description: { en_US: "Add a reaction", zh_Hans: "添加表情回应" } + - action: remove_reaction + description: { en_US: "Remove a reaction", zh_Hans: "移除表情回应" } + - action: typing + description: { en_US: "Send typing indicator", zh_Hans: "发送正在输入状态" } + +execution: + python: + path: ./adapter.py + attr: DiscordAdapter diff --git a/src/langbot/pkg/platform/adapters/discord/message_converter.py b/src/langbot/pkg/platform/adapters/discord/message_converter.py new file mode 100644 index 00000000..d62a5678 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/message_converter.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import base64 +import datetime +import io +import os +import re +import uuid + +import discord + +from langbot.pkg.utils import httpclient +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DiscordMessageConverter: + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, + ) -> tuple[str, list[discord.File]]: + text_parts: list[str] = [] + files: list[discord.File] = [] + + for element in list(message_chain): + if isinstance(element, platform_message.At): + text_parts.append(f'<@{element.target}>') + elif isinstance(element, platform_message.AtAll): + text_parts.append('@everyone') + elif isinstance(element, platform_message.Plain): + text_parts.append(element.text) + elif isinstance(element, platform_message.Image): + file_bytes, filename = await DiscordMessageConverter._load_image(element) + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(element, platform_message.Voice): + file_bytes, filename = await DiscordMessageConverter._load_voice(element) + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(element, platform_message.File): + file_bytes = await DiscordMessageConverter._load_file(element) + if file_bytes: + filename = element.name or f'{uuid.uuid4()}.bin' + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(element, platform_message.Forward): + for node in element.node_list: + node_text, node_files = await DiscordMessageConverter.yiri2target(node.message_chain) + text_parts.append(node_text) + files.extend(node_files) + + return ''.join(text_parts), files + + @staticmethod + async def target2yiri(message: discord.Message) -> platform_message.MessageChain: + message_time = datetime.datetime.fromtimestamp(int(message.created_at.timestamp())) + elements: list[platform_message.MessageComponent] = [platform_message.Source(id=message.id, time=message_time)] + elements.extend(DiscordMessageConverter._text_components(message.content)) + + for attachment in message.attachments: + if DiscordMessageConverter._is_image_attachment(attachment): + elements.append(platform_message.Image(url=attachment.url)) + else: + elements.append( + platform_message.File( + name=attachment.filename, + size=attachment.size or 0, + url=attachment.url, + ) + ) + + return platform_message.MessageChain(elements) + + @staticmethod + def _text_components(text: str) -> list[platform_message.MessageComponent]: + if not text: + return [] + + pattern = re.compile(r'(@everyone|@here|<@!?(\d+)>)') + components: list[platform_message.MessageComponent] = [] + last = 0 + for match in pattern.finditer(text): + if match.start() > last: + components.append(platform_message.Plain(text=text[last : match.start()])) + if match.group(1) in ('@everyone', '@here'): + components.append(platform_message.AtAll()) + else: + components.append(platform_message.At(target=match.group(2))) + last = match.end() + if last < len(text): + components.append(platform_message.Plain(text=text[last:])) + return components + + @staticmethod + async def _load_image(element: platform_message.Image) -> tuple[bytes | None, str]: + filename = f'{uuid.uuid4()}.png' + if element.base64: + header, _, payload = element.base64.partition(',') + data = payload or header + if 'jpeg' in header or 'jpg' in header: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in header: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in header: + filename = f'{uuid.uuid4()}.webp' + return base64.b64decode(data), filename + if element.url: + data, content_type = await DiscordMessageConverter._download(element.url) + if 'jpeg' in content_type or 'jpg' in content_type: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in content_type: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in content_type: + filename = f'{uuid.uuid4()}.webp' + return data, filename + if element.path: + path = os.path.abspath(element.path.replace('\x00', '')) + if not os.path.exists(path): + return None, filename + with open(path, 'rb') as fp: + data = fp.read() + ext = os.path.splitext(path)[1] + if ext: + filename = f'{uuid.uuid4()}{ext}' + return data, filename + return None, filename + + @staticmethod + async def _load_voice(element: platform_message.Voice) -> tuple[bytes | None, str]: + filename = f'{uuid.uuid4()}.mp3' + if element.base64: + header, _, payload = element.base64.partition(',') + data = payload or header + for ext in ('wav', 'mp3', 'ogg', 'm4a', 'aac', 'flac', 'opus', 'webm'): + if ext in header: + filename = f'{uuid.uuid4()}.{ext}' + break + return base64.b64decode(data), filename + if element.url: + data, _ = await DiscordMessageConverter._download(element.url) + return data, filename + return None, filename + + @staticmethod + async def _load_file(element: platform_message.File) -> bytes | None: + if element.base64: + return base64.b64decode(element.base64.split(',')[-1]) + if element.url: + data, _ = await DiscordMessageConverter._download(element.url) + return data + return None + + @staticmethod + async def _download(url: str) -> tuple[bytes, str]: + session = httpclient.get_session(trust_env=True) + async with session.get(url) as response: + return await response.read(), response.headers.get('Content-Type', '') + + @staticmethod + def _is_image_attachment(attachment: discord.Attachment) -> bool: + content_type = attachment.content_type or '' + return content_type.startswith('image/') or attachment.filename.lower().endswith( + ('.png', '.jpg', '.jpeg', '.gif', '.webp') + ) diff --git a/src/langbot/pkg/platform/adapters/discord/platform_api.py b/src/langbot/pkg/platform/adapters/discord/platform_api.py new file mode 100644 index 00000000..4c39e8f3 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/platform_api.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import typing + +import discord + + +async def get_channel(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + return { + 'id': channel.id, + 'name': getattr(channel, 'name', ''), + 'type': str(channel.type), + 'guild_id': getattr(getattr(channel, 'guild', None), 'id', None), + } + + +async def get_guild(bot: discord.Client, params: dict) -> dict: + guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id'])) + return {'id': guild.id, 'name': guild.name, 'member_count': guild.member_count, 'owner_id': guild.owner_id} + + +async def get_guild_channels(bot: discord.Client, params: dict) -> dict: + guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id'])) + channels = guild.channels or await guild.fetch_channels() + return {'channels': [{'id': channel.id, 'name': channel.name, 'type': str(channel.type)} for channel in channels]} + + +async def get_guild_roles(bot: discord.Client, params: dict) -> dict: + guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id'])) + return {'roles': [{'id': role.id, 'name': role.name, 'position': role.position} for role in guild.roles]} + + +async def create_invite(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + invite = await channel.create_invite( + max_age=params.get('max_age', 0), + max_uses=params.get('max_uses', 0), + unique=params.get('unique', True), + reason=params.get('reason', 'LangBot EBA create_invite'), + ) + return {'url': invite.url, 'code': invite.code} + + +async def pin_message(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + await message.pin(reason=params.get('reason', 'LangBot EBA pin_message')) + return {'ok': True} + + +async def unpin_message(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + await message.unpin(reason=params.get('reason', 'LangBot EBA unpin_message')) + return {'ok': True} + + +async def add_reaction(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + await message.add_reaction(params['emoji']) + return {'ok': True} + + +async def remove_reaction(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + user = ( + bot.user + if 'user_id' not in params + else bot.get_user(int(params['user_id'])) or await bot.fetch_user(int(params['user_id'])) + ) + await message.remove_reaction(params['emoji'], user) + return {'ok': True} + + +async def send_typing(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + async with channel.typing(): + return {'ok': True} + + +PLATFORM_API_MAP: dict[str, typing.Callable[[discord.Client, dict], typing.Awaitable[dict]]] = { + 'get_channel': get_channel, + 'get_guild': get_guild, + 'get_guild_channels': get_guild_channels, + 'get_guild_roles': get_guild_roles, + 'create_invite': create_invite, + 'pin_message': pin_message, + 'unpin_message': unpin_message, + 'add_reaction': add_reaction, + 'remove_reaction': remove_reaction, + 'typing': send_typing, +} diff --git a/src/langbot/pkg/platform/adapters/discord/types.py b/src/langbot/pkg/platform/adapters/discord/types.py new file mode 100644 index 00000000..bdb66079 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/types.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import typing + +import pydantic + + +class DiscordAdapterConfig(pydantic.BaseModel): + client_id: str + token: str + guild_id: typing.Optional[str] = None + debug_channel_id: typing.Optional[str] = None diff --git a/src/langbot/pkg/platform/adapters/discord/voice.py b/src/langbot/pkg/platform/adapters/discord/voice.py new file mode 100644 index 00000000..0cf83e6f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/voice.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +# Voice support is still implemented by the legacy Discord source adapter. The +# EBA adapter exposes text, guild, member, moderation, and platform-specific APIs +# first; voice-specific EBA actions will move here when that surface is migrated. diff --git a/src/langbot/pkg/platform/adapters/telegram/telegram.svg b/src/langbot/pkg/platform/adapters/telegram/telegram.svg new file mode 100644 index 00000000..acd7fce6 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/telegram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/live_discord_eba_probe.py b/tests/e2e/live_discord_eba_probe.py new file mode 100644 index 00000000..68ce69ab --- /dev/null +++ b/tests/e2e/live_discord_eba_probe.py @@ -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() diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py new file mode 100644 index 00000000..67430461 --- /dev/null +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -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