Skip to content

fix(channels): extract quoted DingTalk text, richText, and card replies - #9537

Draft
BenGuanRan wants to merge 2 commits into
QwenLM:mainfrom
BenGuanRan:fix/dingtalk-quoted-card-context
Draft

fix(channels): extract quoted DingTalk text, richText, and card replies#9537
BenGuanRan wants to merge 2 commits into
QwenLM:mainfrom
BenGuanRan:fix/dingtalk-quoted-card-context

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Fixes the DingTalk channel's quoted-message extraction so that replied message content actually reaches the agent. The adapter now branches on the quoted message type the way DingTalk delivers it: plain-text quotes read their body from the correct nested field, quoted richText accepts the segment shape used inside replies (in addition to the top-level shape), and quoted interactive cards — the card messages the bot itself sends — have their visible text collected from the nested card element tree. An outdated comment claiming card replies carry no content is removed.

Why it's needed

When a user quotes a previous message and replies to the bot, the quoted text is meant to be prepended to the prompt as reply context. Today only markdown quotes survive; plain-text quotes (the most common case), quoted richText, and quoted bot cards are silently dropped, so the agent answers without seeing what the user is referring to. The field mapping now matches device-verified callback payloads.

Reviewer Test Plan

How to verify

Unit tests simulate inbound DingTalk reply callbacks for each quoted shape and assert the envelope carries the extracted quote:

cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts

Expected: the new DingtalkChannel quoted message context block passes, covering text (content.content), markdown (content.text), msgType-shaped richText segments with an image placeholder, and interactiveCard tree traversal (including isReplyToBot detection when the quoted card was sent by the bot).

Real-device check (optional): in a DingTalk group/DM, quote one of the bot's card replies (or another user's text message) and send a reply to the bot; the prompt shown in debug logging should start with [Replying to: "..."].

Evidence (Before & After)

N/A (non-UI change; covered by unit tests above).

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests only (vitest), no live DingTalk credentials.

Risk & Scope

  • Main risk or tradeoff: quoted text is attacker-controlled input; extraction only changes what reaches the existing sanitizeQuotedText path in ChannelBase (500-code-point cap, control/bidi/delimiter stripping), so sanitization guarantees are unchanged.
  • Not validated / out of scope: quoted richText segments that are pure @mentions are still kept (aligning with mention-stripping behavior elsewhere is a follow-up if desired); the 500-code-point cap and [Replying to: "..."] format are ChannelBase-wide contracts and intentionally untouched; live device capture was not re-run in this PR (payload shapes follow device-verified captures of real DingTalk reply callbacks).
  • Breaking changes / migration notes: none; behavior is additive for previously dropped quotes.

Linked Issues

Fixes #9536

中文说明

这个 PR 做了什么

修复钉钉通道的引用消息提取,使被引用消息的内容能真正进入 agent 上下文。适配器现在按钉钉实际下发的引用消息类型分支处理:纯文本引用从正确的嵌套字段取正文;引用 richText 同时兼容回复内部使用的段形态(以及顶层消息的段形态);引用的 interactiveCard(即 bot 自己发出的卡片消息)从嵌套卡片元素树中收集可见文本。同时移除了一条声称卡片引用不带内容的过时注释。

为什么需要

当用户引用一条消息并回复 bot 时,被引用的文本本应作为回复上下文拼到提示词前面。但目前只有 markdown 引用能保留;纯文本引用(最常见的场景)、引用的 richText、以及引用的 bot 卡片都会被静默丢弃,导致 agent 在看不到用户所指内容的情况下作答。本次修复使字段映射与真机验证过的回调报文一致。

评审测试计划

如何验证

单元测试模拟四种引用形态的钉钉回复回调,断言 envelope 携带提取出的引用文本:

cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts

预期:新增的 DingtalkChannel quoted message context 用例组通过,覆盖 text(content.content)、markdown(content.text)、msgType 形态 richText 段(含图片占位符)、interactiveCard 元素树遍历(引用 bot 卡片时 isReplyToBot 为 true)。

真机验证(可选):在钉钉群/单聊中引用 bot 的一条卡片回复(或其他用户的文本消息)并回复 bot,debug 日志中的提示词应以 [Replying to: "..."] 开头。

前后证据

N/A(非 UI 变更,由上述单元测试覆盖)。

测试平台

macOS 已测;Windows/Linux 未测。

环境

仅单元测试(vitest),未使用真实钉钉凭据。

风险与范围

  • 主要风险/权衡:引用文本属于攻击者可控输入;本次仅改变进入 ChannelBase 既有 sanitizeQuotedText 清洗路径的内容(500 码点上限、控制字符/bidi/分隔符剥离),清洗保证不变。
  • 未验证/超出范围:引用 richText 中纯 @mention 段仍保留(如需与其他去 mention 行为对齐可作后续跟进);500 码点上限与 [Replying to: "..."] 格式是 ChannelBase 跨通道契约,有意不动;本 PR 未重新真机抓包(报文形态遵循真机验证过的钉钉回复回调捕获)。
  • 破坏性变更/迁移说明:无;对此前被丢弃的引用是纯增量行为。

