@openclaw/feishu 2026.3.12 → 2026.5.1-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 (188) 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 +1653 -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 +115 -22
  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 +798 -786
  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 +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -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 +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +77 -25
  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 +76 -35
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  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 +413 -87
  91. package/src/media.ts +488 -154
  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 +220 -313
  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 +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +122 -118
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +23 -60
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. 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
- hasRecordedMessage,
16
- hasRecordedMessagePersistent,
17
- tryRecordMessage,
18
- tryRecordMessagePersistent,
15
+ hasProcessedFeishuMessage,
16
+ recordProcessedFeishuMessage,
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,174 +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,
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)}`);
267
252
  });
268
- await enqueue(chatId, task);
269
- };
270
- const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
271
- const senderId =
272
- event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
273
- return senderId || undefined;
274
- };
275
- const resolveDebounceText = (event: FeishuMessageEvent): string => {
276
- const botOpenId = botOpenIds.get(accountId);
277
- const parsed = parseFeishuMessageEvent(event, botOpenId, botNames.get(accountId));
278
- return parsed.content.trim();
279
- };
280
- const recordSuppressedMessageIds = async (
281
- entries: FeishuMessageEvent[],
282
- dispatchMessageId?: string,
283
- ) => {
284
- const keepMessageId = dispatchMessageId?.trim();
285
- const suppressedIds = new Set(
286
- entries
287
- .map((entry) => entry.message.message_id?.trim())
288
- .filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
289
- );
290
- if (suppressedIds.size === 0) {
291
253
  return;
292
254
  }
293
- for (const messageId of suppressedIds) {
294
- // Keep in-memory dedupe in sync with handleFeishuMessage's keying.
295
- tryRecordMessage(`${accountId}:${messageId}`);
296
- try {
297
- await tryRecordMessagePersistent(messageId, accountId, log);
298
- } catch (err) {
299
- error(
300
- `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
301
- );
302
- }
255
+ try {
256
+ await params.task();
257
+ } catch (err) {
258
+ error(`${params.errorMessage}: ${String(err)}`);
303
259
  }
304
260
  };
305
- const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
306
- const messageId = entry.message.message_id?.trim();
307
- if (!messageId) {
308
- return false;
309
- }
310
- const memoryKey = `${accountId}:${messageId}`;
311
- if (hasRecordedMessage(memoryKey)) {
312
- return true;
313
- }
314
- return hasRecordedMessagePersistent(messageId, accountId, log);
315
- };
316
- const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
317
- debounceMs: inboundDebounceMs,
318
- buildKey: (event) => {
319
- const chatId = event.message.chat_id?.trim();
320
- const senderId = resolveSenderDebounceId(event);
321
- if (!chatId || !senderId) {
322
- return null;
323
- }
324
- const rootId = event.message.root_id?.trim();
325
- const threadKey = rootId ? `thread:${rootId}` : "chat";
326
- return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
327
- },
328
- shouldDebounce: (event) => {
329
- if (event.message.message_type !== "text") {
330
- return false;
331
- }
332
- const text = resolveDebounceText(event);
333
- if (!text) {
334
- return false;
335
- }
336
- return !core.channel.text.hasControlCommand(text, cfg);
337
- },
338
- onFlush: async (entries) => {
339
- const last = entries.at(-1);
340
- if (!last) {
341
- return;
342
- }
343
- if (entries.length === 1) {
344
- await dispatchFeishuMessage(last);
345
- return;
346
- }
347
- const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
348
- const freshEntries: FeishuMessageEvent[] = [];
349
- for (const entry of dedupedEntries) {
350
- if (!(await isMessageAlreadyProcessed(entry))) {
351
- freshEntries.push(entry);
352
- }
353
- }
354
- const dispatchEntry = freshEntries.at(-1);
355
- if (!dispatchEntry) {
356
- return;
357
- }
358
- await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
359
- const combinedText = freshEntries
360
- .map((entry) => resolveDebounceText(entry))
361
- .filter(Boolean)
362
- .join("\n");
363
- const mergedMentions = resolveFeishuDebounceMentions({
364
- entries: freshEntries,
365
- botOpenId: botOpenIds.get(accountId),
366
- });
367
- if (!combinedText.trim()) {
368
- await dispatchFeishuMessage({
369
- ...dispatchEntry,
370
- message: {
371
- ...dispatchEntry.message,
372
- mentions: mergedMentions ?? dispatchEntry.message.mentions,
373
- },
374
- });
375
- return;
376
- }
377
- await dispatchFeishuMessage({
378
- ...dispatchEntry,
379
- message: {
380
- ...dispatchEntry.message,
381
- message_type: "text",
382
- content: JSON.stringify({ text: combinedText }),
383
- mentions: mergedMentions ?? dispatchEntry.message.mentions,
384
- },
385
- });
386
- },
387
- onError: (err) => {
388
- error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
389
- },
390
- });
391
261
 
