Merged
Conversation
Provide a reusable async send library at flocks.channel.builtin.dingtalk that pushes text/markdown messages through the enterprise app-robot OAPI v1.0 endpoints (/robot/oToMessages/batchSend for 1:1, /robot/groupMessages/send for groups), mirroring how Feishu and WeCom expose their outbound surfaces. The package intentionally does **not** register a ChannelPlugin: the "dingtalk" channel id is owned by the project-local Node.js plugin at .flocks/plugins/channels/dingtalk/dingtalk.py, and that plugin can opt in to active push by importing send_message_app directly. clientId/clientSecret are accepted as aliases for appKey/appSecret so DingTalk Stream-style configs work unchanged. Custom group-robot incoming webhooks are out of scope. Made-with: Cursor
…ons from runner
Closes the loop so the `channel_message` tool / `POST /api/channel/session-send`
can actually deliver agent-initiated messages back to a DingTalk conversation.
Why
- The previous commit shipped `flocks.channel.builtin.dingtalk.send_message_app`
as a pure library; the local plugin's `send_text` was still
`not_implemented`, and the Node.js runner created Flocks sessions out-of-
process without ever populating `channel_bindings`. Result: outbound 404'd
with `未找到 session 'ses_…' 的渠道绑定`, and any send that did reach the
plugin fell through to the not-implemented branch.
What
- session_binding: add `SessionBindingService.bind_session(...)` — an
idempotent, public path to register a (channel, conversation) → session
row for an *already-created* session, used by bridges that bypass
`InboundDispatcher.resolve_or_create`.
- server: expose it as `POST /api/channel/{channel_id}/bind`. Whitelist
`chat_type` to `direct|group` (broadcast targets aren't reply-addressable);
unknown session_id → 404, bad chat_type → 400.
- runner.ts: after `getOrCreateSession` actually creates a new session, call
the new endpoint best-effort with `chat_type`/`chat_id` parsed from
`sessionKey`. Failure only degrades outbound, never inbound.
- local plugin `send_text`: delegate to `send_message_app`. Rather than
reading `self._config` (which `PluginLoader` overwrites when it scans the
project subdir twice — default scan's project step + the explicit
`_load_project_channels` — leaving the registry instance with
`_config = None`), live-read the dingtalk block from `Config.get()` each
call. Falls back to `self._config` only when the global config can't be
read. Bonus: UI edits take effect without restarting the runner.
- builtin/dingtalk/config: `robotCode` now defaults to the resolved
`appKey`/`clientId`, matching DingTalk's standard "enterprise internal app
robot" setup. Users no longer have to repeat the value in `flocks.json`;
override only when one app hosts multiple robots.
- builtin/dingtalk/send: error message now points at the real culprit
(missing `appKey/clientId`) since `robotCode` can no longer be unset on its
own.
- runner.ts inbound env: accept `appKey`/`appSecret` as aliases for
`clientId`/`clientSecret` so a single credential pair drives both Stream
inbound and OAPI outbound.
Tests
- Add `TestSessionBindingServiceBindSession` (happy path + missing-session
ValueError) and `TestBindEndpoint` (200 / 400 invalid chat_type / 404
session not found) using FastAPI TestClient.
- Add `TestLocalPluginSendText`: delegation, missing target rejection,
`DingTalkApiError` propagation, robotCode defaulting, and a regression
test that `_resolve_outbound_config` reads `cfg.channels` directly so a
freshly-registered (un-`start()`-ed) instance still finds credentials.
- Update credential-resolver tests to cover the appKey-default and
per-account fallback for `robotCode`.
41/41 dingtalk tests pass; full `tests/channel/` 211 passed (the single
failure `test_runner_extracts_pdf_content` is a pre-existing issue on main,
unrelated to this change).
Made-with: Cursor
…omposite keys Addresses PR #155 review (blocker): under `groupSessionScope=group_sender`, plugin.ts builds `peerId = <conversationId>:<senderId>` purely as a session-isolation composite. runner.ts used to persist that as the binding's chat_id, which /api/channel/session-send then forwarded verbatim as OAPI `openConversationId`, silently sending to an invalid group id. Fix - runner.ts: for group chats take chat_id from `info.conversationId` only; never fall back to peerId. For direct chats keep peerId (== senderId == staffId, the valid OAPI user target). When conversationId is absent — notably `separateSessionByConversation=false` + group, where the connector doesn't even record it — skip binding with a console.warn rather than persist a non-routable id. Defense-in-depth (server-side) - `POST /api/channel/{channel_id}/bind` now 400s when `chat_type=group` and `chat_id` contains ':'. That pattern uniquely marks the session-isolation composite keys used by DingTalk's group_sender mode (and would be nonsense as an openConversationId). The check guarantees this bug cannot regress into the bindings table from any future out-of-process bridge. Direct chats keep the ':' allowed — platforms like Feishu embed it in user IDs. Tests - New `test_bind_endpoint_rejects_group_sender_composite_key`: 400 + the service's bind_session is never called. - New `test_bind_endpoint_accepts_colon_in_direct_targets`: guard is group-only, doesn't touch namespaced direct IDs. 43/43 dingtalk tests pass. Made-with: Cursor
xiami762
approved these changes
Apr 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Feat(dingtalk): wire active outbound through send_text and bind sessions from runner
Closes the loop so the
channel_messagetool /POST /api/channel/session-sendcan actually deliver agent-initiated messages back to a DingTalk conversation.
Why
flocks.channel.builtin.dingtalk.send_message_appas a pure library; the local plugin's
send_textwas stillnot_implemented, and the Node.js runner created Flocks sessions out-of-process without ever populating
channel_bindings. Result: outbound 404'dwith
未找到 session 'ses_…' 的渠道绑定, and any send that did reach theplugin fell through to the not-implemented branch.
What
SessionBindingService.bind_session(...)— anidempotent, public path to register a (channel, conversation) → session
row for an already-created session, used by bridges that bypass
InboundDispatcher.resolve_or_create.POST /api/channel/{channel_id}/bind. Whitelistchat_typetodirect|group(broadcast targets aren't reply-addressable);unknown session_id → 404, bad chat_type → 400.
getOrCreateSessionactually creates a new session, callthe new endpoint best-effort with
chat_type/chat_idparsed fromsessionKey. Failure only degrades outbound, never inbound.send_text: delegate tosend_message_app. Rather thanreading
self._config(whichPluginLoaderoverwrites when it scans theproject subdir twice — default scan's project step + the explicit
_load_project_channels— leaving the registry instance with_config = None), live-read the dingtalk block fromConfig.get()eachcall. Falls back to
self._configonly when the global config can't beread. Bonus: UI edits take effect without restarting the runner.
robotCodenow defaults to the resolvedappKey/clientId, matching DingTalk's standard "enterprise internal approbot" setup. Users no longer have to repeat the value in
flocks.json;override only when one app hosts multiple robots.
(missing
appKey/clientId) sincerobotCodecan no longer be unset on itsown.
appKey/appSecretas aliases forclientId/clientSecretso a single credential pair drives both Streaminbound and OAPI outbound.