@nextclaw/channel-plugin-feishu 0.2.12 → 0.2.14

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 (102) hide show
  1. package/README.md +3 -1
  2. package/index.ts +65 -0
  3. package/openclaw.plugin.json +3 -7
  4. package/package.json +33 -9
  5. package/skills/feishu-doc/SKILL.md +211 -0
  6. package/skills/feishu-doc/references/block-types.md +103 -0
  7. package/skills/feishu-drive/SKILL.md +97 -0
  8. package/skills/feishu-perm/SKILL.md +119 -0
  9. package/skills/feishu-wiki/SKILL.md +111 -0
  10. package/src/accounts.test.ts +371 -0
  11. package/src/accounts.ts +244 -0
  12. package/src/async.ts +62 -0
  13. package/src/bitable.ts +725 -0
  14. package/src/bot.card-action.test.ts +63 -0
  15. package/src/bot.checkBotMentioned.test.ts +193 -0
  16. package/src/bot.stripBotMention.test.ts +134 -0
  17. package/src/bot.test.ts +2107 -0
  18. package/src/bot.ts +1556 -0
  19. package/src/card-action.ts +79 -0
  20. package/src/channel.test.ts +48 -0
  21. package/src/channel.ts +369 -0
  22. package/src/chat-schema.ts +24 -0
  23. package/src/chat.test.ts +89 -0
  24. package/src/chat.ts +130 -0
  25. package/src/client.test.ts +324 -0
  26. package/src/client.ts +196 -0
  27. package/src/config-schema.test.ts +247 -0
  28. package/src/config-schema.ts +306 -0
  29. package/src/dedup.ts +203 -0
  30. package/src/directory.test.ts +40 -0
  31. package/src/directory.ts +156 -0
  32. package/src/doc-schema.ts +182 -0
  33. package/src/docx-batch-insert.test.ts +90 -0
  34. package/src/docx-batch-insert.ts +187 -0
  35. package/src/docx-color-text.ts +149 -0
  36. package/src/docx-table-ops.ts +298 -0
  37. package/src/docx.account-selection.test.ts +70 -0
  38. package/src/docx.test.ts +445 -0
  39. package/src/docx.ts +1460 -0
  40. package/src/drive-schema.ts +46 -0
  41. package/src/drive.ts +228 -0
  42. package/src/dynamic-agent.ts +131 -0
  43. package/src/external-keys.test.ts +20 -0
  44. package/src/external-keys.ts +19 -0
  45. package/src/feishu-command-handler.ts +59 -0
  46. package/src/media.test.ts +523 -0
  47. package/src/media.ts +484 -0
  48. package/src/mention.ts +133 -0
  49. package/src/monitor.account.ts +562 -0
  50. package/src/monitor.reaction.test.ts +653 -0
  51. package/src/monitor.startup.test.ts +190 -0
  52. package/src/monitor.startup.ts +64 -0
  53. package/src/monitor.state.defaults.test.ts +46 -0
  54. package/src/monitor.state.ts +155 -0
  55. package/src/monitor.test-mocks.ts +45 -0
  56. package/src/monitor.transport.ts +264 -0
  57. package/src/monitor.ts +95 -0
  58. package/src/monitor.webhook-e2e.test.ts +214 -0
  59. package/src/monitor.webhook-security.test.ts +142 -0
  60. package/src/monitor.webhook.test-helpers.ts +98 -0
  61. package/src/onboarding.status.test.ts +25 -0
  62. package/src/onboarding.test.ts +143 -0
  63. package/src/onboarding.ts +489 -0
  64. package/src/outbound.test.ts +356 -0
  65. package/src/outbound.ts +176 -0
  66. package/src/perm-schema.ts +52 -0
  67. package/src/perm.ts +176 -0
  68. package/src/policy.test.ts +154 -0
  69. package/src/policy.ts +123 -0
  70. package/src/post.test.ts +105 -0
  71. package/src/post.ts +274 -0
  72. package/src/probe.test.ts +270 -0
  73. package/src/probe.ts +156 -0
  74. package/src/reactions.ts +153 -0
  75. package/src/reply-dispatcher.test.ts +513 -0
  76. package/src/reply-dispatcher.ts +397 -0
  77. package/src/runtime.ts +6 -0
  78. package/src/secret-input.ts +13 -0
  79. package/src/send-message.ts +71 -0
  80. package/src/send-result.ts +29 -0
  81. package/src/send-target.test.ts +74 -0
  82. package/src/send-target.ts +29 -0
  83. package/src/send.reply-fallback.test.ts +189 -0
  84. package/src/send.test.ts +168 -0
  85. package/src/send.ts +481 -0
  86. package/src/streaming-card.test.ts +54 -0
  87. package/src/streaming-card.ts +374 -0
  88. package/src/targets.test.ts +70 -0
  89. package/src/targets.ts +107 -0
  90. package/src/tool-account-routing.test.ts +129 -0
  91. package/src/tool-account.ts +70 -0
  92. package/src/tool-factory-test-harness.ts +76 -0
  93. package/src/tool-result.test.ts +32 -0
  94. package/src/tool-result.ts +14 -0
  95. package/src/tools-config.test.ts +21 -0
  96. package/src/tools-config.ts +22 -0
  97. package/src/types.ts +103 -0
  98. package/src/typing.test.ts +144 -0
  99. package/src/typing.ts +210 -0
  100. package/src/wiki-schema.ts +55 -0
  101. package/src/wiki.ts +233 -0
  102. package/index.js +0 -27
