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