@zhin.js/adapter-lark 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,10 +1,16 @@
1
- import { Endpoint } from 'zhin.js/adapter';
1
+ import { createLarkWebApiTransport } from './web-api-proxy.js';
2
+ import type { LarkCardAction } from './cards.js';
3
+ import { EndpointDeliveryError, type ConversationRef } from '@zhin.js/im-contract';
2
4
  /**
3
5
  * LarkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
4
6
  */
5
7
  import { readFile } from 'node:fs/promises';
6
8
  import { basename } from 'node:path';
7
9
  import {
10
+ Endpoint,
11
+ createEndpointLifecycle,
12
+ type EndpointLifecycle,
13
+ type EndpointTransportState,
8
14
  createRecallEndpointControl,
9
15
  type EndpointControl,
10
16
  type EndpointGroup,
@@ -32,6 +38,7 @@ import {
32
38
  type ResolvedLarkConfig,
33
39
  } from './protocol.js';
34
40
  import { registerLarkWebhookRoutes } from './webhook.js';
41
+ import { createLarkLongConnection, type LarkLongConnectionFactory } from './long-connection.js';
35
42
 
36
43
  /** 出站 HTTP 调用统一 30s 超时。 */
37
44
  const OUTBOUND_TIMEOUT_MS = 30_000;
@@ -53,7 +60,8 @@ export type LarkFetch = (
53
60
 
54
61
  export interface LarkEndpointOptions {
55
62
  readonly id: CapabilityId;
56
- readonly http: HttpHost;
63
+ readonly http?: HttpHost;
64
+ readonly longConnectionFactory?: LarkLongConnectionFactory;
57
65
  readonly config: ResolvedLarkConfig;
58
66
  readonly fetch?: LarkFetch;
59
67
  }
@@ -110,6 +118,7 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
110
118
 
111
119
  readonly #options: LarkEndpointOptions;
112
120
  readonly #fetch: LarkFetch;
121
+ readonly #webApiTransport?: ReturnType<typeof createLarkWebApiTransport>;
113
122
  /**
114
123
  * Console 社交面语义端口。Lark OpenAPI 仅接读取类列表能力:
115
124
  * - listGroups:GET /im/v1/chats(bot 所在群列表,分页归一为 {group_id: chat_id, name})
@@ -142,12 +151,20 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
142
151
  #refreshPromise: Promise<string> | null = null;
143
152
  #open = false;
144
153
  #started = false;
154
+ readonly #lifecycle: EndpointLifecycle;
155
+ readonly #cardSources = new Map<string, { conversation: ConversationRef; time: number }>();
156
+ readonly #seen = new Map<string, number>();
157
+ #startupGeneration = 0;
158
+ readonly #pendingInbound: LarkMessage[] = [];
145
159
 
146
160
  constructor(options: LarkEndpointOptions) {
147
161
  super();
148
162
  this.#logger = getAdapterLogger('lark', options.config.id);
149
163
  this.#options = options;
150
- this.#fetch = options.fetch ?? globalThis.fetch;
164
+ if (options.config.webApiProxy && options.fetch) throw new TypeError('Lark webApiProxy cannot be combined with an injected fetch');
165
+ this.#webApiTransport = options.config.webApiProxy ? createLarkWebApiTransport(options.config.webApiProxy) : undefined;
166
+ this.#fetch = this.#webApiTransport?.fetch ?? options.fetch ?? globalThis.fetch;
167
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
151
168
  }
152
169
 
153
170
  /** Used by webhook handler. */
@@ -159,10 +176,35 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
159
176
  return this.#options.config;
160
177
  }
161
178
 
179
+ get transportState(): EndpointTransportState | undefined {
180
+ return this.config.mode === 'websocket' ? this.#lifecycle.state : undefined;
181
+ }
182
+
162
183
  async start(): Promise<void> {
163
184
  if (this.#started) return;
185
+ this.#webApiTransport?.reopen();
164
186
  this.#started = true;
187
+ const generation = ++this.#startupGeneration;
165
188
  try {
189
+ if (this.config.mode === 'websocket') {
190
+ await this.#lifecycle.start(async (handle) => {
191
+ let active = true;
192
+ const transport = (this.#options.longConnectionFactory ?? createLarkLongConnection)({
193
+ config: this.config,
194
+ receive: (message) => {
195
+ if (!active || !this.#started || generation !== this.#startupGeneration) return;
196
+ if (this.#open) this.admit(message);
197
+ else if (this.#pendingInbound.length < 100) this.#pendingInbound.push(message);
198
+ else this.#logger.warn('lark_inbound_startup_buffer_full');
199
+ },
200
+ cardAction: (event) => { if (active && this.#open && generation === this.#startupGeneration) this.admitCardAction(event); },
201
+ disconnected: () => { if (!active) return; active = false; transport.close(); handle.notifyClosed(); },
202
+ });
203
+ handle.onForceClose(() => { active = false; transport.close(); });
204
+ await transport.connect();
205
+ });
206
+ return;
207
+ }
166
208
  if (!this.#options.config.encryptKey && !this.#options.config.verificationToken) {
167
209
  // encryptKey / verificationToken 都未配置时 webhook 完全无鉴权,任何人可伪造事件
168
210
  this.#logger.warn(formatCompact({
@@ -173,6 +215,8 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
173
215
  }));
174
216
  }
175
217
  await this.#refreshAccessToken();
218
+ if (!this.#started || generation !== this.#startupGeneration) return;
219
+ if (!this.#options.http) throw new Error('Lark webhook mode requires HttpHost');
176
220
  this.#routeReleases.push(...registerLarkWebhookRoutes(this.#options.http, this));
177
221
  this.#logger.debug(formatCompact({
178
222
  endpoint: this.#options.config.id,
@@ -188,6 +232,7 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
188
232
 
189
233
  open(): void {
190
234
  this.#open = true;
235
+ for (const message of this.#pendingInbound.splice(0)) this.admit(message);
191
236
  }
192
237
 
193
238
  close(): void {
@@ -196,42 +241,68 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
196
241
 
197
242
  async stop(): Promise<void> {
198
243
  this.#open = false;
244
+ this.#pendingInbound.length = 0;
245
+ this.#cardSources.clear();
246
+ this.#started = false;
247
+ ++this.#startupGeneration;
248
+ await this.#lifecycle.stop();
249
+ await this.#webApiTransport?.close();
199
250
  for (const release of this.#routeReleases.splice(0)) release();
200
251
  this.#started = false;
201
252
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
202
253
  }
203
254
 
204
255
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
205
- const materialized = await this.#materializeOutboundMedia(payload);
206
- const content = formatOutboundBody(materialized);
207
- const data = await this.#request('/im/v1/messages', {
208
- method: 'POST',
209
- params: { receive_id_type: 'chat_id' },
210
- body: {
211
- receive_id: conversation.id,
212
- msg_type: content.msg_type,
213
- content: content.content,
214
- },
215
- });
216
- if (data.code !== 0) {
217
- throw new Error(`Failed to send message: ${data.msg}`);
256
+ try {
257
+ const materialized = await this.#materializeOutboundMedia(payload);
258
+ const content = formatOutboundBody(materialized);
259
+ const data = await this.#request(content.replyTo
260
+ ? `/im/v1/messages/${encodeURIComponent(content.replyTo)}/reply`
261
+ : '/im/v1/messages', {
262
+ method: 'POST',
263
+ ...(content.replyTo ? {} : { params: { receive_id_type: 'chat_id' } }),
264
+ body: {
265
+ ...(content.replyTo ? {} : { receive_id: conversation.id }),
266
+ msg_type: content.msg_type,
267
+ content: content.content,
268
+ },
269
+ });
270
+ if (typeof data.code === 'number' && data.code !== 0) {
271
+ throw new EndpointDeliveryError('platform_rejected', `Lark rejected message (code=${data.code})`, 'rejected');
272
+ }
273
+ if (data.code !== 0) throw new EndpointDeliveryError('delivery_unconfirmed', 'Malformed platform response', 'unknown');
274
+ const messageId = requireMessageId(data.data?.message_id);
275
+ if (content.msg_type === 'interactive') {
276
+ const now = Date.now();
277
+ for (const [id, source] of this.#cardSources) if (now - source.time > 300_000) this.#cardSources.delete(id);
278
+ this.#cardSources.set(messageId, { conversation, time: now });
279
+ if (this.#cardSources.size > 10_000) this.#cardSources.delete(this.#cardSources.keys().next().value!);
280
+ }
281
+ this.#logger.debug(formatCompact({ op: 'send', to: conversation.id,
282
+ id: messageId,
283
+ }));
284
+ return messageId;
285
+ } catch (error) {
286
+ this.#logger.warn(formatCompact({
287
+ op: 'lark_send_failed',
288
+ status: error instanceof LarkRequestError ? error.status : undefined,
289
+ platform_code: error instanceof LarkRequestError ? error.platformCode : undefined,
290
+ outcome: error instanceof EndpointDeliveryError ? error.disposition : 'unknown',
291
+ }));
292
+ if (error instanceof EndpointDeliveryError) throw error;
293
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
218
294
  }
219
- const messageId = (data.data?.message_id as string) || `${Date.now()}`;
220
- this.#logger.debug(formatCompact({ op: 'send', to: conversation.id,
221
- id: messageId,
222
- }));
223
- return messageId;
224
295
  }
