@zhin.js/adapter-dingtalk 1.1.3 → 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/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
- * 钉钉机器人媒体消息仅支持远程 URL(picture.picURL):
123
- * - image 段 kind=url → 直发 picture;
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;
package/lib/protocol.js CHANGED
@@ -3,9 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
5
  import { createHmac, timingSafeEqual } from 'node:crypto';
6
- import { isMediaRef } from '@zhin.js/im-contract';
7
- import { formatCompact, getLogger } from '@zhin.js/logger';
8
- const logger = getLogger('dingtalk');
6
+ import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
9
7
  /**
10
8
  * 钉钉回调消息 @ 机器人判定:机器人被 @ 时回调带 `isInAtList: true`;
11
9
  * 部分回调形态的 `atUserIds` / `atUsers[].dingtalkId` 会包含机器人 robotCode(来自配置)。
@@ -23,20 +21,31 @@ export function isDingtalkBotMentioned(event, robotCode) {
23
21
  return (event.atUsers ?? []).some((user) => user.dingtalkId === robotCode);
24
22
  }
25
23
  export function resolveDingTalkConfig(config) {
24
+ if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
25
+ || typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName)))
26
+ throw new TypeError('Invalid DingTalk streamProxy port/serverName');
27
+ if (config.mode !== undefined && !['webhook', 'stream'].includes(config.mode))
28
+ throw new TypeError('Invalid DingTalk mode');
26
29
  const id = requiredEndpointField(config.id, 'id');
27
30
  const appKey = requiredEndpointField(config.appKey, 'appKey');
28
31
  const appSecret = requiredEndpointField(config.appSecret, 'appSecret');
29
32
  const webhookPath = normalizeWebhookPath(config.webhookPath ?? '/dingtalk/webhook');
30
33
  const apiBaseUrl = (config.apiBaseUrl ?? 'https://oapi.dingtalk.com').replace(/\/$/, '');
31
34
  const robotCode = config.robotCode;
35
+ if (config.cardButtonCount !== undefined && (!Number.isInteger(config.cardButtonCount) || config.cardButtonCount < 1 || config.cardButtonCount > 5))
36
+ throw new TypeError('cardButtonCount must be 1..5');
32
37
  return {
38
+ ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
33
39
  context: 'dingtalk',
40
+ mode: config.mode ?? 'webhook',
34
41
  id,
35
42
  appKey,
36
43
  appSecret,
37
44
  webhookPath,
38
45
  ...(robotCode ? { robotCode } : {}),
39
46
  apiBaseUrl,
47
+ ...(config.cardTemplateId ? { cardTemplateId: config.cardTemplateId } : {}),
48
+ cardButtonCount: config.cardButtonCount ?? 2,
40
49
  };
41
50
  }
42
51
  function requiredEndpointField(value, field) {
@@ -131,9 +140,8 @@ export function verifySignature(appSecret, timestamp, sign) {
131
140
  * Segment canonicalization is intentionally not done here: media segments carry
132
141
  * the canonical `data.media` MediaRef and nothing else is consulted.
133
142
  *
134
- * 钉钉机器人媒体消息仅支持远程 URL(picture.picURL):
135
- * - image 段 kind=url → 直发 picture;
136
- * - 其余 kind / audio / video / file 段无投递面,warn + 丢弃。
143
+ * 钉钉机器人图片使用官方 Markdown HTTP(S) 图片链接,保留同载荷文本和图片顺序。
144
+ * 本地媒体上传、原生引用、音频/视频/文件未接线,在请求前明确拒绝。
137
145
  */
