@zhin.js/adapter-lark 4.0.1 → 4.0.3

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +59 -84
  3. package/adapters/lark.ts +26 -0
  4. package/agent/tools/add_members.ts +22 -0
  5. package/agent/tools/create_chat.ts +20 -0
  6. package/agent/tools/dissolve_chat.ts +18 -0
  7. package/agent/tools/get_user.ts +18 -0
  8. package/agent/tools/remove_managers.ts +22 -0
  9. package/agent/tools/set_managers.ts +22 -0
  10. package/agent/tools/update_chat.ts +23 -0
  11. package/agent/tools/upload_file.ts +27 -0
  12. package/lib/endpoint.d.ts +41 -72
  13. package/lib/endpoint.js +219 -563
  14. package/lib/index.d.ts +5 -15
  15. package/lib/index.js +5 -216
  16. package/lib/lark-agent-deps.d.ts +27 -0
  17. package/lib/lark-agent-deps.js +30 -0
  18. package/lib/platform-permit.d.ts +1 -2
  19. package/lib/platform-permit.js +4 -2
  20. package/lib/protocol.d.ts +111 -0
  21. package/lib/protocol.js +204 -0
  22. package/lib/webhook.d.ts +13 -0
  23. package/lib/webhook.js +67 -0
  24. package/package.json +50 -21
  25. package/plugin.ts +12 -0
  26. package/schema.json +25 -0
  27. package/src/endpoint.ts +354 -705
  28. package/src/index.ts +46 -236
  29. package/src/lark-agent-deps.ts +62 -0
  30. package/src/platform-permit.ts +1 -2
  31. package/src/protocol.ts +332 -0
  32. package/src/webhook.ts +101 -0
  33. package/lib/adapter.d.ts +0 -20
  34. package/lib/adapter.d.ts.map +0 -1
  35. package/lib/adapter.js +0 -48
  36. package/lib/adapter.js.map +0 -1
  37. package/lib/endpoint.d.ts.map +0 -1
  38. package/lib/endpoint.js.map +0 -1
  39. package/lib/index.d.ts.map +0 -1
  40. package/lib/index.js.map +0 -1
  41. package/lib/platform-permit.d.ts.map +0 -1
  42. package/lib/platform-permit.js.map +0 -1
  43. package/lib/segment-mapper.d.ts +0 -2
  44. package/lib/segment-mapper.d.ts.map +0 -1
  45. package/lib/segment-mapper.js +0 -2
  46. package/lib/segment-mapper.js.map +0 -1
  47. package/lib/types.d.ts +0 -63
  48. package/lib/types.d.ts.map +0 -1
  49. package/lib/types.js +0 -2
  50. package/lib/types.js.map +0 -1
  51. package/plugin.yml +0 -3
  52. package/src/adapter.ts +0 -57
  53. package/src/segment-mapper.ts +0 -1
  54. package/src/types.ts +0 -58
  55. /package/{skills/lark → agent}/PERMITS.md +0 -0
  56. /package/{skills/lark/SKILL.md → agent/skills/lark.md} +0 -0
package/src/endpoint.ts CHANGED
@@ -1,749 +1,398 @@
1
1
  /**
2
- * 飞书/Lark Endpoint 实现
2
+ * LarkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
3
3
  */
