Skip to content

feat(channels): support DingTalk webhook delivery to direct messages - #6891

Merged
wenshao merged 6 commits into
QwenLM:mainfrom
BenGuanRan:feat/dingtalk-webhook-dm
Jul 15, 2026
Merged

feat(channels): support DingTalk webhook delivery to direct messages#6891
wenshao merged 6 commits into
QwenLM:mainfrom
BenGuanRan:feat/dingtalk-webhook-dm

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR extends daemon-triggered DingTalk webhook delivery so the existing channel can send the agent's final Markdown response to either a configured user or group. Direct-message targets use the one-to-one robot API, while existing group targets continue to use the group-message API. Both paths share the current access-token cache, Markdown chunking, one-time HTTP 401 refresh, and delivery error handling.

No new channel type or webhook schema is introduced. Targets still use the existing chatId, senderId, and isGroup fields, with isGroup: false selecting a DingTalk user ID and isGroup: true selecting a group openConversationId.

The shared channel runtime now has a webhook-specific target check that defaults to each channel's existing proactive-target behavior. DingTalk overrides only that webhook check for direct messages, so ordinary channel loops remain group-only and cannot mistake an inbound direct-message conversation ID for a DingTalk user ID.

Why it's needed

External events such as CI results, deployment status, and operational alerts often need to notify one responsible DingTalk user instead of an entire group. The daemon webhook flow already runs unattended agent tasks and routes their final responses to configured targets, but the DingTalk adapter previously rejected every proactive target that was not a group.

Reviewer Test Plan

How to verify

Configure one DingTalk webhook target with a user ID and isGroup: false, then start qwen serve with that channel enabled. Submit an authenticated request to the configured webhook route and confirm it returns 202, the real model completes the unattended task, and the resulting Markdown reaches the selected DingTalk user. Also run the channel-base and DingTalk adapter tests and confirm existing group targets still use openConversationId, direct-message webhook targets use userIds and share the same cached access token, and ordinary direct-message channel-loop targets remain rejected.

Evidence (Before & After)

Before: DingTalk proactive delivery accepted only group targets, so a configured isGroup: false webhook target was rejected before delivery.

After: A local authenticated curl request returned 202 {"accepted":true}; qwen3-coder-plus completed the task with an HTTP 200 model response; the daemon reported the prompt turn completed with no webhook or proactive-send failure; and the recipient confirmed the final Markdown arrived in DingTalk direct messages.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

Local source build with Node.js 25.2.1, a loopback qwen serve daemon, a real Alibaba Cloud Model Studio model, and a real DingTalk Stream bot application.

Risk & Scope

  • Main risk or tradeoff: A syntactically valid but incorrect DingTalk user ID is accepted locally and will fail only when DingTalk rejects the proactive send.
  • Shared runtime scope: The new webhook-target hook delegates to the existing proactive-target hook by default, so other channel adapters keep their current behavior.
  • Not validated / out of scope: Native DingTalk cards, card callbacks, streaming card updates, thread targets, multiple direct-message recipients, and a live group-chat E2E run. Existing group behavior remains covered by unit tests.
  • Baseline build status: The branch is rebased onto the upstream WebShell fix from fix(web-shell): remove duplicate useWebShellPortalRoot import in ChatEditor #6890. Full root build/typecheck, the DingTalk tests, and the channel-base tests pass locally.
  • Breaking changes / migration notes: None. Existing group target configuration and behavior are unchanged.

Linked Issues

Closes #6883

中文说明

本 PR 做了什么

本 PR 扩展了 daemon 触发的钉钉 Webhook 投递能力,使现有 DingTalk Channel 可以把 agent 的最终 Markdown 响应发送到预配置的用户或群聊。单聊目标调用一对一机器人消息接口,已有群聊目标继续调用群消息接口。两条路径共同复用现有 access token 缓存、Markdown 分片、HTTP 401 单次刷新重试和投递错误处理。

本次没有新增 Channel 类型或 Webhook 配置结构。目标仍然使用已有的 chatIdsenderIdisGroup 字段;isGroup: false 表示 chatId 是钉钉用户 ID,isGroup: true 表示 chatId 是群聊 openConversationId

共享 channel runtime 增加了 Webhook 专用目标检查,其默认行为仍委托给各 channel 原有的主动投递目标检查。钉钉仅在 Webhook 检查中放开单聊,因此普通 channel loop 仍只接受群聊目标,不会把入站单聊的会话 ID 错当作钉钉用户 ID。

为什么需要

CI 结果、部署状态和运维告警等外部事件经常只需要通知一个具体负责人,而不是整个群。daemon Webhook 流程已经能够运行无人值守 agent 任务并把最终响应路由到配置目标,但 DingTalk adapter 之前会拒绝所有非群聊的主动投递目标。

Reviewer Test Plan

验证方式

配置一个包含钉钉用户 ID 且 isGroup: false 的 Webhook 目标,然后启动包含该 Channel 的 qwen serve。向配置的 Webhook 路由提交鉴权请求,确认接口返回 202、真实模型完成无人值守任务,并且最终 Markdown 到达指定钉钉用户。同时运行 channel-base 和 DingTalk adapter 测试,确认已有群聊目标继续使用 openConversationId,单聊 Webhook 目标使用 userIds 并复用相同的 access token 缓存,普通单聊 channel-loop 目标仍会被拒绝。

前后证据

修改前:钉钉主动投递只接受群聊目标,因此配置为 isGroup: false 的 Webhook 目标会在投递前被拒绝。

修改后:本地鉴权 curl 请求返回 202 {"accepted":true}qwen3-coder-plus 以 HTTP 200 完成任务;daemon 记录 prompt turn completed,且没有 Webhook 或主动发送失败;接收人确认钉钉单聊已收到最终 Markdown。

测试平台

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

环境

Node.js 25.2.1、本地源码构建、loopback qwen serve daemon、真实阿里云百炼模型和真实钉钉 Stream 机器人应用。

