mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-31 02:06:21 +00:00
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:
parent
1f056b7fdc
commit
4b802ca5f9
8 changed files with 700 additions and 50 deletions
149
docs/design/2026-07-14-dingtalk-webhook-direct-message.md
Normal file
149
docs/design/2026-07-14-dingtalk-webhook-direct-message.md
Normal 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 的行为不变。
|
||||
226
docs/plans/2026-07-14-dingtalk-webhook-direct-message.md
Normal file
226
docs/plans/2026-07-14-dingtalk-webhook-direct-message.md
Normal 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` 保持为未暂存的用户改动。
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue