Skip to content

fix(runtime): route DingTalk 1:1 replies by staff id - #5112

Open
ying-hua wants to merge 1 commit into
apache:mainfrom
ying-hua:fix/dingtalk-oto-send-route
Open

fix(runtime): route DingTalk 1:1 replies by staff id#5112
ying-hua wants to merge 1 commit into
apache:mainfrom
ying-hua:fix/dingtalk-oto-send-route

Conversation

@ying-hua

@ying-hua ying-hua commented Sep 9, 2026

Copy link
Copy Markdown

Summary

DingTalk direct messages were received but could never be answered. The channel looked healthy throughout — connected, operational, ingesting text — so from the user's side the bot simply went quiet.

Two defects stacked, and fixing only the first would not have helped:

  1. The route was guessed from the id, and the guess was wrong. pickDingTalkSendRoute picked the group endpoint whenever the chatId began with cid. A 1:1 conversationId also begins with cid, so every direct reply went to /v1.0/robot/groupMessages/send.
  2. There was no usable 1:1 address in the event. The single-chat endpoint addresses recipients by senderStaffId, which DingTalkBotMessagePayload never declared — so it was dropped at parse time. Neither conversationId nor senderId is accepted there.

The fix stamps the route into the chatId at receive time, where DingTalk's conversationType still says what the conversation is, and decodes it at send time. That is the convention qq-bridge.ts already follows — "based on the chatId prefix that the receive-side helpers stamp" — and DingTalk was the one bridge inferring from the platform's native id format instead. Group chats now carry group:<openConversationId>, single chats oto:<senderStaffId>.

The alternative — adding isGroup to BotSendOptions, which BotReplyStreamOptions already declares — would leave defect 2 unfixed, since senderStaffId would still never be captured.

Fixes #5111

Compatibility

Unprefixed chatIds keep resolving to the group endpoint, which is what they resolved to when they were saved. This matters because a chatId is not always freshly received:

  • scheduled-task bot delivery persists {platform, chatId} into workflow_scheduled_tasks.record_json (and fire claims), migrated verbatim from the legacy workflow_plan_reminders table, with no format validation;
  • the scheduled task form takes the chatId as free text, and DingTalk is in BOT_DELIVERY_PROVIDERS.

Making unprefixed ids unroutable would have silently broken existing DingTalk reminders at fire time. Group reminders keep working unchanged; 1:1 reminders were already failing before this PR and still need an oto:<staffId> target — noted under Follow-ups.

The ${platform}:${chatId} conversation map is process-local and rebuilt each launch, so the group chatId format change costs at most one lost session binding across a restart.

Verification

Unit — the tests fail without the fix. Reverting only dingtalk-bridge.ts to upstream/main and keeping the new tests:

# tests 14
# pass 8
# fail 6
not ok - routes a stamped single chat to the 1:1 endpoint by staff id
not ok - routes a stamped group chat to the group endpoint
not ok - rejects empty and prefix-only ids
not ok - addresses a single chat by staff id, not by conversation id
not ok - addresses a group chat by conversation id
not ok - round-trips a single chat from receive to the 1:1 send body

With the fix: # tests 14 # pass 14 # fail 0. Full bot suite: # tests 107 # pass 107 # fail 0.

The old fixtures passed because they were invented — cidp-abc for a group, user-99 for a single chat. No real 1:1 conversation id looks like user-99; real ones start with cid and took the group branch. The replacements use realistic shapes where both kinds begin with cid, so any implementation that infers the route from the id fails them, plus round-trip cases from payload through to send body.

Live account — the patched path, end to end. A probe mirroring the patched dingTalkPayloadToEvent and pickDingTalkSendRoute line-for-line connected over DingTalk Stream and auto-replied to incoming direct messages: no human copied an id between steps, so receive → stamp → decode → send is exercised as one chain. Two direct messages, two successful replies, both confirmed received in the DingTalk client. Identifiers masked.

Direct message — the path that was broken:

[probe] senderStaffId present in payload: true
[probe] mapped event: isGroup=false chatId=oto:…80 <24 chars> text="hi"
[probe] route chosen: POST /v1.0/robot/oToMessages/batchSend
[probe] ✅ REPLY DELIVERED — HTTP 200, processQueryKey present: true

Line by line: senderStaffId is present in the callback payload, so the old interface was discarding it; the stamped chatId identifies the conversation as 1:1; the route resolves to the single-chat endpoint, where the old code would have chosen the group endpoint; and the send returns 200.

Group message — the path that had to keep working. The bot was installed into a group and @-mentioned:

[probe] mapped event: isGroup=true chatId=grou…== <33 chars> text=" hi"
[probe] route chosen: POST /v1.0/robot/groupMessages/send
[probe] ✅ REPLY DELIVERED — HTTP 200, processQueryKey present: true

Two messages per conversation kind, four replies, all delivered and confirmed received in the DingTalk client.

