@zhin.js/adapter-dingtalk 1.1.4 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +37 -1
- package/adapters/dingtalk/index.js +4 -4
- package/adapters/dingtalk/index.ts +4 -4
- package/lib/cards.d.ts +77 -0
- package/lib/cards.js +81 -0
- package/lib/endpoint.d.ts +9 -4
- package/lib/endpoint.js +139 -35
- package/lib/protocol.d.ts +11 -3
- package/lib/protocol.js +50 -26
- package/lib/stream-proxy.d.ts +7 -0
- package/lib/stream-proxy.js +18 -0
- package/lib/stream.d.ts +25 -0
- package/lib/stream.js +138 -0
- package/lib/webhook.d.ts +1 -1
- package/lib/webhook.js +1 -1
- package/package.json +16 -14
- package/schema.json +61 -0
- package/src/cards.ts +62 -0
- package/src/endpoint.ts +121 -40
- package/src/protocol.ts +43 -26
- package/src/stream-proxy.ts +20 -0
- package/src/stream.ts +104 -0
- package/src/webhook.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @zhin.js/adapter-dingtalk
|
|
2
2
|
|
|
3
|
+
## 1.1.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b9b3953: 新增钉钉 Stream 长连接接收模式,保留默认 HTTP webhook。官方网关注册确认、共享生命周期重连与心跳、停止取消、回调 ACK 和有界去重;失败不会假报连接成功。
|
|
8
|
+
- b9b3953: Stop synthesizing message IDs after outbound API calls. Missing real platform IDs now report an unknown delivery outcome and must not be retried automatically. DingTalk webhook success responses without a message ID therefore remain unconfirmed under the current receipt contract.
|
|
9
|
+
|
|
10
|
+
LINE reply and push use their actual sentMessages IDs. Reply HTTP 400 errors no longer trigger automatic push fallback: rejected or partially successful replies could otherwise duplicate a message. Locally expired cached reply tokens still select push before a reply request is made.
|
|
11
|
+
|
|
12
|
+
- b9b3953: Reject QQ outbound responses carrying a nonzero platform error even when they contain a message ID. Classify DingTalk HTTP client rejection separately from uncertain server/network outcomes and avoid exposing raw platform response text in delivery errors.
|
|
13
|
+
- Updated dependencies [b9b3953]
|
|
14
|
+
- Updated dependencies [b9b3953]
|
|
15
|
+
- Updated dependencies [b9b3953]
|
|
16
|
+
- Updated dependencies [b9b3953]
|
|
17
|
+
- Updated dependencies [b9b3953]
|
|
18
|
+
- Updated dependencies [b9b3953]
|
|
19
|
+
- Updated dependencies [b9b3953]
|
|
20
|
+
- Updated dependencies [b9b3953]
|
|
21
|
+
- Updated dependencies [b9b3953]
|
|
22
|
+
- Updated dependencies [b9b3953]
|
|
23
|
+
- Updated dependencies [b9b3953]
|
|
24
|
+
- @zhin.js/adapter@1.1.16
|
|
25
|
+
- @zhin.js/im-contract@1.1.3
|
|
26
|
+
- @zhin.js/core@1.1.40
|
|
27
|
+
- @zhin.js/host-http@1.1.3
|
|
28
|
+
- @zhin.js/tool@1.1.3
|
|
29
|
+
- @zhin.js/skill@1.1.3
|
|
30
|
+
- @zhin.js/agent-feature@1.1.3
|
|
31
|
+
|
|
3
32
|
## 1.1.4
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -67,12 +67,20 @@ plugins:
|
|
|
67
67
|
| 钉钉类型 | 入站 content(文本摘要) | 出站 wire |
|
|
68
68
|
|----------|--------------------------|-----------|
|
|
69
69
|
| text | 原文 | text |
|
|
70
|
-
| picture | `[image]` |
|
|
70
|
+
| picture | `[image]` | Markdown 图片链接(canonical HTTP(S) `url`) |
|
|
71
71
|
| file | `[file: name]` | — |
|
|
72
72
|
| audio / video | `[audio]` / `[video]` | — |
|
|
73
73
|
| markdown | 原文或 `[markdown]` | markdown |
|
|
74
74
|
| link | — | link |
|
|
75
75
|
|
|
76
|
+
### Stream 进阶验收边界
|
|
77
|
+
|
|
78
|
+
Stream 只承载入站事件,回复仍向本次入站的 `sessionWebhook` 发送 HTTP 请求。当前支持文本、Markdown 和可公网访问的图片 URL;图文组合通过 Markdown 保留文本与图片,图片能否实际显示必须由接收端确认。官方依据:[Stream Markdown 回复](https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/stream/bot/nodejs/send-markdown/)、[Markdown 图片说明](https://opensource.dingtalk.com/developerpedia/docs/learn/stream/faq/)。
|
|
79
|
+
|
|
80
|
+
canonical `reply` 原生引用、本地/base64 图片上传、音频/视频/文件目前未实现,请求前返回 `unsupported_operation`,不会发送 `[reply]` 或只发送剩余文字。撤回没有 Endpoint control 端口,属于 `unsupported`;不会自动申请上传或撤回 API 权限。远程图片是 Markdown 内嵌显示,不能据此宣称原生图片消息已验收。
|
|
81
|
+
|
|
82
|
+
发送需有效的入站会话:缓存尊重 `sessionWebhookExpiredTime`,缺少过期时间时最多保留 5 分钟,最多保存 1024 个会话。Stream 缺少或过期会话时在请求前返回 `session_unavailable`,不自动改走其他 API 或重放。请求发出后的断网、超时或响应缺少真实 `msgId` 仍是 `unknown`,不代表消息未送达;需要人工核对接收端,不能伪造 ID 或自动再次发送。
|
|
83
|
+
|
|
76
84
|
## Agent 工具
|
|
77
85
|
|
|
78
86
|
`agents/dingtalk/skills/dingtalk/tools/` 在 DingTalk Skill 激活后提供 get_user、部门、群聊、工作通知等 Tool。工具声明 `adapter: 'dingtalk'` 后,通过惰性的 `context.$client` 自动取得当前操作的 `DingTalkClient`;无需把 Endpoint id 暴露给模型。
|
|
@@ -96,3 +104,31 @@ pnpm --filter @zhin.js/adapter-dingtalk test
|
|
|
96
104
|
| Webhook 返回 401/403 | 检查 `appSecret`、签名时间戳与服务器时钟 |
|
|
97
105
|
| 能收到但无法回复 | 检查 `robotCode`、应用权限与 session webhook 是否有效 |
|
|
98
106
|
| Endpoint 未出现 | 在日志查 Schema 或凭据错误,再到运行时能力核对 Endpoint |
|
|
107
|
+
|
|
108
|
+
## Stream 长连接
|
|
109
|
+
|
|
110
|
+
端点设置 `mode: stream`(默认 `webhook` 保持 HTTP 回调),使用现有 `appKey` / `appSecret`。钉钉后台创建企业内部应用、启用机器人并选择 Stream 模式,发布后加入测试群;无需公网回调地址。
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
endpoints:
|
|
114
|
+
- id: test-bot
|
|
115
|
+
mode: stream
|
|
116
|
+
appKey: ${DINGTALK_APP_KEY}
|
|
117
|
+
appSecret: ${DINGTALK_APP_SECRET}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
连接通过官方网关获取 WSS 地址和临时 ticket,WSS 握手成功后报告 open(与官方 SDK 一致,不要求可选 REGISTERED 帧)。连接失败会拒绝启动;断线由框架统一退避重连,stop 取消网关请求及 socket,防止迟到连接复活。机器人 CALLBACK 在入站链路成功后 ACK;失败或尚未 open 不 ACK,等待服务端重投。连接内缓存五分钟、最多一万条回调 ID 去重;跨进程业务幂等需要应用持久化处理。HTTP 和 Stream 共用消息归一化及统一出站链路。
|
|
121
|
+
|
|
122
|
+
协议依据:[钉钉官方 Stream SDK](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs)。本适配器直接实现该网关协议,使用共享 EndpointLifecycle 管理重连和心跳。当前新增模式已经过本地注入传输回归,真实租户验收仍需实际应用验证。
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
canonical `share` 编码为原生 `link` 卡片,映射 `url/title/description/image` 到 `messageUrl/title/text/picUrl`。必须实测卡片展示和链接点击;单次消息只支持一个链接卡片。
|
|
126
|
+
|
|
127
|
+
钉钉原生按钮通过配置关联应用的互动卡片模板实现,使用 Stream 卡片回调;传统 `actionCard` URL 导航不作为按钮业务回调。固定模板字段、数量配置、权限、会话/actor 校验及重启边界见 [模板配置指南](./CARD-TEMPLATE.md)。未配置模板时返回 `card_template_required`,而不是声明平台不支持。
|
|
128
|
+
# Stream 可控断线验收
|
|
129
|
+
|
|
130
|
+
可选 `streamProxy: { port: 18443, serverName: "实际网关域名" }` 只将 WSS 的 TCP 连接转发到 `127.0.0.1:18443`。原始 HTTPS discovery 仍直连;真实 WSS URL、HTTP Host、ticket、TLS SNI 与证书校验保持原值,不修改系统网络,也不需要账号 B。此入口使用 [ws 的 createConnection 契约](https://github.com/websockets/ws/blob/master/doc/ws.md),不关闭 TLS 校验。网关主机变化时明确拒绝连接,只记录安全 hostname,绝不回退直连或输出 ticket。
|
|
131
|
+
|
|
132
|
+
先通过临时启动配置加 `streamProxy`,若未知网关域名,可先填写占位域名;拒绝日志只显示实际 hostname。使用该域名启动 `scripts/platform-acceptance/tcp-fault-proxy.mjs --upstream-host 实际网关域名 --upstream-port 443 --port 18443 --control-port 18444`,将 serverName 同步设为此域名。不要覆盖用户已有 `.env`。恢复正常验收后移除临时配置并停止代理。
|
|
133
|
+
|
|
134
|
+
正常收发后 `POST http://127.0.0.1:18444/cut`;现有 WSS 立即关闭,Endpoint 的 `transportState` 经统一 lifecycle 进入重连,期间健康记录不能认定在线。`POST /recover` 后仅允许新建连接;需观察重新 `open`,再发送新的唯一 probe,验证入站及可见回包。Stream 入站恢复与 sessionWebhook 出站恢复是不同链路:该代理仅切断 WSS,HTTP 发送未被切断;不能把这次试验记为所有平台网络故障、发送重试或离线消息无损验收。网关域名变化需停止验收并更新固定上游,不能绕代理继续取通过证据。
|
|
@@ -11,10 +11,10 @@ export { DingTalkEndpoint } from "../../lib/endpoint.js";
|
|
|
11
11
|
export default defineAdapter({
|
|
12
12
|
capabilities: ['inbound', 'outbound'],
|
|
13
13
|
// 钉钉机器人媒体消息仅消费远程 URL;Markdown 走原生 msgtype;
|
|
14
|
-
//
|
|
14
|
+
// 原生按钮需配置固定互动卡片模板,由 Stream 接收回调。
|
|
15
15
|
segments: {
|
|
16
16
|
outboundMedia: ['url'],
|
|
17
|
-
interactive: '
|
|
17
|
+
interactive: 'native',
|
|
18
18
|
markdown: 'native',
|
|
19
19
|
},
|
|
20
20
|
create(context) {
|
|
@@ -22,11 +22,11 @@ export default defineAdapter({
|
|
|
22
22
|
// 注册到插件运行时状态(dingtalk endpoint list 的"运行中"数据源)
|
|
23
23
|
context.use(dingtalkRuntimeStateToken).endpoints.set(config.id, {
|
|
24
24
|
id: config.id,
|
|
25
|
-
mode: 'webhook',
|
|
25
|
+
mode: config.mode ?? 'webhook',
|
|
26
26
|
});
|
|
27
27
|
return new DingTalkEndpoint({
|
|
28
28
|
id: context.id,
|
|
29
|
-
http: context.use(httpHostToken),
|
|
29
|
+
http: config.mode === 'stream' ? undefined : context.use(httpHostToken),
|
|
30
30
|
config,
|
|
31
31
|
});
|
|
32
32
|
},
|
|
@@ -16,10 +16,10 @@ export type { DingTalkEndpointOptions, DingTalkFetch } from '../../src/endpoint.
|
|
|
16
16
|
export default defineAdapter<DingTalkEndpointConfig>({
|
|
17
17
|
capabilities: ['inbound', 'outbound'],
|
|
18
18
|
// 钉钉机器人媒体消息仅消费远程 URL;Markdown 走原生 msgtype;
|
|
19
|
-
//
|
|
19
|
+
// 原生按钮需配置固定互动卡片模板,由 Stream 接收回调。
|
|
20
20
|
segments: {
|
|
21
21
|
outboundMedia: ['url'],
|
|
22
|
-
interactive: '
|
|
22
|
+
interactive: 'native',
|
|
23
23
|
markdown: 'native',
|
|
24
24
|
},
|
|
25
25
|
create(context) {
|
|
@@ -27,11 +27,11 @@ export default defineAdapter<DingTalkEndpointConfig>({
|
|
|
27
27
|
// 注册到插件运行时状态(dingtalk endpoint list 的"运行中"数据源)
|
|
28
28
|
context.use(dingtalkRuntimeStateToken).endpoints.set(config.id, {
|
|
29
29
|
id: config.id,
|
|
30
|
-
mode: 'webhook',
|
|
30
|
+
mode: config.mode ?? 'webhook',
|
|
31
31
|
});
|
|
32
32
|
return new DingTalkEndpoint({
|
|
33
33
|
id: context.id,
|
|
34
|
-
http: context.use(httpHostToken),
|
|
34
|
+
http: config.mode === 'stream' ? undefined : context.use(httpHostToken),
|
|
35
35
|
config,
|
|
36
36
|
});
|
|
37
37
|
},
|
package/lib/cards.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
2
|
+
import type { DingTalkMessage, ResolvedDingTalkConfig } from './protocol.js';
|
|
3
|
+
export interface DingTalkCardCallback {
|
|
4
|
+
outTrackId?: string;
|
|
5
|
+
spaceType?: string;
|
|
6
|
+
spaceId?: string;
|
|
7
|
+
userId?: string;
|
|
8
|
+
userIdType?: number;
|
|
9
|
+
corpId?: string;
|
|
10
|
+
content?: string;
|
|
11
|
+
}
|
|
12
|
+
interface CardRecord {
|
|
13
|
+
conversation: ConversationRef;
|
|
14
|
+
spaceType: string;
|
|
15
|
+
spaceId: string;
|
|
16
|
+
staffId?: string;
|
|
17
|
+
senderId?: string;
|
|
18
|
+
corpId?: string;
|
|
19
|
+
payloads: Set<string>;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
}
|
|
22
|
+
/** Endpoint-owned, bounded correlation. Cards from old generations deliberately cannot dispatch. */
|
|
23
|
+
export declare class DingTalkCards {
|
|
24
|
+
#private;
|
|
25
|
+
remember(conversation: ConversationRef, event: DingTalkMessage): void;
|
|
26
|
+
clear(): void;
|
|
27
|
+
prepare(config: ResolvedDingTalkConfig, conversation: ConversationRef, payload: unknown): {
|
|
28
|
+
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
29
|
+
record: CardRecord;
|
|
30
|
+
epoch: number;
|
|
31
|
+
body: {
|
|
32
|
+
imGroupOpenDeliverModel: {
|
|
33
|
+
robotCode: string;
|
|
34
|
+
};
|
|
35
|
+
cardTemplateId: string;
|
|
36
|
+
outTrackId: `${string}-${string}-${string}-${string}-${string}`;
|
|
37
|
+
cardData: {
|
|
38
|
+
cardParamMap: Record<string, string>;
|
|
39
|
+
};
|
|
40
|
+
callbackType: string;
|
|
41
|
+
userIdType: number;
|
|
42
|
+
openSpaceId: string;
|
|
43
|
+
imGroupOpenSpaceModel: {
|
|
44
|
+
supportForward: boolean;
|
|
45
|
+
};
|
|
46
|
+
imRobotOpenSpaceModel: {
|
|
47
|
+
supportForward: boolean;
|
|
48
|
+
};
|
|
49
|
+
} | {
|
|
50
|
+
imRobotOpenDeliverModel: {
|
|
51
|
+
spaceType: string;
|
|
52
|
+
};
|
|
53
|
+
cardTemplateId: string;
|
|
54
|
+
outTrackId: `${string}-${string}-${string}-${string}-${string}`;
|
|
55
|
+
cardData: {
|
|
56
|
+
cardParamMap: Record<string, string>;
|
|
57
|
+
};
|
|
58
|
+
callbackType: string;
|
|
59
|
+
userIdType: number;
|
|
60
|
+
openSpaceId: string;
|
|
61
|
+
imGroupOpenSpaceModel: {
|
|
62
|
+
supportForward: boolean;
|
|
63
|
+
};
|
|
64
|
+
imRobotOpenSpaceModel: {
|
|
65
|
+
supportForward: boolean;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
} | undefined;
|
|
69
|
+
confirm(prepared: NonNullable<ReturnType<DingTalkCards['prepare']>>, response: any): string;
|
|
70
|
+
resolve(callback: DingTalkCardCallback): {
|
|
71
|
+
conversation: ConversationRef;
|
|
72
|
+
payload: string;
|
|
73
|
+
sourceMessageId: string;
|
|
74
|
+
senderId: string;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export {};
|
package/lib/cards.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
3
|
+
/** Endpoint-owned, bounded correlation. Cards from old generations deliberately cannot dispatch. */
|
|
4
|
+
export class DingTalkCards {
|
|
5
|
+
#epoch = 0;
|
|
6
|
+
#records = new Map();
|
|
7
|
+
#contexts = new Map();
|
|
8
|
+
remember(conversation, event) {
|
|
9
|
+
this.#contexts.delete(conversation.id);
|
|
10
|
+
this.#contexts.set(conversation.id, event);
|
|
11
|
+
while (this.#contexts.size > 1024)
|
|
12
|
+
this.#contexts.delete(this.#contexts.keys().next().value);
|
|
13
|
+
}
|
|
14
|
+
clear() { this.#epoch++; this.#records.clear(); this.#contexts.clear(); }
|
|
15
|
+
prepare(config, conversation, payload) {
|
|
16
|
+
const segments = Array.isArray(payload) ? payload : [payload];
|
|
17
|
+
const keyboard = segments.filter(item => item && typeof item === 'object' && item.type === 'keyboard');
|
|
18
|
+
if (!keyboard.length)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (config.mode !== 'stream' || !config.cardTemplateId)
|
|
21
|
+
throw new EndpointDeliveryError('card_template_required', 'DingTalk callbacks require a configured Stream card template', 'not_sent');
|
|
22
|
+
if (keyboard.length !== 1)
|
|
23
|
+
throw new EndpointDeliveryError('invalid_payload', 'DingTalk supports one keyboard per card', 'not_sent');
|
|
24
|
+
const rows = keyboard[0].data?.rows;
|
|
25
|
+
if (!Array.isArray(rows) || rows.some(row => !Array.isArray(row)))
|
|
26
|
+
throw new EndpointDeliveryError('invalid_payload', 'DingTalk keyboard requires button rows', 'not_sent');
|
|
27
|
+
const buttons = rows.flat();
|
|
28
|
+
const count = config.cardButtonCount ?? 2;
|
|
29
|
+
if (!buttons.length || buttons.length > count || buttons.some((button) => typeof button.label !== 'string' || typeof button.payload !== 'string' || !button.payload || button.disabled))
|
|
30
|
+
throw new EndpointDeliveryError('invalid_payload', 'DingTalk keyboard exceeds the fixed template or contains invalid/disabled buttons', 'not_sent');
|
|
31
|
+
const context = this.#contexts.get(conversation.id);
|
|
32
|
+
if (!context || (conversation.kind !== 'group' && conversation.kind !== 'private'))
|
|
33
|
+
throw new EndpointDeliveryError('card_context_required', 'DingTalk card requires a current inbound conversation', 'not_sent');
|
|
34
|
+
const spaceType = conversation.kind === 'group' ? 'IM_GROUP' : 'IM_ROBOT';
|
|
35
|
+
const spaceId = spaceType === 'IM_GROUP' ? conversation.id : context.senderStaffId;
|
|
36
|
+
if (!spaceId)
|
|
37
|
+
throw new EndpointDeliveryError('card_context_required', 'DingTalk private card requires senderStaffId', 'not_sent');
|
|
38
|
+
const text = segments.filter(item => item?.type !== 'keyboard').map(item => {
|
|
39
|
+
if (typeof item === 'string')
|
|
40
|
+
return item;
|
|
41
|
+
if (item?.type === 'text' || item?.type === 'markdown')
|
|
42
|
+
return String(item.data?.text ?? item.data?.content ?? '');
|
|
43
|
+
throw new EndpointDeliveryError('invalid_payload', 'DingTalk interactive cards accept text/markdown and keyboard only', 'not_sent');
|
|
44
|
+
}).join('');
|
|
45
|
+
const cardParamMap = { title: '消息', text };
|
|
46
|
+
for (let i = 0; i < count; i++) {
|
|
47
|
+
cardParamMap[`button${i}_label`] = buttons[i]?.label ?? '';
|
|
48
|
+
cardParamMap[`button${i}_payload`] = buttons[i]?.payload ?? '';
|
|
49
|
+
cardParamMap[`button${i}_visible`] = buttons[i] ? 'true' : 'false';
|
|
50
|
+
}
|
|
51
|
+
const id = randomUUID();
|
|
52
|
+
const record = { conversation, spaceType, spaceId, staffId: context.senderStaffId, senderId: context.senderId, corpId: context.senderCorpId, payloads: new Set(buttons.map((button) => button.payload)), expiresAt: Date.now() + 3_600_000 };
|
|
53
|
+
return { id, record, epoch: this.#epoch, body: { cardTemplateId: config.cardTemplateId, outTrackId: id, cardData: { cardParamMap }, callbackType: 'STREAM', userIdType: 1, openSpaceId: `dtv1.card//${spaceType}.${spaceId}`, imGroupOpenSpaceModel: { supportForward: false }, imRobotOpenSpaceModel: { supportForward: false }, ...(spaceType === 'IM_GROUP' ? { imGroupOpenDeliverModel: { robotCode: config.robotCode ?? config.appKey } } : { imRobotOpenDeliverModel: { spaceType: 'IM_ROBOT' } }) } };
|
|
54
|
+
}
|
|
55
|
+
confirm(prepared, response) {
|
|
56
|
+
const delivery = response?.result?.deliverResults?.find((item) => item.spaceType === prepared.record.spaceType && item.spaceId === prepared.record.spaceId);
|
|
57
|
+
if (response?.success === false || delivery?.success === false)
|
|
58
|
+
throw new EndpointDeliveryError('platform_rejected', 'DingTalk card delivery was rejected', 'rejected');
|
|
59
|
+
if (response?.success !== true || response.result?.outTrackId !== prepared.id || delivery?.success !== true)
|
|
60
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'DingTalk card delivery is unconfirmed', 'unknown');
|
|
61
|
+
if (prepared.epoch !== this.#epoch)
|
|
62
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'DingTalk card completed after endpoint retirement', 'unknown');
|
|
63
|
+
this.#records.set(prepared.id, prepared.record);
|
|
64
|
+
for (const [id, record] of this.#records)
|
|
65
|
+
if (record.expiresAt <= Date.now())
|
|
66
|
+
this.#records.delete(id);
|
|
67
|
+
while (this.#records.size > 1024)
|
|
68
|
+
this.#records.delete(this.#records.keys().next().value);
|
|
69
|
+
return prepared.id;
|
|
70
|
+
}
|
|
71
|
+
resolve(callback) {
|
|
72
|
+
const record = this.#records.get(callback.outTrackId ?? '');
|
|
73
|
+
if (!record || record.expiresAt <= Date.now() || callback.spaceType !== record.spaceType || callback.spaceId !== record.spaceId || callback.userIdType !== 1 || !callback.userId || (record.corpId && callback.corpId !== record.corpId) || (record.spaceType === 'IM_ROBOT' && callback.userId !== record.staffId))
|
|
74
|
+
throw new Error('DingTalk card callback correlation failed');
|
|
75
|
+
const content = JSON.parse(callback.content ?? '');
|
|
76
|
+
const payload = content.cardPrivateData?.params?.action;
|
|
77
|
+
if (typeof payload !== 'string' || !record.payloads.has(payload))
|
|
78
|
+
throw new Error('DingTalk card callback payload mismatch');
|
|
79
|
+
return { conversation: record.conversation, payload, sourceMessageId: callback.outTrackId, senderId: callback.userId === record.staffId ? record.senderId ?? callback.userId : callback.userId };
|
|
80
|
+
}
|
|
81
|
+
}
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type DingTalkCardCallback } from './cards.js';
|
|
2
|
+
import { type DingTalkStreamSocketFactory } from './stream.js';
|
|
2
3
|
/**
|
|
3
4
|
* DingTalkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
4
5
|
*/
|
|
5
|
-
import { type EndpointSendRequest } from 'zhin.js/adapter';
|
|
6
|
+
import { Endpoint, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
6
7
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
7
8
|
import type { CapabilityId } from 'zhin.js';
|
|
8
9
|
import { type DingTalkEvent, type DingTalkMessage, type ResolvedDingTalkConfig } from './protocol.js';
|
|
@@ -10,6 +11,7 @@ export type DingTalkFetch = (url: string, init?: {
|
|
|
10
11
|
readonly method?: string;
|
|
11
12
|
readonly headers?: Record<string, string>;
|
|
12
13
|
readonly body?: string;
|
|
14
|
+
readonly signal?: AbortSignal;
|
|
13
15
|
}) => Promise<{
|
|
14
16
|
readonly ok: boolean;
|
|
15
17
|
readonly status: number;
|
|
@@ -18,7 +20,8 @@ export type DingTalkFetch = (url: string, init?: {
|
|
|
18
20
|
}>;
|
|
19
21
|
export interface DingTalkEndpointOptions {
|
|
20
22
|
readonly id: CapabilityId;
|
|
21
|
-
readonly http
|
|
23
|
+
readonly http?: HttpHost;
|
|
24
|
+
readonly createStreamSocket?: DingTalkStreamSocketFactory;
|
|
22
25
|
readonly config: ResolvedDingTalkConfig;
|
|
23
26
|
readonly fetch?: DingTalkFetch;
|
|
24
27
|
}
|
|
@@ -59,6 +62,7 @@ export declare class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
59
62
|
#private;
|
|
60
63
|
readonly client: DingTalkClient;
|
|
61
64
|
constructor(options: DingTalkEndpointOptions);
|
|
65
|
+
get transportState(): import("@zhin.js/adapter").EndpointLifecycleState;
|
|
62
66
|
/** Used by webhook handler. */
|
|
63
67
|
get isOpen(): boolean;
|
|
64
68
|
get config(): ResolvedDingTalkConfig;
|
|
@@ -67,6 +71,7 @@ export declare class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
67
71
|
close(): void;
|
|
68
72
|
stop(): Promise<void>;
|
|
69
73
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
74
|
+
admitCard(event: DingTalkCardCallback, id: string): Promise<void>;
|
|
70
75
|
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
71
|
-
admit(event: DingTalkEvent | DingTalkMessage): void
|
|
76
|
+
admit(event: DingTalkEvent | DingTalkMessage): Promise<void>;
|
|
72
77
|
}
|
package/lib/endpoint.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { DingTalkCards } from './cards.js';
|
|
2
|
+
import { DingTalkStream } from './stream.js';
|
|
3
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
4
|
+
/**
|
|
5
|
+
* DingTalkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
6
|
+
*/
|
|
1
7
|
import { Endpoint } from 'zhin.js/adapter';
|
|
2
8
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
3
9
|
import { normalizeDingtalkSenderForPermit } from './platform-permit.js';
|
|
@@ -41,6 +47,8 @@ export class DingTalkEndpoint extends Endpoint {
|
|
|
41
47
|
#accessToken = { token: '', expires_in: 0, timestamp: 0 };
|
|
42
48
|
#refreshPromise = null;
|
|
43
49
|
#sessionWebhooks = new Map();
|
|
50
|
+
#stream;
|
|
51
|
+
#cards = new DingTalkCards();
|
|
44
52
|
#open = false;
|
|
45
53
|
#started = false;
|
|
46
54
|
constructor(options) {
|
|
@@ -49,6 +57,7 @@ export class DingTalkEndpoint extends Endpoint {
|
|
|
49
57
|
this.#options = options;
|
|
50
58
|
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
51
59
|
}
|
|
60
|
+
get transportState() { return this.#stream?.lifecycle.state ?? (this.#started ? 'open' : 'stopped'); }
|
|
52
61
|
/** Used by webhook handler. */
|
|
53
62
|
get isOpen() {
|
|
54
63
|
return this.#open;
|
|
@@ -61,6 +70,11 @@ export class DingTalkEndpoint extends Endpoint {
|
|
|
61
70
|
return;
|
|
62
71
|
this.#started = true;
|
|
63
72
|
try {
|
|
73
|
+
if (this.#options.config.mode === 'stream') {
|
|
74
|
+
this.#stream = new DingTalkStream(this.config, this.#fetch, (event) => this.admit(event), () => this.#open, this.#options.createStreamSocket, (event, id) => this.admitCard(event, id));
|
|
75
|
+
await this.#stream.start();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
64
78
|
await this.#refreshAccessToken();
|
|
65
79
|
this.#routeReleases.push(...registerDingTalkWebhookRoutes(this.#options.http, this));
|
|
66
80
|
this.#logger.debug(formatCompact({
|
|
@@ -83,61 +97,144 @@ export class DingTalkEndpoint extends Endpoint {
|
|
|
83
97
|
}
|
|
84
98
|
async stop() {
|
|
85
99
|
this.#open = false;
|
|
100
|
+
await this.#stream?.stop();
|
|
86
101
|
this.#sessionWebhooks.clear();
|
|
102
|
+
this.#cards.clear();
|
|
87
103
|
for (const release of this.#routeReleases.splice(0))
|
|
88
104
|
release();
|
|
89
105
|
this.#started = false;
|
|
90
106
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
91
107
|
}
|
|
92
108
|
async send({ conversation, payload }) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
try {
|
|
110
|
+
const card = this.#cards.prepare(this.config, conversation, payload);
|
|
111
|
+
if (card) {
|
|
112
|
+
let stage = 'token';
|
|
113
|
+
let httpStatus;
|
|
114
|
+
let platformCode;
|
|
115
|
+
try {
|
|
116
|
+
try {
|
|
117
|
+
await this.#ensureAccessToken();
|
|
118
|
+
}
|
|
119
|
+
catch (cause) {
|
|
120
|
+
throw new EndpointDeliveryError('token_unavailable', 'DingTalk card token unavailable', 'not_sent', { cause });
|
|
121
|
+
}
|
|
122
|
+
stage = 'request';
|
|
123
|
+
const response = await this.#fetch('https://api.dingtalk.com/v1.0/card/instances/createAndDeliver', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': this.#accessToken.token }, body: JSON.stringify(card.body), signal: AbortSignal.timeout(30_000) });
|
|
124
|
+
httpStatus = response.status;
|
|
125
|
+
stage = 'response';
|
|
126
|
+
let data;
|
|
127
|
+
try {
|
|
128
|
+
data = await response.json();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
if (response.ok)
|
|
132
|
+
throw new EndpointDeliveryError('invalid_response', 'DingTalk card response could not be parsed', 'unknown');
|
|
133
|
+
}
|
|
134
|
+
if (data && typeof data === 'object') {
|
|
135
|
+
const code = data.code ?? data.errcode;
|
|
136
|
+
// Only bounded protocol codes; never log URLs, messages, response bodies or credentials.
|
|
137
|
+
if (typeof code === 'number' && Number.isFinite(code))
|
|
138
|
+
platformCode = code;
|
|
139
|
+
else if (typeof code === 'string' && /^[a-zA-Z][a-zA-Z0-9_.-]{0,95}$/.test(code)
|
|
140
|
+
&& ![this.#accessToken.token, this.config.appSecret, this.config.appKey].some(secret => secret && code.includes(secret)))
|
|
141
|
+
platformCode = code;
|
|
142
|
+
}
|
|
143
|
+
if (!response.ok)
|
|
144
|
+
throw new EndpointDeliveryError('http_error', `DingTalk card HTTP ${response.status}`, response.status >= 400 && response.status < 500 ? 'rejected' : 'unknown');
|
|
145
|
+
stage = 'delivery';
|
|
146
|
+
return this.#cards.confirm(card, data);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
this.#logger.warn(formatCompact({ op: 'dingtalk_card_delivery_failed', stage, httpStatus, platformCode,
|
|
150
|
+
code: error instanceof EndpointDeliveryError ? error.code : 'delivery_unconfirmed' }));
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const content = formatOutboundBody(payload);
|
|
155
|
+
const session = this.#sessionWebhooks.get(conversation.id);
|
|
156
|
+
const sessionWebhook = session && session.expiresAt > Date.now() ? session.url : undefined;
|
|
157
|
+
if (session && !sessionWebhook)
|
|
158
|
+
this.#sessionWebhooks.delete(conversation.id);
|
|
159
|
+
if (!sessionWebhook && this.config.mode === 'stream') {
|
|
160
|
+
throw new EndpointDeliveryError('session_unavailable', 'DingTalk Stream send requires a current inbound session webhook', 'not_sent');
|
|
161
|
+
}
|
|
162
|
+
if (sessionWebhook) {
|
|
163
|
+
const response = await this.#fetch(sessionWebhook, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
166
|
+
body: JSON.stringify(content),
|
|
167
|
+
signal: AbortSignal.timeout(30_000),
|
|
168
|
+
});
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
throw new EndpointDeliveryError('http_error', `DingTalk HTTP ${response.status}`, response.status >= 400 && response.status < 500 ? 'rejected' : 'unknown');
|
|
171
|
+
const data = await response.json();
|
|
172
|
+
if (typeof data.errcode === 'number' && data.errcode !== 0) {
|
|
173
|
+
throw new EndpointDeliveryError('platform_rejected', `DingTalk session webhook rejected the outbound request (${data.errcode})`, 'rejected');
|
|
174
|
+
}
|
|
175
|
+
this.#logger.debug(formatCompact({
|
|
176
|
+
op: 'send',
|
|
177
|
+
endpoint: this.#options.config.id,
|
|
178
|
+
via: 'sessionWebhook',
|
|
179
|
+
to: conversation.id,
|
|
180
|
+
}));
|
|
181
|
+
if (data.errcode !== 0)
|
|
182
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Malformed platform response', 'unknown');
|
|
183
|
+
return requireMessageId(data.msgId);
|
|
184
|
+
}
|
|
185
|
+
const body = {
|
|
186
|
+
...content,
|
|
187
|
+
...(this.#options.config.robotCode
|
|
188
|
+
? { robotCode: this.#options.config.robotCode }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
const data = await this.#request('/robot/send', {
|
|
97
192
|
method: 'POST',
|
|
98
|
-
|
|
99
|
-
body: JSON.stringify(content),
|
|
193
|
+
body: body,
|
|
100
194
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
throw new Error(`Failed to send message via session webhook: ${data.errmsg}`);
|
|
195
|
+
if (typeof data.errcode === 'number' && data.errcode !== 0) {
|
|
196
|
+
throw new EndpointDeliveryError('platform_rejected', `DingTalk rejected the outbound request (${data.errcode})`, 'rejected');
|
|
104
197
|
}
|
|
105
|
-
this.#logger.debug(formatCompact({
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
to: conversation.id,
|
|
110
|
-
}));
|
|
111
|
-
return data.msgId || `${Date.now()}`;
|
|
198
|
+
this.#logger.debug(formatCompact({ op: 'send', to: conversation.id }));
|
|
199
|
+
if (data.errcode !== 0)
|
|
200
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Malformed platform response', 'unknown');
|
|
201
|
+
return requireMessageId(data.msgId);
|
|
112
202
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
: {}),
|
|
118
|
-
};
|
|
119
|
-
const data = await this.#request('/robot/send', {
|
|
120
|
-
method: 'POST',
|
|
121
|
-
body: body,
|
|
122
|
-
});
|
|
123
|
-
if (data.errcode !== 0) {
|
|
124
|
-
throw new Error(`Failed to send message: ${data.errmsg}`);
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error instanceof EndpointDeliveryError)
|
|
205
|
+
throw error;
|
|
206
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
|
|
125
207
|
}
|
|
126
|
-
|
|
127
|
-
|
|
208
|
+
}
|
|
209
|
+
async admitCard(event, id) {
|
|
210
|
+
if (!this.#open)
|
|
211
|
+
throw new Error('DingTalk endpoint is closed');
|
|
212
|
+
const action = this.#cards.resolve(event);
|
|
213
|
+
await this.emitAccepted('message.receive', {
|
|
214
|
+
conversation: action.conversation, message: { conversation: action.conversation, id },
|
|
215
|
+
content: `[action: ${action.payload}]`, segments: [{ type: 'action', data: { id, payload: action.payload, sourceMessageId: action.sourceMessageId } }],
|
|
216
|
+
sender: { id: action.senderId }, endpointId: this.config.id,
|
|
217
|
+
metadata: Object.freeze({ eventType: 'card_callback', sourceMessageId: action.sourceMessageId }),
|
|
218
|
+
});
|
|
128
219
|
}
|
|
129
220
|
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
130
|
-
admit(event) {
|
|
221
|
+
async admit(event) {
|
|
131
222
|
if (!this.#open)
|
|
132
223
|
return;
|
|
133
224
|
this.#emitPlatformEvent(event.msgtype || 'event', event);
|
|
134
225
|
if (event.sessionWebhook && event.conversationId) {
|
|
135
|
-
|
|
226
|
+
const expiresAt = Number.isFinite(event.sessionWebhookExpiredTime) ? event.sessionWebhookExpiredTime : Date.now() + 300_000;
|
|
227
|
+
this.#sessionWebhooks.delete(event.conversationId);
|
|
228
|
+
if (expiresAt > Date.now())
|
|
229
|
+
this.#sessionWebhooks.set(event.conversationId, { url: event.sessionWebhook, expiresAt });
|
|
230
|
+
while (this.#sessionWebhooks.size > 1024)
|
|
231
|
+
this.#sessionWebhooks.delete(this.#sessionWebhooks.keys().next().value);
|
|
136
232
|
}
|
|
137
233
|
const conversation = dingtalkInboundConversation(String(this.#options.id), event);
|
|
234
|
+
this.#cards.remember(conversation, event);
|
|
138
235
|
const chatType = resolveChatType(event.conversationType);
|
|
139
236
|
const permit = normalizeDingtalkSenderForPermit({ isAdmin: event.isAdmin === true });
|
|
140
|
-
|
|
237
|
+
await this.emitAccepted('message.receive', {
|
|
141
238
|
conversation,
|
|
142
239
|
message: { conversation, id: generateMessageId(event) },
|
|
143
240
|
content: formatInboundContent(event),
|
|
@@ -157,6 +254,8 @@ export class DingTalkEndpoint extends Endpoint {
|
|
|
157
254
|
conversationType: event.conversationType,
|
|
158
255
|
}),
|
|
159
256
|
}).catch((err) => {
|
|
257
|
+
if (this.config.mode === 'stream')
|
|
258
|
+
throw err;
|
|
160
259
|
this.#logger.warn(formatCompact({
|
|
161
260
|
op: 'dingtalk_gateway_receive_failed',
|
|
162
261
|
conversationId: conversation.id,
|
|
@@ -311,10 +410,10 @@ export class DingTalkEndpoint extends Endpoint {
|
|
|
311
410
|
method,
|
|
312
411
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
313
412
|
body: body && method === 'POST' ? JSON.stringify(body) : undefined,
|
|
413
|
+
signal: AbortSignal.timeout(30_000),
|
|
314
414
|
});
|
|
315
415
|
if (!response.ok) {
|
|
316
|
-
|
|
317
|
-
throw new Error(`DingTalk API error ${response.status}: ${text}`);
|
|
416
|
+
throw new EndpointDeliveryError('http_error', `DingTalk HTTP ${response.status}`, response.status >= 400 && response.status < 500 ? 'rejected' : 'unknown');
|
|
318
417
|
}
|
|
319
418
|
return await response.json();
|
|
320
419
|
}
|
|
@@ -351,3 +450,8 @@ export class DingTalkEndpoint extends Endpoint {
|
|
|
351
450
|
throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
|
|
352
451
|
}
|
|
353
452
|
}
|
|
453
|
+
function requireMessageId(value) {
|
|
454
|
+
if (typeof value === 'string' && value.trim().length > 0)
|
|
455
|
+
return value;
|
|
456
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
|
|
457
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -6,21 +6,29 @@ import type { IncomingMessage } from 'node:http';
|
|
|
6
6
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
7
7
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
8
8
|
export interface DingTalkEndpointConfig {
|
|
9
|
+
readonly streamProxy?: import('./stream-proxy.js').DingTalkStreamProxy;
|
|
9
10
|
readonly id: string;
|
|
10
11
|
readonly appKey: string;
|
|
11
12
|
readonly appSecret: string;
|
|
13
|
+
readonly mode?: 'webhook' | 'stream';
|
|
12
14
|
readonly webhookPath?: string;
|
|
13
15
|
readonly robotCode?: string;
|
|
14
16
|
readonly apiBaseUrl?: string;
|
|
17
|
+
readonly cardTemplateId?: string;
|
|
18
|
+
readonly cardButtonCount?: number;
|
|
15
19
|
}
|
|
16
20
|
export interface ResolvedDingTalkConfig {
|
|
21
|
+
readonly streamProxy?: import('./stream-proxy.js').DingTalkStreamProxy;
|
|
17
22
|
readonly context: 'dingtalk';
|
|
23
|
+
readonly mode?: 'webhook' | 'stream';
|
|
18
24
|
readonly id: string;
|
|
19
25
|
readonly appKey: string;
|
|
20
26
|
readonly appSecret: string;
|
|
21
27
|
readonly webhookPath: string;
|
|
22
28
|
readonly robotCode?: string;
|
|
23
29
|
readonly apiBaseUrl: string;
|
|
30
|
+
readonly cardTemplateId?: string;
|
|
31
|
+
readonly cardButtonCount?: number;
|
|
24
32
|
}
|
|
25
33
|
export interface DingTalkMessage {
|
|
26
34
|
readonly msgtype?: string;
|
|
@@ -35,6 +43,7 @@ export interface DingTalkMessage {
|
|
|
35
43
|
readonly senderNick?: string;
|
|
36
44
|
readonly senderCorpId?: string;
|
|
37
45
|
readonly sessionWebhook?: string;
|
|
46
|
+
readonly sessionWebhookExpiredTime?: number;
|
|
38
47
|
readonly chatbotCorpId?: string;
|
|
39
48
|
readonly chatbotUserId?: string;
|
|
40
49
|
readonly isAdmin?: boolean;
|
|
@@ -119,9 +128,8 @@ export declare function verifySignature(appSecret: string, timestamp: string, si
|
|
|
119
128
|
* Segment canonicalization is intentionally not done here: media segments carry
|
|
120
129
|
* the canonical `data.media` MediaRef and nothing else is consulted.
|
|
121
130
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* - 其余 kind / audio / video / file 段无投递面,warn + 丢弃。
|
|
131
|
+
* 钉钉机器人图片使用官方 Markdown HTTP(S) 图片链接,保留同载荷文本和图片顺序。
|
|
132
|
+
* 本地媒体上传、原生引用、音频/视频/文件未接线,在请求前明确拒绝。
|
|
125
133
|
*/
|
|
126
134
|
export declare function formatOutboundBody(payload: unknown): DingTalkSendBody;
|
|
127
135
|
export declare function headerValue(headers: IncomingMessage['headers'], name: string): string;
|