@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/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
|
-
*
|
|
135
|
-
*
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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\s]/g, character => encodeURIComponent(character))})\n`);
|
|
186
221
|
break;
|
|
187
222
|
}
|
|
188
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/stream.d.ts
ADDED
|
@@ -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
|
+
"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
|
-
"
|
|
37
|
-
"@zhin.js/
|
|
38
|
-
"@zhin.js/
|
|
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.
|
|
41
|
-
"@zhin.js/im-contract": "1.1.
|
|
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.
|
|
44
|
-
"@zhin.js/tool": "1.1.
|
|
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.
|
|
49
|
-
"@zhin.js/command": "^1.1.
|
|
50
|
-
"@zhin.js/core": "^1.1.
|
|
51
|
-
"@zhin.js/host-http": "^1.1.
|
|
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",
|
|
52
53
|
"@zhin.js/permission": "^1.1.3",
|
|
53
|
-
"zhin.js": "^1.1.
|
|
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
|
-
"
|
|
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
|
+
}
|