feat(platform): add dingtalk eba adapter

This commit is contained in:
Junyan Qin
2026-05-10 19:52:36 +08:00
parent 3ed35593e9
commit 950da65797
18 changed files with 1256 additions and 151 deletions

View File

@@ -15,9 +15,10 @@ Current acceptance report: [EBA Adapter Acceptance Report](./acceptance-report.m
| Adapter | Status | Document |
|---------|--------|----------|
| Telegram | Migrated and live-tested | [Telegram](./telegram.md) |
| Discord | Migrated and live-tested | [Discord](./discord.md) |
| OneBot v11 / aiocqhttp | Migrated; Matcha-tested where supported | [OneBot v11 / aiocqhttp](./aiocqhttp.md) |
| Telegram | Migrated; partial plugin E2E, media-inbound gaps remain | [Telegram](./telegram.md) |
| Discord | Migrated; partial plugin E2E, media-inbound gaps remain | [Discord](./discord.md) |
| OneBot v11 / aiocqhttp | Migrated; Matcha UI plus protocol-level multi-component coverage | [OneBot v11 / aiocqhttp](./aiocqhttp.md) |
| DingTalk | Migrated; partial plugin E2E, group/media-inbound gaps remain | [DingTalk](./dingtalk.md) |
## Documentation Checklist
@@ -29,3 +30,4 @@ When migrating a new adapter, add one document here with:
- Supported `call_platform_api` action list.
- Known unsupported APIs and the reason.
- Live test notes, including platform, channel type, destructive operations, and residual risks.
- A clear distinction between real UI inbound media, protocol-level injected inbound media, and bot outbound media.

View File

@@ -8,13 +8,15 @@ Use these evidence levels consistently in adapter records:
| Level | Meaning | Can Mark Complete |
|-------|---------|-------------------|
| `plugin-e2e` | Real SDK plugin running through standalone runtime, LangBot core, the migrated adapter, and a real or simulator platform endpoint. | Yes |
| `plugin-e2e-ui` | Real SDK plugin running through standalone runtime, LangBot core, the migrated adapter, and a real platform/simulator UI action. | Yes |
| `plugin-e2e-protocol` | Real SDK plugin running through standalone runtime, LangBot core, and the migrated adapter from a protocol-boundary event injection, such as a OneBot reverse WebSocket event. | Partial; must not be claimed as UI coverage |
| `plugin-e2e-outbound` | Real SDK plugin calls an API and the bot output is visible in the real platform/simulator UI. | Yes for send/API coverage only |
| `adapter-live` | Direct adapter probe connected to a real or simulator platform endpoint, bypassing plugin runtime. | No, auxiliary only |
| `unit` | Unit/API-shape tests with mocked platform SDK objects or mocked APIs. | No, auxiliary only |
| `not-supported` | Platform protocol or SDK has no equivalent capability. Must include reason and source. | Yes, as explicitly unsupported |
| `blocked` | Intended capability could not be verified because of credentials, permissions, endpoint gaps, or simulator gaps. | No |
The primary acceptance path must be `plugin-e2e`. `adapter-live` and `unit` tests are useful, but they do not prove the EBA architecture path.
The primary acceptance path must be `plugin-e2e-ui` for inbound UI-triggered behavior and `plugin-e2e-outbound` for bot send/API behavior. `adapter-live`, `plugin-e2e-protocol`, and `unit` tests are useful, but they must be labelled precisely.
## Required Architecture Path
@@ -47,7 +49,7 @@ The test plugin must record JSONL evidence containing:
## Required Message Receive Tests
For every adapter, inbound message conversion must be tested through `plugin-e2e` for each component the platform can receive. If the platform cannot create a component from the UI/simulator, record it as `blocked` with the endpoint limitation.
For every adapter, inbound message conversion must be tested through `plugin-e2e-ui` for each component the platform can receive. If a protocol-level injection is used, label it `plugin-e2e-protocol`; it proves the adapter/core/plugin path, but it does not prove that the user-facing platform UI can send that component. If the platform UI/simulator cannot create a component, record it as `blocked` with the endpoint limitation.
| Component | Required Receive Assertion |
|-----------|----------------------------|
@@ -68,7 +70,7 @@ The plugin must subscribe to `MessageReceivedEvent` and assert that `message_cha
## Required Message Send Tests
For every adapter, outbound message conversion must be tested through `plugin-e2e` by having the plugin call SDK platform APIs and verifying the platform UI/simulator receives the expected message.
For every adapter, outbound message conversion must be tested through `plugin-e2e-outbound` by having the plugin call SDK platform APIs and verifying the platform UI/simulator receives the expected message.
| Component | Required Send Assertion |
|-----------|-------------------------|
@@ -87,7 +89,7 @@ If a platform supports a component only in one direction, the adapter record mus
## Required Event Tests
The plugin must subscribe to every event declared in `manifest.yaml -> spec.supported_events` and record one of `plugin-e2e`, `not-supported`, or `blocked`.
The plugin must subscribe to every event declared in `manifest.yaml -> spec.supported_events` and record one of `plugin-e2e-ui`, `plugin-e2e-protocol`, `not-supported`, or `blocked`.
| Event | Required Assertion |
|-------|--------------------|
@@ -154,7 +156,8 @@ The result must be serialized into JSON-safe values before it is returned to the
Every action listed in `manifest.yaml -> spec.platform_specific_apis` must have one acceptance entry:
- `plugin-e2e`: called by the plugin against the live/simulator endpoint.
- `plugin-e2e-ui` or `plugin-e2e-outbound`: called by the plugin against the live/simulator endpoint.
- `plugin-e2e-protocol`: called by the plugin after a protocol-boundary injected event; useful for endpoint-specific simulators but must be labelled.
- `not-supported`: removed from manifest or explained if the platform SDK exposes it but this adapter intentionally does not.
- `blocked`: endpoint did not implement it, permissions missing, or safe fixture unavailable.
@@ -195,10 +198,11 @@ Each adapter document must include:
An adapter can be marked migrated only when:
1. All declared events have `plugin-e2e` or `not-supported` evidence.
2. All declared APIs have `plugin-e2e` or `not-supported` evidence.
3. All platform-supported receive/send message components have `plugin-e2e` evidence.
4. Unit tests cover conversion and API-shape boundaries.
5. The adapter document lists every blocked or skipped item honestly.
1. All declared events have `plugin-e2e-ui`, justified `plugin-e2e-protocol`, or `not-supported` evidence.
2. All declared APIs have `plugin-e2e-outbound` or `not-supported` evidence.
3. All platform-supported receive components have `plugin-e2e-ui` evidence; protocol-only receive coverage keeps the status partial.
4. All platform-supported send components have `plugin-e2e-outbound` evidence.
5. Unit tests cover conversion and API-shape boundaries.
6. The adapter document lists every blocked or skipped item honestly.
If any declared capability is only covered by `adapter-live` or `unit`, the adapter status must remain partial.

View File

@@ -7,177 +7,141 @@ Scope:
- `telegram-eba`
- `discord-eba`
- `aiocqhttp-eba`
- `dingtalk-eba`
This report follows `acceptance-checklist.md`. The primary evidence is a real SDK plugin, `EBAEventProbe`, running through standalone runtime, LangBot core, the migrated adapter, and a real platform or simulator endpoint.
This report follows `acceptance-checklist.md`. Evidence levels are intentionally strict:
- `plugin-e2e-ui`: real platform or simulator UI event reached LangBot, standalone runtime, and `EBAEventProbe`.
- `plugin-e2e-protocol`: real adapter endpoint event reached LangBot, standalone runtime, and `EBAEventProbe`, but the event was injected at the platform protocol boundary rather than sent through the UI.
- `plugin-e2e-outbound`: the plugin called SDK APIs and the resulting bot message was visible on the platform.
- `unit`: mocked converter/API coverage only.
- `blocked`: not completed, either because the platform/simulator/client could not trigger it or because a safe disposable fixture was unavailable.
- `not-supported`: the platform has no equivalent capability.
## Summary
| Adapter | Status | Acceptance summary |
|---------|--------|--------------------|
| Telegram | Accepted with documented platform limits | Private and group `MessageReceived` paths, bot invite event, outbound component sweep, SDK APIs, storage APIs, and Telegram platform APIs were verified through standalone-runtime plugin E2E. Bot API limitations and unsupported common APIs are listed below. |
| Discord | Accepted with documented platform limits | Real Discord server/channel E2E verified `MessageReceived`, common entity conversion, outbound components, SDK APIs, Discord guild/member APIs, and Discord platform APIs. Destructive moderation was not run against the shared server. |
| OneBot v11 / aiocqhttp | Accepted for Matcha-supported capabilities; partial for endpoint-gapped capabilities | Matcha E2E verified message receive, common fields, send, reply, supported outbound components, safe common APIs, safe OneBot platform APIs, and SDK storage/list APIs. Matcha lacks merged-forward, file send, and several destructive/admin fixtures; those remain blocked for that endpoint. |
| Adapter | Status | Honest acceptance summary |
|---------|--------|---------------------------|
| Telegram | Partial EBA acceptance | Real Telegram UI covered private text, group mention text, bot invite, outbound component sweep, safe SDK APIs, and safe Telegram platform APIs. Real UI inbound image/file/voice/quote was not completed in the latest plugin run. |
| Discord | Partial EBA acceptance | Real Discord UI covered group text, outbound image/file/quote/mention components, safe SDK APIs, and safe Discord platform APIs. Real UI inbound attachment/image/file/reply/mention was not completed. A later UI retry was blocked because the Discord client kept the send button disabled. |
| OneBot v11 / aiocqhttp | Partial EBA acceptance | Matcha UI covered real group text and outbound supported components/APIs. Multi-component inbound `Source/Plain/At/Face/Image/Voice/File/Quote` was verified through the real OneBot reverse WebSocket adapter endpoint, but not through Matcha UI upload/send. Matcha blocks file-send and merged-forward APIs. |
| DingTalk | Partial EBA acceptance | Real DingTalk UI covered private text and emoji-as-text inbound, outbound image/file/quote/mention fallback components, safe SDK APIs, and safe DingTalk platform APIs. Real UI inbound image/file/voice/quote and group trigger were not completed. |
No adapter in this report is marked as fully accepted for production-grade media inbound until real user-side UI image/file upload evidence exists in the plugin JSONL.
## Evidence Files
| Adapter | Real endpoint | Evidence |
|---------|---------------|----------|
| Adapter | Endpoint | Evidence |
|---------|----------|----------|
| Telegram private | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-rerun.jsonl` |
| Telegram group | Telegram Lite, `Rock'sBotGroup` | `data/temp/telegram-plugin-e2e-group.jsonl` |
| Discord | Discord web client, LangBot server, `#🐞-debugging` | `data/temp/discord-plugin-e2e-20260510-final.jsonl` |
| aiocqhttp | local Matcha, group `测试群` | `data/temp/aiocqhttp-plugin-e2e-rerun.jsonl` |
| Discord | Discord client, LangBot server, `#debugging` | `data/temp/discord-plugin-e2e-20260510-final.jsonl` |
| aiocqhttp UI | local Matcha, group `test group` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
| aiocqhttp protocol | OneBot reverse WebSocket endpoint `127.0.0.1:2280/ws` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
| DingTalk | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl` |
All runs used standalone runtime ports `5400/5401`, LangBot `--standalone-runtime`, and the plugin at `langbot-plugin-demo/EBAEventProbe`.
All plugin runs used SDK standalone runtime ports `5400/5401`, LangBot `--standalone-runtime`, and the real plugin at `langbot-plugin-demo/EBAEventProbe`.
## Unified Shape Verification
All three adapters deliver common SDK entities to plugins before LangBot core/plugin logic handles the event:
All four adapters deliver common SDK entities to plugins before LangBot core/plugin logic handles the event.
| Requirement | Telegram | Discord | aiocqhttp |
|-------------|----------|---------|-----------|
| `bot_uuid` filled | plugin-e2e: `eba-telegram-live` | plugin-e2e: `eba-discord-live` | plugin-e2e: `eba-aiocqhttp-matcha` |
| `adapter_name` filled | plugin-e2e: `telegram` | plugin-e2e: `discord` | plugin-e2e: `aiocqhttp` |
| common `MessageChain` | plugin-e2e: `At`, `Plain` in group, `Plain` in private | plugin-e2e: `Source`, `Plain` | plugin-e2e: `Source`, `Plain` |
| common user/group entities | plugin-e2e: Telegram user/group IDs and group name | plugin-e2e: Discord user, guild, channel, member count | plugin-e2e: OneBot user, group ID, group name |
| raw native object isolation | plugin-visible behavior used common fields only | plugin-visible behavior used common fields only | plugin-visible behavior used common fields only |
| Requirement | Telegram | Discord | aiocqhttp | DingTalk |
|-------------|----------|---------|-----------|----------|
| `bot_uuid` filled | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e |
| `adapter_name` filled | `telegram` | `discord` | `aiocqhttp` | `dingtalk` |
| common `MessageChain` delivered | `Plain`, group `At + Plain` | `Source + Plain` | UI `Source + Plain`; protocol `Source + Plain + At + Face + Image + Voice + File + Quote + Plain` | `Source + Plain` |
| common user/group entities | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e private user; group not completed |
| raw native object isolation | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` |
## Message Receive Components
| Component | Telegram | Discord | aiocqhttp |
|-----------|----------|---------|-----------|
| `Source` | supported by event `message_id`; converter does not currently append `Source` to chain, documented design gap | plugin-e2e | plugin-e2e |
| `Plain` | plugin-e2e private/group | plugin-e2e | plugin-e2e |
| `At` | plugin-e2e group mention | unit + supported converter path | unit + supported converter path |
| `AtAll` | not-supported: Telegram has no common broadcast mention object in Bot API messages | unit + supported converter path | unit + supported converter path |
| `Image` | supported by converter; not reproduced in plugin run | supported by converter; outbound plugin rendering verified | supported by converter; outbound plugin rendering verified |
| `Voice` | supported by converter; not reproduced in plugin run | not-supported as native voice message; Discord audio is a file attachment | unit + supported converter path |
| `File` | supported by converter; outbound plugin rendering verified | supported by converter; outbound plugin rendering verified | supported by converter; Matcha file send is blocked |
| `Quote` | supported for replies/outbound quoted send; inbound quote not reproduced | outbound quote verified; inbound structured quote not emitted by Discord | unit + supported converter path |
| `Face` | not-supported as common `Face` in current Telegram adapter | not-supported as common message component | unit + supported converter path |
| `Forward` | not-supported for inbound structured forward | not-supported for inbound structured forward | implemented where endpoint supports forward payloads; Matcha forward action blocked |
| `Unknown` | not reproduced | not reproduced | unit coverage |
| Mixed chain | group `At` + `Plain` plugin-e2e | outbound mixed chain plugin-e2e | outbound mixed chain plugin-e2e |
| Component | Telegram | Discord | aiocqhttp | DingTalk |
|-----------|----------|---------|-----------|----------|
| `Source` | design gap: event has message id but chain omits `Source` | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui |
| `Plain` | plugin-e2e-ui private/group | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui |
| `At` | plugin-e2e-ui group mention | unit; real UI mention not completed in latest run | plugin-e2e-protocol; unit | unit; group trigger not completed |
| `AtAll` | not-supported | unit only | unit only | unit/send fallback only |
| `Image` | converter/unit; real UI inbound not completed | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | converter/unit; real UI inbound not completed |
| `Voice` | converter/unit; real UI inbound not completed | not-supported as native voice; audio is attachment/file | plugin-e2e-protocol, not Matcha UI | converter/unit; real UI inbound not completed |
| `File` | converter/unit; real UI inbound not completed | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | converter/unit; real UI inbound not completed |
| `Quote` | converter/unit; real UI reply not completed | unit; real UI reply not completed | plugin-e2e-protocol | converter/unit; real UI quote not completed |
| `Face` | not-supported as common `Face` | not-supported as common `Face` | plugin-e2e-protocol | UI emoji becomes `Plain` (`[smile]` text), not `Face` |
| `Forward` | not-supported inbound | not-supported inbound | unit; Matcha forward UI/action blocked | not-supported inbound |
| Mixed chain | group `At + Plain` only | not completed inbound | plugin-e2e-protocol | not completed inbound |
## Message Send Components
| Component | Telegram | Discord | aiocqhttp |
|-----------|----------|---------|-----------|
| `Plain` | plugin-e2e | plugin-e2e | plugin-e2e |
| `At` | plugin-e2e: group mention text equivalent | plugin-e2e: user mention rendered | plugin-e2e: `@Rock` rendered |
| `AtAll` | plugin-e2e fallback text/equivalent; no native common broadcast object | plugin-e2e: `@everyone` rendered | plugin-e2e: `@全体成员` rendered |
| `Image` | plugin-e2e base64 image | plugin-e2e base64 image | plugin-e2e base64 image |
| `Voice` | not-supported in current send converter | not-supported as native voice; use `File` attachment | supported by converter; not exercised against Matcha |
| `File` | plugin-e2e document send | plugin-e2e attachment send | blocked: Matcha errors on file segment despite official segment shape |
| `Quote` | plugin-e2e quoted reply | plugin-e2e quoted reply | plugin-e2e quoted reply |
| `Face` | not-supported | not-supported | plugin-e2e converter path attempted in `plain_at_face`; Matcha accepts face-like payload path |
| `Forward` | plugin-e2e flattened forward fallback | plugin-e2e flattened forward fallback | blocked: Matcha does not support merged-forward action |
| Mixed chain | plugin-e2e | plugin-e2e | plugin-e2e except Matcha-blocked file/forward |
| Component | Telegram | Discord | aiocqhttp | DingTalk |
|-----------|----------|---------|-----------|----------|
| `Plain` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
| `At` | plugin-e2e-outbound equivalent | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback/equivalent |
| `AtAll` | plugin-e2e-outbound fallback | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback |
| `Image` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
| `Voice` | not-supported in current send converter | not-supported as native voice | converter path; not completed against Matcha UI | fallback as file/text depending DingTalk media support |
| `File` | plugin-e2e-outbound | plugin-e2e-outbound | blocked by Matcha endpoint error | plugin-e2e-outbound |
| `Quote` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback |
| `Face` | not-supported | not-supported | plugin-e2e-outbound attempted in mixed chain | fallback text |
| `Forward` | flattened fallback | flattened fallback | blocked by Matcha unsupported action | flattened fallback |
| Mixed chain | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound except blocked file/forward | plugin-e2e-outbound |
## Event Acceptance
### Telegram
| Event | Evidence | Notes |
|-------|----------|-------|
| `message.received` | plugin-e2e | Private and group messages reached `EBAEventProbe`. |
| `message.edited` | implemented; not reproduced in current plugin run | Requires user edit fixture. |
| `message.reaction` | implemented; not reproduced in current plugin run | Requires Telegram reaction update fixture. |
| `group.member_joined` | implemented; not reproduced in current plugin run | |
| `group.member_left` | adapter-live historical; not reproduced in current plugin run | |
| `group.member_banned` | adapter-live historical; not reproduced in current plugin run | |
| `bot.invited_to_group` | plugin-e2e | Adding the bot to `Rock'sBotGroup` emitted the event. |
| `bot.removed_from_group` | adapter-live historical; destructive not repeated | |
| `bot.muted` | implemented; blocked without disposable moderation target | |
| `bot.unmuted` | implemented; blocked without disposable moderation target | |
| `platform.specific` | implemented; not reproduced in current plugin run | |
### Discord
| Event | Evidence | Notes |
|-------|----------|-------|
| `message.received` | plugin-e2e | Real web-client message in `#🐞-debugging`. |
| `message.edited` | adapter-live historical; not repeated in final plugin run | |
| `message.deleted` | adapter-live historical; not repeated in final plugin run | |
| `message.reaction` | adapter-live historical; not repeated in final plugin run | |
| `group.member_joined` | blocked | No disposable user/bot join fixture in shared server. |
| `group.member_left` | blocked | No disposable user/bot leave fixture in shared server. |
| `group.member_banned` | blocked | No disposable moderation target. |
| `bot.invited_to_group` | plugin-e2e during OAuth invite | Verified by runtime event in the same run series. |
| `bot.removed_from_group` | blocked/destructive | Not repeated after final invite. |
| `platform.specific` | not reproduced | No unmapped gateway payload triggered in final run. |
### OneBot v11 / aiocqhttp
| Event | Evidence | Notes |
|-------|----------|-------|
| `message.received` | plugin-e2e | Real Matcha group message. |
| `message.deleted` | unit | Matcha recall fixture not available. |
| `group.member_joined` | unit | Matcha fixture not available. |
| `group.member_left` | unit | Matcha fixture not available. |
| `group.member_banned` | unit | Matcha fixture not available. |
| `friend.request_received` | unit | Matcha request fixture not available. |
| `friend.added` | unit | Matcha request fixture not available. |
| `bot.invited_to_group` | unit | Matcha invite fixture not available. |
| `bot.removed_from_group` | unit | destructive fixture skipped. |
| `bot.muted` | unit | Matcha moderation fixture not available. |
| `bot.unmuted` | unit | Matcha moderation fixture not available. |
| `platform.specific` | adapter-live | Lifecycle/meta events observed; plugin run focused on message path. |
| Event category | Telegram | Discord | aiocqhttp | DingTalk |
|----------------|----------|---------|-----------|----------|
| `message.received` | plugin-e2e-ui | plugin-e2e-ui | plugin-e2e-ui and plugin-e2e-protocol | plugin-e2e-ui private |
| `message.edited` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
| `message.deleted` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
| `message.reaction` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | not-supported in standard OneBot message path | not declared |
| member join/left/ban | implemented/unit or blocked without disposable users | blocked without disposable users | unit; Matcha fixture unavailable | not declared |
| bot invited/removed | invite plugin-e2e-ui for Telegram; removal blocked | invite historical/plugin-series; removal blocked | unit; Matcha fixture unavailable | not declared |
| requests/friend events | not applicable | not applicable | unit; Matcha fixture unavailable | not declared |
| `platform.specific` | implemented; not latest plugin-e2e | not latest plugin-e2e | adapter lifecycle observed; plugin focus was message path | declared for fallback; not reproduced in UI run |
## Common API Acceptance
| API | Telegram | Discord | aiocqhttp |
|-----|----------|---------|-----------|
| `send_message` | plugin-e2e | plugin-e2e | plugin-e2e |
| `reply_message` | plugin-e2e via `Quote` send | plugin-e2e via `Quote` send | plugin-e2e via `Quote` send |
| `edit_message` | adapter-live historical | adapter-live historical | not-supported: OneBot v11 has no standard edit |
| `delete_message` | adapter-live historical | adapter-live historical | unit; Matcha destructive skipped |
| `forward_message` | plugin-e2e flattened forward | plugin-e2e flattened forward | blocked: Matcha lacks merged-forward action |
| `get_message` | not-supported in Telegram adapter | not-supported in Discord adapter | plugin-e2e |
| `get_group_info` | plugin-e2e | plugin-e2e | plugin-e2e |
| `get_group_list` | not-supported in Telegram adapter | not-supported in Discord adapter | plugin-e2e |
| `get_group_member_list` | plugin-e2e, administrators/member subset | plugin-e2e | plugin-e2e returned Matcha-supported shape |
| `get_group_member_info` | plugin-e2e | plugin-e2e | plugin-e2e |
| `set_group_name` | platform-specific only | not declared common | blocked: Matcha/admin fixture not used |
| `get_user_info` | plugin-e2e | plugin-e2e | plugin-e2e |
| `get_friend_list` | not-supported | not-supported | plugin-e2e returned `[]` |
| `upload_file` | not-supported | not-supported | not-supported |
| `get_file_url` | implemented; not reproduced in final plugin run | supported URL passthrough; covered by attachment send | not-supported portable common API |
| `mute_member` | blocked without disposable target | blocked without disposable target | blocked without disposable target |
| `unmute_member` | blocked without disposable target | blocked without disposable target | blocked without disposable target |
| `kick_member` | blocked/destructive | blocked/destructive | blocked/destructive |
| `leave_group` | blocked/destructive | blocked/destructive | blocked/destructive |
| `call_platform_api` | plugin-e2e safe Telegram actions | plugin-e2e safe Discord actions | plugin-e2e safe OneBot actions |
| API area | Telegram | Discord | aiocqhttp | DingTalk |
|----------|----------|---------|-----------|----------|
| send/reply | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound, with Matcha file/forward gaps | plugin-e2e-outbound |
| edit/delete | historical/direct or unit; destructive/current UI not repeated | historical/direct; destructive/current UI not repeated | unit/destructive blocked | not declared or blocked |
| message lookup | not-supported | not-supported | plugin-e2e | inbound cache-backed where available; limited live coverage |
| group info/member info | plugin-e2e safe subset | plugin-e2e safe subset | plugin-e2e safe subset | private path only; group not completed |
| user/friend info | plugin-e2e where platform allows | plugin-e2e where platform allows | plugin-e2e | plugin-e2e private user |
| moderation/leave | blocked without disposable safe targets | blocked without disposable safe targets | blocked without disposable safe targets | blocked/not declared |
| `get_file_url` | implemented; not latest inbound-file verified | URL passthrough for attachments; inbound attachment not completed | not portable/endpoint-dependent | implemented through DingTalk media API; inbound file not completed |
| `call_platform_api` | plugin-e2e safe actions | plugin-e2e safe actions | plugin-e2e safe actions, Matcha gaps documented | plugin-e2e safe `check_access_token` |
## Platform-Specific API Acceptance
| Adapter | plugin-e2e verified | Blocked or not reproduced |
|---------|---------------------|---------------------------|
| Telegram | `get_chat_administrators`, `get_chat_member_count`, `send_chat_action` | `pin_message`, `unpin_message`, `unpin_all_messages`, `set_chat_title`, `set_chat_description`, `create_chat_invite_link`, `answer_callback_query` were not repeated in final plugin run because they are mutating or require callback fixtures. |
| Discord | `get_channel`, `typing`, `get_guild`, `get_guild_channels`, `get_guild_roles` | `create_invite`, `pin_message`, `unpin_message`, `add_reaction`, `remove_reaction` were verified by prior direct live run; final plugin run avoided extra mutation/reaction side effects. |
| aiocqhttp | `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, `can_send_record` | `get_group_honor_info` blocked by Matcha unsupported action; admin/card/title/whole-ban/record/image/forward actions require endpoint support or destructive/admin fixtures. |
| Telegram | safe chat/admin/member count/chat-action actions | mutating actions and callback-only actions were not repeated; inbound file URL path lacks latest UI file evidence |
| Discord | safe channel/guild/role/typing actions | mutating pin/reaction/invite actions were not repeated in the latest plugin run; inbound attachment paths not completed |
| aiocqhttp | safe OneBot actions such as status/version/can-send checks | `get_group_honor_info` unsupported by Matcha; admin/card/title/ban/record/file/forward require better endpoint fixtures |
| DingTalk | `check_access_token` | media download/get-file-url actions need real inbound media IDs; group actions need a working group trigger |
## SDK API Acceptance
The EBA probe verified these SDK APIs through standalone runtime on all three platform runs:
`EBAEventProbe` exercised the standalone runtime path for:
- `get_langbot_version`
- `get_bots`
- `get_bot_info`
- `send_message`
- `call_platform_api`
- plugin storage set/get/list/delete
- workspace storage set/get/list/delete
- `list_plugins_manifest`
- `list_commands`
- `list_tools`
- `list_knowledge_bases`
- bot discovery and bot info lookup
- send message
- component sweep where enabled
- platform API sweep where enabled
- plugin storage
- workspace storage
- plugin/command/tool/knowledge-base list APIs
## Residual Risks
The probe logs set `ok=true` when the sweep completed with only expected unsupported/blocked items. Individual call details are stored in the JSONL evidence files.
- Full event-matrix coverage still needs disposable Telegram/Discord accounts and richer OneBot simulator fixtures for member join/leave/ban, reactions, edit/delete, and request flows.
- Destructive moderation APIs are implemented but intentionally not re-run against shared real groups/servers.
- Matcha is not a complete OneBot v11 endpoint; file and merged-forward failures are endpoint limitations, not accepted as adapter failures.
## Residual Risks And Required Follow-Up
- Telegram, Discord, and DingTalk still require real UI inbound image and file upload evidence before they can be called media-complete.
- aiocqhttp has rich inbound component evidence only at the OneBot reverse WebSocket boundary; Matcha UI did not provide image/file upload coverage.
- DingTalk group trigger remains unclosed; current evidence is private chat only.
- Discord UI retry on May 10, 2026 was blocked by the client keeping the send button disabled even after text was entered.
- Destructive moderation and leave APIs are intentionally blocked until disposable users/groups are available.
## Conclusion
Telegram, Discord, and aiocqhttp now have real standalone-runtime plugin E2E evidence for their core EBA migration path and safe API/component surfaces. The adapters are acceptable for the supported capabilities documented here. Items marked `blocked` require disposable users/groups or a more complete simulator before they can be claimed as fully verified.
The EBA conversion path is implemented and partially proven for all four adapters, but the current evidence does not support a claim that all platforms are fully end-to-end tested for real user-side image/file inbound. This report therefore treats the adapters as partial acceptance with explicit gaps, not production-complete media acceptance.

View File

@@ -142,11 +142,12 @@ Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot `
Evidence:
- Plugin JSONL: `data/temp/aiocqhttp-plugin-e2e-rerun.jsonl`
- Plugin JSONL: `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl`
Observed and verified:
- A real Matcha group message reached the plugin as `MessageReceived` with `bot_uuid=eba-aiocqhttp-matcha`, `adapter_name=aiocqhttp`, common `Source`/`Plain` message components, common sender, and common group identifiers.
- A protocol-level OneBot reverse WebSocket event reached the plugin as `MessageReceived` with a mixed common chain: `Source`, `Plain`, `At`, `Face`, `Image`, `Voice`, `File`, `Quote`, and trailing `Plain`. This proves the real adapter + LangBot + standalone runtime + plugin path for mixed inbound OneBot payloads, but it was not sent through Matcha UI.
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
- Outbound component sweep succeeded for plain text plus `At`/`Face`, `AtAll`, base64 `Image`, and quoted reply.
- Common APIs succeeded through the plugin path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, `get_group_member_list`, and `get_group_member_info`.
@@ -154,6 +155,7 @@ Observed and verified:
Documented Matcha limits in this E2E run:
- Matcha UI did not provide a completed image/file upload/send path for inbound media. The rich inbound media evidence is `plugin-e2e-protocol`, not UI-level media upload evidence.
- Outbound `File` failed in Matcha even after the adapter emitted an official `file` segment shape.
- Outbound `Forward` failed because Matcha returned unsupported action for merged-forward.
- `get_group_honor_info` failed because Matcha returned unsupported action.

View File

@@ -0,0 +1,111 @@
# DingTalk EBA Adapter Migration Record
Status: migrated with partial plugin E2E evidence.
Adapter directory: `src/langbot/pkg/platform/adapters/dingtalk/`
## What Changed
The DingTalk adapter now has an Event-Based Agents adapter package with:
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, and platform-specific APIs.
- `adapter.py` for DingTalk client startup, native callback handling, legacy compatibility, and EBA dispatch.
- `event_converter.py` for native DingTalk events to common EBA events.
- `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components.
- `api_impl.py` for common EBA API implementations.
- `platform_api.py` for DingTalk-specific `call_platform_api` actions.
The legacy DingTalk HTTP client now returns successful JSON response bodies from proactive send methods and raises with response details on non-200 responses.
## Configuration
| Field | Required | Notes |
|-------|----------|-------|
| `client-id` | yes | DingTalk robot/client identifier. |
| `client-secret` | yes | DingTalk client secret. |
| `robot-code` | yes | Robot code used for send APIs. |
| `robot-name` | no | Used for bot mention/self filtering and display. |
| `encrypt-key` | no | DingTalk callback encryption key when configured. |
| `verification-token` | no | DingTalk callback verification token when configured. |
## Supported Events
| Event | Support | Evidence |
|-------|---------|----------|
| `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. |
| `platform.specific` | implemented | Not reproduced in the latest UI run. |
## Receive Components
| Component | Support | Evidence |
|-----------|---------|----------|
| `Source` | supported | `plugin-e2e-ui` private message. |
| `Plain` | supported | `plugin-e2e-ui` private text. DingTalk emoji currently arrives as plain text such as `[smile]`. |
| `At` | converter path | Group trigger was not completed in the latest run. |
| `AtAll` | fallback/send-side only | Not completed inbound. |
| `Image` | converter path | Real UI inbound image was not completed. |
| `Voice` | converter path | Real UI inbound voice was not completed. |
| `File` | converter path | Real UI inbound file was not completed. |
| `Quote` | converter path | Real UI inbound quote was not completed. |
| `Face` | not native common mapping | DingTalk emoji was observed as `Plain`, not `Face`. |
| `Forward` | not-supported inbound | DingTalk does not expose a portable structured forward event in this adapter. |
## Send Components
| Component | Support | Evidence |
|-----------|---------|----------|
| `Plain` | supported | `plugin-e2e-outbound`. |
| `At` | supported or text fallback | `plugin-e2e-outbound`. |
| `AtAll` | fallback | `plugin-e2e-outbound`. |
| `Image` | supported | `plugin-e2e-outbound`. |
| `File` | supported | `plugin-e2e-outbound`. |
| `Quote` | fallback | `plugin-e2e-outbound`. |
| `Face` | fallback | `plugin-e2e-outbound` as text fallback. |
| `Forward` | flattened fallback | `plugin-e2e-outbound`. |
| `Voice` | fallback/endpoint-dependent | Not separately verified as a native DingTalk voice send. |
## Common APIs
| API | Support | Notes |
|-----|---------|-------|
| `send_message` | supported | Verified through `EBAEventProbe`. |
| `reply_message` | supported | Verified through quoted/fallback send path. |
| `get_message` | cache-backed | Requires the message to have been observed by this adapter process. |
| `get_group_info` | cache-backed/API-backed where available | Group path not completed in latest UI run. |
| `get_group_list` | supported where DingTalk API allows | Limited live coverage. |
| `get_group_member_info` | supported where DingTalk API allows | Limited live coverage. |
| `get_user_info` | supported | Private sender path verified. |
| `get_friend_list` | limited | DingTalk does not expose a portable friend-list equivalent. |
| `get_file_url` | supported with media/file identifiers | Needs real inbound media evidence. |
| `call_platform_api` | supported | Safe action `check_access_token` verified. |
## Platform-Specific APIs
| Action | Support | Evidence |
|--------|---------|----------|
| `check_access_token` | supported | `plugin-e2e`. |
| `refresh_access_token` | supported | Implemented; not separately reproduced in the latest plugin run. |
| `get_file_url` | supported | Needs real inbound file/media ID. |
| `get_audio_base64` | supported | Needs real inbound audio/media ID. |
| `download_image_base64` | supported | Needs real inbound image/media ID. |
## End-to-End Evidence
Evidence file: `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl`
Verified:
- DingTalk Mac private chat in the `LangBot Team` organization produced `MessageReceived` through LangBot standalone runtime and `EBAEventProbe`.
- The common chain was `Source + Plain` for normal text.
- DingTalk emoji was received as `Source + Plain`, not common `Face`.
- The plugin sent outbound text, mention/fallback, image, quote/fallback, file, and forward/fallback messages visible in DingTalk.
- The plugin called safe SDK and DingTalk platform APIs.
Not completed:
- Real UI inbound image.
- Real UI inbound file.
- Real UI inbound voice.
- Real UI inbound quote.
- Group trigger with a real robot mention.
- Destructive or organization-mutating APIs.

View File

@@ -139,6 +139,8 @@ Observed and verified:
Documented limits in this E2E run:
- Real Discord UI inbound attachment/image/file, reply/quote, and fresh mention-chain messages were not completed in the plugin E2E evidence. Outbound image/file attachments from the bot do not prove inbound attachment conversion.
- A later May 10 UI retry could write text into the Discord message box, but the client kept the send button disabled and did not send the message, so it produced no new plugin evidence.
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Discord adapter.
- Destructive moderation and guild-leave APIs were not repeated against the shared LangBot server.
- Native Discord voice is not represented as common `Voice`; audio-like payloads are treated as file attachments.

View File

@@ -122,6 +122,7 @@ Observed and verified:
Documented limits in this E2E run:
- Real Telegram UI inbound image, file, voice, sticker/emoji-as-common-component, and reply/quote messages were not completed in the plugin E2E evidence. Outbound image/file messages from the bot do not prove inbound media conversion.
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Telegram adapter.
- Mutating/destructive Telegram-specific actions such as pin/unpin, title/description changes, invite-link creation, moderation, kick, and leave were not repeated in the plugin run. They remain opt-in live-probe cases.
- Telegram does not expose a portable common `Face` component for native sticker/emoji semantics in the current adapter.

View File

@@ -438,8 +438,13 @@ class DingTalkClient:
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=data)
try:
body = response.json()
except Exception:
body = {'text': response.text}
if response.status_code == 200:
return
return body
raise Exception(f'Error: {response.status_code}, {body}')
except Exception:
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
@@ -464,8 +469,13 @@ class DingTalkClient:
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=data)
try:
body = response.json()
except Exception:
body = {'text': response.text}
if response.status_code == 200:
return
return body
raise Exception(f'Error: {response.status_code}, {body}')
except Exception:
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')

