diff --git a/docs/design/2026-07-14-dingtalk-webhook-direct-message.md b/docs/design/2026-07-14-dingtalk-webhook-direct-message.md new file mode 100644 index 0000000000..15281ce10e --- /dev/null +++ b/docs/design/2026-07-14-dingtalk-webhook-direct-message.md @@ -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 的行为不变。 diff --git a/docs/plans/2026-07-14-dingtalk-webhook-direct-message.md b/docs/plans/2026-07-14-dingtalk-webhook-direct-message.md new file mode 100644 index 0000000000..34271ae7dd --- /dev/null +++ b/docs/plans/2026-07-14-dingtalk-webhook-direct-message.md @@ -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..targets.` 配置 +- 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` 保持为未暂存的用户改动。 diff --git a/docs/users/features/channels/dingtalk.md b/docs/users/features/channels/dingtalk.md index d7e9c59d7f..33afdd2b5e 100644 --- a/docs/users/features/channels/dingtalk.md +++ b/docs/users/features/channels/dingtalk.md @@ -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: diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 05301fc1c4..8d6258b05b 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -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", diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index 126dbf3f8a..95f2efd92c 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -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).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; diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index aaf0ef6f7c..1e75a10706 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -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.', ); diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index 2d2e1658af..c0fca18525 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -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; }; } @@ -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)['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('bad gateway', { 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 diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 1a312944ec..779d86d9f1 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -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 { + 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);