Skip to content

feat/dingtalk channel outbound#155

Merged
xiami762 merged 3 commits intomainfrom
feat/dingtalk-channel-outbound
Apr 21, 2026
Merged

feat/dingtalk channel outbound#155
xiami762 merged 3 commits intomainfrom
feat/dingtalk-channel-outbound

Conversation

@duguwanglong
Copy link
Copy Markdown
Contributor

Feat(dingtalk): wire active outbound through send_text and bind sessions 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.

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
@duguwanglong duguwanglong requested a review from xiami762 April 21, 2026 07:40
…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 xiami762 merged commit bde19e6 into main Apr 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants