@zhin.js/adapter-lark 1.1.26 → 1.1.27
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 +137 -0
- package/LICENSE +21 -0
- package/PERMITS.md +11 -11
- package/README.md +7 -3
- package/adapters/lark/index.js +3 -0
- package/adapters/lark/index.ts +3 -0
- package/agents/lark/conversation.js +15 -0
- package/agents/lark/conversation.ts +17 -0
- package/agents/lark/messages.js +4 -0
- package/agents/lark/messages.ts +4 -0
- package/agents/lark/skills/lark/tools/upload_file/index.js +3 -1
- package/agents/lark/skills/lark/tools/upload_file/index.ts +3 -1
- package/agents/lark/skills/lark-group-admin/tools/add_members/index.js +5 -6
- package/agents/lark/skills/lark-group-admin/tools/add_members/index.ts +6 -7
- package/agents/lark/skills/lark-group-admin/tools/dissolve_chat/index.js +6 -6
- package/agents/lark/skills/lark-group-admin/tools/dissolve_chat/index.ts +7 -7
- package/agents/lark/skills/lark-group-admin/tools/remove_managers/index.js +5 -6
- package/agents/lark/skills/lark-group-admin/tools/remove_managers/index.ts +6 -7
- package/agents/lark/skills/lark-group-admin/tools/set_managers/index.js +5 -6
- package/agents/lark/skills/lark-group-admin/tools/set_managers/index.ts +6 -7
- package/agents/lark/skills/lark-group-admin/tools/update_chat/index.js +5 -6
- package/agents/lark/skills/lark-group-admin/tools/update_chat/index.ts +6 -7
- package/lib/client.js +49 -16
- package/lib/index.d.ts +2 -2
- package/lib/index.js +2 -2
- package/lib/platform-permit.d.ts +4 -11
- package/lib/platform-permit.js +7 -33
- package/lib/protocol.d.ts +10 -5
- package/lib/protocol.js +17 -20
- package/lib/webhook.d.ts +2 -3
- package/lib/webhook.js +35 -28
- package/package.json +25 -26
- package/plugin.js +0 -6
- package/schema.json +0 -56
- package/src/client.ts +44 -19
- package/src/index.ts +2 -7
- package/src/platform-permit.ts +5 -45
- package/src/protocol.ts +21 -24
- package/src/webhook.ts +38 -33
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
2
|
import { messages as t } from "../../../../messages.js";
|
|
3
|
+
import { currentChat } from "../../../../conversation.js";
|
|
3
4
|
import { defineAgentTool } from '@zhin.js/tool';
|
|
4
5
|
import { z } from 'zod';
|
|
5
|
-
import { platformPermission } from '@zhin.js/permission';
|
|
6
6
|
export default defineAgentTool({
|
|
7
|
-
description: '
|
|
7
|
+
description: '更新当前飞书群聊信息(群名、描述)',
|
|
8
8
|
inputSchema: z.object({
|
|
9
|
-
chat_id: z.string().describe('群聊 ID'),
|
|
10
9
|
name: z.string().optional().describe('新群名(可选)'),
|
|
11
10
|
description: z.string().optional().describe('新描述(可选)'),
|
|
12
11
|
}),
|
|
13
12
|
adapter: 'lark',
|
|
14
13
|
scopes: ["group"],
|
|
15
14
|
tags: ['lark'],
|
|
16
|
-
permissions: [
|
|
17
|
-
async execute({
|
|
15
|
+
permissions: ['platform:admin', 'bot:admin'],
|
|
16
|
+
async execute({ name, description }, context) {
|
|
18
17
|
const endpoint = context.$client;
|
|
19
|
-
const success = await endpoint.updateChatInfo(
|
|
18
|
+
const success = await endpoint.updateChatInfo(currentChat(context), { name, description });
|
|
20
19
|
return { success, message: success ? t(context.locale, 'chatInfoUpdated') : t(context.locale, 'updateFailed') };
|
|
21
20
|
},
|
|
22
21
|
});
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import { messages as t } from "../../../../messages.js";
|
|
2
|
+
import { currentChat } from "../../../../conversation.js";
|
|
2
3
|
import { defineAgentTool } from '@zhin.js/tool';
|
|
3
4
|
import { z } from 'zod';
|
|
4
|
-
import { platformPermission } from '@zhin.js/permission';
|
|
5
5
|
|
|
6
|
-
export default defineAgentTool<{
|
|
7
|
-
description: '
|
|
6
|
+
export default defineAgentTool<{ name?: string; description?: string }>({
|
|
7
|
+
description: '更新当前飞书群聊信息(群名、描述)',
|
|
8
8
|
inputSchema: z.object({
|
|
9
|
-
chat_id: z.string().describe('群聊 ID'),
|
|
10
9
|
name: z.string().optional().describe('新群名(可选)'),
|
|
11
10
|
description: z.string().optional().describe('新描述(可选)'),
|
|
12
11
|
}),
|
|
13
12
|
adapter: 'lark',
|
|
14
13
|
scopes: ["group"],
|
|
15
14
|
tags: ['lark'],
|
|
16
|
-
permissions: [
|
|
17
|
-
async execute({
|
|
15
|
+
permissions: ['platform:admin', 'bot:admin'],
|
|
16
|
+
async execute({ name, description }, context) {
|
|
18
17
|
const endpoint = context.$client;
|
|
19
|
-
const success = await endpoint.updateChatInfo(
|
|
18
|
+
const success = await endpoint.updateChatInfo(currentChat(context), { name, description });
|
|
20
19
|
return { success, message: success ? t(context.locale, 'chatInfoUpdated') : t(context.locale, 'updateFailed') };
|
|
21
20
|
},
|
|
22
21
|
});
|
package/lib/client.js
CHANGED
|
@@ -11,7 +11,6 @@ import { createEndpointLifecycle, createEndpointOperation, createRecallEndpointC
|
|
|
11
11
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
12
12
|
import { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
|
|
13
13
|
import { formatInboundContent, formatOutboundBody, generateMessageId, isLarkBotMentioned, larkInboundConversation, resolveChatType, resolveSender, } from './protocol.js';
|
|
14
|
-
import { normalizeLarkSenderForPermit } from './platform-permit.js';
|
|
15
14
|
import { registerLarkWebhookRoutes } from './webhook.js';
|
|
16
15
|
import { createLarkLongConnection } from './long-connection.js';
|
|
17
16
|
import { openLarkReplyStream, supportsLarkReplyStream } from './reply-stream.js';
|
|
@@ -19,6 +18,7 @@ import { openLarkReplyStream, supportsLarkReplyStream } from './reply-stream.js'
|
|
|
19
18
|
const OUTBOUND_TIMEOUT_MS = 30_000;
|
|
20
19
|
/** Feishu/Lark OpenAPI client surface carried by every Endpoint event. */
|
|
21
20
|
const LARK_CHAT_MANAGER_CACHE_TTL_MS = 60_000;
|
|
21
|
+
const LARK_CHAT_MANAGER_FAILURE_TTL_MS = 10_000;
|
|
22
22
|
export class LarkClient extends SdkClient {
|
|
23
23
|
#logger;
|
|
24
24
|
#options;
|
|
@@ -47,6 +47,27 @@ export class LarkClient extends SdkClient {
|
|
|
47
47
|
return groups;
|
|
48
48
|
},
|
|
49
49
|
listGroupMembers: ({ groupId, signal }) => this.#listAllPages(`/im/v1/chats/${encodeURIComponent(groupId)}/members`, { params: { member_id_type: 'open_id' }, signal }),
|
|
50
|
+
/**
|
|
51
|
+
* ADR 0056 §1:机器人自身在群里的地位(chat.get:群主为本应用 → owner;在 bot_manager_id_list →
|
|
52
|
+
* admin;有 owner_id 且都不是 → member)。查询失败或缺 owner_id 为未知。
|
|
53
|
+
*/
|
|
54
|
+
inspectSelfStanding: async ({ conversation, signal }) => {
|
|
55
|
+
if (conversation.kind !== 'group')
|
|
56
|
+
return undefined;
|
|
57
|
+
const appId = this.#options.config.appId;
|
|
58
|
+
const data = await createEndpointOperation({ signal }).run(() => this.#request(`/im/v1/chats/${encodeURIComponent(conversation.id)}`, { method: 'GET' }));
|
|
59
|
+
const info = data?.data;
|
|
60
|
+
if (!info || !appId)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (info.owner_id_type === 'app_id' && info.owner_id === appId)
|
|
63
|
+
return { standing: 'owner' };
|
|
64
|
+
const managers = Array.isArray(info.bot_manager_id_list) ? info.bot_manager_id_list.map(String) : [];
|
|
65
|
+
if (managers.includes(appId))
|
|
66
|
+
return { standing: 'admin' };
|
|
67
|
+
// chat.get may omit owner_id (e.g. when this app owns the group): without it the Bot is not
|
|
68
|
+
// provably a plain member, so the standing stays unknown.
|
|
69
|
+
return typeof info.owner_id === 'string' && info.owner_id ? { standing: 'member' } : undefined;
|
|
70
|
+
},
|
|
50
71
|
});
|
|
51
72
|
endpointControl = createRecallEndpointControl((id, signal) => this.recallMessage(id, { signal }));
|
|
52
73
|
get endpointStreaming() {
|
|
@@ -65,6 +86,8 @@ export class LarkClient extends SdkClient {
|
|
|
65
86
|
#chatManagers = new TtlLookupCache({
|
|
66
87
|
ttlMs: LARK_CHAT_MANAGER_CACHE_TTL_MS,
|
|
67
88
|
});
|
|
89
|
+
/** chat.get failures: the standing stays unknown for a short while instead of retrying every message. */
|
|
90
|
+
#chatManagerFailures = new TtlLookupCache({ ttlMs: LARK_CHAT_MANAGER_FAILURE_TTL_MS });
|
|
68
91
|
#startupGeneration = 0;
|
|
69
92
|
#pendingInbound = [];
|
|
70
93
|
#bindSignal;
|
|
@@ -313,11 +336,10 @@ export class LarkClient extends SdkClient {
|
|
|
313
336
|
async #emitInbound(msg, conversation, nativeEvent) {
|
|
314
337
|
const chatType = resolveChatType(msg.chat_id, msg.chat_type);
|
|
315
338
|
const senderId = resolveSender(msg);
|
|
316
|
-
|
|
317
|
-
|
|
339
|
+
// ADR 0056 §1:群主 / 群管理员 / 成员 → sender.platform.standing(飞书群无原生角色与权限位);未知不上报。
|
|
340
|
+
const role = senderId && chatType === 'group' && msg.chat_id
|
|
341
|
+
? await this.#resolveChatRole(msg.chat_id, senderId)
|
|
318
342
|
: undefined;
|
|
319
|
-
// 规范角色 owner / admin 进 roles;细粒度能力放 senderPermissions
|
|
320
|
-
const role = permit?.role === 'owner' || permit?.role === 'admin' ? permit.role : undefined;
|
|
321
343
|
const mentioned = isLarkBotMentioned(msg);
|
|
322
344
|
const replyToId = msg.parent_id || msg.root_id;
|
|
323
345
|
await this.publishEvent('message.receive', {
|
|
@@ -325,32 +347,43 @@ export class LarkClient extends SdkClient {
|
|
|
325
347
|
conversation,
|
|
326
348
|
message: { conversation, id: generateMessageId(msg) },
|
|
327
349
|
content: formatInboundContent(msg),
|
|
328
|
-
...(senderId ? { sender: { id: senderId, ...(role ? {
|
|
350
|
+
...(senderId ? { sender: { id: senderId, ...(role ? { platform: { standing: role } } : {}) } } : {}),
|
|
329
351
|
endpointId: this.#options.config.id,
|
|
330
352
|
...(mentioned ? { mentioned: true } : {}),
|
|
331
353
|
...(replyToId ? { replyTo: { id: replyToId } } : {}),
|
|
332
354
|
metadata: Object.freeze({
|
|
333
355
|
messageType: msg.message_type,
|
|
334
356
|
chatType,
|
|
335
|
-
...(role ? { senderRole: role } : {}),
|
|
336
|
-
...(role && permit?.permissions?.length ? { senderPermissions: [...permit.permissions] } : {}),
|
|
337
357
|
}),
|
|
338
358
|
});
|
|
339
359
|
}
|
|
340
|
-
/**
|
|
360
|
+
/**
|
|
361
|
+
* 群主 / 群管理员来自 chat.get(owner_id + 管理员列表),按群缓存 60 秒。chat.get 失败时地位未知
|
|
362
|
+
* (负缓存 10 秒,不报确定的 member);未返回 owner_id(如群主是机器人应用)时只能确认管理员,
|
|
363
|
+
* 其余发送者地位未知。
|
|
364
|
+
*/
|
|
341
365
|
async #resolveChatRole(chatId, senderId) {
|
|
366
|
+
if (this.#chatManagerFailures.get(chatId))
|
|
367
|
+
return undefined;
|
|
342
368
|
const entry = await this.#chatManagers.getOrLoad(chatId, async () => {
|
|
343
369
|
const info = await this.getChatInfo(chatId);
|
|
344
|
-
|
|
370
|
+
if (!info) {
|
|
371
|
+
this.#chatManagerFailures.set(chatId, true);
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
const admins = Array.isArray(info.user_manager_id_list) ? info.user_manager_id_list.map(String) : [];
|
|
345
375
|
return {
|
|
346
|
-
...(typeof info
|
|
376
|
+
...(typeof info.owner_id === 'string' && info.owner_id ? { owner: info.owner_id } : {}),
|
|
347
377
|
admins: new Set(admins),
|
|
348
378
|
};
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
379
|
+
}, { cacheWhen: entry => entry !== undefined });
|
|
380
|
+
if (!entry)
|
|
381
|
+
return undefined;
|
|
382
|
+
if (entry.owner !== undefined && entry.owner === senderId)
|
|
383
|
+
return 'owner';
|
|
384
|
+
if (entry.admins.has(senderId))
|
|
385
|
+
return 'admin';
|
|
386
|
+
return entry.owner !== undefined ? 'member' : undefined;
|
|
354
387
|
}
|
|
355
388
|
/** Only correlate callbacks to a card actually sent by this endpoint. */
|
|
356
389
|
admitCardAction(event) {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { formatInboundContent, formatOutboundBody, generateMessageId, headerValue, larkInboundConversation, normalizeWebhookPath,
|
|
1
|
+
export { formatInboundContent, formatOutboundBody, generateMessageId, headerValue, larkInboundConversation, normalizeWebhookPath, resolveChatType, resolveLarkConfig, resolveSender, decryptLarkEvent, verifySignature, type AccessToken, type LarkEndpointConfig, type LarkApiResponse, type LarkEventBody, type LarkMessage, type LarkSendBody, type LarkWireSegment, type ResolvedLarkConfig, } from './protocol.js';
|
|
2
2
|
export { type LarkClientEventMap } from './client.js';
|
|
3
|
-
export {
|
|
3
|
+
export { platformPermit } from './platform-permit.js';
|
|
4
4
|
export { LarkEndpoint, type LarkEndpointOptions, type LarkFetch, } from './endpoint.js';
|
|
5
5
|
export { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, type MediaBinary, } from './media-upload.js';
|
|
6
6
|
export { registerLarkWebhookRoutes, handleLarkWebhookRequest, type LarkWebhookHandler, } from './webhook.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { formatInboundContent, formatOutboundBody, generateMessageId, headerValue, larkInboundConversation, normalizeWebhookPath,
|
|
2
|
-
export {
|
|
1
|
+
export { formatInboundContent, formatOutboundBody, generateMessageId, headerValue, larkInboundConversation, normalizeWebhookPath, resolveChatType, resolveLarkConfig, resolveSender, decryptLarkEvent, verifySignature, } from './protocol.js';
|
|
2
|
+
export { platformPermit } from './platform-permit.js';
|
|
3
3
|
export { LarkEndpoint, } from './endpoint.js';
|
|
4
4
|
export { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
|
|
5
5
|
export { registerLarkWebhookRoutes, handleLarkWebhookRequest, } from './webhook.js';
|
package/lib/platform-permit.d.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 飞书 Lark platform permit
|
|
2
|
+
* 飞书 Lark platform permit helper(ADR 0056 §5)。
|
|
3
|
+
*
|
|
4
|
+
* 平台身份由适配器上报到 `sender.platform`,permit 由内置评估器(`@zhin.js/permission`)判断;
|
|
5
|
+
* 适配器不再导出自有的 permit 解析器或检查函数。
|
|
3
6
|
*/
|
|
4
|
-
import type { PermissionSubject } from '@zhin.js/permission';
|
|
5
7
|
export declare function platformPermit(perm: string): string;
|
|
6
|
-
export declare function larkGroupPermitResolver(logicalPerm: string): string;
|
|
7
|
-
export declare function normalizeLarkSenderForPermit(input: {
|
|
8
|
-
isOwner?: boolean;
|
|
9
|
-
isAdmin?: boolean;
|
|
10
|
-
}): {
|
|
11
|
-
role?: string;
|
|
12
|
-
permissions?: string[];
|
|
13
|
-
};
|
|
14
|
-
export declare function checkLarkPlatformPermit(perm: string, subject: PermissionSubject): boolean;
|
package/lib/platform-permit.js
CHANGED
|
@@ -1,35 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 飞书 Lark platform permit helper(ADR 0056 §5)。
|
|
3
|
+
*
|
|
4
|
+
* 平台身份由适配器上报到 `sender.platform`,permit 由内置评估器(`@zhin.js/permission`)判断;
|
|
5
|
+
* 适配器不再导出自有的 permit 解析器或检查函数。
|
|
6
|
+
*/
|
|
2
7
|
export function platformPermit(perm) {
|
|
3
|
-
return `platform
|
|
4
|
-
}
|
|
5
|
-
const FACTORY_PERM_MAP = {
|
|
6
|
-
scene_admin: 'chat_admin',
|
|
7
|
-
scene_owner: 'chat_owner',
|
|
8
|
-
};
|
|
9
|
-
export function larkGroupPermitResolver(logicalPerm) {
|
|
10
|
-
return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
|
|
11
|
-
}
|
|
12
|
-
export function normalizeLarkSenderForPermit(input) {
|
|
13
|
-
if (input.isOwner) {
|
|
14
|
-
return { role: 'owner', permissions: ['chat_owner', 'chat_admin', 'manage_managers'] };
|
|
15
|
-
}
|
|
16
|
-
if (input.isAdmin) {
|
|
17
|
-
return { role: 'admin', permissions: ['chat_admin'] };
|
|
18
|
-
}
|
|
19
|
-
return { role: 'member', permissions: [] };
|
|
20
|
-
}
|
|
21
|
-
export function checkLarkPlatformPermit(perm, subject) {
|
|
22
|
-
const role = subject.sender?.role?.[0];
|
|
23
|
-
const permissions = subject.sender?.permissions ?? [];
|
|
24
|
-
const has = (t) => permissions.includes(t);
|
|
25
|
-
switch (perm) {
|
|
26
|
-
case 'chat_owner':
|
|
27
|
-
return role === 'owner' || has('chat_owner');
|
|
28
|
-
case 'chat_admin':
|
|
29
|
-
return role === 'owner' || role === 'admin' || has('chat_admin') || has('chat_owner');
|
|
30
|
-
case 'manage_managers':
|
|
31
|
-
return role === 'owner' || has('chat_owner');
|
|
32
|
-
default:
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
8
|
+
return `platform-permission:${perm}`;
|
|
35
9
|
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
2
2
|
import type { Locale } from 'zhin.js/i18n';
|
|
3
|
-
import type { IncomingMessage } from 'node:http';
|
|
4
3
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
5
4
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
6
5
|
export interface LarkEndpointConfig extends EndpointProfileConfiguration {
|
|
@@ -72,6 +71,8 @@ export interface LarkEventBody {
|
|
|
72
71
|
readonly ts?: string;
|
|
73
72
|
readonly type?: string;
|
|
74
73
|
readonly challenge?: string;
|
|
74
|
+
/** Present instead of every other field when the app has an Encrypt Key. */
|
|
75
|
+
readonly encrypt?: string;
|
|
75
76
|
readonly event?: {
|
|
76
77
|
readonly sender?: LarkMessage['sender'];
|
|
77
78
|
readonly message?: LarkMessage;
|
|
@@ -118,6 +119,13 @@ export declare function formatInboundContent(msg: LarkMessage): string;
|
|
|
118
119
|
/** 验签时间戳时效窗口(防重放)。 */
|
|
119
120
|
export declare const LARK_SIGNATURE_MAX_AGE_MS: number;
|
|
120
121
|
export declare function verifySignature(encryptKey: string, timestamp: string, nonce: string, body: string, signature: string, nowMs?: number): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Decrypt an `{"encrypt": "..."}` webhook payload (Lark/Feishu open-platform
|
|
124
|
+
* spec): key = SHA-256(Encrypt Key), base64 payload = 16-byte IV followed by
|
|
125
|
+
* AES-256-CBC ciphertext with PKCS#7 padding. Throws when the payload cannot
|
|
126
|
+
* be decrypted with this key.
|
|
127
|
+
*/
|
|
128
|
+
export declare function decryptLarkEvent(encryptKey: string, encrypted: string): string;
|
|
121
129
|
/** timing-safe 比较 webhook verification token(长度不同快速失败)。 */
|
|
122
130
|
export declare function verifyToken(token: string, expected: string): boolean;
|
|
123
131
|
/**
|
|
@@ -125,7 +133,4 @@ export declare function verifyToken(token: string, expected: string): boolean;
|
|
|
125
133
|
* Segment canonicalization is intentionally not done here.
|
|
126
134
|
*/
|
|
127
135
|
export declare function formatOutboundBody(payload: unknown, locale?: Locale): LarkSendBody;
|
|
128
|
-
export declare function headerValue(headers:
|
|
129
|
-
export declare function readTextBody(request: IncomingMessage, options?: {
|
|
130
|
-
readonly limit?: number;
|
|
131
|
-
}): Promise<string>;
|
|
136
|
+
export declare function headerValue(headers: Headers, name: string): string;
|
package/lib/protocol.js
CHANGED
|
@@ -3,7 +3,7 @@ import { validateLarkWebApiProxy } from './web-api-proxy.js';
|
|
|
3
3
|
* Lark/Feishu protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
4
4
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
5
5
|
*/
|
|
6
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { createDecipheriv, createHash, timingSafeEqual } from 'node:crypto';
|
|
7
7
|
import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
|
|
8
8
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
9
9
|
import { formatLarkCard } from './cards.js';
|
|
@@ -150,6 +150,21 @@ export function verifySignature(encryptKey, timestamp, nonce, body, signature, n
|
|
|
150
150
|
return false;
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Decrypt an `{"encrypt": "..."}` webhook payload (Lark/Feishu open-platform
|
|
155
|
+
* spec): key = SHA-256(Encrypt Key), base64 payload = 16-byte IV followed by
|
|
156
|
+
* AES-256-CBC ciphertext with PKCS#7 padding. Throws when the payload cannot
|
|
157
|
+
* be decrypted with this key.
|
|
158
|
+
*/
|
|
159
|
+
export function decryptLarkEvent(encryptKey, encrypted) {
|
|
160
|
+
const payload = Buffer.from(encrypted, 'base64');
|
|
161
|
+
if (payload.length <= 16 || (payload.length - 16) % 16 !== 0) {
|
|
162
|
+
throw new Error('Malformed encrypted Lark event');
|
|
163
|
+
}
|
|
164
|
+
const key = createHash('sha256').update(encryptKey).digest();
|
|
165
|
+
const decipher = createDecipheriv('aes-256-cbc', key, payload.subarray(0, 16));
|
|
166
|
+
return Buffer.concat([decipher.update(payload.subarray(16)), decipher.final()]).toString('utf8');
|
|
167
|
+
}
|
|
153
168
|
/** timing-safe 比较 webhook verification token(长度不同快速失败)。 */
|
|
154
169
|
export function verifyToken(token, expected) {
|
|
155
170
|
try {
|
|
@@ -323,23 +338,5 @@ export function formatOutboundBody(payload, locale) {
|
|
|
323
338
|
};
|
|
324
339
|
}
|
|
325
340
|
export function headerValue(headers, name) {
|
|
326
|
-
|
|
327
|
-
if (Array.isArray(value))
|
|
328
|
-
return value[0] ?? '';
|
|
329
|
-
return value ?? '';
|
|
330
|
-
}
|
|
331
|
-
export async function readTextBody(request, options = {}) {
|
|
332
|
-
const limit = options.limit ?? 1_048_576;
|
|
333
|
-
const chunks = [];
|
|
334
|
-
let size = 0;
|
|
335
|
-
for await (const chunk of request) {
|
|
336
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
337
|
-
size += buffer.length;
|
|
338
|
-
if (size > limit) {
|
|
339
|
-
request.destroy();
|
|
340
|
-
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
341
|
-
}
|
|
342
|
-
chunks.push(buffer);
|
|
343
|
-
}
|
|
344
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
341
|
+
return headers.get(name) ?? '';
|
|
345
342
|
}
|
package/lib/webhook.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lark/Feishu webhook HTTP:
|
|
2
|
+
* Lark/Feishu webhook HTTP: signature → decrypt → verification token → challenge/event → admit.
|
|
3
3
|
*/
|
|
4
4
|
import type { LarkCardAction } from './cards.js';
|
|
5
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
5
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
6
|
import { type LarkMessage, type ResolvedLarkConfig } from './protocol.js';
|
|
8
7
|
export interface LarkWebhookHandler {
|
|
@@ -13,4 +12,4 @@ export interface LarkWebhookHandler {
|
|
|
13
12
|
admitCardAction?(event: LarkCardAction): void;
|
|
14
13
|
}
|
|
15
14
|
export declare function registerLarkWebhookRoutes(http: HttpHost, handler: LarkWebhookHandler): HttpRouteRegistration[];
|
|
16
|
-
export declare function handleLarkWebhookRequest(request:
|
|
15
|
+
export declare function handleLarkWebhookRequest(request: Request, handler: LarkWebhookHandler): Promise<Response>;
|
package/lib/webhook.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
+
import { readTextBody } from '@zhin.js/host-http-contract';
|
|
1
2
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
-
import {
|
|
3
|
+
import { decryptLarkEvent, headerValue, verifySignature, verifyToken, } from './protocol.js';
|
|
3
4
|
const logger = getLogger('lark');
|
|
4
5
|
export function registerLarkWebhookRoutes(http, handler) {
|
|
5
6
|
const path = handler.config.webhookPath;
|
|
6
7
|
return [
|
|
7
|
-
http.route('POST', path,
|
|
8
|
-
await handleLarkWebhookRequest(request, response, handler);
|
|
9
|
-
}, { summary: 'Lark/Feishu event webhook', tags: ['lark'] }),
|
|
8
|
+
http.route('POST', path, (request) => handleLarkWebhookRequest(request, handler), { summary: 'Lark/Feishu event webhook', tags: ['lark'] }),
|
|
10
9
|
];
|
|
11
10
|
}
|
|
12
|
-
export async function handleLarkWebhookRequest(request,
|
|
11
|
+
export async function handleLarkWebhookRequest(request, handler) {
|
|
13
12
|
try {
|
|
14
13
|
const rawBody = await readTextBody(request);
|
|
15
14
|
let event;
|
|
@@ -17,20 +16,9 @@ export async function handleLarkWebhookRequest(request, response, handler) {
|
|
|
17
16
|
event = JSON.parse(rawBody);
|
|
18
17
|
}
|
|
19
18
|
catch {
|
|
20
|
-
|
|
21
|
-
response.end(JSON.stringify({ code: 0, msg: 'success' }));
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
if (handler.config.verificationToken) {
|
|
25
|
-
const bodyToken = event.header?.token ?? event.token;
|
|
26
|
-
const token = typeof bodyToken === 'string' ? bodyToken : headerValue(request.headers, 'x-lark-request-token');
|
|
27
|
-
if (!verifyToken(token, handler.config.verificationToken)) {
|
|
28
|
-
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
|
|
29
|
-
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
30
|
-
response.end('Forbidden');
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
19
|
+
return new Response(JSON.stringify({ code: 0, msg: 'success' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
33
20
|
}
|
|
21
|
+
// Signature first: it covers the raw (possibly encrypted) body.
|
|
34
22
|
if (handler.config.encryptKey) {
|
|
35
23
|
const timestamp = headerValue(request.headers, 'x-lark-request-timestamp');
|
|
36
24
|
const nonce = headerValue(request.headers, 'x-lark-request-nonce');
|
|
@@ -40,9 +28,32 @@ export async function handleLarkWebhookRequest(request, response, handler) {
|
|
|
40
28
|
|| !signature
|
|
41
29
|
|| !verifySignature(handler.config.encryptKey, timestamp, nonce, rawBody, signature)) {
|
|
42
30
|
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof event?.encrypt === 'string') {
|
|
35
|
+
if (!handler.config.encryptKey) {
|
|
36
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'encrypted event received but encryptKey is not configured' }));
|
|
37
|
+
return new Response(JSON.stringify({ code: -1, msg: 'Encrypt key is not configured' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
event = JSON.parse(decryptLarkEvent(handler.config.encryptKey, event.encrypt));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'event decryption failed' }));
|
|
44
|
+
return new Response(JSON.stringify({ code: -1, msg: 'Decryption failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!event || typeof event !== 'object') {
|
|
48
|
+
return new Response(JSON.stringify({ code: 0, msg: 'success' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
49
|
+
}
|
|
50
|
+
// Token and challenge live inside the decrypted event.
|
|
51
|
+
if (handler.config.verificationToken) {
|
|
52
|
+
const bodyToken = event.header?.token ?? event.token;
|
|
53
|
+
const token = typeof bodyToken === 'string' ? bodyToken : headerValue(request.headers, 'x-lark-request-token');
|
|
54
|
+
if (!verifyToken(token, handler.config.verificationToken)) {
|
|
55
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
|
|
56
|
+
return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
59
|
if (handler.isOpen) {
|
|
@@ -52,9 +63,7 @@ export async function handleLarkWebhookRequest(request, response, handler) {
|
|
|
52
63
|
handler.admitPlatform(eventName, event);
|
|
53
64
|
}
|
|
54
65
|
if (event.type === 'url_verification') {
|
|
55
|
-
|
|
56
|
-
response.end(JSON.stringify({ challenge: event.challenge }));
|
|
57
|
-
return;
|
|
66
|
+
return new Response(JSON.stringify({ challenge: event.challenge }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
58
67
|
}
|
|
59
68
|
const headerEventType = event.header?.event_type;
|
|
60
69
|
if (headerEventType === 'card.action.trigger' && handler.isOpen)
|
|
@@ -66,12 +75,10 @@ export async function handleLarkWebhookRequest(request, response, handler) {
|
|
|
66
75
|
};
|
|
67
76
|
handler.admit(message, event);
|
|
68
77
|
}
|
|
69
|
-
|
|
70
|
-
response.end(JSON.stringify({ code: 0, msg: 'success' }));
|
|
78
|
+
return new Response(JSON.stringify({ code: 0, msg: 'success' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
71
79
|
}
|
|
72
80
|
catch (error) {
|
|
73
81
|
logger.error('Webhook error:', error);
|
|
74
|
-
|
|
75
|
-
response.end(JSON.stringify({ code: -1, msg: 'Internal Server Error' }));
|
|
82
|
+
return new Response(JSON.stringify({ code: -1, msg: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
76
83
|
}
|
|
77
84
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-lark",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
4
4
|
"description": "Zhin.js Lark/Feishu adapter (HTTP webhook and WebSocket long connection)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -35,26 +35,27 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@larksuiteoapi/node-sdk": "^1.67.0",
|
|
37
37
|
"undici": "6.29.1",
|
|
38
|
-
"
|
|
39
|
-
"@zhin.js/
|
|
40
|
-
"@zhin.js/
|
|
41
|
-
"@zhin.js/
|
|
42
|
-
"@zhin.js/
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
45
|
-
"@zhin.js/
|
|
46
|
-
"@zhin.js/
|
|
47
|
-
"@zhin.js/
|
|
48
|
-
"@zhin.js/
|
|
38
|
+
"zod": "^4.6.7",
|
|
39
|
+
"@zhin.js/adapter": "1.1.33",
|
|
40
|
+
"@zhin.js/agent-feature": "1.1.17",
|
|
41
|
+
"@zhin.js/console-contract": "1.1.6",
|
|
42
|
+
"@zhin.js/core": "1.2.0",
|
|
43
|
+
"@zhin.js/feature-kit": "1.1.16",
|
|
44
|
+
"@zhin.js/host-http": "1.2.0",
|
|
45
|
+
"@zhin.js/host-http-contract": "1.2.0",
|
|
46
|
+
"@zhin.js/im-contract": "1.1.14",
|
|
47
|
+
"@zhin.js/logger": "1.1.3",
|
|
48
|
+
"@zhin.js/page": "1.1.18",
|
|
49
|
+
"@zhin.js/skill": "1.1.17",
|
|
50
|
+
"@zhin.js/tool": "1.1.19"
|
|
49
51
|
},
|
|
50
52
|
"peerDependencies": {
|
|
51
|
-
"
|
|
52
|
-
"@zhin.js/
|
|
53
|
-
"@zhin.js/
|
|
54
|
-
"@zhin.js/
|
|
55
|
-
"@zhin.js/
|
|
56
|
-
"
|
|
57
|
-
"zhin.js": "^1.1.25"
|
|
53
|
+
"@zhin.js/adapter": "^1.1.33",
|
|
54
|
+
"@zhin.js/command": "^1.2.0",
|
|
55
|
+
"@zhin.js/core": "^1.2.0",
|
|
56
|
+
"@zhin.js/host-http": "^1.2.0",
|
|
57
|
+
"@zhin.js/permission": "^1.2.0",
|
|
58
|
+
"zhin.js": "^1.1.26"
|
|
58
59
|
},
|
|
59
60
|
"peerDependenciesMeta": {
|
|
60
61
|
"@zhin.js/command": {
|
|
@@ -62,17 +63,16 @@
|
|
|
62
63
|
},
|
|
63
64
|
"zhin.js": {
|
|
64
65
|
"optional": true
|
|
65
|
-
},
|
|
66
|
-
"zod": {
|
|
67
|
-
"optional": true
|
|
68
66
|
}
|
|
69
67
|
},
|
|
70
68
|
"devDependencies": {
|
|
71
69
|
"@types/node": "^26.1.2",
|
|
70
|
+
"@types/ws": "^8.18.1",
|
|
71
|
+
"ajv": "8.18.0",
|
|
72
72
|
"typescript": "^6.0.3",
|
|
73
73
|
"vitest": "^4.1.10",
|
|
74
|
-
"
|
|
75
|
-
"zhin.js": "1.1.
|
|
74
|
+
"ws": "^8.21.1",
|
|
75
|
+
"zhin.js": "1.1.26"
|
|
76
76
|
},
|
|
77
77
|
"files": [
|
|
78
78
|
"adapters",
|
|
@@ -82,7 +82,6 @@
|
|
|
82
82
|
"schema.json",
|
|
83
83
|
"src",
|
|
84
84
|
"lib",
|
|
85
|
-
"tools",
|
|
86
85
|
"agents",
|
|
87
86
|
"README.md",
|
|
88
87
|
"CHANGELOG.md",
|
|
@@ -132,6 +131,6 @@
|
|
|
132
131
|
"scripts": {
|
|
133
132
|
"build": "tsc",
|
|
134
133
|
"clean": "rimraf lib",
|
|
135
|
-
"test": "
|
|
134
|
+
"test": "vitest run --root ../../.. plugins/adapters/lark/tests"
|
|
136
135
|
}
|
|
137
136
|
}
|
package/plugin.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
2
|
import { definePlugin } from 'zhin.js';
|
|
3
|
-
import { permissionHostToken } from '@zhin.js/permission';
|
|
4
|
-
import { checkLarkPlatformPermit } from "./lib/platform-permit.js";
|
|
5
3
|
import { createLarkConsoleRpc } from "./lib/console.js";
|
|
6
4
|
export default definePlugin({
|
|
7
5
|
name: 'lark',
|
|
@@ -10,12 +8,8 @@ export default definePlugin({
|
|
|
10
8
|
displayName: 'Lark/Feishu (飞书) Adapter',
|
|
11
9
|
},
|
|
12
10
|
setup(context) {
|
|
13
|
-
const dispose = context.resources.has(permissionHostToken)
|
|
14
|
-
? context.resources.use(permissionHostToken).registerPlatform('lark', checkLarkPlatformPermit)
|
|
15
|
-
: undefined;
|
|
16
11
|
return {
|
|
17
12
|
consoleRpc: createLarkConsoleRpc(context.resources, () => context.config.get()),
|
|
18
|
-
...(dispose ? { dispose } : {}),
|
|
19
13
|
};
|
|
20
14
|
},
|
|
21
15
|
});
|