风险和范围

  • 主要风险或取舍:格式合法但填写错误的钉钉用户 ID 会通过本地目标校验,直到钉钉拒绝主动发送时才会失败。
  • 共享 runtime 范围:新增的 Webhook 目标钩子默认委托给原有主动投递目标钩子,因此其他 channel adapter 的行为保持不变。
  • 未验证或不在范围内:钉钉原生 Card、Card 回调、Card 流式更新、话题目标、多个单聊接收人,以及真实群聊 E2E。已有群聊行为继续由单元测试覆盖。
  • 基线构建状态:本分支已 rebase 到包含 fix(web-shell): remove duplicate useWebShellPortalRoot import in ChatEditor #6890 WebShell 修复的最新上游基线。根目录完整 build/typecheck、DingTalk 测试和 channel-base 测试均在本地通过。
  • 破坏性变更或迁移说明:无。已有群聊目标配置和行为保持不变。

关联 Issue

Closes #6883

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

本地 E2E 测试报告

场景

验证外部 Webhook 通过 daemon 触发真实 AI 任务,并把最终 Markdown 主动投递到钉钉单聊。

环境

  • macOS
  • Node.js 25.2.1
  • 本地源码构建,Qwen Code 0.19.10
  • qwen serve --channel dingtalk-main,loopback 端口 4197
  • 真实 qwen3-coder-plus 模型
  • 真实钉钉 Stream 机器人凭据和单聊用户目标

结果

  1. /daemon/status 返回 status: ok,DingTalk worker 为 running,Channel 为 live
  2. 直接 curl 请求 POST /channels/dingtalk-main/webhooks/manual-test 返回 HTTP 202 和 {"accepted":true}
  3. daemon 创建新 session 并完成 prompt turn;模型响应状态为 HTTP 200。
  4. 日志中没有 webhook task failedproactive send failed
  5. 接收人确认钉钉单聊已收到最终 Markdown。

自动化验证

  • npx vitest run src/DingtalkAdapter.test.ts:63/63 通过。
  • packages/channels/basepackages/channels/dingtalk 构建:通过。
  • packages/channels/base/src/ChannelBase.test.ts:396/396 通过。
  • DingTalk adapter ESLint:通过。
  • 最新 origin/main 的根目录 npm run buildnpm run typecheck:被 packages/web-shell/client/components/ChatEditor.tsx 中既有的 useWebShellPortalRoot 重复 import 阻塞;本分支未修改 web-shell。

未覆盖

本次没有可用的群聊 openConversationId,因此未执行真实群聊 E2E;原有群聊 endpoint、请求体、Token 缓存和错误处理继续由 adapter 单元测试覆盖。

@BenGuanRan
BenGuanRan force-pushed the feat/dingtalk-webhook-dm branch from 8dc1575 to 9f42f1a Compare July 14, 2026 13:46
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

上游 WebShell 重复 import 已由 #6890 修复并合入。本分支现已 rebase 到最新 main;本地重新验证:npm run buildnpm run typecheck、ChannelBase 396/396、DingTalk 63/63 均通过。已推送新 head 以重新触发 CI。

@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@BenGuanRan
BenGuanRan marked this pull request as ready for review July 14, 2026 14:14
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required sections present with bilingual translation.

Problem: Observed and real. The PR links issue #6883 and provides concrete before/after evidence — a local curl returned 202, the daemon completed the task, and the DingTalk recipient confirmed message delivery. This is not a theoretical concern; webhook users currently cannot target individual DingTalk users.

Direction: Aligned. Extending the existing DingTalk webhook delivery to support DM targets via the one-to-one robot API is a natural, well-scoped addition. No new channel type or config schema is introduced — it reuses chatId/senderId/isGroup with clear semantics. The design doc and implementation plan are thorough.

Size: Not applicable for the core module gate — packages/channels/*/ is not in the core module list. Production logic changes are ~85 lines (ChannelBase.ts: +5/-1, DingtalkAdapter.ts: +80/-31), with ~202 test lines and ~413 docs lines. Compact and well-proportioned.

Approach: The scope feels right. The shared-runtime hook (supportsProactiveWebhookTarget) defaults to the existing check, so other channels are unaffected. The DM-vs-group routing via isGroup is clean. Docs, design plan, and tests are all included — nothing feels extraneous or missing.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ — 所有必需部分和双语翻译均已包含。

问题:已观测且真实存在。PR 关联了 issue #6883 并提供了具体的 before/after 证据——本地 curl 返回 202、daemon 完成任务、钉钉接收人确认收到消息。这不是理论问题;Webhook 用户目前无法将消息投递到钉钉单聊目标。

方向:对齐。通过一对一机器人 API 扩展现有钉钉 Webhook 投递以支持单聊目标,是自然且范围合理的扩展。未新增 channel 类型或配置结构——复用 chatId/senderId/isGroup,语义清晰。设计文档和实现计划详尽。

规模:不适用核心模块门控——packages/channels/*/ 不在核心模块列表中。生产逻辑改动约 85 行(ChannelBase.ts: +5/-1,DingtalkAdapter.ts: +80/-31),测试约 202 行,文档约 413 行。紧凑且比例合理。

方案:范围合理。共享 runtime 钩子(supportsProactiveWebhookTarget)默认委托给现有检查,其他 channel 不受影响。通过 isGroup 区分单聊和群聊路由清晰。文档、设计计划和测试齐全——没有多余或缺失的部分。

进入代码审查 🔍

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: I'd add a webhook-specific target support hook to ChannelBase that delegates to the regular proactive check by default. DingTalk overrides it to also accept isGroup: false targets. In pushProactive, I'd branch on isGroup to pick between groupMessages/send and oToMessages/batchSend. The DM response needs validation for invalidStaffIdList/flowControlledStaffIdList. Tests should cover both paths, token reuse, 401 retry, and response validation.

