@gakr-gakr/feishu 0.1.0

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 (133) hide show
  1. package/api.ts +32 -0
  2. package/autobot.plugin.json +180 -0
  3. package/channel-entry.ts +20 -0
  4. package/channel-plugin-api.ts +1 -0
  5. package/contract-api.ts +16 -0
  6. package/index.ts +82 -0
  7. package/package.json +62 -0
  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.ts +13 -0
  14. package/skills/feishu-doc/SKILL.md +211 -0
  15. package/skills/feishu-doc/references/block-types.md +103 -0
  16. package/skills/feishu-drive/SKILL.md +97 -0
  17. package/skills/feishu-perm/SKILL.md +119 -0
  18. package/skills/feishu-wiki/SKILL.md +113 -0
  19. package/src/accounts.ts +333 -0
  20. package/src/agent-config.ts +21 -0
  21. package/src/app-registration.ts +331 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/async.ts +104 -0
  24. package/src/audio-preflight.runtime.ts +9 -0
  25. package/src/bitable.ts +762 -0
  26. package/src/bot-content.ts +485 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.ts +1703 -0
  30. package/src/card-action.ts +447 -0
  31. package/src/card-interaction.ts +159 -0
  32. package/src/card-test-helpers.ts +54 -0
  33. package/src/card-ux-approval.ts +65 -0
  34. package/src/card-ux-launcher.ts +121 -0
  35. package/src/card-ux-shared.ts +33 -0
  36. package/src/channel-runtime-api.ts +16 -0
  37. package/src/channel.runtime.ts +47 -0
  38. package/src/channel.ts +1423 -0
  39. package/src/chat-schema.ts +25 -0
  40. package/src/chat.ts +188 -0
  41. package/src/client-timeout.ts +42 -0
  42. package/src/client.ts +262 -0
  43. package/src/comment-dispatcher-runtime-api.ts +6 -0
  44. package/src/comment-dispatcher.ts +107 -0
  45. package/src/comment-handler-runtime-api.ts +3 -0
  46. package/src/comment-handler.ts +303 -0
  47. package/src/comment-reaction.ts +259 -0
  48. package/src/comment-shared.ts +406 -0
  49. package/src/comment-target.ts +44 -0
  50. package/src/config-schema.ts +335 -0
  51. package/src/conversation-id.ts +199 -0
  52. package/src/dedup-runtime-api.ts +1 -0
  53. package/src/dedup.ts +141 -0
  54. package/src/dedupe-key.ts +72 -0
  55. package/src/directory.static.ts +61 -0
  56. package/src/directory.ts +124 -0
  57. package/src/doc-schema.ts +182 -0
  58. package/src/docx-batch-insert.ts +223 -0
  59. package/src/docx-color-text.ts +154 -0
  60. package/src/docx-table-ops.ts +316 -0
  61. package/src/docx-types.ts +38 -0
  62. package/src/docx.ts +1596 -0
  63. package/src/drive-schema.ts +92 -0
  64. package/src/drive.ts +829 -0
  65. package/src/dynamic-agent.ts +143 -0
  66. package/src/event-types.ts +45 -0
  67. package/src/external-keys.ts +19 -0
  68. package/src/lifecycle.test-support.ts +220 -0
  69. package/src/media.ts +1105 -0
  70. package/src/mention-target.types.ts +5 -0
  71. package/src/mention.ts +114 -0
  72. package/src/message-action-contract.ts +13 -0
  73. package/src/monitor-state-runtime-api.ts +7 -0
  74. package/src/monitor-transport-runtime-api.ts +10 -0
  75. package/src/monitor.account.ts +492 -0
  76. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  77. package/src/monitor.bot-identity.ts +86 -0
  78. package/src/monitor.bot-menu-handler.ts +165 -0
  79. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  80. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  81. package/src/monitor.card-action.lifecycle.test-support.ts +421 -0
  82. package/src/monitor.comment-notice-handler.ts +105 -0
  83. package/src/monitor.comment.ts +1386 -0
  84. package/src/monitor.message-handler.ts +350 -0
  85. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  86. package/src/monitor.startup.ts +74 -0
  87. package/src/monitor.state.ts +170 -0
  88. package/src/monitor.synthetic-error.ts +18 -0
  89. package/src/monitor.test-mocks.ts +46 -0
  90. package/src/monitor.transport.ts +451 -0
  91. package/src/monitor.ts +100 -0
  92. package/src/outbound-runtime-api.ts +1 -0
  93. package/src/outbound.ts +785 -0
  94. package/src/perm-schema.ts +52 -0
  95. package/src/perm.ts +170 -0
  96. package/src/pins.ts +108 -0
  97. package/src/policy.ts +321 -0
  98. package/src/post.ts +275 -0
  99. package/src/probe.ts +166 -0
  100. package/src/processing-claims.ts +59 -0
  101. package/src/qr-terminal.ts +1 -0
  102. package/src/reactions.ts +123 -0
  103. package/src/reasoning-preview.ts +28 -0
  104. package/src/reply-dispatcher-runtime-api.ts +7 -0
  105. package/src/reply-dispatcher.ts +748 -0
  106. package/src/runtime.ts +9 -0
  107. package/src/secret-contract.ts +145 -0
  108. package/src/secret-input.ts +1 -0
  109. package/src/security-audit-shared.ts +69 -0
  110. package/src/security-audit.ts +1 -0
  111. package/src/send-result.ts +80 -0
  112. package/src/send-target.ts +35 -0
  113. package/src/send.ts +861 -0
  114. package/src/sequential-key.ts +28 -0
  115. package/src/sequential-queue.ts +86 -0
  116. package/src/session-conversation.ts +42 -0
  117. package/src/session-route.ts +48 -0
  118. package/src/setup-core.ts +51 -0
  119. package/src/setup-surface.ts +618 -0
  120. package/src/streaming-card.ts +571 -0
  121. package/src/subagent-hooks.ts +413 -0
  122. package/src/targets.ts +97 -0
  123. package/src/thread-bindings.ts +331 -0
  124. package/src/tool-account.ts +93 -0
  125. package/src/tool-factory-test-harness.ts +79 -0
  126. package/src/tool-result.ts +16 -0
  127. package/src/tools-config.ts +22 -0
  128. package/src/types.ts +106 -0
  129. package/src/typing.ts +214 -0
  130. package/src/wiki-schema.ts +69 -0
  131. package/src/wiki.ts +270 -0
  132. package/subagent-hooks-api.ts +31 -0
  133. package/tsconfig.json +16 -0
