mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-18 07:47:14 +00:00
feat: add discord eba adapter
This commit is contained in:
committed by
huanghuoguoguo
parent
005db16a5b
commit
5f9672d37b
@@ -12,7 +12,7 @@ This directory records adapter-level migration details for the Event-Based Agent
|
|||||||
| Adapter | Status | Document |
|
| Adapter | Status | Document |
|
||||||
|---------|--------|----------|
|
|---------|--------|----------|
|
||||||
| Telegram | Migrated and live-tested | [Telegram](./telegram.md) |
|
| Telegram | Migrated and live-tested | [Telegram](./telegram.md) |
|
||||||
| Discord | In progress | [Discord](./discord.md) |
|
| Discord | Migrated and live-tested | [Discord](./discord.md) |
|
||||||
|
|
||||||
## Documentation Checklist
|
## Documentation Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Discord is currently being migrated from the legacy source adapter:
|
Discord has been migrated from the legacy source adapter:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/langbot/pkg/platform/sources/discord.py
|
src/langbot/pkg/platform/sources/discord.py
|
||||||
src/langbot/pkg/platform/sources/discord.yaml
|
src/langbot/pkg/platform/sources/discord.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Target EBA directory:
|
EBA adapter directory:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/langbot/pkg/platform/adapters/discord/
|
src/langbot/pkg/platform/adapters/discord/
|
||||||
@@ -23,6 +23,8 @@ src/langbot/pkg/platform/adapters/discord/
|
|||||||
└── voice.py
|
└── voice.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `discord-eba`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Field | Required | Default | Description |
|
| Field | Required | Default | Description |
|
||||||
@@ -30,11 +32,11 @@ src/langbot/pkg/platform/adapters/discord/
|
|||||||
| `client_id` | Yes | `""` | Discord application client ID. |
|
| `client_id` | Yes | `""` | Discord application client ID. |
|
||||||
| `token` | Yes | `""` | Discord bot token. |
|
| `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.received`
|
||||||
- `message.edited`
|
- `message.edited`
|
||||||
@@ -42,36 +44,37 @@ Initial EBA migration should support:
|
|||||||
- `message.reaction`
|
- `message.reaction`
|
||||||
- `group.member_joined`
|
- `group.member_joined`
|
||||||
- `group.member_left`
|
- `group.member_left`
|
||||||
|
- `group.member_banned`
|
||||||
- `bot.invited_to_group`
|
- `bot.invited_to_group`
|
||||||
- `bot.removed_from_group`
|
- `bot.removed_from_group`
|
||||||
- `platform.specific`
|
- `platform.specific`
|
||||||
|
|
||||||
Discord-specific events that do not map cleanly to common events should be surfaced as `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. |
|
| `send_message` | Supported | Supports text, image, file, and mixed message chains through Discord messages and attachments. |
|
||||||
| `reply_message` | Supported | Uses Discord message references. |
|
| `reply_message` | Supported | Uses Discord message references when replying to a received EBA message event. |
|
||||||
| `edit_message` | Supported | Bot can edit its own messages. |
|
| `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. |
|
| `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. |
|
| `forward_message` | Emulated | Discord has no native forward API; the adapter copies content and attachments. |
|
||||||
| `get_group_info` | Supported | Maps Discord guild/channel metadata into EBA group info depending on target ID. |
|
| `get_group_info` | Supported | Maps Discord guild metadata to EBA group info. |
|
||||||
| `get_group_member_list` | Supported | Requires member cache or guild member fetch permissions. |
|
| `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_group_member_info` | Supported | Maps Discord roles/permissions into EBA member roles. |
|
||||||
| `get_user_info` | Supported | Uses Discord user fetch/cache. |
|
| `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. |
|
| `upload_file` | Not supported | Discord uploads files as message attachments; standalone upload raises `NotSupportedError`. |
|
||||||
| `get_file_url` | Supported for attachment URLs | Discord attachment URLs are already downloadable URLs. |
|
| `get_file_url` | Supported | Discord attachment URLs are already downloadable URLs, so the adapter returns the input URL. |
|
||||||
| `mute_member` | Supported where possible | Prefer Discord timeout API for guild members. |
|
| `mute_member` | Supported where possible | Uses Discord timeout API and requires guild moderation permission. |
|
||||||
| `unmute_member` | Supported where possible | Clears timeout. |
|
| `unmute_member` | Supported where possible | Clears timeout and requires guild moderation permission. |
|
||||||
| `kick_member` | Supported | Destructive; test only with a disposable account/bot. |
|
| `kick_member` | Supported | Destructive; test only with a disposable account/bot. |
|
||||||
| `leave_group` | Supported | Bot leaves a guild; destructive and should run last. |
|
| `leave_group` | Supported | Bot leaves a guild; destructive and should run last. |
|
||||||
| `call_platform_api` | Supported | Discord-specific actions live here. |
|
| `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_channel`
|
||||||
- `get_guild`
|
- `get_guild`
|
||||||
@@ -84,7 +87,7 @@ Initial actions to expose through `call_platform_api`:
|
|||||||
- `remove_reaction`
|
- `remove_reaction`
|
||||||
- `typing`
|
- `typing`
|
||||||
|
|
||||||
Voice actions should stay Discord-specific:
|
Voice helpers are intentionally kept Discord-specific:
|
||||||
|
|
||||||
- `join_voice_channel`
|
- `join_voice_channel`
|
||||||
- `leave_voice_channel`
|
- `leave_voice_channel`
|
||||||
@@ -92,17 +95,26 @@ Voice actions should stay Discord-specific:
|
|||||||
- `list_active_voice_connections`
|
- `list_active_voice_connections`
|
||||||
- `get_voice_channel_info`
|
- `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.
|
```bash
|
||||||
2. Invite it to the LangBot server with message, member, reaction, and moderation permissions needed by the test.
|
uv run python tests/e2e/live_discord_eba_probe.py --help
|
||||||
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.
|
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:
|
||||||
6. Verify attachment/file URL behavior.
|
|
||||||
7. Verify guild/channel/member info APIs.
|
- SDK standalone runtime started with WebSocket control/debug ports, and the `EBAEventProbe` plugin connected through `lbp run`.
|
||||||
8. Verify platform-specific APIs such as typing, pin/unpin, invite, reaction.
|
- Plugin runtime received real Discord events through LangBot: `BotInvitedToGroup`, `MessageReceived`, `MessageReactionReceived` add/remove, `MessageEdited`, and `MessageDeleted`.
|
||||||
9. Verify moderation APIs against a disposable target member if available.
|
- 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`.
|
||||||
10. Run destructive `leave_group` only at the very end or skip it when preserving the server membership matters.
|
- 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`.
|
||||||
|
|||||||
+1
-1
@@ -120,7 +120,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[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]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||||
|
|
||||||
|
__all__ = ['DiscordAdapter']
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"/>
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<g id="SVGRepo_iconCarrier"> <g> <path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fill-rule="nonzero"> </path> </g> </g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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()
|
||||||
@@ -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