The PR's implementation matches this closely and is well-executed. Key observations:

  • supportsProactiveWebhookTarget() hook in ChannelBase defaults to supportsProactiveTarget() — other channels are completely unaffected.
  • DingTalk override correctly accepts typeof target.isGroup === 'boolean' (not just true), rejecting only when isGroup is missing.
  • Endpoint branching in sendProactiveChunk is clean — group gets openConversationId, DM gets userIds: [chatId].
  • DM response validation catches invalidStaffIdList and flowControlledStaffIdList for the specific target, ignoring unrelated recipients.
  • The isConversationIdisStableTargetId rename is a clarity improvement — the old name was misleading since it validates both conversation IDs and user IDs.
  • Log messages now include targetKind (group/dm) for better debugging.

No critical blockers. No AGENTS.md violations.

Real-Scenario Testing

Ran qwen serve from the PR branch, verified the daemon starts cleanly and the webhook routing infrastructure responds:

qwen serve listening on http://127.0.0.1:4199 (mode=http-bridge, workspace=...)
qwen serve: bound to workspace "..."
qwen serve: startup timing: processToListenMs=1922 runQwenServeToListenMs=1360
qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable.
2026-07-15T01:41:24.522Z [INFO] [DAEMON] deferred runtime: scheduling fallback start in 1000ms
2026-07-15T01:41:25.523Z [INFO] [DAEMON] deferred runtime: fallback timer fired, starting
2026-07-15T01:41:25.842Z [INFO] [DAEMON] ideEnvPresent=false primary=... secondary= daemon workspace roots initialized
qwen serve: session reaper started (interval 60000ms, idle threshold 1800000ms)
qwen serve: /acp WebSocket transport enabled on /acp
2026-07-15T01:41:46.382Z [WARN] [DAEMON] channelName=dingtalk-test source=github-ci channel webhook authentication failed
2026-07-15T01:41:46.383Z [WARN] [DAEMON] route=POST /channels/dingtalk-test/webhooks/github-ci durationMs=1 status=401 request completed

Health endpoint: {"status":"ok"}. Webhook POST without credentials: {"error":"Invalid webhook secret"} — routing works, auth gates correctly.

Unit tests (all passing):

  • DingtalkAdapter.test.ts: 69/69 — includes new DM tests (one-to-one API, invalid recipient, rate-limited recipient, malformed JSON, token reuse across group+DM, 401 retry for DM)
  • ChannelBase.test.ts: 397/397 — includes webhook target support independence tests
  • Typecheck: clean across all packages

Full DM delivery E2E requires live DingTalk credentials, which aren't available in CI. The author confirms real delivery in the PR description.

中文说明

代码审查

独立方案: 我会在 ChannelBase 中增加一个 Webhook 专用的目标支持钩子,默认委托给普通主动投递检查。DingTalk 覆盖它以额外接受 isGroup: false 目标。在 pushProactive 中根据 isGroup 选择 groupMessages/sendoToMessages/batchSend。DM 响应需要校验 invalidStaffIdList/flowControlledStaffIdList。测试应覆盖两条路径、Token 复用、401 重试和响应校验。

PR 实现与此高度一致,执行良好。 关键观察:

  • ChannelBase 中的 supportsProactiveWebhookTarget() 默认委托给 supportsProactiveTarget()——其他 channel 完全不受影响。
  • DingTalk 覆盖正确接受 typeof target.isGroup === 'boolean'(不仅仅是 true),仅在缺少 isGroup 时拒绝。
  • sendProactiveChunk 中的 endpoint 分支清晰——群聊用 openConversationId,单聊用 userIds: [chatId]
  • DM 响应校验针对特定目标捕获 invalidStaffIdListflowControlledStaffIdList,忽略不相关的接收人。
  • isConversationIdisStableTargetId 重命名提升了清晰度——旧名称具有误导性,因为它同时验证会话 ID 和用户 ID。
  • 日志消息现在包含 targetKind(group/dm),便于调试。

无关键阻断问题,无 AGENTS.md 违规。

真实场景测试

从 PR 分支运行 qwen serve,确认 daemon 正常启动,Webhook 路由基础设施响应正常。Health 端点返回 ok,无凭据的 Webhook POST 返回 Invalid webhook secret——路由正常,鉴权正确拦截。

单元测试(全部通过): DingtalkAdapter 69/69,ChannelBase 397/397,Typecheck 全部通过。

完整 DM 投递 E2E 需要真实的钉钉凭据,CI 环境不可用。作者在 PR 描述中确认了真实投递。

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — Clean across every stage; would merge without hesitation.

