@zhin.js/adapter-lark 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/endpoint.js CHANGED
@@ -1,14 +1,16 @@
1
- import { Endpoint } from 'zhin.js/adapter';
1
+ import { createLarkWebApiTransport } from './web-api-proxy.js';
2
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
3
  /**
3
4
  * LarkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
4
5
  */
5
6
  import { readFile } from 'node:fs/promises';
6
7
  import { basename } from 'node:path';
7
- import { createRecallEndpointControl, } from 'zhin.js/adapter';
8
+ import { Endpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
8
9
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
10
  import { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
10
11
  import { formatInboundContent, formatOutboundBody, generateMessageId, larkInboundConversation, resolveChatType, resolveSender, } from './protocol.js';
11
12
  import { registerLarkWebhookRoutes } from './webhook.js';
13
+ import { createLarkLongConnection } from './long-connection.js';
12
14
  /** 出站 HTTP 调用统一 30s 超时。 */
13
15
  const OUTBOUND_TIMEOUT_MS = 30_000;
14
16
  /** Feishu/Lark OpenAPI client surface carried by every Endpoint event. */
@@ -46,6 +48,7 @@ export class LarkEndpoint extends Endpoint {
46
48
  #logger;
47
49
  #options;
48
50
  #fetch;
51
+ #webApiTransport;
49
52
  /**
50
53
  * Console 社交面语义端口。Lark OpenAPI 仅接读取类列表能力:
51
54
  * - listGroups:GET /im/v1/chats(bot 所在群列表,分页归一为 {group_id: chat_id, name})
@@ -76,11 +79,20 @@ export class LarkEndpoint extends Endpoint {
76
79
  #refreshPromise = null;
77
80
  #open = false;
78
81
  #started = false;
82
+ #lifecycle;
83
+ #cardSources = new Map();
84
+ #seen = new Map();
85
+ #startupGeneration = 0;
86
+ #pendingInbound = [];
79
87
  constructor(options) {
80
88
  super();
81
89
  this.#logger = getAdapterLogger('lark', options.config.id);
82
90
  this.#options = options;
83
- this.#fetch = options.fetch ?? globalThis.fetch;
91
+ if (options.config.webApiProxy && options.fetch)
92
+ throw new TypeError('Lark webApiProxy cannot be combined with an injected fetch');
93
+ this.#webApiTransport = options.config.webApiProxy ? createLarkWebApiTransport(options.config.webApiProxy) : undefined;
94
+ this.#fetch = this.#webApiTransport?.fetch ?? options.fetch ?? globalThis.fetch;
95
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
84
96
  }
85
97
  /** Used by webhook handler. */
86
98
  get isOpen() {
@@ -89,11 +101,41 @@ export class LarkEndpoint extends Endpoint {
89
101
  get config() {
90
102
  return this.#options.config;
91
103
  }
104
+ get transportState() {
105
+ return this.config.mode === 'websocket' ? this.#lifecycle.state : undefined;
106
+ }
92
107
  async start() {
93
108
  if (this.#started)
94
109
  return;
110
+ this.#webApiTransport?.reopen();
95
111
  this.#started = true;
112
+ const generation = ++this.#startupGeneration;
96
113
  try {
114
+ if (this.config.mode === 'websocket') {
115
+ await this.#lifecycle.start(async (handle) => {
116
+ let active = true;
117
+ const transport = (this.#options.longConnectionFactory ?? createLarkLongConnection)({
118
+ config: this.config,
119
+ receive: (message) => {
120
+ if (!active || !this.#started || generation !== this.#startupGeneration)
121
+ return;
122
+ if (this.#open)
123
+ this.admit(message);
124
+ else if (this.#pendingInbound.length < 100)
125
+ this.#pendingInbound.push(message);
126
+ else
127
+ this.#logger.warn('lark_inbound_startup_buffer_full');
128
+ },
129
+ cardAction: (event) => { if (active && this.#open && generation === this.#startupGeneration)
130
+ this.admitCardAction(event); },
131
+ disconnected: () => { if (!active)
132
+ return; active = false; transport.close(); handle.notifyClosed(); },
133
+ });
134
+ handle.onForceClose(() => { active = false; transport.close(); });
135
+ await transport.connect();
136
+ });
137
+ return;
138
+ }
97
139
  if (!this.#options.config.encryptKey && !this.#options.config.verificationToken) {
98
140
  // encryptKey / verificationToken 都未配置时 webhook 完全无鉴权,任何人可伪造事件
99
141
  this.#logger.warn(formatCompact({
@@ -104,6 +146,10 @@ export class LarkEndpoint extends Endpoint {
104
146
  }));
105
147
  }
106
148
  await this.#refreshAccessToken();
149
+ if (!this.#started || generation !== this.#startupGeneration)
150
+ return;
151
+ if (!this.#options.http)
152
+ throw new Error('Lark webhook mode requires HttpHost');
107
153
  this.#routeReleases.push(...registerLarkWebhookRoutes(this.#options.http, this));
108
154
  this.#logger.debug(formatCompact({
109
155
  endpoint: this.#options.config.id,
@@ -119,47 +165,81 @@ export class LarkEndpoint extends Endpoint {
119
165
  }
120
166
  open() {
121
167
  this.#open = true;
168
+ for (const message of this.#pendingInbound.splice(0))
169
+ this.admit(message);
122
170
  }
123
171
  close() {
124
172
  this.#open = false;
125
173
  }
126
174
  async stop() {
127
175
  this.#open = false;
176
+ this.#pendingInbound.length = 0;
177
+ this.#cardSources.clear();
178
+ this.#started = false;
179
+ ++this.#startupGeneration;
180
+ await this.#lifecycle.stop();
181
+ await this.#webApiTransport?.close();
128
182
  for (const release of this.#routeReleases.splice(0))
129
183
  release();
130
184
  this.#started = false;
131
185
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
132
186
  }
133
187
  async send({ conversation, payload }) {
134
- const materialized = await this.#materializeOutboundMedia(payload);
135
- const content = formatOutboundBody(materialized);
136
- const data = await this.#request('/im/v1/messages', {
137
- method: 'POST',
138
- params: { receive_id_type: 'chat_id' },
139
- body: {
140
- receive_id: conversation.id,
141
- msg_type: content.msg_type,
142
- content: content.content,
143
- },
144
- });
145
- if (data.code !== 0) {
146
- throw new Error(`Failed to send message: ${data.msg}`);
188
+ try {
189
+ const materialized = await this.#materializeOutboundMedia(payload);
190
+ const content = formatOutboundBody(materialized);
191
+ const data = await this.#request(content.replyTo
192
+ ? `/im/v1/messages/${encodeURIComponent(content.replyTo)}/reply`
193
+ : '/im/v1/messages', {
194
+ method: 'POST',
195
+ ...(content.replyTo ? {} : { params: { receive_id_type: 'chat_id' } }),
196
+ body: {
197
+ ...(content.replyTo ? {} : { receive_id: conversation.id }),
198
+ msg_type: content.msg_type,
199
+ content: content.content,
200
+ },
201
+ });
202
+ if (typeof data.code === 'number' && data.code !== 0) {
203
+ throw new EndpointDeliveryError('platform_rejected', `Lark rejected message (code=${data.code})`, 'rejected');
204
+ }
205
+ if (data.code !== 0)
206
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Malformed platform response', 'unknown');
207
+ const messageId = requireMessageId(data.data?.message_id);
208
+ if (content.msg_type === 'interactive') {
209
+ const now = Date.now();
210
+ for (const [id, source] of this.#cardSources)
211
+ if (now - source.time > 300_000)
212
+ this.#cardSources.delete(id);
213
+ this.#cardSources.set(messageId, { conversation, time: now });
214
+ if (this.#cardSources.size > 10_000)
215
+ this.#cardSources.delete(this.#cardSources.keys().next().value);
216
+ }
217
+ this.#logger.debug(formatCompact({ op: 'send', to: conversation.id,
218
+ id: messageId,
219
+ }));
220
+ return messageId;
221
+ }
222
+ catch (error) {
223
+ this.#logger.warn(formatCompact({
224
+ op: 'lark_send_failed',
225
+ status: error instanceof LarkRequestError ? error.status : undefined,
226
+ platform_code: error instanceof LarkRequestError ? error.platformCode : undefined,
227
+ outcome: error instanceof EndpointDeliveryError ? error.disposition : 'unknown',
228
+ }));
229
+ if (error instanceof EndpointDeliveryError)
230
+ throw error;
231
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
147
232
  }
148
- const messageId = data.data?.message_id || `${Date.now()}`;
149
- this.#logger.debug(formatCompact({ op: 'send', to: conversation.id,
150
- id: messageId,
151
- }));
152
- return messageId;
153
233
  }
154
234
  async recallMessage(messageId) {
155
235
  if (!messageId || messageId.startsWith('outbound:'))
156
236
  return;
157
- await this.#request(`/im/v1/messages/${messageId}`, { method: 'DELETE' });
237
+ await this.#request(`/im/v1/messages/${encodeURIComponent(messageId)}`, { method: 'DELETE' });
158
238
  }
159
239
  /**
160
240
  * im/v1 消息 image 段只接受 image_key:canonical MediaRef(base64/本地路径/URL)
161
241
  * 先经 /im/v1/images 物化,产物回写为 MediaRef kind=file(平台不透明引用);
162
- * 上传失败降级为文本(alt 优先),不阻断发送。
242
+ * 上传失败阻止消息发送并保留拒绝或未知分类。
163
243
  */
164
244
  async #materializeOutboundMedia(payload) {
165
245
  if (!Array.isArray(payload))
@@ -175,44 +255,51 @@ export class LarkEndpoint extends Endpoint {
175
255
  if (!media)
176
256
  return item;
177
257
  const imageKey = await this.uploadImage(media);
178
- if (!imageKey) {
179
- const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
180
- return { type: 'text', data: { text: alt } };
181
- }
258
+ if (!imageKey)
259
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Lark image upload returned no image key', 'unknown');
182
260
  return {
183
261
  type: 'image',
184
262
  data: { media: { kind: 'file', value: imageKey } },
185
263
  };
186
264
  }));