@@ -0,0 +1,350 @@
1
+ import type { ClawdbotConfig, HistoryEntry, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
2
+ import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js";
3
+ import type { FeishuMessageEvent } from "./event-types.js";
4
+ import { isMentionForwardRequest } from "./mention.js";
5
+ import {
6
+ releaseFeishuMessageProcessing,
7
+ tryBeginFeishuMessageProcessing,
8
+ } from "./processing-claims.js";
9
+ import { createSequentialQueue } from "./sequential-queue.js";
10
+ import type { FeishuChatType } from "./types.js";
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function readString(value: unknown): string | undefined {
17
+ return typeof value === "string" ? value : undefined;
18
+ }
19
+
20
+ type FeishuMessageReceiveHandlerContext = {
21
+ cfg: ClawdbotConfig;
22
+ core: PluginRuntime;
23
+ accountId: string;
24
+ runtime?: RuntimeEnv;
25
+ chatHistories: Map<string, HistoryEntry[]>;
26
+ fireAndForget?: boolean;
27
+ handleMessage: (params: {
28
+ cfg: ClawdbotConfig;
29
+ event: FeishuMessageEvent;
30
+ botOpenId?: string;
31
+ botName?: string;
32
+ runtime?: RuntimeEnv;
33
+ chatHistories?: Map<string, HistoryEntry[]>;
34
+ accountId?: string;
35
+ processingClaimHeld?: boolean;
36
+ }) => Promise<void>;
37
+ resolveDebounceText: (params: {
38
+ event: FeishuMessageEvent;
39
+ botOpenId?: string;
40
+ botName?: string;
41
+ }) => string;
42
+ hasProcessedMessage: (
43
+ messageId: string | undefined | null,
44
+ namespace: string,
45
+ log?: (...args: unknown[]) => void,
46
+ ) => Promise<boolean>;
47
+ recordProcessedMessage: (
48
+ messageId: string | undefined | null,
49
+ namespace: string,
50
+ log?: (...args: unknown[]) => void,
51
+ ) => Promise<boolean>;
52
+ getBotOpenId?: (accountId: string) => string | undefined;
53
+ getBotName?: (accountId: string) => string | undefined;
54
+ resolveSequentialKey?: (params: {
55
+ accountId: string;
56
+ event: FeishuMessageEvent;
57
+ botOpenId?: string;
58
+ botName?: string;
59
+ }) => string;
60
+ };
61
+
62
+ function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
63
+ return value === "group" || value === "topic_group" || value === "private" || value === "p2p"
64
+ ? value
65
+ : undefined;
66
+ }
67
+
68
+ function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null {
69
+ if (!isRecord(value)) {
70
+ return null;
71
+ }
72
+ const sender = value.sender;
73
+ const message = value.message;
74
+ if (!isRecord(sender) || !isRecord(message)) {
75
+ return null;
76
+ }
77
+ const senderId = sender.sender_id;
78
+ if (!isRecord(senderId)) {
79
+ return null;
80
+ }
81
+ const messageId = readString(message.message_id);
82
+ const chatId = readString(message.chat_id);
83
+ const chatType = normalizeFeishuChatType(message.chat_type);
84
+ const messageType = readString(message.message_type);
85
+ const content = readString(message.content);
86
+ if (!messageId || !chatId || !chatType || !messageType || !content) {
87
+ return null;
88
+ }
89
+ return value as FeishuMessageEvent;
90
+ }
91
+
92
+ function mergeFeishuDebounceMentions(
93
+ entries: FeishuMessageEvent[],
94
+ ): FeishuMessageEvent["message"]["mentions"] | undefined {
95
+ const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
96
+ for (const entry of entries) {
97
+ for (const mention of entry.message.mentions ?? []) {
98
+ const stableId =
99
+ mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
100
+ const mentionName = mention.name?.trim();
101
+ const mentionKey = mention.key?.trim();
102
+ const fallback =
103
+ mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
104
+ const key = stableId || fallback;
105
+ if (!key || merged.has(key)) {
106
+ continue;
107
+ }
108
+ merged.set(key, mention);
109
+ }
110
+ }
111
+ return merged.size > 0 ? Array.from(merged.values()) : undefined;
112
+ }
113
+
114
+ function dedupeFeishuDebounceEntriesByDedupeKey(
115
+ entries: FeishuMessageEvent[],
116
+ ): FeishuMessageEvent[] {
117
+ const seen = new Set<string>();
118
+ const deduped: FeishuMessageEvent[] = [];
119
+ for (const entry of entries) {
120
+ const dedupeKey = resolveFeishuMessageDedupeKey(entry);
121
+ if (!dedupeKey) {
122
+ deduped.push(entry);
123
+ continue;
124
+ }
125
+ if (seen.has(dedupeKey)) {
126
+ continue;
127
+ }
128
+ seen.add(dedupeKey);
129
+ deduped.push(entry);
130
+ }
131
+ return deduped;
132
+ }
133
+
134
+ function resolveFeishuDebounceMentions(params: {
135
+ entries: FeishuMessageEvent[];
136
+ botOpenId?: string;
137
+ }): FeishuMessageEvent["message"]["mentions"] | undefined {
138
+ const { entries, botOpenId } = params;
139
+ if (entries.length === 0) {
140
+ return undefined;
141
+ }
142
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
143
+ const entry = entries[index];
144
+ if (isMentionForwardRequest(entry, botOpenId)) {
145
+ return mergeFeishuDebounceMentions([entry]);
146
+ }
147
+ }
148
+ const merged = mergeFeishuDebounceMentions(entries);
149
+ if (!merged) {
150
+ return undefined;
151
+ }
152
+ const normalizedBotOpenId = botOpenId?.trim();
153
+ if (!normalizedBotOpenId) {
154
+ return undefined;
155
+ }
156
+ const botMentions = merged.filter(
157
+ (mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
158
+ );
159
+ return botMentions.length > 0 ? botMentions : undefined;
160
+ }
161
+
162
+ export function createFeishuMessageReceiveHandler({
163
+ cfg,
164
+ core,
165
+ accountId,
166
+ runtime,
167
+ chatHistories,
168
+ fireAndForget,
169
+ handleMessage,
170
+ resolveDebounceText: resolveText,
171
+ hasProcessedMessage,
172
+ recordProcessedMessage,
173
+ getBotOpenId = () => undefined,
174
+ getBotName = () => undefined,
175
+ resolveSequentialKey = ({ accountId, event }) =>
176
+ `feishu:${accountId}:${event.message.chat_id?.trim() || "unknown"}`,
177
+ }: FeishuMessageReceiveHandlerContext): (data: unknown) => Promise<void> {
178
+ const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
179
+ cfg,
180
+ channel: "feishu",
181
+ });
182
+ const log = runtime?.log ?? console.log;
183
+ const error = runtime?.error ?? console.error;
184
+ const enqueue = createSequentialQueue({
185
+ onTaskTimeout: (key, timeoutMs) => {
186
+ log(
187
+ `feishu[${accountId}]: per-chat task exceeded ${timeoutMs}ms cap (key=${key}); evicting from queue so later same-key messages can proceed (#70133)`,
188
+ );
189
+ },
190
+ });
191
+
192
+ const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
193
+ const sequentialKey = resolveSequentialKey({
194
+ accountId,
195
+ event,
196
+ botOpenId: getBotOpenId(accountId),
197
+ botName: getBotName(accountId),
198
+ });
199
+ const task = () =>
200
+ handleMessage({
201
+ cfg,
202
+ event,
203
+ botOpenId: getBotOpenId(accountId),
204
+ botName: getBotName(accountId),
205
+ runtime,
206
+ chatHistories,
207
+ accountId,
208
+ processingClaimHeld: true,
209
+ });
210
+ await enqueue(sequentialKey, task);
211
+ };
212
+
213
+ const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
214
+ const senderId =
215
+ event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
216
+ return senderId || undefined;
217
+ };
218
+
219
+ const resolveDebounceText = (event: FeishuMessageEvent): string => {
220
+ return resolveText({
221
+ event,
222
+ botOpenId: getBotOpenId(accountId),
223
+ botName: getBotName(accountId),
224
+ }).trim();
225
+ };
226
+
227
+ const recordSuppressedMessageIds = async (
228
+ entries: FeishuMessageEvent[],
229
+ dispatchDedupeKey?: string,
230
+ ) => {
231
+ const keepDedupeKey = dispatchDedupeKey?.trim();
232
+ const suppressedIds = new Set(
233
+ entries
234
+ .map((entry) => resolveFeishuMessageDedupeKey(entry))
235
+ .filter((id): id is string => Boolean(id) && (!keepDedupeKey || id !== keepDedupeKey)),
236
+ );
237
+ for (const messageId of suppressedIds) {
238
+ try {
239
+ await recordProcessedMessage(messageId, accountId, log);
240
+ } catch (err) {
241
+ error(
242
+ `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
243
+ );
244
+ }
245
+ }
246
+ };
247
+
248
+ const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
249
+ debounceMs: inboundDebounceMs,
250
+ buildKey: (event) => {
251
+ const chatId = event.message.chat_id?.trim();
252
+ const senderId = resolveSenderDebounceId(event);
253
+ if (!chatId || !senderId) {
254
+ return null;
255
+ }
256
+ const rootId = event.message.root_id?.trim();
257
+ const threadKey = rootId ? `thread:${rootId}` : "chat";
258
+ return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
259
+ },
260
+ shouldDebounce: (event) => {
261
+ if (event.message.message_type !== "text") {
262
+ return false;
263
+ }
264
+ const text = resolveDebounceText(event);
265
+ return Boolean(text) && !core.channel.text.hasControlCommand(text, cfg);
266
+ },
267
+ onFlush: async (entries) => {
268
+ const last = entries.at(-1);
269
+ if (!last) {
270
+ return;
271
+ }
272
+ if (entries.length === 1) {
273
+ await dispatchFeishuMessage(last);
274
+ return;
275
+ }
276
+ const dedupedEntries = dedupeFeishuDebounceEntriesByDedupeKey(entries);
277
+ const freshEntries: FeishuMessageEvent[] = [];
278
+ for (const entry of dedupedEntries) {
279
+ if (!(await hasProcessedMessage(resolveFeishuMessageDedupeKey(entry), accountId, log))) {
280
+ freshEntries.push(entry);
281
+ }
282
+ }
283
+ const dispatchEntry = freshEntries.at(-1);
284
+ if (!dispatchEntry) {
285
+ return;
286
+ }
287
+ await recordSuppressedMessageIds(
288
+ dedupedEntries,
289
+ resolveFeishuMessageDedupeKey(dispatchEntry),
290
+ );
291
+ const combinedText = freshEntries
292
+ .map((entry) => resolveDebounceText(entry))
293
+ .filter(Boolean)
294
+ .join("\n");
295
+ const mergedMentions = resolveFeishuDebounceMentions({
296
+ entries: freshEntries,
297
+ botOpenId: getBotOpenId(accountId),
298
+ });
299
+ await dispatchFeishuMessage({
300
+ ...dispatchEntry,
301
+ message: {
302
+ ...dispatchEntry.message,
303
+ ...(combinedText.trim()
304
+ ? {
305
+ message_type: "text",
306
+ content: JSON.stringify({ text: combinedText }),
307
+ }
308
+ : {}),
309
+ mentions: mergedMentions ?? dispatchEntry.message.mentions,
310
+ },
311
+ });
312
+ },
313
+ onError: (err, entries) => {
314
+ for (const entry of entries) {
315
+ releaseFeishuMessageProcessing(resolveFeishuMessageDedupeKey(entry), accountId);
316
+ }
317
+ error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
318
+ },
319
+ });
320
+
321
+ return async (data) => {
322
+ const event = parseFeishuMessageEventPayload(data);
323
+ if (!event) {
324
+ error(`feishu[${accountId}]: ignoring malformed message event payload`);
325
+ return;
326
+ }
327
+ const messageId = event.message?.message_id?.trim();
328
+ const messageDedupeKey = resolveFeishuMessageDedupeKey(event);
329
+ if (!tryBeginFeishuMessageProcessing(messageDedupeKey, accountId)) {
330
+ log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
331
+ return;
332
+ }
333
+ const processMessage = async () => {
334
+ await inboundDebouncer.enqueue(event);
335
+ };
336
+ if (fireAndForget) {
337
+ void processMessage().catch((err) => {
338
+ releaseFeishuMessageProcessing(messageDedupeKey, accountId);
339
+ error(`feishu[${accountId}]: error handling message: ${String(err)}`);
340
+ });
341
+ return;
342
+ }
343
+ try {
344
+ await processMessage();
345
+ } catch (err) {
346
+ releaseFeishuMessageProcessing(messageDedupeKey, accountId);
347
+ error(`feishu[${accountId}]: error handling message: ${String(err)}`);
348
+ }
349
+ };
350
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ClawdbotConfig } from "../runtime-api.js";
3
+ import {
4
+ resolveReactionSyntheticEvent,
5
+ type FeishuReactionCreatedEvent,
6
+ } from "./monitor.account.js";
7
+
8
+ const cfg = {} as ClawdbotConfig;
9
+
10
+ function makeReactionEvent(
11
+ overrides: Partial<FeishuReactionCreatedEvent> = {},
12
+ ): FeishuReactionCreatedEvent {
13
+ return {
14
+ message_id: "om_msg1",
15
+ reaction_type: { emoji_type: "THUMBSUP" },
16
+ operator_type: "user",
17
+ user_id: { open_id: "ou_user1" },
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("Feishu reaction lifecycle", () => {
23
+ it("builds a created synthetic interaction payload", async () => {
24
+ const result = await resolveReactionSyntheticEvent({
25
+ cfg,
26
+ accountId: "default",
27
+ event: makeReactionEvent({ user_id: { open_id: "ou_user1", user_id: "on_user1" } }),
28
+ botOpenId: "ou_bot",
29
+ fetchMessage: async () => ({
30
+ messageId: "om_msg1",
31
+ chatId: "oc_group_1",
32
+ chatType: "group",
33
+ senderOpenId: "ou_bot",
34
+ senderType: "app",
35
+ content: "hello",
36
+ contentType: "text",
37
+ }),
38
+ uuid: () => "fixed-uuid",
39
+ });
40
+
41
+ expect(result?.sender.sender_id).toEqual({ open_id: "ou_user1", user_id: "on_user1" });
42
+ expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
43
+ });
44
+
45
+ it("builds a deleted synthetic interaction payload", async () => {
46
+ const result = await resolveReactionSyntheticEvent({
47
+ cfg,
48
+ accountId: "default",
49
+ event: makeReactionEvent(),
50
+ botOpenId: "ou_bot",
51
+ fetchMessage: async () => ({
52
+ messageId: "om_msg1",
53
+ chatId: "oc_group_1",
54
+ chatType: "group",
55
+ senderOpenId: "ou_bot",
56
+ senderType: "app",
57
+ content: "hello",
58
+ contentType: "text",
59
+ }),
60
+ uuid: () => "fixed-uuid",
61
+ action: "deleted",
62
+ });
63
+
64
+ expect(result?.message.content).toBe(
65
+ '{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
66
+ );
67
+ });
68
+ });
@@ -0,0 +1,74 @@
1
+ import { normalizeLowercaseStringOrEmpty } from "autobot/plugin-sdk/string-coerce-runtime";
2
+ import type { RuntimeEnv } from "../runtime-api.js";
3
+ import { probeFeishu } from "./probe.js";
4
+ import type { ResolvedFeishuAccount } from "./types.js";
5
+
6
+ const FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS = 30_000;
7
+ const FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV = "AUTOBOT_FEISHU_STARTUP_PROBE_TIMEOUT_MS";
8
+
9
+ function resolveStartupProbeTimeoutMs(): number {
10
+ const raw = process.env[FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV];
11
+ if (raw) {
12
+ const parsed = Number(raw);
13
+ if (Number.isFinite(parsed) && parsed > 0) {
14
+ return Math.floor(parsed);
15
+ }
16
+ console.warn(
17
+ `[feishu] ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV}="${raw}" is invalid; using default ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS}ms`,
18
+ );
19
+ }
20
+ return FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS;
21
+ }
22
+
23
+ const FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS = resolveStartupProbeTimeoutMs();
24
+
25
+ type FetchBotOpenIdOptions = {
26
+ runtime?: RuntimeEnv;
27
+ abortSignal?: AbortSignal;
28
+ timeoutMs?: number;
29
+ };
30
+
31
+ export type FeishuMonitorBotIdentity = {
32
+ botOpenId?: string;
33
+ botName?: string;
34
+ };
35
+
36
+ function isTimeoutErrorMessage(message: string | undefined): boolean {
37
+ const lower = normalizeLowercaseStringOrEmpty(message);
38
+ return lower.includes("timeout") || lower.includes("timed out");
39
+ }
40
+
41
+ function isAbortErrorMessage(message: string | undefined): boolean {
42
+ return normalizeLowercaseStringOrEmpty(message).includes("aborted");
43
+ }
44
+
45
+ export async function fetchBotIdentityForMonitor(
46
+ account: ResolvedFeishuAccount,
47
+ options: FetchBotOpenIdOptions = {},
48
+ ): Promise<FeishuMonitorBotIdentity> {
49
+ if (options.abortSignal?.aborted) {
50
+ return {};
51
+ }
52
+
53
+ const timeoutMs = options.timeoutMs ?? FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS;
54
+ const result = await probeFeishu(account, {
55
+ timeoutMs,
56
+ abortSignal: options.abortSignal,
57
+ });
58
+ if (result.ok) {
59
+ return { botOpenId: result.botOpenId, botName: result.botName };
60
+ }
61
+
62
+ const probeError = result.error ?? undefined;
63
+ if (options.abortSignal?.aborted || isAbortErrorMessage(probeError)) {
64
+ return {};
65
+ }
66
+
67
+ if (isTimeoutErrorMessage(probeError)) {
68
+ const error = options.runtime?.error ?? console.error;
69
+ error(
70
+ `feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`,
71
+ );
72
+ }
73
+ return {};
74
+ }
@@ -0,0 +1,170 @@
1
+ import * as http from "node:http";
2
+ import type * as Lark from "@larksuiteoapi/node-sdk";
3
+ import {
4
+ createFixedWindowRateLimiter,
5
+ createWebhookAnomalyTracker,
6
+ type RuntimeEnv,
7
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS as WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
8
+ WEBHOOK_RATE_LIMIT_DEFAULTS as WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
9
+ } from "./monitor-state-runtime-api.js";
10
+
11
+ export const wsClients = new Map<string, Lark.WSClient>();
12
+ export const httpServers = new Map<string, http.Server>();
13
+ export const botOpenIds = new Map<string, string>();
14
+ export const botNames = new Map<string, string>();
15
+
16
+ export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
17
+ export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
18
+
19
+ type WebhookRateLimitDefaults = {
20
+ windowMs: number;
21
+ maxRequests: number;
22
+ maxTrackedKeys: number;
23
+ };
24
+
25
+ type WebhookAnomalyDefaults = {
26
+ maxTrackedKeys: number;
27
+ ttlMs: number;
28
+ logEvery: number;
29
+ };
30
+
31
+ const FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS: WebhookRateLimitDefaults = {
32
+ windowMs: 60_000,
33
+ maxRequests: 120,
34
+ maxTrackedKeys: 4_096,
35
+ };
36
+
37
+ const FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS: WebhookAnomalyDefaults = {
38
+ maxTrackedKeys: 4_096,
39
+ ttlMs: 6 * 60 * 60_000,
40
+ logEvery: 25,
41
+ };
42
+
43
+ function coercePositiveInt(value: unknown, fallback: number): number {
44
+ if (typeof value !== "number" || !Number.isFinite(value)) {
45
+ return fallback;
46
+ }
47
+ const normalized = Math.floor(value);
48
+ return normalized > 0 ? normalized : fallback;
49
+ }
50
+
51
+ export function resolveFeishuWebhookRateLimitDefaultsForTest(
52
+ defaults: unknown,
53
+ ): WebhookRateLimitDefaults {
54
+ const resolved = defaults as Partial<WebhookRateLimitDefaults> | null | undefined;
55
+ return {
56
+ windowMs: coercePositiveInt(
57
+ resolved?.windowMs,
58
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.windowMs,
59
+ ),
60
+ maxRequests: coercePositiveInt(
61
+ resolved?.maxRequests,
62
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxRequests,
63
+ ),
64
+ maxTrackedKeys: coercePositiveInt(
65
+ resolved?.maxTrackedKeys,
66
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxTrackedKeys,
67
+ ),
68
+ };
69
+ }
70
+
71
+ export function resolveFeishuWebhookAnomalyDefaultsForTest(
72
+ defaults: unknown,
73
+ ): WebhookAnomalyDefaults {
74
+ const resolved = defaults as Partial<WebhookAnomalyDefaults> | null | undefined;
75
+ return {
76
+ maxTrackedKeys: coercePositiveInt(
77
+ resolved?.maxTrackedKeys,
78
+ FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.maxTrackedKeys,
79
+ ),
80
+ ttlMs: coercePositiveInt(resolved?.ttlMs, FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.ttlMs),
81
+ logEvery: coercePositiveInt(
82
+ resolved?.logEvery,
83
+ FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.logEvery,
84
+ ),
85
+ };
86
+ }
87
+
88
+ const feishuWebhookRateLimitDefaults = resolveFeishuWebhookRateLimitDefaultsForTest(
89
+ WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
90
+ );
91
+ const feishuWebhookAnomalyDefaults = resolveFeishuWebhookAnomalyDefaultsForTest(
92
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
93
+ );
94
+
95
+ export const feishuWebhookRateLimiter = createFixedWindowRateLimiter({
96
+ windowMs: feishuWebhookRateLimitDefaults.windowMs,
97
+ maxRequests: feishuWebhookRateLimitDefaults.maxRequests,
98
+ maxTrackedKeys: feishuWebhookRateLimitDefaults.maxTrackedKeys,
99
+ });
100
+
101
+ const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({
102
+ maxTrackedKeys: feishuWebhookAnomalyDefaults.maxTrackedKeys,
103
+ ttlMs: feishuWebhookAnomalyDefaults.ttlMs,
104
+ logEvery: feishuWebhookAnomalyDefaults.logEvery,
105
+ });
106
+
107
+ function closeWsClient(client: Lark.WSClient | undefined): void {
108
+ if (!client) {
109
+ return;
110
+ }
111
+ try {
112
+ client.close();
113
+ } catch {
114
+ /* Best-effort cleanup */
115
+ }
116
+ }
117
+
118
+ export function clearFeishuWebhookRateLimitStateForTest(): void {
119
+ feishuWebhookRateLimiter.clear();
120
+ feishuWebhookAnomalyTracker.clear();
121
+ }
122
+
123
+ export function getFeishuWebhookRateLimitStateSizeForTest(): number {
124
+ return feishuWebhookRateLimiter.size();
125
+ }
126
+
127
+ export function isWebhookRateLimitedForTest(key: string, nowMs: number): boolean {
128
+ return feishuWebhookRateLimiter.isRateLimited(key, nowMs);
129
+ }
130
+
131
+ export function recordWebhookStatus(
132
+ runtime: RuntimeEnv | undefined,
133
+ accountId: string,
134
+ path: string,
135
+ statusCode: number,
136
+ ): void {
137
+ feishuWebhookAnomalyTracker.record({
138
+ key: `${accountId}:${path}:${statusCode}`,
139
+ statusCode,
140
+ log: runtime?.log ?? console.log,
141
+ message: (count) =>
142
+ `feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${count}`,
143
+ });
144
+ }
145
+
146
+ export function stopFeishuMonitorState(accountId?: string): void {
147
+ if (accountId) {
148
+ closeWsClient(wsClients.get(accountId));
149
+ wsClients.delete(accountId);
150
+ const server = httpServers.get(accountId);
151
+ if (server) {
152
+ server.close();
153
+ httpServers.delete(accountId);
154
+ }
155
+ botOpenIds.delete(accountId);
156
+ botNames.delete(accountId);
157
+ return;
158
+ }
159
+
160
+ for (const client of wsClients.values()) {
161
+ closeWsClient(client);
162
+ }
163
+ wsClients.clear();
164
+ for (const server of httpServers.values()) {
165
+ server.close();
166
+ }
167
+ httpServers.clear();
168
+ botOpenIds.clear();
169
+ botNames.clear();
170
+ }
@@ -0,0 +1,18 @@
1
+ export class FeishuRetryableSyntheticEventError extends Error {
2
+ constructor(message: string, options?: ErrorOptions) {
3
+ super(message, options);
4
+ this.name = "FeishuRetryableSyntheticEventError";
5
+ }
6
+ }
7
+
8
+ export function isFeishuRetryableSyntheticEventError(
9
+ error: unknown,
10
+ ): error is FeishuRetryableSyntheticEventError {
11
+ return (
12
+ error instanceof FeishuRetryableSyntheticEventError ||
13
+ (typeof error === "object" &&
14
+ error !== null &&
15
+ "name" in error &&
16
+ error.name === "FeishuRetryableSyntheticEventError")
17
+ );
18
+ }