关联 Issue

Fixes #9536

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 20, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on ff2ef62 and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— ff2ef62 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug, not theoretical. Linked issue #9536 documents — and I confirmed against the current code — that summarizeRepliedContent only reads content.text, has no interactiveCard branch, and only understands {type, text} richText segments. So text quotes, quoted richText, and quoted bot cards really are silently dropped today; only markdown quotes survive. The issue is self-reported by the PR author, but the defect is verifiable from the code itself. The one thing that rests on the author's word is the exact wire shapes DingTalk delivers (claimed device-verified against the internal gateway) — the new unit tests pin those shapes.

Direction: aligned. This fixes context loss in an existing channel feature — quoted reply context reaching the agent — rather than adding new surface. No CHANGELOG reference, but that's expected: DingTalk channels are Qwen Code-specific, not an upstream Claude Code feature.

Size: not applicable — packages/channels/dingtalk/** is outside the protected core paths. ~56 production lines (DingtalkAdapter.ts +47/−9) and 100 test lines.

Approach: the scope feels right. It extends the existing summarizeRepliedContent instead of adding a parallel extraction path, drops the now-incorrect comment claiming card replies carry no content, and adds adapter-level tests for all four quote shapes. No unrelated changes in the diff.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths (streaming/MCP/shell/sandbox/ACP). The quoted text is attacker-controlled input, but it feeds the existing sanitizeQuotedText path in ChannelBase (500-code-point cap, control/bidi/delimiter stripping), so the sanitization contract is unchanged.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,不是理论性问题。关联 issue #9536 记录了、并且我也在当前代码中确认了:summarizeRepliedContent 只读取 content.text、没有 interactiveCard 分支、且只识别 {type, text} 形态的 richText 段。因此纯文本引用、引用的 richText、引用的 bot 卡片目前确实会被静默丢弃,只有 markdown 引用能保留。该 issue 由 PR 作者本人提交,但缺陷本身可以从代码中直接验证。唯一依赖作者说法的是钉钉实际下发的报文形态(声称已在内部 gateway 真机验证过)——新增的单元测试把这些形态固定了下来。

方向:对齐。这是修复既有通道功能(引用回复上下文进入 agent)的上下文丢失问题,没有引入新面。CHANGELOG 无直接引用,但这属于预期:钉钉通道是 Qwen Code 特有功能,不是上游 Claude Code 的特性。

规模:不适用——packages/channels/dingtalk/** 不在核心保护路径内。约 56 行生产代码(DingtalkAdapter.ts +47/−9)和 100 行测试。

方案:范围合理。在既有 summarizeRepliedContent 上扩展,而不是新增一条并行的提取路径;移除了声称卡片引用不带内容的过时注释;并为四种引用形态都补了适配器级测试。diff 中没有无关改动。

风险:无升级风险信号——变更文件均不匹配 revert 相关的高风险路径(streaming/MCP/shell/sandbox/ACP)。引用文本属于攻击者可控输入,但它进入的是 ChannelBase 既有的 sanitizeQuotedText 清洗路径(500 码点上限、控制字符/bidi/分隔符剥离),清洗契约不变。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at ff2ef62f8491376fa3a53b8c786beffebc808bec · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal first, for honesty: given the issue, I would have branched summarizeRepliedContent on repliedMsg.msgType (text → content.content, markdown keeps content.text, richText accepts both {type, text} and {msgType, content} segment shapes, interactiveCard → recursive TEXT-node collection over cardContent), removed the stale comment, and added adapter tests feeding each shape through onMessage. This PR does exactly that — no simpler path was missed.

What I verified in the diff:

  • Branch ordering is correct. The new text case sits before the generic content.text fallback, so markdown quotes (the one shape that worked before) keep working; the interactivecard case sits after the text/richText checks and before the media switch, so nothing shadows it. Falling through on an empty content.content behaves exactly as before.
  • richText generalization is safe. part.type || part.msgType || 'text' and part.text ?? part.content cover both segment shapes; the only behavior change for the top-level shape is that segments are now trimmed and whitespace-only segments are dropped, which is harmless given the join('').
  • collectCardText is sound. The payload arrives via JSON.parse, so the recursion can't see cycles; non-object nodes are skipped; the collected text is length-capped downstream by ChannelBase's sanitizeQuotedText(…, 500) before it reaches the prompt, so a huge card tree can't balloon the context.
  • Type changes are additive — new optional fields on DingTalkRepliedMsg/DingTalkRichTextPart plus the new DingTalkCardElement. extractContent (top-level messages) still uses part.type/part.text untouched, so no other consumer is affected.
  • isReplyToBot is unchanged, and the card test asserts it fires when the quoted card's senderId matches chatbotUserId.
  • Tests match house style — same createChannel() / onMessage / vi.mocked(handleInbound) pattern as the existing quoted-media tests, asserting on the envelope. All four shapes covered.

No correctness, security, or convention blockers found. The sanitization story holds: extraction only changes what reaches the existing sanitizeQuotedText path, not the path itself.

Testing evidence (this PR's own CI)

Unattended CI run — I do not execute PR code; the evidence below is the PR's own CI on the reviewed commit, read via the API. As of this pass the main unit suite is still running, prechecks are green, and there are no failures. The windows/macos/integration jobs show skipped at this point — no conclusion to report for them yet. The finalize workflow will refresh this table once CI settles.

Final CI results for ff2ef62 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Secret scan (TruffleHog) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

One gap worth naming: the unit tests pin the extraction logic against the payload shapes from the issue, but the shapes themselves are the author's device-verified claim — CI cannot prove real DingTalk callbacks actually look like that, and neither static review nor a green suite settles it. Sandboxed verification would settle the extraction half: @qwen-code /verify — an A/B proof that the previously-dropped quote shapes now yield referencedText where the base build returns empty. This is a sponsored run (fork author, no write access — /tmux is unavailable): a maintainer's @qwen-code /verify approves the head it's written against, the run carries a pre-execution risk screen and a workspace wipe, and the resulting report should be read with the same skepticism as the fork's own CI logs. The remaining half — whether live DingTalk delivers these exact shapes — needs a maintainer with real device credentials; no CI lane can close it.

Not verified: live DingTalk device behavior (no credentials in CI); windows/macos test conclusions (jobs skipped so far).

中文说明

代码审查

先说我的独立方案以示诚实:针对这个 issue,我会让 summarizeRepliedContentrepliedMsg.msgType 分支(text 读 content.content,markdown 继续走 content.text,richText 同时接受 {type, text}{msgType, content} 两种段形态,interactiveCard 递归收集 cardContent 的 TEXT 节点),移除过时注释,并为每种形态补上经 onMessage 投递的适配器测试。这个 PR 正是这么做的——没有遗漏更简单的路径。

已核对:分支顺序正确(text 分支在通用 content.text 回退之前,markdown 引用不受影响;interactiveCard 分支不会被前面的检查拦截);richText 泛化安全(对顶层形态的唯一变化是段被 trim、纯空白段被丢弃,配合 join('') 无害);collectCardText 递归稳妥(报文经 JSON.parse 不可能成环,下游 sanitizeQuotedText(…, 500) 兜底长度);类型改动纯增量,extractContent 等其他消费方不受影响;isReplyToBot 逻辑未动且有断言覆盖;测试写法与既有 quoted-media 用例一致。未发现正确性、安全性或规范层面的阻塞问题。

测试证据(来自该 PR 自身的 CI)

无人值守 CI 运行——不执行 PR 代码,以下为通过 API 读取的该 commit 上的 CI 结果。主单测套件仍在运行,precheck 全绿,暂无失败;windows/macos/集成测试目前为 skipped,尚无结论。CI 结束后 finalize 工作流会刷新上方表格。

一个值得点明的缺口:单测把提取逻辑固定为 issue 中给出的报文形态,但这些形态本身是作者的真机验证说法——CI 无法证明真实钉钉回调确实长这样。沙盒验证可以解决其中一半:@qwen-code /verify——A/B 证明此前被丢弃的引用形态在新代码下能产出 referencedText 而 base 构建为空。这是 sponsored run(fork 作者无写权限,/tmux 不可用):由 maintainer 发起,运行前有风控筛查与工作区清理,报告应像对待 fork CI 日志一样保持审慎。另一半——真实钉钉是否真的下发这些形态——需要有真机凭据的 maintainer 确认,任何 CI 通道都无法闭合。

未验证:真实钉钉设备行为(CI 无凭据);windows/macos 测试结论(目前 skipped)。

Qwen Code · qwen3.8-max

Reviewed at ff2ef62f8491376fa3a53b8c786beffebc808bec · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal, well-tested fix for a verified defect; the one reservation is evidence, not code: the DingTalk payload shapes come from the author's device-verified claim, which no CI lane can confirm.

Stepping back: the defect is real — I confirmed in the current code that plain-text, richText, and card quotes are silently dropped before reaching the agent, and the issue documents the shapes precisely. The fix does exactly what the problem asks: it corrects the field mapping inside the one function responsible for it, removes the comment that asserted the wrong behavior, and pins every shape with a test in the existing style. Nothing extra rode along. My independent proposal for this issue is indistinguishable from what the PR does, so there's no simpler path being missed. The recursive card walk is the only new logic and it's small, cycle-safe by construction (JSON-parsed input), and length-bounded downstream by the existing sanitizer. In six months this reads as a straightforward bugfix, not something to curse.

The reason it's a 4 rather than a 5: green unit tests here prove the extraction matches the mocked shapes, not that live DingTalk delivers them — that half needs a maintainer with device credentials (or the sponsored @qwen-code /verify run named above for the A/B half). That's an acceptance caveat, not a blocker.

Approval deferred until CI lands green on ff2ef62f8491376fa3a53b8c786beffebc808bec — the main unit suite is still running on this commit, so no approval is posted in this run; the finalize workflow will land the commit-pinned approval once every check completes green (and withhold it if anything lands red or the head moves).

中文说明

置信度:4/5 —— 干净、最小化、测试充分的修复,缺陷已核实;唯一的保留意见在证据而非代码:钉钉报文形态来自作者的真机验证说法,任何 CI 通道都无法确认这一点。

整体来看:缺陷真实存在——我在当前代码中确认了纯文本、richText 和卡片引用在到达 agent 前会被静默丢弃,issue 也精确记录了报文形态。修复恰好做了问题所需要的事:在唯一负责的函数内修正字段映射、移除断言错误行为的注释、并用既有风格的测试固定每种形态。没有夹带任何额外改动。我对这个 issue 的独立方案与该 PR 完全一致,因此不存在被遗漏的更简路径。递归卡片遍历是唯一的新逻辑,它很小、由构造保证无环(输入经 JSON 解析),且长度由既有清洗器在下游兜底。六个月后再看,这是一个直白的 bugfix,不会让人抱怨。

给 4 而不是 5 的原因:这里的绿色单测证明的是提取逻辑与模拟的形态一致,而不是真实钉钉确实下发这些形态——那一半需要有真机凭据的 maintainer 确认(或上文提到的 sponsored @qwen-code /verify 来闭合 A/B 部分)。这是验收层面的提醒,不是阻塞项。

批准暂缓,等待 CI 在 ff2ef62f8491376fa3a53b8c786beffebc808bec 上全绿——该 commit 的主单测套件仍在运行,因此本次不发布批准;CI 全部绿后 finalize 工作流会补上绑定该 commit 的批准(若有检查变红或 head 移动则不会批准)。

Qwen Code · qwen3.8-max

Reviewed at ff2ef62f8491376fa3a53b8c786beffebc808bec · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship — CI landed green after the review. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.8-max via Qwen Code /review (v0.21.14)

const partType = part.type || 'text';
if (partType === 'text' && part.text) {
parts.push(part.text);
const partType = (part.type || part.msgType || 'text').toLowerCase();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The richText loop still supports the legacy {type, text} part shape via the part.type || fallback, but no test pins that backward-compatible branch — the new quoted-richText tests use only the {msgType, content} shape. Dropping part.type || (mutating to (part.msgType || 'text')) leaves the full package suite green (verified: 320/320 pass after the mutation), so a future refactor could silently break legacy-shape quotes — a {type:'picture'} part losing its [image] placeholder, {type:'text'} text skipped — while the suite stays green.

Consider adding one legacy-shaped fixture to the new quoted message context block, e.g.

content: {
  richText: [
    { type: 'text', text: 'legacy body' },
    { type: 'picture', downloadCode: 'c' },
  ],
},

and asserting referencedText === 'legacy body[image]'.

中文说明

richText 循环目前仍通过 part.type || 回退兼容旧的 {type, text} 段形态,但没有任何测试固定这条向后兼容分支——新增的引用 richText 测试只用了 {msgType, content} 形态。删掉 part.type ||(突变为 (part.msgType || 'text'))后整个包的测试套件依然全绿(已验证:突变后 320/320 通过),因此未来的重构可能在测试全绿时悄悄破坏旧形态的引用——{type:'picture'} 段丢失 [image] 占位符、{type:'text'} 文本被跳过。

建议在新的 quoted message context 用例组补一个旧形态用例:

content: {
  richText: [
    { type: 'text', text: 'legacy body' },
    { type: 'picture', downloadCode: 'c' },
  ],
},

并断言 referencedText === 'legacy body[image]'

— qwen3.8-max via Qwen Code /review (v0.21.14)

}
};
if (Array.isArray(nodes)) walk(nodes);
return segments.join('\n');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] collectCardText joins multiple TEXT segments with '\n', but the new interactiveCard test contains exactly one TEXT node, so this separator is unpinned — mutating segments.join('\n') to segments.join('') leaves the whole suite green (verified). Bot reply cards routinely contain several TEXT nodes; with the mutant they would merge into the quoted context as HelloHow can I help you today? — word boundaries across segments lost. Downstream sanitizeQuotedText folds '\n' to a space, so '\n' vs '' is the difference between a space and glued words.

Add a card fixture with two TEXT nodes and assert the joined output, e.g. referencedText === 'first line\nsecond line'.

中文说明

collectCardText'\n' 连接多个 TEXT 段,但新的 interactiveCard 测试只有一个 TEXT 节点,因此该分隔符未被测试固定——把 segments.join('\n') 突变为 segments.join('') 后整个套件依然全绿(已验证)。bot 的卡片回复通常包含多个 TEXT 节点,突变后它们会在引用上下文中连成 HelloHow can I help you today?——段间词边界丢失。下游 sanitizeQuotedText 会把 '\n' 折成空格,所以 '\n''' 的差别就是有空格与词粘在一起。

补一个含两个 TEXT 节点的卡片用例,断言拼接结果,例如 referencedText === 'first line\nsecond line'

— qwen3.8-max via Qwen Code /review (v0.21.14)

Comment on lines +1484 to +1486
if (msgType === 'interactivecard') {
return this.collectCardText(content?.cardContent);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The empty/absent cardContent path of this new branch is untested, even though the comment this diff deletes explicitly stated DingTalk card quotes can arrive without content. Today collectCardText(undefined) degrades gracefully (the Array.isArray guard returns '', so referencedText becomes undefined). But remove that guard and walk(undefined) throws a TypeError inside onMessage, dropping the user's entire message — and no test distinguishes the guard being present from absent (verified: the suite stays green with the mutant).

Add one test: an interactiveCard quote with content: {}, asserting envelope.referencedText is undefined (and isReplyToBot per senderId).

中文说明

这个新分支的空/缺失 cardContent 路径没有测试覆盖,尽管本次 diff 删除的注释恰好明确说过钉钉卡片引用可能不带内容。目前 collectCardText(undefined) 会优雅降级(Array.isArray 守卫返回 ''referencedText 变为 undefined)。但删掉该守卫后,walk(undefined) 会在 onMessage 内抛出 TypeError,导致整条用户消息被丢弃——而且没有任何测试能区分守卫存在与否(已验证:突变后套件依然全绿)。

补一个测试:interactiveCard 引用且 content: {},断言 envelope.referencedTextundefined(且 isReplyToBot 按 senderId 判定)。

— qwen3.8-max via Qwen Code /review (v0.21.14)

@BenGuanRan
BenGuanRan marked this pull request as draft August 20, 2026 07:21
@wenshao

wenshao commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Local verification — full DingTalk pipeline, two arms

I built a real end-to-end environment for this instead of relying on the unit tests, because the claim under review is "the quoted text actually reaches the agent", and only the wire can answer that.

What is real: a fake DingTalk platform (HTTPS on :443 impersonating api.dingtalk.com / oapi.dingtalk.com behind a self-signed CA trusted via NODE_EXTRA_CA_CERTS, plus a real WebSocket stream endpoint) drives the unmodified vendor dingtalk-stream-sdk-nodejs DWClient through its real gettokenconnections/open → WS-upgrade handshake. From there everything is product code: DingtalkChannel.onMessageextractQuotedContextsummarizeRepliedContentChannelBase.sanitizeQuotedText → the daemon session → the agent. Only the LLM is scripted (a ~60-line OpenAI-compatible SSE server that records every request body verbatim), so the prompt the daemon actually sends is the oracle. The bot's outbound POST /robot/send is captured byte-for-byte at the other end.

Two arms, each with its own full esbuild bundle: wt-base = origin/main@40889bad75, wt-pr = the same tree with #9537 on top. Arm swap confirmed by grep collectCardText dist/chunks/*.js (PR: 1 hit, base: 0).

harness

Result: the fix does what the issue says

A/B matrix

Quoted shape main @ 40889ba with #9537
textcontent.content quote dropped [Replying to: "the original question about the build"]
markdowncontent.text already worked unchanged
richText, {msgType, content} segments quote dropped [Replying to: "look at this image please"]
interactiveCardcardContent tree quote dropped [Replying to: "Hi! How can I help you today?"]

Exactly the three-of-four breakage #9536 describes, and the reply the bot sends back over robot/send flips with it. isReplyToBot is true for the quoted bot card, so the group path is unaffected either way.

Two things to settle before merge

findings

1. The per-segment .trim() deletes the spaces between richText segments — a regression on the shape that already worked.

main pushes the raw segment text and trims once after join(''). This PR trims each segment, so any space carried at a segment boundary is destroyed. Same live harness, same inbound frame, existing {type, text} segment shape:

segments  [{text: "see the note "}, {text: "below and "}, {text: "reply"}]

main      [Replying to: "see the note below and reply"]
#9537     [Replying to: "see the notebelow andreply"]

It bites the new segment shape too — an at segment followed by {msgType: 'text', content: ' please review'} reaches the model as @Bobplease review. The fix is to drop both .trim() calls inside the loop; parts.join('').trim() already handles the ends:

const partText = part.text ?? part.content;
if (partType === 'text' && partText) {
  parts.push(partText);
} else if (partType === 'picture') {

2. The PR no longer merges, and the conflict has two traps.

Both hunks in DingtalkAdapter.ts collide with #9339 (quoted chat records) and #9922. I verified against a manual rebase; two things to watch when you redo it:

  • msgType is now lowercased, but main's chat-record branch compares msgType === 'chatRecord'. Keeping that literal under the lowercased variable silently disables fix(dingtalk): parse forwarded chat records #9339's quoted forwarded-record support — 6 existing tests go red, so CI catches it, but the resolution has to lowercase the literal too.
  • Don't carry the PR's inline media switch over: main routes quoted media through mediaTypePlaceholder(msgType, content?.fileName), which passes the file name through bracketSafeChatRecordField. The PR's [file: ${content?.fileName || 'file'}] re-introduces an unsanitized name inside a bracket wrapper.
  • Likewise, add content?: string and cardContent?: DingTalkCardElement[] to main's DingTalkMessageContent rather than replacing DingTalkRepliedMsg.content with an inline type — the inline type drops chatRecord / records / messages / recognition / title / summary.

The resolution I ran everything against:

private summarizeRepliedContent(replied: DingTalkRepliedMsg): string {
  const rawMsgType = replied.msgType;
  const msgType = rawMsgType?.toLowerCase();
  const content = replied.content;

  if (msgType === 'text' && content?.content?.trim()) return content.content.trim();
  if (content?.text?.trim()) return content.text.trim();
  if (content?.richText && Array.isArray(content.richText)) { /* segment loop */ }
  if (msgType === 'interactivecard') return this.collectCardText(content?.cardContent);
  if (msgType === 'chatrecord') { /* main's block, unchanged */ }
  return mediaTypePlaceholder(rawMsgType, content?.fileName) ?? '';
}