187
265
  }
188
- /** POST /im/v1/images(image_type=message),返回 image_key;失败返回 null。 */
266
+ /** Upload must succeed before issuing a message; dropped media is never confirmed. */
189
267
  async uploadImage(media) {
190
- try {
191
- await this.#ensureAccessToken();
192
- const binary = await resolveMediaBinary(media);
193
- const form = buildImageUploadForm(binary);
194
- const url = `${this.#options.config.apiBaseUrl}/im/v1/images`;
195
- const response = await this.#fetch(url, {
196
- method: 'POST',
197
- headers: { Authorization: `Bearer ${this.#accessToken.token}` },
198
- body: form,
199
- signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
200
- });
201
- const data = await response.json();
202
- if (data.code === 0) {
203
- return data.data?.image_key || null;
204
- }
205
- throw new Error(`Image upload failed: ${data.msg}`);
206
- }
207
- catch (error) {
208
- this.#logger.error('Failed to upload image:', error);
209
- return null;
210
- }
268
+ await this.#ensureAccessToken();
269
+ const binary = await resolveMediaBinary(media);
270
+ const form = buildImageUploadForm(binary);
271
+ const response = await this.#fetch(`${this.#options.config.apiBaseUrl}/im/v1/images`, {
272
+ method: 'POST',
273
+ headers: { Authorization: `Bearer ${this.#accessToken.token}` },
274
+ body: form,
275
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
276
+ });
277
+ const data = await response.json().catch(() => undefined);
278
+ const code = typeof data?.code === 'number' && Number.isFinite(data.code) ? data.code : undefined;
279
+ if (!response.ok)
280
+ throw new LarkRequestError(response.status, code, response.status >= 400 && response.status < 500 && response.status !== 408);
281
+ if (code !== 0)
282
+ throw new LarkRequestError(response.status, code, code !== undefined);
283
+ const key = data?.data?.image_key;
284
+ if (typeof key !== 'string' || !key.trim())
285
+ throw new LarkRequestError(response.status, code, false);
286
+ return key;
211
287
  }