This is a well-executed feature PR. The problem is real (webhook users can't target DingTalk DMs), the solution is minimal (~85 production lines), and the implementation follows the project's conventions precisely.

The shared-runtime hook pattern (supportsProactiveWebhookTarget defaulting to the existing check) is the right abstraction — it isolates DM support to webhook tasks without leaking into regular channel loops. The endpoint branching is straightforward, the DM response validation handles the DingTalk-specific error shape correctly, and the log enrichment with targetKind adds useful debugging context without noise.

Tests are thorough: 69 DingTalk adapter tests covering both group and DM paths, token reuse, 401 retry, response validation, and error logging. 397 ChannelBase tests including webhook target independence. All pass. Typecheck clean.

The author's before/after evidence (curl → 202 → task completed → message received) confirms the feature works end-to-end. The design doc and implementation plan are committed to docs/ for future reference.

Approving. ✅

中文说明

信心度:5/5 — 各阶段均通过,可以合并。

这是一个执行良好的功能 PR。问题真实存在(Webhook 用户无法将消息投递到钉钉单聊),方案最小化(约 85 行生产代码),实现严格遵循项目规范。

共享 runtime 钩子模式(supportsProactiveWebhookTarget 默认委托给现有检查)是正确的抽象——它将单聊支持隔离在 Webhook 任务中,不会泄漏到普通 channel loop。Endpoint 分支直观,DM 响应校验正确处理了钉钉特有的错误格式,targetKind 日志增强在不过度噪音的前提下提供了有用的调试上下文。

测试充分:69 个 DingTalk adapter 测试覆盖群聊和单聊路径、Token 复用、401 重试、响应校验和错误日志。397 个 ChannelBase 测试包含 Webhook 目标独立性验证。全部通过。Typecheck 干净。

作者的 before/after 证据(curl → 202 → 任务完成 → 消息送达)确认了端到端功能正常。设计文档和实现计划已提交到 docs/ 供后续参考。

批准 ✅

Qwen Code · qwen3.7-max

Reviewed at 6c5148ffabfe5f56b2f7d6ce39bc32492e7f9ee4 · 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. ✅

@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. Suggestions are inline. Not reviewed: chunk 1, chunk 2, chunk 3 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

Comment thread packages/channels/dingtalk/src/DingtalkAdapter.test.ts
Comment thread packages/channels/base/src/ChannelBase.test.ts
Comment thread packages/channels/dingtalk/src/DingtalkAdapter.ts

@wenshao wenshao 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.

Review — PR #6891 (DingTalk webhook direct-message delivery)

Verdict: COMMENT — 0 Critical, 3 Suggestions. Targeted build passes; 459 unit tests pass (396 ChannelBase + 63 DingtalkAdapter); issue #6883 fully matched (the DM body shape was verified against the reference implementation cited in the issue).

Clean, well-scoped change: a new ChannelBase.supportsProactiveWebhookTarget hook that defaults to supportsProactiveTarget (so non-DingTalk channels are unaffected), a DingTalk override that accepts isGroup:false webhook targets, and sendProactiveChunk routing group → groupMessages/send / direct → oToMessages/batchSend. The webhook-only broadening correctly keeps ordinary channel loops group-only, so an inbound DM conversation id can't be mistaken for a user id. The renames (isConversationIdisStableTargetId, GROUP_MSG_KEYPROACTIVE_MSG_KEY) are complete and behavior-preserving.

Two Suggestions are posted inline. One more, lower-priority note that couldn't be anchored to a changed line:

[Suggestion] Proactive-send failure logs omit target kind and chatId — in sendProactiveChunk, sends can now be group or direct, but the failure logs carry only chunk N/M. For the unattended webhook path, a failed delivery can't be correlated to which configured target/chat without cross-referencing other events. Consider including the group/direct kind and a sanitized chatId in both the proactive send error and proactive send failed logs.

🤖 Automated review · 12 dimension agents + 3 reverse-audit rounds · not a maintainer gate

Comment thread packages/channels/dingtalk/src/DingtalkAdapter.ts
Comment thread packages/channels/dingtalk/src/DingtalkAdapter.ts
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@wenshao Follow-up on the unanchored logging suggestion: e700463 adds group/dm to both transport-error and HTTP-failure logs, with regression coverage for each path. I intentionally did not log chatId: it is a stable DingTalk user/conversation identifier, and copying it into unattended daemon logs would broaden PII exposure. The target kind identifies the selected DingTalk API path; the configured webhook target and task metadata remain the appropriate correlation sources when a specific destination must be investigated. The same commit also handles batchSend HTTP 200 recipient failures by surfacing invalidStaffIdList and flowControlledStaffIdList as delivery errors. Local verification: DingTalk 67/67, ChannelBase 397/397, build, typecheck, and targeted ESLint all passed.

@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.

Not reviewed: chunk 1, chunk 2, chunk 3 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

Comment thread packages/channels/dingtalk/src/DingtalkAdapter.ts Outdated
Comment thread packages/channels/dingtalk/src/DingtalkAdapter.test.ts
Comment thread packages/channels/dingtalk/src/DingtalkAdapter.ts Outdated
Comment thread packages/channels/dingtalk/src/DingtalkAdapter.ts

@wenshao wenshao 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 diff-only — the PR’s existing discussion could not be fetched, so this is not an approval and not a no-blockers claim. Unresolved, please confirm: [Critical] packages/channels/dingtalk/src/DingtalkAdapter.ts:631 — existing malformed-JSON Critical: Response.json() does consume the body, but non-JSON HTTP 200 delivery semantics could not be established from the API contract [Critical] review 4697493827 — existing coverage blocker: all three chunks were manually read in this run, but the transcript coverage harness was unavailable Not reviewed: coverage verification — qwen transcript harness unavailable; all 3 chunks were manually read but could not be machine-verified. Not reviewed: build-and-test — npm test reached its 120-second cap; targeted ChannelBase and DingTalk suites passed, but the full-suite result was inconclusive. Not reviewed: CI — route check is failing at the reviewed commit. Not reviewed: coverage — could not read the agents' transcripts (the CLI did not export QWEN_CODE_PROJECT_DIR / QWEN_CODE_SESSION_ID, so this run cannot find the harness's record of what its agents did), so this run cannot show that any of the diff was read.

— Codex $qreview via Qwen Code /review

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

Review follow-up is complete in 6c5148f. Malformed HTTP 200 JSON from the DingTalk one-to-one API is now a delivery error rather than a silent success. The regression test confirms Response.json() consumed the body (bodyUsed === true), so no extra cancel is needed after a parse failure. The three additional suggestions are also covered: group payload excludes userIds, failure lists containing only other recipients do not reject the current target, and the regular-proactive/group-only boundary is documented. Local verification: DingTalk 69/69, full repository build, full typecheck, targeted ESLint/Prettier, two clean diff audits, and an independent read-only code review. All four review threads have been replied to and resolved.

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

本地 E2E 回归结论

