@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/src/endpoint.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { Endpoint } from 'zhin.js/adapter';
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: HttpHost;
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, this));
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
- const content = formatOutboundBody(payload);
159
- const sessionWebhook = this.#sessionWebhooks.get(conversation.id);
160
- if (sessionWebhook) {
161
- const response = await this.#fetch(sessionWebhook, {
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
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
164
- body: JSON.stringify(content),
242
+ body: body as unknown as Record<string, unknown>,
165
243
  });
166
- const data = await response.json() as DingTalkApiResponse;
167
- if (data.errcode !== 0) {
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
- op: 'send',
172
- endpoint: this.#options.config.id,
173
- via: 'sessionWebhook',
174
- to: conversation.id,
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
- const body: DingTalkSendBody = {
180
- ...content,
181
- ...(this.#options.config.robotCode
182
- ? { robotCode: this.#options.config.robotCode }
183
- : {}),
184
- };
185
- const data = await this.#request('/robot/send', {
186
- method: 'POST',
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
- this.#sessionWebhooks.set(event.conversationId, event.sessionWebhook);
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
- void this.emit('message.receive', {
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
- const text = await response.text().catch(() => '');
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
- * 钉钉机器人媒体消息仅支持远程 URL(picture.picURL):
237
- * - image 段 kind=url → 直发 picture;
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
- media = {
288
- msgtype: 'picture',
289
- picture: { picURL: ref.value },
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![](${ref.value.replace(/[()\s]/g, character => encodeURIComponent(character))})\n`);
291
319
  break;
292
320
  }
293
- logger.warn(formatCompact({
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
- // 钉钉机器人无音频/视频/文件投递面,warn + 丢弃
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' });