@zhin.js/adapter-lark 1.0.1

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/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@zhin.js/adapter-lark",
3
+ "version": "1.0.1",
4
+ "description": "zhin adapter for lark/feishu",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ }
13
+ },
14
+ "repository": {
15
+ "url": "git+https://github.com/lc-cn/zhin-next.git",
16
+ "type": "git",
17
+ "directory": "adapters/lark"
18
+ },
19
+ "dependencies": {
20
+ "axios": "^1.6.0"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.3.0",
24
+ "@types/node": "^22.9.0",
25
+ "@types/koa": "^2.15.0"
26
+ },
27
+ "peerDependencies": {
28
+ "zhin.js": "1.0.1",
29
+ "@zhin.js/types": "1.0.1",
30
+ "@zhin.js/http": "1.0.1"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@zhin.js/types": {
34
+ "optional": true
35
+ },
36
+ "@zhin.js/http": {
37
+ "optional": false
38
+ }
39
+ },
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "clean": "rm -rf lib"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,609 @@
1
+ import {
2
+ Bot,
3
+ BotConfig,
4
+ Adapter,
5
+ Plugin,
6
+ registerAdapter,
7
+ Message,
8
+ SendOptions,
9
+ SendContent,
10
+ MessageSegment,
11
+ segment,
12
+ useContext
13
+ } from "zhin.js";
14
+ import type { Context } from 'koa';
15
+ import axios, { AxiosInstance } from 'axios';
16
+ import { createHash, createHmac } from 'crypto';
17
+
18
+ // 声明模块,注册飞书适配器类型
19
+ declare module 'zhin.js' {
20
+ interface RegisteredAdapters {
21
+ lark: Adapter<LarkBot>
22
+ }
23
+ }
24
+
25
+ // 飞书配置类型定义
26
+ export interface LarkBotConfig extends BotConfig {
27
+ context: 'lark'
28
+ name: string
29
+ appId: string // 飞书应用 ID
30
+ appSecret: string // 飞书应用密钥
31
+ encryptKey?: string // 事件推送加密密钥(可选)
32
+ verificationToken?: string // 事件推送验证令牌(可选)
33
+ webhookPath: string // Webhook 路径,如 '/lark/webhook'
34
+ // API 配置
35
+ apiBaseUrl?: string // API 基础 URL,默认为飞书
36
+ isFeishu?: boolean // 是否为飞书(中国版),默认 false 为 Lark 国际版
37
+ }
38
+
39
+ // 飞书消息类型
40
+ export interface LarkMessage {
41
+ message_id?: string
42
+ root_id?: string
43
+ parent_id?: string
44
+ create_time?: string
45
+ update_time?: string
46
+ chat_id?: string
47
+ sender?: {
48
+ sender_id?: {
49
+ user_id?: string
50
+ open_id?: string
51
+ union_id?: string
52
+ }
53
+ sender_type?: string
54
+ tenant_key?: string
55
+ }
56
+ message_type?: string
57
+ content?: string
58
+ mentions?: Array<{
59
+ key?: string
60
+ id?: {
61
+ user_id?: string
62
+ open_id?: string
63
+ union_id?: string
64
+ }
65
+ name?: string
66
+ tenant_key?: string
67
+ }>
68
+ }
69
+
70
+ // 飞书事件类型
71
+ interface LarkEvent {
72
+ uuid?: string
73
+ token?: string
74
+ ts?: string
75
+ type?: string
76
+ event?: {
77
+ sender?: any
78
+ message?: LarkMessage
79
+ [key: string]: any
80
+ }
81
+ }
82
+
83
+ // Token 管理
84
+ interface AccessToken {
85
+ token: string
86
+ expires_in: number
87
+ timestamp: number
88
+ }
89
+
90
+ // Bot 接口
91
+ export interface LarkBot {
92
+ $config: LarkBotConfig
93
+ }
94
+
95
+ // ================================================================================================
96
+ // LarkBot 类
97
+ // ================================================================================================
98
+
99
+ export class LarkBot implements Bot<LarkMessage, LarkBotConfig> {
100
+ $connected?: boolean
101
+ private router: any
102
+ private accessToken: AccessToken
103
+ private axiosInstance: AxiosInstance
104
+
105
+ constructor(private plugin: Plugin, router: any, public $config: LarkBotConfig) {
106
+ this.router = router;
107
+ this.$connected = false;
108
+ this.accessToken = { token: '', expires_in: 0, timestamp: 0 };
109
+
110
+ // 设置 API 基础 URL
111
+ const baseURL = $config.apiBaseUrl || ($config.isFeishu ?
112
+ 'https://open.feishu.cn/open-apis' :
113
+ 'https://open.larksuite.com/open-apis'
114
+ );
115
+
116
+ this.axiosInstance = axios.create({
117
+ baseURL,
118
+ timeout: 30000,
119
+ headers: {
120
+ 'Content-Type': 'application/json; charset=utf-8'
121
+ }
122
+ });
123
+
124
+ // 设置请求拦截器,自动添加 access_token
125
+ this.axiosInstance.interceptors.request.use(async (config) => {
126
+ await this.ensureAccessToken();
127
+ config.headers = config.headers || {};
128
+ config.headers['Authorization'] = `Bearer ${this.accessToken.token}`;
129
+ return config;
130
+ });
131
+
132
+ // 设置 webhook 路由
133
+ this.setupWebhookRoute();
134
+ }
135
+
136
+ private setupWebhookRoute(): void {
137
+ this.router.post(this.$config.webhookPath, (ctx: Context) => {
138
+ this.handleWebhook(ctx);
139
+ });
140
+ }
141
+
142
+ private async handleWebhook(ctx: Context): Promise<void> {
143
+ try {
144
+ const body = (ctx.request as any).body;
145
+ const headers = ctx.request.headers;
146
+
147
+ // 验证请求(如果配置了验证令牌)
148
+ if (this.$config.verificationToken) {
149
+ const token = headers['x-lark-request-token'] as string;
150
+ if (token !== this.$config.verificationToken) {
151
+ this.plugin.logger.warn('Invalid verification token in webhook');
152
+ ctx.status = 403;
153
+ ctx.body = 'Forbidden';
154
+ return;
155
+ }
156
+ }
157
+
158
+ // 签名验证(如果配置了加密密钥)
159
+ if (this.$config.encryptKey) {
160
+ const timestamp = headers['x-lark-request-timestamp'] as string;
161
+ const nonce = headers['x-lark-request-nonce'] as string;
162
+ const signature = headers['x-lark-signature'] as string;
163
+ const bodyStr = JSON.stringify(body);
164
+
165
+ if (!this.verifySignature(timestamp, nonce, bodyStr, signature)) {
166
+ this.plugin.logger.warn('Invalid signature in webhook');
167
+ ctx.status = 403;
168
+ ctx.body = 'Forbidden';
169
+ return;
170
+ }
171
+ }
172
+
173
+ const event: LarkEvent = body;
174
+
175
+ // URL 验证挑战(首次配置 webhook 时)
176
+ if (event.type === 'url_verification') {
177
+ ctx.body = { challenge: (event as any).challenge };
178
+ return;
179
+ }
180
+
181
+ // 处理消息事件
182
+ if (event.type === 'event_callback' && event.event) {
183
+ await this.handleEvent(event.event);
184
+ }
185
+
186
+ ctx.status = 200;
187
+ ctx.body = { code: 0, msg: 'success' };
188
+
189
+ } catch (error) {
190
+ this.plugin.logger.error('Webhook error:', error);
191
+ ctx.status = 500;
192
+ ctx.body = { code: -1, msg: 'Internal Server Error' };
193
+ }
194
+ }
195
+
196
+ private verifySignature(timestamp: string, nonce: string, body: string, signature: string): boolean {
197
+ if (!this.$config.encryptKey) return true;
198
+
199
+ try {
200
+ const stringToSign = `${timestamp}${nonce}${this.$config.encryptKey}${body}`;
201
+ const calculatedSignature = createHash('sha256').update(stringToSign).digest('hex');
202
+ return calculatedSignature === signature;
203
+ } catch (error) {
204
+ this.plugin.logger.error('Signature verification error:', error);
205
+ return false;
206
+ }
207
+ }
208
+
209
+ private async handleEvent(event: any): Promise<void> {
210
+ // 处理消息事件
211
+ if (event.message) {
212
+ const message = this.$formatMessage(event.message, event);
213
+ this.plugin.dispatch('message.receive', message);
214
+ this.plugin.logger.info(`recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`);
215
+ this.plugin.dispatch(`message.${message.$channel.type}.receive`, message);
216
+ }
217
+ }
218
+
219
+ // ================================================================================================
220
+ // Token 管理
221
+ // ================================================================================================
222
+
223
+ private async ensureAccessToken(): Promise<void> {
224
+ const now = Date.now();
225
+ // 提前 5 分钟刷新 token
226
+ if (this.accessToken.token && now < (this.accessToken.timestamp + (this.accessToken.expires_in - 300) * 1000)) {
227
+ return;
228
+ }
229
+
230
+ await this.refreshAccessToken();
231
+ }
232
+
233
+ private async refreshAccessToken(): Promise<void> {
234
+ try {
235
+ const response = await axios.post(
236
+ `${this.$config.apiBaseUrl || (this.$config.isFeishu ?
237
+ 'https://open.feishu.cn/open-apis' :
238
+ 'https://open.larksuite.com/open-apis'
239
+ )}/auth/v3/tenant_access_token/internal`,
240
+ {
241
+ app_id: this.$config.appId,
242
+ app_secret: this.$config.appSecret
243
+ }
244
+ );
245
+
246
+ if (response.data.code === 0) {
247
+ this.accessToken = {
248
+ token: response.data.tenant_access_token,
249
+ expires_in: response.data.expire,
250
+ timestamp: Date.now()
251
+ };
252
+ this.plugin.logger.debug('Access token refreshed successfully');
253
+ } else {
254
+ throw new Error(`Failed to get access token: ${response.data.msg}`);
255
+ }
256
+ } catch (error) {
257
+ this.plugin.logger.error('Failed to refresh access token:', error);
258
+ throw error;
259
+ }
260
+ }
261
+
262
+ // ================================================================================================
263
+ // 消息格式化
264
+ // ================================================================================================
265
+
266
+ $formatMessage(msg: LarkMessage, event?: any): Message<LarkMessage> {
267
+ const content = this.parseMessageContent(msg);
268
+
269
+ // 确定聊天类型
270
+ const chatType = msg.chat_id?.startsWith('oc_') ? 'group' : 'private';
271
+
272
+ return Message.from(msg, {
273
+ $id: msg.message_id || Date.now().toString(),
274
+ $adapter: 'lark',
275
+ $bot: this.$config.name,
276
+ $sender: {
277
+ id: msg.sender?.sender_id?.open_id || 'unknown',
278
+ name: msg.sender?.sender_id?.user_id || msg.sender?.sender_id?.open_id || 'Unknown User'
279
+ },
280
+ $channel: {
281
+ id: msg.chat_id || 'unknown',
282
+ type: chatType as any
283
+ },
284
+ $content: content,
285
+ $raw: JSON.stringify(msg),
286
+ $timestamp: msg.create_time ? parseInt(msg.create_time) : Date.now(),
287
+ $reply: async (content: SendContent): Promise<void> => {
288
+ await this.$sendMessage({
289
+ context: 'lark',
290
+ bot: this.$config.name,
291
+ id: msg.chat_id || 'unknown',
292
+ type: chatType,
293
+ content: content
294
+ });
295
+ }
296
+ });
297
+ }
298
+
299
+ private parseMessageContent(msg: LarkMessage): MessageSegment[] {
300
+ const content: MessageSegment[] = [];
301
+
302
+ if (!msg.content || !msg.message_type) {
303
+ return content;
304
+ }
305
+
306
+ try {
307
+ const messageContent = JSON.parse(msg.content);
308
+
309
+ switch (msg.message_type) {
310
+ case 'text':
311
+ if (messageContent.text) {
312
+ content.push(segment('text', { content: messageContent.text }));
313
+
314
+ // 处理 @提及
315
+ if (msg.mentions) {
316
+ for (const mention of msg.mentions) {
317
+ if (mention.key && messageContent.text.includes(mention.key)) {
318
+ content.push(segment('at', {
319
+ id: mention.id?.open_id,
320
+ name: mention.name
321
+ }));
322
+ }
323
+ }
324
+ }
325
+ }
326
+ break;
327
+
328
+ case 'image':
329
+ content.push(segment('image', {
330
+ file_key: messageContent.image_key,
331
+ url: `https://open.feishu.cn/open-apis/im/v1/messages/${msg.message_id}/resources/${messageContent.image_key}`
332
+ }));
333
+ break;
334
+
335
+ case 'file':
336
+ content.push(segment('file', {
337
+ file_key: messageContent.file_key,
338
+ file_name: messageContent.file_name,
339
+ file_size: messageContent.file_size
340
+ }));
341
+ break;
342
+
343
+ case 'audio':
344
+ content.push(segment('audio', {
345
+ file_key: messageContent.file_key,
346
+ duration: messageContent.duration
347
+ }));
348
+ break;
349
+
350
+ case 'video':
351
+ content.push(segment('video', {
352
+ file_key: messageContent.file_key,
353
+ duration: messageContent.duration,
354
+ width: messageContent.width,
355
+ height: messageContent.height
356
+ }));
357
+ break;
358
+
359
+ case 'sticker':
360
+ content.push(segment('sticker', {
361
+ file_key: messageContent.file_key
362
+ }));
363
+ break;
364
+
365
+ case 'rich_text':
366
+ // 富文本消息处理(简化)
367
+ if (messageContent.content) {
368
+ this.parseRichTextContent(messageContent.content, content);
369
+ }
370
+ break;
371
+
372
+ case 'post':
373
+ // 卡片消息处理
374
+ content.push(segment('card', messageContent));
375
+ break;
376
+
377
+ default:
378
+ content.push(segment('text', { content: `[不支持的消息类型: ${msg.message_type}]` }));
379
+ break;
380
+ }
381
+ } catch (error) {
382
+ this.plugin.logger.error('Failed to parse message content:', error);
383
+ content.push(segment('text', { content: '[消息解析失败]' }));
384
+ }
385
+
386
+ return content;
387
+ }
388
+
389
+ private parseRichTextContent(richContent: any, content: MessageSegment[]): void {
390
+ // 简化的富文本解析
391
+ if (Array.isArray(richContent)) {
392
+ for (const block of richContent) {
393
+ if (block.tag === 'text' && block.text) {
394
+ content.push(segment('text', { content: block.text }));
395
+ } else if (block.tag === 'a' && block.href) {
396
+ content.push(segment('link', {
397
+ url: block.href,
398
+ text: block.text || block.href
399
+ }));
400
+ } else if (block.tag === 'at' && block.user_id) {
401
+ content.push(segment('at', {
402
+ id: block.user_id,
403
+ name: block.user_name
404
+ }));
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ // ================================================================================================
411
+ // 消息发送
412
+ // ================================================================================================
413
+
414
+ async $sendMessage(options: SendOptions): Promise<void> {
415
+ const chatId = options.id;
416
+ const content = this.formatSendContent(options.content);
417
+
418
+ try {
419
+ const response = await this.axiosInstance.post('/im/v1/messages', {
420
+ receive_id: chatId,
421
+ receive_id_type: 'chat_id',
422
+ msg_type: content.msg_type,
423
+ content: content.content
424
+ });
425
+
426
+ if (response.data.code !== 0) {
427
+ throw new Error(`Failed to send message: ${response.data.msg}`);
428
+ }
429
+
430
+ this.plugin.logger.debug('Message sent successfully:', response.data.data?.message_id);
431
+ } catch (error) {
432
+ this.plugin.logger.error('Failed to send message:', error);
433
+ throw error;
434
+ }
435
+ }
436
+
437
+ private formatSendContent(content: SendContent): { msg_type: string, content: string } {
438
+ if (typeof content === 'string') {
439
+ return {
440
+ msg_type: 'text',
441
+ content: JSON.stringify({ text: content })
442
+ };
443
+ }
444
+
445
+ if (Array.isArray(content)) {
446
+ const textParts: string[] = [];
447
+ let hasMedia = false;
448
+ let mediaContent: any = null;
449
+
450
+ for (const item of content) {
451
+ if (typeof item === 'string') {
452
+ textParts.push(item);
453
+ } else {
454
+ const segment = item as MessageSegment;
455
+ switch (segment.type) {
456
+ case 'text':
457
+ textParts.push(segment.data.content || segment.data.text || '');
458
+ break;
459
+
460
+ case 'at':
461
+ textParts.push(`<at user_id="${segment.data.id}">${segment.data.name || segment.data.id}</at>`);
462
+ break;
463
+
464
+ case 'image':
465
+ if (!hasMedia) {
466
+ hasMedia = true;
467
+ mediaContent = {
468
+ msg_type: 'image',
469
+ content: JSON.stringify({
470
+ image_key: segment.data.file_key || segment.data.key
471
+ })
472
+ };
473
+ }
474
+ break;
475
+
476
+ case 'file':
477
+ if (!hasMedia) {
478
+ hasMedia = true;
479
+ mediaContent = {
480
+ msg_type: 'file',
481
+ content: JSON.stringify({
482
+ file_key: segment.data.file_key || segment.data.key
483
+ })
484
+ };
485
+ }
486
+ break;
487
+
488
+ case 'card':
489
+ if (!hasMedia) {
490
+ hasMedia = true;
491
+ mediaContent = {
492
+ msg_type: 'interactive',
493
+ content: JSON.stringify(segment.data)
494
+ };
495
+ }
496
+ break;
497
+ }
498
+ }
499
+ }
500
+
501
+ // 优先发送媒体内容
502
+ if (hasMedia && mediaContent) {
503
+ return mediaContent;
504
+ }
505
+
506
+ // 否则发送文本内容
507
+ return {
508
+ msg_type: 'text',
509
+ content: JSON.stringify({ text: textParts.join('') })
510
+ };
511
+ }
512
+
513
+ return {
514
+ msg_type: 'text',
515
+ content: JSON.stringify({ text: String(content) })
516
+ };
517
+ }
518
+
519
+ // ================================================================================================
520
+ // Bot 生命周期
521
+ // ================================================================================================
522
+
523
+ async $connect(): Promise<void> {
524
+ try {
525
+ // 获取 access token
526
+ await this.refreshAccessToken();
527
+
528
+ this.$connected = true;
529
+ this.plugin.logger.info(`Lark bot connected: ${this.$config.name}`);
530
+ this.plugin.logger.info(`Webhook URL: ${this.$config.webhookPath}`);
531
+
532
+ } catch (error) {
533
+ this.plugin.logger.error('Failed to connect Lark bot:', error);
534
+ throw error;
535
+ }
536
+ }
537
+
538
+ async $disconnect(): Promise<void> {
539
+ try {
540
+ this.$connected = false;
541
+ this.plugin.logger.info('Lark bot disconnected');
542
+ } catch (error) {
543
+ this.plugin.logger.error('Error disconnecting Lark bot:', error);
544
+ }
545
+ }
546
+
547
+ // ================================================================================================
548
+ // 工具方法
549
+ // ================================================================================================
550
+
551
+ // 获取用户信息
552
+ async getUserInfo(userId: string, userIdType: 'open_id' | 'user_id' | 'union_id' = 'open_id'): Promise<any> {
553
+ try {
554
+ const response = await this.axiosInstance.get(`/contact/v3/users/${userId}`, {
555
+ params: { user_id_type: userIdType }
556
+ });
557
+
558
+ return response.data.data?.user;
559
+ } catch (error) {
560
+ this.plugin.logger.error('Failed to get user info:', error);
561
+ return null;
562
+ }
563
+ }
564
+
565
+ // 获取群聊信息
566
+ async getChatInfo(chatId: string): Promise<any> {
567
+ try {
568
+ const response = await this.axiosInstance.get(`/im/v1/chats/${chatId}`);
569
+ return response.data.data;
570
+ } catch (error) {
571
+ this.plugin.logger.error('Failed to get chat info:', error);
572
+ return null;
573
+ }
574
+ }
575
+
576
+ // 上传文件
577
+ async uploadFile(filePath: string, fileType: 'image' | 'file' | 'video' | 'audio' = 'file'): Promise<string | null> {
578
+ try {
579
+ const FormData = require('form-data');
580
+ const fs = require('fs');
581
+
582
+ const form = new FormData();
583
+ form.append('file', fs.createReadStream(filePath));
584
+ form.append('file_type', fileType);
585
+
586
+ const response = await this.axiosInstance.post('/im/v1/files', form, {
587
+ headers: {
588
+ ...form.getHeaders()
589
+ }
590
+ });
591
+
592
+ if (response.data.code === 0) {
593
+ return response.data.data.file_key;
594
+ }
595
+
596
+ throw new Error(`Upload failed: ${response.data.msg}`);
597
+ } catch (error) {
598
+ this.plugin.logger.error('Failed to upload file:', error);
599
+ return null;
600
+ }
601
+ }
602
+ }
603
+
604
+ // 注册适配器(需要 router)
605
+ useContext('router', (router) => {
606
+ registerAdapter(new Adapter('lark',
607
+ (plugin: Plugin, config: any) => new LarkBot(plugin, router, config as LarkBotConfig)
608
+ ));
609
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./lib",
5
+ "rootDir": "./src",
6
+ "composite": true,
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "moduleResolution": "node",
15
+ "allowSyntheticDefaultImports": true,
16
+ "resolveJsonModule": true
17
+ },
18
+ "include": [
19
+ "src/**/*"
20
+ ],
21
+ "exclude": [
22
+ "lib",
23
+ "node_modules"
24
+ ],
25
+ }