392
262
  eventDispatcher.register({
393
- "im.message.receive_v1": async (data) => {
394
- const processMessage = async () => {
395
- const event = data as unknown as FeishuMessageEvent;
396
- await inboundDebouncer.enqueue(event);
397
- };
398
- if (fireAndForget) {
399
- void processMessage().catch((err) => {
400
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
401
- });
402
- return;
403
- }
404
- try {
405
- await processMessage();
406
- } catch (err) {
407
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
408
- }
409
- },
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
+ }),
410
279
  "im.message.message_read_v1": async () => {
411
280
  // Ignore read receipts
412
281
  },
413
282
  "im.chat.member.bot.added_v1": async (data) => {
414
283
  try {
415
- const event = data as unknown as FeishuBotAddedEvent;
284
+ const event = parseFeishuBotAddedEventPayload(data);
285
+ if (!event) {
286
+ return;
287
+ }
416
288
  log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
417
289
  } catch (err) {
418
290
  error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
@@ -420,63 +292,94 @@ function registerEventHandlers(
420
292
  },
421
293
  "im.chat.member.bot.deleted_v1": async (data) => {
422
294
  try {
423
- const event = data as unknown as { chat_id: string };
424
- 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}`);
425
300
  } catch (err) {
426
301
  error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
427
302
  }
428
303
  },
304
+ "drive.notice.comment_add_v1": createFeishuDriveCommentNoticeHandler({
305
+ cfg,
306
+ accountId,
307
+ runtime,
308
+ fireAndForget,
309
+ }),
429
310
  "im.message.reaction.created_v1": async (data) => {
430
- const processReaction = async () => {
431
- const event = data as FeishuReactionCreatedEvent;
432
- const myBotId = botOpenIds.get(accountId);
433
- const syntheticEvent = await resolveReactionSyntheticEvent({
434
- cfg,
435
- accountId,
436
- event,
437
- botOpenId: myBotId,
438
- logger: log,
439
- });
440
- if (!syntheticEvent) {
441
- return;
442
- }
443
- const promise = handleFeishuMessage({
444
- cfg,
445
- event: syntheticEvent,
446
- botOpenId: myBotId,
447
- botName: botNames.get(accountId),
448
- runtime,
449
- chatHistories,
450
- accountId,
451
- });
452
- if (fireAndForget) {
453
- promise.catch((err) => {
454
- 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,
455
322
  });
456
- return;
457
- }
458
- await promise;
459
- };
460
-
461
- if (fireAndForget) {
462
- void processReaction().catch((err) => {
463
- error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
464
- });
465
- return;
466
- }
467
-
468
- try {
469
- await processReaction();
470
- } catch (err) {
471
- error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
472
- }
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
+ });
473
338
  },
474
- "im.message.reaction.deleted_v1": async () => {
475
- // 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
+ });
476
368
  },
369
+ "application.bot.menu_v6": createFeishuBotMenuHandler({
370
+ cfg,
371
+ accountId,
372
+ runtime,
373
+ chatHistories,
374
+ fireAndForget,
375
+ }),
477
376
  "card.action.trigger": async (data: unknown) => {
478
377
  try {
479
- 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
+ }
480
383
  const promise = handleFeishuCardAction({
481
384
  cfg,
482
385
  event,
@@ -508,6 +411,7 @@ export type MonitorSingleAccountParams = {
508
411
  runtime?: RuntimeEnv;
509
412
  abortSignal?: AbortSignal;
510
413
  botOpenIdSource?: BotOpenIdSource;
414
+ fireAndForget?: boolean;
511
415
  };
512
416
 
513
417
  export async function monitorSingleAccount(params: MonitorSingleAccountParams): Promise<void> {
@@ -520,16 +424,13 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
520
424
  botOpenIdSource.kind === "prefetched"
521
425
  ? { botOpenId: botOpenIdSource.botOpenId, botName: botOpenIdSource.botName }
522
426
  : await fetchBotIdentityForMonitor(account, { runtime, abortSignal });
523
- const botOpenId = botIdentity.botOpenId;
524
- const botName = botIdentity.botName?.trim();
525
- botOpenIds.set(accountId, botOpenId ?? "");
526
- if (botName) {
527
- botNames.set(accountId, botName);
528
- } else {
529
- botNames.delete(accountId);
530
- }
427
+ const { botOpenId } = applyBotIdentityState(accountId, botIdentity);
531
428
  log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
532
429
 
430
+ if (!botOpenId && !abortSignal?.aborted) {
431
+ startBotIdentityRecovery({ account, accountId, runtime, abortSignal });
432
+ }
433
+
533
434
  const connectionMode = account.config.connectionMode ?? "websocket";
534
435
  if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
535
436
  throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
@@ -543,19 +444,25 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
543
444
  log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
544
445
  }
545
446
 
546
- const eventDispatcher = createEventDispatcher(account);
547
- 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 });
548
452
 
549
- registerEventHandlers(eventDispatcher, {
550
- cfg,
551
- accountId,
552
- runtime,
553
- chatHistories,
554
- fireAndForget: true,
555
- });
453
+ registerEventHandlers(eventDispatcher, {
454
+ cfg,
455
+ accountId,
456
+ runtime,
457
+ chatHistories,
458
+ fireAndForget: params.fireAndForget ?? true,
459
+ });
556
460
 
557
- if (connectionMode === "webhook") {
558
- 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();
559
467
  }
560
- return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
561
468
  }