225
296
 
226
297
  async recallMessage(messageId: string): Promise<void> {
227
298
  if (!messageId || messageId.startsWith('outbound:')) return;
228
- await this.#request(`/im/v1/messages/${messageId}`, { method: 'DELETE' });
299
+ await this.#request(`/im/v1/messages/${encodeURIComponent(messageId)}`, { method: 'DELETE' });
229
300
  }
230
301
 
231
302
  /**
232
303
  * im/v1 消息 image 段只接受 image_key:canonical MediaRef(base64/本地路径/URL)
233
304
  * 先经 /im/v1/images 物化,产物回写为 MediaRef kind=file(平台不透明引用);
234
- * 上传失败降级为文本(alt 优先),不阻断发送。
305
+ * 上传失败阻止消息发送并保留拒绝或未知分类。
235
306
  */
236
307
  async #materializeOutboundMedia(payload: unknown): Promise<unknown> {
237
308
  if (!Array.isArray(payload)) return payload;
@@ -243,10 +314,7 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
243
314
  const media = readOutboundImageMedia(data);
244
315
  if (!media) return item;
245
316
  const imageKey = await this.uploadImage(media);
246
- if (!imageKey) {
247
- const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
248
- return { type: 'text', data: { text: alt } };
249
- }
317
+ if (!imageKey) throw new EndpointDeliveryError('delivery_unconfirmed', 'Lark image upload returned no image key', 'unknown');
250
318
  return {
251
319
  type: 'image',
252
320
  data: { media: { kind: 'file', value: imageKey } },
@@ -254,33 +322,36 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
254
322
  }));
