@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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +651 -0
- package/package.json +44 -0
- package/src/index.ts +609 -0
- package/tsconfig.json +25 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
+
}
|