fix(feishu): cache probeFeishu() results with 10-min TTL to reduce API calls (openclaw#28907) thanks @Glucksberg
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
206
extensions/feishu/src/probe.test.ts
Normal file
206
extensions/feishu/src/probe.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
import { probeFeishu, clearProbeCache } from "./probe.js";
|
||||
|
||||
function makeRequestFn(response: Record<string, unknown>) {
|
||||
return vi.fn().mockResolvedValue(response);
|
||||
}
|
||||
|
||||
function setupClient(response: Record<string, unknown>) {
|
||||
const requestFn = makeRequestFn(response);
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
return requestFn;
|
||||
}
|
||||
|
||||
describe("probeFeishu", () => {
|
||||
beforeEach(() => {
|
||||
clearProbeCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearProbeCache();
|
||||
});
|
||||
|
||||
it("returns error when credentials are missing", async () => {
|
||||
const result = await probeFeishu();
|
||||
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
|
||||
});
|
||||
|
||||
it("returns error when appId is missing", async () => {
|
||||
const result = await probeFeishu({ appSecret: "secret" } as never);
|
||||
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
|
||||
});
|
||||
|
||||
it("returns error when appSecret is missing", async () => {
|
||||
const result = await probeFeishu({ appId: "cli_123" } as never);
|
||||
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
|
||||
});
|
||||
|
||||
it("returns bot info on successful probe", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
||||
});
|
||||
|
||||
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
appId: "cli_123",
|
||||
botName: "TestBot",
|
||||
botOpenId: "ou_abc123",
|
||||
});
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns cached result on subsequent calls within TTL", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
||||
});
|
||||
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
const first = await probeFeishu(creds);
|
||||
const second = await probeFeishu(creds);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
// Only one API call should have been made
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("makes a fresh API call after cache expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
||||
});
|
||||
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time past the 10-minute TTL
|
||||
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
||||
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cache failed probe results (API error)", async () => {
|
||||
const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
const first = await probeFeishu(creds);
|
||||
expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
|
||||
|
||||
// Second call should make a fresh request since failures are not cached
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache results when request throws", async () => {
|
||||
const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
const first = await probeFeishu(creds);
|
||||
expect(first).toMatchObject({ ok: false, error: "network error" });
|
||||
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches per account independently", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
||||
});
|
||||
|
||||
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Different appId should trigger a new API call
|
||||
await probeFeishu({ appId: "cli_bbb", appSecret: "s2" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Same appId + appSecret as first call should return cached
|
||||
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share cache between accounts with same appId but different appSecret", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
||||
});
|
||||
|
||||
// First account with appId + secret A
|
||||
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second account with same appId but different secret (e.g. after rotation)
|
||||
// must NOT reuse the cached result
|
||||
await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses accountId for cache key when available", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
||||
});
|
||||
|
||||
// Two accounts with same appId+appSecret but different accountIds are cached separately
|
||||
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Same accountId should return cached
|
||||
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clearProbeCache forces fresh API call", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
||||
});
|
||||
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
clearProbeCache();
|
||||
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("handles response.data.bot fallback path", async () => {
|
||||
setupClient({
|
||||
code: 0,
|
||||
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
|
||||
});
|
||||
|
||||
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
appId: "cli_123",
|
||||
botName: "DataBot",
|
||||
botOpenId: "ou_data",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
||||
import type { FeishuProbeResult } from "./types.js";
|
||||
|
||||
/** Cache successful probe results to reduce API calls (bot info is static).
|
||||
* Gateway health checks call probeFeishu() every minute; without caching this
|
||||
* burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
|
||||
* A 10-min TTL cuts that to ~4,320 calls/month. (#26684) */
|
||||
const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
|
||||
const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const MAX_PROBE_CACHE_SIZE = 64;
|
||||
|
||||
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
|
||||
if (!creds?.appId || !creds?.appSecret) {
|
||||
return {
|
||||
@@ -9,6 +17,16 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
|
||||
};
|
||||
}
|
||||
|
||||
// Return cached result if still valid.
|
||||
// Use accountId when available; otherwise include appSecret prefix so two
|
||||
// accounts sharing the same appId (e.g. after secret rotation) don't
|
||||
// pollute each other's cache entry.
|
||||
const cacheKey = creds.accountId ?? `${creds.appId}:${creds.appSecret.slice(0, 8)}`;
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(creds);
|
||||
// Use bot/v3/info API to get bot information
|
||||
@@ -28,12 +46,24 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
|
||||
}
|
||||
|
||||
const bot = response.bot || response.data?.bot;
|
||||
return {
|
||||
const result: FeishuProbeResult = {
|
||||
ok: true,
|
||||
appId: creds.appId,
|
||||
botName: bot?.bot_name,
|
||||
botOpenId: bot?.open_id,
|
||||
};
|
||||
|
||||
// Cache successful results only
|
||||
probeCache.set(cacheKey, { result, expiresAt: Date.now() + PROBE_CACHE_TTL_MS });
|
||||
// Evict oldest entry if cache exceeds max size
|
||||
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
||||
const oldest = probeCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
probeCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -42,3 +72,8 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the probe cache (for testing). */
|
||||
export function clearProbeCache(): void {
|
||||
probeCache.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user