Skip to content

Commit 60ef923

Browse files
GlucksbergTakhoffman
andauthored
fix(feishu): cache probeFeishu() results with 10-min TTL to reduce API calls (#28907) thanks @Glucksberg
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Glucksberg <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 56fa058 commit 60ef923

File tree

3 files changed

+243
-1
lines changed

3 files changed

+243
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515

1616
### Fixes
1717

18+
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
1819
- Feishu/Local media sends: propagate `mediaLocalRoots` through Feishu outbound media sending into `loadWebMedia` so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.
1920
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
2021
- Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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 { probeFeishu, clearProbeCache } from "./probe.js";
10+
11+
function makeRequestFn(response: Record<string, unknown>) {
12+
return vi.fn().mockResolvedValue(response);
13+
}
14+
15+
function setupClient(response: Record<string, unknown>) {
16+
const requestFn = makeRequestFn(response);
17+
createFeishuClientMock.mockReturnValue({ request: requestFn });
18+
return requestFn;
19+
}
20+
21+
describe("probeFeishu", () => {
22+
beforeEach(() => {
23+
clearProbeCache();
24+
vi.restoreAllMocks();
25+
});
26+
27+
afterEach(() => {
28+
clearProbeCache();
29+
});
30+
31+
it("returns error when credentials are missing", async () => {
32+
const result = await probeFeishu();
33+
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
34+
});
35+
36+
it("returns error when appId is missing", async () => {
37+
const result = await probeFeishu({ appSecret: "secret" } as never);
38+
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
39+
});
40+
41+
it("returns error when appSecret is missing", async () => {
42+
const result = await probeFeishu({ appId: "cli_123" } as never);
43+
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
44+
});
45+
46+
it("returns bot info on successful probe", async () => {
47+
const requestFn = setupClient({
48+
code: 0,
49+
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
50+
});
51+
52+
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
53+
expect(result).toEqual({
54+
ok: true,
55+
appId: "cli_123",
56+
botName: "TestBot",
57+
botOpenId: "ou_abc123",
58+
});
59+
expect(requestFn).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it("returns cached result on subsequent calls within TTL", async () => {
63+
const requestFn = setupClient({
64+
code: 0,
65+
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
66+
});
67+
68+
const creds = { appId: "cli_123", appSecret: "secret" };
69+
const first = await probeFeishu(creds);
70+
const second = await probeFeishu(creds);
71+
72+
expect(first).toEqual(second);
73+
// Only one API call should have been made
74+
expect(requestFn).toHaveBeenCalledTimes(1);
75+
});
76+
77+
it("makes a fresh API call after cache expires", async () => {
78+
vi.useFakeTimers();
79+
try {
80+
const requestFn = setupClient({
81+
code: 0,
82+
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
83+
});
84+
85+
const creds = { appId: "cli_123", appSecret: "secret" };
86+
await probeFeishu(creds);
87+
expect(requestFn).toHaveBeenCalledTimes(1);
88+
89+
// Advance time past the 10-minute TTL
90+
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
91+
92+
await probeFeishu(creds);
93+
expect(requestFn).toHaveBeenCalledTimes(2);
94+
} finally {
95+
vi.useRealTimers();
96+
}
97+
});
98+
99+
it("does not cache failed probe results (API error)", async () => {
100+
const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
101+
createFeishuClientMock.mockReturnValue({ request: requestFn });
102+
103+
const creds = { appId: "cli_123", appSecret: "secret" };
104+
const first = await probeFeishu(creds);
105+
expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
106+
107+
// Second call should make a fresh request since failures are not cached
108+
await probeFeishu(creds);
109+
expect(requestFn).toHaveBeenCalledTimes(2);
110+
});
111+
112+
it("does not cache results when request throws", async () => {
113+
const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
114+
createFeishuClientMock.mockReturnValue({ request: requestFn });
115+
116+
const creds = { appId: "cli_123", appSecret: "secret" };
117+
const first = await probeFeishu(creds);
118+
expect(first).toMatchObject({ ok: false, error: "network error" });
119+
120+
await probeFeishu(creds);
121+
expect(requestFn).toHaveBeenCalledTimes(2);
122+
});
123+
124+
it("caches per account independently", async () => {
125+
const requestFn = setupClient({
126+
code: 0,
127+
bot: { bot_name: "Bot1", open_id: "ou_1" },
128+
});
129+
130+
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
131+
expect(requestFn).toHaveBeenCalledTimes(1);
132+
133+
// Different appId should trigger a new API call
134+
await probeFeishu({ appId: "cli_bbb", appSecret: "s2" });
135+
expect(requestFn).toHaveBeenCalledTimes(2);
136+
137+
// Same appId + appSecret as first call should return cached
138+
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
139+
expect(requestFn).toHaveBeenCalledTimes(2);
140+
});
141+
142+
it("does not share cache between accounts with same appId but different appSecret", async () => {
143+
const requestFn = setupClient({
144+
code: 0,
145+
bot: { bot_name: "Bot1", open_id: "ou_1" },
146+
});
147+
148+
// First account with appId + secret A
149+
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" });
150+
expect(requestFn).toHaveBeenCalledTimes(1);
151+
152+
// Second account with same appId but different secret (e.g. after rotation)
153+
// must NOT reuse the cached result
154+
await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" });
155+
expect(requestFn).toHaveBeenCalledTimes(2);
156+
});
157+
158+
it("uses accountId for cache key when available", async () => {
159+
const requestFn = setupClient({
160+
code: 0,
161+
bot: { bot_name: "Bot1", open_id: "ou_1" },
162+
});
163+
164+
// Two accounts with same appId+appSecret but different accountIds are cached separately
165+
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
166+
expect(requestFn).toHaveBeenCalledTimes(1);
167+
168+
await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" });
169+
expect(requestFn).toHaveBeenCalledTimes(2);
170+
171+
// Same accountId should return cached
172+
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
173+
expect(requestFn).toHaveBeenCalledTimes(2);
174+
});
175+
176+
it("clearProbeCache forces fresh API call", async () => {
177+
const requestFn = setupClient({
178+
code: 0,
179+
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
180+
});
181+
182+
const creds = { appId: "cli_123", appSecret: "secret" };
183+
await probeFeishu(creds);
184+
expect(requestFn).toHaveBeenCalledTimes(1);
185+
186+
clearProbeCache();
187+
188+
await probeFeishu(creds);
189+
expect(requestFn).toHaveBeenCalledTimes(2);
190+
});
191+
192+
it("handles response.data.bot fallback path", async () => {
193+
setupClient({
194+
code: 0,
195+
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
196+
});
197+
198+
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
199+
expect(result).toEqual({
200+
ok: true,
201+
appId: "cli_123",
202+
botName: "DataBot",
203+
botOpenId: "ou_data",
204+
});
205+
});
206+
});

extensions/feishu/src/probe.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
22
import type { FeishuProbeResult } from "./types.js";
33

4+
/** Cache successful probe results to reduce API calls (bot info is static).
5+
* Gateway health checks call probeFeishu() every minute; without caching this
6+
* burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
7+
* A 10-min TTL cuts that to ~4,320 calls/month. (#26684) */
8+
const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
9+
const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
10+
const MAX_PROBE_CACHE_SIZE = 64;
11+
412
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
513
if (!creds?.appId || !creds?.appSecret) {
614
return {
@@ -9,6 +17,16 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
917
};
1018
}
1119

20+
// Return cached result if still valid.
21+
// Use accountId when available; otherwise include appSecret prefix so two
22+
// accounts sharing the same appId (e.g. after secret rotation) don't
23+
// pollute each other's cache entry.
24+
const cacheKey = creds.accountId ?? `${creds.appId}:${creds.appSecret.slice(0, 8)}`;
25+
const cached = probeCache.get(cacheKey);
26+
if (cached && cached.expiresAt > Date.now()) {
27+
return cached.result;
28+
}
29+
1230
try {
1331
const client = createFeishuClient(creds);
1432
// Use bot/v3/info API to get bot information
@@ -28,12 +46,24 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
2846
}
2947

3048
const bot = response.bot || response.data?.bot;
31-
return {
49+
const result: FeishuProbeResult = {
3250
ok: true,
3351
appId: creds.appId,
3452
botName: bot?.bot_name,
3553
botOpenId: bot?.open_id,
3654
};
55+
56+
// Cache successful results only
57+
probeCache.set(cacheKey, { result, expiresAt: Date.now() + PROBE_CACHE_TTL_MS });
58+
// Evict oldest entry if cache exceeds max size
59+
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
60+
const oldest = probeCache.keys().next().value;
61+
if (oldest !== undefined) {
62+
probeCache.delete(oldest);
63+
}
64+
}
65+
66+
return result;
3767
} catch (err) {
3868
return {
3969
ok: false,
@@ -42,3 +72,8 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
4272
};
4373
}
4474
}
75+
76+
/** Clear the probe cache (for testing). */
77+
export function clearProbeCache(): void {
78+
probeCache.clear();
79+
}

0 commit comments

Comments
 (0)