已基于最新提交 6c5148ffabfe 完成真实钉钉单聊和群聊回归。

  • 构建:npm run build && npm run bundle 通过。
  • 单聊链路:本地 curl → daemon webhook → 真实 AI 任务 → 钉钉 OTO Markdown 投递;webhook 返回 202 {"accepted":true},AI 任务约 4.14 秒完成,客户端已确认收到消息。
  • 群聊链路:本地 curl → daemon webhook → 真实 AI 任务 → 钉钉群 Markdown 投递;使用 openConversationId 作为群目标,webhook 返回 202 {"accepted":true},AI 任务约 32.86 秒完成,客户端已确认群内收到消息。
  • daemon 状态:DingTalk Stream 连接正常,channelLive=true,任务完成后未出现 webhook task failedproactive send failed、DingTalk HTTP 4xx/5xx 等失败日志。
  • 自动化验证:DingTalk package 116/116、ChannelBase 397/397 通过;全仓 npm run typecheck 通过;相关 serve channel worker、streaming 和 routes 集成测试通过。
  • 隔离与清理:真实凭据仅通过本机环境变量注入,未写入仓库;一次性配置、日志和 daemon 进程均已清理,原持久化配置未修改。

结论:本 PR 覆盖的“外部 webhook → daemon → 钉钉单聊/群聊 Markdown 投递”已完成真实端到端验证。

@wenshao wenshao 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. Suggestions are inline. Unresolved, please confirm: [Critical] review 4697493827 coverage blocker — the Qwen transcript harness remains unavailable in Codex, so machine coverage could not be verified even though the root and reviewers manually read all three chunks Not reviewed: build-and-test — root npm test was interrupted before completion; the changed ChannelBase and DingTalk test files were separately proven effective by the efficacy probe. Not reviewed: coverage — could not read the agents' transcripts (the CLI did not export QWEN_CODE_PROJECT_DIR / QWEN_CODE_SESSION_ID, so this run cannot find the harness's record of what its agents did), so this run cannot show that any of the diff was read.

— Codex $qreview via Qwen Code /review

Comment on lines +205 to +207
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts && npm run build
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts && npm run build
cd /Users/ben/workspace/qwen-code && npm run build && npm run typecheck

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 verification block cannot be run as written: after the first command changes into packages/channels/base, the second relative cd resolves below it, and the final command hard-codes the author's /Users/ben checkout. A contributor copying this block stops after the first package, so the documented DingTalk/root verification never runs.

Suggested change
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts && npm run build
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts && npm run build
cd /Users/ben/workspace/qwen-code && npm run build && npm run typecheck
(cd packages/channels/base && npx vitest run src/ChannelBase.test.ts && npm run build)
(cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts && npm run build)
npm run build && npm run typecheck

— Codex $qreview via Qwen Code /review