255
323
  }
256
324
 
257
- /** POST /im/v1/images(image_type=message),返回 image_key;失败返回 null。 */
258
- async uploadImage(media: Parameters<typeof resolveMediaBinary>[0]): Promise<string | null> {
259
- try {
260
- await this.#ensureAccessToken();
261
- const binary = await resolveMediaBinary(media);
262
- const form = buildImageUploadForm(binary);
263
- const url = `${this.#options.config.apiBaseUrl}/im/v1/images`;
264
- const response = await this.#fetch(url, {
265
- method: 'POST',
266
- headers: { Authorization: `Bearer ${this.#accessToken.token}` },
267
- body: form,
268
- signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
269
- });
270
- const data = await response.json() as LarkApiResponse;
271
- if (data.code === 0) {
272
- return (data.data?.image_key as string) || null;
273
- }
274
- throw new Error(`Image upload failed: ${data.msg}`);
275
- } catch (error) {
276
- this.#logger.error('Failed to upload image:', error);
277
- return null;
278
- }
325
+ /** Upload must succeed before issuing a message; dropped media is never confirmed. */
326
+ async uploadImage(media: Parameters<typeof resolveMediaBinary>[0]): Promise<string> {
327
+ await this.#ensureAccessToken();
328
+ const binary = await resolveMediaBinary(media);
329
+ const form = buildImageUploadForm(binary);
330
+ const response = await this.#fetch(`${this.#options.config.apiBaseUrl}/im/v1/images`, {
331
+ method: 'POST',
332
+ headers: { Authorization: `Bearer ${this.#accessToken.token}` },
333
+ body: form,
334
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
335
+ });
336
+ const data = await response.json().catch(() => undefined) as LarkApiResponse | undefined;
337
+ const code = typeof data?.code === 'number' && Number.isFinite(data.code) ? data.code : undefined;
338
+ if (!response.ok) throw new LarkRequestError(response.status, code, response.status >= 400 && response.status < 500 && response.status !== 408);
339
+ if (code !== 0) throw new LarkRequestError(response.status, code, code !== undefined);
340
+ const key = data?.data?.image_key;
341
+ if (typeof key !== 'string' || !key.trim()) throw new LarkRequestError(response.status, code, false);
342
+ return key;
279
343
  }
