diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ed652bc..f0e83b1ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts new file mode 100644 index 000000000..b86939360 --- /dev/null +++ b/extensions/feishu/src/probe.test.ts @@ -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) { + return vi.fn().mockResolvedValue(response); +} + +function setupClient(response: Record) { + 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", + }); + }); +}); diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts index d96ff4915..fff93dc89 100644 --- a/extensions/feishu/src/probe.ts +++ b/extensions/feishu/src/probe.ts @@ -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(); +const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const MAX_PROBE_CACHE_SIZE = 64; + export async function probeFeishu(creds?: FeishuClientCredentials): Promise { if (!creds?.appId || !creds?.appSecret) { return { @@ -9,6 +17,16 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise 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 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