4
- import { registerFetchRoute, type Router, type RouterContext } from "@zhin.js/host-router/router";
5
- import axios, { type AxiosInstance } from "axios";
6
- import { createHash } from "crypto";
7
- import { formatCompact, Endpoint, Message, MessageSegment, segment, SendContent, SendOptions, expandInteractiveSegmentsInContent,} from 'zhin.js';
8
- import type { LarkEndpointConfig, LarkMessage, LarkEvent, AccessToken } from "./types.js";
9
- import type { LarkAdapter } from "./adapter.js";
10
- import { normalizeLarkSenderForPermit } from "./platform-permit.js";
11
- import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
12
-
13
- export class LarkEndpoint implements Endpoint<LarkEndpointConfig, LarkMessage> {
14
- $connected: boolean
15
- private router: any
16
- private accessToken: AccessToken
17
- private axiosInstance: AxiosInstance
4
+ import { readFile } from 'node:fs/promises';
5
+ import { basename } from 'node:path';
6
+ import type { EndpointInstance } from '@zhin.js/adapter';
7
+ import type { MessageGateway } from '@zhin.js/core/runtime';
8
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
9
+ import { formatCompact, getLogger } from '@zhin.js/logger';
10
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
11
+ import { registerLarkAgentEndpoint } from './lark-agent-deps.js';
12
+ import {
13
+ formatInboundContent,
14
+ formatOutboundBody,
15
+ generateMessageId,
16
+ resolveChatType,
17
+ resolveSender,
18
+ resolveTarget,
19
+ type AccessToken,
20
+ type LarkApiResponse,
21
+ type LarkMessage,
22
+ type ResolvedLarkConfig,
23
+ } from './protocol.js';
24
+ import { registerLarkWebhookRoutes } from './webhook.js';
25
+
26
+ const logger = getLogger('lark');
27
+
28
+ export type LarkFetch = (
29
+ url: string,
30
+ init?: {
31
+ readonly method?: string;
32
+ readonly headers?: Record<string, string>;
33
+ readonly body?: string | FormData;
34
+ },
35
+ ) => Promise<{
36
+ readonly ok: boolean;
37
+ readonly status: number;
38
+ text(): Promise<string>;
39
+ json(): Promise<unknown>;
40
+ }>;
41
+
42
+ export interface LarkEndpointOptions {
43
+ readonly id: CapabilityId;
44
+ readonly gateway: MessageGateway;
45
+ readonly http: HttpHost;
46
+ readonly config: ResolvedLarkConfig;
47
+ readonly fetch?: LarkFetch;
48
+ }
18
49
 
19
- get logger() {
20
- return this.adapter.plugin.logger;
50
+ export class LarkEndpoint implements EndpointInstance {
51
+ readonly #options: LarkEndpointOptions;
52
+ readonly #fetch: LarkFetch;
53
+ #routeReleases: HttpRouteRegistration[] = [];
54
+ #accessToken: AccessToken = { token: '', expires_in: 0, timestamp: 0 };
55
+ #refreshPromise: Promise<string> | null = null;
56
+ #open = false;
57
+ #started = false;
58
+ #unregisterAgent?: () => void;
59
+
60
+ constructor(options: LarkEndpointOptions) {
61
+ this.#options = options;
62
+ this.#fetch = options.fetch ?? globalThis.fetch;
21
63
  }
22
64
 
23
- get $id() {
24
- return this.$config.name;
25
- }
26
-
27
- constructor(public adapter: LarkAdapter, router: any, public $config: LarkEndpointConfig) {
28
- this.router = router;
29
- this.$connected = false;
30
- this.accessToken = { token: '', expires_in: 0, timestamp: 0 };
31
-
32
- // 设置 API 基础 URL
33
- const baseURL = $config.apiBaseUrl || ($config.isFeishu ?
34
- 'https://open.feishu.cn/open-apis' :
35
- 'https://open.larksuite.com/open-apis'
36
- );
37
-
38
- this.axiosInstance = axios.create({
39
- baseURL,
40
- timeout: 30000,
41
- headers: {
42
- 'Content-Type': 'application/json; charset=utf-8'
43
- }
44
- });
45
-
46
- // 设置请求拦截器,自动添加 access_token
47
- this.axiosInstance.interceptors.request.use(async (config) => {
48
- await this.ensureAccessToken();
49
- config.headers = config.headers ?? {};
50
- config.headers['Authorization'] = `Bearer ${this.accessToken.token}`;
51
- return config;
52
- });
53
-
54
- // 设置 webhook 路由
55
- this.setupWebhookRoute();
56
- }
57
-
58
- private setupWebhookRoute(): void {
59
- registerFetchRoute(this.router, "POST", this.$config.webhookPath, (ctx: RouterContext) => {
60
- void this.handleWebhook(ctx);
61
- });
62
- }
63
-
64
- private async handleWebhook(ctx: RouterContext): Promise<void> {
65
- try {
66
- const body = ctx.request.body;
67
-
68
- // 验证请求(如果配置了验证令牌)
69
- if (this.$config.verificationToken) {
70
- const token = ctx.get('x-lark-request-token');
71
- if (token !== this.$config.verificationToken) {
72
- this.logger.warn(formatCompact( { op: 'webhook', ok: false, error: 'invalid verification token' }));
73
- ctx.status = 403;
74
- ctx.body = 'Forbidden';
75
- return;
76
- }
77
- }
78
-
79
- // 签名验证(如果配置了加密密钥)
80
- if (this.$config.encryptKey) {
81
- const timestamp = ctx.get('x-lark-request-timestamp');
82
- const nonce = ctx.get('x-lark-request-nonce');
83
- const signature = ctx.get('x-lark-signature');
84
- const bodyStr = JSON.stringify(body);
85
-
86
- if (!timestamp || !nonce || !signature || !this.verifySignature(timestamp, nonce, bodyStr, signature)) {
87
- this.logger.warn(formatCompact( { op: 'webhook', ok: false, error: 'invalid signature' }));
88
- ctx.status = 403;
89
- ctx.body = 'Forbidden';
90
- return;
91
- }
92
- }
93
-
94
- const event = body as LarkEvent;
95
-
96
- // URL 验证挑战(首次配置 webhook 时)
97
- if (event.type === 'url_verification') {
98
- ctx.body = { challenge: (event as any).challenge };
99
- return;
100
- }
101
-
102
- // 处理消息事件
103
- if (event.type === 'event_callback' && event.event) {
104
- await this.handleEvent(event.event);
105
- }
106
-
107
- ctx.status = 200;
108
- ctx.body = { code: 0, msg: 'success' };
109
-
110
- } catch (error) {
111
- this.logger.error('Webhook error:', error);
112
- ctx.status = 500;
113
- ctx.body = { code: -1, msg: 'Internal Server Error' };
114
- }
115
- }
116
-
117
- private verifySignature(timestamp: string, nonce: string, body: string, signature: string): boolean {
118
- if (!this.$config.encryptKey) return true;
119
-
120
- try {
121
- const stringToSign = `${timestamp}${nonce}${this.$config.encryptKey}${body}`;
122
- const calculatedSignature = createHash('sha256').update(stringToSign).digest('hex');
123
- return calculatedSignature === signature;
124
- } catch (error) {
125
- this.logger.error('Signature verification error:', error);
126
- return false;
127
- }
128
- }
129
-
130
- private chatPermitCache = new Map<string, { at: number; ownerId?: string; managers: string[] }>();
131
-
132
- private async enrichGroupSender(message: Message<LarkMessage>, msg: LarkMessage): Promise<void> {
133
- if (message.$channel.type !== 'group' || !msg.chat_id) return;
134
- const senderId = msg.sender?.sender_id?.open_id;
135
- if (!senderId) return;
136
- const now = Date.now();
137
- let ownerId: string | undefined;
138
- let managers: string[] = [];
139
- const cached = this.chatPermitCache.get(msg.chat_id);
140
- if (cached && now - cached.at < 60_000) {
141
- ownerId = cached.ownerId;
142
- managers = cached.managers;
143
- } else {
144
- try {
145
- const chat = await this.getChatInfo(msg.chat_id);
146
- ownerId = chat?.owner_id;
147
- managers = Array.isArray(chat?.user_manager_id_list) ? chat.user_manager_id_list : [];
148
- this.chatPermitCache.set(msg.chat_id, { at: now, ownerId, managers });
149
- } catch {
150
- return;
151
- }
152
- }
153
- const permit = normalizeLarkSenderForPermit({
154
- isOwner: ownerId === senderId,
155
- isAdmin: managers.includes(senderId),
156
- });
157
- message.$sender.role = permit.role;
158
- message.$sender.permissions = permit.permissions;
159
- }
160
-
161
- private async handleEvent(event: any): Promise<void> {
162
- // 处理消息事件
163
- if (event.message) {
164
- const message = this.$formatMessage(event.message, event);
165
- await this.enrichGroupSender(message, event.message);
166
- this.adapter.emit('message.receive', message);
167
- this.logger.debug(formatCompact( {
168
- op: 'recv',
169
- endpoint: this.$config.name,
170
- channel: message.$channel.type,
171
- id: message.$channel.id,
172
- len: segment.raw(message.$content).length,
173
- }));
174
- }
175
- }
176
-
177
- // ================================================================================================
178
- // Token 管理
179
- // ================================================================================================
65
+ /** Used by webhook handler. */
66
+ get isOpen(): boolean {
67
+ return this.#open;
68
+ }
180
69
 
181
- private async ensureAccessToken(): Promise<void> {
182
- const now = Date.now();
183
- // 提前 5 分钟刷新 token
184
- if (this.accessToken.token && now < (this.accessToken.timestamp + (this.accessToken.expires_in - 300) * 1000)) {
185
- return;
186
- }
187
-
188
- await this.refreshAccessToken();
189
- }
70
+ get config(): ResolvedLarkConfig {
71
+ return this.#options.config;
72
+ }
190
73
 
191
- private async refreshAccessToken(): Promise<void> {
192
- try {
193
- const response = await axios.post(
194
- `${this.$config.apiBaseUrl || (this.$config.isFeishu ?
195
- 'https://open.feishu.cn/open-apis' :
196
- 'https://open.larksuite.com/open-apis'
197
- )}/auth/v3/tenant_access_token/internal`,
198
- {
199
- app_id: this.$config.appId,
200
- app_secret: this.$config.appSecret
201
- }
202
- );
203
-
204
- if (response.data.code === 0) {
205
- this.accessToken = {
206
- token: response.data.tenant_access_token,
207
- expires_in: response.data.expire,
208
- timestamp: Date.now()
209
- };
210
- this.logger.debug('Access token refreshed successfully');
211
- } else {
212
- throw new Error(`Failed to get access token: ${response.data.msg}`);
213
- }
214
- } catch (error) {
215
- this.logger.error('Failed to refresh access token:', error);
216
- throw error;
217
- }
74
+ async start(): Promise<void> {
75
+ if (this.#started) return;
76
+ this.#started = true;
77
+ try {
78
+ await this.#refreshAccessToken();
79
+ this.#unregisterAgent = registerLarkAgentEndpoint(this.#options.config.name, this);
80
+ this.#routeReleases.push(...registerLarkWebhookRoutes(this.#options.http, this));
81
+ logger.debug(formatCompact({
82
+ endpoint: this.#options.config.name,
83
+ op: 'webhook',
84
+ path: this.#options.config.webhookPath,
85
+ }));
86
+ } catch (error) {
87
+ await this.stop();
88
+ logger.error('Failed to connect Lark endpoint:', error);
89
+ throw error;
218
90
  }
91
+ }
219
92
 
220
- // ================================================================================================
221
- // 消息格式化
222
- // ================================================================================================
93
+ open(): void {
94
+ this.#open = true;
95
+ }
223
96
 
224
- $formatMessage(msg: LarkMessage, event?: any): Message<LarkMessage> {
225
- const wire = this.parseMessageContent(msg);
226
- const content = toCanonicalSegments(wire);
227
-
228
- // 确定聊天类型
229
- const chatType = msg.chat_id?.startsWith('oc_') ? 'group' : 'private';
230
-
231
- return Message.from(msg, {
232
- $id: msg.message_id || Date.now().toString(),
233
- $adapter: 'lark',
234
- $endpoint: this.$config.name,
235
- $sender: {
236
- id: msg.sender?.sender_id?.open_id || 'unknown',
237
- name: msg.sender?.sender_id?.user_id || msg.sender?.sender_id?.open_id || 'Unknown User'
238
- },
239
- $channel: {
240
- id: msg.chat_id || 'unknown',
241
- type: chatType as any
242
- },
243
- $content: content,
244
- $raw: JSON.stringify(msg),
245
- $timestamp: msg.create_time ? parseInt(msg.create_time) : Date.now(),
246
- $recall: async () => {
247
- await this.$recallMessage(msg.message_id || '');
248
- },
249
- $reply: async (content: SendContent): Promise<string> => {
250
- return await this.adapter.sendMessage({
251
- context: 'lark',
252
- endpoint: this.$config.name,
253
- id: msg.chat_id || 'unknown',
254
- type: chatType,
255
- content: content
256
- });
257
- }
258
- });
259
- }
97
+ close(): void {
98
+ this.#open = false;
99
+ }
260
100
 
261
- private parseMessageContent(msg: LarkMessage): MessageSegment[] {
262
- const content: MessageSegment[] = [];
263
-
264
- if (!msg.content || !msg.message_type) {
265
- return content;
266
- }
267
-
268
- try {
269
- const messageContent = JSON.parse(msg.content);
270
-
271
- switch (msg.message_type) {
272
- case 'text':
273
- if (messageContent.text) {
274
- content.push(segment('text', { content: messageContent.text }));
275
-
276
- // 处理 @提及
277
- if (msg.mentions) {
278
- for (const mention of msg.mentions) {
279
- if (mention.key && messageContent.text.includes(mention.key)) {
280
- content.push(segment('at', {
281
- id: mention.id?.open_id,
282
- name: mention.name
283
- }));
284
- }
285
- }
286
- }
287
- }
288
- break;
289
-
290
- case 'image':
291
- content.push(segment('image', {
292
- file_key: messageContent.image_key,
293
- url: `https://open.feishu.cn/open-apis/im/v1/messages/${msg.message_id}/resources/${messageContent.image_key}`
294
- }));
295
- break;
296
-
297
- case 'file':
298
- content.push(segment('file', {
299
- file_key: messageContent.file_key,
300
- file_name: messageContent.file_name,
301
- file_size: messageContent.file_size
302
- }));
303
- break;
304
-
305
- case 'audio':
306
- content.push(segment('audio', {
307
- file_key: messageContent.file_key,
308
- duration: messageContent.duration
309
- }));
310
- break;
311
-
312
- case 'video':
313
- content.push(segment('video', {
314
- file_key: messageContent.file_key,
315
- duration: messageContent.duration,
316
- width: messageContent.width,
317
- height: messageContent.height
318
- }));
319
- break;
320
-
321
- case 'sticker':
322
- content.push(segment('sticker', {
323
- file_key: messageContent.file_key
324
- }));
325
- break;
326
-
327
- case 'rich_text':
328
- // 富文本消息处理(简化)
329
- if (messageContent.content) {
330
- this.parseRichTextContent(messageContent.content, content);
331
- }
332
- break;
333
-
334
- case 'post':
335
- // 卡片消息处理
336
- content.push(segment('card', messageContent));
337
- break;
338
-
339
- default:
340
- content.push(segment('text', { content: `[不支持的消息类型: ${msg.message_type}]` }));
341
- break;
342
- }
343
- } catch (error) {
344
- this.logger.error('Failed to parse message content:', error);
345
- content.push(segment('text', { content: '[消息解析失败]' }));
346
- }
347
-
348
- return content;
349
- }
101
+ async stop(): Promise<void> {
102
+ this.#open = false;
103
+ for (const release of this.#routeReleases.splice(0)) release();
104
+ this.#unregisterAgent?.();
105
+ this.#unregisterAgent = undefined;
106
+ this.#started = false;
107
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
108
+ }
350
109
 
351
- private parseRichTextContent(richContent: any, content: MessageSegment[]): void {
352
- // 简化的富文本解析
353
- if (Array.isArray(richContent)) {
354
- for (const block of richContent) {
355
- if (block.tag === 'text' && block.text) {
356
- content.push(segment('text', { content: block.text }));
357
- } else if (block.tag === 'a' && block.href) {
358
- content.push(segment('link', {
359
- url: block.href,
360
- text: block.text || block.href
361
- }));
362
- } else if (block.tag === 'at' && block.user_id) {
363
- content.push(segment('at', {
364
- id: block.user_id,
365
- name: block.user_name
366
- }));
367
- }
368
- }
369
- }
110
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
111
+ const content = formatOutboundBody(payload);
112
+ const data = await this.#request('/im/v1/messages', {
113
+ method: 'POST',
114
+ params: { receive_id_type: 'chat_id' },
115
+ body: {
116
+ receive_id: target,
117
+ msg_type: content.msg_type,
118
+ content: content.content,
119
+ },
120
+ });
121
+ if (data.code !== 0) {
122
+ throw new Error(`Failed to send message: ${data.msg}`);
370
123
  }
124
+ const messageId = (data.data?.message_id as string) || `${Date.now()}`;
125
+ logger.debug(formatCompact({
126
+ op: 'send',
127
+ endpoint: this.#options.config.name,
128
+ to: target,
129
+ id: messageId,
130
+ }));
131
+ return messageId;
132
+ }
371
133
 
372
- // ================================================================================================
373
- // 消息发送
374
- // ================================================================================================
134
+ /** Test / internal: admit a parsed message when open (non-webhook path). */
135
+ admit(msg: LarkMessage): void {
136
+ if (!this.#open) return;
137
+ const target = resolveTarget(msg);
138
+ void this.#options.gateway.receive({
139
+ adapter: this.#options.id,
140
+ target,
141
+ content: formatInboundContent(msg),
142
+ sender: resolveSender(msg),
143
+ id: generateMessageId(msg),
144
+ metadata: Object.freeze({
145
+ messageType: msg.message_type,
146
+ chatType: resolveChatType(msg.chat_id),
147
+ endpoint: this.#options.config.name,
148
+ }),
149
+ }).catch((err) => {
150
+ logger.warn(formatCompact({
151
+ op: 'lark_gateway_receive_failed',
152
+ target,
153
+ error: err instanceof Error ? err.message : String(err),
154
+ }));
155
+ });
156
+ }
375
157
 
376
- async $sendMessage(options: SendOptions): Promise<string> {
377
- const chatId = options.id;
378
- const canonical = expandInteractiveSegmentsInContent(options.content);
379
- const wire = fromCanonicalSegments(canonical);
380
- const content = this.formatSendContent(wire);
381
-
382
- try {
383
- const response = await this.axiosInstance.post('/im/v1/messages', {
384
- receive_id: chatId,
385
- receive_id_type: 'chat_id',
386
- msg_type: content.msg_type,
387
- content: content.content
388
- });
389
-
390
- if (response.data.code !== 0) {
391
- throw new Error(`Failed to send message: ${response.data.msg}`);
392
- }
393
-
394
- this.logger.debug('Message sent successfully:', response.data.data?.message_id);
395
- return response.data.data?.message_id || '';
396
- } catch (error) {
397
- this.logger.error('Failed to send message:', error);
398
- throw error;
399
- }
400
- }
401
- async $recallMessage(id:string):Promise<void> {
402
- await this.axiosInstance.post('/im/v1/messages/recall', {
403
- message_id: id
404
- });
158
+ async getUserInfo(
159
+ userId: string,
160
+ userIdType: 'open_id' | 'user_id' | 'union_id' = 'open_id',
161
+ ): Promise<unknown> {
162
+ try {
163
+ const data = await this.#request(`/contact/v3/users/${userId}`, {
164
+ method: 'GET',
165
+ params: { user_id_type: userIdType },
166
+ });
167
+ return data.data?.user ?? null;
168
+ } catch (error) {
169
+ logger.error('Failed to get user info:', error);
170
+ return null;
405
171
  }
172
+ }
406
173
 
407
- private formatSendContent(content: SendContent): { msg_type: string, content: string } {
408
- if (typeof content === 'string') {
409
- return {
410
- msg_type: 'text',
411
- content: JSON.stringify({ text: content })
412
- };
413
- }
414
-
415
- if (Array.isArray(content)) {
416
- const textParts: string[] = [];
417
- let hasMedia = false;
418
- let mediaContent: any = null;
419
-
420
- for (const item of content) {
421
- if (typeof item === 'string') {
422
- textParts.push(item);
423
- } else {
424
- const segment = item as MessageSegment;
425
- switch (segment.type) {
426
- case 'text':
427
- textParts.push(segment.data.content || segment.data.text || '');
428
- break;
429
-
430
- case 'at':
431
- textParts.push(`<at user_id="${segment.data.id}">${segment.data.name || segment.data.id}</at>`);
432
- break;
433
-
434
- case 'image':
435
- if (!hasMedia) {
436
- hasMedia = true;
437
- mediaContent = {
438
- msg_type: 'image',
439
- content: JSON.stringify({
440
- image_key: segment.data.file_key || segment.data.key
441
- })
442
- };
443
- }
444
- break;
445
-
446
- case 'file':
447
- if (!hasMedia) {
448
- hasMedia = true;
449
- mediaContent = {
450
- msg_type: 'file',
451
- content: JSON.stringify({
452
- file_key: segment.data.file_key || segment.data.key
453
- })
454
- };
455
- }
456
- break;
457
-
458
- case 'card':
459
- if (!hasMedia) {
460
- hasMedia = true;
461
- mediaContent = {
462
- msg_type: 'interactive',
463
- content: JSON.stringify(segment.data)
464
- };
465
- }
466
- break;
467
- }
468
- }
469
- }
470
-
471
- // 优先发送媒体内容
472
- if (hasMedia && mediaContent) {
473
- return mediaContent;
474
- }
475
-
476
- // 否则发送文本内容
477
- return {
478
- msg_type: 'text',
479
- content: JSON.stringify({ text: textParts.join('') })
480
- };
481
- }
482
-
483
- return {
484
- msg_type: 'text',
485
- content: JSON.stringify({ text: String(content) })
486
- };
174
+ async getChatInfo(chatId: string): Promise<unknown> {
175
+ try {
176
+ const data = await this.#request(`/im/v1/chats/${chatId}`, { method: 'GET' });
177
+ return data.data ?? null;
178
+ } catch (error) {
179
+ logger.error('Failed to get chat info:', error);
180
+ return null;
487
181
  }
182
+ }
488
183
 
489
- // ================================================================================================
490
- // Endpoint 生命周期
491
- // ================================================================================================
492
-
493
- async $connect(): Promise<void> {
494
- try {
495
- // 获取 access token
496
- await this.refreshAccessToken();
497
-
498
- this.$connected = true;
499
- this.logger.info(formatCompact({ endpoint: this.$config.name }));
500
- this.logger.info(formatCompact( { op: 'webhook', path: this.$config.webhookPath }));
501
-
502
- } catch (error) {
503
- this.logger.error('Failed to connect Lark bot:', error);
504
- throw error;
505
- }
184
+ async uploadFile(
185
+ filePath: string,
186
+ fileType: 'image' | 'file' | 'video' | 'audio' = 'file',
187
+ ): Promise<string | null> {
188
+ try {
189
+ await this.#ensureAccessToken();
190
+ const buf = await readFile(filePath);
191
+ const form = new FormData();
192
+ form.append('file', new Blob([buf]), basename(filePath));
193
+ form.append('file_type', fileType);
194
+ const url = `${this.#options.config.apiBaseUrl}/im/v1/files`;
195
+ const response = await this.#fetch(url, {
196
+ method: 'POST',
197
+ headers: { Authorization: `Bearer ${this.#accessToken.token}` },
198
+ body: form,
199
+ });
200
+ const data = await response.json() as LarkApiResponse;
201
+ if (data.code === 0) {
202
+ return (data.data?.file_key as string) || null;
203
+ }
204
+ throw new Error(`Upload failed: ${data.msg}`);
205
+ } catch (error) {
206
+ logger.error('Failed to upload file:', error);
207
+ return null;
506
208
  }
209
+ }
507
210
 
508
- async $disconnect(): Promise<void> {
509
- try {
510
- this.$connected = false;
511
- this.logger.info(formatCompact( { op: 'disconnect', endpoint: this.$config.name }));
512
- } catch (error) {
513
- this.logger.error('Error disconnecting Lark bot:', error);
514
- }
211
+ async createChat(
212
+ name: string,
213
+ userIds: string[],
214
+ ownerId?: string,
215
+ ): Promise<string | null> {
216
+ try {
217
+ const data = await this.#request('/im/v1/chats', {
218
+ method: 'POST',
219
+ body: {
220
+ name,
221
+ user_id_list: userIds,
222
+ ...(ownerId ? { owner_id: ownerId } : {}),
223
+ },
224
+ });
225
+ if (data.code === 0) return (data.data?.chat_id as string) || null;
226
+ throw new Error(`Failed to create chat: ${data.msg}`);
227
+ } catch (error) {
228
+ logger.error('Failed to create chat:', error);
229
+ return null;
515
230
  }
231
+ }
516
232
 
517
- // ================================================================================================
518
- // 工具方法
519
- // ================================================================================================
520
-
521
- // 获取用户信息
522
- async getUserInfo(userId: string, userIdType: 'open_id' | 'user_id' | 'union_id' = 'open_id'): Promise<any> {
523
- try {
524
- const response = await this.axiosInstance.get(`/contact/v3/users/${userId}`, {
525
- params: { user_id_type: userIdType }
526
- });
527
-
528
- return response.data.data?.user;
529
- } catch (error) {
530
- this.logger.error('Failed to get user info:', error);
531
- return null;
532
- }
233
+ async updateChatInfo(
234
+ chatId: string,
235
+ options: { name?: string; description?: string },
236
+ ): Promise<boolean> {
237
+ try {
238
+ const data = await this.#request(`/im/v1/chats/${chatId}`, {
239
+ method: 'PUT',
240
+ body: options,
241
+ });
242
+ if (data.code === 0) return true;
243
+ throw new Error(`Failed to update chat: ${data.msg}`);
244
+ } catch (error) {
245
+ logger.error('Failed to update chat:', error);
246
+ return false;
533
247
  }
248
+ }
534
249
 
