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