Comment on lines +634 to +636
} catch {
process.stderr.write(
`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid JSON response\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] This catch treats every Response.json() rejection as invalid JSON, but body reads can also fail after headers because the stream times out or resets. In that case the worker discards the original TimeoutError/socket error and reports a schema problem, leaving the on-call unable to diagnose a transport failure. Catch the error, distinguish SyntaxError from read/abort failures, preserve a sanitized cause in the log and thrown delivery error, and add a stream-error or timeout regression test.

— Codex $qreview via Qwen Code /review

Comment on lines +608 to +610
protected supportsProactiveWebhookTarget(target: SessionTarget): boolean {
return this.supportsProactiveTarget(target);
}

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] This new webhook-only target hook changes the adapter extension contract, but the canonical adapter guide still says only supportsProactiveTarget() controls webhook target shapes. An adapter author following that guide can broaden the ordinary predicate for a webhook-only shape and unintentionally enable it for loops—the boundary this hook exists to protect. Document supportsProactiveWebhookTarget(), its default delegation, and when adapters should override it separately.

— Codex $qreview via Qwen Code /review

@wenshao

wenshao commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

@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.

Reviewed. Suggestions are inline. Not reviewed: chunk 1, chunk 2, chunk 3 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

Comment on lines 607 to +609
const cause = (err as { cause?: unknown }).cause;
process.stderr.write(
`[DingTalk:${this.name}] proactive send error (${chunkLabel}): ${err}${cause ? ` (${cause})` : ''}\n`,
`[DingTalk:${this.name}] proactive send error (${targetKind}, ${chunkLabel}): ${err}${cause ? ` (${cause})` : ''}\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] The catch block logs ${err} and ${cause} directly to stderr without sanitizeLogText. The adjacent !resp.ok branch already uses sanitizeLogText(await resp.text().catch(() => ''), 300) for the same class of external data, making the two error paths inconsistent. Concrete cost: A network error whose .message or .cause contains control characters (CR, ANSI escape sequences) could corrupt the stderr log line — e.g. \r overwriting the line prefix or an ANSI OSC sequence injecting a fake log entry.

Suggested change
const cause = (err as { cause?: unknown }).cause;
process.stderr.write(
`[DingTalk:${this.name}] proactive send error (${chunkLabel}): ${err}${cause ? ` (${cause})` : ''}\n`,
`[DingTalk:${this.name}] proactive send error (${targetKind}, ${chunkLabel}): ${err}${cause ? ` (${cause})` : ''}\n`,
const cause = (err as { cause?: unknown }).cause;
process.stderr.write(
`[DingTalk:${this.name}] proactive send error (${targetKind}, ${chunkLabel}): ${sanitizeLogText(String(err), 300)}${cause ? ` (${sanitizeLogText(String(cause), 200)})` : ''}\n`,

— qwen3.7-max via Qwen Code /review

Comment on lines +631 to +634
let data: DingTalkDirectMessageResponse;
try {
data = (await resp.json()) as DingTalkDirectMessageResponse;
} catch {

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] If DingTalk returns valid JSON that is not an object (e.g. JSON null, a number, or a string) with HTTP 200, resp.json() succeeds and data is assigned a non-object value. The subsequent data.invalidStaffIdList then throws TypeError: Cannot read properties of null outside the try/catch, bypassing the structured error handling. Concrete cost: A DingTalk API change returning "null" or a non-object body causes a raw TypeError instead of the informative "invalid JSON response" error.

Suggested change
let data: DingTalkDirectMessageResponse;
try {
data = (await resp.json()) as DingTalkDirectMessageResponse;
} catch {
let data: DingTalkDirectMessageResponse;
try {
const raw = await resp.json();
if (raw === null || typeof raw !== 'object') {
throw new TypeError('non-object response');
}
data = raw as DingTalkDirectMessageResponse;
} catch {

— qwen3.7-max via Qwen Code /review

Comment on lines +650 to +652
if (data.flowControlledStaffIdList?.includes(target.chatId)) {
process.stderr.write(
`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): direct recipient rate limited\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] The DM response is only checked for invalidStaffIdList and flowControlledStaffIdList. If DingTalk returns HTTP 200 with a top-level error (e.g. {"errcode": 310000, "errmsg": "robot not authorized"}) that doesn't populate either list, the send is silently treated as success. Concrete cost: A permission change or robot misconfiguration causes silent delivery failure — the webhook caller believes the operator was notified, but the operator never receives anything. In a CI/monitoring context, this defeats the feature's core purpose.

Consider checking for common DingTalk error fields after the two list checks, or logging the full response body at debug level for investigation:

if ((data as Record<string, unknown>).errcode) {
  // handle application-level error
}

— qwen3.7-max via Qwen Code /review

Comment on lines +1024 to 1027
if (!this.supportsProactiveWebhookTarget(target)) {
throw new Error(
'Channel does not support proactive webhook messages for this chat target.',
);

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 rejection error message is identical regardless of why the target failed validation. At 3 AM, seeing "Channel does not support proactive webhook messages for this chat target" gives no clue whether isGroup was missing, chatId was a webhook URL, chatId was empty, or threadId was set. Concrete cost: An operator must manually diff the target config against the documentation example to find the missing field, increasing mean-time-to-diagnosis.

Consider logging the sanitized target fields before throwing:

process.stderr.write(
  `[Channel:${this.name}] webhook target rejected: isGroup=${target.isGroup}, threadId=${target.threadId}, chatId=${target.chatId ? '***' : '(empty)'}\n`,
);

— qwen3.7-max via Qwen Code /review

text: string,
chunkLabel: string,
): Promise<void> {
const targetKind = target.isGroup === true ? 'group' : 'dm';

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] DM failure logs include the channel name, target kind (dm), and chunk label, but never the recipient user ID or targetRef. When a DingTalk channel has multiple DM targets configured, you cannot tell which DM target failed from the adapter log alone. Concrete cost: At 3 AM with two DM targets (e.g. operator and oncall-engineer), you must correlate timestamps against the ChannelBase webhook task log to figure out who didn't get paged.

Consider including a sanitized form of the target (e.g. last 4 chars of chatId or the targetRef) in the log lines.

— qwen3.7-max via Qwen Code /review

Comment on lines +740 to 743
/** Stable API targets are conversation or user IDs, never webhook URLs. */
private isStableTargetId(chatId: string): boolean {
return !!chatId && !/^https?:\/\//i.test(chatId);
}

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 rename from isConversationId to isStableTargetId broadens the method's apparent scope — the doc comment says "conversation or user IDs" — but the same method is still used by startReaction and stopReaction where the chatId must be a conversation ID specifically (the emotion API requires one). A user ID would pass isStableTargetId but fail at the emotion API level. Concrete cost: A future maintainer reading the method name and doc comment would conclude "any non-URL identifier is fine" and reuse it in a context where only conversation IDs are valid, causing silent API failures.

Consider adding a comment at the reaction call sites clarifying that isStableTargetId rejects webhook-URL fallback chatIds, not ID types.

— qwen3.7-max via Qwen Code /review

Comment on lines 2142 to +2145
it('stops at the first failed chunk', async () => {
const channel = proactive(createChannel());
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { sendCalls } = stubProactiveFetch(
const { directSendCalls } = stubProactiveFetch(

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 'stops at the first failed chunk' test was changed from groupTarget to directTarget. While the chunking loop is shared code, the group-specific sendProactiveChunk path under multi-chunk failure no longer has a dedicated assertion. Concrete cost: A regression that only affects group-target error handling (e.g. silently swallowing the error for group but not DM) would not be caught by this test.

Consider parameterizing: it.each([groupTarget, directTarget])('stops at the first failed chunk for %s', ...).

— qwen3.7-max via Qwen Code /review

Comment on lines +630 to +633
if (target.isGroup === false) {
let data: DingTalkDirectMessageResponse;
try {
data = (await resp.json()) as DingTalkDirectMessageResponse;

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 DM response-body error detection is embedded inline in sendProactiveChunk with no structural separation from the group path. A future maintainer who refactors for readability could easily collapse both paths into one and either strip the DM body-parsing (causing silent delivery failures) or apply it to the group path (causing crashes when the group API returns {}). Concrete cost: The invariant "DM requires body inspection, group does not" is implicit — it lives only in the code structure, not in a named method or comment.

Consider extracting post-200 response validation into a private method like validateProactiveSendResponse(target, resp, chunkLabel) with a comment explaining the API-level difference.

— qwen3.7-max via Qwen Code /review

Comment on lines 581 to +588
for (let attempt = 0; ; attempt++) {
const token = await this.getProactiveToken();
let resp: Response;
try {
resp = await fetch(GROUP_MSG_API, {
method: 'POST',
headers: {
'x-acs-dingtalk-access-token': token,
'Content-Type': 'application/json',
const targetBody =
target.isGroup === true
? { openConversationId: target.chatId }
: { userIds: [target.chatId] };

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] targetBody, the API URL, and targetKind are computed inside the for (let attempt = 0; ; attempt++) retry loop, but they are pure functions of target, which never changes across iterations. On a 401 retry, both are recomputed identically. Concrete cost: Extra object allocations and conditionals on each retry, plus readability — a future maintainer must verify these values are loop-invariant before reasoning about the retry path.

Hoist above the loop:

const isGroup = target.isGroup === true;
const targetKind = isGroup ? 'group' : 'dm';
const targetBody = isGroup
  ? { openConversationId: target.chatId }
  : { userIds: [target.chatId] };
const apiUrl = isGroup ? GROUP_MSG_API : DIRECT_MSG_API;

— qwen3.7-max via Qwen Code /review

Comment on lines +502 to +508
protected override supportsProactiveWebhookTarget(
target: SessionTarget,
): boolean {
return (
typeof target.isGroup === 'boolean' &&
target.threadId === undefined &&
this.isStableTargetId(target.chatId)

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] isStableTargetId only rejects empty strings and HTTP URLs. For DM targets, there is no validation that the chatId resembles a DingTalk user ID. An admin who deploys with the literal placeholder "DINGTALK_USER_ID" from the docs passes validation, reaches the DingTalk API, and fails opaquely. Concrete cost: The design doc states "invalid targets fail before the agent runs," but this gate is too permissive to honor that guarantee for DM targets — misconfigured IDs pass validation and fail at the API level with an opaque error.

Consider adding a basic format check for DM targets (e.g. rejecting IDs that don't match /^[a-zA-Z0-9._-]+$/ or exceed a reasonable length).

— qwen3.7-max via Qwen Code /review

@wenshao wenshao 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. The prior malformed-JSON Critical is fixed on this commit (the DM path now wraps resp.json() in try/catch and throws on parse failure, and the body is drained — regression test asserts bodyUsed === true). Two new suggestions are inline.

— qwen-latest-series-invite-beta-v77 via Qwen Code /review

Comment on lines +630 to +631
if (target.isGroup === false) {
let data: DingTalkDirectMessageResponse;

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 group/DM decision is derived with two different predicates: API routing keys on target.isGroup === true (anything not-true → DM endpoint/userIds), but this DM response-validation block keys on target.isGroup === false (anything not-false → group drain). For a boolean these are equivalent, but they diverge for isGroup === undefined — such a target would POST to the one-to-one DM endpoint yet skip the invalidStaffIdList/flowControlledStaffIdList checks, silently reintroducing the very "DM non-delivery reported as success" bug this PR fixes. Unreachable today (all three pushProactive call sites gate isGroup to a boolean), so this is a latent footgun rather than a live bug. — Concrete cost: a future caller or refactor that reaches sendProactiveChunk with a non-boolean isGroup silently bypasses DM delivery validation.

Derive the decision once and branch on it everywhere so routing and validation can never disagree:

const isGroup = target.isGroup === true;
const targetKind = isGroup ? 'group' : 'dm';
// ...targetBody / endpoint: isGroup ? group : dm
if (!isGroup) { /* DM per-recipient validation */ }

— qwen-latest-series-invite-beta-v77 via Qwen Code /review

'DingTalk proactive send failed: invalid direct recipient',
);
}
if (data.flowControlledStaffIdList?.includes(target.chatId)) {

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] A flow-controlled recipient (flowControlledStaffIdList) is a transient rate-limit, but it is thrown immediately just like a permanent invalid recipient (invalidStaffIdList) — no backoff or retry — even though the 401 branch just above deliberately retries once precisely because its condition is transient. The distinct direct recipient rate limited message shows the code already recognizes this is a different kind of failure, yet acts on it the same way. — Failure scenario: this feature targets CI/alerting → DM, exactly the burst scenario where DingTalk flow-control bites; a short alert storm or several CI runs to one operator can flow-control a single-chunk notification, which is then dropped and reported as a hard failure instead of recovering after a brief wait. (Note: flow-control needs backoff, not the immediate 401-style retry, which would just fail again.) Consider distinguishing transient flow-control from a permanent invalid recipient and backing off before a bounded retry.

— qwen-latest-series-invite-beta-v77 via Qwen Code /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.

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — maintainer merge reference

I built and drove the real PR code end-to-end (not the mocked unit layer) to confirm the DingTalk webhook → direct‑message feature actually works, is regression‑safe for groups, and that the change is load‑bearing. Verified at head 6c5148ffa against merge‑base 669228945, macOS + Node 22.23.1.

Why a separate harness: DingtalkAdapter.test.ts mocks out the entire @qwen-code/channel-base, so the cross‑layer path — the real ChannelBase.runWebhookTask webhook gate calling the real DingtalkChannel override and routing over the network — is not exercised by the unit tests. The harness below fills exactly that gap.


1) Cross‑layer real‑HTTP E2E (32/32)

Drives the real built ChannelBase + DingtalkChannel (PR source) over real HTTP sockets against a loopback DingTalk‑API emulator (oapi.dingtalk.com / api.dingtalk.com redirected to 127.0.0.1). The only stub is the agent itself — bridge.prompt() returns canned Markdown — because the logic under test is delivery/routing, not the model.

What it proves:

  • Headline: a webhook task with isGroup:false flows all the way through runWebhookTask and is delivered via the one‑to‑one API robot/oToMessages/batchSend with userIds:["manager-user-id"] (no openConversationId), robotCode, msgKey:"sampleMarkdown", and the agent title/text.
  • Regression guard: a group target still hits robot/groupMessages/send with openConversationId and no userIds.
  • Shared token: a DM send and a group send fetch the access token once and both carry the same cached token.
  • 401 path: a 401 on the DM send triggers exactly one token refresh + one retry (real HTTP status handling).
  • Failure surfaced: when DingTalk returns invalidStaffIdList for the recipient, the webhook task fails loudly rather than reporting success.
  • Blast radius: ordinary channel loops stay group‑only (supportsProactiveTarget(DM) === false); only the new webhook hook opens DMs; a webhook‑URL fallback chatId is still rejected.

cross-layer E2E


2) Load‑bearing controlled experiment

Reverting the single gate line in ChannelBase.resolveWebhookTaskTarget (supportsProactiveWebhookTargetsupportsProactiveTarget) flips the DM path from ACCEPTED / DELIVERED / 1 send to REJECTED / FAILED / 0 sends, with the exact production error message. This confirms the new hook is what enables DM delivery — the feature isn't accidentally passing for another reason.

load-bearing before/after


3) Unit suites on PR source

DingtalkAdapter.test.ts 69/69 and ChannelBase.test.ts 397/397, including every new direct‑message test and both new webhook‑gate tests.

unit suites


Notes for merge

  • Docs match code: isGroup:falsechatId is a DingTalk user ID; isGroup:truechatId is an openConversationId. Webhook‑URL and thread targets are correctly excluded.
  • Stated risk is mitigated in‑band: the PR notes a syntactically valid but wrong user ID only fails at DingTalk. In practice that failure is caught — the adapter parses invalidStaffIdList / flowControlledStaffIdList and throws, so a bad recipient surfaces as a failed delivery (S5 + unit tests), not a silent success.
  • Out of local scope (needs real credentials, done by the author per the PR body): a live send landing in a real DingTalk user's chat, and the real model run. My harness substitutes a loopback emulator + canned agent output for exactly those two external dependencies.
  • CI is green on the branch (Test / ubuntu Node 22.x).

Verdict: LGTM — headline behavior verified end‑to‑end at the real cross‑layer boundary, group path regression‑guarded, and the gate change proven load‑bearing.

中文版本(点击展开)

✅ 本地验证 —— 供 maintainer 合并参考

我在本地构建并端到端驱动了 PR 的真实代码(而非 mock 的单测层),确认「钉钉 Webhook → 单聊」功能确实可用、对群聊路径无回归,并且该改动是「承重(load‑bearing)」的。验证基于 head 6c5148ffa、merge‑base 669228945,环境为 macOS + Node 22.23.1。

为什么单独写 harness:DingtalkAdapter.test.ts整个 @qwen-code/channel-base 都 mock 掉了,因此跨层路径 —— 真实的 ChannelBase.runWebhookTask Webhook 网关调用真实的 DingtalkChannel override 并走真实网络投递 —— 并没有被单测覆盖。下面的 harness 正好补上这个缺口。

1)跨层真实 HTTP 端到端(32/32)

真实构建的 ChannelBase + DingtalkChannel(PR 源码),通过真实 HTTP socket 打到一个本地回环的钉钉 API 模拟器(把 oapi.dingtalk.com / api.dingtalk.com 重定向到 127.0.0.1)。唯一被替换的是 agent 本身 —— bridge.prompt() 返回固定 Markdown —— 因为被测的是投递/路由逻辑,而非模型。

它证明了:

  • 核心功能: isGroup:false 的 Webhook 任务完整走过 runWebhookTask,并通过一对一接口 robot/oToMessages/batchSend 投递,body 为 userIds:["manager-user-id"](无 openConversationId)、robotCodemsgKey:"sampleMarkdown",以及 agent 的标题/正文。
  • 回归防护: 群聊目标仍然打到 robot/groupMessages/send,body 带 openConversationId 且无 userIds
  • Token 复用: 一次单聊发送 + 一次群聊发送只拉取 access token 一次,两者携带同一个缓存 token。
  • 401 路径: 单聊发送返回 401 时,恰好触发一次 token 刷新 + 一次重试(真实 HTTP 状态处理)。
  • 失败会暴露: 当钉钉在响应里返回 invalidStaffIdList 命中收件人时,Webhook 任务会明确失败,而不是误报成功。
  • 影响范围: 普通 channel loop 仍只支持群聊(supportsProactiveTarget(DM) === false);只有新的 Webhook 钩子放开单聊;Webhook‑URL 兜底的 chatId 仍被拒绝。

2)承重性对照实验(before/after)

只回退 ChannelBase.resolveWebhookTaskTarget 里的那一行网关判断(supportsProactiveWebhookTargetsupportsProactiveTarget),单聊路径就从 ACCEPTED / DELIVERED / 1 次发送 翻转为 REJECTED / FAILED / 0 次发送,且报错信息与生产代码完全一致。这说明正是这个新钩子在支撑单聊投递 —— 功能不是因为别的原因「碰巧通过」。

3)PR 源码上的单元测试

DingtalkAdapter.test.ts 69/69ChannelBase.test.ts 397/397,覆盖了全部新增的单聊测试以及两个新增的 Webhook 网关测试。

合并注意事项

  • 文档与代码一致: isGroup:falsechatId 是钉钉用户 ID;isGroup:truechatIdopenConversationId。Webhook‑URL 与话题目标被正确排除。
  • PR 提到的风险已在链路内缓解: PR 指出「格式合法但填错的用户 ID」只有在钉钉侧才会失败。实际上该失败会被捕获 —— adapter 解析 invalidStaffIdList / flowControlledStaffIdList 并抛错,因此错误收件人会表现为一次失败投递(S5 + 单测),而非静默成功。
  • 本地无法覆盖的部分(需真实凭据,PR 作者已按 PR 描述完成): 消息真正落到某个真实钉钉用户的会话、以及真实模型运行。我的 harness 正是用本地回环模拟器 + 固定 agent 输出替换了这两个外部依赖。
  • 分支 CI 全绿(Test / ubuntu Node 22.x)。

结论:LGTM —— 核心行为已在真实跨层边界端到端验证,群聊路径有回归防护,网关改动被证明是承重的。

@wenshao
wenshao added this pull request to the merge queue Jul 15, 2026
Merged via the queue into QwenLM:main with commit 4b802ca Jul 15, 2026
234 of 235 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.

feat(channels): 支持钉钉 Webhook 任务投递到单聊

3 participants