535
- // 获取群聊信息
536
- async getChatInfo(chatId: string): Promise<any> {
537
- try {
538
- const response = await this.axiosInstance.get(`/im/v1/chats/${chatId}`);
539
- return response.data.data;
540
- } catch (error) {
541
- this.logger.error('Failed to get chat info:', error);
542
- return null;
543
- }
250
+ async addChatMembers(chatId: string, userIds: string[]): Promise<boolean> {
251
+ try {
252
+ const data = await this.#request(`/im/v1/chats/${chatId}/members`, {
253
+ method: 'POST',
254
+ body: { id_list: userIds },
255
+ });
256
+ if (data.code === 0) return true;
257
+ throw new Error(`Failed to add members: ${data.msg}`);
258
+ } catch (error) {
259
+ logger.error('Failed to add chat members:', error);
260
+ return false;
544
261
  }
262
+ }
545
263
 
546
- // 上传文件
547
- async uploadFile(filePath: string, fileType: 'image' | 'file' | 'video' | 'audio' = 'file'): Promise<string | null> {
548
- try {
549
- const FormData = require('form-data');
550
- const fs = require('fs');
551
-
552
- const form = new FormData();
553
- form.append('file', fs.createReadStream(filePath));
554
- form.append('file_type', fileType);
555
-
556
- const response = await this.axiosInstance.post('/im/v1/files', form, {
557
- headers: {
558
- ...form.getHeaders()
559
- }
560
- });
561
-
562
- if (response.data.code === 0) {
563
- return response.data.data.file_key;
564
- }
565
-
566
- throw new Error(`Upload failed: ${response.data.msg}`);
567
- } catch (error) {
568
- this.logger.error('Failed to upload file:', error);
569
- return null;
570
- }
264
+ async removeChatMembers(chatId: string, userIds: string[]): Promise<boolean> {
265
+ try {
266
+ const data = await this.#request(`/im/v1/chats/${chatId}/members`, {
267
+ method: 'DELETE',
268
+ body: { id_list: userIds },
269
+ });
270
+ if (data.code === 0) return true;
271
+ throw new Error(`Failed to remove members: ${data.msg}`);
272
+ } catch (error) {
273
+ logger.error('Failed to remove chat members:', error);
274
+ return false;
571
275
  }
