@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
@@ -1,7 +1,6 @@
1
- import * as crypto from "crypto";
2
- import * as Lark from "@larksuiteoapi/node-sdk";
3
- import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk/feishu";
4
- import { resolveFeishuAccount } from "./accounts.js";
1
+ import * as crypto from "node:crypto";
2
+ import type * as Lark from "@larksuiteoapi/node-sdk";
3
+ import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "../runtime-api.js";
5
4
  import { raceWithTimeoutAndAbort } from "./async.js";
6
5
  import {
7
6
  handleFeishuMessage,
@@ -11,33 +10,44 @@ import {
11
10
  } from "./bot.js";
12
11
  import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
13
12
  import { createEventDispatcher } from "./client.js";
13
+ import { isRecord, readString } from "./comment-shared.js";
14
14
  import {
15
15
  hasProcessedFeishuMessage,
16
16
  recordProcessedFeishuMessage,
17
- releaseFeishuMessageProcessing,
18
- tryBeginFeishuMessageProcessing,
19
17
  warmupDedupFromDisk,
20
18
  } from "./dedup.js";
21
- import { isMentionForwardRequest } from "./mention.js";
19
+ import { applyBotIdentityState, startBotIdentityRecovery } from "./monitor.bot-identity.js";
20
+ import { createFeishuBotMenuHandler } from "./monitor.bot-menu-handler.js";
21
+ import { createFeishuDriveCommentNoticeHandler } from "./monitor.comment-notice-handler.js";
22
+ import { createFeishuMessageReceiveHandler } from "./monitor.message-handler.js";
22
23
  import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
23
24
  import { botNames, botOpenIds } from "./monitor.state.js";
25
+ import { FeishuRetryableSyntheticEventError } from "./monitor.synthetic-error.js";
24
26
  import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
25
27
  import { getFeishuRuntime } from "./runtime.js";
26
28
  import { getMessageFeishu } from "./send.js";
29
+ import { getFeishuSequentialKey } from "./sequential-key.js";
30
+ import { createFeishuThreadBindingManager } from "./thread-bindings.js";
27
31
  import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
28
32
 
29
33
  const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
30
34
 
35
+ export { FeishuRetryableSyntheticEventError };
36
+
31
37
  export type FeishuReactionCreatedEvent = {
32
38
  message_id: string;
33
39
  chat_id?: string;
34
40
  chat_type?: string;
35
41
  reaction_type?: { emoji_type?: string };
36
42
  operator_type?: string;
37
- user_id?: { open_id?: string };
43
+ user_id?: { open_id?: string; user_id?: string };
38
44
  action_time?: string;
39
45
  };
40
46
 
47
+ export type FeishuReactionDeletedEvent = FeishuReactionCreatedEvent & {
48
+ reaction_id?: string;
49
+ };
50
+
41
51
  type ResolveReactionSyntheticEventParams = {
42
52
  cfg: ClawdbotConfig;
43
53
  accountId: string;
@@ -47,6 +57,7 @@ type ResolveReactionSyntheticEventParams = {
47
57
  verificationTimeoutMs?: number;
48
58
  logger?: (message: string) => void;
49
59
  uuid?: () => string;
60
+ action?: "created" | "deleted";
50
61
  };
51
62
 
52
63
  export async function resolveReactionSyntheticEvent(
@@ -61,15 +72,18 @@ export async function resolveReactionSyntheticEvent(
61
72
  verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
62
73
  logger,
63
74
  uuid = () => crypto.randomUUID(),
75
+ action = "created",
64
76
  } = params;
65
77
 
66
78
  const emoji = event.reaction_type?.emoji_type;
67
79
  const messageId = event.message_id;
68
80
  const senderId = event.user_id?.open_id;
81
+ const senderUserId = event.user_id?.user_id;
69
82
  if (!emoji || !messageId || !senderId) {
70
83
  return null;
71
84
  }
72
85
 
86
+ const { resolveFeishuAccount } = await import("./accounts.js");
73
87
  const account = resolveFeishuAccount({ cfg, accountId });
74
88
  const reactionNotifications = account.config.reactionNotifications ?? "own";
75
89
  if (reactionNotifications === "off") {
@@ -120,7 +134,10 @@ export async function resolveReactionSyntheticEvent(
120
134
  const syntheticChatType: FeishuChatType = resolvedChatType;
121
135
  return {
122
136
  sender: {
123
- sender_id: { open_id: senderId },
137
+ sender_id: {
138
+ open_id: senderId,
139
+ ...(senderUserId ? { user_id: senderUserId } : {}),
140
+ },
124
141
  sender_type: "user",
125
142
  },
126
143
  message: {
@@ -129,14 +146,19 @@ export async function resolveReactionSyntheticEvent(
129
146
  chat_type: syntheticChatType,
130
147
  message_type: "text",
131
148
  content: JSON.stringify({
132
- text: `[reacted with ${emoji} to message ${messageId}]`,
149
+ text:
150
+ action === "deleted"
151
+ ? `[removed reaction ${emoji} from message ${messageId}]`
152
+ : `[reacted with ${emoji} to message ${messageId}]`,
133
153
  }),
134
154
  },
135
155
  };
136
156
  }
137
157
 
138
158
  function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
139
- return value === "group" || value === "private" || value === "p2p" ? value : undefined;
159
+ return value === "group" || value === "topic_group" || value === "private" || value === "p2p"
160
+ ? value
161
+ : undefined;
140
162
  }
141
163
 
142
164
  type RegisterEventHandlersContext = {
@@ -147,97 +169,73 @@ type RegisterEventHandlersContext = {
147
169
  fireAndForget?: boolean;
148
170
  };
149
171
 
150
- /**
151
- * Per-chat serial queue that ensures messages from the same chat are processed
152
- * in arrival order while allowing different chats to run concurrently.
153
- */
154
- function createChatQueue() {
155
- const queues = new Map<string, Promise<void>>();
156
- return (chatId: string, task: () => Promise<void>): Promise<void> => {
157
- const prev = queues.get(chatId) ?? Promise.resolve();
158
- const next = prev.then(task, task);
159
- queues.set(chatId, next);
160
- void next.finally(() => {
161
- if (queues.get(chatId) === next) {
162
- queues.delete(chatId);
163
- }
164
- });
165
- return next;
166
- };
172
+ function parseFeishuBotAddedEventPayload(value: unknown): FeishuBotAddedEvent | null {
173
+ if (!isRecord(value) || !readString(value.chat_id) || !isRecord(value.operator_id)) {
174
+ return null;
175
+ }
176
+ return value as FeishuBotAddedEvent;
167
177
  }
168
178
 
169
- function mergeFeishuDebounceMentions(
170
- entries: FeishuMessageEvent[],
171
- ): FeishuMessageEvent["message"]["mentions"] | undefined {
172
- const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
173
- for (const entry of entries) {
174
- for (const mention of entry.message.mentions ?? []) {
175
- const stableId =
176
- mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
177
- const mentionName = mention.name?.trim();
178
- const mentionKey = mention.key?.trim();
179
- const fallback =
180
- mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
181
- const key = stableId || fallback;
182
- if (!key || merged.has(key)) {
183
- continue;
184
- }
185
- merged.set(key, mention);
186
- }
187
- }
188
- if (merged.size === 0) {
189
- return undefined;
179
+ function parseFeishuBotRemovedChatId(value: unknown): string | null {
180
+ if (!isRecord(value)) {
181
+ return null;
190
182
  }
191
- return Array.from(merged.values());
183
+ return readString(value.chat_id) ?? null;
192
184
  }
193
185
 
194
- function dedupeFeishuDebounceEntriesByMessageId(
195
- entries: FeishuMessageEvent[],
196
- ): FeishuMessageEvent[] {
197
- const seen = new Set<string>();
198
- const deduped: FeishuMessageEvent[] = [];
199
- for (const entry of entries) {
200
- const messageId = entry.message.message_id?.trim();
201
- if (!messageId) {
202
- deduped.push(entry);
203
- continue;
204
- }
205
- if (seen.has(messageId)) {
206
- continue;
186
+ function firstString(...values: unknown[]): string | undefined {
187
+ for (const value of values) {
188
+ const stringValue = readString(value);
189
+ const trimmed = stringValue?.trim();
190
+ if (trimmed) {
191
+ return trimmed;
207
192
  }
208
- seen.add(messageId);
209
- deduped.push(entry);
210
193
  }
211
- return deduped;
194
+ return undefined;
212
195
  }
213
196
 
214
- function resolveFeishuDebounceMentions(params: {
215
- entries: FeishuMessageEvent[];
216
- botOpenId?: string;
217
- }): FeishuMessageEvent["message"]["mentions"] | undefined {
218
- const { entries, botOpenId } = params;
219
- if (entries.length === 0) {
220
- return undefined;
221
- }
222
- for (let index = entries.length - 1; index >= 0; index -= 1) {
223
- const entry = entries[index];
224
- if (isMentionForwardRequest(entry, botOpenId)) {
225
- // Keep mention-forward semantics scoped to a single source message.
226
- return mergeFeishuDebounceMentions([entry]);
227
- }
197
+ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEvent | null {
198
+ if (!isRecord(value)) {
199
+ return null;
228
200
  }
229
- const merged = mergeFeishuDebounceMentions(entries);
230
- if (!merged) {
231
- return undefined;
201
+ const operator = isRecord(value.operator) ? value.operator : {};
202
+ const action = value.action;
203
+ const context = isRecord(value.context) ? value.context : {};
204
+ if (!isRecord(action)) {
205
+ return null;
232
206
  }
233
- const normalizedBotOpenId = botOpenId?.trim();
234
- if (!normalizedBotOpenId) {
235
- return undefined;
207
+ const token = readString(value.token);
208
+ const openId = firstString(operator.open_id, value.open_id, context.open_id);
209
+ const userId = firstString(operator.user_id, value.user_id, context.user_id);
210
+ const unionId = firstString(operator.union_id);
211
+ const tag = readString(action.tag);
212
+ const actionValue = action.value;
213
+ const openMessageId = firstString(value.open_message_id, context.open_message_id);
214
+ const contextOpenId = firstString(context.open_id, openId);
215
+ const contextUserId = firstString(context.user_id, userId);
216
+ const chatId = firstString(context.chat_id, context.open_chat_id);
217
+ if (!token || !openId || !tag || !isRecord(actionValue)) {
218
+ return null;
236
219
  }
237
- const botMentions = merged.filter(
238
- (mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
239
- );
240
- return botMentions.length > 0 ? botMentions : undefined;
220
+ return {
221
+ operator: {
222
+ open_id: openId,
223
+ ...(userId ? { user_id: userId } : {}),
224
+ ...(unionId ? { union_id: unionId } : {}),
225
+ },
226
+ token,
227
+ action: {
228
+ value: actionValue,
229
+ tag,
230
+ },
231
+ ...(openMessageId ? { open_message_id: openMessageId } : {}),
232
+ context: {
233
+ ...(openMessageId ? { open_message_id: openMessageId } : {}),
234
+ ...(contextOpenId ? { open_id: contextOpenId } : {}),
235
+ ...(contextUserId ? { user_id: contextUserId } : {}),
236
+ ...(chatId ? { chat_id: chatId } : {}),
237
+ },
238
+ };
241
239
  }
242
240
 
243
241
  function registerEventHandlers(
@@ -245,175 +243,48 @@ function registerEventHandlers(
245
243
  context: RegisterEventHandlersContext,
246
244
  ): void {
247
245
  const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
248
- const core = getFeishuRuntime();
249
- const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
250
- cfg,
251
- channel: "feishu",
252
- });
253
246
  const log = runtime?.log ?? console.log;
254
247
  const error = runtime?.error ?? console.error;
255
- const enqueue = createChatQueue();
256
- const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
257
- const chatId = event.message.chat_id?.trim() || "unknown";
258
- const task = () =>
259
- handleFeishuMessage({
260
- cfg,
261
- event,
262
- botOpenId: botOpenIds.get(accountId),
263
- botName: botNames.get(accountId),
264
- runtime,
265
- chatHistories,
266
- accountId,
267
- processingClaimHeld: true,
248
+ const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
249
+ if (fireAndForget) {
250
+ void params.task().catch((err) => {
251
+ error(`${params.errorMessage}: ${String(err)}`);
268
252
  });
269
- await enqueue(chatId, task);
270
- };
271
- const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
272
- const senderId =
273
- event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
274
- return senderId || undefined;
275
- };
276
- const resolveDebounceText = (event: FeishuMessageEvent): string => {
277
- const botOpenId = botOpenIds.get(accountId);
278
- const parsed = parseFeishuMessageEvent(event, botOpenId, botNames.get(accountId));
279
- return parsed.content.trim();
280
- };
281
- const recordSuppressedMessageIds = async (
282
- entries: FeishuMessageEvent[],
283
- dispatchMessageId?: string,
284
- ) => {
285
- const keepMessageId = dispatchMessageId?.trim();
286
- const suppressedIds = new Set(
287
- entries
288
- .map((entry) => entry.message.message_id?.trim())
289
- .filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
290
- );
291
- if (suppressedIds.size === 0) {
292
253
  return;
293
254
  }
294
- for (const messageId of suppressedIds) {
295
- try {
296
- await recordProcessedFeishuMessage(messageId, accountId, log);
297
- } catch (err) {
298
- error(
299
- `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
300
- );
301
- }
255
+ try {
256
+ await params.task();
257
+ } catch (err) {
258
+ error(`${params.errorMessage}: ${String(err)}`);
302
259
  }
303
260
  };
304
- const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
305
- return await hasProcessedFeishuMessage(entry.message.message_id, accountId, log);
306
- };
307
- const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
308
- debounceMs: inboundDebounceMs,
309
- buildKey: (event) => {
310
- const chatId = event.message.chat_id?.trim();
311
- const senderId = resolveSenderDebounceId(event);
312
- if (!chatId || !senderId) {
313
- return null;
314
- }
315
- const rootId = event.message.root_id?.trim();
316
- const threadKey = rootId ? `thread:${rootId}` : "chat";
317
- return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
318
- },
319
- shouldDebounce: (event) => {
320
- if (event.message.message_type !== "text") {
321
- return false;
322
- }
323
- const text = resolveDebounceText(event);
324
- if (!text) {
325
- return false;
326
- }
327
- return !core.channel.text.hasControlCommand(text, cfg);
328
- },
329
- onFlush: async (entries) => {
330
- const last = entries.at(-1);
331
- if (!last) {
332
- return;
333
- }
334
- if (entries.length === 1) {
335
- await dispatchFeishuMessage(last);
336
- return;
337
- }
338
- const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
339
- const freshEntries: FeishuMessageEvent[] = [];
340
- for (const entry of dedupedEntries) {
341
- if (!(await isMessageAlreadyProcessed(entry))) {
342
- freshEntries.push(entry);
343
- }
344
- }
345
- const dispatchEntry = freshEntries.at(-1);
346
- if (!dispatchEntry) {
347
- return;
348
- }
349
- await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
350
- const combinedText = freshEntries
351
- .map((entry) => resolveDebounceText(entry))
352
- .filter(Boolean)
353
- .join("\n");
354
- const mergedMentions = resolveFeishuDebounceMentions({
355
- entries: freshEntries,
356
- botOpenId: botOpenIds.get(accountId),
357
- });
358
- if (!combinedText.trim()) {
359
- await dispatchFeishuMessage({
360
- ...dispatchEntry,
361
- message: {
362
- ...dispatchEntry.message,
363
- mentions: mergedMentions ?? dispatchEntry.message.mentions,
364
- },
365
- });
366
- return;
367
- }
368
- await dispatchFeishuMessage({
369
- ...dispatchEntry,
370
- message: {
371
- ...dispatchEntry.message,
372
- message_type: "text",
373
- content: JSON.stringify({ text: combinedText }),
374
- mentions: mergedMentions ?? dispatchEntry.message.mentions,
375
- },
376
- });
377
- },
378
- onError: (err, entries) => {
379
- for (const entry of entries) {
380
- releaseFeishuMessageProcessing(entry.message.message_id, accountId);
381
- }
382
- error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
383
- },
384
- });
385
261
 
386
262
  eventDispatcher.register({
387
- "im.message.receive_v1": async (data) => {
388
- const event = data as unknown as FeishuMessageEvent;
389
- const messageId = event.message?.message_id?.trim();
390
- if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
391
- log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
392
- return;
393
- }
394
- const processMessage = async () => {
395
- await inboundDebouncer.enqueue(event);
396
- };
397
- if (fireAndForget) {
398
- void processMessage().catch((err) => {
399
- releaseFeishuMessageProcessing(messageId, accountId);
400
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
401
- });
402
- return;
403
- }
404
- try {
405
- await processMessage();
406
- } catch (err) {
407
- releaseFeishuMessageProcessing(messageId, accountId);
408
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
409
- }
410
- },
263
+ "im.message.receive_v1": createFeishuMessageReceiveHandler({
264
+ cfg,
265
+ core: getFeishuRuntime(),
266
+ accountId,
267
+ runtime,
268
+ chatHistories,
269
+ fireAndForget,
270
+ handleMessage: handleFeishuMessage,
271
+ resolveDebounceText: ({ event, botOpenId, botName }) =>
272
+ parseFeishuMessageEvent(event, botOpenId, botName).content,
273
+ hasProcessedMessage: hasProcessedFeishuMessage,
274
+ recordProcessedMessage: recordProcessedFeishuMessage,
275
+ getBotOpenId: (id) => botOpenIds.get(id),
276
+ getBotName: (id) => botNames.get(id),
277
+ resolveSequentialKey: getFeishuSequentialKey,
278
+ }),
411
279
  "im.message.message_read_v1": async () => {
412
280
  // Ignore read receipts
413
281
  },
414
282
  "im.chat.member.bot.added_v1": async (data) => {
415
283
  try {
416
- const event = data as unknown as FeishuBotAddedEvent;
284
+ const event = parseFeishuBotAddedEventPayload(data);
285
+ if (!event) {
286
+ return;
287
+ }
417
288
  log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
418
289
  } catch (err) {
419
290
  error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
@@ -421,63 +292,94 @@ function registerEventHandlers(
421
292
  },
422
293
  "im.chat.member.bot.deleted_v1": async (data) => {
423
294
  try {
424
- const event = data as unknown as { chat_id: string };
425
- log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
295
+ const chatId = parseFeishuBotRemovedChatId(data);
296
+ if (!chatId) {
297
+ return;
298
+ }
299
+ log(`feishu[${accountId}]: bot removed from chat ${chatId}`);
426
300
  } catch (err) {
427
301
  error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
428
302
  }
429
303
  },
304
+ "drive.notice.comment_add_v1": createFeishuDriveCommentNoticeHandler({
305
+ cfg,
306
+ accountId,
307
+ runtime,
308
+ fireAndForget,
309
+ }),
430
310
  "im.message.reaction.created_v1": async (data) => {
431
- const processReaction = async () => {
432
- const event = data as FeishuReactionCreatedEvent;
433
- const myBotId = botOpenIds.get(accountId);
434
- const syntheticEvent = await resolveReactionSyntheticEvent({
435
- cfg,
436
- accountId,
437
- event,
438
- botOpenId: myBotId,
439
- logger: log,
440
- });
441
- if (!syntheticEvent) {
442
- return;
443
- }
444
- const promise = handleFeishuMessage({
445
- cfg,
446
- event: syntheticEvent,
447
- botOpenId: myBotId,
448
- botName: botNames.get(accountId),
449
- runtime,
450
- chatHistories,
451
- accountId,
452
- });
453
- if (fireAndForget) {
454
- promise.catch((err) => {
455
- error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
311
+ await runFeishuHandler({
312
+ errorMessage: `feishu[${accountId}]: error handling reaction event`,
313
+ task: async () => {
314
+ const event = data as FeishuReactionCreatedEvent;
315
+ const myBotId = botOpenIds.get(accountId);
316
+ const syntheticEvent = await resolveReactionSyntheticEvent({
317
+ cfg,
318
+ accountId,
319
+ event,
320
+ botOpenId: myBotId,
321
+ logger: log,
456
322
  });
457
- return;
458
- }
459
- await promise;
460
- };
461
-
462
- if (fireAndForget) {
463
- void processReaction().catch((err) => {
464
- error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
465
- });
466
- return;
467
- }
468
-
469
- try {
470
- await processReaction();
471
- } catch (err) {
472
- error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
473
- }
323
+ if (!syntheticEvent) {
324
+ return;
325
+ }
326
+ const promise = handleFeishuMessage({
327
+ cfg,
328
+ event: syntheticEvent,
329
+ botOpenId: myBotId,
330
+ botName: botNames.get(accountId),
331
+ runtime,
332
+ chatHistories,
333
+ accountId,
334
+ });
335
+ await promise;
336
+ },
337
+ });
474
338
  },
475
- "im.message.reaction.deleted_v1": async () => {
476
- // Ignore reaction removals
339
+ "im.message.reaction.deleted_v1": async (data) => {
340
+ await runFeishuHandler({
341
+ errorMessage: `feishu[${accountId}]: error handling reaction removal event`,
342
+ task: async () => {
343
+ const event = data as FeishuReactionDeletedEvent;
344
+ const myBotId = botOpenIds.get(accountId);
345
+ const syntheticEvent = await resolveReactionSyntheticEvent({
346
+ cfg,
347
+ accountId,
348
+ event,
349
+ botOpenId: myBotId,
350
+ logger: log,
351
+ action: "deleted",
352
+ });
353
+ if (!syntheticEvent) {
354
+ return;
355
+ }
356
+ const promise = handleFeishuMessage({
357
+ cfg,
358
+ event: syntheticEvent,
359
+ botOpenId: myBotId,
360
+ botName: botNames.get(accountId),
361
+ runtime,
362
+ chatHistories,
363
+ accountId,
364
+ });
365
+ await promise;
366
+ },
367
+ });
477
368
  },
369
+ "application.bot.menu_v6": createFeishuBotMenuHandler({
370
+ cfg,
371
+ accountId,
372
+ runtime,
373
+ chatHistories,
374
+ fireAndForget,
375
+ }),
478
376
  "card.action.trigger": async (data: unknown) => {
479
377
  try {
480
- const event = data as unknown as FeishuCardActionEvent;
378
+ const event = parseFeishuCardActionEventPayload(data);
379
+ if (!event) {
380
+ error(`feishu[${accountId}]: ignoring malformed card action payload`);
381
+ return;
382
+ }
481
383
  const promise = handleFeishuCardAction({
482
384
  cfg,
483
385
  event,
@@ -509,6 +411,7 @@ export type MonitorSingleAccountParams = {
509
411
  runtime?: RuntimeEnv;
510
412
  abortSignal?: AbortSignal;
511
413
  botOpenIdSource?: BotOpenIdSource;
414
+ fireAndForget?: boolean;
512
415
  };
513
416
 
514
417
  export async function monitorSingleAccount(params: MonitorSingleAccountParams): Promise<void> {
@@ -521,16 +424,13 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
521
424
  botOpenIdSource.kind === "prefetched"
522
425
  ? { botOpenId: botOpenIdSource.botOpenId, botName: botOpenIdSource.botName }
523
426
  : await fetchBotIdentityForMonitor(account, { runtime, abortSignal });
524
- const botOpenId = botIdentity.botOpenId;
525
- const botName = botIdentity.botName?.trim();
526
- botOpenIds.set(accountId, botOpenId ?? "");
527
- if (botName) {
528
- botNames.set(accountId, botName);
529
- } else {
530
- botNames.delete(accountId);
531
- }
427
+ const { botOpenId } = applyBotIdentityState(accountId, botIdentity);
532
428
  log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
533
429
 
430
+ if (!botOpenId && !abortSignal?.aborted) {
431
+ startBotIdentityRecovery({ account, accountId, runtime, abortSignal });
432
+ }
433
+
534
434
  const connectionMode = account.config.connectionMode ?? "websocket";
535
435
  if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
536
436
  throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
@@ -544,19 +444,25 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
544
444
  log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
545
445
  }
546
446
 
547
- const eventDispatcher = createEventDispatcher(account);
548
- const chatHistories = new Map<string, HistoryEntry[]>();
447
+ let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
448
+ try {
449
+ const eventDispatcher = createEventDispatcher(account);
450
+ const chatHistories = new Map<string, HistoryEntry[]>();
451
+ threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg });
549
452
 
550
- registerEventHandlers(eventDispatcher, {
551
- cfg,
552
- accountId,
553
- runtime,
554
- chatHistories,
555
- fireAndForget: true,
556
- });
453
+ registerEventHandlers(eventDispatcher, {
454
+ cfg,
455
+ accountId,
456
+ runtime,
457
+ chatHistories,
458
+ fireAndForget: params.fireAndForget ?? true,
459
+ });
557
460
 
558
- if (connectionMode === "webhook") {
559
- return monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
461
+ if (connectionMode === "webhook") {
462
+ return await monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
463
+ }
464
+ return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
465
+ } finally {
466
+ threadBindingManager?.stop();
560
467
  }
561
- return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
562
468
  }