@openclaw/feishu 2026.2.24 → 2026.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +90 -0
- package/src/accounts.ts +11 -2
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +55 -0
- package/src/bot.test.ts +863 -9
- package/src/bot.ts +414 -200
- package/src/card-action.ts +79 -0
- package/src/channel.ts +6 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +107 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +82 -1
- package/src/config-schema.ts +54 -3
- package/src/doc-schema.ts +141 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +76 -0
- package/src/docx.test.ts +470 -0
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +123 -6
- package/src/media.ts +31 -10
- package/src/monitor.account.ts +286 -0
- package/src/monitor.reaction.test.ts +235 -0
- package/src/monitor.startup.test.ts +187 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.ts +76 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +27 -1
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +253 -0
- package/src/probe.ts +99 -7
- package/src/reply-dispatcher.test.ts +259 -0
- package/src/reply-dispatcher.ts +139 -45
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +26 -1
- package/src/targets.ts +11 -6
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +1 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
package/src/drive.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
3
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
4
|
-
import { createFeishuClient } from "./client.js";
|
|
5
4
|
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
|
6
|
-
import {
|
|
5
|
+
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
|
7
6
|
|
|
8
7
|
// ============ Helpers ============
|
|
9
8
|
|
|
@@ -180,45 +179,51 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
|
|
180
179
|
return;
|
|
181
180
|
}
|
|
182
181
|
|
|
183
|
-
const
|
|
184
|
-
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
|
182
|
+
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
|
185
183
|
if (!toolsCfg.drive) {
|
|
186
184
|
api.logger.debug?.("feishu_drive: drive tool disabled in config");
|
|
187
185
|
return;
|
|
188
186
|
}
|
|
189
187
|
|
|
190
|
-
|
|
188
|
+
type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
|
|
191
189
|
|
|
192
190
|
api.registerTool(
|
|
193
|
-
{
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
"Feishu
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
191
|
+
(ctx) => {
|
|
192
|
+
const defaultAccountId = ctx.agentAccountId;
|
|
193
|
+
return {
|
|
194
|
+
name: "feishu_drive",
|
|
195
|
+
label: "Feishu Drive",
|
|
196
|
+
description:
|
|
197
|
+
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
|
|
198
|
+
parameters: FeishuDriveSchema,
|
|
199
|
+
async execute(_toolCallId, params) {
|
|
200
|
+
const p = params as FeishuDriveExecuteParams;
|
|
201
|
+
try {
|
|
202
|
+
const client = createFeishuToolClient({
|
|
203
|
+
api,
|
|
204
|
+
executeParams: p,
|
|
205
|
+
defaultAccountId,
|
|
206
|
+
});
|
|
207
|
+
switch (p.action) {
|
|
208
|
+
case "list":
|
|
209
|
+
return json(await listFolder(client, p.folder_token));
|
|
210
|
+
case "info":
|
|
211
|
+
return json(await getFileInfo(client, p.file_token));
|
|
212
|
+
case "create_folder":
|
|
213
|
+
return json(await createFolder(client, p.name, p.folder_token));
|
|
214
|
+
case "move":
|
|
215
|
+
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
|
|
216
|
+
case "delete":
|
|
217
|
+
return json(await deleteFile(client, p.file_token, p.type));
|
|
218
|
+
default:
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
|
220
|
+
return json({ error: `Unknown action: ${(p as any).action}` });
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
217
224
|
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
222
227
|
},
|
|
223
228
|
{ name: "feishu_drive" },
|
|
224
229
|
);
|
package/src/media.test.ts
CHANGED
|
@@ -108,7 +108,7 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
108
108
|
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
it("uses msg_type=
|
|
111
|
+
it("uses msg_type=file for mp4", async () => {
|
|
112
112
|
await sendMediaFeishu({
|
|
113
113
|
cfg: {} as any,
|
|
114
114
|
to: "user:ou_target",
|
|
@@ -124,12 +124,12 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
124
124
|
|
|
125
125
|
expect(messageCreateMock).toHaveBeenCalledWith(
|
|
126
126
|
expect.objectContaining({
|
|
127
|
-
data: expect.objectContaining({ msg_type: "
|
|
127
|
+
data: expect.objectContaining({ msg_type: "file" }),
|
|
128
128
|
}),
|
|
129
129
|
);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
it("uses msg_type=
|
|
132
|
+
it("uses msg_type=audio for opus", async () => {
|
|
133
133
|
await sendMediaFeishu({
|
|
134
134
|
cfg: {} as any,
|
|
135
135
|
to: "user:ou_target",
|
|
@@ -145,7 +145,7 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
145
145
|
|
|
146
146
|
expect(messageCreateMock).toHaveBeenCalledWith(
|
|
147
147
|
expect.objectContaining({
|
|
148
|
-
data: expect.objectContaining({ msg_type: "
|
|
148
|
+
data: expect.objectContaining({ msg_type: "audio" }),
|
|
149
149
|
}),
|
|
150
150
|
);
|
|
151
151
|
});
|
|
@@ -171,7 +171,7 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
171
171
|
);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
it("uses msg_type=
|
|
174
|
+
it("uses msg_type=file when replying with mp4", async () => {
|
|
175
175
|
await sendMediaFeishu({
|
|
176
176
|
cfg: {} as any,
|
|
177
177
|
to: "user:ou_target",
|
|
@@ -183,13 +183,71 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
183
183
|
expect(messageReplyMock).toHaveBeenCalledWith(
|
|
184
184
|
expect.objectContaining({
|
|
185
185
|
path: { message_id: "om_parent" },
|
|
186
|
-
data: expect.objectContaining({ msg_type: "
|
|
186
|
+
data: expect.objectContaining({ msg_type: "file" }),
|
|
187
187
|
}),
|
|
188
188
|
);
|
|
189
189
|
|
|
190
190
|
expect(messageCreateMock).not.toHaveBeenCalled();
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it("passes reply_in_thread when replyInThread is true", async () => {
|
|
194
|
+
await sendMediaFeishu({
|
|
195
|
+
cfg: {} as any,
|
|
196
|
+
to: "user:ou_target",
|
|
197
|
+
mediaBuffer: Buffer.from("video"),
|
|
198
|
+
fileName: "reply.mp4",
|
|
199
|
+
replyToMessageId: "om_parent",
|
|
200
|
+
replyInThread: true,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(messageReplyMock).toHaveBeenCalledWith(
|
|
204
|
+
expect.objectContaining({
|
|
205
|
+
path: { message_id: "om_parent" },
|
|
206
|
+
data: expect.objectContaining({ msg_type: "file", reply_in_thread: true }),
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("omits reply_in_thread when replyInThread is false", async () => {
|
|
212
|
+
await sendMediaFeishu({
|
|
213
|
+
cfg: {} as any,
|
|
214
|
+
to: "user:ou_target",
|
|
215
|
+
mediaBuffer: Buffer.from("video"),
|
|
216
|
+
fileName: "reply.mp4",
|
|
217
|
+
replyToMessageId: "om_parent",
|
|
218
|
+
replyInThread: false,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const callData = messageReplyMock.mock.calls[0][0].data;
|
|
222
|
+
expect(callData).not.toHaveProperty("reply_in_thread");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("passes mediaLocalRoots as localRoots to loadWebMedia for local paths (#27884)", async () => {
|
|
226
|
+
loadWebMediaMock.mockResolvedValue({
|
|
227
|
+
buffer: Buffer.from("local-file"),
|
|
228
|
+
fileName: "doc.pdf",
|
|
229
|
+
kind: "document",
|
|
230
|
+
contentType: "application/pdf",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const roots = ["/allowed/workspace", "/tmp/openclaw"];
|
|
234
|
+
await sendMediaFeishu({
|
|
235
|
+
cfg: {} as any,
|
|
236
|
+
to: "user:ou_target",
|
|
237
|
+
mediaUrl: "/allowed/workspace/file.pdf",
|
|
238
|
+
mediaLocalRoots: roots,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
|
242
|
+
"/allowed/workspace/file.pdf",
|
|
243
|
+
expect.objectContaining({
|
|
244
|
+
maxBytes: expect.any(Number),
|
|
245
|
+
optimizeImages: false,
|
|
246
|
+
localRoots: roots,
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
193
251
|
it("fails closed when media URL fetch is blocked", async () => {
|
|
194
252
|
loadWebMediaMock.mockRejectedValueOnce(
|
|
195
253
|
new Error("Blocked: resolves to private/internal IP address"),
|
|
@@ -277,3 +335,62 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
277
335
|
expect(messageResourceGetMock).not.toHaveBeenCalled();
|
|
278
336
|
});
|
|
279
337
|
});
|
|
338
|
+
|
|
339
|
+
describe("downloadMessageResourceFeishu", () => {
|
|
340
|
+
beforeEach(() => {
|
|
341
|
+
vi.clearAllMocks();
|
|
342
|
+
|
|
343
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
344
|
+
configured: true,
|
|
345
|
+
accountId: "main",
|
|
346
|
+
config: {},
|
|
347
|
+
appId: "app_id",
|
|
348
|
+
appSecret: "app_secret",
|
|
349
|
+
domain: "feishu",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
createFeishuClientMock.mockReturnValue({
|
|
353
|
+
im: {
|
|
354
|
+
messageResource: {
|
|
355
|
+
get: messageResourceGetMock,
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-audio-data"));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Regression: Feishu API only supports type=image|file for messageResource.get.
|
|
364
|
+
// Audio/video resources must use type=file, not type=audio (#8746).
|
|
365
|
+
it("forwards provided type=file for non-image resources", async () => {
|
|
366
|
+
const result = await downloadMessageResourceFeishu({
|
|
367
|
+
cfg: {} as any,
|
|
368
|
+
messageId: "om_audio_msg",
|
|
369
|
+
fileKey: "file_key_audio",
|
|
370
|
+
type: "file",
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(messageResourceGetMock).toHaveBeenCalledWith({
|
|
374
|
+
path: { message_id: "om_audio_msg", file_key: "file_key_audio" },
|
|
375
|
+
params: { type: "file" },
|
|
376
|
+
});
|
|
377
|
+
expect(result.buffer).toBeInstanceOf(Buffer);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("image uses type=image", async () => {
|
|
381
|
+
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
|
|
382
|
+
|
|
383
|
+
const result = await downloadMessageResourceFeishu({
|
|
384
|
+
cfg: {} as any,
|
|
385
|
+
messageId: "om_img_msg",
|
|
386
|
+
fileKey: "img_key_1",
|
|
387
|
+
type: "image",
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(messageResourceGetMock).toHaveBeenCalledWith({
|
|
391
|
+
path: { message_id: "om_img_msg", file_key: "img_key_1" },
|
|
392
|
+
params: { type: "image" },
|
|
393
|
+
});
|
|
394
|
+
expect(result.buffer).toBeInstanceOf(Buffer);
|
|
395
|
+
});
|
|
396
|
+
});
|
package/src/media.ts
CHANGED
|
@@ -265,9 +265,10 @@ export async function sendImageFeishu(params: {
|
|
|
265
265
|
to: string;
|
|
266
266
|
imageKey: string;
|
|
267
267
|
replyToMessageId?: string;
|
|
268
|
+
replyInThread?: boolean;
|
|
268
269
|
accountId?: string;
|
|
269
270
|
}): Promise<SendMediaResult> {
|
|
270
|
-
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
|
|
271
|
+
const { cfg, to, imageKey, replyToMessageId, replyInThread, accountId } = params;
|
|
271
272
|
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
|
272
273
|
cfg,
|
|
273
274
|
to,
|
|
@@ -281,6 +282,7 @@ export async function sendImageFeishu(params: {
|
|
|
281
282
|
data: {
|
|
282
283
|
content,
|
|
283
284
|
msg_type: "image",
|
|
285
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
284
286
|
},
|
|
285
287
|
});
|
|
286
288
|
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
|
|
@@ -306,12 +308,13 @@ export async function sendFileFeishu(params: {
|
|
|
306
308
|
cfg: ClawdbotConfig;
|
|
307
309
|
to: string;
|
|
308
310
|
fileKey: string;
|
|
309
|
-
/** Use "
|
|
310
|
-
msgType?: "file" | "
|
|
311
|
+
/** Use "audio" for audio files, "file" for documents and video */
|
|
312
|
+
msgType?: "file" | "audio";
|
|
311
313
|
replyToMessageId?: string;
|
|
314
|
+
replyInThread?: boolean;
|
|
312
315
|
accountId?: string;
|
|
313
316
|
}): Promise<SendMediaResult> {
|
|
314
|
-
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
|
|
317
|
+
const { cfg, to, fileKey, replyToMessageId, replyInThread, accountId } = params;
|
|
315
318
|
const msgType = params.msgType ?? "file";
|
|
316
319
|
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
|
317
320
|
cfg,
|
|
@@ -326,6 +329,7 @@ export async function sendFileFeishu(params: {
|
|
|
326
329
|
data: {
|
|
327
330
|
content,
|
|
328
331
|
msg_type: msgType,
|
|
332
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
329
333
|
},
|
|
330
334
|
});
|
|
331
335
|
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
|
|
@@ -376,7 +380,9 @@ export function detectFileType(
|
|
|
376
380
|
}
|
|
377
381
|
|
|
378
382
|
/**
|
|
379
|
-
* Upload and send media (image or file) from URL, local path, or buffer
|
|
383
|
+
* Upload and send media (image or file) from URL, local path, or buffer.
|
|
384
|
+
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
|
|
385
|
+
* must be passed so loadWebMedia allows the path (post CVE-2026-26321).
|
|
380
386
|
*/
|
|
381
387
|
export async function sendMediaFeishu(params: {
|
|
382
388
|
cfg: ClawdbotConfig;
|
|
@@ -385,9 +391,22 @@ export async function sendMediaFeishu(params: {
|
|
|
385
391
|
mediaBuffer?: Buffer;
|
|
386
392
|
fileName?: string;
|
|
387
393
|
replyToMessageId?: string;
|
|
394
|
+
replyInThread?: boolean;
|
|
388
395
|
accountId?: string;
|
|
396
|
+
/** Allowed roots for local path reads; required for local filePath to work. */
|
|
397
|
+
mediaLocalRoots?: readonly string[];
|
|
389
398
|
}): Promise<SendMediaResult> {
|
|
390
|
-
const {
|
|
399
|
+
const {
|
|
400
|
+
cfg,
|
|
401
|
+
to,
|
|
402
|
+
mediaUrl,
|
|
403
|
+
mediaBuffer,
|
|
404
|
+
fileName,
|
|
405
|
+
replyToMessageId,
|
|
406
|
+
replyInThread,
|
|
407
|
+
accountId,
|
|
408
|
+
mediaLocalRoots,
|
|
409
|
+
} = params;
|
|
391
410
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
392
411
|
if (!account.configured) {
|
|
393
412
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
@@ -404,6 +423,7 @@ export async function sendMediaFeishu(params: {
|
|
|
404
423
|
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
|
|
405
424
|
maxBytes: mediaMaxBytes,
|
|
406
425
|
optimizeImages: false,
|
|
426
|
+
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
|
|
407
427
|
});
|
|
408
428
|
buffer = loaded.buffer;
|
|
409
429
|
name = fileName ?? loaded.fileName ?? "file";
|
|
@@ -417,7 +437,7 @@ export async function sendMediaFeishu(params: {
|
|
|
417
437
|
|
|
418
438
|
if (isImage) {
|
|
419
439
|
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
|
|
420
|
-
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId });
|
|
440
|
+
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
|
|
421
441
|
} else {
|
|
422
442
|
const fileType = detectFileType(name);
|
|
423
443
|
const { fileKey } = await uploadFileFeishu({
|
|
@@ -427,14 +447,15 @@ export async function sendMediaFeishu(params: {
|
|
|
427
447
|
fileType,
|
|
428
448
|
accountId,
|
|
429
449
|
});
|
|
430
|
-
// Feishu
|
|
431
|
-
const
|
|
450
|
+
// Feishu API: opus -> "audio", everything else (including video) -> "file"
|
|
451
|
+
const msgType = fileType === "opus" ? "audio" : "file";
|
|
432
452
|
return sendFileFeishu({
|
|
433
453
|
cfg,
|
|
434
454
|
to,
|
|
435
455
|
fileKey,
|
|
436
|
-
msgType
|
|
456
|
+
msgType,
|
|
437
457
|
replyToMessageId,
|
|
458
|
+
replyInThread,
|
|
438
459
|
accountId,
|
|
439
460
|
});
|
|
440
461
|
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
3
|
+
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
|
4
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
5
|
+
import { raceWithTimeoutAndAbort } from "./async.js";
|
|
6
|
+
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
|
7
|
+
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
|
|
8
|
+
import { createEventDispatcher } from "./client.js";
|
|
9
|
+
import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
|
|
10
|
+
import { botOpenIds } from "./monitor.state.js";
|
|
11
|
+
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
|
12
|
+
import { getMessageFeishu } from "./send.js";
|
|
13
|
+
import type { ResolvedFeishuAccount } from "./types.js";
|
|
14
|
+
|
|
15
|
+
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
|
16
|
+
|
|
17
|
+
export type FeishuReactionCreatedEvent = {
|
|
18
|
+
message_id: string;
|
|
19
|
+
chat_id?: string;
|
|
20
|
+
chat_type?: "p2p" | "group";
|
|
21
|
+
reaction_type?: { emoji_type?: string };
|
|
22
|
+
operator_type?: string;
|
|
23
|
+
user_id?: { open_id?: string };
|
|
24
|
+
action_time?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ResolveReactionSyntheticEventParams = {
|
|
28
|
+
cfg: ClawdbotConfig;
|
|
29
|
+
accountId: string;
|
|
30
|
+
event: FeishuReactionCreatedEvent;
|
|
31
|
+
botOpenId?: string;
|
|
32
|
+
fetchMessage?: typeof getMessageFeishu;
|
|
33
|
+
verificationTimeoutMs?: number;
|
|
34
|
+
logger?: (message: string) => void;
|
|
35
|
+
uuid?: () => string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function resolveReactionSyntheticEvent(
|
|
39
|
+
params: ResolveReactionSyntheticEventParams,
|
|
40
|
+
): Promise<FeishuMessageEvent | null> {
|
|
41
|
+
const {
|
|
42
|
+
cfg,
|
|
43
|
+
accountId,
|
|
44
|
+
event,
|
|
45
|
+
botOpenId,
|
|
46
|
+
fetchMessage = getMessageFeishu,
|
|
47
|
+
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
|
|
48
|
+
logger,
|
|
49
|
+
uuid = () => crypto.randomUUID(),
|
|
50
|
+
} = params;
|
|
51
|
+
|
|
52
|
+
const emoji = event.reaction_type?.emoji_type;
|
|
53
|
+
const messageId = event.message_id;
|
|
54
|
+
const senderId = event.user_id?.open_id;
|
|
55
|
+
if (!emoji || !messageId || !senderId) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
60
|
+
const reactionNotifications = account.config.reactionNotifications ?? "own";
|
|
61
|
+
if (reactionNotifications === "off") {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (event.operator_type === "app" || senderId === botOpenId) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (emoji === "Typing") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (reactionNotifications === "own" && !botOpenId) {
|
|
74
|
+
logger?.(
|
|
75
|
+
`feishu[${accountId}]: bot open_id unavailable, skipping reaction ${emoji} on ${messageId}`,
|
|
76
|
+
);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const reactedMsg = await raceWithTimeoutAndAbort(fetchMessage({ cfg, messageId, accountId }), {
|
|
81
|
+
timeoutMs: verificationTimeoutMs,
|
|
82
|
+
})
|
|
83
|
+
.then((result) => (result.status === "resolved" ? result.value : null))
|
|
84
|
+
.catch(() => null);
|
|
85
|
+
const isBotMessage = reactedMsg?.senderType === "app" || reactedMsg?.senderOpenId === botOpenId;
|
|
86
|
+
if (!reactedMsg || (reactionNotifications === "own" && !isBotMessage)) {
|
|
87
|
+
logger?.(
|
|
88
|
+
`feishu[${accountId}]: ignoring reaction on non-bot/unverified message ${messageId} ` +
|
|
89
|
+
`(sender: ${reactedMsg?.senderOpenId ?? "unknown"})`,
|
|
90
|
+
);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
|
|
95
|
+
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
|
|
96
|
+
const syntheticChatType: "p2p" | "group" = event.chat_type ?? "p2p";
|
|
97
|
+
return {
|
|
98
|
+
sender: {
|
|
99
|
+
sender_id: { open_id: senderId },
|
|
100
|
+
sender_type: "user",
|
|
101
|
+
},
|
|
102
|
+
message: {
|
|
103
|
+
message_id: `${messageId}:reaction:${emoji}:${uuid()}`,
|
|
104
|
+
chat_id: syntheticChatId,
|
|
105
|
+
chat_type: syntheticChatType,
|
|
106
|
+
message_type: "text",
|
|
107
|
+
content: JSON.stringify({
|
|
108
|
+
text: `[reacted with ${emoji} to message ${messageId}]`,
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type RegisterEventHandlersContext = {
|
|
115
|
+
cfg: ClawdbotConfig;
|
|
116
|
+
accountId: string;
|
|
117
|
+
runtime?: RuntimeEnv;
|
|
118
|
+
chatHistories: Map<string, HistoryEntry[]>;
|
|
119
|
+
fireAndForget?: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function registerEventHandlers(
|
|
123
|
+
eventDispatcher: Lark.EventDispatcher,
|
|
124
|
+
context: RegisterEventHandlersContext,
|
|
125
|
+
): void {
|
|
126
|
+
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
|
|
127
|
+
const log = runtime?.log ?? console.log;
|
|
128
|
+
const error = runtime?.error ?? console.error;
|
|
129
|
+
|
|
130
|
+
eventDispatcher.register({
|
|
131
|
+
"im.message.receive_v1": async (data) => {
|
|
132
|
+
try {
|
|
133
|
+
const event = data as unknown as FeishuMessageEvent;
|
|
134
|
+
const promise = handleFeishuMessage({
|
|
135
|
+
cfg,
|
|
136
|
+
event,
|
|
137
|
+
botOpenId: botOpenIds.get(accountId),
|
|
138
|
+
runtime,
|
|
139
|
+
chatHistories,
|
|
140
|
+
accountId,
|
|
141
|
+
});
|
|
142
|
+
if (fireAndForget) {
|
|
143
|
+
promise.catch((err) => {
|
|
144
|
+
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
145
|
+
});
|
|
146
|
+
} else {
|
|
147
|
+
await promise;
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"im.message.message_read_v1": async () => {
|
|
154
|
+
// Ignore read receipts
|
|
155
|
+
},
|
|
156
|
+
"im.chat.member.bot.added_v1": async (data) => {
|
|
157
|
+
try {
|
|
158
|
+
const event = data as unknown as FeishuBotAddedEvent;
|
|
159
|
+
log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"im.chat.member.bot.deleted_v1": async (data) => {
|
|
165
|
+
try {
|
|
166
|
+
const event = data as unknown as { chat_id: string };
|
|
167
|
+
log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
"im.message.reaction.created_v1": async (data) => {
|
|
173
|
+
const processReaction = async () => {
|
|
174
|
+
const event = data as FeishuReactionCreatedEvent;
|
|
175
|
+
const myBotId = botOpenIds.get(accountId);
|
|
176
|
+
const syntheticEvent = await resolveReactionSyntheticEvent({
|
|
177
|
+
cfg,
|
|
178
|
+
accountId,
|
|
179
|
+
event,
|
|
180
|
+
botOpenId: myBotId,
|
|
181
|
+
logger: log,
|
|
182
|
+
});
|
|
183
|
+
if (!syntheticEvent) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const promise = handleFeishuMessage({
|
|
187
|
+
cfg,
|
|
188
|
+
event: syntheticEvent,
|
|
189
|
+
botOpenId: myBotId,
|
|
190
|
+
runtime,
|
|
191
|
+
chatHistories,
|
|
192
|
+
accountId,
|
|
193
|
+
});
|
|
194
|
+
if (fireAndForget) {
|
|
195
|
+
promise.catch((err) => {
|
|
196
|
+
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
await promise;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (fireAndForget) {
|
|
204
|
+
void processReaction().catch((err) => {
|
|
205
|
+
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await processReaction();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
"im.message.reaction.deleted_v1": async () => {
|
|
217
|
+
// Ignore reaction removals
|
|
218
|
+
},
|
|
219
|
+
"card.action.trigger": async (data: unknown) => {
|
|
220
|
+
try {
|
|
221
|
+
const event = data as unknown as FeishuCardActionEvent;
|
|
222
|
+
const promise = handleFeishuCardAction({
|
|
223
|
+
cfg,
|
|
224
|
+
event,
|
|
225
|
+
botOpenId: botOpenIds.get(accountId),
|
|
226
|
+
runtime,
|
|
227
|
+
accountId,
|
|
228
|
+
});
|
|
229
|
+
if (fireAndForget) {
|
|
230
|
+
promise.catch((err) => {
|
|
231
|
+
error(`feishu[${accountId}]: error handling card action: ${String(err)}`);
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
await promise;
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
error(`feishu[${accountId}]: error handling card action: ${String(err)}`);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type BotOpenIdSource = { kind: "prefetched"; botOpenId?: string } | { kind: "fetch" };
|
|
244
|
+
|
|
245
|
+
export type MonitorSingleAccountParams = {
|
|
246
|
+
cfg: ClawdbotConfig;
|
|
247
|
+
account: ResolvedFeishuAccount;
|
|
248
|
+
runtime?: RuntimeEnv;
|
|
249
|
+
abortSignal?: AbortSignal;
|
|
250
|
+
botOpenIdSource?: BotOpenIdSource;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export async function monitorSingleAccount(params: MonitorSingleAccountParams): Promise<void> {
|
|
254
|
+
const { cfg, account, runtime, abortSignal } = params;
|
|
255
|
+
const { accountId } = account;
|
|
256
|
+
const log = runtime?.log ?? console.log;
|
|
257
|
+
|
|
258
|
+
const botOpenIdSource = params.botOpenIdSource ?? { kind: "fetch" };
|
|
259
|
+
const botOpenId =
|
|
260
|
+
botOpenIdSource.kind === "prefetched"
|
|
261
|
+
? botOpenIdSource.botOpenId
|
|
262
|
+
: await fetchBotOpenIdForMonitor(account, { runtime, abortSignal });
|
|
263
|
+
botOpenIds.set(accountId, botOpenId ?? "");
|
|
264
|
+
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
|
265
|
+
|
|
266
|
+
const connectionMode = account.config.connectionMode ?? "websocket";
|
|
267
|
+
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
|
|
268
|
+
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const eventDispatcher = createEventDispatcher(account);
|
|
272
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
273
|
+
|
|
274
|
+
registerEventHandlers(eventDispatcher, {
|
|
275
|
+
cfg,
|
|
276
|
+
accountId,
|
|
277
|
+
runtime,
|
|
278
|
+
chatHistories,
|
|
279
|
+
fireAndForget: true,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (connectionMode === "webhook") {
|
|
283
|
+
return monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
|
|
284
|
+
}
|
|
285
|
+
return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
|
|
286
|
+
}
|