View File

@@ -0,0 +1 @@
"""DingTalk EBA platform adapter."""

View File

@@ -0,0 +1,235 @@
from __future__ import annotations
import traceback
import typing
import pydantic
from langbot.libs.dingtalk_api.api import DingTalkClient
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
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.dingtalk.api_impl import DingTalkAPIMixin
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
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
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: DingTalkClient = pydantic.Field(exclude=True)
message_converter: DingTalkMessageConverter = DingTalkMessageConverter()
event_converter: DingTalkEventConverter = DingTalkEventConverter()
config: dict
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
card_instance_id_dict: dict = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = ['client_id', 'client_secret', 'robot_name', 'robot_code']
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员')
bot = DingTalkClient(
client_id=config['client_id'],
client_secret=config['client_secret'],
robot_name=config['robot_name'],
robot_code=config['robot_code'],
markdown_card=config.get('markdown_card', True),
logger=logger,
)
super().__init__(
config=config,
logger=logger,
card_instance_id_dict={},
bot_account_id=config['robot_name'],
bot=bot,
listeners={},
_message_cache={},
_user_cache={},
_group_cache={},
)
self._register_native_handlers()
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_group_info',
'get_group_list',
'get_group_member_info',
'get_user_info',
'get_friend_list',
'get_file_url',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
markdown_enabled = self.config.get('markdown_card', False)
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
if target_type in ('person', 'private'):
raw = await self.bot.send_proactive_message_to_one(target_id, content)
elif target_type == 'group':
raw = await self.bot.send_proactive_message_to_group(target_id, content)
else:
raise ValueError(f'Unsupported dingtalk target_type: {target_type}')
return platform_events.MessageResult(raw=raw if isinstance(raw, dict) else {'result': raw})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
assert isinstance(message_source.source_platform_object, DingTalkEvent)
incoming_message = message_source.source_platform_object.incoming_message
markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
raw = await self.bot.send_message(content, incoming_message, at)
return platform_events.MessageResult(
message_id=getattr(incoming_message, 'message_id', None),
raw=raw if isinstance(raw, dict) else {'result': raw},
)
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
message_id = bot_message.resp_message_id
msg_seq = bot_message.msg_sequence
if (msg_seq - 1) % 8 != 0 and not is_final:
return
markdown_enabled = self.config.get('markdown_card', False)
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
if not content and bot_message.content:
content = bot_message.content
if content:
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
if is_final and bot_message.tool_calls is None:
self.card_instance_id_dict.pop(message_id)
async def create_message_card(self, message_id, event):
card_template_id = self.config['card_template_id']
incoming_message = event.source_platform_object.incoming_message
card_auto_layout = self.config.get('card_auto_layout', False)
card_instance, card_instance_id = await self.bot.create_and_card(
card_template_id,
incoming_message,
card_auto_layout=card_auto_layout,
)
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
return True
async def is_stream_output_supported(self) -> bool:
return bool(self.config.get('enable-stream-reply', False))
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
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
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def run_async(self):
await self.logger.info('DingTalk EBA adapter starting')
await self.bot.start()
async def kill(self) -> bool:
await self.bot.stop()
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
def _register_native_handlers(self):
async def on_message(event: DingTalkEvent):
await self._handle_native_event(event)
self.bot.on_message('FriendMessage')(on_message)
self.bot.on_message('GroupMessage')(on_message)
async def _handle_native_event(self, event: DingTalkEvent):
try:
await self.logger.debug(
'DingTalk EBA event received: '
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
)
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event, self.config['robot_name'])
if legacy_event:
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
eba_event = await self.event_converter.target2yiri(event, self.config['robot_name'])
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in dingtalk native event: {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 _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import typing
from langbot.libs.dingtalk_api.api import DingTalkClient
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.errors import NotSupportedError
class DingTalkAPIMixin:
bot: DingTalkClient
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
return self._group_cache.get(str(group_id)) or platform_entities.UserGroup(id=group_id, name='')
async def get_group_list(self) -> list[platform_entities.UserGroup]:
return list(self._group_cache.values())
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raise NotSupportedError('get_group_member_list')
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
user = self._user_cache.get(str(user_id))
if user is None:
raise NotSupportedError('get_group_member_info:user_not_cached')
return platform_entities.UserGroupMember(
user=user,
group_id=group_id,
role=platform_entities.MemberRole.MEMBER,
display_name=user.nickname,
)
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
return self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id, nickname='')
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
return await self.bot.get_file_url(file_id)

