feat: add discord eba adapter

This commit is contained in:
Junyan Qin
2026-05-07 23:05:38 +08:00
parent 503d29ffed
commit 57f2e85388
16 changed files with 1827 additions and 35 deletions

View File

@@ -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

View File

@@ -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`.

View File

@@ -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"

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
__all__ = ['DiscordAdapter']

View File

@@ -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

View File

@@ -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,
)

View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="80px" height="80px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid" fill="#000000">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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')
)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#29b6f6" d="M24,4C13,4,4,13,4,24s9,20,20,20s20-9,20-20S35,4,24,4z"/><path fill="#fff" d="M34,15l-3.7,19.1c0,0-0.2,0.9-1.2,0.9c-0.6,0-0.9-0.3-0.9-0.3L20,28l-4-2l-5.1-1.4c0,0-0.9-0.3-0.9-1 c0-0.6,0.9-0.9,0.9-0.9l21.3-8.5c0,0,0.7-0.2,1.1-0.2c0.3,0,0.6,0.1,0.6,0.5C34,14.8,34,15,34,15z"/><path fill="#b0bec5" d="M23,30.5l-3.4,3.4c0,0-0.1,0.1-0.3,0.1c-0.1,0-0.1,0-0.2,0l1-6L23,30.5z"/><path fill="#cfd8dc" d="M29.9,18.2c-0.2-0.2-0.5-0.3-0.7-0.1L16,26c0,0,2.1,5.9,2.4,6.9c0.3,1,0.6,1,0.6,1l1-6l9.8-9.1 C30,18.7,30.1,18.4,29.9,18.2z"/></svg>

After

Width:  |  Height:  |  Size: 634 B

View File

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

View File

@@ -0,0 +1,283 @@
from __future__ import annotations
import pathlib
import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import yaml
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
def make_adapter() -> DiscordAdapter:
return DiscordAdapter({'client_id': '123', 'token': 'fake'}, DummyLogger())
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'discord'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def test_discord_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_discord_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_discord_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_discord_adapter_dispatches_most_specific_eba_listener():
adapter = make_adapter()
calls: list[str] = []
async def wildcard_listener(event, adapter):
calls.append('event')
async def eba_listener(event, adapter):
calls.append('eba')
async def message_listener(event, adapter):
calls.append('message')
adapter.register_listener(platform_events.Event, wildcard_listener)
adapter.register_listener(platform_events.EBAEvent, eba_listener)
adapter.register_listener(
platform_events.MessageReceivedEvent,
message_listener,
)
event = platform_events.MessageReceivedEvent(
message_id=1,
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
sender={'id': 1},
chat_id=1,
)
await adapter._dispatch_eba_event(event)
assert calls == ['message']
@pytest.mark.asyncio
async def test_discord_message_converter_maps_mentions_and_text_to_target():
content, files = await DiscordMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi '),
platform_message.At(target='123'),
platform_message.Plain(text=' all '),
platform_message.AtAll(),
]
)
)
assert content == 'hi <@123> all @everyone'
assert files == []
def test_discord_message_converter_splits_discord_mentions():
components = DiscordMessageConverter._text_components('hi <@123> and @everyone')
assert isinstance(components[0], platform_message.Plain)
assert components[0].text == 'hi '
assert isinstance(components[1], platform_message.At)
assert components[1].target == '123'
assert isinstance(components[3], platform_message.AtAll)
def fake_user(user_id=123, name='user', bot=False):
return SimpleNamespace(
id=user_id,
name=name,
display_name=name.title(),
bot=bot,
display_avatar=SimpleNamespace(url=f'https://cdn.example/{user_id}.png'),
)
def fake_guild(guild_id=456):
return SimpleNamespace(
id=guild_id,
name='Guild',
member_count=3,
icon=None,
owner_id=1,
)
def fake_channel(channel_id=789, guild=None):
return SimpleNamespace(
id=channel_id,
name='general',
guild=guild,
)
def fake_message(content='hello <@123>', *, guild=None, channel=None, author=None, message_id=999):
guild = guild if guild is not None else fake_guild()
channel = channel if channel is not None else fake_channel(guild=guild)
author = author if author is not None else fake_user()
return SimpleNamespace(
id=message_id,
content=content,
author=author,
channel=channel,
guild=guild,
attachments=[],
created_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC),
edited_at=datetime.datetime(2026, 5, 7, 0, 1, tzinfo=datetime.UTC),
)
@pytest.mark.asyncio
async def test_discord_converter_maps_message_edit_delete_and_reaction_events():
message = fake_message()
received = await DiscordEventConverter.message_to_eba(message)
assert isinstance(received, platform_events.MessageReceivedEvent)
assert received.type == 'message.received'
assert received.adapter_name == 'discord'
assert received.chat_type == platform_entities.ChatType.GROUP
assert received.chat_id == 789
assert received.group.id == 456
assert isinstance(received.message_chain[1], platform_message.Plain)
assert isinstance(received.message_chain[2], platform_message.At)
edited = await DiscordEventConverter.message_edit_to_eba(message, fake_message(content='edited', message_id=999))
assert isinstance(edited, platform_events.MessageEditedEvent)
assert edited.type == 'message.edited'
assert str(edited.new_content) == 'edited'
deleted = await DiscordEventConverter.message_delete_to_eba(message)
assert isinstance(deleted, platform_events.MessageDeletedEvent)
assert deleted.type == 'message.deleted'
assert deleted.message_id == 999
reaction = SimpleNamespace(message=message, emoji='👍')
reaction_event = DiscordEventConverter.reaction_to_eba(reaction, fake_user(321, 'reactor'), True)
assert isinstance(reaction_event, platform_events.MessageReactionEvent)
assert reaction_event.type == 'message.reaction'
assert reaction_event.reaction == '👍'
assert reaction_event.user.id == 321
@pytest.mark.asyncio
async def test_discord_converter_maps_uncached_raw_gateway_events():
raw_delete = SimpleNamespace(message_id=10, channel_id=20, guild_id=30)
deleted = await DiscordEventConverter.target2yiri(('raw_message_delete', raw_delete), bot_user_id=1)
assert isinstance(deleted, platform_events.MessageDeletedEvent)
assert deleted.message_id == 10
assert deleted.chat_id == 20
assert deleted.group.id == 30
raw_reaction = SimpleNamespace(
message_id=11,
channel_id=21,
guild_id=31,
user_id=41,
emoji='🔥',
member=fake_user(41, 'member'),
)
reaction = await DiscordEventConverter.target2yiri(('raw_reaction_add', raw_reaction), bot_user_id=1)
assert isinstance(reaction, platform_events.MessageReactionEvent)
assert reaction.reaction == '🔥'
assert reaction.is_add is True
assert reaction.user.id == 41
def test_discord_converter_maps_member_and_bot_guild_events():
guild = fake_guild()
member = SimpleNamespace(
**fake_user(123, 'member').__dict__,
guild=guild,
joined_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC),
)
joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=999)
assert isinstance(joined, platform_events.MemberJoinedEvent)
assert joined.join_type == 'direct'
bot_joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=123)
assert isinstance(bot_joined, platform_events.BotInvitedToGroupEvent)
bot_invited = DiscordEventConverter.guild_join_to_eba(guild)
bot_removed = DiscordEventConverter.guild_remove_to_eba(guild)
assert isinstance(bot_invited, platform_events.BotInvitedToGroupEvent)
assert isinstance(bot_removed, platform_events.BotRemovedFromGroupEvent)
@pytest.mark.asyncio
async def test_discord_send_and_reply_omit_empty_files_and_return_message_result(monkeypatch):
adapter = make_adapter()
sent = SimpleNamespace(id=111)
channel = SimpleNamespace(send=AsyncMock(return_value=sent))
object.__setattr__(adapter, '_get_channel', AsyncMock(return_value=channel))
result = await adapter.send_message(
'group',
'789',
platform_message.MessageChain([platform_message.Plain(text='hello')]),
)
assert result.message_id == 111
assert channel.send.await_args.kwargs == {'content': 'hello'}
monkeypatch.setattr(discord_adapter_module.discord, 'Message', SimpleNamespace)
source_message = SimpleNamespace(channel=channel)
source = platform_events.MessageReceivedEvent(
message_id=1,
source_platform_object=source_message,
)
result = await adapter.reply_message(
source,
platform_message.MessageChain([platform_message.Plain(text='reply')]),
quote_origin=True,
)
assert result.message_id == 111
assert channel.send.await_args.kwargs['content'] == 'reply'
assert channel.send.await_args.kwargs['reference'] is source_message
assert channel.send.await_args.kwargs['mention_author'] is False