Non-blocking

  • collectCardText only reads elementType === 'TEXT' with a string value. A quoted card whose visible body arrives as a MARKDOWN element (or any node keyed text rather than value) still yields an empty quote — I probed that shape and both arms return nothing. The bot's own cards are rendered by DingTalk from a cardTemplateId + cardParamMap, so which element kinds come back inside repliedMsg.content.cardContent is precisely the device-verified part I can't reproduce here. Could you say which card the capture came from? A fallback that also accepts a string text on unknown element types would make this shape-agnostic.
  • A quoted text without msgType is still dropped, because content.content is gated on msgType === 'text'. Falling back to content.content unconditionally after content.text costs one line and removes the dependency on the type field being present.
  • Case handling is now asymmetric: the summary lowercases msgType, but mediaTypeFromMsgType(replied.msgType) right next to it still compares case-sensitively, so a differently-cased picture would render the [image] placeholder while silently not attaching the media.
  • Pre-existing, but newly visible: sanitizeQuotedText strips [, ] and ", so the [image] placeholder reaches the model as image — see the richText row above. Not introduced here, but this PR makes the quoted-richText path reachable for the first time, so the placeholder now shows up in prompts. A bracket-free spelling would survive.

Checked and cleared

  • Recursion depth of the card walker. The walker tolerates ~5,900 levels of nesting; JSON.parse on the incoming frame caps out at ~2,080, so the walker can never be the first thing to fail. No depth budget needed.
  • Quote budget still applies. A card with 5,000 TEXT nodes lands in the prompt as exactly 500 code points plus the marker.
  • Blank content.content falls back to content.text — byte-identical on both arms.
  • markdown quotes — byte-identical on both arms.