View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#4aa4f8" width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class="icon" stroke="#4aa4f8">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import typing
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.types import ADAPTER_NAME
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 DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event):
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
if event.conversation in {'FriendMessage', 'GroupMessage'}:
return await DingTalkEventConverter.message_to_eba(event, bot_name)
return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
@staticmethod
async def target2legacy(
event: DingTalkEvent,
bot_name: str,
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await DingTalkEventConverter.message_to_eba(event, bot_name)
if eba_event:
return eba_event.to_legacy_event()
return None
@staticmethod
async def message_to_eba(event: DingTalkEvent, bot_name: str) -> platform_events.MessageReceivedEvent:
incoming_message = event.incoming_message
message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name)
sender = DingTalkEventConverter.user_from_event(event)
chat_type = platform_entities.ChatType.PRIVATE
chat_id = getattr(incoming_message, 'sender_staff_id', '')
group = None
if event.conversation == 'GroupMessage':
chat_type = platform_entities.ChatType.GROUP
chat_id = getattr(incoming_message, 'conversation_id', '')
group = DingTalkEventConverter.group_from_event(event)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=getattr(incoming_message, 'message_id', ''),
message_chain=message_chain,
sender=sender,
chat_type=chat_type,
chat_id=chat_id,
group=group,
timestamp=DingTalkEventConverter._timestamp(incoming_message),
source_platform_object=event,
)
@staticmethod
def user_from_event(event: DingTalkEvent) -> platform_entities.User:
incoming_message = event.incoming_message
return platform_entities.User(
id=getattr(incoming_message, 'sender_staff_id', ''),
nickname=getattr(incoming_message, 'sender_nick', '') or '',
)
@staticmethod
def group_from_event(event: DingTalkEvent) -> platform_entities.UserGroup:
incoming_message = event.incoming_message
return platform_entities.UserGroup(
id=getattr(incoming_message, 'conversation_id', ''),
name=getattr(incoming_message, 'conversation_title', '') or '',
)
@staticmethod
def platform_specific(event: DingTalkEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data={
key: value for key, value in dict(event).items() if key not in {'IncomingMessage', 'Picture', 'Audio'}
},
timestamp=DingTalkEventConverter._timestamp(event.incoming_message),
source_platform_object=event,
)
@staticmethod
def _timestamp(incoming_message: typing.Any) -> float:
value = getattr(incoming_message, 'create_at', None)
if isinstance(value, (int, float)):
timestamp = float(value)
return timestamp / 1000 if timestamp > 10_000_000_000 else timestamp
if hasattr(value, 'timestamp'):
return float(value.timestamp())
return 0.0