package/src/post.ts ADDED
@@ -0,0 +1,274 @@
1
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
2
+
3
+ const FALLBACK_POST_TEXT = "[Rich text message]";
4
+ const MARKDOWN_SPECIAL_CHARS = /([\\`*_{}\[\]()#+\-!|>~])/g;
5
+
6
+ type PostParseResult = {
7
+ textContent: string;
8
+ imageKeys: string[];
9
+ mediaKeys: Array<{ fileKey: string; fileName?: string }>;
10
+ mentionedOpenIds: string[];
11
+ };
12
+
13
+ type PostPayload = {
14
+ title: string;
15
+ content: unknown[];
16
+ };
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null;
20
+ }
21
+
22
+ function toStringOrEmpty(value: unknown): string {
23
+ return typeof value === "string" ? value : "";
24
+ }
25
+
26
+ function escapeMarkdownText(text: string): string {
27
+ return text.replace(MARKDOWN_SPECIAL_CHARS, "\\$1");
28
+ }
29
+
30
+ function toBoolean(value: unknown): boolean {
31
+ return value === true || value === 1 || value === "true";
32
+ }
33
+
34
+ function isStyleEnabled(style: Record<string, unknown> | undefined, key: string): boolean {
35
+ if (!style) {
36
+ return false;
37
+ }
38
+ return toBoolean(style[key]);
39
+ }
40
+
41
+ function wrapInlineCode(text: string): string {
42
+ const maxRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
43
+ const fence = "`".repeat(maxRun + 1);
44
+ const needsPadding = text.startsWith("`") || text.endsWith("`");
45
+ const body = needsPadding ? ` ${text} ` : text;
46
+ return `${fence}${body}${fence}`;
47
+ }
48
+
49
+ function sanitizeFenceLanguage(language: string): string {
50
+ return language.trim().replace(/[^A-Za-z0-9_+#.-]/g, "");
51
+ }
52
+
53
+ function renderTextElement(element: Record<string, unknown>): string {
54
+ const text = toStringOrEmpty(element.text);
55
+ const style = isRecord(element.style) ? element.style : undefined;
56
+
57
+ if (isStyleEnabled(style, "code")) {
58
+ return wrapInlineCode(text);
59
+ }
60
+
61
+ let rendered = escapeMarkdownText(text);
62
+ if (!rendered) {
63
+ return "";
64
+ }
65
+
66
+ if (isStyleEnabled(style, "bold")) {
67
+ rendered = `**${rendered}**`;
68
+ }
69
+ if (isStyleEnabled(style, "italic")) {
70
+ rendered = `*${rendered}*`;
71
+ }
72
+ if (isStyleEnabled(style, "underline")) {
73
+ rendered = `<u>${rendered}</u>`;
74
+ }
75
+ if (
76
+ isStyleEnabled(style, "strikethrough") ||
77
+ isStyleEnabled(style, "line_through") ||
78
+ isStyleEnabled(style, "lineThrough")
79
+ ) {
80
+ rendered = `~~${rendered}~~`;
81
+ }
82
+ return rendered;
83
+ }
84
+
85
+ function renderLinkElement(element: Record<string, unknown>): string {
86
+ const href = toStringOrEmpty(element.href).trim();
87
+ const rawText = toStringOrEmpty(element.text);
88
+ const text = rawText || href;
89
+ if (!text) {
90
+ return "";
91
+ }
92
+ if (!href) {
93
+ return escapeMarkdownText(text);
94
+ }
95
+ return `[${escapeMarkdownText(text)}](${href})`;
96
+ }
97
+
98
+ function renderMentionElement(element: Record<string, unknown>): string {
99
+ const mention =
100
+ toStringOrEmpty(element.user_name) ||
101
+ toStringOrEmpty(element.user_id) ||
102
+ toStringOrEmpty(element.open_id);
103
+ if (!mention) {
104
+ return "";
105
+ }
106
+ return `@${escapeMarkdownText(mention)}`;
107
+ }
108
+
109
+ function renderEmotionElement(element: Record<string, unknown>): string {
110
+ const text =
111
+ toStringOrEmpty(element.emoji) ||
112
+ toStringOrEmpty(element.text) ||
113
+ toStringOrEmpty(element.emoji_type);
114
+ return escapeMarkdownText(text);
115
+ }
116
+
117
+ function renderCodeBlockElement(element: Record<string, unknown>): string {
118
+ const language = sanitizeFenceLanguage(
119
+ toStringOrEmpty(element.language) || toStringOrEmpty(element.lang),
120
+ );
121
+ const code = (toStringOrEmpty(element.text) || toStringOrEmpty(element.content)).replace(
122
+ /\r\n/g,
123
+ "\n",
124
+ );
125
+ const trailingNewline = code.endsWith("\n") ? "" : "\n";
126
+ return `\`\`\`${language}\n${code}${trailingNewline}\`\`\``;
127
+ }
128
+
129
+ function renderElement(
130
+ element: unknown,
131
+ imageKeys: string[],
132
+ mediaKeys: Array<{ fileKey: string; fileName?: string }>,
133
+ mentionedOpenIds: string[],
134
+ ): string {
135
+ if (!isRecord(element)) {
136
+ return escapeMarkdownText(toStringOrEmpty(element));
137
+ }
138
+
139
+ const tag = toStringOrEmpty(element.tag).toLowerCase();
140
+ switch (tag) {
141
+ case "text":
142
+ return renderTextElement(element);
143
+ case "a":
144
+ return renderLinkElement(element);
145
+ case "at":
146
+ {
147
+ const mentioned = toStringOrEmpty(element.open_id) || toStringOrEmpty(element.user_id);
148
+ const normalizedMention = normalizeFeishuExternalKey(mentioned);
149
+ if (normalizedMention) {
150
+ mentionedOpenIds.push(normalizedMention);
151
+ }
152
+ }
153
+ return renderMentionElement(element);
154
+ case "img": {
155
+ const imageKey = normalizeFeishuExternalKey(toStringOrEmpty(element.image_key));
156
+ if (imageKey) {
157
+ imageKeys.push(imageKey);
158
+ }
159
+ return "![image]";
160
+ }
161
+ case "media": {
162
+ const fileKey = normalizeFeishuExternalKey(toStringOrEmpty(element.file_key));
163
+ if (fileKey) {
164
+ const fileName = toStringOrEmpty(element.file_name) || undefined;
165
+ mediaKeys.push({ fileKey, fileName });
166
+ }
167
+ return "[media]";
168
+ }
169
+ case "emotion":
170
+ return renderEmotionElement(element);
171
+ case "br":
172
+ return "\n";
173
+ case "hr":
174
+ return "\n\n---\n\n";
175
+ case "code": {
176
+ const code = toStringOrEmpty(element.text) || toStringOrEmpty(element.content);
177
+ return code ? wrapInlineCode(code) : "";
178
+ }
179
+ case "code_block":
180
+ case "pre":
181
+ return renderCodeBlockElement(element);
182
+ default:
183
+ return escapeMarkdownText(toStringOrEmpty(element.text));
184
+ }
185
+ }
186
+
187
+ function toPostPayload(candidate: unknown): PostPayload | null {
188
+ if (!isRecord(candidate) || !Array.isArray(candidate.content)) {
189
+ return null;
190
+ }
191
+ return {
192
+ title: toStringOrEmpty(candidate.title),
193
+ content: candidate.content,
194
+ };
195
+ }
196
+
197
+ function resolveLocalePayload(candidate: unknown): PostPayload | null {
198
+ const direct = toPostPayload(candidate);
199
+ if (direct) {
200
+ return direct;
201
+ }
202
+ if (!isRecord(candidate)) {
203
+ return null;
204
+ }
205
+ for (const value of Object.values(candidate)) {
206
+ const localePayload = toPostPayload(value);
207
+ if (localePayload) {
208
+ return localePayload;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function resolvePostPayload(parsed: unknown): PostPayload | null {
215
+ const direct = toPostPayload(parsed);
216
+ if (direct) {
217
+ return direct;
218
+ }
219
+
220
+ if (!isRecord(parsed)) {
221
+ return null;
222
+ }
223
+
224
+ const wrappedPost = resolveLocalePayload(parsed.post);
225
+ if (wrappedPost) {
226
+ return wrappedPost;
227
+ }
228
+
229
+ return resolveLocalePayload(parsed);
230
+ }
231
+
232
+ export function parsePostContent(content: string): PostParseResult {
233
+ try {
234
+ const parsed = JSON.parse(content);
235
+ const payload = resolvePostPayload(parsed);
236
+ if (!payload) {
237
+ return {
238
+ textContent: FALLBACK_POST_TEXT,
239
+ imageKeys: [],
240
+ mediaKeys: [],
241
+ mentionedOpenIds: [],
242
+ };
243
+ }
244
+
245
+ const imageKeys: string[] = [];
246
+ const mediaKeys: Array<{ fileKey: string; fileName?: string }> = [];
247
+ const mentionedOpenIds: string[] = [];
248
+ const paragraphs: string[] = [];
249
+
250
+ for (const paragraph of payload.content) {
251
+ if (!Array.isArray(paragraph)) {
252
+ continue;
253
+ }
254
+ let renderedParagraph = "";
255
+ for (const element of paragraph) {
256
+ renderedParagraph += renderElement(element, imageKeys, mediaKeys, mentionedOpenIds);
257
+ }
258
+ paragraphs.push(renderedParagraph);
259
+ }
260
+
261
+ const title = escapeMarkdownText(payload.title.trim());
262
+ const body = paragraphs.join("\n").trim();
263
+ const textContent = [title, body].filter(Boolean).join("\n\n").trim();
264
+
265
+ return {
266
+ textContent: textContent || FALLBACK_POST_TEXT,
267
+ imageKeys,
268
+ mediaKeys,
269
+ mentionedOpenIds,
270
+ };
271
+ } catch {
272
+ return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mediaKeys: [], mentionedOpenIds: [] };
273
+ }
274
+ }
@@ -0,0 +1,270 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("./client.js", () => ({
6
+ createFeishuClient: createFeishuClientMock,
7
+ }));
8
+
9
+ import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
10
+
11
+ const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
12
+ const DEFAULT_SUCCESS_RESPONSE = {
13
+ code: 0,
14
+ bot: { bot_name: "TestBot", open_id: "ou_abc123" },
15
+ } as const;
16
+ const DEFAULT_SUCCESS_RESULT = {
17
+ ok: true,
18
+ appId: "cli_123",
19
+ botName: "TestBot",
20
+ botOpenId: "ou_abc123",
21
+ } as const;
22
+ const BOT1_RESPONSE = {
23
+ code: 0,
24
+ bot: { bot_name: "Bot1", open_id: "ou_1" },
25
+ } as const;
26
+
27
+ function makeRequestFn(response: Record<string, unknown>) {
28
+ return vi.fn().mockResolvedValue(response);
29
+ }
30
+
31
+ function setupClient(response: Record<string, unknown>) {
32
+ const requestFn = makeRequestFn(response);
33
+ createFeishuClientMock.mockReturnValue({ request: requestFn });
34
+ return requestFn;
35
+ }
36
+
37
+ function setupSuccessClient() {
38
+ return setupClient(DEFAULT_SUCCESS_RESPONSE);
39
+ }
40
+
41
+ async function expectDefaultSuccessResult(
42
+ creds = DEFAULT_CREDS,
43
+ expected: Awaited<ReturnType<typeof probeFeishu>> = DEFAULT_SUCCESS_RESULT,
44
+ ) {
45
+ const result = await probeFeishu(creds);
46
+ expect(result).toEqual(expected);
47
+ }
48
+
49
+ async function withFakeTimers(run: () => Promise<void>) {
50
+ vi.useFakeTimers();
51
+ try {
52
+ await run();
53
+ } finally {
54
+ vi.useRealTimers();
55
+ }
56
+ }
57
+
58
+ async function expectErrorResultCached(params: {
59
+ requestFn: ReturnType<typeof vi.fn>;
60
+ expectedError: string;
61
+ ttlMs: number;
62
+ }) {
63
+ createFeishuClientMock.mockReturnValue({ request: params.requestFn });
64
+
65
+ const first = await probeFeishu(DEFAULT_CREDS);
66
+ const second = await probeFeishu(DEFAULT_CREDS);
67
+ expect(first).toMatchObject({ ok: false, error: params.expectedError });
68
+ expect(second).toMatchObject({ ok: false, error: params.expectedError });
69
+ expect(params.requestFn).toHaveBeenCalledTimes(1);
70
+
71
+ vi.advanceTimersByTime(params.ttlMs + 1);
72
+
73
+ await probeFeishu(DEFAULT_CREDS);
74
+ expect(params.requestFn).toHaveBeenCalledTimes(2);
75
+ }
76
+
77
+ async function expectFreshDefaultProbeAfter(
78
+ requestFn: ReturnType<typeof vi.fn>,
79
+ invalidate: () => void,
80
+ ) {
81
+ await probeFeishu(DEFAULT_CREDS);
82
+ expect(requestFn).toHaveBeenCalledTimes(1);
83
+
84
+ invalidate();
85
+
86
+ await probeFeishu(DEFAULT_CREDS);
87
+ expect(requestFn).toHaveBeenCalledTimes(2);
88
+ }
89
+
90
+ async function readSequentialDefaultProbePair() {
91
+ const first = await probeFeishu(DEFAULT_CREDS);
92
+ return { first, second: await probeFeishu(DEFAULT_CREDS) };
93
+ }
94
+
95
+ describe("probeFeishu", () => {
96
+ beforeEach(() => {
97
+ clearProbeCache();
98
+ vi.restoreAllMocks();
99
+ });
100
+
101
+ afterEach(() => {
102
+ clearProbeCache();
103
+ });
104
+
105
+ it("returns error when credentials are missing", async () => {
106
+ const result = await probeFeishu();
107
+ expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
108
+ });
109
+
110
+ it("returns error when appId is missing", async () => {
111
+ const result = await probeFeishu({ appSecret: "secret" } as never); // pragma: allowlist secret
112
+ expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
113
+ });
114
+
115
+ it("returns error when appSecret is missing", async () => {
116
+ const result = await probeFeishu({ appId: "cli_123" } as never);
117
+ expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
118
+ });
119
+
120
+ it("returns bot info on successful probe", async () => {
121
+ const requestFn = setupSuccessClient();
122
+
123
+ await expectDefaultSuccessResult();
124
+ expect(requestFn).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it("passes the probe timeout to the Feishu request", async () => {
128
+ const requestFn = setupSuccessClient();
129
+
130
+ await probeFeishu(DEFAULT_CREDS);
131
+
132
+ expect(requestFn).toHaveBeenCalledWith(
133
+ expect.objectContaining({
134
+ method: "GET",
135
+ url: "/open-apis/bot/v3/info",
136
+ timeout: FEISHU_PROBE_REQUEST_TIMEOUT_MS,
137
+ }),
138
+ );
139
+ });
140
+
141
+ it("returns timeout error when request exceeds timeout", async () => {
142
+ await withFakeTimers(async () => {
143
+ const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
144
+ createFeishuClientMock.mockReturnValue({ request: requestFn });
145
+
146
+ const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
147
+ await vi.advanceTimersByTimeAsync(1_000);
148
+ const result = await promise;
149
+
150
+ expect(result).toMatchObject({ ok: false, error: "probe timed out after 1000ms" });
151
+ });
152
+ });
153
+
154
+ it("returns aborted when abort signal is already aborted", async () => {
155
+ createFeishuClientMock.mockClear();
156
+ const abortController = new AbortController();
157
+ abortController.abort();
158
+
159
+ const result = await probeFeishu(
160
+ { appId: "cli_123", appSecret: "secret" }, // pragma: allowlist secret
161
+ { abortSignal: abortController.signal },
162
+ );
163
+
164
+ expect(result).toMatchObject({ ok: false, error: "probe aborted" });
165
+ expect(createFeishuClientMock).not.toHaveBeenCalled();
166
+ });
167
+ it("returns cached result on subsequent calls within TTL", async () => {
168
+ const requestFn = setupSuccessClient();
169
+
170
+ const { first, second } = await readSequentialDefaultProbePair();
171
+
172
+ expect(first).toEqual(second);
173
+ // Only one API call should have been made
174
+ expect(requestFn).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it("makes a fresh API call after cache expires", async () => {
178
+ await withFakeTimers(async () => {
179
+ const requestFn = setupSuccessClient();
180
+
181
+ await expectFreshDefaultProbeAfter(requestFn, () => {
182
+ vi.advanceTimersByTime(10 * 60 * 1000 + 1);
183
+ });
184
+ });
185
+ });
186
+
187
+ it("caches failed probe results (API error) for the error TTL", async () => {
188
+ await withFakeTimers(async () => {
189
+ await expectErrorResultCached({
190
+ requestFn: makeRequestFn({ code: 99, msg: "token expired" }),
191
+ expectedError: "API error: token expired",
192
+ ttlMs: 60 * 1000,
193
+ });
194
+ });
195
+ });
196
+
197
+ it("caches thrown request errors for the error TTL", async () => {
198
+ await withFakeTimers(async () => {
199
+ await expectErrorResultCached({
200
+ requestFn: vi.fn().mockRejectedValue(new Error("network error")),
201
+ expectedError: "network error",
202
+ ttlMs: 60 * 1000,
203
+ });
204
+ });
205
+ });
206
+
207
+ it("caches per account independently", async () => {
208
+ const requestFn = setupClient(BOT1_RESPONSE);
209
+
210
+ await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
211
+ expect(requestFn).toHaveBeenCalledTimes(1);
212
+
213
+ // Different appId should trigger a new API call
214
+ await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); // pragma: allowlist secret
215
+ expect(requestFn).toHaveBeenCalledTimes(2);
216
+
217
+ // Same appId + appSecret as first call should return cached
218
+ await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
219
+ expect(requestFn).toHaveBeenCalledTimes(2);
220
+ });
221
+
222
+ it("does not share cache between accounts with same appId but different appSecret", async () => {
223
+ const requestFn = setupClient(BOT1_RESPONSE);
224
+
225
+ // First account with appId + secret A
226
+ await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret
227
+ expect(requestFn).toHaveBeenCalledTimes(1);
228
+
229
+ // Second account with same appId but different secret (e.g. after rotation)
230
+ // must NOT reuse the cached result
231
+ await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); // pragma: allowlist secret
232
+ expect(requestFn).toHaveBeenCalledTimes(2);
233
+ });
234
+
235
+ it("uses accountId for cache key when available", async () => {
236
+ const requestFn = setupClient(BOT1_RESPONSE);
237
+
238
+ // Two accounts with same appId+appSecret but different accountIds are cached separately
239
+ await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
240
+ expect(requestFn).toHaveBeenCalledTimes(1);
241
+
242
+ await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
243
+ expect(requestFn).toHaveBeenCalledTimes(2);
244
+
245
+ // Same accountId should return cached
246
+ await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
247
+ expect(requestFn).toHaveBeenCalledTimes(2);
248
+ });
249
+
250
+ it("clearProbeCache forces fresh API call", async () => {
251
+ const requestFn = setupSuccessClient();
252
+
253
+ await expectFreshDefaultProbeAfter(requestFn, () => {
254
+ clearProbeCache();
255
+ });
256
+ });
257
+
258
+ it("handles response.data.bot fallback path", async () => {
259
+ setupClient({
260
+ code: 0,
261
+ data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
262
+ });
263
+
264
+ await expectDefaultSuccessResult(DEFAULT_CREDS, {
265
+ ...DEFAULT_SUCCESS_RESULT,
266
+ botName: "DataBot",
267
+ botOpenId: "ou_data",
268
+ });
269
+ });
270
+ });
package/src/probe.ts ADDED
@@ -0,0 +1,156 @@
1
+ import { raceWithTimeoutAndAbort } from "./async.js";
2
+ import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
3
+ import type { FeishuProbeResult } from "./types.js";
4
+
5
+ /** Cache probe results to reduce repeated health-check calls.
6
+ * Gateway health checks call probeFeishu() every minute; without caching this
7
+ * burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
8
+ * Successful bot info is effectively static, while failures are cached briefly
9
+ * to avoid hammering the API during transient outages. */
10
+ const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
11
+ const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
12
+ const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
13
+ const MAX_PROBE_CACHE_SIZE = 64;
14
+ export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
15
+ export type ProbeFeishuOptions = {
16
+ timeoutMs?: number;
17
+ abortSignal?: AbortSignal;
18
+ };
19
+
20
+ type FeishuBotInfoResponse = {
21
+ code: number;
22
+ msg?: string;
23
+ bot?: { bot_name?: string; open_id?: string };
24
+ data?: { bot?: { bot_name?: string; open_id?: string } };
25
+ };
26
+
27
+ function setCachedProbeResult(
28
+ cacheKey: string,
29
+ result: FeishuProbeResult,
30
+ ttlMs: number,
31
+ ): FeishuProbeResult {
32
+ probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
33
+ if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
34
+ const oldest = probeCache.keys().next().value;
35
+ if (oldest !== undefined) {
36
+ probeCache.delete(oldest);
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
42
+ export async function probeFeishu(
43
+ creds?: FeishuClientCredentials,
44
+ options: ProbeFeishuOptions = {},
45
+ ): Promise<FeishuProbeResult> {
46
+ if (!creds?.appId || !creds?.appSecret) {
47
+ return {
48
+ ok: false,
49
+ error: "missing credentials (appId, appSecret)",
50
+ };
51
+ }
52
+ if (options.abortSignal?.aborted) {
53
+ return {
54
+ ok: false,
55
+ appId: creds.appId,
56
+ error: "probe aborted",
57
+ };
58
+ }
59
+
60
+ const timeoutMs = options.timeoutMs ?? FEISHU_PROBE_REQUEST_TIMEOUT_MS;
61
+
62
+ // Return cached result if still valid.
63
+ // Use accountId when available; otherwise include appSecret prefix so two
64
+ // accounts sharing the same appId (e.g. after secret rotation) don't
65
+ // pollute each other's cache entry.
66
+ const cacheKey = creds.accountId ?? `${creds.appId}:${creds.appSecret.slice(0, 8)}`;
67
+ const cached = probeCache.get(cacheKey);
68
+ if (cached && cached.expiresAt > Date.now()) {
69
+ return cached.result;
70
+ }
71
+
72
+ try {
73
+ const client = createFeishuClient(creds);
74
+ // Use bot/v3/info API to get bot information
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK generic request method
76
+ const responseResult = await raceWithTimeoutAndAbort<FeishuBotInfoResponse>(
77
+ (client as any).request({
78
+ method: "GET",
79
+ url: "/open-apis/bot/v3/info",
80
+ data: {},
81
+ timeout: timeoutMs,
82
+ }) as Promise<FeishuBotInfoResponse>,
83
+ {
84
+ timeoutMs,
85
+ abortSignal: options.abortSignal,
86
+ },
87
+ );
88
+
89
+ if (responseResult.status === "aborted") {
90
+ return {
91
+ ok: false,
92
+ appId: creds.appId,
93
+ error: "probe aborted",
94
+ };
95
+ }
96
+ if (responseResult.status === "timeout") {
97
+ return setCachedProbeResult(
98
+ cacheKey,
99
+ {
100
+ ok: false,
101
+ appId: creds.appId,
102
+ error: `probe timed out after ${timeoutMs}ms`,
103
+ },
104
+ PROBE_ERROR_TTL_MS,
105
+ );
106
+ }
107
+
108
+ const response = responseResult.value;
109
+ if (options.abortSignal?.aborted) {
110
+ return {
111
+ ok: false,
112
+ appId: creds.appId,
113
+ error: "probe aborted",
114
+ };
115
+ }
116
+
117
+ if (response.code !== 0) {
118
+ return setCachedProbeResult(
119
+ cacheKey,
120
+ {
121
+ ok: false,
122
+ appId: creds.appId,
123
+ error: `API error: ${response.msg || `code ${response.code}`}`,
124
+ },
125
+ PROBE_ERROR_TTL_MS,
126
+ );
127
+ }
128
+
129
+ const bot = response.bot || response.data?.bot;
130
+ return setCachedProbeResult(
131
+ cacheKey,
132
+ {
133
+ ok: true,
134
+ appId: creds.appId,
135
+ botName: bot?.bot_name,
136
+ botOpenId: bot?.open_id,
137
+ },
138
+ PROBE_SUCCESS_TTL_MS,
139
+ );
140
+ } catch (err) {
141
+ return setCachedProbeResult(
142
+ cacheKey,
143
+ {
144
+ ok: false,
145
+ appId: creds.appId,
146
+ error: err instanceof Error ? err.message : String(err),
147
+ },
148
+ PROBE_ERROR_TTL_MS,
149
+ );
150
+ }
151
+ }
152
+
153
+ /** Clear the probe cache (for testing). */
154
+ export function clearProbeCache(): void {
155
+ probeCache.clear();
156
+ }