276
+ }
572
277
 
573
- // ==================== 群组管理 API ====================
574
-
575
- /**
576
- * 创建群聊
577
- * @param name 群名
578
- * @param userIds 成员 open_id 列表
579
- * @param ownerId 群主 open_id
580
- */
581
- async createChat(name: string, userIds: string[], ownerId?: string): Promise<string | null> {
582
- try {
583
- const response = await this.axiosInstance.post('/im/v1/chats', {
584
- name,
585
- user_id_list: userIds,
586
- owner_id: ownerId
587
- });
588
-
589
- if (response.data.code === 0) {
590
- this.logger.debug(formatCompact( { op: 'create_chat', chat: response.data.data.chat_id }));
591
- return response.data.data.chat_id;
592
- }
593
- throw new Error(`Failed to create chat: ${response.data.msg}`);
594
- } catch (error) {
595
- this.logger.error('Failed to create chat:', error);
596
- return null;
597
- }
278
+ async getChatMembers(chatId: string): Promise<unknown[]> {
279
+ try {
280
+ const data = await this.#request(`/im/v1/chats/${chatId}/members`, { method: 'GET' });
281
+ if (data.code === 0) return (data.data?.items as unknown[]) || [];
282
+ throw new Error(`Failed to get members: ${data.msg}`);
283
+ } catch (error) {
284
+ logger.error('Failed to get chat members:', error);
285
+ return [];
598
286
  }
287
+ }
599
288
 
600
- /**
601
- * 更新群信息
602
- * @param chatId 群聊 ID
603
- * @param options 更新选项
604
- */
605
- async updateChatInfo(chatId: string, options: {
606
- name?: string;
607
- description?: string;
608
- }): Promise<boolean> {
609
- try {
610
- const response = await this.axiosInstance.put(`/im/v1/chats/${chatId}`, options);
611
-
612
- if (response.data.code === 0) {
613
- this.logger.debug(formatCompact( { op: 'update_chat', chat: chatId }));
614
- return true;
615
- }
616
- throw new Error(`Failed to update chat: ${response.data.msg}`);
617
- } catch (error) {
618
- this.logger.error('Failed to update chat:', error);
619
- return false;
620
- }
289
+ async dissolveChat(chatId: string): Promise<boolean> {
290
+ try {
291
+ const data = await this.#request(`/im/v1/chats/${chatId}`, { method: 'DELETE' });
292
+ if (data.code === 0) return true;
293
+ throw new Error(`Failed to dissolve chat: ${data.msg}`);
294
+ } catch (error) {
295
+ logger.error('Failed to dissolve chat:', error);
296
+ return false;
621
297
  }
298
+ }
622
299
 
623
- /**
624
- * 添加群成员
625
- * @param chatId 群聊 ID
626
- * @param userIds 用户 ID 列表
627
- */
628
- async addChatMembers(chatId: string, userIds: string[]): Promise<boolean> {
629
- try {
630
- const response = await this.axiosInstance.post(`/im/v1/chats/${chatId}/members`, {
631
- id_list: userIds
632
- });
633
-
634
- if (response.data.code === 0) {
635
- this.logger.debug(formatCompact( { op: 'add_member', chat: chatId }));
636
- return true;
637
- }
638
- throw new Error(`Failed to add members: ${response.data.msg}`);
639
- } catch (error) {
640
- this.logger.error('Failed to add chat members:', error);
641
- return false;
642
- }
300
+ async setChatManagers(chatId: string, userIds: string[]): Promise<boolean> {
301
+ try {
302
+ const data = await this.#request(`/im/v1/chats/${chatId}/managers/add_managers`, {
303
+ method: 'POST',
304
+ body: { manager_ids: userIds },
305
+ });
306
+ if (data.code === 0) return true;
307
+ throw new Error(`Failed to set managers: ${data.msg}`);
308
+ } catch (error) {
309
+ logger.error('Failed to set chat managers:', error);
310
+ return false;
643
311
  }
312
+ }
644
313
 
645
- /**
646
- * 移除群成员
647
- * @param chatId 群聊 ID
648
- * @param userIds 用户 ID 列表
649
- */
650
- async removeChatMembers(chatId: string, userIds: string[]): Promise<boolean> {
651
- try {
652
- const response = await this.axiosInstance.delete(`/im/v1/chats/${chatId}/members`, {
653
- data: { id_list: userIds }
654
- });
655
-
656
- if (response.data.code === 0) {
657
- this.logger.debug(formatCompact( { op: 'remove_member', chat: chatId }));
658
- return true;
659
- }
660
- throw new Error(`Failed to remove members: ${response.data.msg}`);
661
- } catch (error) {
662
- this.logger.error('Failed to remove chat members:', error);
663
- return false;
664
- }
314
+ async removeChatManagers(chatId: string, userIds: string[]): Promise<boolean> {
315
+ try {
316
+ const data = await this.#request(`/im/v1/chats/${chatId}/managers/delete_managers`, {
317
+ method: 'POST',
318
+ body: { manager_ids: userIds },
319
+ });
320
+ if (data.code === 0) return true;
321
+ throw new Error(`Failed to remove managers: ${data.msg}`);
322
+ } catch (error) {
323
+ logger.error('Failed to remove chat managers:', error);
324
+ return false;
665
325
  }
326
+ }
666
327
 