View File

@@ -0,0 +1,126 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: dingtalk-eba
label:
en_US: DingTalk (EBA)
zh_Hans: 钉钉 (EBA)
zh_Hant: 釘釘 (EBA)
description:
en_US: DingTalk adapter (EBA architecture)
zh_Hans: 钉钉适配器EBA 架构版本)
zh_Hant: 釘釘適配器EBA 架構版本)
icon: dingtalk.svg
spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/dingtalk
en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk
config:
- name: client_id
label:
en_US: Client ID
zh_Hans: 客户端ID
zh_Hant: 用戶端ID
type: string
required: true
default: ""
- name: client_secret
label:
en_US: Client Secret
zh_Hans: 客户端密钥
zh_Hant: 用戶端密鑰
type: string
required: true
default: ""
- name: robot_code
label:
en_US: Robot Code
zh_Hans: 机器人代码
zh_Hant: 機器人代碼
type: string
required: true
default: ""
- name: robot_name
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
- name: markdown_card
label:
en_US: Markdown Card
zh_Hans: 是否使用 Markdown 卡片
zh_Hant: 是否使用 Markdown 卡片
type: boolean
required: false
default: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式
description:
en_US: If enabled, the bot will use DingTalk card streaming replies.
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean
required: true
default: false
- name: card_auto_layout
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
zh_Hant: 卡片寬螢幕自動佈局
type: boolean
required: false
default: false
- name: card_template_id
label:
en_US: Card Template ID
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
type: string
required: true
default: "填写你的卡片template_id"
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_group_info
- get_group_list
- get_group_member_info
- get_user_info
- get_friend_list
- get_file_url
- call_platform_api
platform_specific_apis:
- action: check_access_token
description: { en_US: "Check whether the current DingTalk access token is usable", zh_Hans: "检查当前钉钉 access token 是否可用" }
- action: refresh_access_token
description: { en_US: "Refresh the DingTalk access token", zh_Hans: "刷新钉钉 access token" }
- action: get_file_url
description: { en_US: "Resolve a DingTalk download code to a file URL", zh_Hans: "将钉钉 downloadCode 解析为文件 URL" }
- action: get_audio_base64
description: { en_US: "Download DingTalk audio as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉语音并转为 base64" }
- action: download_image_base64
description: { en_US: "Download DingTalk image as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉图片并转为 base64" }
execution:
python:
path: ./adapter.py
attr: DingTalkAdapter

