diff --git a/docs/event-based-agents/adapters/00-index.md b/docs/event-based-agents/adapters/00-index.md index 3f399994..e802bc4f 100644 --- a/docs/event-based-agents/adapters/00-index.md +++ b/docs/event-based-agents/adapters/00-index.md @@ -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. diff --git a/docs/event-based-agents/adapters/acceptance-checklist.md b/docs/event-based-agents/adapters/acceptance-checklist.md index 5dd3a23b..14eb9f4f 100644 --- a/docs/event-based-agents/adapters/acceptance-checklist.md +++ b/docs/event-based-agents/adapters/acceptance-checklist.md @@ -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. diff --git a/docs/event-based-agents/adapters/acceptance-report.md b/docs/event-based-agents/adapters/acceptance-report.md index 952b2825..acb47a7a 100644 --- a/docs/event-based-agents/adapters/acceptance-report.md +++ b/docs/event-based-agents/adapters/acceptance-report.md @@ -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. diff --git a/docs/event-based-agents/adapters/aiocqhttp.md b/docs/event-based-agents/adapters/aiocqhttp.md index 530875ed..270ceb2a 100644 --- a/docs/event-based-agents/adapters/aiocqhttp.md +++ b/docs/event-based-agents/adapters/aiocqhttp.md @@ -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. diff --git a/docs/event-based-agents/adapters/dingtalk.md b/docs/event-based-agents/adapters/dingtalk.md new file mode 100644 index 00000000..3e1d38c0 --- /dev/null +++ b/docs/event-based-agents/adapters/dingtalk.md @@ -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. diff --git a/docs/event-based-agents/adapters/discord.md b/docs/event-based-agents/adapters/discord.md index e0381226..7f2d106f 100644 --- a/docs/event-based-agents/adapters/discord.md +++ b/docs/event-based-agents/adapters/discord.md @@ -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. diff --git a/docs/event-based-agents/adapters/telegram.md b/docs/event-based-agents/adapters/telegram.md index 887db47d..4e15e6bc 100644 --- a/docs/event-based-agents/adapters/telegram.md +++ b/docs/event-based-agents/adapters/telegram.md @@ -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. diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index f453d0bf..9f5d1d93 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -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()}') diff --git a/src/langbot/pkg/platform/adapters/dingtalk/__init__.py b/src/langbot/pkg/platform/adapters/dingtalk/__init__.py new file mode 100644 index 00000000..3d51bd22 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/__init__.py @@ -0,0 +1 @@ +"""DingTalk EBA platform adapter.""" diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py new file mode 100644 index 00000000..b07e34c5 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -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 diff --git a/src/langbot/pkg/platform/adapters/dingtalk/api_impl.py b/src/langbot/pkg/platform/adapters/dingtalk/api_impl.py new file mode 100644 index 00000000..6db28ef4 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/api_impl.py @@ -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) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/dingtalk.svg b/src/langbot/pkg/platform/adapters/dingtalk/dingtalk.svg new file mode 100644 index 00000000..b60653b7 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/dingtalk.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py new file mode 100644 index 00000000..1965a5a5 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py @@ -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 diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml new file mode 100644 index 00000000..d11af49e --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -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 diff --git a/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py new file mode 100644 index 00000000..92bd44d6 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py @@ -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() diff --git a/src/langbot/pkg/platform/adapters/dingtalk/platform_api.py b/src/langbot/pkg/platform/adapters/dingtalk/platform_api.py new file mode 100644 index 00000000..6491e5fb --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/platform_api.py @@ -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, +} diff --git a/src/langbot/pkg/platform/adapters/dingtalk/types.py b/src/langbot/pkg/platform/adapters/dingtalk/types.py new file mode 100644 index 00000000..c2663b9f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +ADAPTER_NAME = 'dingtalk' diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py new file mode 100644 index 00000000..4eca7508 --- /dev/null +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -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'}