280
344
 
281
345
  /** Test / internal: admit a parsed message when open (non-webhook path). */
282
346
  admit(msg: LarkMessage): void {
283
347
  if (!this.#open) return;
348
+ if (msg.message_id) {
349
+ const now = Date.now();
350
+ for (const [id, time] of this.#seen) if (now - time > 300_000) this.#seen.delete(id);
351
+ if (this.#seen.has(msg.message_id)) return;
352
+ this.#seen.set(msg.message_id, now);
353
+ if (this.#seen.size > 10_000) this.#seen.delete(this.#seen.keys().next().value!);
354
+ }
284
355
  const conversation = larkInboundConversation(String(this.#options.id), msg);
285
356
  void this.emit('message.receive', {
286
357
  conversation,
@@ -301,6 +372,28 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
301
372
  });
302
373
  }
303
374
 
375
+ /** Only correlate callbacks to a card actually sent by this endpoint. */
376
+ admitCardAction(event: LarkCardAction): void {
377
+ if (!this.#open) return;
378
+ const sourceId = event.context?.open_message_id ?? event.open_message_id;
379
+ const chatId = event.context?.open_chat_id ?? event.open_chat_id;
380
+ const sender = event.operator?.open_id ?? event.operator?.user_id;
381
+ const payload = event.action?.value?.zhin_payload;
382
+ if (!sourceId || !chatId || !sender || typeof payload !== 'string' || event.action?.tag !== 'button') return;
383
+ const source = this.#cardSources.get(sourceId);
384
+ if (!source || source.conversation.id !== chatId || Date.now() - source.time > 300_000) return;
385
+ const dedup = `card:${event.token ?? `${sourceId}:${sender}:${payload}`}`;
386
+ if (this.#seen.has(dedup)) return;
387
+ this.#seen.set(dedup, Date.now());
388
+ if (this.#seen.size > 10_000) this.#seen.delete(this.#seen.keys().next().value!);
389
+ const conversation = source.conversation;
390
+ void this.emit('message.receive', {
391
+ conversation, message: { conversation, id: dedup }, content: '',
392
+ segments: [{ type: 'action', data: { payload } }], sender: { id: sender }, endpointId: this.config.id,
393
+ metadata: Object.freeze({ eventType: 'card.action.trigger', sourceMessageId: sourceId }),
394
+ }).catch(() => this.#logger.warn('lark_card_action_receive_failed'));
395
+ }
396
+
304
397
  admitPlatform(name: string, event: unknown): void {
305
398
  if (!this.#open) return;
306
399
  void this.emitPlatform(name, event).catch((error) => {
@@ -542,11 +635,15 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
542
635
  body: body && method !== 'GET' ? JSON.stringify(body) : undefined,
543
636
  signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
544
637
  });
638
+ const data = await response.json().catch(() => undefined) as LarkApiResponse | undefined;
639
+ const platformCode = typeof data?.code === 'number' && Number.isFinite(data.code) ? data.code : undefined;
545
640
  if (!response.ok) {
546
- const text = await response.text().catch(() => '');
547
- throw new Error(`Lark API error ${response.status}: ${text}`);
641
+ const rejected = response.status >= 400 && response.status < 500 && response.status !== 408;
642
+ throw new LarkRequestError(response.status, platformCode, rejected);
548
643
  }
549
- return await response.json() as LarkApiResponse;
644
+ if (platformCode === undefined) throw new LarkRequestError(response.status, undefined, false);
645
+ if (platformCode !== 0) throw new LarkRequestError(response.status, platformCode, true);
646
+ return data!;
550
647
  }
551
648
 
552
649
  async #ensureAccessToken(): Promise<void> {
@@ -585,6 +682,24 @@ export class LarkEndpoint extends Endpoint<LarkClient> {
585
682
  this.#logger.debug('Access token refreshed successfully');
586
683
  return;
587
684
  }
588
- throw new Error(`Failed to get access token: ${data.msg} (${data.code})`);
685
+ throw new LarkRequestError(response.status, typeof data.code === 'number' ? data.code : undefined, response.ok && typeof data.code === 'number' && data.code !== 0 || response.status >= 400 && response.status < 500 && response.status !== 408);
686
+ }
687
+ }
688
+
689
+ function requireMessageId(value: unknown): string {
690
+ if (typeof value === 'string' && value.trim().length > 0) return value;
691
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
692
+ }
693
+
694
+ /** Carries only safe numerical diagnostics, never response bodies or credentials. */
695
+ class LarkRequestError extends EndpointDeliveryError {
696
+ readonly status: number;
697
+ readonly platformCode: number | undefined;
698
+ constructor(status: number, platformCode: number | undefined, rejected: boolean) {
699
+ super(rejected ? 'platform_rejected' : 'delivery_unconfirmed',
700
+ `Lark request failed (status=${status}${platformCode === undefined ? '' : `, code=${platformCode}`})`,
701
+ rejected ? 'rejected' : 'unknown');
702
+ this.status = status;
703
+ this.platformCode = platformCode;
589
704
  }
590
705
  }
package/src/index.ts CHANGED
@@ -49,3 +49,10 @@ export {
49
49
  handleLarkWebhookRequest,
50
50
  type LarkWebhookHandler,
51
51
  } from './webhook.js';
52
+
53
+ export {
54
+ createLarkLongConnection,
55
+ type LarkLongConnection,
56
+ type LarkLongConnectionFactory,
57
+ type LarkLongConnectionOptions,
58
+ } from './long-connection.js';
@@ -0,0 +1,87 @@
1
+ import type { LarkCardAction } from './cards.js';
2
+ import * as lark from '@larksuiteoapi/node-sdk';
3
+ import { createLarkStreamAgent } from './stream-proxy.js';
4
+ import type { LarkMessage, ResolvedLarkConfig } from './protocol.js';
5
+
6
+ export interface LarkLongConnection {
7
+ /** Resolves only after the transport handshake, never merely after SDK start(). */
8
+ connect(): Promise<void>;
9
+ close(): void;
10
+ }
11
+ export interface LarkLongConnectionOptions {
12
+ readonly config: ResolvedLarkConfig;
13
+ readonly receive: (message: LarkMessage) => void;
14
+ readonly cardAction?: (event: LarkCardAction) => void;
15
+ readonly disconnected: () => void;
16
+ }
17
+ export type LarkLongConnectionFactory = (options: LarkLongConnectionOptions) => LarkLongConnection;
18
+
19
+ /** The SDK owns wire framing / acknowledgements / ping; Zhin owns reconnect. */
20
+ export const createLarkLongConnection: LarkLongConnectionFactory = ({ config, receive, cardAction, disconnected }) => {
21
+ let cancelled = false;
22
+ let ready = false;
23
+ let settleReady!: () => void;
24
+ let settleError!: (error: Error) => void;
25
+ const connected = new Promise<void>((resolve, reject) => {
26
+ settleReady = resolve;
27
+ settleError = reject;
28
+ });
29
+ // close-before-connect must never create an unhandled rejection.
30
+ connected.catch(() => {});
31
+ const agent = config.streamProxy ? createLarkStreamAgent(config.streamProxy) : undefined;
32
+ const client = new lark.WSClient({
33
+ appId: config.appId,
34
+ appSecret: config.appSecret,
35
+ agent,
36
+ domain: config.isFeishu ? lark.Domain.Feishu : lark.Domain.Lark,
37
+ autoReconnect: false,
38
+ loggerLevel: lark.LoggerLevel.error,
39
+ onReady: () => {
40
+ if (cancelled) { client.close({ force: true }); return; }
41
+ ready = true;
42
+ settleReady();
43
+ },
44
+ onError: () => {
45
+ if (cancelled) return;
46
+ if (ready) disconnected();
47
+ else settleError(new Error('Lark long connection failed'));
48
+ },
49
+ });
50
+ const dispatcher = new lark.EventDispatcher({}).register({
51
+ 'card.action.trigger': (event: LarkCardAction) => { if (!cancelled) cardAction?.(event as LarkCardAction); return {}; },
52
+ 'im.message.receive_v1': (event) => {
53
+ if (!cancelled) receive({ ...event.message, sender: event.sender });
54
+ // No downstream send is awaited: SDK ACK remains within its 3-second window.
55
+ },
56
+ });
57
+ let healthTimer: ReturnType<typeof setInterval> | undefined;
58
+ return {
59
+ async connect() {
60
+ if (cancelled) throw new Error('Lark long connection stopped');
61
+ const deadline = setTimeout(() => settleError(new Error('Lark long connection handshake timed out')), 30_000);
62
+ try {
63
+ await client.start({ eventDispatcher: dispatcher });
64
+ await connected;
65
+ if (cancelled) { client.close({ force: true }); return; }
66
+ healthTimer = setInterval(() => {
67
+ if (!cancelled && ready && client.getConnectionStatus().state !== 'connected') {
68
+ ready = false;
69
+ disconnected();
70
+ }
71
+ }, 1_000);
72
+ healthTimer.unref();
73
+ } catch (error) {
74
+ client.close({ force: true });
75
+ agent?.destroy();
76
+ throw error;
77
+ } finally { clearTimeout(deadline); }
78
+ },
79
+ close() {
80
+ cancelled = true;
81
+ if (healthTimer) clearInterval(healthTimer);
82
+ settleError(new Error('Lark long connection stopped'));
83
+ client.close({ force: true });
84
+ agent?.destroy();
85
+ },
86
+ };
87
+ };
package/src/protocol.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateLarkWebApiProxy } from './web-api-proxy.js';
1
2
  /**
2
3
  * Lark/Feishu protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
4
  * Canonicalization is owned by gateway/core before endpoint.send.
@@ -5,14 +6,19 @@
5
6
 
6
7
  import { createHash, timingSafeEqual } from 'node:crypto';
7
8
  import type { IncomingMessage } from 'node:http';
8
- import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
9
+ import { EndpointDeliveryError, isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
9
10
  import { formatCompact, getLogger } from '@zhin.js/logger';
10
11
 
12
+ import { formatLarkCard } from './cards.js';
13
+
11
14
  const logger = getLogger('lark');
12
15
 
13
16
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
14
17
  export interface LarkEndpointConfig {
18
+ readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
19
+ readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
15
20
  readonly id: string;
21
+ readonly mode?: 'webhook' | 'websocket';
16
22
  readonly appId: string;
17
23
  readonly appSecret: string;
18
24
  readonly encryptKey?: string;
@@ -23,8 +29,11 @@ export interface LarkEndpointConfig {
23
29
  }
24
30
 
25
31
  export interface ResolvedLarkConfig {
32
+ readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
33
+ readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
26
34
  readonly context: 'lark';
27
35
  readonly id: string;
36
+ readonly mode: 'webhook' | 'websocket';
28
37
  readonly appId: string;
29
38
  readonly appSecret: string;
30
39
  readonly encryptKey?: string;
@@ -100,14 +109,20 @@ export interface LarkWireSegment {
100
109
  }
101
110
 
102
111
  export interface LarkSendBody {
112
+ readonly replyTo?: string;
103
113
  readonly msg_type: string;
104
114
  readonly content: string;
105
115
  }
106
116
 
107
117
  export function resolveLarkConfig(config: LarkEndpointConfig): ResolvedLarkConfig {
118
+ if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
119
+ || typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName))) throw new TypeError('Invalid Lark streamProxy port/serverName');
120
+ if (config.webApiProxy) validateLarkWebApiProxy(config.webApiProxy);
108
121
  const id = requiredEndpointField(config.id, 'id');
109
122
  const appId = requiredEndpointField(config.appId, 'appId');
110
123
  const appSecret = requiredEndpointField(config.appSecret, 'appSecret');
124
+ const mode = config.mode ?? 'webhook';
125
+ if (mode !== 'webhook' && mode !== 'websocket') throw new TypeError('Lark mode must be webhook or websocket');
111
126
  const isFeishu = config.isFeishu ?? true;
112
127
  const webhookPath = normalizeWebhookPath(config.webhookPath ?? '/lark/webhook');
113
128
  const defaultBase = isFeishu
@@ -116,10 +131,14 @@ export function resolveLarkConfig(config: LarkEndpointConfig): ResolvedLarkConfi
116
131
  const apiBaseUrl = (
117
132
  config.apiBaseUrl ?? defaultBase
118
133
  ).replace(/\/$/, '');
134
+ if (config.webApiProxy && (!isFeishu || apiBaseUrl !== 'https://open.feishu.cn/open-apis')) throw new TypeError('Lark webApiProxy requires the official Feishu API origin');
119
135
  const encryptKey = config.encryptKey;
120
136
  const verificationToken = config.verificationToken;
121
137
  return {
138
+ ...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
139
+ ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
122
140
  context: 'lark',
141
+ mode,
123
142
  id,
124
143
  appId,
125
144
  appSecret,
@@ -266,6 +285,7 @@ export function formatOutboundBody(payload: unknown): LarkSendBody {
266
285
  : payload && typeof payload === 'object' && 'type' in (payload as object)
267
286
  ? [payload as LarkWireSegment]
268
287
  : [];
288
+ if (items.some(item => typeof item !== 'string' && ['markdown', 'share', 'keyboard'].includes(item.type))) return formatLarkCard(items);
269
289
  const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
270
290
 
271
291
  if (items.length === 0) {
@@ -282,6 +302,7 @@ export function formatOutboundBody(payload: unknown): LarkSendBody {
282
302
 
283
303
  const textParts: string[] = [];
284
304
  let media: LarkSendBody | null = null;
305
+ let replyTo: string | undefined;
285
306
 
286
307
  for (const item of items) {
287
308
  if (typeof item === 'string') {
@@ -290,6 +311,12 @@ export function formatOutboundBody(payload: unknown): LarkSendBody {
290
311
  }
291
312
  const data = item.data ?? {};
292
313
  switch (item.type) {
314
+ case 'share':
315
+ case 'keyboard':
316
+ throw new EndpointDeliveryError('unsupported_operation', 'Lark canonical share cards and native keyboards are not implemented', 'not_sent');
317
+ case 'reply':
318
+ if (typeof data.message_id === 'string' && data.message_id.trim()) replyTo = data.message_id;
319
+ break;
293
320
  case 'text':
294
321
  textParts.push(String(data.content ?? data.text ?? ''));
295
322
  break;
@@ -349,9 +376,35 @@ export function formatOutboundBody(payload: unknown): LarkSendBody {
349
376
  }
350
377
  }
351
378
 
352
- if (media) return media;
379
+ const imageCount = items.filter((item) => typeof item !== 'string' && item.type === 'image').length;
380
+ if (imageCount > 0 && (textParts.some((text) => text.length > 0) || imageCount > 1)
381
+ && !items.some((item) => typeof item !== 'string' && (item.type === 'file' || item.type === 'card'))) {
382
+ const rows: Record<string, string>[][] = [];
383
+ for (const item of items) {
384
+ if (typeof item === 'string') { if (item) rows.push([{ tag: 'text', text: item }]); continue; }
385
+ const data = item.data ?? {};
386
+ if (item.type === 'reply') continue;
387
+ if (item.type === 'image') {
388
+ const ref = data.media;
389
+ if (isMediaRef(ref) && ref.kind === 'file') rows.push([{ tag: 'img', image_key: ref.value }]);
390
+ } else if (item.type === 'at' || item.type === 'mention') {
391
+ rows.push([{ tag: 'at', user_id: String(data.target ?? data.id ?? ''), user_name: String(data.name ?? data.target ?? data.id ?? '') }]);
392
+ } else {
393
+ const text = item.type === 'text' || item.type === 'markdown'
394
+ ? String(data.content ?? data.text ?? '') : `[${item.type}]`;
395
+ if (text) rows.push([{ tag: 'text', text }]);
396
+ }
397
+ }
398
+ return {
399
+ ...(replyTo ? { replyTo } : {}),
400
+ msg_type: 'post',
401
+ content: JSON.stringify({ zh_cn: { title: '', content: rows } }),
402
+ };
403
+ }
404
+ if (media) return { ...media, ...(replyTo ? { replyTo } : {}) };
353
405
  if (hasMarkdown) {
354
406
  return {
407
+ ...(replyTo ? { replyTo } : {}),
355
408
  msg_type: 'interactive',
356
409
  content: JSON.stringify({
357
410
  elements: [{
@@ -362,6 +415,7 @@ export function formatOutboundBody(payload: unknown): LarkSendBody {
362
415
  };
363
416
  }
364
417
  return {
418
+ ...(replyTo ? { replyTo } : {}),
365
419
  msg_type: 'text',
366
420
  content: JSON.stringify({ text: textParts.join('') }),
367
421
  };
@@ -0,0 +1,20 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { getLogger } from '@zhin.js/logger';
4
+
5
+ export interface LarkStreamProxy { readonly port: number; readonly serverName: string }
6
+
7
+ /** WSClient uses this agent only for WSS; discovery/OpenAPI stay unchanged. */
8
+ export function createLarkStreamAgent(proxy: LarkStreamProxy): Agent {
9
+ const agent = new Agent({ keepAlive: false });
10
+ agent.createConnection = options => {
11
+ const hostname = String(options.hostname ?? options.host ?? '');
12
+ if (hostname !== proxy.serverName || Number(options.port ?? 443) !== 443) {
13
+ getLogger('lark').warn(`Stream proxy rejected gateway hostname: ${hostname}`);
14
+ throw new Error('Lark Stream proxy gateway identity changed; update fixed upstream before retrying');
15
+ }
16
+ return tls.connect({ ...options as tls.ConnectionOptions, path: undefined, host: '127.0.0.1', port: proxy.port,
17
+ servername: hostname, rejectUnauthorized: true });
18
+ };
19
+ return agent;
20
+ }
@@ -0,0 +1,51 @@
1
+ import { Agent, fetch as dispatcherFetch } from 'undici';
2
+ import tls from 'node:tls';
3
+ import type { LarkFetch } from './endpoint.js';
4
+
5
+ export interface LarkWebApiProxy { readonly port: number }
6
+
7
+ export function validateLarkWebApiProxy(value: LarkWebApiProxy): void {
8
+ if (!value || typeof value !== 'object' || Object.keys(value).some(key => key !== 'port')
9
+ || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535) {
10
+ throw new TypeError('Invalid Lark webApiProxy: only a loopback port is allowed');
11
+ }
12
+ }
13
+
14
+ /** Per-endpoint fetch dispatcher; URL/Host/SNI remain the official Feishu identity. */
15
+ export function createLarkWebApiTransport(proxy: LarkWebApiProxy) {
16
+ validateLarkWebApiProxy(proxy);
17
+ const port = proxy.port;
18
+ const sockets = new Set<tls.TLSSocket>();
19
+ let active = true;
20
+ let dispatcher: Agent | undefined;
21
+ const fetch: LarkFetch = async (url, init) => {
22
+ const target = new URL(url);
23
+ if (target.origin !== 'https://open.feishu.cn' || target.username || target.password
24
+ || target.hash || !target.pathname.startsWith('/open-apis/')) {
25
+ throw new TypeError('Lark Web API proxy rejected destination identity');
26
+ }
27
+ if (!active) throw new Error('Lark Web API proxy transport is stopped');
28
+ dispatcher ??= new Agent({ connect(options, callback) {
29
+ if (options.hostname !== 'open.feishu.cn' || options.protocol !== 'https:' || Number(options.port || 443) !== 443) {
30
+ callback(new Error('Lark Web API proxy rejected destination identity'), null);
31
+ return;
32
+ }
33
+ const socket = tls.connect({ host: '127.0.0.1', port,
34
+ servername: 'open.feishu.cn', rejectUnauthorized: true });
35
+ sockets.add(socket);
36
+ socket.once('close', () => sockets.delete(socket));
37
+ socket.setTimeout(10_000, () => socket.destroy(new Error('Lark Web API TLS connection timeout')));
38
+ const failed = (error: Error) => callback(error, null);
39
+ socket.once('error', failed);
40
+ socket.once('secureConnect', () => { socket.setTimeout(0); socket.removeListener('error', failed); callback(null, socket); });
41
+ } });
42
+ // Pair fetch and its dispatcher from the same Undici version; Node's bundled
43
+ // handler protocol may differ (notably Node 26 versus Undici 6).
44
+ return dispatcherFetch(url, { ...init, redirect: 'error', dispatcher } as Parameters<typeof dispatcherFetch>[1]);
45
+ };
46
+ return {
47
+ fetch,
48
+ reopen() { active = true; },
49
+ async close() { active = false; for (const socket of sockets) socket.destroy(new Error('Lark Web API proxy transport stopped')); const previous = dispatcher; dispatcher = undefined; await previous?.destroy(); },
50
+ };
51
+ }