View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import datetime
import typing
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
def _format_image_as_markdown(msg: platform_message.Image) -> str:
if msg.url:
return f'\n![image]({msg.url})\n'
if msg.base64:
if msg.base64.startswith('data:'):
return f'\n![image]({msg.base64})\n'
return f'\n![image](data:image/png;base64,{msg.base64})\n'
return ''
@staticmethod
def _component_text_fallback(component: platform_message.MessageComponent) -> str:
if isinstance(component, platform_message.At):
return f'@{component.display or component.target}'
if isinstance(component, platform_message.AtAll):
return '@所有人'
if isinstance(component, platform_message.File):
if component.url:
return f'\n[{component.name or "file"}]({component.url})\n'
return f'\n[File]{component.name or component.id or "file"}\n'
if isinstance(component, platform_message.Voice):
return component.url or '[Voice]'
if isinstance(component, platform_message.Face):
return str(component)
if isinstance(component, platform_message.Unknown):
return component.text
return str(component)
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
markdown_enabled: bool = True,
) -> tuple[str, bool]:
content = ''
at = False
for msg in message_chain:
if isinstance(msg, platform_message.Source):
continue
if isinstance(msg, platform_message.Plain):
content += msg.text
elif isinstance(msg, platform_message.At):
at = True
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.AtAll):
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.Image):
if markdown_enabled:
content += DingTalkMessageConverter._format_image_as_markdown(msg)
else:
content += '[Image]'
elif isinstance(msg, platform_message.File):
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.Voice):
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.Quote):
if msg.id is not None:
content += f'[引用消息 {msg.id}] '
if msg.origin:
quote_content, quote_at = await DingTalkMessageConverter.yiri2target(msg.origin, markdown_enabled)
content += quote_content
at = at or quote_at
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
sender = node.sender_name or node.sender_id or ''
if sender:
content += f'\n[{sender}] '
if node.message_chain:
forwarded_content, forwarded_at = await DingTalkMessageConverter.yiri2target(
node.message_chain, markdown_enabled
)
content += forwarded_content
at = at or forwarded_at
else:
content += DingTalkMessageConverter._component_text_fallback(msg)
return content, at
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_message.MessageChain:
incoming_message = event.incoming_message
components: list[platform_message.MessageComponent] = [
platform_message.Source(
id=getattr(incoming_message, 'message_id', ''),
time=DingTalkMessageConverter._message_time(incoming_message),
)
]
for at_user in getattr(incoming_message, 'at_users', []) or []:
if getattr(at_user, 'dingtalk_id', None) == getattr(incoming_message, 'chatbot_user_id', None):
components.append(platform_message.At(target=bot_name, display=bot_name))
rich_content = event.rich_content
if rich_content:
for element in rich_content.get('Elements') or []:
if element.get('Type') == 'text':
text = DingTalkMessageConverter._strip_bot_mention(element.get('Content', ''), bot_name)
if text.strip():
components.append(platform_message.Plain(text=text))
elif element.get('Type') == 'image' and element.get('Picture'):
components.append(platform_message.Image(base64=element['Picture']))
else:
if event.content and event.type != 'audio':
components.append(
platform_message.Plain(
text=DingTalkMessageConverter._strip_bot_mention(event.content, bot_name),
)
)
if event.picture:
components.append(platform_message.Image(base64=event.picture))
if event.file:
components.append(platform_message.File(url=event.file, name=event.name or 'file'))
if event.audio:
if event.content and event.type == 'audio':
components.append(platform_message.Plain(text=event.content))
else:
components.append(platform_message.Voice(base64=event.audio))
quote = DingTalkMessageConverter._quote_component(event)
if quote:
components.append(quote)
return platform_message.MessageChain(components)
@staticmethod
def _quote_component(event: DingTalkEvent) -> platform_message.Quote | None:
quote_info = event.quoted_message
if not quote_info:
return None
origin_components: list[platform_message.MessageComponent] = []
msg_type = quote_info.get('msg_type', '')
if msg_type == 'file' and quote_info.get('file_url'):
origin_components.append(
platform_message.File(url=quote_info['file_url'], name=quote_info.get('file_name', 'file'))
)
elif msg_type == 'picture' and quote_info.get('picture'):
origin_components.append(platform_message.Image(base64=quote_info['picture']))
elif msg_type == 'audio' and quote_info.get('audio'):
origin_components.append(platform_message.Voice(base64=quote_info['audio']))
elif quote_info.get('content'):
origin_components.append(platform_message.Plain(text=str(quote_info['content'])))
incoming_message = event.incoming_message
return platform_message.Quote(
id=quote_info.get('message_id') or None,
group_id=getattr(incoming_message, 'conversation_id', None),
sender_id=quote_info.get('sender_id') or None,
target_id=getattr(incoming_message, 'conversation_id', None)
or getattr(incoming_message, 'sender_staff_id', None),
origin=platform_message.MessageChain(origin_components),
)
@staticmethod
def _strip_bot_mention(text: str, bot_name: str) -> str:
return text.replace('@' + bot_name, '')
@staticmethod
def _message_time(incoming_message: typing.Any) -> datetime.datetime:
value = getattr(incoming_message, 'create_at', None)
if isinstance(value, datetime.datetime):
return value
if isinstance(value, (int, float)):
timestamp = float(value)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000
return datetime.datetime.fromtimestamp(timestamp)
return datetime.datetime.now()

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import typing
from langbot.libs.dingtalk_api.api import DingTalkClient
async def check_access_token(bot: DingTalkClient, params: dict) -> dict:
return {'valid': await bot.check_access_token()}
async def refresh_access_token(bot: DingTalkClient, params: dict) -> dict:
await bot.get_access_token()
return {'ok': bool(bot.access_token)}
async def get_file_url(bot: DingTalkClient, params: dict) -> dict:
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
if not download_code:
raise ValueError('download_code is required')
return {'url': await bot.get_file_url(str(download_code))}
async def get_audio_base64(bot: DingTalkClient, params: dict) -> dict:
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
if not download_code:
raise ValueError('download_code is required')
return {'base64': await bot.get_audio_url(str(download_code))}
async def download_image_base64(bot: DingTalkClient, params: dict) -> dict:
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
if not download_code:
raise ValueError('download_code is required')
return {'base64': await bot.download_image(str(download_code))}
PLATFORM_API_MAP: dict[str, typing.Callable[[DingTalkClient, dict], typing.Awaitable[dict]]] = {
'check_access_token': check_access_token,
'refresh_access_token': refresh_access_token,
'get_file_url': get_file_url,
'get_audio_base64': get_audio_base64,
'download_image_base64': download_image_base64,
}

