@openclaw/feishu 2026.3.13 → 2026.5.2-beta.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 (187) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1827 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
package/src/bitable.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
- import { Type } from "@sinclair/typebox";
3
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
+ import { Type, type TSchema } from "typebox";
4
+ import type { OpenClawPluginApi } from "../runtime-api.js";
4
5
  import { listEnabledFeishuAccounts } from "./accounts.js";
5
6
  import { createFeishuToolClient } from "./tool-account.js";
6
7
 
@@ -14,6 +15,16 @@ function json(data: unknown) {
14
15
  }
15
16
 
16
17
  type LarkResponse<T = unknown> = { code?: number; msg?: string; data?: T };
18
+ type BitableRecordCreatePayload = NonNullable<
19
+ Parameters<Lark.Client["bitable"]["appTableRecord"]["create"]>[0]
20
+ >;
21
+ type BitableRecordUpdatePayload = NonNullable<
22
+ Parameters<Lark.Client["bitable"]["appTableRecord"]["update"]>[0]
23
+ >;
24
+ type BitableRecordFields = NonNullable<NonNullable<BitableRecordCreatePayload["data"]>["fields"]>;
25
+ type BitableRecordUpdateFields = NonNullable<
26
+ NonNullable<BitableRecordUpdatePayload["data"]>["fields"]
27
+ >;
17
28
 
18
29
  export class LarkApiError extends Error {
19
30
  readonly code: number;
@@ -212,12 +223,11 @@ async function createRecord(
212
223
  client: Lark.Client,
213
224
  appToken: string,
214
225
  tableId: string,
215
- fields: Record<string, unknown>,
226
+ fields: BitableRecordFields,
216
227
  ) {
217
228
  const res = await client.bitable.appTableRecord.create({
218
229
  path: { app_token: appToken, table_id: tableId },
219
- // oxlint-disable-next-line typescript/no-explicit-any
220
- data: { fields: fields as any },
230
+ data: { fields },
221
231
  });
222
232
  ensureLarkSuccess(res, "bitable.appTableRecord.create", { appToken, tableId });
223
233
 
@@ -235,6 +245,35 @@ type CleanupLogger = {
235
245
  /** Default field types created for new Bitable tables (to be cleaned up) */
236
246
  const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
237
247
 
248
+ function isDefaultEmptyBitableFieldValue(value: unknown): boolean {
249
+ if (value === undefined || value === null || value === "") {
250
+ return true;
251
+ }
252
+ if (Array.isArray(value)) {
253
+ return value.every(isDefaultEmptyBitableFieldValue);
254
+ }
255
+ if (typeof value === "object") {
256
+ const record = value as Record<string, unknown>;
257
+ const keys = Object.keys(record);
258
+ if (keys.length === 0) {
259
+ return true;
260
+ }
261
+ if ("text" in record && keys.every((key) => key === "text" || key === "type")) {
262
+ return record.text === undefined || record.text === null || record.text === "";
263
+ }
264
+ return Object.values(record).every(isDefaultEmptyBitableFieldValue);
265
+ }
266
+ return false;
267
+ }
268
+
269
+ function isPlaceholderBitableRecord(fields: unknown): boolean {
270
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
271
+ return true;
272
+ }
273
+ const values = Object.values(fields);
274
+ return values.every(isDefaultEmptyBitableFieldValue);
275
+ }
276
+
238
277
  /** Clean up default placeholder rows and fields in a newly created Bitable table */
239
278
  async function cleanupNewBitable(
240
279
  client: Lark.Client,
@@ -270,7 +309,7 @@ async function cleanupNewBitable(
270
309
  });
271
310
  cleanedFields++;
272
311
  } catch (err) {
273
- logger.debug(`Failed to rename primary field: ${err}`);
312
+ logger.debug(`Failed to rename primary field: ${String(err)}`);
274
313
  }
275
314
  }
276
315
 
@@ -291,7 +330,7 @@ async function cleanupNewBitable(
291
330
  });
292
331
  cleanedFields++;
