@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.
Files changed (64) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +90 -0
  5. package/src/accounts.ts +11 -2
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +55 -0
  10. package/src/bot.test.ts +863 -9
  11. package/src/bot.ts +414 -200
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +6 -0
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +107 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +82 -1
  20. package/src/config-schema.ts +54 -3
  21. package/src/doc-schema.ts +141 -0
  22. package/src/docx-batch-insert.ts +190 -0
  23. package/src/docx-color-text.ts +149 -0
  24. package/src/docx-table-ops.ts +298 -0
  25. package/src/docx.account-selection.test.ts +76 -0
  26. package/src/docx.test.ts +470 -0
  27. package/src/docx.ts +996 -72
  28. package/src/drive.ts +38 -33
  29. package/src/media.test.ts +123 -6
  30. package/src/media.ts +31 -10
  31. package/src/monitor.account.ts +286 -0
  32. package/src/monitor.reaction.test.ts +235 -0
  33. package/src/monitor.startup.test.ts +187 -0
  34. package/src/monitor.startup.ts +51 -0
  35. package/src/monitor.state.ts +76 -0
  36. package/src/monitor.transport.ts +163 -0
  37. package/src/monitor.ts +44 -346
  38. package/src/monitor.webhook-security.test.ts +27 -1
  39. package/src/outbound.test.ts +181 -0
  40. package/src/outbound.ts +94 -7
  41. package/src/perm.ts +37 -30
  42. package/src/policy.test.ts +56 -1
  43. package/src/policy.ts +5 -1
  44. package/src/post.test.ts +105 -0
  45. package/src/post.ts +274 -0
  46. package/src/probe.test.ts +253 -0
  47. package/src/probe.ts +99 -7
  48. package/src/reply-dispatcher.test.ts +259 -0
  49. package/src/reply-dispatcher.ts +139 -45
  50. package/src/send.reply-fallback.test.ts +105 -0
  51. package/src/send.test.ts +168 -0
  52. package/src/send.ts +143 -18
  53. package/src/streaming-card.ts +131 -43
  54. package/src/targets.test.ts +26 -1
  55. package/src/targets.ts +11 -6
  56. package/src/tool-account-routing.test.ts +129 -0
  57. package/src/tool-account.ts +70 -0
  58. package/src/tool-factory-test-harness.ts +76 -0
  59. package/src/tools-config.test.ts +21 -0
  60. package/src/tools-config.ts +2 -1
  61. package/src/types.ts +1 -0
  62. package/src/typing.test.ts +144 -0
  63. package/src/typing.ts +140 -10
  64. 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 { resolveToolsConfig } from "./tools-config.js";
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 firstAccount = accounts[0];
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
- const getClient = () => createFeishuClient(firstAccount);
188
+ type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
191
189
 
192
190
  api.registerTool(
193
- {
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 FeishuDriveParams;
201
- try {
202
- const client = getClient();
203
- switch (p.action) {
204
- case "list":
205
- return json(await listFolder(client, p.folder_token));
206
- case "info":
207
- return json(await getFileInfo(client, p.file_token));
208
- case "create_folder":
209
- return json(await createFolder(client, p.name, p.folder_token));
210
- case "move":
211
- return json(await moveFile(client, p.file_token, p.type, p.folder_token));
212
- case "delete":
213
- return json(await deleteFile(client, p.file_token, p.type));
214
- default:
215
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
216
- return json({ error: `Unknown action: ${(p as any).action}` });
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
- } catch (err) {
219
- return json({ error: err instanceof Error ? err.message : String(err) });
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=media for mp4", async () => {
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: "media" }),
127
+ data: expect.objectContaining({ msg_type: "file" }),
128
128
  }),
129
129
  );
130
130
  });
131
131
 
132
- it("uses msg_type=media for opus", async () => {
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: "media" }),
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=media when replying with mp4", async () => {
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: "media" }),
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 "media" for audio/video files, "file" for documents */
310
- msgType?: "file" | "media";
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 { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
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 requires msg_type "media" for audio/video, "file" for documents
431
- const isMedia = fileType === "mp4" || fileType === "opus";
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: isMedia ? "media" : "file",
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
+ }