fix(runtime): route DingTalk 1:1 replies by staff id - #5112
Open
ying-hua wants to merge 1 commit into
Open
Conversation
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)
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>
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.
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:
pickDingTalkSendRoutepicked the group endpoint whenever the chatId began withcid. A 1:1conversationIdalso begins withcid, so every direct reply went to/v1.0/robot/groupMessages/send.senderStaffId, whichDingTalkBotMessagePayloadnever declared — so it was dropped at parse time. NeitherconversationIdnorsenderIdis accepted there.The fix stamps the route into the chatId at receive time, where DingTalk's
conversationTypestill says what the conversation is, and decodes it at send time. That is the conventionqq-bridge.tsalready 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 carrygroup:<openConversationId>, single chatsoto:<senderStaffId>.The alternative — adding
isGrouptoBotSendOptions, whichBotReplyStreamOptionsalready declares — would leave defect 2 unfixed, sincesenderStaffIdwould 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:
{platform, chatId}intoworkflow_scheduled_tasks.record_json(and fire claims), migrated verbatim from the legacyworkflow_plan_reminderstable, with no format validation;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.tstoupstream/mainand keeping the new tests: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-abcfor a group,user-99for a single chat. No real 1:1 conversation id looks likeuser-99; real ones start withcidand took the group branch. The replacements use realistic shapes where both kinds begin withcid, 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
dingTalkPayloadToEventandpickDingTalkSendRouteline-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:
Line by line:
senderStaffIdis 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:
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.chatIdconversationId(cid…=) — what the bridge stamped before this PRresource.not.found— "robot 不存在;请确认 robotCode 是否正确"senderId($:LWCP_v1:$…)staffId.notExistedsenderStaffId— what this PR stampsRow A is what shipped; row C is what this PR does.
Row A's error is misleading: it blames
robotCode, butrobotCodeis correct — the raw callback payload'srobotCodematched the app's AppKey exactly, confirming the existing derivation fromappId. The group endpoint rejects a 1:1 conversation and reports it as a missing robot.Checks run:
biome checkon both changed files,tscbuild 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 spawnbiome.cmdin this Windows environment, so its checks were run directly instead; the commit is--no-verifyfor 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)
例如 Telegram chat_idand no per-platform guidance. Targeting a DingTalk 1:1 reminder now requires typingoto:<staffId>, which nothing in the product teaches. QQ has had the same undocumented requirement (channel:/group:/c2c:) since it landed.qq-bridge.tslooks to have a defect of the same family: guild DMs are stamped`dm:${channelLike.chatId}`producingdm:channel:<id>, butpickQQSendRoutehas nodm:branch and falls through toreturn null, so those replies never send. Untested against a live account; I have not filed it.AI use
Select exactly one:
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-bytrailer.Checklist
Does this PR entail a change in behavior?