Test runs

Check Result
PR as authored, on its own base 320/320 (packages/channels/dingtalk)
Rebased onto origin/main 418/418 (packages/channels/dingtalk), 1159/1159 (packages/channels/base)
eslint --max-warnings 0 on the touched package clean
Negative control (revert only DingtalkAdapter.ts, keep the new tests) exactly 3 of the 4 new tests go red; the markdown one stays green because that is the shape that already worked
Naive conflict resolution (lowercased msgType, 'chatRecord' literal kept) 6 chat-record tests red

Tested on 🐧 Linux (Node v22.22.2) — the E2E above ran there, so the PR body's Linux ⚠️ can become ✅ for this change.

中文说明

本地验证 —— 完整钉钉链路,双臂对照

我没有只依赖单测,而是搭了一套真实的端到端环境:本 PR 要证明的是「引用文本真的进入了 agent 上下文」,这个问题只有在真实链路上才能回答。

真实的部分: 一个假的钉钉平台(HTTPS 监听 :443 冒充 api.dingtalk.com / oapi.dingtalk.com,自签 CA 通过 NODE_EXTRA_CA_CERTS 被信任,另有真实的 WebSocket 流端点)驱动未经修改的官方 dingtalk-stream-sdk-nodejs DWClient,走完它真实的 gettokenconnections/open → WS 升级握手。之后全是产品代码:DingtalkChannel.onMessageextractQuotedContextsummarizeRepliedContentChannelBase.sanitizeQuotedText → daemon 会话 → agent。只有大模型是脚本化的(约 60 行 OpenAI 兼容 SSE 服务,逐字记录每个请求体),因此daemon 实际发出的提示词就是判据。机器人回程的 POST /robot/send 在另一端逐字节捕获。

