@zhin.js/adapter-lark 1.1.26 → 1.1.28
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 +151 -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
package/schema.json
CHANGED
|
@@ -14,34 +14,6 @@
|
|
|
14
14
|
"type": "boolean",
|
|
15
15
|
"default": true
|
|
16
16
|
},
|
|
17
|
-
"master": {
|
|
18
|
-
"type": [
|
|
19
|
-
"string",
|
|
20
|
-
"number"
|
|
21
|
-
],
|
|
22
|
-
"description": "框架 master(Lark/Feishu open_id or user_id;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖",
|
|
23
|
-
"x-i18n": {
|
|
24
|
-
"en": {
|
|
25
|
-
"description": "Framework masters (Lark/Feishu open_id or user_id; AI/tool permissions and endpoint management). endpoints[i].master can override this value."
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
},
|
|
29
|
-
"trusted": {
|
|
30
|
-
"type": "array",
|
|
31
|
-
"items": {
|
|
32
|
-
"type": [
|
|
33
|
-
"string",
|
|
34
|
-
"number"
|
|
35
|
-
],
|
|
36
|
-
"description": "Trusted Lark/Feishu open_id or user_id"
|
|
37
|
-
},
|
|
38
|
-
"description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加",
|
|
39
|
-
"x-i18n": {
|
|
40
|
-
"en": {
|
|
41
|
-
"description": "Framework trusted users, below master authority. endpoints[i].trusted can add users."
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
17
|
"endpoints": {
|
|
46
18
|
"type": "array",
|
|
47
19
|
"minItems": 1,
|
|
@@ -50,34 +22,6 @@
|
|
|
50
22
|
"type": "object",
|
|
51
23
|
"additionalProperties": false,
|
|
52
24
|
"properties": {
|
|
53
|
-
"master": {
|
|
54
|
-
"type": [
|
|
55
|
-
"string",
|
|
56
|
-
"number"
|
|
57
|
-
],
|
|
58
|
-
"description": "本 endpoint 的框架 master(Lark/Feishu open_id or user_id);覆盖顶层 master",
|
|
59
|
-
"x-i18n": {
|
|
60
|
-
"en": {
|
|
61
|
-
"description": "Framework masters for this endpoint (Lark/Feishu open_id or user_id), overriding the top-level master list."
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
"trusted": {
|
|
66
|
-
"type": "array",
|
|
67
|
-
"items": {
|
|
68
|
-
"type": [
|
|
69
|
-
"string",
|
|
70
|
-
"number"
|
|
71
|
-
],
|
|
72
|
-
"description": "Trusted Lark/Feishu open_id or user_id"
|
|
73
|
-
},
|
|
74
|
-
"description": "本 endpoint 的 trusted 列表",
|
|
75
|
-
"x-i18n": {
|
|
76
|
-
"en": {
|
|
77
|
-
"description": "Trusted users for this endpoint."
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
25
|
"appId": {
|
|
82
26
|
"type": "string",
|
|
83
27
|
"description": "Lark app ID"
|
package/src/client.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Client as SdkClient, LoggerLevel, DefaultCache } from '@larksuiteoapi/n
|
|
|
2
2
|
import { createLarkSdkHttpInstance } from './sdk-http.js';
|
|
3
3
|
import { createLarkWebApiTransport } from './web-api-proxy.js';
|
|
4
4
|
import type { LarkCardAction } from './cards.js';
|
|
5
|
-
import { EndpointDeliveryError, type ConversationRef } from '@zhin.js/im-contract';
|
|
5
|
+
import { EndpointDeliveryError, type ConversationRef, type PlatformStanding, type SelfStandingRequest } from '@zhin.js/im-contract';
|
|
6
6
|
/**
|
|
7
7
|
* LarkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
8
8
|
*/
|
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
type LarkMessage,
|
|
44
44
|
type ResolvedLarkConfig,
|
|
45
45
|
} from './protocol.js';
|
|
46
|
-
import { normalizeLarkSenderForPermit } from './platform-permit.js';
|
|
47
46
|
import { registerLarkWebhookRoutes } from './webhook.js';
|
|
48
47
|
import { createLarkLongConnection, type LarkLongConnectionFactory } from './long-connection.js';
|
|
49
48
|
import { openLarkReplyStream, supportsLarkReplyStream } from './reply-stream.js';
|
|
@@ -93,6 +92,7 @@ export interface LarkClientApi {
|
|
|
93
92
|
/** Feishu/Lark OpenAPI client surface carried by every Endpoint event. */
|
|
94
93
|
|
|
95
94
|
const LARK_CHAT_MANAGER_CACHE_TTL_MS = 60_000;
|
|
95
|
+
const LARK_CHAT_MANAGER_FAILURE_TTL_MS = 10_000;
|
|
96
96
|
|
|
97
97
|
export class LarkClient extends SdkClient {
|
|
98
98
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
@@ -125,6 +125,23 @@ export class LarkClient extends SdkClient {
|
|
|
125
125
|
`/im/v1/chats/${encodeURIComponent(groupId)}/members`,
|
|
126
126
|
{ params: { member_id_type: 'open_id' }, signal },
|
|
127
127
|
),
|
|
128
|
+
/**
|
|
129
|
+
* ADR 0056 §1:机器人自身在群里的地位(chat.get:群主为本应用 → owner;在 bot_manager_id_list →
|
|
130
|
+
* admin;有 owner_id 且都不是 → member)。查询失败或缺 owner_id 为未知。
|
|
131
|
+
*/
|
|
132
|
+
inspectSelfStanding: async ({ conversation, signal }: SelfStandingRequest): Promise<PlatformStanding | undefined> => {
|
|
133
|
+
if (conversation.kind !== 'group') return undefined;
|
|
134
|
+
const appId = this.#options.config.appId;
|
|
135
|
+
const data = await createEndpointOperation({ signal }).run(() => this.#request(`/im/v1/chats/${encodeURIComponent(conversation.id)}`, { method: 'GET' }));
|
|
136
|
+
const info = data?.data as { owner_id?: unknown; owner_id_type?: unknown; bot_manager_id_list?: unknown } | undefined;
|
|
137
|
+
if (!info || !appId) return undefined;
|
|
138
|
+
if (info.owner_id_type === 'app_id' && info.owner_id === appId) return { standing: 'owner' };
|
|
139
|
+
const managers = Array.isArray(info.bot_manager_id_list) ? info.bot_manager_id_list.map(String) : [];
|
|
140
|
+
if (managers.includes(appId)) return { standing: 'admin' };
|
|
141
|
+
// chat.get may omit owner_id (e.g. when this app owns the group): without it the Bot is not
|
|
142
|
+
// provably a plain member, so the standing stays unknown.
|
|
143
|
+
return typeof info.owner_id === 'string' && info.owner_id ? { standing: 'member' } : undefined;
|
|
144
|
+
},
|
|
128
145
|
});
|
|
129
146
|
protected readonly endpointControl: EndpointControl = createRecallEndpointControl((id, signal) => this.recallMessage(id, { signal }));
|
|
130
147
|
protected get endpointStreaming(): import('zhin.js/adapter').EndpointStreaming | undefined {
|
|
@@ -141,9 +158,11 @@ export class LarkClient extends SdkClient {
|
|
|
141
158
|
readonly #lifecycle: EndpointLifecycle;
|
|
142
159
|
readonly #cardSources = new Map<string, { conversation: ConversationRef; time: number }>();
|
|
143
160
|
readonly #seen = new InboundDeduper({ ttlMs: 300_000, maxEntries: 10_000 });
|
|
144
|
-
readonly #chatManagers = new TtlLookupCache<string, { owner?: string; admins: ReadonlySet<string> }>({
|
|
161
|
+
readonly #chatManagers = new TtlLookupCache<string, { owner?: string; admins: ReadonlySet<string> } | undefined>({
|
|
145
162
|
ttlMs: LARK_CHAT_MANAGER_CACHE_TTL_MS,
|
|
146
163
|
});
|
|
164
|
+
/** chat.get failures: the standing stays unknown for a short while instead of retrying every message. */
|
|
165
|
+
readonly #chatManagerFailures = new TtlLookupCache<string, true>({ ttlMs: LARK_CHAT_MANAGER_FAILURE_TTL_MS });
|
|
147
166
|
#startupGeneration = 0;
|
|
148
167
|
readonly #pendingInbound: { message: LarkMessage; event: unknown }[] = [];
|
|
149
168
|
|
|
@@ -370,11 +389,10 @@ export class LarkClient extends SdkClient {
|
|
|
370
389
|
async #emitInbound(msg: LarkMessage, conversation: ConversationRef, nativeEvent: unknown): Promise<void> {
|
|
371
390
|
const chatType = resolveChatType(msg.chat_id, msg.chat_type);
|
|
372
391
|
const senderId = resolveSender(msg);
|
|
373
|
-
|
|
374
|
-
|
|
392
|
+
// ADR 0056 §1:群主 / 群管理员 / 成员 → sender.platform.standing(飞书群无原生角色与权限位);未知不上报。
|
|
393
|
+
const role = senderId && chatType === 'group' && msg.chat_id
|
|
394
|
+
? await this.#resolveChatRole(msg.chat_id, senderId)
|
|
375
395
|
: undefined;
|
|
376
|
-
// 规范角色 owner / admin 进 roles;细粒度能力放 senderPermissions
|
|
377
|
-
const role = permit?.role === 'owner' || permit?.role === 'admin' ? permit.role : undefined;
|
|
378
396
|
const mentioned = isLarkBotMentioned(msg);
|
|
379
397
|
const replyToId = msg.parent_id || msg.root_id;
|
|
380
398
|
await this.publishEvent('message.receive', {
|
|
@@ -382,36 +400,43 @@ export class LarkClient extends SdkClient {
|
|
|
382
400
|
conversation,
|
|
383
401
|
message: { conversation, id: generateMessageId(msg) },
|
|
384
402
|
content: formatInboundContent(msg),
|
|
385
|
-
...(senderId ? { sender: { id: senderId, ...(role ? {
|
|
403
|
+
...(senderId ? { sender: { id: senderId, ...(role ? { platform: { standing: role } } : {}) } } : {}),
|
|
386
404
|
endpointId: this.#options.config.id,
|
|
387
405
|
...(mentioned ? { mentioned: true } : {}),
|
|
388
406
|
...(replyToId ? { replyTo: { id: replyToId } } : {}),
|
|
389
407
|
metadata: Object.freeze({
|
|
390
408
|
messageType: msg.message_type,
|
|
391
409
|
chatType,
|
|
392
|
-
...(role ? { senderRole: role } : {}),
|
|
393
|
-
...(role && permit?.permissions?.length ? { senderPermissions: [...permit.permissions] } : {}),
|
|
394
410
|
}),
|
|
395
411
|
});
|
|
396
412
|
}
|
|
397
413
|
|
|
398
|
-
/**
|
|
399
|
-
|
|
414
|
+
/**
|
|
415
|
+
* 群主 / 群管理员来自 chat.get(owner_id + 管理员列表),按群缓存 60 秒。chat.get 失败时地位未知
|
|
416
|
+
* (负缓存 10 秒,不报确定的 member);未返回 owner_id(如群主是机器人应用)时只能确认管理员,
|
|
417
|
+
* 其余发送者地位未知。
|
|
418
|
+
*/
|
|
419
|
+
async #resolveChatRole(chatId: string, senderId: string): Promise<'owner' | 'admin' | 'member' | undefined> {
|
|
420
|
+
if (this.#chatManagerFailures.get(chatId)) return undefined;
|
|
400
421
|
const entry = await this.#chatManagers.getOrLoad(chatId, async () => {
|
|
401
422
|
const info = await this.getChatInfo(chatId) as {
|
|
402
423
|
owner_id?: unknown;
|
|
403
424
|
user_manager_id_list?: unknown;
|
|
404
425
|
} | null;
|
|
405
|
-
|
|
426
|
+
if (!info) {
|
|
427
|
+
this.#chatManagerFailures.set(chatId, true);
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
const admins = Array.isArray(info.user_manager_id_list) ? info.user_manager_id_list.map(String) : [];
|
|
406
431
|
return {
|
|
407
|
-
...(typeof info
|
|
432
|
+
...(typeof info.owner_id === 'string' && info.owner_id ? { owner: info.owner_id } : {}),
|
|
408
433
|
admins: new Set(admins) as ReadonlySet<string>,
|
|
409
434
|
};
|
|
410
|
-
});
|
|
411
|
-
return
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
435
|
+
}, { cacheWhen: entry => entry !== undefined });
|
|
436
|
+
if (!entry) return undefined;
|
|
437
|
+
if (entry.owner !== undefined && entry.owner === senderId) return 'owner';
|
|
438
|
+
if (entry.admins.has(senderId)) return 'admin';
|
|
439
|
+
return entry.owner !== undefined ? 'member' : undefined;
|
|
415
440
|
}
|
|
416
441
|
|
|
417
442
|
/** Only correlate callbacks to a card actually sent by this endpoint. */
|
package/src/index.ts
CHANGED
|
@@ -5,10 +5,10 @@ export {
|
|
|
5
5
|
headerValue,
|
|
6
6
|
larkInboundConversation,
|
|
7
7
|
normalizeWebhookPath,
|
|
8
|
-
readTextBody,
|
|
9
8
|
resolveChatType,
|
|
10
9
|
resolveLarkConfig,
|
|
11
10
|
resolveSender,
|
|
11
|
+
decryptLarkEvent,
|
|
12
12
|
verifySignature,
|
|
13
13
|
type AccessToken,
|
|
14
14
|
type LarkEndpointConfig,
|
|
@@ -22,12 +22,7 @@ export {
|
|
|
22
22
|
|
|
23
23
|
export { type LarkClientEventMap } from './client.js';
|
|
24
24
|
|
|
25
|
-
export {
|
|
26
|
-
checkLarkPlatformPermit,
|
|
27
|
-
larkGroupPermitResolver,
|
|
28
|
-
normalizeLarkSenderForPermit,
|
|
29
|
-
platformPermit,
|
|
30
|
-
} from './platform-permit.js';
|
|
25
|
+
export { platformPermit } from './platform-permit.js';
|
|
31
26
|
|
|
32
27
|
export {
|
|
33
28
|
LarkEndpoint,
|
package/src/platform-permit.ts
CHANGED
|
@@ -1,50 +1,10 @@
|
|
|
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
|
-
|
|
6
|
-
const ADAPTER = 'lark';
|
|
7
7
|
|
|
8
8
|
export function platformPermit(perm: string): string {
|
|
9
|
-
return `platform
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const FACTORY_PERM_MAP: Record<string, string> = {
|
|
13
|
-
scene_admin: 'chat_admin',
|
|
14
|
-
scene_owner: 'chat_owner',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export function larkGroupPermitResolver(logicalPerm: string): string {
|
|
18
|
-
return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function normalizeLarkSenderForPermit(input: {
|
|
22
|
-
isOwner?: boolean;
|
|
23
|
-
isAdmin?: boolean;
|
|
24
|
-
}): { role?: string; permissions?: string[] } {
|
|
25
|
-
if (input.isOwner) {
|
|
26
|
-
return { role: 'owner', permissions: ['chat_owner', 'chat_admin', 'manage_managers'] };
|
|
27
|
-
}
|
|
28
|
-
if (input.isAdmin) {
|
|
29
|
-
return { role: 'admin', permissions: ['chat_admin'] };
|
|
30
|
-
}
|
|
31
|
-
return { role: 'member', permissions: [] };
|
|
9
|
+
return `platform-permission:${perm}`;
|
|
32
10
|
}
|
|
33
|
-
|
|
34
|
-
export function checkLarkPlatformPermit(perm: string, subject: PermissionSubject): boolean {
|
|
35
|
-
const role = subject.sender?.role?.[0];
|
|
36
|
-
const permissions = subject.sender?.permissions ?? [];
|
|
37
|
-
const has = (t: string) => permissions.includes(t);
|
|
38
|
-
|
|
39
|
-
switch (perm) {
|
|
40
|
-
case 'chat_owner':
|
|
41
|
-
return role === 'owner' || has('chat_owner');
|
|
42
|
-
case 'chat_admin':
|
|
43
|
-
return role === 'owner' || role === 'admin' || has('chat_admin') || has('chat_owner');
|
|
44
|
-
case 'manage_managers':
|
|
45
|
-
return role === 'owner' || has('chat_owner');
|
|
46
|
-
default:
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
package/src/protocol.ts
CHANGED
|
@@ -6,8 +6,7 @@ import { validateLarkWebApiProxy } from './web-api-proxy.js';
|
|
|
6
6
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
10
|
-
import type { IncomingMessage } from 'node:http';
|
|
9
|
+
import { createDecipheriv, createHash, timingSafeEqual } from 'node:crypto';
|
|
11
10
|
import { EndpointDeliveryError, isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
12
11
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
13
12
|
|
|
@@ -88,6 +87,8 @@ export interface LarkEventBody {
|
|
|
88
87
|
readonly ts?: string;
|
|
89
88
|
readonly type?: string;
|
|
90
89
|
readonly challenge?: string;
|
|
90
|
+
/** Present instead of every other field when the app has an Encrypt Key. */
|
|
91
|
+
readonly encrypt?: string;
|
|
91
92
|
readonly event?: {
|
|
92
93
|
readonly sender?: LarkMessage['sender'];
|
|
93
94
|
readonly message?: LarkMessage;
|
|
@@ -273,6 +274,22 @@ export function verifySignature(
|
|
|
273
274
|
}
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Decrypt an `{"encrypt": "..."}` webhook payload (Lark/Feishu open-platform
|
|
279
|
+
* spec): key = SHA-256(Encrypt Key), base64 payload = 16-byte IV followed by
|
|
280
|
+
* AES-256-CBC ciphertext with PKCS#7 padding. Throws when the payload cannot
|
|
281
|
+
* be decrypted with this key.
|
|
282
|
+
*/
|
|
283
|
+
export function decryptLarkEvent(encryptKey: string, encrypted: string): string {
|
|
284
|
+
const payload = Buffer.from(encrypted, 'base64');
|
|
285
|
+
if (payload.length <= 16 || (payload.length - 16) % 16 !== 0) {
|
|
286
|
+
throw new Error('Malformed encrypted Lark event');
|
|
287
|
+
}
|
|
288
|
+
const key = createHash('sha256').update(encryptKey).digest();
|
|
289
|
+
const decipher = createDecipheriv('aes-256-cbc', key, payload.subarray(0, 16));
|
|
290
|
+
return Buffer.concat([decipher.update(payload.subarray(16)), decipher.final()]).toString('utf8');
|
|
291
|
+
}
|
|
292
|
+
|
|
276
293
|
/** timing-safe 比较 webhook verification token(长度不同快速失败)。 */
|
|
277
294
|
export function verifyToken(token: string, expected: string): boolean {
|
|
278
295
|
try {
|
|
@@ -439,29 +456,9 @@ export function formatOutboundBody(payload: unknown, locale?: Locale): LarkSendB
|
|
|
439
456
|
}
|
|
440
457
|
|
|
441
458
|
export function headerValue(
|
|
442
|
-
headers:
|
|
459
|
+
headers: Headers,
|
|
443
460
|
name: string,
|
|
444
461
|
): string {
|
|
445
|
-
|
|
446
|
-
if (Array.isArray(value)) return value[0] ?? '';
|
|
447
|
-
return value ?? '';
|
|
462
|
+
return headers.get(name) ?? '';
|
|
448
463
|
}
|
|
449
464
|
|
|
450
|
-
export async function readTextBody(
|
|
451
|
-
request: IncomingMessage,
|
|
452
|
-
options: { readonly limit?: number } = {},
|
|
453
|
-
): Promise<string> {
|
|
454
|
-
const limit = options.limit ?? 1_048_576;
|
|
455
|
-
const chunks: Buffer[] = [];
|
|
456
|
-
let size = 0;
|
|
457
|
-
for await (const chunk of request) {
|
|
458
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
459
|
-
size += buffer.length;
|
|
460
|
-
if (size > limit) {
|
|
461
|
-
request.destroy();
|
|
462
|
-
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
463
|
-
}
|
|
464
|
-
chunks.push(buffer);
|
|
465
|
-
}
|
|
466
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
467
|
-
}
|
package/src/webhook.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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';
|
|
6
|
+
import { readTextBody } from '@zhin.js/host-http-contract';
|
|
7
7
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
8
|
import {
|
|
9
|
+
decryptLarkEvent,
|
|
9
10
|
headerValue,
|
|
10
|
-
readTextBody,
|
|
11
11
|
verifySignature,
|
|
12
12
|
verifyToken,
|
|
13
13
|
type LarkEventBody,
|
|
@@ -31,17 +31,14 @@ export function registerLarkWebhookRoutes(
|
|
|
31
31
|
): HttpRouteRegistration[] {
|
|
32
32
|
const path = handler.config.webhookPath;
|
|
33
33
|
return [
|
|
34
|
-
http.route('POST', path,
|
|
35
|
-
await handleLarkWebhookRequest(request, response, handler);
|
|
36
|
-
}, { summary: 'Lark/Feishu event webhook', tags: ['lark'] }),
|
|
34
|
+
http.route('POST', path, (request) => handleLarkWebhookRequest(request, handler), { summary: 'Lark/Feishu event webhook', tags: ['lark'] }),
|
|
37
35
|
];
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
export async function handleLarkWebhookRequest(
|
|
41
|
-
request:
|
|
42
|
-
response: ServerResponse,
|
|
39
|
+
request: Request,
|
|
43
40
|
handler: LarkWebhookHandler,
|
|
44
|
-
): Promise<
|
|
41
|
+
): Promise<Response> {
|
|
45
42
|
try {
|
|
46
43
|
const rawBody = await readTextBody(request);
|
|
47
44
|
|
|
@@ -49,22 +46,10 @@ export async function handleLarkWebhookRequest(
|
|
|
49
46
|
try {
|
|
50
47
|
event = JSON.parse(rawBody) as LarkEventBody;
|
|
51
48
|
} catch {
|
|
52
|
-
|
|
53
|
-
response.end(JSON.stringify({ code: 0, msg: 'success' }));
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (handler.config.verificationToken) {
|
|
58
|
-
const bodyToken = (event as LarkEventBody & { header?: { token?: unknown } }).header?.token ?? event.token;
|
|
59
|
-
const token = typeof bodyToken === 'string' ? bodyToken : headerValue(request.headers, 'x-lark-request-token');
|
|
60
|
-
if (!verifyToken(token, handler.config.verificationToken)) {
|
|
61
|
-
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
|
|
62
|
-
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
63
|
-
response.end('Forbidden');
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
49
|
+
return new Response(JSON.stringify({ code: 0, msg: 'success' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
66
50
|
}
|
|
67
51
|
|
|
52
|
+
// Signature first: it covers the raw (possibly encrypted) body.
|
|
68
53
|
if (handler.config.encryptKey) {
|
|
69
54
|
const timestamp = headerValue(request.headers, 'x-lark-request-timestamp');
|
|
70
55
|
const nonce = headerValue(request.headers, 'x-lark-request-nonce');
|
|
@@ -76,12 +61,36 @@ export async function handleLarkWebhookRequest(
|
|
|
76
61
|
|| !verifySignature(handler.config.encryptKey, timestamp, nonce, rawBody, signature)
|
|
77
62
|
) {
|
|
78
63
|
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
|
|
79
|
-
|
|
80
|
-
response.end('Forbidden');
|
|
81
|
-
return;
|
|
64
|
+
return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
|
|
82
65
|
}
|
|
83
66
|
}
|
|
84
67
|
|
|
68
|
+
if (typeof event?.encrypt === 'string') {
|
|
69
|
+
if (!handler.config.encryptKey) {
|
|
70
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'encrypted event received but encryptKey is not configured' }));
|
|
71
|
+
return new Response(JSON.stringify({ code: -1, msg: 'Encrypt key is not configured' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
event = JSON.parse(decryptLarkEvent(handler.config.encryptKey, event.encrypt)) as LarkEventBody;
|
|
75
|
+
} catch {
|
|
76
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'event decryption failed' }));
|
|
77
|
+
return new Response(JSON.stringify({ code: -1, msg: 'Decryption failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!event || typeof event !== 'object') {
|
|
82
|
+
return new Response(JSON.stringify({ code: 0, msg: 'success' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Token and challenge live inside the decrypted event.
|
|
86
|
+
if (handler.config.verificationToken) {
|
|
87
|
+
const bodyToken = (event as LarkEventBody & { header?: { token?: unknown } }).header?.token ?? event.token;
|
|
88
|
+
const token = typeof bodyToken === 'string' ? bodyToken : headerValue(request.headers, 'x-lark-request-token');
|
|
89
|
+
if (!verifyToken(token, handler.config.verificationToken)) {
|
|
90
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
|
|
91
|
+
return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
85
94
|
|
|
86
95
|
if (handler.isOpen) {
|
|
87
96
|
const eventName = event.type === 'event_callback'
|
|
@@ -91,9 +100,7 @@ export async function handleLarkWebhookRequest(
|
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
if (event.type === 'url_verification') {
|
|
94
|
-
|
|
95
|
-
response.end(JSON.stringify({ challenge: event.challenge }));
|
|
96
|
-
return;
|
|
103
|
+
return new Response(JSON.stringify({ challenge: event.challenge }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
const headerEventType = (event as LarkEventBody & { header?: { event_type?: string } }).header?.event_type;
|
|
@@ -107,11 +114,9 @@ export async function handleLarkWebhookRequest(
|
|
|
107
114
|
handler.admit(message, event);
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
|
|
111
|
-
response.end(JSON.stringify({ code: 0, msg: 'success' }));
|
|
117
|
+
return new Response(JSON.stringify({ code: 0, msg: 'success' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
112
118
|
} catch (error) {
|
|
113
119
|
logger.error('Webhook error:', error);
|
|
114
|
-
|
|
115
|
-
response.end(JSON.stringify({ code: -1, msg: 'Internal Server Error' }));
|
|
120
|
+
return new Response(JSON.stringify({ code: -1, msg: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
116
121
|
}
|
|
117
122
|
}
|