293
332
  } catch (err) {
294
- logger.debug(`Failed to delete default field ${field.field_name}: ${err}`);
333
+ logger.debug(`Failed to delete default field ${field.field_name}: ${String(err)}`);
295
334
  }
296
335
  }
297
336
  }
@@ -305,7 +344,7 @@ async function cleanupNewBitable(
305
344
 
306
345
  if (recordsRes.code === 0 && recordsRes.data?.items) {
307
346
  const emptyRecordIds = recordsRes.data.items
308
- .filter((r) => !r.fields || Object.keys(r.fields).length === 0)
347
+ .filter((r) => isPlaceholderBitableRecord(r.fields))
309
348
  .map((r) => r.record_id)
310
349
  .filter((id): id is string => Boolean(id));
311
350
 
@@ -325,7 +364,7 @@ async function cleanupNewBitable(
325
364
  });
326
365
  cleanedRows++;
327
366
  } catch (err) {
328
- logger.debug(`Failed to delete empty row ${recordId}: ${err}`);
367
+ logger.debug(`Failed to delete empty row ${recordId}: ${String(err)}`);
329
368
  }
330
369
  }
331
370
  }
@@ -372,7 +411,7 @@ async function createApp(
372
411
  }
373
412
  }
374
413
  } catch (err) {
375
- log.debug(`Cleanup failed (non-critical): ${err}`);
414
+ log.debug(`Cleanup failed (non-critical): ${String(err)}`);
376
415
  }
377
416
 
378
417
  return {
@@ -424,12 +463,11 @@ async function updateRecord(
424
463
  appToken: string,
425
464
  tableId: string,
426
465
  recordId: string,
427
- fields: Record<string, unknown>,
466
+ fields: NonNullable<NonNullable<BitableRecordUpdatePayload["data"]>["fields"]>,
428
467
  ) {
429
468
  const res = await client.bitable.appTableRecord.update({
430
469
  path: { app_token: appToken, table_id: tableId, record_id: recordId },
431
- // oxlint-disable-next-line typescript/no-explicit-any
432
- data: { fields: fields as any },
470
+ data: { fields },
433
471
  });
434
472
  ensureLarkSuccess(res, "bitable.appTableRecord.update", { appToken, tableId, recordId });
435
473
 
@@ -534,13 +572,11 @@ const UpdateRecordSchema = Type.Object({
534
572
 
535
573
  export function registerFeishuBitableTools(api: OpenClawPluginApi) {
536
574
  if (!api.config) {
537
- api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools");
538
575
  return;
539
576
  }
540
577
 
541
578
  const accounts = listEnabledFeishuAccounts(api.config);
542
579
  if (accounts.length === 0) {
543
- api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
544
580
  return;
545
581
  }
546
582
 
@@ -549,11 +585,14 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
549
585
  const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) =>
550
586
  createFeishuToolClient({ api, executeParams: params, defaultAccountId });
551
587
 
552
- const registerBitableTool = <TParams extends AccountAwareParams>(params: {
588
+ const registerBitableTool = <
589
+ // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Tool params bind each schema-specific executor to its registered tool.
590
+ TParams extends AccountAwareParams,
591
+ >(params: {
553
592
  name: string;
554
593
  label: string;
555
594
  description: string;
556
- parameters: unknown;
595
+ parameters: TSchema;
557
596
  execute: (args: { params: TParams; defaultAccountId?: string }) => Promise<unknown>;
558
597
  }) => {
559
598
  api.registerTool(
@@ -571,7 +610,7 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
571
610
  }),
572
611
  );
573
612
  } catch (err) {
574
- return json({ error: err instanceof Error ? err.message : String(err) });
613
+ return json({ error: formatErrorMessage(err) });
575
614
  }
576
615
  },
577
616
  }),
@@ -645,7 +684,7 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
645
684
  registerBitableTool<{
646
685
  app_token: string;
647
686
  table_id: string;
648
- fields: Record<string, unknown>;
687
+ fields: BitableRecordFields;
649
688
  accountId?: string;
650
689
  }>({
651
690
  name: "feishu_bitable_create_record",
@@ -666,7 +705,7 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
666
705
  app_token: string;
667
706
  table_id: string;
668
707
  record_id: string;
669
- fields: Record<string, unknown>;
708
+ fields: BitableRecordUpdateFields;
670
709
  accountId?: string;
671
710
  }>({
672
711
  name: "feishu_bitable_update_record",
@@ -720,6 +759,4 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
720
759
  );
721
760
  },
722
761
  });
