Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions docs/design/2026-07-14-dingtalk-webhook-direct-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 钉钉 Webhook 单聊投递设计

## 状态

已实现并完成单聊真实链路验证。对应 Issue:
[QwenLM/qwen-code#6883](https://github.com/QwenLM/qwen-code/issues/6883)。

## 背景

由 daemon 托管的 channel 可以接收经过鉴权的外部 Webhook 事件,以无人值守任务的方式运行 agent,并将最终结果主动投递到预先配置的聊天目标。目前钉钉只支持投递到群聊:目标必须设置 `isGroup: true`,adapter 通过群消息 API 发送 Markdown。

这使得 CI 系统、监控告警等 Webhook 来源无法直接通知某个负责的钉钉用户,只能投递到群聊。

## 目标

- 将 daemon Webhook 任务结果投递到钉钉单聊目标。
- 保持现有钉钉群聊 Webhook 投递行为不变。
- 保持普通主动投递和 channel loop 仍只接受钉钉群聊目标,不把入站单聊的会话 ID 当作用户 ID。
- 复用现有的目标配置结构、Token 缓存、Markdown 格式化、消息分片、重试和投递错误处理。
- 沿用现有钉钉 channel,不新增 channel 或配置字段。

## 非目标

- 钉钉原生 Card 或 Card 回调。
- Card 流式更新、按钮、反馈或从钉钉取消任务。
- 单个目标配置多个接收人。
- 钉钉话题投递。
- 新增 channel 类型或修改 daemon Webhook 协议。

## 目标配置

无需新增配置字段。现有 Webhook 目标字段在钉钉 channel 中的含义如下:

| `isGroup` | `chatId` 含义 | 投递 API |
| --------- | ----------------------------- | ----------------------------- |
| `true` | 钉钉群聊 `openConversationId` | `robot/groupMessages/send` |
| `false` | 钉钉用户 ID | `robot/oToMessages/batchSend` |

`senderId` 仍然是用于将 Webhook 任务路由到 agent session 的虚拟身份,不是钉钉接收人 ID。

配置示例:

```json
{
"webhooks": {
"sources": {
"github-ci": {
"secretEnv": "QWEN_CHANNEL_GITHUB_CI_SECRET",
"targets": {
"operator": {
"chatId": "DINGTALK_USER_ID",
"senderId": "webhook:github-ci",
"isGroup": false
},
"team": {
"chatId": "OPEN_CONVERSATION_ID",
"senderId": "webhook:github-ci",
"isGroup": true
}
}
}
}
}
}
```

目标必须显式设置 `isGroup`。以下目标继续被 adapter 拒绝:`chatId` 为空、设置了 `threadId`、缺少 `isGroup`,或者使用 Webhook URL 代替稳定的目标 ID。

## 投递链路

daemon 路由和 worker IPC 保持不变;共享 channel runtime 仅增加 Webhook 专用目标检查:

```text
POST /channels/:channelName/webhooks/:source
-> daemon 对事件进行鉴权和校验
-> channel worker 运行无人值守 agent 任务
-> ChannelBase 调用 DingtalkChannel.pushProactive()
-> adapter 根据 target.isGroup 选择钉钉 API
-> 钉钉接收 Markdown
```

共享 channel runtime 使用独立的 Webhook 目标能力检查。默认实现仍沿用普通主动投递的目标规则;钉钉仅在 Webhook 任务解析时额外接受 `isGroup: false`。因此普通 channel loop 继续拒绝单聊目标,避免把入站单聊的 `conversationId` 错当成一对一消息 API 所需的用户 ID。

群聊目标继续使用现有请求体:

```json
{
"robotCode": "CLIENT_ID",
"openConversationId": "OPEN_CONVERSATION_ID",
"msgKey": "sampleMarkdown",
"msgParam": "{...}"
}
```

单聊目标通过一对一消息 API 发送相同的 Markdown 模板:

```json
{
"robotCode": "CLIENT_ID",
"userIds": ["DINGTALK_USER_ID"],
"msgKey": "sampleMarkdown",
"msgParam": "{...}"
}
```

两条路径共用现有的 access token 缓存,在 Token 到期前一分钟刷新;遇到 HTTP 401 时重试一次;同时使用相同的 Markdown 规范化和分片限制。多分片投递在首个分片失败后停止。

## 错误处理

- 无效目标在 agent 运行前即无法通过 Webhook 任务校验。
- 获取 Token 失败仍作为投递失败处理,并在不暴露凭据的前提下记录日志。
- HTTP 401 会清除缓存的 Token,并对当前分片重试一次。
- 其他非成功 HTTP 响应会中止投递,并在 channel worker 日志中输出脱敏后的 API 错误详情。
- daemon 返回 `202 {"accepted": true}` 仍然只表示 worker 已接收任务,不代表钉钉投递成功。

本期范围内仅支持 Markdown,因此无需设计 Markdown 降级策略。

## 测试

### 单元测试

- Webhook 接受显式配置的群聊和单聊目标,普通主动投递仍只接受群聊目标。
- 拒绝缺少 `isGroup`、ID 为空、使用 Webhook URL 和设置 `threadId` 的目标。
- 保持现有群聊 endpoint 和包含 `openConversationId` 的请求体不变。
- 单聊使用一对一消息 endpoint 和包含 `userIds` 的请求体。
- 群聊和单聊发送共用缓存的 Token。
- HTTP 401 后刷新 Token,并仅重试一次。
- 单聊投递同样遵循消息分片和首个失败即中止的规则。

### 本地端到端验证

在 `.qwen/e2e-tests/` 下编写测试计划,并先使用全局安装的 `qwen` CLI,记录当前单聊 Webhook 目标被拒绝的基线行为。实现完成后:

1. 分别配置一个单聊目标和一个群聊目标。
2. 启用钉钉 channel 并启动 `qwen serve`。
3. 使用 `curl` 分别向两个 `targetRef` 提交一条事件。
4. 确认两个请求均返回 `202`。
5. 确认 channel worker 完成两个任务。
6. 确认目标钉钉用户和群聊都收到预期的 Markdown 消息。

如果本地没有可用的钉钉凭据或接收目标,则以单元测试作为自动化投递验证,并明确说明缺少的在线验证步骤。

## 文档

更新 channel Webhook 文档,展示钉钉单聊和群聊两种目标配置,并说明单聊目标的 `chatId` 填写钉钉用户 ID。

## 兼容性

本次为增量变更。现有群聊目标的配置、校验、endpoint、请求体、格式化和重试行为均不变,无需迁移配置。共享 runtime 新增的 Webhook 目标检查默认委托给原有主动投递目标检查,因此其他 channel 的行为不变。
226 changes: 226 additions & 0 deletions docs/plans/2026-07-14-dingtalk-webhook-direct-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# DingTalk Webhook Direct-Message Implementation Plan

> **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

## Global Constraints

- 首版只支持 Markdown,不支持原生 Card、Card 回调或流式更新。
- 不新增 channel 类型或配置字段。
- 群聊目标使用 `isGroup: true` 与 `openConversationId`;单聊目标使用 `isGroup: false` 与钉钉用户 ID。
- 目标必须显式设置 `isGroup`,且不支持 `threadId` 和 Webhook URL。
- 普通主动投递和 channel loop 仍只接受群聊目标,单聊能力仅用于 Webhook。
- 保持群聊 endpoint、请求体、Token 缓存、401 单次重试、分片和首个失败即停止的行为不变。
- 不修改或提交用户现有的 `package-lock.json` 改动。

---

### Task 1: 为单聊目标补充失败测试并实现最小投递分支

**Files:**

- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`
- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts`
- Modify: `packages/channels/base/src/ChannelBase.ts`
- Modify: `packages/channels/base/src/ChannelBase.test.ts`

**Interfaces:**

- Consumes: `SessionTarget.isGroup`, `SessionTarget.chatId`, `DingtalkChannel.pushProactive()`
- Produces: 群聊调用 `robot/groupMessages/send`,单聊调用 `robot/oToMessages/batchSend`

- [ ] **Step 1: 写单聊目标和请求体失败测试**

在主动投递测试中新增单聊目标:

```ts
const directTarget: SessionTarget = {
channelName: 'test-dingtalk',
senderId: 'webhook:github-ci',
chatId: 'manager-user-id',
isGroup: false,
};
```

将 Webhook 目标校验断言改为允许群聊和单聊,同时断言普通主动投递仍拒绝单聊;两种检查都继续拒绝空 ID、Webhook URL 和 `threadId`。新增单聊请求测试:

```ts
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();
});
```

- [ ] **Step 2: 运行测试并确认因功能缺失而失败**

Run:

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

Expected: 单聊目标断言或单聊发送测试失败,因为当前实现只接受 `isGroup: true`,且只调用群消息 API。

- [ ] **Step 3: 实现最小单聊分支**

在共享 runtime 中增加 Webhook 专用目标检查,默认委托给原有主动投递检查。钉钉保留普通主动投递只接受群聊的规则,仅在 Webhook 专用检查中接受显式群聊或单聊目标。

在 adapter 中加入单聊 endpoint:

```ts
const DIRECT_MSG_API =
'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
```

钉钉 Webhook 目标校验必须要求 `isGroup` 是布尔值、`threadId` 未设置、`chatId` 是稳定 ID:

```ts
return (
typeof target.isGroup === 'boolean' &&
target.threadId === undefined &&
this.isStableTargetId(target.chatId)
);
```

把完整 `SessionTarget` 传给分片发送方法,并仅在该方法中选择 endpoint 和目标字段:

```ts
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),
});
```

- [ ] **Step 4: 运行钉钉单测并确认通过**

Run:

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

Expected: 全部测试通过,群聊原有断言与新增单聊断言同时为绿色。

### Task 2: 更新用户文档和本地 E2E 测试说明

**Files:**

- Modify: `docs/users/features/channels/overview.md`
- Modify: `docs/users/features/channels/dingtalk.md`
- Create: `.qwen/e2e-tests/2026-07-14-dingtalk-webhook-direct-message.md`

**Interfaces:**

- Consumes: 现有 `webhooks.sources.<source>.targets.<targetRef>` 配置
- Produces: 可复制的单聊/群聊配置和 `curl` 验证步骤

- [ ] **Step 1: 文档化两种目标配置**

在 Webhook 示例中保留群聊目标并加入单聊目标:

```json
"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`。

- [ ] **Step 2: 写直接 curl 的 E2E 计划**

E2E 计划应使用隔离的 `HOME` 和真实凭据启动本地 daemon,然后分别执行:

```bash
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。

### Task 3: 完整验证和自审

**Files:**

- Verify: `packages/channels/dingtalk/src/DingtalkAdapter.ts`
- Verify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`
- Verify: `packages/channels/base/src/ChannelBase.ts`
- Verify: `packages/channels/base/src/ChannelBase.test.ts`
- Verify: `docs/users/features/channels/overview.md`
- Verify: `docs/users/features/channels/dingtalk.md`

**Interfaces:**

- Consumes: Task 1 与 Task 2 的实现和文档
- Produces: 可提交的本地实现与验证证据

- [ ] **Step 1: 运行定向单测、构建和类型检查**

Run:

```bash
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
Comment on lines +205 to +207

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

```

Expected: 两个受影响 package 的测试和构建退出码为 0。根目录 build/typecheck 也应运行;若最新 `origin/main` 自身存在阻塞,则记录基线文件和错误,并确认本分支未修改该范围。

- [ ] **Step 2: 使用真实凭据执行 curl 验证**

从隔离配置启动 `qwen serve --channel dingtalk-main`,对单聊和群聊 `targetRef` 各执行一次 curl。记录 HTTP 状态、daemon/worker 日志和钉钉实际收件结果;若缺少某个目标 ID,明确标记对应在线步骤未验证。

- [ ] **Step 3: 自审完整 diff**

Run:

```bash
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` 保持为未暂存的用户改动。
Loading
Loading