mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: add discord eba adapter
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
5
src/langbot/pkg/platform/adapters/discord/__init__.py
Normal file
5
src/langbot/pkg/platform/adapters/discord/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||
|
||||
__all__ = ['DiscordAdapter']
|
||||
253
src/langbot/pkg/platform/adapters/discord/adapter.py
Normal file
253
src/langbot/pkg/platform/adapters/discord/adapter.py
Normal 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
|
||||
153
src/langbot/pkg/platform/adapters/discord/api_impl.py
Normal file
153
src/langbot/pkg/platform/adapters/discord/api_impl.py
Normal 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,
|
||||
)
|
||||
7
src/langbot/pkg/platform/adapters/discord/discord.svg
Normal file
7
src/langbot/pkg/platform/adapters/discord/discord.svg
Normal 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 |
296
src/langbot/pkg/platform/adapters/discord/event_converter.py
Normal file
296
src/langbot/pkg/platform/adapters/discord/event_converter.py
Normal 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,
|
||||
)
|
||||
89
src/langbot/pkg/platform/adapters/discord/manifest.yaml
Normal file
89
src/langbot/pkg/platform/adapters/discord/manifest.yaml
Normal 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
|
||||
162
src/langbot/pkg/platform/adapters/discord/message_converter.py
Normal file
162
src/langbot/pkg/platform/adapters/discord/message_converter.py
Normal 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')
|
||||
)
|
||||
95
src/langbot/pkg/platform/adapters/discord/platform_api.py
Normal file
95
src/langbot/pkg/platform/adapters/discord/platform_api.py
Normal 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,
|
||||
}
|
||||
12
src/langbot/pkg/platform/adapters/discord/types.py
Normal file
12
src/langbot/pkg/platform/adapters/discord/types.py
Normal 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
|
||||
5
src/langbot/pkg/platform/adapters/discord/voice.py
Normal file
5
src/langbot/pkg/platform/adapters/discord/voice.py
Normal 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.
|
||||
1
src/langbot/pkg/platform/adapters/telegram/telegram.svg
Normal file
1
src/langbot/pkg/platform/adapters/telegram/telegram.svg
Normal 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 |
420
tests/e2e/live_discord_eba_probe.py
Normal file
420
tests/e2e/live_discord_eba_probe.py
Normal file
@@ -0,0 +1,420 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import errors as platform_errors
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
class ProbeLogger(AbstractEventLogger):
|
||||
async def info(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[info] {text}')
|
||||
|
||||
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[debug] {text}')
|
||||
|
||||
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[warning] {text}')
|
||||
|
||||
async def error(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[error] {text}')
|
||||
|
||||
|
||||
PNG_1X1 = base64.b64decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
||||
)
|
||||
|
||||
|
||||
def summarize_event(event: platform_events.EBAEvent) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
'type': event.type,
|
||||
'adapter_name': event.adapter_name,
|
||||
'timestamp': event.timestamp,
|
||||
}
|
||||
for field in ('message_id', 'chat_id', 'chat_type', 'reaction', 'is_add', 'action', 'data'):
|
||||
if hasattr(event, field):
|
||||
value = getattr(event, field)
|
||||
data[field] = value.value if hasattr(value, 'value') else value
|
||||
for field in ('sender', 'user', 'member', 'group', 'operator', 'inviter'):
|
||||
if hasattr(event, field):
|
||||
value = getattr(event, field)
|
||||
if value is not None and hasattr(value, 'model_dump'):
|
||||
data[field] = value.model_dump()
|
||||
return data
|
||||
|
||||
|
||||
def chat_type_value(chat_type: platform_entities.ChatType | str) -> str:
|
||||
return chat_type.value if hasattr(chat_type, 'value') else str(chat_type)
|
||||
|
||||
|
||||
def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None):
|
||||
entry: dict[str, Any] = {'name': name, 'ok': ok}
|
||||
if result is not None:
|
||||
entry['result'] = result
|
||||
if error is not None:
|
||||
entry['error'] = repr(error)
|
||||
results.append(entry)
|
||||
print('DISCORD_EBA_API', json.dumps(entry, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
async def run_api(results: list[dict[str, Any]], name: str, func):
|
||||
try:
|
||||
result = await func()
|
||||
record_api(results, name, True, result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
record_api(results, name, False, error=exc)
|
||||
return None
|
||||
|
||||
|
||||
async def run_expected_error(results: list[dict[str, Any]], name: str, func, error_type: type[Exception]):
|
||||
try:
|
||||
await func()
|
||||
except Exception as exc:
|
||||
if isinstance(exc, error_type):
|
||||
record_api(results, name, True, {'expected_error': type(exc).__name__})
|
||||
return
|
||||
record_api(results, name, False, error=exc)
|
||||
return
|
||||
record_api(results, name, False, error=RuntimeError(f'expected {error_type.__name__}'))
|
||||
|
||||
|
||||
async def wait_for_event(events: list[platform_events.EBAEvent], predicate, timeout: int) -> platform_events.EBAEvent:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
seen = 0
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
for event in events[seen:]:
|
||||
if predicate(event):
|
||||
return event
|
||||
seen = len(events)
|
||||
await asyncio.sleep(0.2)
|
||||
raise TimeoutError('event was not observed before timeout')
|
||||
|
||||
|
||||
async def run_probe(
|
||||
token: str,
|
||||
client_id: str,
|
||||
channel_id: str,
|
||||
log_path: Path,
|
||||
timeout: int,
|
||||
guild_id: str | None,
|
||||
moderation_user_id: str | None,
|
||||
kick_user_id: str | None,
|
||||
allow_invite: bool,
|
||||
allow_leave_group: bool,
|
||||
):
|
||||
adapter = DiscordAdapter({'client_id': client_id, 'token': token}, ProbeLogger())
|
||||
events: list[platform_events.EBAEvent] = []
|
||||
api_results: list[dict[str, Any]] = []
|
||||
first_message = asyncio.Event()
|
||||
|
||||
async def listener(event, adapter):
|
||||
events.append(event)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log_path.open('a', encoding='utf-8') as fp:
|
||||
fp.write(json.dumps(summarize_event(event), ensure_ascii=False, default=str) + '\n')
|
||||
print('DISCORD_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False, default=str))
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
first_message.set()
|
||||
|
||||
adapter.register_listener(platform_events.EBAEvent, listener)
|
||||
run_task = asyncio.create_task(adapter.run_async())
|
||||
|
||||
try:
|
||||
print('READY: send a message in the Discord test channel now.')
|
||||
await asyncio.wait_for(first_message.wait(), timeout=timeout)
|
||||
source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent))
|
||||
source_chat_type = chat_type_value(source.chat_type)
|
||||
target_chat_id = str(source.chat_id)
|
||||
guild_id = guild_id or (str(source.group.id) if source.group else None)
|
||||
actor_user_id = str(source.sender.id)
|
||||
|
||||
await run_api(
|
||||
api_results,
|
||||
'reply_message:text_image_file',
|
||||
lambda: adapter.reply_message(
|
||||
source,
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='Discord EBA live reply: text'),
|
||||
platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()),
|
||||
platform_message.File(
|
||||
name='discord-eba-live.txt',
|
||||
size=16,
|
||||
base64='data:text/plain;base64,' + base64.b64encode(b'discord-eba-live').decode(),
|
||||
),
|
||||
]
|
||||
),
|
||||
quote_origin=True,
|
||||
),
|
||||
)
|
||||
sent = await run_api(
|
||||
api_results,
|
||||
'send_message:text_image_file',
|
||||
lambda: adapter.send_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='Discord EBA live send_message OK'),
|
||||
platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()),
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
edit_probe = await run_api(
|
||||
api_results,
|
||||
'send_message:edit_delete_probe',
|
||||
lambda: adapter.send_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
platform_message.MessageChain([platform_message.Plain(text='Discord EBA edit/delete probe')]),
|
||||
),
|
||||
)
|
||||
if edit_probe:
|
||||
await run_api(
|
||||
api_results,
|
||||
'edit_message',
|
||||
lambda: adapter.edit_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
edit_probe.message_id,
|
||||
platform_message.MessageChain([platform_message.Plain(text='Discord EBA edit probe edited')]),
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'delete_message',
|
||||
lambda: adapter.delete_message(source_chat_type, target_chat_id, edit_probe.message_id),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'event_observed:message.edited',
|
||||
lambda: wait_for_event(
|
||||
events,
|
||||
lambda event: isinstance(event, platform_events.MessageEditedEvent)
|
||||
and str(event.message_id) == str(edit_probe.message_id),
|
||||
timeout=max(10, timeout // 3),
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'event_observed:message.deleted',
|
||||
lambda: wait_for_event(
|
||||
events,
|
||||
lambda event: isinstance(event, platform_events.MessageDeletedEvent)
|
||||
and str(event.message_id) == str(edit_probe.message_id),
|
||||
timeout=max(10, timeout // 3),
|
||||
),
|
||||
)
|
||||
|
||||
if sent:
|
||||
await run_api(
|
||||
api_results,
|
||||
'forward_message',
|
||||
lambda: adapter.forward_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
sent.message_id,
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:add_reaction',
|
||||
lambda: adapter.call_platform_api(
|
||||
'add_reaction',
|
||||
{'channel_id': target_chat_id, 'message_id': sent.message_id, 'emoji': '👍'},
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:remove_reaction',
|
||||
lambda: adapter.call_platform_api(
|
||||
'remove_reaction',
|
||||
{'channel_id': target_chat_id, 'message_id': sent.message_id, 'emoji': '👍'},
|
||||
),
|
||||
)
|
||||
|
||||
await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(actor_user_id))
|
||||
await run_expected_error(
|
||||
api_results,
|
||||
'upload_file:not_supported',
|
||||
lambda: adapter.upload_file(b'discord-eba-upload', 'discord-eba-upload.txt'),
|
||||
platform_errors.NotSupportedError,
|
||||
)
|
||||
await run_api(api_results, 'get_file_url', lambda: adapter.get_file_url('https://cdn.discordapp.com/file.txt'))
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_channel',
|
||||
lambda: adapter.call_platform_api('get_channel', {'channel_id': target_chat_id}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:typing',
|
||||
lambda: adapter.call_platform_api('typing', {'channel_id': target_chat_id}),
|
||||
)
|
||||
|
||||
pin_probe = await run_api(
|
||||
api_results,
|
||||
'send_message:pin_probe',
|
||||
lambda: adapter.send_message(
|
||||
source_chat_type,
|
||||
target_chat_id,
|
||||
platform_message.MessageChain([platform_message.Plain(text='Discord EBA pin probe')]),
|
||||
),
|
||||
)
|
||||
if pin_probe:
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:pin_message',
|
||||
lambda: adapter.call_platform_api(
|
||||
'pin_message',
|
||||
{'channel_id': target_chat_id, 'message_id': pin_probe.message_id},
|
||||
),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:unpin_message',
|
||||
lambda: adapter.call_platform_api(
|
||||
'unpin_message',
|
||||
{'channel_id': target_chat_id, 'message_id': pin_probe.message_id},
|
||||
),
|
||||
)
|
||||
|
||||
if guild_id:
|
||||
await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(guild_id))
|
||||
await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(guild_id))
|
||||
await run_api(
|
||||
api_results,
|
||||
'get_group_member_info',
|
||||
lambda: adapter.get_group_member_info(guild_id, actor_user_id),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_guild',
|
||||
lambda: adapter.call_platform_api('get_guild', {'guild_id': guild_id}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_guild_channels',
|
||||
lambda: adapter.call_platform_api('get_guild_channels', {'guild_id': guild_id}),
|
||||
)
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:get_guild_roles',
|
||||
lambda: adapter.call_platform_api('get_guild_roles', {'guild_id': guild_id}),
|
||||
)
|
||||
if allow_invite:
|
||||
await run_api(
|
||||
api_results,
|
||||
'call_platform_api:create_invite',
|
||||
lambda: adapter.call_platform_api('create_invite', {'channel_id': target_chat_id, 'max_age': 300}),
|
||||
)
|
||||
else:
|
||||
record_api(api_results, 'call_platform_api:create_invite', False, error=RuntimeError('skipped'))
|
||||
|
||||
if moderation_user_id:
|
||||
await run_api(
|
||||
api_results,
|
||||
'mute_member',
|
||||
lambda: adapter.mute_member(guild_id, moderation_user_id, duration=30),
|
||||
)
|
||||
await run_api(api_results, 'unmute_member', lambda: adapter.unmute_member(guild_id, moderation_user_id))
|
||||
else:
|
||||
record_api(api_results, 'mute_member', False, error=RuntimeError('skipped'))
|
||||
record_api(api_results, 'unmute_member', False, error=RuntimeError('skipped'))
|
||||
|
||||
if kick_user_id:
|
||||
await run_api(api_results, 'kick_member', lambda: adapter.kick_member(guild_id, kick_user_id))
|
||||
else:
|
||||
record_api(api_results, 'kick_member', False, error=RuntimeError('skipped'))
|
||||
|
||||
if allow_leave_group:
|
||||
await run_api(api_results, 'leave_group', lambda: adapter.leave_group(guild_id))
|
||||
else:
|
||||
record_api(api_results, 'leave_group', False, error=RuntimeError('skipped'))
|
||||
else:
|
||||
for name in (
|
||||
'get_group_info',
|
||||
'get_group_member_list',
|
||||
'get_group_member_info',
|
||||
'call_platform_api:get_guild',
|
||||
'call_platform_api:get_guild_channels',
|
||||
'call_platform_api:get_guild_roles',
|
||||
'call_platform_api:create_invite',
|
||||
'mute_member',
|
||||
'unmute_member',
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
):
|
||||
record_api(api_results, name, False, error=RuntimeError('skipped: no guild id'))
|
||||
|
||||
await asyncio.sleep(3)
|
||||
finally:
|
||||
await adapter.kill()
|
||||
run_task.cancel()
|
||||
summary = {
|
||||
'events': [summarize_event(event) for event in events],
|
||||
'event_types': [event.type for event in events],
|
||||
'api_results': api_results,
|
||||
'api_passed': [result['name'] for result in api_results if result['ok']],
|
||||
'api_failed': [result for result in api_results if not result['ok']],
|
||||
}
|
||||
print('SUMMARY', json.dumps(summary, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--token', default=os.getenv('DISCORD_BOT_TOKEN', ''))
|
||||
parser.add_argument('--client-id', default=os.getenv('DISCORD_CLIENT_ID', ''))
|
||||
parser.add_argument('--channel-id', default=os.getenv('DISCORD_EBA_CHANNEL_ID', ''))
|
||||
parser.add_argument('--guild-id', default=os.getenv('DISCORD_EBA_GUILD_ID'))
|
||||
parser.add_argument('--moderation-user-id', default=os.getenv('DISCORD_EBA_MODERATION_USER_ID'))
|
||||
parser.add_argument('--kick-user-id', default=os.getenv('DISCORD_EBA_KICK_USER_ID'))
|
||||
parser.add_argument('--log', default='data/temp/live_discord_eba_probe.jsonl')
|
||||
parser.add_argument('--timeout', type=int, default=90)
|
||||
parser.add_argument('--allow-invite', action='store_true')
|
||||
parser.add_argument('--allow-leave-group', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.token:
|
||||
raise SystemExit('Set DISCORD_BOT_TOKEN or pass --token')
|
||||
if not args.client_id:
|
||||
raise SystemExit('Set DISCORD_CLIENT_ID or pass --client-id')
|
||||
if not args.channel_id:
|
||||
raise SystemExit('Set DISCORD_EBA_CHANNEL_ID or pass --channel-id')
|
||||
|
||||
log_path = Path(args.log)
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
asyncio.run(
|
||||
run_probe(
|
||||
args.token,
|
||||
args.client_id,
|
||||
args.channel_id,
|
||||
log_path,
|
||||
args.timeout,
|
||||
args.guild_id,
|
||||
args.moderation_user_id,
|
||||
args.kick_user_id,
|
||||
args.allow_invite,
|
||||
args.allow_leave_group,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
283
tests/unit_tests/platform/test_discord_eba_adapter.py
Normal file
283
tests/unit_tests/platform/test_discord_eba_adapter.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||
import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module
|
||||
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
class DummyLogger(AbstractEventLogger):
|
||||
async def info(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
async def error(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
|
||||
def make_adapter() -> DiscordAdapter:
|
||||
return DiscordAdapter({'client_id': '123', 'token': 'fake'}, DummyLogger())
|
||||
|
||||
|
||||
def manifest() -> dict:
|
||||
manifest_path = (
|
||||
pathlib.Path(__file__).parents[3]
|
||||
/ 'src'
|
||||
/ 'langbot'
|
||||
/ 'pkg'
|
||||
/ 'platform'
|
||||
/ 'adapters'
|
||||
/ 'discord'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
|
||||
|
||||
def test_discord_supported_events_match_manifest():
|
||||
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
|
||||
|
||||
|
||||
def test_discord_supported_apis_match_manifest():
|
||||
supported_apis = make_adapter().get_supported_apis()
|
||||
manifest_apis = manifest()['spec']['supported_apis']
|
||||
|
||||
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
|
||||
|
||||
|
||||
def test_discord_platform_api_map_matches_manifest():
|
||||
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
|
||||
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_adapter_dispatches_most_specific_eba_listener():
|
||||
adapter = make_adapter()
|
||||
calls: list[str] = []
|
||||
|
||||
async def wildcard_listener(event, adapter):
|
||||
calls.append('event')
|
||||
|
||||
async def eba_listener(event, adapter):
|
||||
calls.append('eba')
|
||||
|
||||
async def message_listener(event, adapter):
|
||||
calls.append('message')
|
||||
|
||||
adapter.register_listener(platform_events.Event, wildcard_listener)
|
||||
adapter.register_listener(platform_events.EBAEvent, eba_listener)
|
||||
adapter.register_listener(
|
||||
platform_events.MessageReceivedEvent,
|
||||
message_listener,
|
||||
)
|
||||
|
||||
event = platform_events.MessageReceivedEvent(
|
||||
message_id=1,
|
||||
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
|
||||
sender={'id': 1},
|
||||
chat_id=1,
|
||||
)
|
||||
|
||||
await adapter._dispatch_eba_event(event)
|
||||
|
||||
assert calls == ['message']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_message_converter_maps_mentions_and_text_to_target():
|
||||
content, files = await DiscordMessageConverter.yiri2target(
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='hi '),
|
||||
platform_message.At(target='123'),
|
||||
platform_message.Plain(text=' all '),
|
||||
platform_message.AtAll(),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert content == 'hi <@123> all @everyone'
|
||||
assert files == []
|
||||
|
||||
|
||||
def test_discord_message_converter_splits_discord_mentions():
|
||||
components = DiscordMessageConverter._text_components('hi <@123> and @everyone')
|
||||
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == 'hi '
|
||||
assert isinstance(components[1], platform_message.At)
|
||||
assert components[1].target == '123'
|
||||
assert isinstance(components[3], platform_message.AtAll)
|
||||
|
||||
|
||||
def fake_user(user_id=123, name='user', bot=False):
|
||||
return SimpleNamespace(
|
||||
id=user_id,
|
||||
name=name,
|
||||
display_name=name.title(),
|
||||
bot=bot,
|
||||
display_avatar=SimpleNamespace(url=f'https://cdn.example/{user_id}.png'),
|
||||
)
|
||||
|
||||
|
||||
def fake_guild(guild_id=456):
|
||||
return SimpleNamespace(
|
||||
id=guild_id,
|
||||
name='Guild',
|
||||
member_count=3,
|
||||
icon=None,
|
||||
owner_id=1,
|
||||
)
|
||||
|
||||
|
||||
def fake_channel(channel_id=789, guild=None):
|
||||
return SimpleNamespace(
|
||||
id=channel_id,
|
||||
name='general',
|
||||
guild=guild,
|
||||
)
|
||||
|
||||
|
||||
def fake_message(content='hello <@123>', *, guild=None, channel=None, author=None, message_id=999):
|
||||
guild = guild if guild is not None else fake_guild()
|
||||
channel = channel if channel is not None else fake_channel(guild=guild)
|
||||
author = author if author is not None else fake_user()
|
||||
return SimpleNamespace(
|
||||
id=message_id,
|
||||
content=content,
|
||||
author=author,
|
||||
channel=channel,
|
||||
guild=guild,
|
||||
attachments=[],
|
||||
created_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC),
|
||||
edited_at=datetime.datetime(2026, 5, 7, 0, 1, tzinfo=datetime.UTC),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_converter_maps_message_edit_delete_and_reaction_events():
|
||||
message = fake_message()
|
||||
received = await DiscordEventConverter.message_to_eba(message)
|
||||
|
||||
assert isinstance(received, platform_events.MessageReceivedEvent)
|
||||
assert received.type == 'message.received'
|
||||
assert received.adapter_name == 'discord'
|
||||
assert received.chat_type == platform_entities.ChatType.GROUP
|
||||
assert received.chat_id == 789
|
||||
assert received.group.id == 456
|
||||
assert isinstance(received.message_chain[1], platform_message.Plain)
|
||||
assert isinstance(received.message_chain[2], platform_message.At)
|
||||
|
||||
edited = await DiscordEventConverter.message_edit_to_eba(message, fake_message(content='edited', message_id=999))
|
||||
assert isinstance(edited, platform_events.MessageEditedEvent)
|
||||
assert edited.type == 'message.edited'
|
||||
assert str(edited.new_content) == 'edited'
|
||||
|
||||
deleted = await DiscordEventConverter.message_delete_to_eba(message)
|
||||
assert isinstance(deleted, platform_events.MessageDeletedEvent)
|
||||
assert deleted.type == 'message.deleted'
|
||||
assert deleted.message_id == 999
|
||||
|
||||
reaction = SimpleNamespace(message=message, emoji='👍')
|
||||
reaction_event = DiscordEventConverter.reaction_to_eba(reaction, fake_user(321, 'reactor'), True)
|
||||
assert isinstance(reaction_event, platform_events.MessageReactionEvent)
|
||||
assert reaction_event.type == 'message.reaction'
|
||||
assert reaction_event.reaction == '👍'
|
||||
assert reaction_event.user.id == 321
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_converter_maps_uncached_raw_gateway_events():
|
||||
raw_delete = SimpleNamespace(message_id=10, channel_id=20, guild_id=30)
|
||||
deleted = await DiscordEventConverter.target2yiri(('raw_message_delete', raw_delete), bot_user_id=1)
|
||||
assert isinstance(deleted, platform_events.MessageDeletedEvent)
|
||||
assert deleted.message_id == 10
|
||||
assert deleted.chat_id == 20
|
||||
assert deleted.group.id == 30
|
||||
|
||||
raw_reaction = SimpleNamespace(
|
||||
message_id=11,
|
||||
channel_id=21,
|
||||
guild_id=31,
|
||||
user_id=41,
|
||||
emoji='🔥',
|
||||
member=fake_user(41, 'member'),
|
||||
)
|
||||
reaction = await DiscordEventConverter.target2yiri(('raw_reaction_add', raw_reaction), bot_user_id=1)
|
||||
assert isinstance(reaction, platform_events.MessageReactionEvent)
|
||||
assert reaction.reaction == '🔥'
|
||||
assert reaction.is_add is True
|
||||
assert reaction.user.id == 41
|
||||
|
||||
|
||||
def test_discord_converter_maps_member_and_bot_guild_events():
|
||||
guild = fake_guild()
|
||||
member = SimpleNamespace(
|
||||
**fake_user(123, 'member').__dict__,
|
||||
guild=guild,
|
||||
joined_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC),
|
||||
)
|
||||
|
||||
joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=999)
|
||||
assert isinstance(joined, platform_events.MemberJoinedEvent)
|
||||
assert joined.join_type == 'direct'
|
||||
|
||||
bot_joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=123)
|
||||
assert isinstance(bot_joined, platform_events.BotInvitedToGroupEvent)
|
||||
|
||||
bot_invited = DiscordEventConverter.guild_join_to_eba(guild)
|
||||
bot_removed = DiscordEventConverter.guild_remove_to_eba(guild)
|
||||
assert isinstance(bot_invited, platform_events.BotInvitedToGroupEvent)
|
||||
assert isinstance(bot_removed, platform_events.BotRemovedFromGroupEvent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_send_and_reply_omit_empty_files_and_return_message_result(monkeypatch):
|
||||
adapter = make_adapter()
|
||||
sent = SimpleNamespace(id=111)
|
||||
channel = SimpleNamespace(send=AsyncMock(return_value=sent))
|
||||
object.__setattr__(adapter, '_get_channel', AsyncMock(return_value=channel))
|
||||
|
||||
result = await adapter.send_message(
|
||||
'group',
|
||||
'789',
|
||||
platform_message.MessageChain([platform_message.Plain(text='hello')]),
|
||||
)
|
||||
|
||||
assert result.message_id == 111
|
||||
assert channel.send.await_args.kwargs == {'content': 'hello'}
|
||||
|
||||
monkeypatch.setattr(discord_adapter_module.discord, 'Message', SimpleNamespace)
|
||||
source_message = SimpleNamespace(channel=channel)
|
||||
source = platform_events.MessageReceivedEvent(
|
||||
message_id=1,
|
||||
source_platform_object=source_message,
|
||||
)
|
||||
result = await adapter.reply_message(
|
||||
source,
|
||||
platform_message.MessageChain([platform_message.Plain(text='reply')]),
|
||||
quote_origin=True,
|
||||
)
|
||||
|
||||
assert result.message_id == 111
|
||||
assert channel.send.await_args.kwargs['content'] == 'reply'
|
||||
assert channel.send.await_args.kwargs['reference'] is source_message
|
||||
assert channel.send.await_args.kwargs['mention_author'] is False
|
||||
Reference in New Issue
Block a user