723
-
724
- api.logger.info?.("feishu_bitable: Registered bitable tools");
725
762
  }
@@ -0,0 +1,474 @@
1
+ import type { ClawdbotConfig } from "../runtime-api.js";
2
+ import { buildFeishuConversationId } from "./conversation-id.js";
3
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
4
+ import { downloadMessageResourceFeishu } from "./media.js";
5
+ import { isFeishuBroadcastMention } from "./mention.js";
6
+ import { parsePostContent } from "./post.js";
7
+ import { getFeishuRuntime } from "./runtime.js";
8
+ import type { FeishuChatType, FeishuMediaInfo } from "./types.js";
9
+
10
+ type FeishuMention = {
11
+ key: string;
12
+ id: {
13
+ open_id?: string;
14
+ user_id?: string;
15
+ union_id?: string;
16
+ };
17
+ name: string;
18
+ tenant_key?: string;
19
+ };
20
+
21
+ type FeishuMessageLike = {
22
+ message: {
23
+ content: string;
24
+ message_type: string;
25
+ mentions?: FeishuMention[];
26
+ chat_id: string;
27
+ root_id?: string;
28
+ parent_id?: string;
29
+ thread_id?: string;
30
+ message_id: string;
31
+ };
32
+ sender: {
33
+ sender_id: {
34
+ open_id?: string;
35
+ user_id?: string;
36
+ };
37
+ };
38
+ };
39
+
40
+ type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
41
+
42
+ type FeishuLogger = (...args: unknown[]) => void;
43
+
44
+ type ResolvedFeishuGroupSession = {
45
+ peerId: string;
46
+ parentPeer: { kind: "group"; id: string } | null;
47
+ groupSessionScope: GroupSessionScope;
48
+ replyInThread: boolean;
49
+ threadReply: boolean;
50
+ };
51
+
52
+ export function resolveFeishuGroupSession(params: {
53
+ chatId: string;
54
+ senderOpenId: string;
55
+ messageId: string;
56
+ rootId?: string;
57
+ threadId?: string;
58
+ chatType?: FeishuChatType;
59
+ groupConfig?: {
60
+ groupSessionScope?: GroupSessionScope;
61
+ topicSessionMode?: "enabled" | "disabled";
62
+ replyInThread?: "enabled" | "disabled";
63
+ };
64
+ feishuCfg?: {
65
+ groupSessionScope?: GroupSessionScope;
66
+ topicSessionMode?: "enabled" | "disabled";
67
+ replyInThread?: "enabled" | "disabled";
68
+ };
69
+ }): ResolvedFeishuGroupSession {
70
+ const { chatId, senderOpenId, messageId, rootId, threadId, chatType, groupConfig, feishuCfg } =
71
+ params;
72
+ const normalizedThreadId = threadId?.trim();
73
+ const normalizedRootId = rootId?.trim();
74
+ const threadReply = Boolean(normalizedThreadId || normalizedRootId);
75
+ const replyInThread =
76
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
77
+ threadReply;
78
+ const legacyTopicSessionMode =
79
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
80
+ const groupSessionScope: GroupSessionScope =
81
+ groupConfig?.groupSessionScope ??
82
+ feishuCfg?.groupSessionScope ??
83
+ (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
84
+ const normalizedTopicGroupThreadId =
85
+ chatType === "topic_group" ? (normalizedThreadId ?? normalizedRootId) : undefined;
86
+ const topicScope =
87
+ groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
88
+ ? (normalizedTopicGroupThreadId ??
89
+ normalizedRootId ??
90
+ normalizedThreadId ??
91
+ (replyInThread ? messageId : null))
92
+ : null;
93
+
94
+ let peerId = chatId;
95
+ switch (groupSessionScope) {
96
+ case "group_sender":
97
+ peerId = buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
98
+ break;
99
+ case "group_topic":
100
+ peerId = topicScope
101
+ ? buildFeishuConversationId({ chatId, scope: "group_topic", topicId: topicScope })
102
+ : chatId;
103
+ break;
104
+ case "group_topic_sender":
105
+ peerId = topicScope
106
+ ? buildFeishuConversationId({
107
+ chatId,
108
+ scope: "group_topic_sender",
109
+ topicId: topicScope,
110
+ senderOpenId,
111
+ })
112
+ : buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
113
+ break;
114
+ case "group":
115
+ default:
116
+ peerId = chatId;
117
+ break;
118
+ }
119
+
120
+ return {
121
+ peerId,
122
+ parentPeer:
123
+ topicScope &&
124
+ (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
125
+ ? { kind: "group", id: chatId }
126
+ : null,
127
+ groupSessionScope,
128
+ replyInThread,
129
+ threadReply,
130
+ };
131
+ }
132
+
133
+ export function parseMessageContent(content: string, messageType: string): string {
134
+ if (messageType === "post") {
135
+ return parsePostContent(content).textContent;
136
+ }
137
+
138
+ try {
139
+ const parsed = JSON.parse(content);
140
+ if (messageType === "text") {
141
+ return parsed.text || "";
142
+ }
143
+ if (["image", "file", "audio", "video", "media", "sticker"].includes(messageType)) {
144
+ if (messageType === "audio") {
145
+ const speechToText =
146
+ typeof parsed.speech_to_text === "string" ? parsed.speech_to_text.trim() : "";
147
+ if (speechToText) {
148
+ return speechToText;
149
+ }
150
+ }
151
+ const placeholder = inferPlaceholder(messageType);
152
+ const fileName = typeof parsed.file_name === "string" ? parsed.file_name.trim() : "";
153
+ return fileName ? `${placeholder} (${fileName})` : placeholder;
154
+ }
155
+ if (messageType === "share_chat") {
156
+ if (parsed && typeof parsed === "object") {
157
+ const share = parsed as { body?: unknown; summary?: unknown; share_chat_id?: unknown };
158
+ if (typeof share.body === "string" && share.body.trim()) {
159
+ return share.body.trim();
160
+ }
161
+ if (typeof share.summary === "string" && share.summary.trim()) {
162
+ return share.summary.trim();
163
+ }
164
+ if (typeof share.share_chat_id === "string" && share.share_chat_id.trim()) {
165
+ return `[Forwarded message: ${share.share_chat_id.trim()}]`;
166
+ }
167
+ }
168
+ return "[Forwarded message]";
169
+ }
170
+ if (messageType === "merge_forward") {
171
+ return "[Merged and Forwarded Message - loading...]";
172
+ }
173
+ return content;
174
+ } catch {
175
+ return content;
176
+ }
177
+ }
178
+
179
+ function formatSubMessageContent(content: string, contentType: string): string {
180
+ try {
181
+ const parsed = JSON.parse(content);
182
+ switch (contentType) {
183
+ case "text":
184
+ return parsed.text || content;
185
+ case "post":
186
+ return parsePostContent(content).textContent;
187
+ case "image":
188
+ return "[Image]";
189
+ case "file":
190
+ return `[File: ${parsed.file_name || "unknown"}]`;
191
+ case "audio":
192
+ return "[Audio]";
193
+ case "video":
194
+ return "[Video]";
195
+ case "sticker":
196
+ return "[Sticker]";
197
+ case "merge_forward":
198
+ return "[Nested Merged Forward]";
199
+ default:
200
+ return `[${contentType}]`;
201
+ }
202
+ } catch {
203
+ return content;
204
+ }
205
+ }
206
+
207
+ export function parseMergeForwardContent(params: { content: string; log?: FeishuLogger }): string {
208
+ const { content, log } = params;
209
+ const maxMessages = 50;
210
+ log?.("feishu: parsing merge_forward sub-messages from API response");
211
+
212
+ let items: Array<{
213
+ message_id?: string;
214
+ msg_type?: string;
215
+ body?: { content?: string };
216
+ sender?: { id?: string };
217
+ upper_message_id?: string;
218
+ create_time?: string;
219
+ }>;
220
+ try {
221
+ items = JSON.parse(content);
222
+ } catch {
223
+ log?.("feishu: merge_forward items parse failed");
224
+ return "[Merged and Forwarded Message - parse error]";
225
+ }
226
+ if (!Array.isArray(items) || items.length === 0) {
227
+ return "[Merged and Forwarded Message - no sub-messages]";
228
+ }
229
+ const subMessages = items.filter((item) => item.upper_message_id);
230
+ if (subMessages.length === 0) {
231
+ return "[Merged and Forwarded Message - no sub-messages found]";
232
+ }
233
+
234
+ log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
235
+ subMessages.sort(
236
+ (a, b) => Number.parseInt(a.create_time || "0", 10) - Number.parseInt(b.create_time || "0", 10),
237
+ );
238
+
239
+ const lines = ["[Merged and Forwarded Messages]"];
240
+ for (const item of subMessages.slice(0, maxMessages)) {
241
+ lines.push(`- ${formatSubMessageContent(item.body?.content || "", item.msg_type || "text")}`);
242
+ }
243
+ if (subMessages.length > maxMessages) {
244
+ lines.push(`... and ${subMessages.length - maxMessages} more messages`);
245
+ }
246
+ return lines.join("\n");
247
+ }
248
+
249
+ export function checkBotMentioned(event: FeishuMessageLike, botOpenId?: string): boolean {
250
+ if (!botOpenId) {
251
+ return false;
252
+ }
253
+ const mentions = event.message.mentions ?? [];
254
+ if (mentions.length > 0) {
255
+ return mentions.some(
256
+ (mention) => !isFeishuBroadcastMention(mention) && mention.id.open_id === botOpenId,
257
+ );
258
+ }
259
+ if (event.message.message_type === "post") {
260
+ return parsePostContent(event.message.content).mentionedOpenIds.some(
261
+ (id) => id.trim().toLowerCase() !== "all" && id === botOpenId,
262
+ );
263
+ }
264
+ return false;
265
+ }
266
+
267
+ export function normalizeMentions(
268
+ text: string,
269
+ mentions?: FeishuMention[],
270
+ botStripId?: string,
271
+ ): string {
272
+ if (!mentions || mentions.length === 0) {
273
+ return text;
274
+ }
275
+ const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
276
+ const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
277
+ let result = text;
278
+ for (const mention of mentions) {
279
+ const mentionId = mention.id.open_id;
280
+ const replacement =
281
+ botStripId && mentionId === botStripId
282
+ ? ""
283
+ : mentionId
284
+ ? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
285
+ : `@${mention.name}`;
286
+ result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
287
+ }
288
+ return result;
289
+ }
290
+
291
+ export function normalizeFeishuCommandProbeBody(text: string): string {
292
+ if (!text) {
293
+ return "";
294
+ }
295
+ return text
296
+ .replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
297
+ .replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
298
+ .replace(/\s+/g, " ")
299
+ .trim();
300
+ }
301
+
302
+ function parseMediaKeys(
303
+ content: string,
304
+ messageType: string,
305
+ ): { imageKey?: string; fileKey?: string; fileName?: string } {
306
+ try {
307
+ const parsed = JSON.parse(content);
308
+ const imageKey = normalizeFeishuExternalKey(parsed.image_key);
309
+ const fileKey = normalizeFeishuExternalKey(parsed.file_key);
310
+ switch (messageType) {
311
+ case "image":
312
+ return { imageKey, fileName: parsed.file_name };
313
+ case "file":
314
+ case "audio":
315
+ case "sticker":
316
+ return { fileKey, fileName: parsed.file_name };
317
+ case "video":
318
+ case "media":
319
+ return { fileKey, imageKey, fileName: parsed.file_name };
320
+ default:
321
+ return {};
322
+ }
323
+ } catch {
324
+ return {};
325
+ }
326
+ }
327
+
328
+ export function toMessageResourceType(messageType: string): "image" | "file" {
329
+ return messageType === "image" ? "image" : "file";
330
+ }
331
+
332
+ function inferPlaceholder(messageType: string): string {
333
+ switch (messageType) {
334
+ case "image":
335
+ return "<media:image>";
336
+ case "file":
337
+ return "<media:document>";
338
+ case "audio":
339
+ return "<media:audio>";
340
+ case "video":
341
+ case "media":
342
+ return "<media:video>";
343
+ case "sticker":
344
+ return "<media:sticker>";
345
+ default:
346
+ return "<media:document>";
347
+ }
348
+ }
349
+
350
+ export async function resolveFeishuMediaList(params: {
351
+ cfg: ClawdbotConfig;
352
+ messageId: string;
353
+ messageType: string;
354
+ content: string;
355
+ maxBytes: number;
356
+ log?: (msg: string) => void;
357
+ accountId?: string;
358
+ }): Promise<FeishuMediaInfo[]> {
359
+ const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
360
+ const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
361
+ if (!mediaTypes.includes(messageType)) {
362
+ return [];
363
+ }
364
+
365
+ const out: FeishuMediaInfo[] = [];
366
+ const core = getFeishuRuntime();
367
+
368
+ if (messageType === "post") {
369
+ const { imageKeys, mediaKeys } = parsePostContent(content);
370
+ if (imageKeys.length === 0 && mediaKeys.length === 0) {
371
+ return [];
372
+ }
373
+ if (imageKeys.length > 0) {
374
+ log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
375
+ }
376
+ if (mediaKeys.length > 0) {
377
+ log?.(`feishu: post message contains ${mediaKeys.length} embedded media file(s)`);
378
+ }
379
+
380
+ for (const imageKey of imageKeys) {
381
+ try {
382
+ const result = await downloadMessageResourceFeishu({
383
+ cfg,
384
+ messageId,
385
+ fileKey: imageKey,
386
+ type: "image",
387
+ accountId,
388
+ });
389
+ const contentType =
390
+ result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
391
+ const saved = await core.channel.media.saveMediaBuffer(
392
+ result.buffer,
393
+ contentType,
394
+ "inbound",
395
+ maxBytes,
396
+ );
397
+ out.push({
398
+ path: saved.path,
399
+ contentType: saved.contentType,
400
+ placeholder: "<media:image>",
401
+ });
402
+ log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
403
+ } catch (err) {
404
+ log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
405
+ }
406
+ }
407
+
408
+ for (const media of mediaKeys) {
409
+ try {
410
+ const result = await downloadMessageResourceFeishu({
411
+ cfg,
412
+ messageId,
413
+ fileKey: media.fileKey,
414
+ type: "file",
415
+ accountId,
416
+ });
417
+ const contentType =
418
+ result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
419
+ const saved = await core.channel.media.saveMediaBuffer(
420
+ result.buffer,
421
+ contentType,
422
+ "inbound",
423
+ maxBytes,
424
+ );
425
+ out.push({
426
+ path: saved.path,
427
+ contentType: saved.contentType,
428
+ placeholder: "<media:video>",
429
+ });
430
+ log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
431
+ } catch (err) {
432
+ log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
433
+ }
434
+ }
435
+ return out;
436
+ }
437
+
438
+ const mediaKeys = parseMediaKeys(content, messageType);
439
+ if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
440
+ return [];
441
+ }
442
+
443
+ try {
444
+ const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
445
+ if (!fileKey) {
446
+ return [];
447
+ }
448
+ const result = await downloadMessageResourceFeishu({
449
+ cfg,
450
+ messageId,
451
+ fileKey,
452
+ type: toMessageResourceType(messageType),
453
+ accountId,
454
+ });
455
+ const contentType =
456
+ result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
457
+ const saved = await core.channel.media.saveMediaBuffer(
458
+ result.buffer,
459
+ contentType,
460
+ "inbound",
461
+ maxBytes,
462
+ result.fileName || mediaKeys.fileName,
463
+ );
464
+ out.push({
465
+ path: saved.path,
466
+ contentType: saved.contentType,
467
+ placeholder: inferPlaceholder(messageType),
468
+ });
469
+ log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
470
+ } catch (err) {
471
+ log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
472
+ }
473
+ return out;
474
+ }