@zhin.js/adapter-lark 1.0.97 → 1.1.0
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 +637 -3
- package/README.md +70 -83
- package/adapters/lark.js +36 -0
- package/adapters/lark.ts +41 -0
- package/agent/PERMITS.md +22 -0
- package/agent/tools/add_members.ts +20 -0
- package/agent/tools/create_chat.ts +18 -0
- package/agent/tools/dissolve_chat.ts +16 -0
- package/agent/tools/get_user.ts +16 -0
- package/agent/tools/remove_managers.ts +20 -0
- package/agent/tools/set_managers.ts +20 -0
- package/agent/tools/update_chat.ts +21 -0
- package/agent/tools/upload_file.ts +23 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +12 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +100 -0
- package/lib/endpoint.js +497 -0
- package/lib/index.d.ts +6 -15
- package/lib/index.js +6 -209
- package/lib/lark-endpoint-commands.d.ts +1 -0
- package/lib/lark-endpoint-commands.js +19 -0
- package/lib/lark-runtime-state.d.ts +1 -0
- package/lib/lark-runtime-state.js +6 -0
- package/lib/media-upload.d.ts +15 -0
- package/lib/media-upload.js +58 -0
- package/lib/platform-permit.d.ts +14 -0
- package/lib/platform-permit.js +35 -0
- package/lib/protocol.d.ts +123 -0
- package/lib/protocol.js +273 -0
- package/lib/webhook.d.ts +14 -0
- package/lib/webhook.js +73 -0
- package/package.json +63 -20
- package/plugin.js +19 -0
- package/schema.json +97 -0
- package/src/client.ts +16 -0
- package/src/endpoint.ts +590 -0
- package/src/index.ts +51 -227
- package/src/lark-endpoint-commands.ts +20 -0
- package/src/lark-runtime-state.ts +7 -0
- package/src/media-upload.ts +69 -0
- package/src/platform-permit.ts +50 -0
- package/src/protocol.ts +402 -0
- package/src/webhook.ts +110 -0
- package/lib/adapter.d.ts +0 -17
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -45
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -82
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -638
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.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 -55
- package/src/bot.ts +0 -712
- package/src/types.ts +0 -58
- /package/{skills/lark/SKILL.md → agent/skills/lark.md} +0 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lark/Feishu protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import type { IncomingMessage } from 'node:http';
|
|
8
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
9
|
+
import type { ConversationRef } from '@zhin.js/im-contract';
|
|
10
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
11
|
+
|
|
12
|
+
const logger = getLogger('lark');
|
|
13
|
+
|
|
14
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
15
|
+
export interface LarkAdapterConfig {
|
|
16
|
+
readonly id?: string;
|
|
17
|
+
readonly appId?: string;
|
|
18
|
+
readonly appSecret?: string;
|
|
19
|
+
readonly encryptKey?: string;
|
|
20
|
+
readonly verificationToken?: string;
|
|
21
|
+
readonly webhookPath?: string;
|
|
22
|
+
readonly apiBaseUrl?: string;
|
|
23
|
+
readonly isFeishu?: boolean;
|
|
24
|
+
/** Transitional: legacy root `endpoints[]` with `context: lark`. */
|
|
25
|
+
readonly endpoints?: ReadonlyArray<Partial<ResolvedLarkConfig> & {
|
|
26
|
+
readonly context?: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedLarkConfig {
|
|
31
|
+
readonly context: 'lark';
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly appId: string;
|
|
34
|
+
readonly appSecret: string;
|
|
35
|
+
readonly encryptKey?: string;
|
|
36
|
+
readonly verificationToken?: string;
|
|
37
|
+
readonly webhookPath: string;
|
|
38
|
+
readonly apiBaseUrl: string;
|
|
39
|
+
readonly isFeishu: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LarkMessage {
|
|
43
|
+
readonly message_id?: string;
|
|
44
|
+
readonly root_id?: string;
|
|
45
|
+
readonly parent_id?: string;
|
|
46
|
+
readonly create_time?: string;
|
|
47
|
+
readonly update_time?: string;
|
|
48
|
+
readonly chat_id?: string;
|
|
49
|
+
/** 事件携带的会话类型('p2p' | 'group');p2p 的 chat_id 也是 oc_ 开头。 */
|
|
50
|
+
readonly chat_type?: string;
|
|
51
|
+
readonly sender?: {
|
|
52
|
+
readonly sender_id?: {
|
|
53
|
+
readonly user_id?: string;
|
|
54
|
+
readonly open_id?: string;
|
|
55
|
+
readonly union_id?: string;
|
|
56
|
+
};
|
|
57
|
+
readonly sender_type?: string;
|
|
58
|
+
readonly tenant_key?: string;
|
|
59
|
+
};
|
|
60
|
+
readonly message_type?: string;
|
|
61
|
+
readonly content?: string;
|
|
62
|
+
readonly mentions?: ReadonlyArray<{
|
|
63
|
+
readonly key?: string;
|
|
64
|
+
readonly id?: {
|
|
65
|
+
readonly user_id?: string;
|
|
66
|
+
readonly open_id?: string;
|
|
67
|
+
readonly union_id?: string;
|
|
68
|
+
};
|
|
69
|
+
readonly name?: string;
|
|
70
|
+
readonly tenant_key?: string;
|
|
71
|
+
}>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface LarkEventBody {
|
|
75
|
+
readonly uuid?: string;
|
|
76
|
+
readonly token?: string;
|
|
77
|
+
readonly ts?: string;
|
|
78
|
+
readonly type?: string;
|
|
79
|
+
readonly challenge?: string;
|
|
80
|
+
readonly event?: {
|
|
81
|
+
readonly sender?: LarkMessage['sender'];
|
|
82
|
+
readonly message?: LarkMessage;
|
|
83
|
+
readonly [key: string]: unknown;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AccessToken {
|
|
88
|
+
token: string;
|
|
89
|
+
expires_in: number;
|
|
90
|
+
timestamp: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface LarkApiResponse {
|
|
94
|
+
readonly code: number;
|
|
95
|
+
readonly msg?: string;
|
|
96
|
+
readonly tenant_access_token?: string;
|
|
97
|
+
readonly expire?: number;
|
|
98
|
+
readonly data?: Record<string, unknown>;
|
|
99
|
+
readonly [key: string]: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface LarkWireSegment {
|
|
103
|
+
readonly type: string;
|
|
104
|
+
readonly data?: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface LarkSendBody {
|
|
108
|
+
readonly msg_type: string;
|
|
109
|
+
readonly content: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveLarkConfig(config: LarkAdapterConfig = {}): ResolvedLarkConfig {
|
|
113
|
+
const entry = config.endpoints?.find((item) => item.context === 'lark');
|
|
114
|
+
const appId = config.appId ?? entry?.appId ?? process.env.LARK_APP_ID;
|
|
115
|
+
const appSecret = config.appSecret ?? entry?.appSecret ?? process.env.LARK_APP_SECRET;
|
|
116
|
+
if (!appId || !appSecret) {
|
|
117
|
+
throw new TypeError(
|
|
118
|
+
'Lark adapter requires appId + appSecret (plugins.<key> or endpoints with context: lark)',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const isFeishu = config.isFeishu ?? entry?.isFeishu ?? true;
|
|
122
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
123
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
124
|
+
|| process.env.LARK_BOT_NAME
|
|
125
|
+
|| 'lark-bot';
|
|
126
|
+
const webhookPath = normalizeWebhookPath(
|
|
127
|
+
config.webhookPath ?? entry?.webhookPath ?? '/lark/webhook',
|
|
128
|
+
);
|
|
129
|
+
const defaultBase = isFeishu
|
|
130
|
+
? 'https://open.feishu.cn/open-apis'
|
|
131
|
+
: 'https://open.larksuite.com/open-apis';
|
|
132
|
+
const apiBaseUrl = (
|
|
133
|
+
config.apiBaseUrl ?? entry?.apiBaseUrl ?? defaultBase
|
|
134
|
+
).replace(/\/$/, '');
|
|
135
|
+
const encryptKey = config.encryptKey ?? entry?.encryptKey;
|
|
136
|
+
const verificationToken = config.verificationToken ?? entry?.verificationToken;
|
|
137
|
+
return {
|
|
138
|
+
context: 'lark',
|
|
139
|
+
id,
|
|
140
|
+
appId,
|
|
141
|
+
appSecret,
|
|
142
|
+
...(encryptKey ? { encryptKey } : {}),
|
|
143
|
+
...(verificationToken ? { verificationToken } : {}),
|
|
144
|
+
webhookPath,
|
|
145
|
+
apiBaseUrl,
|
|
146
|
+
isFeishu,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function normalizeWebhookPath(path: string): string {
|
|
151
|
+
const trimmed = path.trim() || '/lark/webhook';
|
|
152
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function resolveChatType(chatId?: string, chatType?: string): 'group' | 'private' {
|
|
156
|
+
// 优先使用事件里的 chat_type:p2p 会话的 chat_id 同样以 oc_ 开头,不能靠前缀判断
|
|
157
|
+
if (chatType === 'group') return 'group';
|
|
158
|
+
if (chatType === 'p2p') return 'private';
|
|
159
|
+
return chatId?.startsWith('oc_') ? 'group' : 'private';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 入站归一化 → ConversationRef:`chat_type` 为 'group' 的会话映射为 group,
|
|
164
|
+
* p2p 单聊映射为 private;id 一律为平台 chat_id。Lark 无 guild/频道容器,
|
|
165
|
+
* 不产生 `parent`。
|
|
166
|
+
*/
|
|
167
|
+
export function larkInboundConversation(endpointKey: string, msg: LarkMessage): ConversationRef {
|
|
168
|
+
return {
|
|
169
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
170
|
+
kind: resolveChatType(msg.chat_id, msg.chat_type),
|
|
171
|
+
id: msg.chat_id || 'unknown',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function resolveSender(msg: LarkMessage): string {
|
|
176
|
+
return msg.sender?.sender_id?.open_id
|
|
177
|
+
|| msg.sender?.sender_id?.user_id
|
|
178
|
+
|| 'unknown';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function generateMessageId(msg: LarkMessage): string {
|
|
182
|
+
return msg.message_id || `${msg.create_time ?? Date.now()}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Build inbound text for OutboundMessageService.receive. */
|
|
186
|
+
export function formatInboundContent(msg: LarkMessage): string {
|
|
187
|
+
if (!msg.content || !msg.message_type) return '';
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(msg.content) as Record<string, unknown>;
|
|
190
|
+
switch (msg.message_type) {
|
|
191
|
+
case 'text':
|
|
192
|
+
return typeof parsed.text === 'string' ? parsed.text : '';
|
|
193
|
+
case 'image':
|
|
194
|
+
return '[image]';
|
|
195
|
+
case 'file': {
|
|
196
|
+
const name = typeof parsed.file_name === 'string' ? parsed.file_name : '';
|
|
197
|
+
return name ? `[file: ${name}]` : '[file]';
|
|
198
|
+
}
|
|
199
|
+
case 'audio':
|
|
200
|
+
return '[audio]';
|
|
201
|
+
case 'video':
|
|
202
|
+
return '[video]';
|
|
203
|
+
case 'sticker':
|
|
204
|
+
return '[sticker]';
|
|
205
|
+
case 'post':
|
|
206
|
+
case 'interactive':
|
|
207
|
+
return `[${msg.message_type}]`;
|
|
208
|
+
case 'rich_text':
|
|
209
|
+
return '[rich_text]';
|
|
210
|
+
default:
|
|
211
|
+
return `[${msg.message_type}]`;
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
return '[消息解析失败]';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 验签时间戳时效窗口(防重放)。 */
|
|
219
|
+
export const LARK_SIGNATURE_MAX_AGE_MS = 5 * 60_000;
|
|
220
|
+
|
|
221
|
+
export function verifySignature(
|
|
222
|
+
encryptKey: string,
|
|
223
|
+
timestamp: string,
|
|
224
|
+
nonce: string,
|
|
225
|
+
body: string,
|
|
226
|
+
signature: string,
|
|
227
|
+
nowMs: number = Date.now(),
|
|
228
|
+
): boolean {
|
|
229
|
+
try {
|
|
230
|
+
// 时效窗口 ±5min,超出直接拒绝(timestamp 为秒)
|
|
231
|
+
const ts = Number(timestamp);
|
|
232
|
+
if (!Number.isFinite(ts) || Math.abs(nowMs - ts * 1000) > LARK_SIGNATURE_MAX_AGE_MS) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const stringToSign = `${timestamp}${nonce}${encryptKey}${body}`;
|
|
236
|
+
const calculated = createHash('sha256').update(stringToSign).digest('hex');
|
|
237
|
+
const a = Buffer.from(calculated);
|
|
238
|
+
const b = Buffer.from(signature);
|
|
239
|
+
if (a.length !== b.length) return false;
|
|
240
|
+
return timingSafeEqual(a, b);
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** timing-safe 比较 webhook verification token(长度不同快速失败)。 */
|
|
247
|
+
export function verifyToken(token: string, expected: string): boolean {
|
|
248
|
+
try {
|
|
249
|
+
const a = Buffer.from(token);
|
|
250
|
+
const b = Buffer.from(expected);
|
|
251
|
+
if (a.length !== b.length) return false;
|
|
252
|
+
return timingSafeEqual(a, b);
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Wire-encode an already-rendered outbound payload into Lark im/v1 message body.
|
|
260
|
+
* Segment canonicalization is intentionally not done here.
|
|
261
|
+
*/
|
|
262
|
+
export function formatOutboundBody(payload: unknown): LarkSendBody {
|
|
263
|
+
if (typeof payload === 'string') {
|
|
264
|
+
return {
|
|
265
|
+
msg_type: 'text',
|
|
266
|
+
content: JSON.stringify({ text: payload }),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const items: Array<string | LarkWireSegment> = Array.isArray(payload)
|
|
271
|
+
? payload as Array<string | LarkWireSegment>
|
|
272
|
+
: payload && typeof payload === 'object' && 'type' in (payload as object)
|
|
273
|
+
? [payload as LarkWireSegment]
|
|
274
|
+
: [];
|
|
275
|
+
const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
|
|
276
|
+
|
|
277
|
+
if (items.length === 0) {
|
|
278
|
+
const text = payload == null
|
|
279
|
+
? ''
|
|
280
|
+
: typeof payload === 'object'
|
|
281
|
+
? JSON.stringify(payload)
|
|
282
|
+
: String(payload);
|
|
283
|
+
return {
|
|
284
|
+
msg_type: 'text',
|
|
285
|
+
content: JSON.stringify({ text }),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const textParts: string[] = [];
|
|
290
|
+
let media: LarkSendBody | null = null;
|
|
291
|
+
|
|
292
|
+
for (const item of items) {
|
|
293
|
+
if (typeof item === 'string') {
|
|
294
|
+
textParts.push(item);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const data = item.data ?? {};
|
|
298
|
+
switch (item.type) {
|
|
299
|
+
case 'text':
|
|
300
|
+
textParts.push(String(data.content ?? data.text ?? ''));
|
|
301
|
+
break;
|
|
302
|
+
case 'markdown':
|
|
303
|
+
textParts.push(String(data.content ?? data.text ?? ''));
|
|
304
|
+
break;
|
|
305
|
+
case 'at':
|
|
306
|
+
textParts.push(
|
|
307
|
+
`<at user_id="${String(data.id ?? '')}">${String(data.name || data.id || '')}</at>`,
|
|
308
|
+
);
|
|
309
|
+
break;
|
|
310
|
+
case 'image':
|
|
311
|
+
if (!media) {
|
|
312
|
+
// image_key 唯一来源:canonical MediaRef kind=file(endpoint 上传物化产物或调用方既有平台引用)
|
|
313
|
+
const ref = data.media;
|
|
314
|
+
if (isMediaRef(ref) && ref.kind === 'file') {
|
|
315
|
+
media = {
|
|
316
|
+
msg_type: 'image',
|
|
317
|
+
content: JSON.stringify({ image_key: ref.value }),
|
|
318
|
+
};
|
|
319
|
+
} else {
|
|
320
|
+
logger.warn(formatCompact({
|
|
321
|
+
op: 'lark_outbound_media_dropped',
|
|
322
|
+
type: 'image',
|
|
323
|
+
reason: isMediaRef(ref) ? 'unmaterialized_media' : 'missing_media_ref',
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
case 'file':
|
|
329
|
+
if (!media) {
|
|
330
|
+
const ref = data.media;
|
|
331
|
+
if (isMediaRef(ref) && ref.kind === 'file') {
|
|
332
|
+
media = {
|
|
333
|
+
msg_type: 'file',
|
|
334
|
+
content: JSON.stringify({ file_key: ref.value }),
|
|
335
|
+
};
|
|
336
|
+
} else {
|
|
337
|
+
logger.warn(formatCompact({
|
|
338
|
+
op: 'lark_outbound_media_dropped',
|
|
339
|
+
type: 'file',
|
|
340
|
+
reason: isMediaRef(ref) ? 'unmaterialized_media' : 'missing_media_ref',
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
case 'card':
|
|
346
|
+
if (!media) {
|
|
347
|
+
media = {
|
|
348
|
+
msg_type: 'interactive',
|
|
349
|
+
content: JSON.stringify(data),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
default:
|
|
354
|
+
textParts.push(`[${item.type}]`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (media) return media;
|
|
359
|
+
if (hasMarkdown) {
|
|
360
|
+
return {
|
|
361
|
+
msg_type: 'interactive',
|
|
362
|
+
content: JSON.stringify({
|
|
363
|
+
elements: [{
|
|
364
|
+
tag: 'div',
|
|
365
|
+
text: { tag: 'lark_md', content: textParts.join('') },
|
|
366
|
+
}],
|
|
367
|
+
}),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
msg_type: 'text',
|
|
372
|
+
content: JSON.stringify({ text: textParts.join('') }),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function headerValue(
|
|
377
|
+
headers: IncomingMessage['headers'],
|
|
378
|
+
name: string,
|
|
379
|
+
): string {
|
|
380
|
+
const value = headers[name] ?? headers[name.toLowerCase()];
|
|
381
|
+
if (Array.isArray(value)) return value[0] ?? '';
|
|
382
|
+
return value ?? '';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function readTextBody(
|
|
386
|
+
request: IncomingMessage,
|
|
387
|
+
options: { readonly limit?: number } = {},
|
|
388
|
+
): Promise<string> {
|
|
389
|
+
const limit = options.limit ?? 1_048_576;
|
|
390
|
+
const chunks: Buffer[] = [];
|
|
391
|
+
let size = 0;
|
|
392
|
+
for await (const chunk of request) {
|
|
393
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
394
|
+
size += buffer.length;
|
|
395
|
+
if (size > limit) {
|
|
396
|
+
request.destroy();
|
|
397
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
398
|
+
}
|
|
399
|
+
chunks.push(buffer);
|
|
400
|
+
}
|
|
401
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
402
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lark/Feishu webhook HTTP: verification → signature → challenge/event → admit.
|
|
3
|
+
*/
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
import {
|
|
8
|
+
headerValue,
|
|
9
|
+
readTextBody,
|
|
10
|
+
verifySignature,
|
|
11
|
+
verifyToken,
|
|
12
|
+
type LarkEventBody,
|
|
13
|
+
type LarkMessage,
|
|
14
|
+
type ResolvedLarkConfig,
|
|
15
|
+
} from './protocol.js';
|
|
16
|
+
|
|
17
|
+
const logger = getLogger('lark');
|
|
18
|
+
|
|
19
|
+
export interface LarkWebhookHandler {
|
|
20
|
+
readonly config: ResolvedLarkConfig;
|
|
21
|
+
readonly isOpen: boolean;
|
|
22
|
+
admitPlatform(name: string, event: unknown): void;
|
|
23
|
+
admit(msg: LarkMessage): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerLarkWebhookRoutes(
|
|
27
|
+
http: HttpHost,
|
|
28
|
+
handler: LarkWebhookHandler,
|
|
29
|
+
): HttpRouteRegistration[] {
|
|
30
|
+
const path = handler.config.webhookPath;
|
|
31
|
+
return [
|
|
32
|
+
http.route('POST', path, async (request, response) => {
|
|
33
|
+
await handleLarkWebhookRequest(request, response, handler);
|
|
34
|
+
}, { summary: 'Lark/Feishu event webhook', tags: ['lark'] }),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleLarkWebhookRequest(
|
|
39
|
+
request: IncomingMessage,
|
|
40
|
+
response: ServerResponse,
|
|
41
|
+
handler: LarkWebhookHandler,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
const rawBody = await readTextBody(request);
|
|
45
|
+
|
|
46
|
+
if (handler.config.verificationToken) {
|
|
47
|
+
const token = headerValue(request.headers, 'x-lark-request-token');
|
|
48
|
+
if (!verifyToken(token, handler.config.verificationToken)) {
|
|
49
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
|
|
50
|
+
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
51
|
+
response.end('Forbidden');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (handler.config.encryptKey) {
|
|
57
|
+
const timestamp = headerValue(request.headers, 'x-lark-request-timestamp');
|
|
58
|
+
const nonce = headerValue(request.headers, 'x-lark-request-nonce');
|
|
59
|
+
const signature = headerValue(request.headers, 'x-lark-signature');
|
|
60
|
+
if (
|
|
61
|
+
!timestamp
|
|
62
|
+
|| !nonce
|
|
63
|
+
|| !signature
|
|
64
|
+
|| !verifySignature(handler.config.encryptKey, timestamp, nonce, rawBody, signature)
|
|
65
|
+
) {
|
|
66
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
|
|
67
|
+
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
68
|
+
response.end('Forbidden');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let event: LarkEventBody;
|
|
74
|
+
try {
|
|
75
|
+
event = JSON.parse(rawBody) as LarkEventBody;
|
|
76
|
+
} catch {
|
|
77
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
78
|
+
response.end(JSON.stringify({ code: 0, msg: 'success' }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (handler.isOpen) {
|
|
83
|
+
const eventName = event.type === 'event_callback'
|
|
84
|
+
? String(event.event?.type ?? event.type)
|
|
85
|
+
: String(event.type ?? 'event');
|
|
86
|
+
handler.admitPlatform(eventName, event);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (event.type === 'url_verification') {
|
|
90
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
91
|
+
response.end(JSON.stringify({ challenge: event.challenge }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (event.type === 'event_callback' && event.event?.message && handler.isOpen) {
|
|
96
|
+
const message: LarkMessage = {
|
|
97
|
+
...event.event.message,
|
|
98
|
+
sender: event.event.message.sender ?? event.event.sender,
|
|
99
|
+
};
|
|
100
|
+
handler.admit(message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
104
|
+
response.end(JSON.stringify({ code: 0, msg: 'success' }));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
logger.error('Webhook error:', error);
|
|
107
|
+
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
108
|
+
response.end(JSON.stringify({ code: -1, msg: 'Internal Server Error' }));
|
|
109
|
+
}
|
|
110
|
+
}
|
package/lib/adapter.d.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 飞书/Lark 适配器
|
|
3
|
-
*/
|
|
4
|
-
import { Adapter, Plugin } from "zhin.js";
|
|
5
|
-
import { LarkBot } from "./bot.js";
|
|
6
|
-
import type { LarkBotConfig } from "./types.js";
|
|
7
|
-
export declare class LarkAdapter extends Adapter<LarkBot> {
|
|
8
|
-
#private;
|
|
9
|
-
constructor(plugin: Plugin, router: any);
|
|
10
|
-
createBot(config: LarkBotConfig): LarkBot;
|
|
11
|
-
kickMember(botId: string, sceneId: string, userId: string): Promise<boolean>;
|
|
12
|
-
listMembers(botId: string, sceneId: string): Promise<any[]>;
|
|
13
|
-
getGroupInfo(botId: string, sceneId: string): Promise<any>;
|
|
14
|
-
setGroupName(botId: string, sceneId: string, name: string): Promise<boolean>;
|
|
15
|
-
start(): Promise<void>;
|
|
16
|
-
}
|
|
17
|
-
//# sourceMappingURL=adapter.d.ts.map
|
package/lib/adapter.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,OAAO,EACP,MAAM,EACP,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,qBAAa,WAAY,SAAQ,OAAO,CAAC,OAAO,CAAC;;gBAGjC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG;IAKvC,SAAS,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO;IAMnC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;IAMzD,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;IAM1C,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;IAM3C,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAQzD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI/B"}
|
package/lib/adapter.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 飞书/Lark 适配器
|
|
3
|
-
*/
|
|
4
|
-
import { Adapter, } from "zhin.js";
|
|
5
|
-
import { LarkBot } from "./bot.js";
|
|
6
|
-
export class LarkAdapter extends Adapter {
|
|
7
|
-
#router;
|
|
8
|
-
constructor(plugin, router) {
|
|
9
|
-
super(plugin, 'lark', []);
|
|
10
|
-
this.#router = router;
|
|
11
|
-
}
|
|
12
|
-
createBot(config) {
|
|
13
|
-
return new LarkBot(this, this.#router, config);
|
|
14
|
-
}
|
|
15
|
-
// ── IGroupManagement 标准群管方法 ──────────────────────────────────
|
|
16
|
-
async kickMember(botId, sceneId, userId) {
|
|
17
|
-
const bot = this.bots.get(botId);
|
|
18
|
-
if (!bot)
|
|
19
|
-
throw new Error(`Bot ${botId} 不存在`);
|
|
20
|
-
return bot.removeChatMembers(sceneId, [userId]);
|
|
21
|
-
}
|
|
22
|
-
async listMembers(botId, sceneId) {
|
|
23
|
-
const bot = this.bots.get(botId);
|
|
24
|
-
if (!bot)
|
|
25
|
-
throw new Error(`Bot ${botId} 不存在`);
|
|
26
|
-
return bot.getChatMembers(sceneId);
|
|
27
|
-
}
|
|
28
|
-
async getGroupInfo(botId, sceneId) {
|
|
29
|
-
const bot = this.bots.get(botId);
|
|
30
|
-
if (!bot)
|
|
31
|
-
throw new Error(`Bot ${botId} 不存在`);
|
|
32
|
-
return bot.getChatInfo(sceneId);
|
|
33
|
-
}
|
|
34
|
-
async setGroupName(botId, sceneId, name) {
|
|
35
|
-
const bot = this.bots.get(botId);
|
|
36
|
-
if (!bot)
|
|
37
|
-
throw new Error(`Bot ${botId} 不存在`);
|
|
38
|
-
return bot.updateChatInfo(sceneId, { name });
|
|
39
|
-
}
|
|
40
|
-
// ── 生命周期 ───────────────────────────────────────────────────────
|
|
41
|
-
async start() {
|
|
42
|
-
await super.start();
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
//# sourceMappingURL=adapter.js.map
|
package/lib/adapter.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,OAAO,GAER,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAGnC,MAAM,OAAO,WAAY,SAAQ,OAAgB;IAC7C,OAAO,CAAM;IAEb,YAAY,MAAc,EAAE,MAAW;QACnC,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;QAC1B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;IAC1B,CAAC;IAED,SAAS,CAAC,MAAqB;QAC3B,OAAO,IAAI,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACnD,CAAC;IAED,gEAAgE;IAEhE,KAAK,CAAC,UAAU,CAAC,KAAa,EAAE,OAAe,EAAE,MAAc;QAC3D,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACjC,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;QAC9C,OAAO,GAAG,CAAC,iBAAiB,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,KAAa,EAAE,OAAe;QAC5C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACjC,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;QAC9C,OAAO,GAAG,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;IACvC,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,KAAa,EAAE,OAAe;QAC7C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACjC,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;QAC9C,OAAO,GAAG,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IACpC,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,KAAa,EAAE,OAAe,EAAE,IAAY;QAC3D,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACjC,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;QAC9C,OAAO,GAAG,CAAC,cAAc,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;IACjD,CAAC;IAED,kEAAkE;IAElE,KAAK,CAAC,KAAK;QACP,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;CAEJ"}
|
package/lib/bot.d.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { Bot, Message, SendOptions } from 'zhin.js';
|
|
2
|
-
import type { LarkBotConfig, LarkMessage } from "./types.js";
|
|
3
|
-
import type { LarkAdapter } from "./adapter.js";
|
|
4
|
-
export declare class LarkBot implements Bot<LarkBotConfig, LarkMessage> {
|
|
5
|
-
adapter: LarkAdapter;
|
|
6
|
-
$config: LarkBotConfig;
|
|
7
|
-
$connected: boolean;
|
|
8
|
-
private router;
|
|
9
|
-
private accessToken;
|
|
10
|
-
private axiosInstance;
|
|
11
|
-
get logger(): import("zhin.js").Logger;
|
|
12
|
-
get $id(): string;
|
|
13
|
-
constructor(adapter: LarkAdapter, router: any, $config: LarkBotConfig);
|
|
14
|
-
private setupWebhookRoute;
|
|
15
|
-
private handleWebhook;
|
|
16
|
-
private verifySignature;
|
|
17
|
-
private handleEvent;
|
|
18
|
-
private ensureAccessToken;
|
|
19
|
-
private refreshAccessToken;
|
|
20
|
-
$formatMessage(msg: LarkMessage, event?: any): Message<LarkMessage>;
|
|
21
|
-
private parseMessageContent;
|
|
22
|
-
private parseRichTextContent;
|
|
23
|
-
$sendMessage(options: SendOptions): Promise<string>;
|
|
24
|
-
$recallMessage(id: string): Promise<void>;
|
|
25
|
-
private formatSendContent;
|
|
26
|
-
$connect(): Promise<void>;
|
|
27
|
-
$disconnect(): Promise<void>;
|
|
28
|
-
getUserInfo(userId: string, userIdType?: 'open_id' | 'user_id' | 'union_id'): Promise<any>;
|
|
29
|
-
getChatInfo(chatId: string): Promise<any>;
|
|
30
|
-
uploadFile(filePath: string, fileType?: 'image' | 'file' | 'video' | 'audio'): Promise<string | null>;
|
|
31
|
-
/**
|
|
32
|
-
* 创建群聊
|
|
33
|
-
* @param name 群名
|
|
34
|
-
* @param userIds 成员 open_id 列表
|
|
35
|
-
* @param ownerId 群主 open_id
|
|
36
|
-
*/
|
|
37
|
-
createChat(name: string, userIds: string[], ownerId?: string): Promise<string | null>;
|
|
38
|
-
/**
|
|
39
|
-
* 更新群信息
|
|
40
|
-
* @param chatId 群聊 ID
|
|
41
|
-
* @param options 更新选项
|
|
42
|
-
*/
|
|
43
|
-
updateChatInfo(chatId: string, options: {
|
|
44
|
-
name?: string;
|
|
45
|
-
description?: string;
|
|
46
|
-
}): Promise<boolean>;
|
|
47
|
-
/**
|
|
48
|
-
* 添加群成员
|
|
49
|
-
* @param chatId 群聊 ID
|
|
50
|
-
* @param userIds 用户 ID 列表
|
|
51
|
-
*/
|
|
52
|
-
addChatMembers(chatId: string, userIds: string[]): Promise<boolean>;
|
|
53
|
-
/**
|
|
54
|
-
* 移除群成员
|
|
55
|
-
* @param chatId 群聊 ID
|
|
56
|
-
* @param userIds 用户 ID 列表
|
|
57
|
-
*/
|
|
58
|
-
removeChatMembers(chatId: string, userIds: string[]): Promise<boolean>;
|
|
59
|
-
/**
|
|
60
|
-
* 获取群成员列表
|
|
61
|
-
* @param chatId 群聊 ID
|
|
62
|
-
*/
|
|
63
|
-
getChatMembers(chatId: string): Promise<any[]>;
|
|
64
|
-
/**
|
|
65
|
-
* 解散群聊
|
|
66
|
-
* @param chatId 群聊 ID
|
|
67
|
-
*/
|
|
68
|
-
dissolveChat(chatId: string): Promise<boolean>;
|
|
69
|
-
/**
|
|
70
|
-
* 设置群管理员
|
|
71
|
-
* @param chatId 群聊 ID
|
|
72
|
-
* @param userIds 用户 ID 列表
|
|
73
|
-
*/
|
|
74
|
-
setChatManagers(chatId: string, userIds: string[]): Promise<boolean>;
|
|
75
|
-
/**
|
|
76
|
-
* 移除群管理员
|
|
77
|
-
* @param chatId 群聊 ID
|
|
78
|
-
* @param userIds 用户 ID 列表
|
|
79
|
-
*/
|
|
80
|
-
removeChatManagers(chatId: string, userIds: string[]): Promise<boolean>;
|
|
81
|
-
}
|
|
82
|
-
//# sourceMappingURL=bot.d.ts.map
|
package/lib/bot.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../src/bot.ts"],"names":[],"mappings":"AAMA,OAAO,EAAiB,GAAG,EAAE,OAAO,EAAwC,WAAW,EAAE,MAAM,SAAS,CAAC;AACzG,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAA0B,MAAM,YAAY,CAAC;AACrF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,qBAAa,OAAQ,YAAW,GAAG,CAAC,aAAa,EAAE,WAAW,CAAC;IAcxC,OAAO,EAAE,WAAW;IAAsB,OAAO,EAAE,aAAa;IAbnF,UAAU,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,WAAW,CAAa;IAChC,OAAO,CAAC,aAAa,CAAe;IAEpC,IAAI,MAAM,6BAEX;IAED,IAAI,GAAG,WAEJ;gBAEkB,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAS,OAAO,EAAE,aAAa;IA+BnF,OAAO,CAAC,iBAAiB;YAMX,aAAa;IAqD3B,OAAO,CAAC,eAAe;YAaT,WAAW;YAmBX,iBAAiB;YAUjB,kBAAkB;IAiChC,cAAc,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,CAAC,EAAE,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC;IAoCnE,OAAO,CAAC,mBAAmB;IA0F3B,OAAO,CAAC,oBAAoB;IAyBtB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IAuBnD,cAAc,CAAC,EAAE,EAAC,MAAM,GAAE,OAAO,CAAC,IAAI,CAAC;IAM7C,OAAO,CAAC,iBAAiB;IAsFnB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAezB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAc5B,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,GAAE,SAAS,GAAG,SAAS,GAAG,UAAsB,GAAG,OAAO,CAAC,GAAG,CAAC;IAcrG,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAWzC,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,GAAE,OAAO,GAAG,MAAM,GAAG,OAAO,GAAG,OAAgB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IA4BnH;;;;;OAKG;IACG,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAmB3F;;;;OAIG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE;QAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;KACxB,GAAG,OAAO,CAAC,OAAO,CAAC;IAepB;;;;OAIG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;IAiBzE;;;;OAIG;IACG,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;IAiB5E;;;OAGG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;IAcpD;;;OAGG;IACG,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAepD;;;;OAIG;IACG,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;IAiB1E;;;;OAIG;IACG,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;CAgBhF"}
|