@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/lib/endpoint.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
266
|
+
/** Upload must succeed before issuing a message; dropped media is never confirmed. */
|
|
189
267
|
async uploadImage(media) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
458
|
-
throw new
|
|
572
|
+
const rejected = response.status >= 400 && response.status < 500 && response.status !== 408;
|
|
573
|
+
throw new LarkRequestError(response.status, platformCode, rejected);
|
|
459
574
|
}
|
|
460
|
-
|
|
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
|
|
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;
|