两条臂各自完整 esbuild 打包:wt-base = origin/main@40889bad75wt-pr = 同一棵树叠加 #9537。臂切换用 grep collectCardText dist/chunks/*.js 证实(PR 命中 1 处,base 0 处)。

结论:修复确实做到了 issue 所述的效果

引用形态 main @ 40889ba 加上 #9537
textcontent.content 引用被丢弃 [Replying to: "the original question about the build"]
markdowncontent.text 本来就正常 不变
richText{msgType, content} 引用被丢弃 [Replying to: "look at this image please"]
interactiveCardcardContent 引用被丢弃 [Replying to: "Hi! How can I help you today?"]

#9536 描述的「四种形态坏三种」完全一致,机器人经 robot/send 发回的回复也随之翻转。引用 bot 卡片时 isReplyToBottrue,两条臂的群聊准入路径都不受影响。

合入前需要处理的两件事

1. 逐段 .trim() 会吃掉 richText 段之间的空格 —— 这是对本来就正常的形态造成的回归。

main 是把段文本原样 push,最后 join('') 之后统一 trim 一次。本 PR 对每一段单独 trim,于是任何落在段边界上的空格都被销毁。同一套真实链路、同一条入站报文、既有的 {type, text} 段形态:

segments  [{text: "see the note "}, {text: "below and "}, {text: "reply"}]

