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

* docs(channels): design DingTalk webhook DM delivery

* docs(channels): translate DingTalk webhook DM design

* feat(channels): support DingTalk webhook direct messages

* fix(channels): isolate DingTalk webhook DM targets

* fix(channels): handle DingTalk direct delivery failures

* fix(channels): reject malformed DingTalk responses
This commit is contained in:
BaboBen 2026-07-15 11:07:30 +08:00 committed by GitHub
parent 1f056b7fdc
commit 4b802ca5f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 700 additions and 50 deletions

View file

@ -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 的行为不变。

View file

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

View file

@ -81,6 +81,36 @@ qwen channel start
Open DingTalk and send a message to the bot. You should see a 👀 emoji reaction appear while the agent processes, followed by the response.
## Daemon Webhook Delivery
When the channel runs under `qwen serve`, authenticated external Webhook events can trigger unattended agent tasks and deliver the final Markdown response to either a DingTalk user or group. Use the existing Webhook target fields; no separate channel type is required:
```json
{
"webhooks": {
"sources": {
"manual-test": {
"secretEnv": "QWEN_CHANNEL_DINGTALK_TEST_SECRET",
"targets": {
"operator": {
"chatId": "DINGTALK_USER_ID",
"senderId": "webhook:manual-test",
"isGroup": false
},
"team": {
"chatId": "OPEN_CONVERSATION_ID",
"senderId": "webhook:manual-test",
"isGroup": true
}
}
}
}
}
}
```
Every target must set `isGroup` explicitly. For a direct message, `chatId` is the recipient's DingTalk user ID. For a group message, `chatId` is the group's `openConversationId`. Thread targets and incoming robot Webhook URLs are not supported for proactive delivery. See [Webhook-triggered tasks](./overview#webhook-triggered-tasks) for the complete channel configuration and request format.
## Group Chats
DingTalk bots work in both DM and group conversations. To enable group support:

View file

@ -442,7 +442,12 @@ Example channel config:
"github-ci": {
"secretEnv": "QWEN_CHANNEL_GITHUB_CI_SECRET",
"targets": {
"default": {
"operator": {
"chatId": "DINGTALK_USER_ID",
"senderId": "webhook:github-ci",
"isGroup": false
},
"team": {
"chatId": "OPEN_CONVERSATION_ID",
"senderId": "webhook:github-ci",
"isGroup": true
@ -456,7 +461,7 @@ Example channel config:
}
```
For DingTalk, `chatId` must be the group `openConversationId`; other adapters may require their own proactive target shape.
For DingTalk, set `isGroup` explicitly on every target. A direct-message target uses the DingTalk user ID as `chatId` with `isGroup: false`; a group target uses the group `openConversationId` with `isGroup: true`. Other adapters may require their own proactive target shape.
Start `qwen serve` with the channel worker enabled:
@ -472,7 +477,7 @@ curl -X POST "http://127.0.0.1:4170/channels/dingtalk-main/webhooks/github-ci" \
-H "Content-Type: application/json" \
-d '{
"eventType": "push",
"targetRef": "default",
"targetRef": "operator",
"title": "CI pipeline finished",
"payload": {
"targetRef": "refs/heads/main",

View file

@ -34,6 +34,7 @@ class TestChannel extends ChannelBase {
proactiveTargets: SessionTarget[] = [];
proactiveSupported = false;
proactiveTargetSupported: boolean | undefined;
proactiveWebhookTargetSupported: boolean | undefined;
sendMessageError?: Error;
connected = false;
toolCalls: Array<{ chatId: string; event: unknown }> = [];
@ -95,6 +96,15 @@ class TestChannel extends ChannelBase {
);
}
protected override supportsProactiveWebhookTarget(
target: SessionTarget,
): boolean {
return (
this.proactiveWebhookTargetSupported ??
super.supportsProactiveWebhookTarget(target)
);
}
protected override async pushProactive(
target: SessionTarget,
text: string,
@ -11144,6 +11154,35 @@ describe('ChannelBase', () => {
expect(bridge.prompt).not.toHaveBeenCalled();
});
it('uses webhook-specific target support independently', async () => {
(bridge.prompt as ReturnType<typeof vi.fn>).mockResolvedValue(
'Webhook response.',
);
const ch = createChannel({ approvalMode: 'yolo', webhooks });
ch.proactiveSupported = true;
ch.proactiveTargetSupported = false;
ch.proactiveWebhookTargetSupported = true;
await expect(ch.runWebhookTask(webhookTask)).resolves.toBe(
'Webhook response.',
);
expect(ch.proactive).toEqual([
{ chatId: 'group-1', text: 'Webhook response.' },
]);
});
it('rejects webhook targets when webhook support is more restrictive', async () => {
const ch = createChannel({ approvalMode: 'yolo', webhooks });
ch.proactiveSupported = true;
ch.proactiveTargetSupported = true;
ch.proactiveWebhookTargetSupported = false;
await expect(ch.runWebhookTask(webhookTask)).rejects.toThrow(
'Channel does not support proactive webhook messages for this chat target.',
);
expect(bridge.prompt).not.toHaveBeenCalled();
});
it('rejects prompt approval mode before prompting', async () => {
const ch = createChannel({ approvalMode: 'prompt', webhooks });
ch.proactiveSupported = true;

View file

@ -607,6 +607,10 @@ export abstract class ChannelBase {
return target.threadId === undefined;
}
protected supportsProactiveWebhookTarget(target: SessionTarget): boolean {
return this.supportsProactiveTarget(target);
}
protected async pushProactive(
target: SessionTarget,
text: string,
@ -1019,7 +1023,7 @@ export abstract class ChannelBase {
task.source,
task.targetRef,
);
if (!this.supportsProactiveTarget(target)) {
if (!this.supportsProactiveWebhookTarget(target)) {
throw new Error(
'Channel does not support proactive webhook messages for this chat target.',
);

View file

@ -113,6 +113,12 @@ vi.mock('@qwen-code/channel-base', async () => {
_sessionId: string,
_messageIds: string[],
): void {}
protected supportsProactiveTarget(target: SessionTarget): boolean {
return target.threadId === undefined;
}
protected supportsProactiveWebhookTarget(target: SessionTarget): boolean {
return this.supportsProactiveTarget(target);
}
constructor(
name: string,
@ -2006,9 +2012,17 @@ describe('DingtalkChannel proactive send', () => {
isGroup: true,
};
const directTarget: SessionTarget = {
channelName: 'test-dingtalk',
senderId: 'webhook:github-ci',
chatId: 'manager-user-id',
isGroup: false,
};
function proactive(channel: DingtalkChannelInstance) {
return channel as unknown as {
supportsProactiveTarget(target: SessionTarget): boolean;
supportsProactiveWebhookTarget(target: SessionTarget): boolean;
pushProactive(target: SessionTarget, text: string): Promise<void>;
};
}
@ -2042,6 +2056,8 @@ describe('DingtalkChannel proactive send', () => {
spy,
sendCalls: () =>
calls('https://api.dingtalk.com/v1.0/robot/groupMessages/send'),
directSendCalls: () =>
calls('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend'),
tokenCalls: () => calls('https://oapi.dingtalk.com/gettoken'),
};
}
@ -2055,30 +2071,33 @@ describe('DingtalkChannel proactive send', () => {
expect(createChannel().supportsProactiveSend()).toBe(true);
});
it('accepts only group conversation targets', () => {
it('accepts direct-message targets only for webhooks', () => {
const channel = proactive(createChannel());
expect(channel.supportsProactiveTarget(groupTarget)).toBe(true);
expect(channel.supportsProactiveTarget(directTarget)).toBe(false);
expect(channel.supportsProactiveWebhookTarget(groupTarget)).toBe(true);
expect(channel.supportsProactiveWebhookTarget(directTarget)).toBe(true);
expect(
channel.supportsProactiveTarget({ ...groupTarget, isGroup: false }),
).toBe(false);
expect(
channel.supportsProactiveTarget({
channel.supportsProactiveWebhookTarget({
channelName: groupTarget.channelName,
senderId: groupTarget.senderId,
chatId: groupTarget.chatId,
}),
).toBe(false);
expect(
channel.supportsProactiveTarget({
channel.supportsProactiveWebhookTarget({
...groupTarget,
chatId: 'https://oapi.dingtalk.com/robot/sendBySession?session=abc',
}),
).toBe(false);
expect(
channel.supportsProactiveTarget({ ...groupTarget, chatId: '' }),
channel.supportsProactiveWebhookTarget({ ...groupTarget, chatId: '' }),
).toBe(false);
expect(
channel.supportsProactiveTarget({ ...groupTarget, threadId: '7' }),
channel.supportsProactiveWebhookTarget({
...groupTarget,
threadId: '7',
}),
).toBe(false);
});
@ -2099,17 +2118,113 @@ describe('DingtalkChannel proactive send', () => {
const body = JSON.parse(String(init.body));
expect(body.robotCode).toBe('client-id');
expect(body.openConversationId).toBe(groupTarget.chatId);
expect(body.userIds).toBeUndefined();
expect(body.msgKey).toBe('sampleMarkdown');
expect(msgParamOf(sends[0]!).title).toBe('Result');
expect(msgParamOf(sends[0]!).text).toContain('loop output');
});
it('reuses the cached token across sends', async () => {
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;
expect(init.method).toBe('POST');
expect(
(init.headers as Record<string, string>)['x-acs-dingtalk-access-token'],
).toBe('proactive-token');
const body = JSON.parse(String(init.body));
expect(body.robotCode).toBe('client-id');
expect(body.userIds).toEqual([directTarget.chatId]);
expect(body.openConversationId).toBeUndefined();
expect(body.msgKey).toBe('sampleMarkdown');
expect(msgParamOf(sends[0]!).title).toBe('Result');
expect(msgParamOf(sends[0]!).text).toContain('loop output');
});
it('rejects direct messages when DingTalk reports an invalid recipient', async () => {
const channel = proactive(createChannel());
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
stubProactiveFetch(
() =>
new Response(
JSON.stringify({ invalidStaffIdList: [directTarget.chatId] }),
{ status: 200 },
),
);
await expect(channel.pushProactive(directTarget, 'hello')).rejects.toThrow(
'DingTalk proactive send failed: invalid direct recipient',
);
});
it('rejects direct messages when DingTalk reports a rate-limited recipient', async () => {
const channel = proactive(createChannel());
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
stubProactiveFetch(
() =>
new Response(
JSON.stringify({
flowControlledStaffIdList: [directTarget.chatId],
}),
{ status: 200 },
),
);
await expect(channel.pushProactive(directTarget, 'hello')).rejects.toThrow(
'DingTalk proactive send failed: direct recipient rate limited',
);
});
it('rejects direct messages when DingTalk returns malformed JSON', async () => {
const channel = proactive(createChannel());
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
const response = new Response('<html>bad gateway</html>', { status: 200 });
stubProactiveFetch(() => response);
await expect(channel.pushProactive(directTarget, 'hello')).rejects.toThrow(
'DingTalk proactive send failed: invalid JSON response',
);
expect(response.bodyUsed).toBe(true);
const logged = writeSpy.mock.calls.map((c) => String(c[0])).join('');
expect(logged).toContain(
'proactive send failed (dm, chunk 1/1): invalid JSON response',
);
});
it('accepts direct messages when DingTalk rejects only other recipients', async () => {
const channel = proactive(createChannel());
const { directSendCalls } = stubProactiveFetch(
() =>
new Response(
JSON.stringify({
invalidStaffIdList: ['other-user'],
flowControlledStaffIdList: ['another-user'],
}),
{ status: 200 },
),
);
await expect(
channel.pushProactive(directTarget, 'hello'),
).resolves.toBeUndefined();
expect(directSendCalls()).toHaveLength(1);
});
it('reuses the cached token across group and direct-message sends', async () => {
const channel = proactive(createChannel());
const { tokenCalls } = stubProactiveFetch();
await channel.pushProactive(groupTarget, 'first');
await channel.pushProactive(groupTarget, 'second');
await channel.pushProactive(directTarget, 'second');
expect(tokenCalls()).toHaveLength(1);
});
@ -2131,17 +2246,17 @@ describe('DingtalkChannel proactive send', () => {
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(
() => new Response('denied', { status: 403 }),
);
const longLine = 'x'.repeat(100);
const longText = Array.from({ length: 50 }, () => longLine).join('\n');
await expect(channel.pushProactive(groupTarget, longText)).rejects.toThrow(
await expect(channel.pushProactive(directTarget, longText)).rejects.toThrow(
'HTTP 403',
);
expect(sendCalls()).toHaveLength(1);
expect(directSendCalls()).toHaveLength(1);
});
it('surfaces API detail in the error and log on failure', async () => {
@ -2157,11 +2272,44 @@ describe('DingtalkChannel proactive send', () => {
const logged = writeSpy.mock.calls.map((c) => String(c[0])).join('');
expect(logged).toContain(
'proactive send failed (chunk 1/1): HTTP 403 perm denied',
'proactive send failed (group, chunk 1/1): HTTP 403 perm denied',
);
});
it('refreshes the token and retries once on 401', async () => {
it('includes the direct target kind in network-error logs', async () => {
const channel = proactive(createChannel());
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stubProactiveFetch(() => {
throw new Error('connection reset');
});
await expect(channel.pushProactive(directTarget, 'hello')).rejects.toThrow(
'DingTalk proactive send failed: connection reset',
);
const logged = writeSpy.mock.calls.map((c) => String(c[0])).join('');
expect(logged).toContain(
'proactive send error (dm, chunk 1/1): Error: connection reset',
);
});
it('refreshes the token and retries a direct message once on 401', async () => {
const channel = proactive(createChannel());
const { directSendCalls, tokenCalls } = stubProactiveFetch((sendCall) =>
sendCall === 0
? new Response('expired', { status: 401 })
: new Response('{}', { status: 200 }),
);
await channel.pushProactive(directTarget, 'hello');
expect(directSendCalls()).toHaveLength(2);
expect(tokenCalls()).toHaveLength(2);
});
it('refreshes the token and retries a group message once on 401', async () => {
const channel = proactive(createChannel());
const { sendCalls, tokenCalls } = stubProactiveFetch((sendCall) =>
sendCall === 0

View file

@ -88,7 +88,9 @@ const ACK_EMOTION_ID = '2659900';
const ACK_EMOTION_BG_ID = 'im_bg_1';
const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion';
const GROUP_MSG_API = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
const GROUP_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
const DIRECT_MSG_API =
'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
const TOKEN_API = 'https://oapi.dingtalk.com/gettoken';
const PROACTIVE_FETCH_TIMEOUT_MS = 15_000;
const TEXT_MESSAGE_LIMIT = 3800;
@ -105,6 +107,11 @@ interface DingTalkTokenResponse {
expires_in?: number;
}
interface DingTalkDirectMessageResponse {
flowControlledStaffIdList?: string[];
invalidStaffIdList?: string[];
}
function splitTextChunks(text: string, firstChunkLimit: number): string[] {
if (!text) return [text];
@ -482,15 +489,23 @@ export class DingtalkChannel extends ChannelBase {
return true;
}
/**
* The group-message API needs a real openConversationId reject DMs
* (a different API) and webhook-URL fallback chatIds.
*/
// Regular proactive paths accept only group targets; webhook tasks may use
// DMs through the one-to-one API.
protected override supportsProactiveTarget(target: SessionTarget): boolean {
return (
target.isGroup === true &&
target.threadId === undefined &&
this.isConversationId(target.chatId)
this.isStableTargetId(target.chatId)
);
}
protected override supportsProactiveWebhookTarget(
target: SessionTarget,
): boolean {
return (
typeof target.isGroup === 'boolean' &&
target.threadId === undefined &&
this.isStableTargetId(target.chatId)
);
}
@ -509,7 +524,7 @@ export class DingtalkChannel extends ChannelBase {
for (let i = 0; i < chunks.length; i++) {
await this.sendProactiveChunk(
target.chatId,
target,
i === 0 ? title : `${title} (cont.)`,
chunks[i]!,
`chunk ${i + 1}/${chunks.length}`,
@ -555,33 +570,41 @@ export class DingtalkChannel extends ChannelBase {
}
private async sendProactiveChunk(
conversationId: string,
target: SessionTarget,
title: string,
text: string,
chunkLabel: string,
): Promise<void> {
const targetKind = target.isGroup === true ? 'group' : 'dm';
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] };
resp = await fetch(
target.isGroup === true ? 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),
},
body: JSON.stringify({
robotCode: this.config.clientId!,
openConversationId: conversationId,
msgKey: GROUP_MSG_KEY,
msgParam: JSON.stringify({ title, text }),
}),
signal: AbortSignal.timeout(PROACTIVE_FETCH_TIMEOUT_MS),
});
);
} catch (err) {
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`,
);
throw new Error(
`DingTalk proactive send failed: ${err instanceof Error ? err.message : String(err)}`,
@ -596,12 +619,42 @@ export class DingtalkChannel extends ChannelBase {
if (!resp.ok) {
const detail = sanitizeLogText(await resp.text().catch(() => ''), 300);
process.stderr.write(
`[DingTalk:${this.name}] proactive send failed (${chunkLabel}): HTTP ${resp.status} ${detail}\n`,
`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): HTTP ${resp.status} ${detail}\n`,
);
throw new Error(
`DingTalk proactive send failed: HTTP ${resp.status}${detail ? ` ${detail}` : ''}`,
);
}
if (target.isGroup === false) {
let data: DingTalkDirectMessageResponse;
try {
data = (await resp.json()) as DingTalkDirectMessageResponse;
} catch {
process.stderr.write(
`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid JSON response\n`,
);
throw new Error(
'DingTalk proactive send failed: invalid JSON response',
);
}
if (data.invalidStaffIdList?.includes(target.chatId)) {
process.stderr.write(
`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid direct recipient\n`,
);
throw new Error(
'DingTalk proactive send failed: invalid direct recipient',
);
}
if (data.flowControlledStaffIdList?.includes(target.chatId)) {
process.stderr.write(
`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): direct recipient rate limited\n`,
);
throw new Error(
'DingTalk proactive send failed: direct recipient rate limited',
);
}
return;
}
await resp.body?.cancel();
return;
}
@ -682,12 +735,8 @@ export class DingtalkChannel extends ChannelBase {
process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`);
}
/**
* The chatId passed to onPromptStart/onPromptEnd is `conversationId ||
* sessionWebhook` (see message handler below). Reactions and proactive
* sends require a real conversation ID skip the webhook-URL fallback case.
*/
private isConversationId(chatId: string): boolean {
/** Stable API targets are conversation or user IDs, never webhook URLs. */
private isStableTargetId(chatId: string): boolean {
return !!chatId && !/^https?:\/\//i.test(chatId);
}
@ -715,7 +764,7 @@ export class DingtalkChannel extends ChannelBase {
messageId?: string,
sessionId?: string,
): void {
if (!messageId || !this.isConversationId(chatId)) return;
if (!messageId || !this.isStableTargetId(chatId)) return;
// Loop lifecycle events carry the internal job id as messageId; the
// emotion API only accepts ids of real inbound messages, so skip anything
// we never saw arrive.
@ -750,7 +799,7 @@ export class DingtalkChannel extends ChannelBase {
messageId?: string,
sessionId?: string,
): void {
if (!messageId || !this.isConversationId(chatId)) return;
if (!messageId || !this.isStableTargetId(chatId)) return;
const key = this.reactionKey(messageId, chatId);
if (sessionId) {
const keys = this.sessionReactionKeys.get(sessionId);