@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/CHANGELOG.md +49 -0
- package/README.md +58 -2
- package/adapters/lark/index.js +3 -3
- package/adapters/lark/index.ts +3 -3
- package/lib/cards.d.ts +23 -0
- package/lib/cards.js +77 -0
- package/lib/endpoint.d.ts +10 -5
- package/lib/endpoint.js +187 -53
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/long-connection.d.ts +16 -0
- package/lib/long-connection.js +88 -0
- package/lib/protocol.d.ts +7 -4
- package/lib/protocol.js +63 -2
- package/lib/stream-proxy.d.ts +7 -0
- package/lib/stream-proxy.js +17 -0
- package/lib/web-api-proxy.d.ts +11 -0
- package/lib/web-api-proxy.js +48 -0
- package/lib/webhook.d.ts +2 -0
- package/lib/webhook.js +13 -10
- package/package.json +19 -17
- package/schema.json +67 -0
- package/src/cards.ts +69 -0
- package/src/endpoint.ts +168 -53
- package/src/index.ts +7 -0
- package/src/long-connection.ts +87 -0
- package/src/protocol.ts +56 -2
- package/src/stream-proxy.ts +20 -0
- package/src/web-api-proxy.ts +51 -0
- package/src/webhook.ts +15 -9
package/src/endpoint.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
258
|
-
async uploadImage(media: Parameters<typeof resolveMediaBinary>[0]): Promise<string
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
547
|
-
throw new
|
|
641
|
+
const rejected = response.status >= 400 && response.status < 500 && response.status !== 408;
|
|
642
|
+
throw new LarkRequestError(response.status, platformCode, rejected);
|
|
548
643
|
}
|
|
549
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|