@zhin.js/adapter-dingtalk 1.1.4 → 1.1.6
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 +42 -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 +18 -16
- 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/src/endpoint.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DingTalkCards, type DingTalkCardCallback } from './cards.js';
|
|
2
|
+
import { DingTalkStream, type DingTalkStreamSocketFactory } from './stream.js';
|
|
3
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
4
|
/**
|
|
3
5
|
* DingTalkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
4
6
|
*/
|
|
5
|
-
import { type EndpointSendRequest } from 'zhin.js/adapter';
|
|
7
|
+
import { Endpoint, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
6
8
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
9
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
8
10
|
import type { CapabilityId } from 'zhin.js';
|
|
@@ -30,6 +32,7 @@ export type DingTalkFetch = (
|
|
|
30
32
|
readonly method?: string;
|
|
31
33
|
readonly headers?: Record<string, string>;
|
|
32
34
|
readonly body?: string;
|
|
35
|
+
readonly signal?: AbortSignal;
|
|
33
36
|
},
|
|
34
37
|
) => Promise<{
|
|
35
38
|
readonly ok: boolean;
|
|
@@ -40,7 +43,8 @@ export type DingTalkFetch = (
|
|
|
40
43
|
|
|
41
44
|
export interface DingTalkEndpointOptions {
|
|
42
45
|
readonly id: CapabilityId;
|
|
43
|
-
readonly http
|
|
46
|
+
readonly http?: HttpHost;
|
|
47
|
+
readonly createStreamSocket?: DingTalkStreamSocketFactory;
|
|
44
48
|
readonly config: ResolvedDingTalkConfig;
|
|
45
49
|
readonly fetch?: DingTalkFetch;
|
|
46
50
|
}
|
|
@@ -100,7 +104,9 @@ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
100
104
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
101
105
|
#accessToken: AccessToken = { token: '', expires_in: 0, timestamp: 0 };
|
|
102
106
|
#refreshPromise: Promise<string> | null = null;
|
|
103
|
-
#sessionWebhooks = new Map<string, string>();
|
|
107
|
+
#sessionWebhooks = new Map<string, { url: string; expiresAt: number }>();
|
|
108
|
+
#stream?: DingTalkStream;
|
|
109
|
+
readonly #cards = new DingTalkCards();
|
|
104
110
|
#open = false;
|
|
105
111
|
#started = false;
|
|
106
112
|
|
|
@@ -111,6 +117,8 @@ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
111
117
|
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
get transportState() { return this.#stream?.lifecycle.state ?? (this.#started ? 'open' : 'stopped'); }
|
|
121
|
+
|
|
114
122
|
/** Used by webhook handler. */
|
|
115
123
|
get isOpen(): boolean {
|
|
116
124
|
return this.#open;
|
|
@@ -124,8 +132,13 @@ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
124
132
|
if (this.#started) return;
|
|
125
133
|
this.#started = true;
|
|
126
134
|
try {
|
|
135
|
+
if (this.#options.config.mode === 'stream') {
|
|
136
|
+
this.#stream = new DingTalkStream(this.config, this.#fetch, (event) => this.admit(event), () => this.#open, this.#options.createStreamSocket, (event, id) => this.admitCard(event, id));
|
|
137
|
+
await this.#stream.start();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
127
140
|
await this.#refreshAccessToken();
|
|
128
|
-
this.#routeReleases.push(...registerDingTalkWebhookRoutes(this.#options.http
|
|
141
|
+
this.#routeReleases.push(...registerDingTalkWebhookRoutes(this.#options.http!, this));
|
|
129
142
|
this.#logger.debug(formatCompact({
|
|
130
143
|
endpoint: this.#options.config.id,
|
|
131
144
|
op: 'webhook',
|
|
@@ -148,62 +161,124 @@ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
148
161
|
|
|
149
162
|
async stop(): Promise<void> {
|
|
150
163
|
this.#open = false;
|
|
164
|
+
await this.#stream?.stop();
|
|
151
165
|
this.#sessionWebhooks.clear();
|
|
166
|
+
this.#cards.clear();
|
|
152
167
|
for (const release of this.#routeReleases.splice(0)) release();
|
|
153
168
|
this.#started = false;
|
|
154
169
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
155
170
|
}
|
|
156
171
|
|
|
157
172
|
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
173
|
+
try {
|
|
174
|
+
const card = this.#cards.prepare(this.config, conversation, payload);
|
|
175
|
+
if (card) {
|
|
176
|
+
let stage = 'token';
|
|
177
|
+
let httpStatus: number | undefined;
|
|
178
|
+
let platformCode: string | number | undefined;
|
|
179
|
+
try {
|
|
180
|
+
try { await this.#ensureAccessToken(); } catch (cause) { throw new EndpointDeliveryError('token_unavailable', 'DingTalk card token unavailable', 'not_sent', { cause }); }
|
|
181
|
+
stage = 'request';
|
|
182
|
+
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) });
|
|
183
|
+
httpStatus = response.status;
|
|
184
|
+
stage = 'response';
|
|
185
|
+
let data: unknown;
|
|
186
|
+
try { data = await response.json(); } catch {
|
|
187
|
+
if (response.ok) throw new EndpointDeliveryError('invalid_response', 'DingTalk card response could not be parsed', 'unknown');
|
|
188
|
+
}
|
|
189
|
+
if (data && typeof data === 'object') {
|
|
190
|
+
const code = (data as { code?: unknown; errcode?: unknown }).code ?? (data as { errcode?: unknown }).errcode;
|
|
191
|
+
// Only bounded protocol codes; never log URLs, messages, response bodies or credentials.
|
|
192
|
+
if (typeof code === 'number' && Number.isFinite(code)) platformCode = code;
|
|
193
|
+
else if (typeof code === 'string' && /^[a-zA-Z][a-zA-Z0-9_.-]{0,95}$/.test(code)
|
|
194
|
+
&& ![this.#accessToken.token, this.config.appSecret, this.config.appKey].some(secret => secret && code.includes(secret))) platformCode = code;
|
|
195
|
+
}
|
|
196
|
+
if (!response.ok) throw new EndpointDeliveryError('http_error', `DingTalk card HTTP ${response.status}`, response.status >= 400 && response.status < 500 ? 'rejected' : 'unknown');
|
|
197
|
+
stage = 'delivery';
|
|
198
|
+
return this.#cards.confirm(card, data);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.#logger.warn(formatCompact({ op: 'dingtalk_card_delivery_failed', stage, httpStatus, platformCode,
|
|
201
|
+
code: error instanceof EndpointDeliveryError ? error.code : 'delivery_unconfirmed' }));
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const content = formatOutboundBody(payload);
|
|
206
|
+
const session = this.#sessionWebhooks.get(conversation.id);
|
|
207
|
+
const sessionWebhook = session && session.expiresAt > Date.now() ? session.url : undefined;
|
|
208
|
+
if (session && !sessionWebhook) this.#sessionWebhooks.delete(conversation.id);
|
|
209
|
+
if (!sessionWebhook && this.config.mode === 'stream') {
|
|
210
|
+
throw new EndpointDeliveryError('session_unavailable', 'DingTalk Stream send requires a current inbound session webhook', 'not_sent');
|
|
211
|
+
}
|
|
212
|
+
if (sessionWebhook) {
|
|
213
|
+
const response = await this.#fetch(sessionWebhook, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
216
|
+
body: JSON.stringify(content),
|
|
217
|
+
signal: AbortSignal.timeout(30_000),
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) throw new EndpointDeliveryError('http_error', `DingTalk HTTP ${response.status}`, response.status >= 400 && response.status < 500 ? 'rejected' : 'unknown');
|
|
220
|
+
const data = await response.json() as DingTalkApiResponse;
|
|
221
|
+
if (typeof data.errcode === 'number' && data.errcode !== 0) {
|
|
222
|
+
throw new EndpointDeliveryError('platform_rejected', `DingTalk session webhook rejected the outbound request (${data.errcode})`, 'rejected');
|
|
223
|
+
}
|
|
224
|
+
this.#logger.debug(formatCompact({
|
|
225
|
+
op: 'send',
|
|
226
|
+
endpoint: this.#options.config.id,
|
|
227
|
+
via: 'sessionWebhook',
|
|
228
|
+
to: conversation.id,
|
|
229
|
+
}));
|
|
230
|
+
if (data.errcode !== 0) throw new EndpointDeliveryError('delivery_unconfirmed', 'Malformed platform response', 'unknown');
|
|
231
|
+
return requireMessageId(data.msgId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const body: DingTalkSendBody = {
|
|
235
|
+
...content,
|
|
236
|
+
...(this.#options.config.robotCode
|
|
237
|
+
? { robotCode: this.#options.config.robotCode }
|
|
238
|
+
: {}),
|
|
239
|
+
};
|
|
240
|
+
const data = await this.#request('/robot/send', {
|
|
162
241
|
method: 'POST',
|
|
163
|
-
|
|
164
|
-
body: JSON.stringify(content),
|
|
242
|
+
body: body as unknown as Record<string, unknown>,
|
|
165
243
|
});
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
throw new Error(`Failed to send message via session webhook: ${data.errmsg}`);
|
|
244
|
+
if (typeof data.errcode === 'number' && data.errcode !== 0) {
|
|
245
|
+
throw new EndpointDeliveryError('platform_rejected', `DingTalk rejected the outbound request (${data.errcode})`, 'rejected');
|
|
169
246
|
}
|
|
170
|
-
this.#logger.debug(formatCompact({
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
176
|
-
return (data.msgId as string) || `${Date.now()}`;
|
|
247
|
+
this.#logger.debug(formatCompact({ op: 'send', to: conversation.id }));
|
|
248
|
+
if (data.errcode !== 0) throw new EndpointDeliveryError('delivery_unconfirmed', 'Malformed platform response', 'unknown');
|
|
249
|
+
return requireMessageId(data.msgId);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error instanceof EndpointDeliveryError) throw error;
|
|
252
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
|
|
177
253
|
}
|
|
254
|
+
}
|
|
178
255
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
body: body as unknown as Record<string, unknown>,
|
|
256
|
+
async admitCard(event: DingTalkCardCallback, id: string): Promise<void> {
|
|
257
|
+
if (!this.#open) throw new Error('DingTalk endpoint is closed');
|
|
258
|
+
const action = this.#cards.resolve(event);
|
|
259
|
+
await this.emitAccepted('message.receive', {
|
|
260
|
+
conversation: action.conversation, message: { conversation: action.conversation, id },
|
|
261
|
+
content: `[action: ${action.payload}]`, segments: [{ type: 'action', data: { id, payload: action.payload, sourceMessageId: action.sourceMessageId } }],
|
|
262
|
+
sender: { id: action.senderId }, endpointId: this.config.id,
|
|
263
|
+
metadata: Object.freeze({ eventType: 'card_callback', sourceMessageId: action.sourceMessageId }),
|
|
188
264
|
});
|
|
189
|
-
if (data.errcode !== 0) {
|
|
190
|
-
throw new Error(`Failed to send message: ${data.errmsg}`);
|
|
191
|
-
}
|
|
192
|
-
this.#logger.debug(formatCompact({ op: 'send', to: conversation.id }));
|
|
193
|
-
return (data.msgId as string) || `${Date.now()}`;
|
|
194
265
|
}
|
|
195
266
|
|
|
196
267
|
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
197
|
-
admit(event: DingTalkEvent | DingTalkMessage): void {
|
|
268
|
+
async admit(event: DingTalkEvent | DingTalkMessage): Promise<void> {
|
|
198
269
|
if (!this.#open) return;
|
|
199
270
|
this.#emitPlatformEvent(event.msgtype || 'event', event);
|
|
200
271
|
if (event.sessionWebhook && event.conversationId) {
|
|
201
|
-
|
|
272
|
+
const expiresAt = Number.isFinite(event.sessionWebhookExpiredTime) ? event.sessionWebhookExpiredTime! : Date.now() + 300_000;
|
|
273
|
+
this.#sessionWebhooks.delete(event.conversationId);
|
|
274
|
+
if (expiresAt > Date.now()) this.#sessionWebhooks.set(event.conversationId, { url: event.sessionWebhook, expiresAt });
|
|
275
|
+
while (this.#sessionWebhooks.size > 1024) this.#sessionWebhooks.delete(this.#sessionWebhooks.keys().next().value!);
|
|
202
276
|
}
|
|
203
277
|
const conversation = dingtalkInboundConversation(String(this.#options.id), event);
|
|
278
|
+
this.#cards.remember(conversation, event);
|
|
204
279
|
const chatType = resolveChatType(event.conversationType);
|
|
205
280
|
const permit = normalizeDingtalkSenderForPermit({ isAdmin: event.isAdmin === true });
|
|
206
|
-
|
|
281
|
+
await this.emitAccepted('message.receive', {
|
|
207
282
|
conversation,
|
|
208
283
|
message: { conversation, id: generateMessageId(event) },
|
|
209
284
|
content: formatInboundContent(event),
|
|
@@ -223,6 +298,7 @@ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
223
298
|
conversationType: event.conversationType,
|
|
224
299
|
}),
|
|
225
300
|
}).catch((err) => {
|
|
301
|
+
if (this.config.mode === 'stream') throw err;
|
|
226
302
|
this.#logger.warn(formatCompact({
|
|
227
303
|
op: 'dingtalk_gateway_receive_failed',
|
|
228
304
|
conversationId: conversation.id,
|
|
@@ -393,10 +469,10 @@ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
393
469
|
method,
|
|
394
470
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
395
471
|
body: body && method === 'POST' ? JSON.stringify(body) : undefined,
|
|
472
|
+
signal: AbortSignal.timeout(30_000),
|
|
396
473
|
});
|
|
397
474
|
if (!response.ok) {
|
|
398
|
-
|
|
399
|
-
throw new Error(`DingTalk API error ${response.status}: ${text}`);
|
|
475
|
+
throw new EndpointDeliveryError('http_error', `DingTalk HTTP ${response.status}`, response.status >= 400 && response.status < 500 ? 'rejected' : 'unknown');
|
|
400
476
|
}
|
|
401
477
|
return await response.json() as DingTalkApiResponse;
|
|
402
478
|
}
|
|
@@ -437,3 +513,8 @@ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
|
|
|
437
513
|
throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
|
|
438
514
|
}
|
|
439
515
|
}
|
|
516
|
+
|
|
517
|
+
function requireMessageId(value: unknown): string {
|
|
518
|
+
if (typeof value === 'string' && value.trim().length > 0) return value;
|
|
519
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
|
|
520
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -6,29 +6,34 @@
|
|
|
6
6
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
7
7
|
import type { IncomingMessage } from 'node:http';
|
|
8
8
|
|
|
9
|
-
import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
10
|
-
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
11
|
-
|
|
12
|
-
const logger = getLogger('dingtalk');
|
|
9
|
+
import { EndpointDeliveryError, isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
13
10
|
|
|
14
11
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
15
12
|
export interface DingTalkEndpointConfig {
|
|
13
|
+
readonly streamProxy?: import('./stream-proxy.js').DingTalkStreamProxy;
|
|
16
14
|
readonly id: string;
|
|
17
15
|
readonly appKey: string;
|
|
18
16
|
readonly appSecret: string;
|
|
17
|
+
readonly mode?: 'webhook' | 'stream';
|
|
19
18
|
readonly webhookPath?: string;
|
|
20
19
|
readonly robotCode?: string;
|
|
21
20
|
readonly apiBaseUrl?: string;
|
|
21
|
+
readonly cardTemplateId?: string;
|
|
22
|
+
readonly cardButtonCount?: number;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface ResolvedDingTalkConfig {
|
|
26
|
+
readonly streamProxy?: import('./stream-proxy.js').DingTalkStreamProxy;
|
|
25
27
|
readonly context: 'dingtalk';
|
|
28
|
+
readonly mode?: 'webhook' | 'stream';
|
|
26
29
|
readonly id: string;
|
|
27
30
|
readonly appKey: string;
|
|
28
31
|
readonly appSecret: string;
|
|
29
32
|
readonly webhookPath: string;
|
|
30
33
|
readonly robotCode?: string;
|
|
31
34
|
readonly apiBaseUrl: string;
|
|
35
|
+
readonly cardTemplateId?: string;
|
|
36
|
+
readonly cardButtonCount?: number;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export interface DingTalkMessage {
|
|
@@ -42,6 +47,7 @@ export interface DingTalkMessage {
|
|
|
42
47
|
readonly senderNick?: string;
|
|
43
48
|
readonly senderCorpId?: string;
|
|
44
49
|
readonly sessionWebhook?: string;
|
|
50
|
+
readonly sessionWebhookExpiredTime?: number;
|
|
45
51
|
readonly chatbotCorpId?: string;
|
|
46
52
|
readonly chatbotUserId?: string;
|
|
47
53
|
readonly isAdmin?: boolean;
|
|
@@ -111,20 +117,28 @@ export interface DingTalkSendBody {
|
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
export function resolveDingTalkConfig(config: DingTalkEndpointConfig): ResolvedDingTalkConfig {
|
|
120
|
+
if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
|
|
121
|
+
|| typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName))) throw new TypeError('Invalid DingTalk streamProxy port/serverName');
|
|
122
|
+
if (config.mode !== undefined && !['webhook', 'stream'].includes(config.mode)) throw new TypeError('Invalid DingTalk mode');
|
|
114
123
|
const id = requiredEndpointField(config.id, 'id');
|
|
115
124
|
const appKey = requiredEndpointField(config.appKey, 'appKey');
|
|
116
125
|
const appSecret = requiredEndpointField(config.appSecret, 'appSecret');
|
|
117
126
|
const webhookPath = normalizeWebhookPath(config.webhookPath ?? '/dingtalk/webhook');
|
|
118
127
|
const apiBaseUrl = (config.apiBaseUrl ?? 'https://oapi.dingtalk.com').replace(/\/$/, '');
|
|
119
128
|
const robotCode = config.robotCode;
|
|
129
|
+
if (config.cardButtonCount !== undefined && (!Number.isInteger(config.cardButtonCount) || config.cardButtonCount < 1 || config.cardButtonCount > 5)) throw new TypeError('cardButtonCount must be 1..5');
|
|
120
130
|
return {
|
|
131
|
+
...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
|
|
121
132
|
context: 'dingtalk',
|
|
133
|
+
mode: config.mode ?? 'webhook',
|
|
122
134
|
id,
|
|
123
135
|
appKey,
|
|
124
136
|
appSecret,
|
|
125
137
|
webhookPath,
|
|
126
138
|
...(robotCode ? { robotCode } : {}),
|
|
127
139
|
apiBaseUrl,
|
|
140
|
+
...(config.cardTemplateId ? { cardTemplateId: config.cardTemplateId } : {}),
|
|
141
|
+
cardButtonCount: config.cardButtonCount ?? 2,
|
|
128
142
|
};
|
|
129
143
|
}
|
|
130
144
|
|
|
@@ -233,9 +247,8 @@ export function verifySignature(
|
|
|
233
247
|
* Segment canonicalization is intentionally not done here: media segments carry
|
|
234
248
|
* the canonical `data.media` MediaRef and nothing else is consulted.
|
|
235
249
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* - 其余 kind / audio / video / file 段无投递面,warn + 丢弃。
|
|
250
|
+
* 钉钉机器人图片使用官方 Markdown HTTP(S) 图片链接,保留同载荷文本和图片顺序。
|
|
251
|
+
* 本地媒体上传、原生引用、音频/视频/文件未接线,在请求前明确拒绝。
|
|
239
252
|
*/
|
|
240
253
|
export function formatOutboundBody(payload: unknown): DingTalkSendBody {
|
|
241
254
|
if (typeof payload === 'string') {
|
|
@@ -280,33 +293,37 @@ export function formatOutboundBody(payload: unknown): DingTalkSendBody {
|
|
|
280
293
|
}
|
|
281
294
|
break;
|
|
282
295
|
}
|
|
296
|
+
case 'reply':
|
|
297
|
+
throw new EndpointDeliveryError('unsupported_operation', 'DingTalk native quoted replies are not implemented', 'not_sent');
|
|
298
|
+
case 'share': {
|
|
299
|
+
if (['audio', 'artist', 'duration', 'config'].some(key => data[key] !== undefined)) {
|
|
300
|
+
throw new EndpointDeliveryError('unsupported_operation', 'dingtalk rich share metadata is not implemented', 'not_sent');
|
|
301
|
+
}
|
|
302
|
+
let url: URL;
|
|
303
|
+
try { url = new URL(String(data.url ?? '')); } catch { throw new EndpointDeliveryError('invalid_payload', 'DingTalk share requires an HTTP(S) URL', 'not_sent'); }
|
|
304
|
+
if (!['https:', 'http:'].includes(url.protocol)) throw new EndpointDeliveryError('invalid_payload', 'DingTalk share requires an HTTP(S) URL', 'not_sent');
|
|
305
|
+
if (media) throw new EndpointDeliveryError('invalid_payload', 'DingTalk accepts one link card per message', 'not_sent');
|
|
306
|
+
media = { msgtype: 'link', link: { title: String(data.title ?? '链接'), text: String(data.description ?? data.content ?? ''), messageUrl: url.toString(), ...(typeof data.image === 'string' ? { picUrl: data.image } : {}) } };
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case 'keyboard':
|
|
310
|
+
throw new EndpointDeliveryError('unsupported_operation', 'DingTalk native keyboard callback requires an interactive card template and is not implemented', 'not_sent');
|
|
283
311
|
case 'image': {
|
|
284
|
-
if (media) break;
|
|
285
312
|
const ref = data.media;
|
|
286
313
|
if (isMediaRef(ref) && ref.kind === 'url') {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
314
|
+
let url: URL;
|
|
315
|
+
try { url = new URL(ref.value); } catch { throw new EndpointDeliveryError('invalid_payload', 'DingTalk image requires an HTTP(S) URL', 'not_sent'); }
|
|
316
|
+
if (!['https:', 'http:'].includes(url.protocol)) throw new EndpointDeliveryError('invalid_payload', 'DingTalk image requires an HTTP(S) URL', 'not_sent');
|
|
317
|
+
markdownTitle ??= '消息';
|
|
318
|
+
textParts.push(`\n\s]/g, character => encodeURIComponent(character))})\n`);
|
|
291
319
|
break;
|
|
292
320
|
}
|
|
293
|
-
|
|
294
|
-
op: 'dingtalk_outbound_media_dropped',
|
|
295
|
-
type: item.type,
|
|
296
|
-
reason: isMediaRef(ref) ? `unsupported_kind:${ref.kind}` : 'missing_media_ref',
|
|
297
|
-
}));
|
|
298
|
-
break;
|
|
321
|
+
throw new EndpointDeliveryError('unsupported_operation', 'DingTalk image upload is not implemented; use a canonical HTTP(S) URL', 'not_sent');
|
|
299
322
|
}
|
|
300
323
|
case 'audio':
|
|
301
324
|
case 'video':
|
|
302
325
|
case 'file':
|
|
303
|
-
|
|
304
|
-
logger.warn(formatCompact({
|
|
305
|
-
op: 'dingtalk_outbound_media_dropped',
|
|
306
|
-
type: item.type,
|
|
307
|
-
reason: 'undeliverable_msgtype',
|
|
308
|
-
}));
|
|
309
|
-
break;
|
|
326
|
+
throw new EndpointDeliveryError('unsupported_operation', 'DingTalk audio/video/file delivery is not implemented', 'not_sent');
|
|
310
327
|
case 'markdown':
|
|
311
328
|
markdownTitle ??= String(data.title || '消息');
|
|
312
329
|
textParts.push(String(data.content ?? data.text ?? ''));
|
|
@@ -329,7 +346,7 @@ export function formatOutboundBody(payload: unknown): DingTalkSendBody {
|
|
|
329
346
|
}
|
|
330
347
|
}
|
|
331
348
|
|
|
332
|
-
if (media) return media;
|
|
349
|
+
if (media) return media.link && textParts.length ? { ...media, link: { ...media.link, text: [...textParts, media.link.text].filter(Boolean).join('\n') } } : media;
|
|
333
350
|
if (markdownTitle !== undefined) {
|
|
334
351
|
return {
|
|
335
352
|
msgtype: 'markdown',
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import tls from 'node:tls';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { getLogger } from '@zhin.js/logger';
|
|
4
|
+
|
|
5
|
+
export interface DingTalkStreamProxy { readonly port: number; readonly serverName: string }
|
|
6
|
+
|
|
7
|
+
/** Route TCP only. WSS HTTP Host, path, ticket, TLS SNI and peer validation stay original. */
|
|
8
|
+
export function createDingTalkStreamSocket(url: string, proxy?: DingTalkStreamProxy): WebSocket {
|
|
9
|
+
if (!proxy) return new WebSocket(url);
|
|
10
|
+
const target = new URL(url);
|
|
11
|
+
if (target.protocol !== 'wss:' || target.hostname !== proxy.serverName || (target.port && target.port !== '443')) {
|
|
12
|
+
// The hostname is safe diagnostics; never print URL/query/ticket or SDK errors.
|
|
13
|
+
getLogger('dingtalk').warn(`Stream proxy rejected gateway hostname: ${target.hostname}`);
|
|
14
|
+
throw new Error('DingTalk Stream proxy gateway identity changed; update the fixed upstream before retrying');
|
|
15
|
+
}
|
|
16
|
+
return new WebSocket(url, {
|
|
17
|
+
createConnection: (options: import('node:http').ClientRequestArgs) => tls.connect({ ...options as tls.ConnectionOptions, path: undefined, host: '127.0.0.1', port: proxy.port,
|
|
18
|
+
servername: target.hostname, rejectUnauthorized: true }),
|
|
19
|
+
});
|
|
20
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createDingTalkStreamSocket } from './stream-proxy.js';
|
|
2
|
+
import { createEndpointLifecycle } from 'zhin.js/adapter';
|
|
3
|
+
import type { DingTalkFetch } from './endpoint.js';
|
|
4
|
+
import type { DingTalkEvent, ResolvedDingTalkConfig } from './protocol.js';
|
|
5
|
+
|
|
6
|
+
export const CARD_TOPIC = '/v1.0/card/instances/callback';
|
|
7
|
+
export const ROBOT_TOPIC = '/v1.0/im/bot/messages/get';
|
|
8
|
+
export interface DingTalkStreamSocket {
|
|
9
|
+
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
10
|
+
send(data: string): void;
|
|
11
|
+
terminate(): void;
|
|
12
|
+
ping(): void;
|
|
13
|
+
}
|
|
14
|
+
export type DingTalkStreamSocketFactory = (url: string) => DingTalkStreamSocket;
|
|
15
|
+
|
|
16
|
+
/** Official gateway wire contract; reconnect/heartbeat belong to the shared lifecycle. */
|
|
17
|
+
export class DingTalkStream {
|
|
18
|
+
readonly lifecycle;
|
|
19
|
+
#active?: AbortController;
|
|
20
|
+
#socket?: DingTalkStreamSocket;
|
|
21
|
+
#seen = new Map<string, number>();
|
|
22
|
+
#pending = new Set<string>();
|
|
23
|
+
constructor(
|
|
24
|
+
readonly config: ResolvedDingTalkConfig,
|
|
25
|
+
readonly fetch: DingTalkFetch,
|
|
26
|
+
readonly admit: (event: DingTalkEvent) => Promise<void>,
|
|
27
|
+
readonly isOpen: () => boolean,
|
|
28
|
+
readonly createSocket: DingTalkStreamSocketFactory = (url) => createDingTalkStreamSocket(url, config.streamProxy),
|
|
29
|
+
readonly admitCard?: (event: import('./cards.js').DingTalkCardCallback, id: string) => Promise<void>,
|
|
30
|
+
) {
|
|
31
|
+
this.lifecycle = createEndpointLifecycle({ name: config.id, heartbeat: { intervalMs: 30_000, watchdogMisses: 2 } });
|
|
32
|
+
}
|
|
33
|
+
async start(): Promise<void> {
|
|
34
|
+
await this.lifecycle.start(async (handle) => {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
this.#active = controller;
|
|
37
|
+
const transport: { socket?: DingTalkStreamSocket } = {};
|
|
38
|
+
handle.onForceClose(() => { controller.abort(); transport.socket?.terminate(); });
|
|
39
|
+
const response = await this.fetch('https://api.dingtalk.com/v1.0/gateway/connections/open', {
|
|
40
|
+
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ clientId: this.config.appKey, clientSecret: this.config.appSecret,
|
|
42
|
+
subscriptions: [{ type: 'CALLBACK', topic: ROBOT_TOPIC }, ...(this.admitCard && this.config.cardTemplateId ? [{ type: 'CALLBACK', topic: CARD_TOPIC }] : [])], ua: 'zhin.js' }),
|
|
43
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]),
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) throw new Error(`DingTalk Stream gateway HTTP ${response.status}`);
|
|
46
|
+
const gateway = await response.json() as { endpoint?: string; ticket?: string };
|
|
47
|
+
if (controller.signal.aborted) return;
|
|
48
|
+
if (!gateway.endpoint || !gateway.ticket) throw new Error('Invalid DingTalk Stream gateway response');
|
|
49
|
+
const url = new URL(gateway.endpoint);
|
|
50
|
+
if (url.protocol !== 'wss:') throw new Error('DingTalk Stream requires WSS');
|
|
51
|
+
url.searchParams.set('ticket', gateway.ticket);
|
|
52
|
+
const current = this.createSocket(url.toString());
|
|
53
|
+
transport.socket = current;
|
|
54
|
+
this.#socket = current;
|
|
55
|
+
await new Promise<void>((resolve, reject) => {
|
|
56
|
+
const timer = setTimeout(() => { current.terminate(); reject(new Error('DingTalk Stream connection timeout')); }, 30_000);
|
|
57
|
+
const clear = () => clearTimeout(timer);
|
|
58
|
+
controller.signal.addEventListener('abort', clear, { once: true });
|
|
59
|
+
current.on('open', () => {
|
|
60
|
+
if (controller.signal.aborted || this.#socket !== current) { current.terminate(); return; }
|
|
61
|
+
clear(); this.lifecycle.startHeartbeat(() => current.ping()); resolve();
|
|
62
|
+
});
|
|
63
|
+
current.on('error', () => { clear(); current.terminate(); reject(new Error('DingTalk Stream transport error')); });
|
|
64
|
+
current.on('close', () => { clear(); if (this.#socket === current) this.#socket = undefined;
|
|
65
|
+
handle.notifyClosed('transport closed'); reject(new Error('DingTalk Stream closed before connection')); });
|
|
66
|
+
current.on('pong', () => { if (!controller.signal.aborted) this.lifecycle.notifyHeartbeatAck(); });
|
|
67
|
+
current.on('message', (raw: { toString(): string }) => {
|
|
68
|
+
if (controller.signal.aborted || this.#socket !== current) return;
|
|
69
|
+
this.lifecycle.notifyHeartbeatAck();
|
|
70
|
+
let frame: { type?: string; headers?: { topic?: string; messageId?: string }; data?: string };
|
|
71
|
+
try { frame = JSON.parse(raw.toString()); } catch { return; }
|
|
72
|
+
if (frame.type === 'SYSTEM') {
|
|
73
|
+
// REGISTERED is optional; successful WSS handshake already establishes the subscribed transport.
|
|
74
|
+
if (frame.headers?.topic === 'ping' || frame.headers?.topic === 'disconnect') {
|
|
75
|
+
current.send(JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: frame.data }));
|
|
76
|
+
if (frame.headers.topic === 'disconnect') current.terminate();
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (frame.type !== 'CALLBACK' || ![ROBOT_TOPIC, ...(this.admitCard && this.config.cardTemplateId ? [CARD_TOPIC] : [])].includes(frame.headers?.topic ?? '') || !frame.headers?.messageId) return;
|
|
81
|
+
const id = frame.headers.messageId;
|
|
82
|
+
const ack = () => { if (!controller.signal.aborted && this.#socket === current) current.send(JSON.stringify({ code: 200,
|
|
83
|
+
headers: { contentType: 'application/json', messageId: id }, message: 'OK', data: JSON.stringify({ response: null }) })); };
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
for (const [key, time] of this.#seen) if (now - time > 300_000) this.#seen.delete(key);
|
|
86
|
+
if (this.#seen.has(id)) { ack(); return; }
|
|
87
|
+
if (!this.isOpen() || this.#pending.has(id) || this.#pending.size >= 100) return;
|
|
88
|
+
let event: DingTalkEvent | import('./cards.js').DingTalkCardCallback;
|
|
89
|
+
try { event = JSON.parse(frame.data ?? ''); } catch { return; }
|
|
90
|
+
const card = frame.headers.topic === CARD_TOPIC;
|
|
91
|
+
if (!event || typeof event !== 'object' || (!card && !(event as DingTalkEvent).msgtype)) return;
|
|
92
|
+
this.#pending.add(id);
|
|
93
|
+
void (card ? this.admitCard!(event as import('./cards.js').DingTalkCardCallback, id) : this.admit(event as DingTalkEvent)).then(() => {
|
|
94
|
+
if (controller.signal.aborted) return;
|
|
95
|
+
this.#seen.set(id, Date.now());
|
|
96
|
+
while (this.#seen.size > 10_000) this.#seen.delete(this.#seen.keys().next().value!);
|
|
97
|
+
ack();
|
|
98
|
+
}).catch(() => { /* no ACK: allow server retry */ }).finally(() => this.#pending.delete(id));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async stop(): Promise<void> { this.#active?.abort(); await this.lifecycle.stop(); this.#socket = undefined; this.#seen.clear(); }
|
|
104
|
+
}
|
package/src/webhook.ts
CHANGED
|
@@ -17,7 +17,7 @@ const logger = getLogger('dingtalk');
|
|
|
17
17
|
export interface DingTalkWebhookHandler {
|
|
18
18
|
readonly config: ResolvedDingTalkConfig;
|
|
19
19
|
readonly isOpen: boolean;
|
|
20
|
-
admit(event: DingTalkEvent): void
|
|
20
|
+
admit(event: DingTalkEvent): void | Promise<void>;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function registerDingTalkWebhookRoutes(
|
|
@@ -62,7 +62,7 @@ export async function handleDingTalkWebhookRequest(
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
if (event.msgtype && handler.isOpen) {
|
|
65
|
-
handler.admit(event);
|
|
65
|
+
await handler.admit(event);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|