View File

@@ -0,0 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'dingtalk'

View File

@@ -0,0 +1,254 @@
from __future__ import annotations
import pathlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from langbot.libs.dingtalk_api.api import DingTalkClient
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
from langbot.pkg.platform.adapters.dingtalk.adapter import DingTalkAdapter
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.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
class DummyDingTalkClient(DingTalkClient):
def __init__(self, *args, **kwargs):
self._message_handlers = {}
self.markdown_card = kwargs.get('markdown_card', True)
self.access_token = ''
self.send_message = AsyncMock()
self.send_proactive_message_to_one = AsyncMock(return_value={'ok': True})
self.send_proactive_message_to_group = AsyncMock(return_value={'ok': True})
self.get_file_url = AsyncMock(return_value='https://example.test/file')
self.check_access_token = AsyncMock(return_value=True)
self.get_access_token = AsyncMock()
self.get_audio_url = AsyncMock(return_value='data:audio/ogg;base64,AAAA')
self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB')
self.create_and_card = AsyncMock(return_value=('card', 'card-id'))
self.send_card_message = AsyncMock()
self.start = AsyncMock()
self.stop = AsyncMock()
def on_message(self, msg_type: str):
def decorator(func):
self._message_handlers.setdefault(msg_type, []).append(func)
return func
return decorator
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'dingtalk'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def make_adapter() -> DingTalkAdapter:
config = {
'client_id': 'client-id',
'client_secret': 'client-secret',
'robot_name': 'LangBot',
'robot_code': 'robot-code',
'markdown_card': True,
'enable-stream-reply': False,
'card_auto_layout': False,
'card_template_id': 'template-id',
}
with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient):
return DingTalkAdapter(config, DummyLogger())
def dingtalk_event(conversation='GroupMessage', **overrides) -> DingTalkEvent:
incoming = SimpleNamespace(
message_id=overrides.get('message_id', 'msg-1'),
create_at=1_714_000_000_000,
sender_staff_id=overrides.get('sender_staff_id', 'user-1'),
sender_nick=overrides.get('sender_nick', 'Alice'),
conversation_id=overrides.get('conversation_id', 'group-1'),
conversation_title=overrides.get('conversation_title', 'LangBot Team'),
chatbot_user_id='robot-dingtalk-id',
at_users=[SimpleNamespace(dingtalk_id='robot-dingtalk-id')],
)
payload = {
'IncomingMessage': incoming,
'conversation_type': conversation,
'Type': overrides.get('msg_type', 'text'),
'Content': overrides.get('content', '@LangBot hello'),
'Picture': overrides.get('picture', ''),
'Audio': overrides.get('audio', ''),
'File': overrides.get('file', ''),
'Name': overrides.get('name', ''),
'QuotedMessage': overrides.get('quoted_message'),
}
if 'rich_content' in overrides:
payload['Rich_Content'] = overrides['rich_content']
return DingTalkEvent.from_payload(payload)
def test_dingtalk_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_dingtalk_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_dingtalk_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_dingtalk_message_converter_maps_outbound_components():
content, at = await DingTalkMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi '),
platform_message.At(target='user-1', display='Alice'),
platform_message.AtAll(),
platform_message.Image(url='https://example.test/a.png'),
platform_message.File(name='doc.txt', url='https://example.test/doc.txt'),
platform_message.Quote(
id='origin', origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])
),
platform_message.Forward(
node_list=[
platform_message.ForwardMessageNode(
sender_id='user-2',
sender_name='Bob',
message_chain=platform_message.MessageChain([platform_message.Plain(text='node')]),
)
]
),
]
)
)
assert at is True
assert '@Alice' in content
assert '@所有人' in content
assert '![image](https://example.test/a.png)' in content
assert '[doc.txt](https://example.test/doc.txt)' in content
assert '[引用消息 origin]' in content
assert '[Bob] node' in content
@pytest.mark.asyncio
async def test_dingtalk_message_converter_maps_inbound_components():
event = dingtalk_event(
file='https://example.test/doc.txt',
name='doc.txt',
quoted_message={
'message_id': 'origin',
'msg_type': 'text',
'sender_id': 'user-2',
'content': 'quoted text',
},
)
chain = await DingTalkMessageConverter.target2yiri(event, 'LangBot')
assert isinstance(chain[0], platform_message.Source)
assert isinstance(chain[1], platform_message.At)
assert isinstance(chain[2], platform_message.Plain)
assert chain[2].text == ' hello'
assert isinstance(chain[3], platform_message.File)
assert isinstance(chain[4], platform_message.Quote)
assert str(chain[4].origin) == 'quoted text'
@pytest.mark.asyncio
async def test_dingtalk_event_converter_maps_group_and_private_message():
group_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot')
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.adapter_name == 'dingtalk'
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'group-1'
assert group_event.group.name == 'LangBot Team'
assert group_event.sender.id == 'user-1'
private_event = await DingTalkEventConverter.target2yiri(
dingtalk_event('FriendMessage', content='hello'),
'LangBot',
)
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-1'
assert private_event.group is None
@pytest.mark.asyncio
async def test_dingtalk_adapter_dispatches_and_caches_message_event():
adapter = make_adapter()
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.MessageReceivedEvent, listener)
event = dingtalk_event()
await adapter._handle_native_event(event)
assert len(calls) == 1
received = calls[0]
assert isinstance(received, platform_events.MessageReceivedEvent)
assert await adapter.get_message('group', 'group-1', 'msg-1') == received
assert (await adapter.get_group_info('group-1')).name == 'LangBot Team'
assert (await adapter.get_user_info('user-1')).nickname == 'Alice'
@pytest.mark.asyncio
async def test_dingtalk_send_reply_and_platform_api_use_underlying_client():
adapter = make_adapter()
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
sent = await adapter.send_message('group', 'group-1', message)
assert sent.raw == {'ok': True}
adapter.bot.send_proactive_message_to_group.assert_awaited_once()
source_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot')
replied = await adapter.reply_message(source_event, message)
assert replied.message_id == 'msg-1'
adapter.bot.send_message.assert_awaited_once()
token_status = await adapter.call_platform_api('check_access_token', {})
file_url = await adapter.call_platform_api('get_file_url', {'download_code': 'download-code'})
assert token_status == {'valid': True}
assert file_url == {'url': 'https://example.test/file'}