138
146
  export function formatOutboundBody(payload) {
139
147
  if (typeof payload === 'string') {
@@ -174,34 +182,50 @@ export function formatOutboundBody(payload) {
174
182
  }
175
183
  break;
176
184
  }
177
- case 'image': {
185
+ case 'reply':
186
+ throw new EndpointDeliveryError('unsupported_operation', 'DingTalk native quoted replies are not implemented', 'not_sent');
187
+ case 'share': {
188
+ if (['audio', 'artist', 'duration', 'config'].some(key => data[key] !== undefined)) {
189
+ throw new EndpointDeliveryError('unsupported_operation', 'dingtalk rich share metadata is not implemented', 'not_sent');
190
+ }
191
+ let url;
192
+ try {
193
+ url = new URL(String(data.url ?? ''));
194
+ }
195
+ catch {
196
+ throw new EndpointDeliveryError('invalid_payload', 'DingTalk share requires an HTTP(S) URL', 'not_sent');
197
+ }
198
+ if (!['https:', 'http:'].includes(url.protocol))
199
+ throw new EndpointDeliveryError('invalid_payload', 'DingTalk share requires an HTTP(S) URL', 'not_sent');
178
200
  if (media)
179
- break;
201
+ throw new EndpointDeliveryError('invalid_payload', 'DingTalk accepts one link card per message', 'not_sent');
202
+ media = { msgtype: 'link', link: { title: String(data.title ?? '链接'), text: String(data.description ?? data.content ?? ''), messageUrl: url.toString(), ...(typeof data.image === 'string' ? { picUrl: data.image } : {}) } };
203
+ break;
204
+ }
205
+ case 'keyboard':
206
+ throw new EndpointDeliveryError('unsupported_operation', 'DingTalk native keyboard callback requires an interactive card template and is not implemented', 'not_sent');
207
+ case 'image': {
180
208
  const ref = data.media;
181
209
  if (isMediaRef(ref) && ref.kind === 'url') {
182
- media = {
183
- msgtype: 'picture',
184
- picture: { picURL: ref.value },
185
- };
210
+ let url;
211
+ try {
212
+ url = new URL(ref.value);
213
+ }
214
+ catch {
215
+ throw new EndpointDeliveryError('invalid_payload', 'DingTalk image requires an HTTP(S) URL', 'not_sent');
216
+ }
217
+ if (!['https:', 'http:'].includes(url.protocol))
218
+ throw new EndpointDeliveryError('invalid_payload', 'DingTalk image requires an HTTP(S) URL', 'not_sent');
219
+ markdownTitle ??= '消息';
220
+ textParts.push(`\n![](${ref.value.replace(/[()\s]/g, character => encodeURIComponent(character))})\n`);
186
221
  break;
187
222
  }
188
- logger.warn(formatCompact({
189
- op: 'dingtalk_outbound_media_dropped',
190
- type: item.type,
191
- reason: isMediaRef(ref) ? `unsupported_kind:${ref.kind}` : 'missing_media_ref',
192
- }));
193
- break;
223
+ throw new EndpointDeliveryError('unsupported_operation', 'DingTalk image upload is not implemented; use a canonical HTTP(S) URL', 'not_sent');
194
224
  }
195
225
  case 'audio':
196
226
  case 'video':
197
227
  case 'file':
198
- // 钉钉机器人无音频/视频/文件投递面,warn + 丢弃
199
- logger.warn(formatCompact({
200
- op: 'dingtalk_outbound_media_dropped',
201
- type: item.type,
202
- reason: 'undeliverable_msgtype',
203
- }));
204
- break;
228
+ throw new EndpointDeliveryError('unsupported_operation', 'DingTalk audio/video/file delivery is not implemented', 'not_sent');
205
229
  case 'markdown':
206
230
  markdownTitle ??= String(data.title || '消息');
207
231
  textParts.push(String(data.content ?? data.text ?? ''));
@@ -224,7 +248,7 @@ export function formatOutboundBody(payload) {
224
248
  }
225
249
  }
226
250
  if (media)
227
- return media;
251
+ return media.link && textParts.length ? { ...media, link: { ...media.link, text: [...textParts, media.link.text].filter(Boolean).join('\n') } } : media;
228
252
  if (markdownTitle !== undefined) {
229
253
  return {
230
254
  msgtype: 'markdown',
@@ -0,0 +1,7 @@
1
+ import WebSocket from 'ws';
2
+ export interface DingTalkStreamProxy {
3
+ readonly port: number;
4
+ readonly serverName: string;
5
+ }
6
+ /** Route TCP only. WSS HTTP Host, path, ticket, TLS SNI and peer validation stay original. */
7
+ export declare function createDingTalkStreamSocket(url: string, proxy?: DingTalkStreamProxy): WebSocket;
@@ -0,0 +1,18 @@
1
+ import tls from 'node:tls';
2
+ import WebSocket from 'ws';
3
+ import { getLogger } from '@zhin.js/logger';
4
+ /** Route TCP only. WSS HTTP Host, path, ticket, TLS SNI and peer validation stay original. */
5
+ export function createDingTalkStreamSocket(url, proxy) {
6
+ if (!proxy)
7
+ return new WebSocket(url);
8
+ const target = new URL(url);
9
+ if (target.protocol !== 'wss:' || target.hostname !== proxy.serverName || (target.port && target.port !== '443')) {
10
+ // The hostname is safe diagnostics; never print URL/query/ticket or SDK errors.
11
+ getLogger('dingtalk').warn(`Stream proxy rejected gateway hostname: ${target.hostname}`);
12
+ throw new Error('DingTalk Stream proxy gateway identity changed; update the fixed upstream before retrying');
13
+ }
14
+ return new WebSocket(url, {
15
+ createConnection: (options) => tls.connect({ ...options, path: undefined, host: '127.0.0.1', port: proxy.port,
16
+ servername: target.hostname, rejectUnauthorized: true }),
17
+ });
18
+ }
@@ -0,0 +1,25 @@
1
+ import type { DingTalkFetch } from './endpoint.js';
2
+ import type { DingTalkEvent, ResolvedDingTalkConfig } from './protocol.js';
3
+ export declare const CARD_TOPIC = "/v1.0/card/instances/callback";
4
+ export declare const ROBOT_TOPIC = "/v1.0/im/bot/messages/get";
5
+ export interface DingTalkStreamSocket {
6
+ on(event: string, listener: (...args: any[]) => void): unknown;
7
+ send(data: string): void;
8
+ terminate(): void;
9
+ ping(): void;
10
+ }
11
+ export type DingTalkStreamSocketFactory = (url: string) => DingTalkStreamSocket;
12
+ /** Official gateway wire contract; reconnect/heartbeat belong to the shared lifecycle. */
13
+ export declare class DingTalkStream {
14
+ #private;
15
+ readonly config: ResolvedDingTalkConfig;
16
+ readonly fetch: DingTalkFetch;
17
+ readonly admit: (event: DingTalkEvent) => Promise<void>;
18
+ readonly isOpen: () => boolean;
19
+ readonly createSocket: DingTalkStreamSocketFactory;
20
+ readonly admitCard?: ((event: import("./cards.js").DingTalkCardCallback, id: string) => Promise<void>) | undefined;
21
+ readonly lifecycle: import("@zhin.js/adapter").EndpointLifecycle;
22
+ constructor(config: ResolvedDingTalkConfig, fetch: DingTalkFetch, admit: (event: DingTalkEvent) => Promise<void>, isOpen: () => boolean, createSocket?: DingTalkStreamSocketFactory, admitCard?: ((event: import("./cards.js").DingTalkCardCallback, id: string) => Promise<void>) | undefined);
23
+ start(): Promise<void>;
24
+ stop(): Promise<void>;
25
+ }
package/lib/stream.js ADDED
@@ -0,0 +1,138 @@
1
+ import { createDingTalkStreamSocket } from './stream-proxy.js';
2
+ import { createEndpointLifecycle } from 'zhin.js/adapter';
3
+ export const CARD_TOPIC = '/v1.0/card/instances/callback';
4
+ export const ROBOT_TOPIC = '/v1.0/im/bot/messages/get';
5
+ /** Official gateway wire contract; reconnect/heartbeat belong to the shared lifecycle. */
6
+ export class DingTalkStream {
7
+ config;
8
+ fetch;
9
+ admit;
10
+ isOpen;
11
+ createSocket;
12
+ admitCard;
13
+ lifecycle;
14
+ #active;
15
+ #socket;
16
+ #seen = new Map();
17
+ #pending = new Set();
18
+ constructor(config, fetch, admit, isOpen, createSocket = (url) => createDingTalkStreamSocket(url, config.streamProxy), admitCard) {
19
+ this.config = config;
20
+ this.fetch = fetch;
21
+ this.admit = admit;
22
+ this.isOpen = isOpen;
23
+ this.createSocket = createSocket;
24
+ this.admitCard = admitCard;
25
+ this.lifecycle = createEndpointLifecycle({ name: config.id, heartbeat: { intervalMs: 30_000, watchdogMisses: 2 } });
26
+ }
27
+ async start() {
28
+ await this.lifecycle.start(async (handle) => {
29
+ const controller = new AbortController();
30
+ this.#active = controller;
31
+ const transport = {};
32
+ handle.onForceClose(() => { controller.abort(); transport.socket?.terminate(); });
33
+ const response = await this.fetch('https://api.dingtalk.com/v1.0/gateway/connections/open', {
34
+ method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ clientId: this.config.appKey, clientSecret: this.config.appSecret,
36
+ subscriptions: [{ type: 'CALLBACK', topic: ROBOT_TOPIC }, ...(this.admitCard && this.config.cardTemplateId ? [{ type: 'CALLBACK', topic: CARD_TOPIC }] : [])], ua: 'zhin.js' }),
37
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]),
38
+ });
39
+ if (!response.ok)
40
+ throw new Error(`DingTalk Stream gateway HTTP ${response.status}`);
41
+ const gateway = await response.json();
42
+ if (controller.signal.aborted)
43
+ return;
44
+ if (!gateway.endpoint || !gateway.ticket)
45
+ throw new Error('Invalid DingTalk Stream gateway response');
46
+ const url = new URL(gateway.endpoint);
47
+ if (url.protocol !== 'wss:')
48
+ throw new Error('DingTalk Stream requires WSS');
49
+ url.searchParams.set('ticket', gateway.ticket);
50
+ const current = this.createSocket(url.toString());
51
+ transport.socket = current;
52
+ this.#socket = current;
53
+ await new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => { current.terminate(); reject(new Error('DingTalk Stream connection timeout')); }, 30_000);
55
+ const clear = () => clearTimeout(timer);
56
+ controller.signal.addEventListener('abort', clear, { once: true });
57
+ current.on('open', () => {
58
+ if (controller.signal.aborted || this.#socket !== current) {
59
+ current.terminate();
60
+ return;
61
+ }
62
+ clear();
63
+ this.lifecycle.startHeartbeat(() => current.ping());
64
+ resolve();
65
+ });
66
+ current.on('error', () => { clear(); current.terminate(); reject(new Error('DingTalk Stream transport error')); });
67
+ current.on('close', () => {
68
+ clear();
69
+ if (this.#socket === current)
70
+ this.#socket = undefined;
71
+ handle.notifyClosed('transport closed');
72
+ reject(new Error('DingTalk Stream closed before connection'));
73
+ });
74
+ current.on('pong', () => { if (!controller.signal.aborted)
75
+ this.lifecycle.notifyHeartbeatAck(); });
76
+ current.on('message', (raw) => {
77
+ if (controller.signal.aborted || this.#socket !== current)
78
+ return;
79
+ this.lifecycle.notifyHeartbeatAck();
80
+ let frame;
81
+ try {
82
+ frame = JSON.parse(raw.toString());
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (frame.type === 'SYSTEM') {
88
+ // REGISTERED is optional; successful WSS handshake already establishes the subscribed transport.
89
+ if (frame.headers?.topic === 'ping' || frame.headers?.topic === 'disconnect') {
90
+ current.send(JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: frame.data }));
91
+ if (frame.headers.topic === 'disconnect')
92
+ current.terminate();
93
+ }
94
+ return;
95
+ }
96
+ if (frame.type !== 'CALLBACK' || ![ROBOT_TOPIC, ...(this.admitCard && this.config.cardTemplateId ? [CARD_TOPIC] : [])].includes(frame.headers?.topic ?? '') || !frame.headers?.messageId)
97
+ return;
98
+ const id = frame.headers.messageId;
99
+ const ack = () => {
100
+ if (!controller.signal.aborted && this.#socket === current)
101
+ current.send(JSON.stringify({ code: 200,
102
+ headers: { contentType: 'application/json', messageId: id }, message: 'OK', data: JSON.stringify({ response: null }) }));
103
+ };
104
+ const now = Date.now();
105
+ for (const [key, time] of this.#seen)
106
+ if (now - time > 300_000)
107
+ this.#seen.delete(key);
108
+ if (this.#seen.has(id)) {
109
+ ack();
110
+ return;
111
+ }
112
+ if (!this.isOpen() || this.#pending.has(id) || this.#pending.size >= 100)
113
+ return;
114
+ let event;
115
+ try {
116
+ event = JSON.parse(frame.data ?? '');
117
+ }
118
+ catch {
119
+ return;
120
+ }
121
+ const card = frame.headers.topic === CARD_TOPIC;
122
+ if (!event || typeof event !== 'object' || (!card && !event.msgtype))
123
+ return;
124
+ this.#pending.add(id);
125
+ void (card ? this.admitCard(event, id) : this.admit(event)).then(() => {
126
+ if (controller.signal.aborted)
127
+ return;
128
+ this.#seen.set(id, Date.now());
129
+ while (this.#seen.size > 10_000)
130
+ this.#seen.delete(this.#seen.keys().next().value);
131
+ ack();
132
+ }).catch(() => { }).finally(() => this.#pending.delete(id));
133
+ });
134
+ });
135
+ });
136
+ }
137
+ async stop() { this.#active?.abort(); await this.lifecycle.stop(); this.#socket = undefined; this.#seen.clear(); }
138
+ }
package/lib/webhook.d.ts CHANGED
@@ -7,7 +7,7 @@ import { type DingTalkEvent, type ResolvedDingTalkConfig } from './protocol.js';
7
7
  export interface DingTalkWebhookHandler {
8
8
  readonly config: ResolvedDingTalkConfig;
9
9
  readonly isOpen: boolean;
10
- admit(event: DingTalkEvent): void;
10
+ admit(event: DingTalkEvent): void | Promise<void>;
11
11
  }
12
12
  export declare function registerDingTalkWebhookRoutes(http: HttpHost, handler: DingTalkWebhookHandler): HttpRouteRegistration[];
13
13
  export declare function handleDingTalkWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: DingTalkWebhookHandler): Promise<void>;
package/lib/webhook.js CHANGED
@@ -34,7 +34,7 @@ export async function handleDingTalkWebhookRequest(request, response, handler) {
34
34
  return;
35
35
  }
36
36
  if (event.msgtype && handler.isOpen) {
37
- handler.admit(event);
37
+ await handler.admit(event);
38
38
  }
39
39
  response.writeHead(200, { 'Content-Type': 'application/json' });
40
40
  response.end(JSON.stringify({ code: 0, msg: 'success' }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-dingtalk",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Zhin.js DingTalk (钉钉) adapter for Plugin Runtime (HTTP webhook)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,24 +33,25 @@
33
33
  "directory": "plugins/adapters/dingtalk"
34
34
  },
35
35
  "dependencies": {
36
- "@zhin.js/adapter": "1.1.15",
37
- "@zhin.js/agent-feature": "1.1.2",
38
- "@zhin.js/core": "1.1.38",
36
+ "ws": "^8.18.3",
37
+ "@zhin.js/adapter": "1.1.16",
38
+ "@zhin.js/agent-feature": "1.1.3",
39
+ "@zhin.js/core": "1.1.40",
39
40
  "@zhin.js/feature-kit": "1.1.2",
40
- "@zhin.js/host-http": "1.1.2",
41
- "@zhin.js/im-contract": "1.1.2",
41
+ "@zhin.js/host-http": "1.1.3",
42
+ "@zhin.js/im-contract": "1.1.3",
42
43
  "@zhin.js/logger": "1.1.1",
43
- "@zhin.js/skill": "1.1.2",
44
- "@zhin.js/tool": "1.1.2"
44
+ "@zhin.js/skill": "1.1.3",
45
+ "@zhin.js/tool": "1.1.3"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "zod": "^4.0.0",
48
- "@zhin.js/adapter": "^1.1.15",
49
- "@zhin.js/command": "^1.1.2",
50
- "@zhin.js/core": "^1.1.38",
51
- "@zhin.js/host-http": "^1.1.2",
52
- "@zhin.js/permission": "^1.1.2",
53
- "zhin.js": "^1.1.3"
49
+ "@zhin.js/adapter": "^1.1.16",
50
+ "@zhin.js/command": "^1.1.4",
51
+ "@zhin.js/core": "^1.1.40",
52
+ "@zhin.js/host-http": "^1.1.3",
53
+ "@zhin.js/permission": "^1.1.3",
54
+ "zhin.js": "^1.1.5"
54
55
  },
55
56
  "peerDependenciesMeta": {
56
57
  "@zhin.js/command": {
@@ -68,7 +69,8 @@
68
69
  "typescript": "^6.0.3",
69
70
  "vitest": "^4.1.10",
70
71
  "zod": "^4.4.3",
71
- "zhin.js": "1.1.3"
72
+ "@types/ws": "^8.18.1",
73
+ "zhin.js": "1.1.5"
72
74
  },
73
75
  "files": [
74
76
  "adapters",
package/schema.json CHANGED
@@ -76,6 +76,47 @@
76
76
  "id": {
77
77
  "type": "string",
78
78
  "description": "Dingtalk bot name"
79
+ },
80
+ "mode": {
81
+ "type": "string",
82
+ "enum": [
83
+ "webhook",
84
+ "stream"
85
+ ],
86
+ "default": "webhook"
87
+ },
88
+ "cardTemplateId": {
89
+ "type": "string",
90
+ "minLength": 1,
91
+ "description": "Stream interactive card template ID"
92
+ },
93
+ "cardButtonCount": {
94
+ "type": "integer",
95
+ "minimum": 1,
96
+ "maximum": 5,
97
+ "default": 2,
98
+ "description": "Fixed button count in the card template"
99
+ },
100
+ "streamProxy": {
101
+ "type": "object",
102
+ "additionalProperties": false,
103
+ "properties": {
104
+ "port": {
105
+ "type": "integer",
106
+ "minimum": 1,
107
+ "maximum": 65535
108
+ },
109
+ "serverName": {
110
+ "type": "string",
111
+ "minLength": 1,
112
+ "pattern": "^[^\\s/:@?#]+$"
113
+ }
114
+ },
115
+ "required": [
116
+ "port",
117
+ "serverName"
118
+ ],
119
+ "description": "故障验收专用:TCP固定127.0.0.1,保留真实WSS TLS身份"
79
120
  }
80
121
  },
81
122
  "required": [
@@ -89,6 +130,26 @@
89
130
  "type": "string",
90
131
  "default": "",
91
132
  "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
133
+ },
134
+ "mode": {
135
+ "type": "string",
136
+ "enum": [
137
+ "webhook",
138
+ "stream"
139
+ ],
140
+ "default": "webhook"
141
+ },
142
+ "cardTemplateId": {
143
+ "type": "string",
144
+ "minLength": 1,
145
+ "description": "Stream interactive card template ID"
146
+ },
147
+ "cardButtonCount": {
148
+ "type": "integer",
149
+ "minimum": 1,
150
+ "maximum": 5,
151
+ "default": 2,
152
+ "description": "Fixed button count in the card template"
92
153
  }
93
154
  },
94
155
  "required": [
package/src/cards.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { EndpointDeliveryError, type ConversationRef } from '@zhin.js/im-contract';
3
+ import type { DingTalkMessage, ResolvedDingTalkConfig } from './protocol.js';
4
+
5
+ export interface DingTalkCardCallback { outTrackId?: string; spaceType?: string; spaceId?: string; userId?: string; userIdType?: number; corpId?: string; content?: string }
6
+ interface CardRecord { conversation: ConversationRef; spaceType: string; spaceId: string; staffId?: string; senderId?: string; corpId?: string; payloads: Set<string>; expiresAt: number }
7
+ /** Endpoint-owned, bounded correlation. Cards from old generations deliberately cannot dispatch. */
8
+ export class DingTalkCards {
9
+ #epoch = 0;
10
+ #records = new Map<string, CardRecord>();
11
+ #contexts = new Map<string, DingTalkMessage>();
12
+ remember(conversation: ConversationRef, event: DingTalkMessage): void {
13
+ this.#contexts.delete(conversation.id); this.#contexts.set(conversation.id, event);
14
+ while (this.#contexts.size > 1024) this.#contexts.delete(this.#contexts.keys().next().value!);
15
+ }
16
+ clear(): void { this.#epoch++; this.#records.clear(); this.#contexts.clear(); }
17
+ prepare(config: ResolvedDingTalkConfig, conversation: ConversationRef, payload: unknown) {
18
+ const segments = Array.isArray(payload) ? payload : [payload];
19
+ const keyboard = segments.filter(item => item && typeof item === 'object' && item.type === 'keyboard');
20
+ if (!keyboard.length) return undefined;
21
+ if (config.mode !== 'stream' || !config.cardTemplateId) throw new EndpointDeliveryError('card_template_required', 'DingTalk callbacks require a configured Stream card template', 'not_sent');
22
+ if (keyboard.length !== 1) throw new EndpointDeliveryError('invalid_payload', 'DingTalk supports one keyboard per card', 'not_sent');
23
+ const rows = keyboard[0].data?.rows;
24
+ if (!Array.isArray(rows) || rows.some(row => !Array.isArray(row))) throw new EndpointDeliveryError('invalid_payload', 'DingTalk keyboard requires button rows', 'not_sent');
25
+ const buttons = rows.flat();
26
+ const count = config.cardButtonCount ?? 2;
27
+ if (!buttons.length || buttons.length > count || buttons.some((button: any) => typeof button.label !== 'string' || typeof button.payload !== 'string' || !button.payload || button.disabled)) throw new EndpointDeliveryError('invalid_payload', 'DingTalk keyboard exceeds the fixed template or contains invalid/disabled buttons', 'not_sent');
28
+ const context = this.#contexts.get(conversation.id);
29
+ if (!context || (conversation.kind !== 'group' && conversation.kind !== 'private')) throw new EndpointDeliveryError('card_context_required', 'DingTalk card requires a current inbound conversation', 'not_sent');
30
+ const spaceType = conversation.kind === 'group' ? 'IM_GROUP' : 'IM_ROBOT';
31
+ const spaceId = spaceType === 'IM_GROUP' ? conversation.id : context.senderStaffId;
32
+ if (!spaceId) throw new EndpointDeliveryError('card_context_required', 'DingTalk private card requires senderStaffId', 'not_sent');
33
+ const text = segments.filter(item => item?.type !== 'keyboard').map(item => {
34
+ if (typeof item === 'string') return item;
35
+ if (item?.type === 'text' || item?.type === 'markdown') return String(item.data?.text ?? item.data?.content ?? '');
36
+ throw new EndpointDeliveryError('invalid_payload', 'DingTalk interactive cards accept text/markdown and keyboard only', 'not_sent');
37
+ }).join('');
38
+ const cardParamMap: Record<string, string> = { title: '消息', text };
39
+ for (let i = 0; i < count; i++) { cardParamMap[`button${i}_label`] = buttons[i]?.label ?? ''; cardParamMap[`button${i}_payload`] = buttons[i]?.payload ?? ''; cardParamMap[`button${i}_visible`] = buttons[i] ? 'true' : 'false'; }
40
+ const id = randomUUID();
41
+ const record: CardRecord = { conversation, spaceType, spaceId, staffId: context.senderStaffId, senderId: context.senderId, corpId: context.senderCorpId, payloads: new Set(buttons.map((button: any) => button.payload)), expiresAt: Date.now() + 3_600_000 };
42
+ 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' } }) } };
43
+ }
44
+ confirm(prepared: NonNullable<ReturnType<DingTalkCards['prepare']>>, response: any): string {
45
+ const delivery = response?.result?.deliverResults?.find((item: any) => item.spaceType === prepared.record.spaceType && item.spaceId === prepared.record.spaceId);
46
+ if (response?.success === false || delivery?.success === false) throw new EndpointDeliveryError('platform_rejected', 'DingTalk card delivery was rejected', 'rejected');
47
+ if (response?.success !== true || response.result?.outTrackId !== prepared.id || delivery?.success !== true) throw new EndpointDeliveryError('delivery_unconfirmed', 'DingTalk card delivery is unconfirmed', 'unknown');
48
+ if (prepared.epoch !== this.#epoch) throw new EndpointDeliveryError('delivery_unconfirmed', 'DingTalk card completed after endpoint retirement', 'unknown');
49
+ this.#records.set(prepared.id, prepared.record);
50
+ for (const [id, record] of this.#records) if (record.expiresAt <= Date.now()) this.#records.delete(id);
51
+ while (this.#records.size > 1024) this.#records.delete(this.#records.keys().next().value!);
52
+ return prepared.id;
53
+ }
54
+ resolve(callback: DingTalkCardCallback) {
55
+ const record = this.#records.get(callback.outTrackId ?? '');
56
+ 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)) throw new Error('DingTalk card callback correlation failed');
57
+ const content = JSON.parse(callback.content ?? '') as { cardPrivateData?: { params?: { action?: unknown } } };
58
+ const payload = content.cardPrivateData?.params?.action;
59
+ if (typeof payload !== 'string' || !record.payloads.has(payload)) throw new Error('DingTalk card callback payload mismatch');
60
+ return { conversation: record.conversation, payload, sourceMessageId: callback.outTrackId!, senderId: callback.userId === record.staffId ? record.senderId ?? callback.userId : callback.userId };
61
+ }
62
+ }