Live account — three-way controlled experiment (how the diagnosis was pinned down before the patch). Same conversation, same robotCode, same token, same text; only the target id varied.

# Target passed as chatId Route Result
A conversationId (cid…=) — what the bridge stamped before this PR group ❌ 400 resource.not.found — "robot 不存在;请确认 robotCode 是否正确"
B senderId ($:LWCP_v1:$…) 1:1 ❌ 400 staffId.notExisted
C senderStaffId — what this PR stamps 1:1 ✅ delivered, receipt confirmed in the DingTalk client

Row A is what shipped; row C is what this PR does.

Row A's error is misleading: it blames robotCode, but robotCode is correct — the raw callback payload's robotCode matched the app's AppKey exactly, confirming the existing derivation from appId. The group endpoint rejects a 1:1 conversation and reports it as a missing robot.

Checks run: biome check on both changed files, tsc build of @maka/runtime, ASF header audit, protocol epoch guard, git diff --check.

Not run: the full repo suite (npm test) — this touches one bridge's pure helpers and the bot suite passes in full. The husky pre-commit wrapper could not spawn biome.cmd in this Windows environment, so its checks were run directly instead; the commit is --no-verify for that reason alone.

Scope

Both conversation kinds are exercised against a live account. Not covered: non-text message types, and orgs where the callback payload omits senderStaffId (external contacts) — that case falls back to the bare conversationId, which keeps the message flowing upward and routes the send exactly as it did before this PR.

Follow-ups (not in this PR)

  • The scheduled task form's chatId field is free text with the placeholder 例如 Telegram chat_id and no per-platform guidance. Targeting a DingTalk 1:1 reminder now requires typing oto:<staffId>, which nothing in the product teaches. QQ has had the same undocumented requirement (channel: / group: / c2c:) since it landed.
  • qq-bridge.ts looks to have a defect of the same family: guild DMs are stamped `dm:${channelLike.chatId}` producing dm:channel:<id>, but pickQQSendRoute has no dm: branch and falls through to return null, so those replies never send. Untested against a live account; I have not filed it.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code (Opus 5) — diagnosis via live-account probes, the patch, and the tests. Reviewed and verified by the author against a real DingTalk app. Commit carries a Generated-by trailer.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

DingTalk direct messages were received but could never be answered.

`pickDingTalkSendRoute` chose the group endpoint whenever the chatId
began with `cid`. A 1:1 `conversationId` also begins with `cid`, so
every direct reply went to `/v1.0/robot/groupMessages/send` and came
back HTTP 400 `resource.not.found` — an error that blames `robotCode`,
which is in fact correct.

Fixing the prefix test alone would not have helped: the 1:1 endpoint
addresses recipients by `senderStaffId`, and that field was never
declared on `DingTalkBotMessagePayload`, so it was dropped at parse
time. Neither `conversationId` nor `senderId` is accepted there
(`senderId` returns `staffId.notExisted`).

Stamp the route into the chatId at receive time, where DingTalk's
`conversationType` still says what the conversation is, and decode it
at send time — the convention `qq-bridge.ts` already follows. Group
chats carry `group:<openConversationId>`, single chats carry
`oto:<senderStaffId>`.

Unprefixed ids keep resolving to the group endpoint. Scheduled-task
bot delivery persists a chatId in `workflow_scheduled_tasks`, and the
scheduled task form takes one as free text, so ids predating the
prefix must keep the meaning they had when they were saved.

The old tests passed because their fixtures were invented: `cidp-abc`
for a group and `user-99` for a single chat. No real 1:1 conversation
id looks like `user-99`. The replacements use realistic shapes where
both kinds start with `cid`, so inferring the route from the id fails
them, plus round-trip cases from payload through to send body.

Refs apache#5111

Generated-by: Claude Code (Opus 5)
@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 9, 2026
@ying-hua
ying-hua marked this pull request as ready for review September 9, 2026 14:13
ying-hua added a commit to ying-hua/maka that referenced this pull request Sep 9, 2026
Rebased onto current main, which renamed the per-channel status reasons
to snake_case. Updated the three the guide quotes:
missing-slack-tokens to slack_tokens_missing,
missing-feishu-credentials to feishu_credentials_missing, and the
generic no-credentials to wecom_credentials_missing now that each
channel carries its own code.

Re-derived the capability matrix against the new tree; every row still
maps to the same bridges, and the load-bearing constants are unchanged
(Discord's 2000-character limit, Telegram's 4000 UTF-16 units and
message-only allowed_updates, QQ's fatal 4004/4014 closes, Slack
promoting to operational on connect).

Points the DingTalk defect at apache#5112, which replaces the
prefix guess with chat IDs stamped at receive time. That fix is not on
main yet, so the section still documents the broken behaviour and says
what to rewrite once it lands.

Refs apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(runtime): DingTalk 1:1 replies are unroutable — conversationId misrouted to the group endpoint and senderStaffId is never captured

1 participant