main      [Replying to: "see the note below and reply"]
#9537     [Replying to: "see the notebelow andreply"]

新段形态同样中招:一个 at 段后面跟 {msgType: 'text', content: ' please review'},到模型手里是 @Bobplease review。修法是去掉循环内的两处 .trim(),首尾由 parts.join('').trim() 已经处理:

const partText = part.text ?? part.content;
if (partType === 'text' && partText) {
  parts.push(partText);
} else if (partType === 'picture') {

2. PR 已经无法合入,而且冲突解决里有两个坑。

DingtalkAdapter.ts 的两处 hunk 都与 #9339(引用聊天记录)和 #9922 冲突。我按手工 rebase 验证,重做时请注意:

  • msgType 现在被 toLowerCase() 了,而 main 的聊天记录分支比较的是 msgType === 'chatRecord'。在小写变量下保留这个字面量会静默关掉 fix(dingtalk): parse forwarded chat records #9339 的引用转发记录支持 —— 6 个既有用例会红,CI 能拦住,但解冲突时必须把字面量也改成小写。
  • 不要把 PR 里内联的媒体 switch 带过来:main 的引用媒体走 mediaTypePlaceholder(msgType, content?.fileName),文件名会经过 bracketSafeChatRecordField。PR 的 [file: ${content?.fileName || 'file'}] 会把未清洗的文件名重新放回方括号包装里。
  • 同理,应该在 main 的 DingTalkMessageContent 上补 content?: stringcardContent?: DingTalkCardElement[],而不是用内联类型替换 DingTalkRepliedMsg.content —— 内联类型会丢掉 chatRecord / records / messages / recognition / title / summary

我全程验证所用的解法:

private summarizeRepliedContent(replied: DingTalkRepliedMsg): string {
  const rawMsgType = replied.msgType;
  const msgType = rawMsgType?.toLowerCase();
  const content = replied.content;

  if (msgType === 'text' && content?.content?.trim()) return content.content.trim();
  if (content?.text?.trim()) return content.text.trim();
  if (content?.richText && Array.isArray(content.richText)) { /* 段循环 */ }
  if (msgType === 'interactivecard') return this.collectCardText(content?.cardContent);
  if (msgType === 'chatrecord') { /* main 的分支,保持不变 */ }
  return mediaTypePlaceholder(rawMsgType, content?.fileName) ?? '';
}

非阻塞

  • collectCardText 只读 elementType === 'TEXT'value 为字符串的节点。 如果被引用卡片的可见正文以 MARKDOWN 元素(或任何用 text 而非 value 承载文本的节点)下发,引用仍然是空 —— 我实测了这个形态,两条臂都取不到内容。机器人自己的卡片是钉钉按 cardTemplateId + cardParamMap 服务端渲染的,所以 repliedMsg.content.cardContent 里究竟回传哪些元素类型,恰恰是我在本地无法复现的「真机验证」部分。方便的话能否说明抓包来自哪种卡片?对未知元素类型同时接受字符串 text 的兜底,可以让这段与形态无关。
  • 没有 msgType 的引用文本仍然会被丢,因为 content.contentmsgType === 'text' 门控。在 content.text 之后无条件兜底到 content.content,只要一行,就能去掉对该字段必须存在的依赖。
  • 大小写处理现在不对称: 摘要里 msgType 被小写化,而紧邻的 mediaTypeFromMsgType(replied.msgType) 仍是大小写敏感的,于是大小写不同的 picture 会渲染出 [image] 占位符,却静默不挂载媒体。
  • 既有问题,但这次才暴露出来: sanitizeQuotedText 会剥掉 []",所以 [image] 占位符到模型手里是 image(见上面 richText 那行)。这不是本 PR 引入的,但本 PR 第一次让引用 richText 路径可达,占位符因此才出现在提示词里。换一个不带方括号的写法才能存活。

已核查并排除

  • 卡片遍历的递归深度。 遍历本身能扛约 5,900 层嵌套,而入站报文的 JSON.parse 在约 2,080 层就先失败,所以遍历不可能是先崩的那一环,无需深度预算。
  • 引用长度预算仍然生效。 含 5,000 个 TEXT 节点的卡片,进入提示词时正好是 500 码点加 标记。
  • content.content 为空白时回退到 content.text —— 两条臂逐字节一致。
  • markdown 引用 —— 两条臂逐字节一致。

测试执行

检查项 结果
PR 原样,在其自身 base 上 320/320(packages/channels/dingtalk
rebase 到 origin/main 418/418(packages/channels/dingtalk)、1159/1159(packages/channels/base
对改动包跑 eslint --max-warnings 0 干净
反向对照(只回退 DingtalkAdapter.ts,保留新用例) 新增 4 个用例中恰好 3 个转红;markdown 那个保持绿,因为它本来就是能工作的形态
朴素解冲突(小写 msgType,保留 'chatRecord' 字面量) 6 个聊天记录用例转红

已在 🐧 Linux(Node v22.22.2)验证 —— 上述 E2E 就跑在这上面,PR 描述里的 Linux ⚠️ 对本次改动可以改成 ✅。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@wenshao

wenshao commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge resolution for PR #9537

Root cause

  1. fix(dingtalk): parse forwarded chat records #9339 fix(dingtalk): parse forwarded chat records on main rewrote the same function this PR rewrites — summarizeRepliedContent — adding chat-record rendering, the shared mediaTypePlaceholder() helper, and refactoring the replied-content type into a shared DingTalkMessageContent interface. Semantic conflict in DingtalkAdapter.ts.
  2. feat(dingtalk): project reserved file output safely #9991 fix(dingtalk): project reserved file output safely inserted a test describe block exactly where this PR inserts its quoted message context block — positional conflict in DingtalkAdapter.test.ts; both blocks kept, PR's first.

Semantic conflict — resolved code

Both sides changed summarizeRepliedContent logic, not just adjacency:

const msgType = replied.msgType?.toLowerCase();
// 1. text quotes via content.content            (PR)
// 2. content.text (markdown etc.)               (both)
// 3. richText incl. {msgType, content} aliases  (PR)
if (msgType === 'chatrecord') { /* main's formatChatRecord */ }
if (msgType === 'interactivecard') return this.collectCardText(content?.cardContent);  // PR
return mediaTypePlaceholder(msgType, content?.fileName) ?? '';  // main helper replaces PR's inline switch

Interfaces: kept PR's DingTalkCardElement; folded PR's content/cardContent fields into main's DingTalkMessageContent; DingTalkRepliedMsg.content uses main's refactored type.

Load-bearing points

  • Case handling: main compared msgType === 'chatRecord' without lowercasing; the PR lowercases msgType once at the top (its tests send camelCase 'interactiveCard'). Resolution keeps lowercasing and compares 'chatrecord'. A future edit restoring the 'chatRecord' literal here would silently kill the quoted chat-record path. The top-level direct-message path (extractContent) intentionally stays case-sensitive as in main.
  • Using main's mediaTypePlaceholder means quoted file placeholders get main's bracketSafeChatRecordField sanitization — keep the helper, don't re-inline the PR's switch.
  • chatrecord vs interactivecard order is not load-bearing (mutually exclusive msgTypes); the text/richText checks before them are.

Not verified here

No build/tests were run (per this command's scope). Both suites exercise the merged function; main's 'chatRecord' fixtures still match the lowercased comparison. The only behavior delta beyond both sides: msgType matching on the quoted path is now case-insensitive (a superset of main's), which is the PR's intent.

中文说明

根因:main 上 #9339(转发聊天记录)重写了本 PR 同样重写的 summarizeRepliedContent,并重构出共享 DingTalkMessageContent 接口(语义冲突);#9991 的测试 describe 块与本 PR 新测试块插在同一位置(位置冲突,两块均保留)。

解决:合并后函数依次处理 content.content 文本 → content.text → richText(含 {msgType, content} 别名)→ chatrecord(main 逻辑)→ interactivecard(PR 卡片文本提取)→ main 的 mediaTypePlaceholder。PR 新增字段并入 main 的共享接口。

关键点:main 原本不大写化而比较 'chatRecord',PR 在开头统一 toLowerCase(),故解决后比较小写 'chatrecord';直接消息路径仍保持 main 的区分大小写。改用 main 的占位符助手使引用文件 fileName 经过转义清洗。

未验证:本步骤不运行构建或测试,由 PR 自身 CI 覆盖。唯一超出双方原意的变化:引用路径 msgType 匹配变为不区分大小写(更宽松,符合 PR 意图)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DingTalk channel drops quoted message content for text, richText, and interactiveCard replies

4 participants