667
- /**
668
- * 获取群成员列表
669
- * @param chatId 群聊 ID
670
- */
671
- async getChatMembers(chatId: string): Promise<any[]> {
672
- try {
673
- const response = await this.axiosInstance.get(`/im/v1/chats/${chatId}/members`);
674
-
675
- if (response.data.code === 0) {
676
- return response.data.data.items || [];
677
- }
678
- throw new Error(`Failed to get members: ${response.data.msg}`);
679
- } catch (error) {
680
- this.logger.error('Failed to get chat members:', error);
681
- return [];
682
- }
328
+ async #request(
329
+ path: string,
330
+ options: {
331
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
332
+ params?: Record<string, string | number>;
333
+ body?: Record<string, unknown>;
334
+ } = {},
335
+ ): Promise<LarkApiResponse> {
336
+ await this.#ensureAccessToken();
337
+ const { method = 'GET', params = {}, body } = options;
338
+ const urlParams = new URLSearchParams(
339
+ Object.fromEntries(
340
+ Object.entries(params).map(([key, value]) => [key, String(value)]),
341
+ ),
342
+ );
343
+ const query = urlParams.toString();
344
+ const url = `${this.#options.config.apiBaseUrl}${path}${query ? `?${query}` : ''}`;
345
+ const response = await this.#fetch(url, {
346
+ method,
347
+ headers: {
348
+ 'Content-Type': 'application/json; charset=utf-8',
349
+ Authorization: `Bearer ${this.#accessToken.token}`,
350
+ },
351
+ body: body && method !== 'GET' ? JSON.stringify(body) : undefined,
352
+ });
353
+ if (!response.ok) {
354
+ const text = await response.text().catch(() => '');
355
+ throw new Error(`Lark API error ${response.status}: ${text}`);
683
356
  }
