docs/plans/2026-07-14-dingtalk-webhook-direct-message.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让 daemon 接收外部 Webhook 后,既能把 Markdown 结果投递到钉钉群聊,也能投递到钉钉单聊。
Architecture: 沿用现有 DingtalkChannel 和 Webhook 目标结构。共享 runtime 用 Webhook 专用目标检查与普通主动投递检查隔离;钉钉仅为 Webhook 放开单聊目标,并根据 SessionTarget.isGroup 在群聊 API 与单聊 API 之间选择 endpoint 和请求体。其他 Token、Markdown 分片、401 重试及错误处理逻辑保持共用。
Tech Stack: TypeScript、Vitest、DingTalk OpenAPI、Express daemon Webhook route、curl
isGroup: true 与 openConversationId;单聊目标使用 isGroup: false 与钉钉用户 ID。isGroup,且不支持 threadId 和 Webhook URL。package-lock.json 改动。Files:
packages/channels/dingtalk/src/DingtalkAdapter.test.tspackages/channels/dingtalk/src/DingtalkAdapter.tspackages/channels/base/src/ChannelBase.tspackages/channels/base/src/ChannelBase.test.tsInterfaces:
Consumes: SessionTarget.isGroup, SessionTarget.chatId, DingtalkChannel.pushProactive()
Produces: 群聊调用 robot/groupMessages/send,单聊调用 robot/oToMessages/batchSend
Step 1: 写单聊目标和请求体失败测试
在主动投递测试中新增单聊目标:
const directTarget: SessionTarget = {
channelName: 'test-dingtalk',
senderId: 'webhook:github-ci',
chatId: 'manager-user-id',
isGroup: false,
};
将 Webhook 目标校验断言改为允许群聊和单聊,同时断言普通主动投递仍拒绝单聊;两种检查都继续拒绝空 ID、Webhook URL 和 threadId。新增单聊请求测试:
it('sends proactive direct messages through the one-to-one robot API', async () => {
const channel = proactive(createChannel());
const { directSendCalls, tokenCalls } = stubProactiveFetch();
await channel.pushProactive(directTarget, '# Result\nloop output');
expect(tokenCalls()).toHaveLength(1);
const sends = directSendCalls();
expect(sends).toHaveLength(1);
const init = sends[0]![1] as RequestInit;
const body = JSON.parse(String(init.body));
expect(body).toMatchObject({
robotCode: 'client-id',
userIds: [directTarget.chatId],
msgKey: 'sampleMarkdown',
});
expect(body.openConversationId).toBeUndefined();
});
Run:
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts
Expected: 单聊目标断言或单聊发送测试失败,因为当前实现只接受 isGroup: true,且只调用群消息 API。
在共享 runtime 中增加 Webhook 专用目标检查,默认委托给原有主动投递检查。钉钉保留普通主动投递只接受群聊的规则,仅在 Webhook 专用检查中接受显式群聊或单聊目标。
在 adapter 中加入单聊 endpoint:
const DIRECT_MSG_API =
'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
钉钉 Webhook 目标校验必须要求 isGroup 是布尔值、threadId 未设置、chatId 是稳定 ID:
return (
typeof target.isGroup === 'boolean' &&
target.threadId === undefined &&
this.isStableTargetId(target.chatId)
);
把完整 SessionTarget 传给分片发送方法,并仅在该方法中选择 endpoint 和目标字段:
const targetBody = target.isGroup
? { openConversationId: target.chatId }
: { userIds: [target.chatId] };
resp = await fetch(target.isGroup ? GROUP_MSG_API : DIRECT_MSG_API, {
method: 'POST',
headers: {
'x-acs-dingtalk-access-token': token,
'Content-Type': 'application/json',
},
body: JSON.stringify({
robotCode: this.config.clientId!,
...targetBody,
msgKey: PROACTIVE_MSG_KEY,
msgParam: JSON.stringify({ title, text }),
}),
signal: AbortSignal.timeout(PROACTIVE_FETCH_TIMEOUT_MS),
});
Run:
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts
Expected: 全部测试通过,群聊原有断言与新增单聊断言同时为绿色。
Files:
docs/users/features/channels/overview.mddocs/users/features/channels/dingtalk.md.qwen/e2e-tests/2026-07-14-dingtalk-webhook-direct-message.mdInterfaces:
Consumes: 现有 webhooks.sources.<source>.targets.<targetRef> 配置
Produces: 可复制的单聊/群聊配置和 curl 验证步骤
Step 1: 文档化两种目标配置
在 Webhook 示例中保留群聊目标并加入单聊目标:
"targets": {
"operator": {
"chatId": "DINGTALK_USER_ID",
"senderId": "webhook:github-ci",
"isGroup": false
},
"team": {
"chatId": "OPEN_CONVERSATION_ID",
"senderId": "webhook:github-ci",
"isGroup": true
}
}
明确说明单聊 chatId 是钉钉用户 ID,群聊 chatId 是 openConversationId,二者都必须显式设置 isGroup。
E2E 计划应使用隔离的 HOME 和真实凭据启动本地 daemon,然后分别执行:
curl -i -X POST 'http://127.0.0.1:4170/channels/dingtalk-main/webhooks/manual-test' \
-H "x-qwen-webhook-secret: $QWEN_CHANNEL_DINGTALK_TEST_SECRET" \
-H 'Content-Type: application/json' \
-d '{"eventType":"manual_test","targetRef":"operator","title":"DingTalk DM self-test","payload":{"source":"curl"}}'
以及将 targetRef 改为 team 的群聊兼容性请求。预期 HTTP 均返回 202 {"accepted":true},worker 日志显示任务完成,钉钉目标收到 Markdown。
Files:
packages/channels/dingtalk/src/DingtalkAdapter.tspackages/channels/dingtalk/src/DingtalkAdapter.test.tspackages/channels/base/src/ChannelBase.tspackages/channels/base/src/ChannelBase.test.tsdocs/users/features/channels/overview.mddocs/users/features/channels/dingtalk.mdInterfaces:
Consumes: Task 1 与 Task 2 的实现和文档
Produces: 可提交的本地实现与验证证据
Step 1: 运行定向单测、构建和类型检查
Run:
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
Expected: 两个受影响 package 的测试和构建退出码为 0。根目录 build/typecheck 也应运行;若最新 origin/main 自身存在阻塞,则记录基线文件和错误,并确认本分支未修改该范围。
从隔离配置启动 qwen serve --channel dingtalk-main,对单聊和群聊 targetRef 各执行一次 curl。记录 HTTP 状态、daemon/worker 日志和钉钉实际收件结果;若缺少某个目标 ID,明确标记对应在线步骤未验证。
Run:
git diff --check
git diff -- packages/channels/base/src/ChannelBase.ts packages/channels/dingtalk/src/DingtalkAdapter.ts packages/channels/dingtalk/src/DingtalkAdapter.test.ts docs/users/features/channels/overview.md docs/users/features/channels/dingtalk.md docs/plans/2026-07-14-dingtalk-webhook-direct-message.md
git status --short
Expected: 无空白错误,diff 仅包含本功能文件,package-lock.json 保持为未暂存的用户改动。