212
288
  /** Test / internal: admit a parsed message when open (non-webhook path). */
213
289
  admit(msg) {
214
290
  if (!this.#open)
215
291
  return;
292
+ if (msg.message_id) {
293
+ const now = Date.now();
294
+ for (const [id, time] of this.#seen)
295
+ if (now - time > 300_000)
296
+ this.#seen.delete(id);
297
+ if (this.#seen.has(msg.message_id))
298
+ return;
299
+ this.#seen.set(msg.message_id, now);
300
+ if (this.#seen.size > 10_000)
301
+ this.#seen.delete(this.#seen.keys().next().value);
302
+ }
216
303
  const conversation = larkInboundConversation(String(this.#options.id), msg);
217
304
  void this.emit('message.receive', {
218
305
  conversation,
@@ -232,6 +319,32 @@ export class LarkEndpoint extends Endpoint {
232
319
  }));
233
320
  });
234
321
  }
322
+ /** Only correlate callbacks to a card actually sent by this endpoint. */
323
+ admitCardAction(event) {
324
+ if (!this.#open)
325
+ return;
326
+ const sourceId = event.context?.open_message_id ?? event.open_message_id;
327
+ const chatId = event.context?.open_chat_id ?? event.open_chat_id;
328
+ const sender = event.operator?.open_id ?? event.operator?.user_id;
329
+ const payload = event.action?.value?.zhin_payload;
330
+ if (!sourceId || !chatId || !sender || typeof payload !== 'string' || event.action?.tag !== 'button')
331
+ return;
332
+ const source = this.#cardSources.get(sourceId);
333
+ if (!source || source.conversation.id !== chatId || Date.now() - source.time > 300_000)
334
+ return;
335
+ const dedup = `card:${event.token ?? `${sourceId}:${sender}:${payload}`}`;
336
+ if (this.#seen.has(dedup))
337
+ return;
338
+ this.#seen.set(dedup, Date.now());
339
+ if (this.#seen.size > 10_000)
340
+ this.#seen.delete(this.#seen.keys().next().value);
341
+ const conversation = source.conversation;
342
+ void this.emit('message.receive', {
343
+ conversation, message: { conversation, id: dedup }, content: '',
344
+ segments: [{ type: 'action', data: { payload } }], sender: { id: sender }, endpointId: this.config.id,
345
+ metadata: Object.freeze({ eventType: 'card.action.trigger', sourceMessageId: sourceId }),
346
+ }).catch(() => this.#logger.warn('lark_card_action_receive_failed'));
347
+ }
235
348
  admitPlatform(name, event) {
236
349
  if (!this.#open)
237
350
  return;
@@ -453,11 +566,17 @@ export class LarkEndpoint extends Endpoint {
453
566
  body: body && method !== 'GET' ? JSON.stringify(body) : undefined,
454
567
  signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
455
568
  });
569
+ const data = await response.json().catch(() => undefined);
570
+ const platformCode = typeof data?.code === 'number' && Number.isFinite(data.code) ? data.code : undefined;
456
571
  if (!response.ok) {
457
- const text = await response.text().catch(() => '');
458
- throw new Error(`Lark API error ${response.status}: ${text}`);
572
+ const rejected = response.status >= 400 && response.status < 500 && response.status !== 408;
573
+ throw new LarkRequestError(response.status, platformCode, rejected);
459
574
  }
460
- return await response.json();
575
+ if (platformCode === undefined)
576
+ throw new LarkRequestError(response.status, undefined, false);
577
+ if (platformCode !== 0)
578
+ throw new LarkRequestError(response.status, platformCode, true);
579
+ return data;
461
580
  }
462
581
  async #ensureAccessToken() {
463
582
  const now = Date.now();
@@ -492,6 +611,21 @@ export class LarkEndpoint extends Endpoint {
492
611
  this.#logger.debug('Access token refreshed successfully');
493
612
  return;
494
613
  }
495
- throw new Error(`Failed to get access token: ${data.msg} (${data.code})`);
614
+ 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);
615
+ }
616
+ }
617
+ function requireMessageId(value) {
618
+ if (typeof value === 'string' && value.trim().length > 0)
619
+ return value;
620
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
621
+ }
622
+ /** Carries only safe numerical diagnostics, never response bodies or credentials. */
623
+ class LarkRequestError extends EndpointDeliveryError {
624
+ status;
625
+ platformCode;
626
+ constructor(status, platformCode, rejected) {
627
+ super(rejected ? 'platform_rejected' : 'delivery_unconfirmed', `Lark request failed (status=${status}${platformCode === undefined ? '' : `, code=${platformCode}`})`, rejected ? 'rejected' : 'unknown');
628
+ this.status = status;
629
+ this.platformCode = platformCode;
496
630
  }
497
631
  }
package/lib/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export { checkLarkPlatformPermit, larkGroupPermitResolver, normalizeLarkSenderFo
4
4
  export { LarkClient, LarkEndpoint, type LarkClientApi, type LarkEndpointOptions, type LarkFetch, } from './endpoint.js';
5
5
  export { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, type MediaBinary, } from './media-upload.js';
6
6
  export { registerLarkWebhookRoutes, handleLarkWebhookRequest, type LarkWebhookHandler, } from './webhook.js';
7
+ export { createLarkLongConnection, type LarkLongConnection, type LarkLongConnectionFactory, type LarkLongConnectionOptions, } from './long-connection.js';
package/lib/index.js CHANGED
@@ -4,3 +4,4 @@ export { checkLarkPlatformPermit, larkGroupPermitResolver, normalizeLarkSenderFo
4
4
  export { LarkClient, LarkEndpoint, } from './endpoint.js';
5
5
  export { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
6
6
  export { registerLarkWebhookRoutes, handleLarkWebhookRequest, } from './webhook.js';
7
+ export { createLarkLongConnection, } from './long-connection.js';
@@ -0,0 +1,16 @@
1
+ import type { LarkCardAction } from './cards.js';
2
+ import type { LarkMessage, ResolvedLarkConfig } from './protocol.js';
3
+ export interface LarkLongConnection {
4
+ /** Resolves only after the transport handshake, never merely after SDK start(). */
5
+ connect(): Promise<void>;
6
+ close(): void;
7
+ }
8
+ export interface LarkLongConnectionOptions {
9
+ readonly config: ResolvedLarkConfig;
10
+ readonly receive: (message: LarkMessage) => void;
11
+ readonly cardAction?: (event: LarkCardAction) => void;
12
+ readonly disconnected: () => void;
13
+ }
14
+ export type LarkLongConnectionFactory = (options: LarkLongConnectionOptions) => LarkLongConnection;
15
+ /** The SDK owns wire framing / acknowledgements / ping; Zhin owns reconnect. */
16
+ export declare const createLarkLongConnection: LarkLongConnectionFactory;
@@ -0,0 +1,88 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk';
2
+ import { createLarkStreamAgent } from './stream-proxy.js';
3
+ /** The SDK owns wire framing / acknowledgements / ping; Zhin owns reconnect. */
4
+ export const createLarkLongConnection = ({ config, receive, cardAction, disconnected }) => {
5
+ let cancelled = false;
6
+ let ready = false;
7
+ let settleReady;
8
+ let settleError;
9
+ const connected = new Promise((resolve, reject) => {
10
+ settleReady = resolve;
11
+ settleError = reject;
12
+ });
13
+ // close-before-connect must never create an unhandled rejection.
14
+ connected.catch(() => { });
15
+ const agent = config.streamProxy ? createLarkStreamAgent(config.streamProxy) : undefined;
16
+ const client = new lark.WSClient({
17
+ appId: config.appId,
18
+ appSecret: config.appSecret,
19
+ agent,
20
+ domain: config.isFeishu ? lark.Domain.Feishu : lark.Domain.Lark,
21
+ autoReconnect: false,
22
+ loggerLevel: lark.LoggerLevel.error,
23
+ onReady: () => {
24
+ if (cancelled) {
25
+ client.close({ force: true });
26
+ return;
27
+ }
28
+ ready = true;
29
+ settleReady();
30
+ },
31
+ onError: () => {
32
+ if (cancelled)
33
+ return;
34
+ if (ready)
35
+ disconnected();
36
+ else
37
+ settleError(new Error('Lark long connection failed'));
38
+ },
39
+ });
40
+ const dispatcher = new lark.EventDispatcher({}).register({
41
+ 'card.action.trigger': (event) => { if (!cancelled)
42
+ cardAction?.(event); return {}; },
43
+ 'im.message.receive_v1': (event) => {
44
+ if (!cancelled)
45
+ receive({ ...event.message, sender: event.sender });
46
+ // No downstream send is awaited: SDK ACK remains within its 3-second window.
47
+ },
48
+ });
49
+ let healthTimer;
50
+ return {
51
+ async connect() {
52
+ if (cancelled)
53
+ throw new Error('Lark long connection stopped');
54
+ const deadline = setTimeout(() => settleError(new Error('Lark long connection handshake timed out')), 30_000);
55
+ try {
56
+ await client.start({ eventDispatcher: dispatcher });
57
+ await connected;
58
+ if (cancelled) {
59
+ client.close({ force: true });
60
+ return;
61
+ }
62
+ healthTimer = setInterval(() => {
63
+ if (!cancelled && ready && client.getConnectionStatus().state !== 'connected') {
64
+ ready = false;
65
+ disconnected();
66
+ }
67
+ }, 1_000);
68
+ healthTimer.unref();
69
+ }
70
+ catch (error) {
71
+ client.close({ force: true });
72
+ agent?.destroy();
73
+ throw error;
74
+ }
75
+ finally {
76
+ clearTimeout(deadline);
77
+ }
78
+ },
79
+ close() {
80
+ cancelled = true;
81
+ if (healthTimer)
82
+ clearInterval(healthTimer);
83
+ settleError(new Error('Lark long connection stopped'));
84
+ client.close({ force: true });
85
+ agent?.destroy();
86
+ },
87
+ };
88
+ };
package/lib/protocol.d.ts CHANGED
@@ -1,12 +1,11 @@
1
- /**
2
- * Lark/Feishu protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
- * Canonicalization is owned by gateway/core before endpoint.send.
4
- */
5
1
  import type { IncomingMessage } from 'node:http';
6
2
  import { type ConversationRef } from '@zhin.js/im-contract';
7
3
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
8
4
  export interface LarkEndpointConfig {
5
+ readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
6
+ readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
9
7
  readonly id: string;
8
+ readonly mode?: 'webhook' | 'websocket';
10
9
  readonly appId: string;
11
10
  readonly appSecret: string;
12
11
  readonly encryptKey?: string;
@@ -16,8 +15,11 @@ export interface LarkEndpointConfig {
16
15
  readonly isFeishu?: boolean;
17
16
  }
18
17
  export interface ResolvedLarkConfig {
18
+ readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
19
+ readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
19
20
  readonly context: 'lark';
20
21
  readonly id: string;
22
+ readonly mode: 'webhook' | 'websocket';
21
23
  readonly appId: string;
22
24
  readonly appSecret: string;
23
25
  readonly encryptKey?: string;
@@ -87,6 +89,7 @@ export interface LarkWireSegment {
87
89
  readonly data?: Record<string, unknown>;
88
90
  }
89
91
  export interface LarkSendBody {
92
+ readonly replyTo?: string;
90
93
  readonly msg_type: string;
91
94
  readonly content: string;
92
95
  }
package/lib/protocol.js CHANGED
@@ -1,25 +1,40 @@
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.
4
5
  */
5
6
  import { createHash, timingSafeEqual } from 'node:crypto';
6
- import { isMediaRef } from '@zhin.js/im-contract';
7
+ import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
7
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import { formatLarkCard } from './cards.js';
8
10
  const logger = getLogger('lark');
9
11
  export function resolveLarkConfig(config) {
12
+ if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
13
+ || typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName)))
14
+ throw new TypeError('Invalid Lark streamProxy port/serverName');
15
+ if (config.webApiProxy)
16
+ validateLarkWebApiProxy(config.webApiProxy);
10
17
  const id = requiredEndpointField(config.id, 'id');
11
18
  const appId = requiredEndpointField(config.appId, 'appId');
12
19
  const appSecret = requiredEndpointField(config.appSecret, 'appSecret');
20
+ const mode = config.mode ?? 'webhook';
21
+ if (mode !== 'webhook' && mode !== 'websocket')
22
+ throw new TypeError('Lark mode must be webhook or websocket');
13
23
  const isFeishu = config.isFeishu ?? true;
14
24
  const webhookPath = normalizeWebhookPath(config.webhookPath ?? '/lark/webhook');
15
25
  const defaultBase = isFeishu
16
26
  ? 'https://open.feishu.cn/open-apis'
17
27
  : 'https://open.larksuite.com/open-apis';
18
28
  const apiBaseUrl = (config.apiBaseUrl ?? defaultBase).replace(/\/$/, '');
29
+ if (config.webApiProxy && (!isFeishu || apiBaseUrl !== 'https://open.feishu.cn/open-apis'))
30
+ throw new TypeError('Lark webApiProxy requires the official Feishu API origin');
19
31
  const encryptKey = config.encryptKey;
20
32
  const verificationToken = config.verificationToken;
21
33
  return {
34
+ ...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
35
+ ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
22
36
  context: 'lark',
37
+ mode,
23
38
  id,
24
39
  appId,
25
40
  appSecret,
@@ -152,6 +167,8 @@ export function formatOutboundBody(payload) {
152
167
  : payload && typeof payload === 'object' && 'type' in payload
153
168
  ? [payload]
154
169
  : [];
170
+ if (items.some(item => typeof item !== 'string' && ['markdown', 'share', 'keyboard'].includes(item.type)))
171
+ return formatLarkCard(items);
155
172
  const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
156
173
  if (items.length === 0) {
157
174
  const text = payload == null
@@ -166,6 +183,7 @@ export function formatOutboundBody(payload) {
166
183
  }
167
184
  const textParts = [];
168
185
  let media = null;
186
+ let replyTo;
169
187
  for (const item of items) {
170
188
  if (typeof item === 'string') {
171
189
  textParts.push(item);
@@ -173,6 +191,13 @@ export function formatOutboundBody(payload) {
173
191
  }
174
192
  const data = item.data ?? {};
175
193
  switch (item.type) {
194
+ case 'share':
195
+ case 'keyboard':
196
+ throw new EndpointDeliveryError('unsupported_operation', 'Lark canonical share cards and native keyboards are not implemented', 'not_sent');
197
+ case 'reply':
198
+ if (typeof data.message_id === 'string' && data.message_id.trim())
199
+ replyTo = data.message_id;
200
+ break;
176
201
  case 'text':
177
202
  textParts.push(String(data.content ?? data.text ?? ''));
178
203
  break;
@@ -231,10 +256,45 @@ export function formatOutboundBody(payload) {
231
256
  textParts.push(`[${item.type}]`);
232
257
  }
233
258
  }
259
+ const imageCount = items.filter((item) => typeof item !== 'string' && item.type === 'image').length;
260
+ if (imageCount > 0 && (textParts.some((text) => text.length > 0) || imageCount > 1)
261
+ && !items.some((item) => typeof item !== 'string' && (item.type === 'file' || item.type === 'card'))) {
262
+ const rows = [];
263
+ for (const item of items) {
264
+ if (typeof item === 'string') {
265
+ if (item)
266
+ rows.push([{ tag: 'text', text: item }]);
267
+ continue;
268
+ }
269
+ const data = item.data ?? {};
270
+ if (item.type === 'reply')
271
+ continue;
272
+ if (item.type === 'image') {
273
+ const ref = data.media;
274
+ if (isMediaRef(ref) && ref.kind === 'file')
275
+ rows.push([{ tag: 'img', image_key: ref.value }]);
276
+ }
277
+ else if (item.type === 'at' || item.type === 'mention') {
278
+ rows.push([{ tag: 'at', user_id: String(data.target ?? data.id ?? ''), user_name: String(data.name ?? data.target ?? data.id ?? '') }]);
279
+ }
280
+ else {
281
+ const text = item.type === 'text' || item.type === 'markdown'
282
+ ? String(data.content ?? data.text ?? '') : `[${item.type}]`;
283
+ if (text)
284
+ rows.push([{ tag: 'text', text }]);
285
+ }
286
+ }
287
+ return {
288
+ ...(replyTo ? { replyTo } : {}),
289
+ msg_type: 'post',
290
+ content: JSON.stringify({ zh_cn: { title: '', content: rows } }),
291
+ };
292
+ }
234
293
  if (media)
235
- return media;
294
+ return { ...media, ...(replyTo ? { replyTo } : {}) };
236
295
  if (hasMarkdown) {
237
296
  return {
297
+ ...(replyTo ? { replyTo } : {}),
238
298
  msg_type: 'interactive',
239
299
  content: JSON.stringify({
240
300
  elements: [{
@@ -245,6 +305,7 @@ export function formatOutboundBody(payload) {
245
305
  };
246
306
  }
247
307
  return {
308
+ ...(replyTo ? { replyTo } : {}),
248
309
  msg_type: 'text',
249
310
  content: JSON.stringify({ text: textParts.join('') }),
250
311
  };
@@ -0,0 +1,7 @@
1
+ import { Agent } from 'node:https';
2
+ export interface LarkStreamProxy {
3
+ readonly port: number;
4
+ readonly serverName: string;
5
+ }
6
+ /** WSClient uses this agent only for WSS; discovery/OpenAPI stay unchanged. */
7
+ export declare function createLarkStreamAgent(proxy: LarkStreamProxy): Agent;