357
+ return await response.json() as LarkApiResponse;
358
+ }
684
359
 
685
- /**
686
- * 解散群聊
687
- * @param chatId 群聊 ID
688
- */
689
- async dissolveChat(chatId: string): Promise<boolean> {
690
- try {
691
- const response = await this.axiosInstance.delete(`/im/v1/chats/${chatId}`);
692
-
693
- if (response.data.code === 0) {
694
- this.logger.debug(formatCompact( { op: 'delete_chat', chat: chatId }));
695
- return true;
696
- }
697
- throw new Error(`Failed to dissolve chat: ${response.data.msg}`);
698
- } catch (error) {
699
- this.logger.error('Failed to dissolve chat:', error);
700
- return false;
701
- }
360
+ async #ensureAccessToken(): Promise<void> {
361
+ const now = Date.now();
362
+ if (
363
+ this.#accessToken.token
364
+ && now < this.#accessToken.timestamp + (this.#accessToken.expires_in - 300) * 1000
365
+ ) {
366
+ return;
702
367
  }
703
-
704
- /**
705
- * 设置群管理员
706
- * @param chatId 群聊 ID
707
- * @param userIds 用户 ID 列表
708
- */
709
- async setChatManagers(chatId: string, userIds: string[]): Promise<boolean> {
710
- try {
711
- const response = await this.axiosInstance.post(`/im/v1/chats/${chatId}/managers/add_managers`, {
712
- manager_ids: userIds
713
- });
714
-
715
- if (response.data.code === 0) {
716
- this.logger.debug(formatCompact( { op: 'set_admin', chat: chatId }));
717
- return true;
718
- }
719
- throw new Error(`Failed to set managers: ${response.data.msg}`);
720
- } catch (error) {
721
- this.logger.error('Failed to set chat managers:', error);
722
- return false;
723
- }
368
+ if (this.#refreshPromise) {
369
+ await this.#refreshPromise;
370
+ return;
724
371
  }
372
+ this.#refreshPromise = this.#refreshAccessToken()
373
+ .then(() => this.#accessToken.token)
374
+ .finally(() => { this.#refreshPromise = null; });
375
+ await this.#refreshPromise;
376
+ }
725
377
 
726
- /**
727
- * 移除群管理员
728
- * @param chatId 群聊 ID
729
- * @param userIds 用户 ID 列表
730
- */
731
- async removeChatManagers(chatId: string, userIds: string[]): Promise<boolean> {
732
- try {
733
- const response = await this.axiosInstance.post(`/im/v1/chats/${chatId}/managers/delete_managers`, {
734
- manager_ids: userIds
735
- });
736
-
737
- if (response.data.code === 0) {
738
- this.logger.debug(formatCompact( { op: 'remove_admin', chat: chatId }));
739
- return true;
740
- }
741
- throw new Error(`Failed to remove managers: ${response.data.msg}`);
742
- } catch (error) {
743
- this.logger.error('Failed to remove chat managers:', error);
744
- return false;
745
- }
378
+ async #refreshAccessToken(): Promise<void> {
379
+ const { appId, appSecret, apiBaseUrl } = this.#options.config;
380
+ const url = `${apiBaseUrl}/auth/v3/tenant_access_token/internal`;
381
+ const response = await this.#fetch(url, {
382
+ method: 'POST',
383
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
384
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
385
+ });
386
+ const data = await response.json() as LarkApiResponse;
387
+ if (data.code === 0 && data.tenant_access_token) {
388
+ this.#accessToken = {
389
+ token: data.tenant_access_token,
390
+ expires_in: data.expire ?? 7200,
391
+ timestamp: Date.now(),
392
+ };
393
+ logger.debug('Access token refreshed successfully');
394
+ return;
746
395
  }
396
+ throw new Error(`Failed to get access token: ${data.msg} (${data.code})`);
397
+ }
747
398
  }
748
-
749
- // 定义 Adapter 类