diff --git a/extensions/feishu/src/bot-identity-cache.test.ts b/extensions/feishu/src/bot-identity-cache.test.ts new file mode 100644 index 000000000000..f3aa2b29cec4 --- /dev/null +++ b/extensions/feishu/src/bot-identity-cache.test.ts @@ -0,0 +1,90 @@ +// Feishu tests cover provider-verified bot identity cache behavior. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readCachedFeishuBotIdentity, writeCachedFeishuBotIdentity } from "./bot-identity-cache.js"; + +const cacheHarness = vi.hoisted(() => ({ + entries: new Map(), + openKeyedStore: vi.fn(), +})); + +vi.mock("./runtime.js", () => ({ + getFeishuRuntime: () => ({ + state: { + openKeyedStore: cacheHarness.openKeyedStore, + }, + }), +})); + +beforeEach(() => { + cacheHarness.entries.clear(); + cacheHarness.openKeyedStore.mockReset().mockReturnValue({ + lookup: vi.fn(async (key: string) => cacheHarness.entries.get(key)), + register: vi.fn(async (key: string, value: unknown) => { + cacheHarness.entries.set(key, value); + }), + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Feishu bot identity cache", () => { + it("persists provider-verified identity in a bounded plugin-state namespace", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-22T23:00:00.000Z")); + + await writeCachedFeishuBotIdentity({ + accountId: "person-2", + appId: "cli_person_2", + botOpenId: "ou_bot_person_2", + botName: "OpenClaw QA", + }); + + await expect( + readCachedFeishuBotIdentity({ accountId: "person-2", appId: "cli_person_2" }), + ).resolves.toEqual({ + botOpenId: "ou_bot_person_2", + botName: "OpenClaw QA", + fetchedAt: "2026-07-22T23:00:00.000Z", + }); + expect(cacheHarness.openKeyedStore).toHaveBeenCalledWith({ + namespace: "feishu.bot-identity-cache", + maxEntries: 128, + }); + }); + + it("keeps identity across secret rotation but rejects a different app id", async () => { + await writeCachedFeishuBotIdentity({ + accountId: "person-2", + appId: "cli_person_2", + botOpenId: "ou_bot_person_2", + }); + + await expect( + readCachedFeishuBotIdentity({ accountId: "person-2", appId: "cli_person_2" }), + ).resolves.toMatchObject({ botOpenId: "ou_bot_person_2" }); + await expect( + readCachedFeishuBotIdentity({ accountId: "person-2", appId: "cli_replacement" }), + ).resolves.toBeNull(); + }); + + it("rejects malformed or incomplete persisted values", async () => { + cacheHarness.entries.set("person-2", { + appId: "cli_person_2", + botOpenId: "ou_bot_person_2", + fetchedAt: "not-a-date", + }); + + await expect( + readCachedFeishuBotIdentity({ accountId: "person-2", appId: "cli_person_2" }), + ).resolves.toBeNull(); + }); + + it("does not persist an identity without both app and bot ids", async () => { + await writeCachedFeishuBotIdentity({ accountId: "person-2", botOpenId: "ou_bot_person_2" }); + await writeCachedFeishuBotIdentity({ accountId: "person-2", appId: "cli_person_2" }); + + expect(cacheHarness.entries.size).toBe(0); + }); +}); diff --git a/extensions/feishu/src/bot-identity-cache.ts b/extensions/feishu/src/bot-identity-cache.ts new file mode 100644 index 000000000000..ce3f65994570 --- /dev/null +++ b/extensions/feishu/src/bot-identity-cache.ts @@ -0,0 +1,85 @@ +// Feishu plugin module implements provider-verified bot identity cache behavior. +import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { getFeishuRuntime } from "./runtime.js"; + +const FEISHU_BOT_IDENTITY_CACHE_NAMESPACE = "feishu.bot-identity-cache"; +const FEISHU_BOT_IDENTITY_CACHE_MAX_ENTRIES = 128; + +type FeishuBotIdentityCacheState = { + appId: string; + botOpenId: string; + botName?: string; + fetchedAt: string; +}; + +export type CachedFeishuBotIdentity = { + botOpenId: string; + botName?: string; + fetchedAt: string; +}; + +function openFeishuBotIdentityCache() { + return getFeishuRuntime().state.openKeyedStore({ + namespace: FEISHU_BOT_IDENTITY_CACHE_NAMESPACE, + maxEntries: FEISHU_BOT_IDENTITY_CACHE_MAX_ENTRIES, + }); +} + +function parseCachedFeishuBotIdentity(value: unknown): FeishuBotIdentityCacheState | null { + if (!value || typeof value !== "object") { + return null; + } + const state = value as Partial; + const appId = normalizeOptionalString(state.appId); + const botOpenId = normalizeOptionalString(state.botOpenId); + const botName = normalizeOptionalString(state.botName); + const fetchedAt = normalizeOptionalString(state.fetchedAt); + if (!appId || !botOpenId || !fetchedAt || Number.isNaN(Date.parse(fetchedAt))) { + return null; + } + return { appId, botOpenId, botName, fetchedAt }; +} + +export async function readCachedFeishuBotIdentity(params: { + accountId: string; + appId?: string; +}): Promise { + const appId = normalizeOptionalString(params.appId); + if (!appId) { + return null; + } + const cached = parseCachedFeishuBotIdentity( + await openFeishuBotIdentityCache().lookup(normalizeAccountId(params.accountId)), + ); + // The app id is the stable provider identity boundary. Secret rotation keeps + // this cache valid; changing apps must never reuse another bot's identity. + if (!cached || cached.appId !== appId) { + return null; + } + return { + botOpenId: cached.botOpenId, + botName: cached.botName, + fetchedAt: cached.fetchedAt, + }; +} + +export async function writeCachedFeishuBotIdentity(params: { + accountId: string; + appId?: string; + botOpenId?: string; + botName?: string; +}): Promise { + const appId = normalizeOptionalString(params.appId); + const botOpenId = normalizeOptionalString(params.botOpenId); + if (!appId || !botOpenId) { + return; + } + const botName = normalizeOptionalString(params.botName); + await openFeishuBotIdentityCache().register(normalizeAccountId(params.accountId), { + appId, + botOpenId, + botName, + fetchedAt: new Date().toISOString(), + }); +} diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index b969682ca5cf..b191e9ac698c 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -459,7 +459,12 @@ function registerEventHandlers( } type BotOpenIdSource = - | { kind: "prefetched"; botOpenId?: string; botName?: string } + | { + kind: "prefetched"; + botOpenId?: string; + botName?: string; + source?: "provider" | "cache"; + } | { kind: "fetch" }; type MonitorSingleAccountParams = { @@ -486,13 +491,23 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams): const botOpenIdSource = params.botOpenIdSource ?? { kind: "fetch" }; const botIdentity = botOpenIdSource.kind === "prefetched" - ? { botOpenId: botOpenIdSource.botOpenId, botName: botOpenIdSource.botName } + ? { + botOpenId: botOpenIdSource.botOpenId, + botName: botOpenIdSource.botName, + source: botOpenIdSource.source, + } : await fetchBotIdentityForMonitor(account, { runtime, abortSignal }); const { botOpenId } = applyBotIdentityState(accountId, botIdentity); log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`); - if (!botOpenId && !abortSignal?.aborted) { - startBotIdentityRecovery({ account, accountId, runtime, abortSignal }); + if ((!botOpenId || botIdentity.source === "cache") && !abortSignal?.aborted) { + startBotIdentityRecovery({ + account, + accountId, + runtime, + abortSignal, + currentSource: botIdentity.source, + }); } const connectionMode = account.config.connectionMode ?? "websocket"; diff --git a/extensions/feishu/src/monitor.bot-identity.test.ts b/extensions/feishu/src/monitor.bot-identity.test.ts new file mode 100644 index 000000000000..9fb827d9108d --- /dev/null +++ b/extensions/feishu/src/monitor.bot-identity.test.ts @@ -0,0 +1,74 @@ +// Feishu tests cover background bot identity recovery behavior. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime-api.js"; +import { startBotIdentityRecovery } from "./monitor.bot-identity.js"; + +const fetchBotIdentityForMonitorMock = vi.hoisted(() => vi.fn()); +const setFeishuBotIdentityStateMock = vi.hoisted(() => vi.fn()); + +vi.mock("./monitor.startup.js", () => ({ + fetchBotIdentityForMonitor: fetchBotIdentityForMonitorMock, +})); + +vi.mock("./monitor.state.js", () => ({ + setFeishuBotIdentityState: setFeishuBotIdentityStateMock, +})); + +beforeEach(() => { + vi.useFakeTimers(); + fetchBotIdentityForMonitorMock.mockReset(); + setFeishuBotIdentityStateMock.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Feishu bot identity recovery", () => { + it("bypasses cache and stops only after a provider-verified refresh", async () => { + fetchBotIdentityForMonitorMock + .mockResolvedValueOnce({ botOpenId: "ou_cached", source: "cache" }) + .mockResolvedValueOnce({ + botOpenId: "ou_provider", + botName: "OpenClaw QA", + source: "provider", + }); + const runtime = { + log: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeEnv; + + startBotIdentityRecovery({ + account: { + accountId: "person-2", + appId: "cli_person_2", + appSecret: "secret_person_2", // pragma: allowlist secret + } as never, + accountId: "person-2", + runtime, + currentSource: "cache", + }); + + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchBotIdentityForMonitorMock).toHaveBeenCalledTimes(1); + expect(fetchBotIdentityForMonitorMock).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ allowCachedFallback: false }), + ); + expect(runtime.log).not.toHaveBeenCalledWith( + expect.stringContaining("recovered via background retry"), + ); + expect(setFeishuBotIdentityStateMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(120_000); + expect(fetchBotIdentityForMonitorMock).toHaveBeenCalledTimes(2); + expect(runtime.log).toHaveBeenCalledWith( + "feishu[person-2]: bot open_id recovered via background retry: ou_provider", + ); + expect(setFeishuBotIdentityStateMock).toHaveBeenCalledTimes(1); + expect(setFeishuBotIdentityStateMock).toHaveBeenLastCalledWith("person-2", { + botOpenId: "ou_provider", + botName: "OpenClaw QA", + }); + }); +}); diff --git a/extensions/feishu/src/monitor.bot-identity.ts b/extensions/feishu/src/monitor.bot-identity.ts index fee5f92d8103..807fbe1988bc 100644 --- a/extensions/feishu/src/monitor.bot-identity.ts +++ b/extensions/feishu/src/monitor.bot-identity.ts @@ -13,13 +13,13 @@ const BOT_IDENTITY_RETRY_DELAYS_MS = [60_000, 120_000, 300_000, 600_000, 900_000 export function applyBotIdentityState( accountId: string, identity: FeishuMonitorBotIdentity, -): { botOpenId?: string; botName?: string } { +): FeishuMonitorBotIdentity { const botOpenId = normalizeOptionalString(identity.botOpenId); const botName = normalizeOptionalString(identity.botName); setFeishuBotIdentityState(accountId, { botOpenId: botOpenId ?? "", botName }); - return { botOpenId, botName }; + return { botOpenId, botName, source: botOpenId ? identity.source : undefined }; } async function retryBotIdentityProbe( @@ -42,9 +42,13 @@ async function retryBotIdentityProbe( return; } - const identity = await fetchBotIdentityForMonitor(account, { runtime, abortSignal }); - const resolved = applyBotIdentityState(accountId, identity); - if (resolved.botOpenId) { + const identity = await fetchBotIdentityForMonitor(account, { + runtime, + abortSignal, + allowCachedFallback: false, + }); + if (normalizeOptionalString(identity.botOpenId) && identity.source === "provider") { + const resolved = applyBotIdentityState(accountId, identity); log( `feishu[${accountId}]: bot open_id recovered via background retry: ${resolved.botOpenId}`, ); @@ -69,16 +73,20 @@ export function startBotIdentityRecovery(params: { accountId: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; + currentSource?: FeishuMonitorBotIdentity["source"]; }): void { - const { account, accountId, runtime, abortSignal } = params; + const { account, accountId, runtime, abortSignal, currentSource } = params; const log = runtime?.log ?? console.log; + const identityState = currentSource === "cache" ? "loaded from cache" : "unknown"; log( - `feishu[${accountId}]: bot open_id unknown; starting background retry (delays: ${BOT_IDENTITY_RETRY_DELAYS_MS.map((delay) => `${delay / 1000}s`).join(", ")})`, - ); - log( - `feishu[${accountId}]: requireMention group messages stay gated until bot identity recovery succeeds`, + `feishu[${accountId}]: bot open_id ${identityState}; starting background provider refresh (delays: ${BOT_IDENTITY_RETRY_DELAYS_MS.map((delay) => `${delay / 1000}s`).join(", ")})`, ); + if (currentSource !== "cache") { + log( + `feishu[${accountId}]: requireMention group messages stay gated until bot identity recovery succeeds`, + ); + } void retryBotIdentityProbe(account, accountId, runtime, abortSignal); } diff --git a/extensions/feishu/src/monitor.comment.test.ts b/extensions/feishu/src/monitor.comment.test.ts index def450f9d18f..0b173a70894f 100644 --- a/extensions/feishu/src/monitor.comment.test.ts +++ b/extensions/feishu/src/monitor.comment.test.ts @@ -858,7 +858,7 @@ describe("resolveDriveCommentEventTurn", () => { expect(turn).toBeNull(); }); - it("skips comment notices when bot open_id is unavailable", async () => { + it("uses a mentioned event recipient when startup bot identity is unavailable", async () => { const turn = await resolveDriveCommentEventTurn({ cfg: buildMonitorConfig(), accountId: "default", @@ -867,6 +867,67 @@ describe("resolveDriveCommentEventTurn", () => { createClient: () => makeOpenApiClient({}) as never, }); + expect(turn?.senderId).toBe("ou_509d4d7ace4a9addec2312676ffcba9b"); + }); + + it("uses the event recipient to reject self-authored cold-start notices", async () => { + const turn = await resolveDriveCommentEventTurn({ + cfg: buildMonitorConfig(), + accountId: "default", + event: makeDriveCommentEvent({ + notice_meta: { + ...makeDriveCommentEvent().notice_meta, + from_user_id: { open_id: "ou_bot" }, + }, + }), + botOpenId: undefined, + createClient: () => makeOpenApiClient({}) as never, + }); + + expect(turn).toBeNull(); + }); + + it("prefers startup bot identity over a mismatched event recipient", async () => { + const turn = await resolveDriveCommentEventTurn({ + cfg: buildMonitorConfig(), + accountId: "default", + event: makeDriveCommentEvent({ + notice_meta: { + ...makeDriveCommentEvent().notice_meta, + from_user_id: { open_id: "ou_configured_bot" }, + to_user_id: { open_id: "ou_other_bot" }, + }, + }), + botOpenId: "ou_configured_bot", + createClient: () => makeOpenApiClient({}) as never, + }); + + expect(turn).toBeNull(); + }); + + it.each([ + { + name: "not explicitly mentioned", + event: makeDriveCommentEvent({ is_mentioned: false }), + }, + { + name: "missing recipient identity", + event: makeDriveCommentEvent({ + notice_meta: { + ...makeDriveCommentEvent().notice_meta, + to_user_id: undefined, + }, + }), + }, + ])("skips a cold-start comment notice when $name", async ({ event }) => { + const turn = await resolveDriveCommentEventTurn({ + cfg: buildMonitorConfig(), + accountId: "default", + event, + botOpenId: undefined, + createClient: () => makeOpenApiClient({}) as never, + }); + expect(turn).toBeNull(); }); }); diff --git a/extensions/feishu/src/monitor.comment.ts b/extensions/feishu/src/monitor.comment.ts index cb4f4738eb71..a020dec20cdf 100644 --- a/extensions/feishu/src/monitor.comment.ts +++ b/extensions/feishu/src/monitor.comment.ts @@ -1255,14 +1255,21 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara logger?.(`feishu[${accountId}]: unsupported drive comment notice type ${noticeType}`); return null; } - if (!botOpenId) { + const configuredBotOpenId = botOpenId?.trim(); + const eventRecipientBotOpenId = event.notice_meta?.to_user_id?.open_id?.trim(); + // Mentioned comment notices identify their recipient even when the startup + // identity probe is unavailable. Keep this fallback event-local so an + // unverified envelope can never seed process or persisted bot identity. + const effectiveBotOpenId = + configuredBotOpenId || (event.is_mentioned === true ? eventRecipientBotOpenId : undefined); + if (!effectiveBotOpenId) { logger?.( `feishu[${accountId}]: skipping drive comment notice because bot open_id is unavailable ` + `event=${eventId}`, ); return null; } - if (senderId === botOpenId) { + if (senderId === effectiveBotOpenId) { logger?.( `feishu[${accountId}]: ignoring self-authored drive comment notice event=${eventId} sender=${senderId}`, ); @@ -1280,7 +1287,7 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara fileType, commentId, replyId, - botOpenIds: [botOpenId, event.notice_meta?.to_user_id?.open_id], + botOpenIds: [effectiveBotOpenId, event.notice_meta?.to_user_id?.open_id], timeoutMs: verificationTimeoutMs, logger, accountId, diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index 6b1c655d6562..2eaf4dca41b7 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -9,12 +9,19 @@ import { fetchBotIdentityForMonitor } from "./monitor.startup.js"; const probeFeishuMock = vi.hoisted(() => vi.fn()); const registerFeishuAiAgentMock = vi.hoisted(() => vi.fn()); +const readCachedFeishuBotIdentityMock = vi.hoisted(() => vi.fn()); +const writeCachedFeishuBotIdentityMock = vi.hoisted(() => vi.fn()); vi.mock("./probe.js", () => ({ probeFeishu: probeFeishuMock, registerFeishuAiAgent: registerFeishuAiAgentMock, })); +vi.mock("./bot-identity-cache.js", () => ({ + readCachedFeishuBotIdentity: readCachedFeishuBotIdentityMock, + writeCachedFeishuBotIdentity: writeCachedFeishuBotIdentityMock, +})); + vi.mock("./client.js", async () => { const { createFeishuClientMockModule } = await import("./monitor.test-mocks.js"); return createFeishuClientMockModule(); @@ -30,6 +37,8 @@ beforeAll(async () => { beforeEach(() => { registerFeishuAiAgentMock.mockResolvedValue({ ok: true }); + readCachedFeishuBotIdentityMock.mockReset().mockResolvedValue(null); + writeCachedFeishuBotIdentityMock.mockReset().mockResolvedValue(undefined); }); function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig { @@ -218,7 +227,13 @@ describe("Feishu monitor startup preflight", () => { appId: "cli_alpha", appSecret: "secret_alpha", // pragma: allowlist secret } as never), - ).resolves.toEqual({ botOpenId: "bot_alpha", botName: "Alpha" }); + ).resolves.toEqual({ botOpenId: "bot_alpha", botName: "Alpha", source: "provider" }); + expect(writeCachedFeishuBotIdentityMock).toHaveBeenCalledWith({ + accountId: "alpha", + appId: "cli_alpha", + botOpenId: "bot_alpha", + botName: "Alpha", + }); }); it("keeps standard bot identity when AI-agent registration is unavailable", async () => { @@ -239,7 +254,7 @@ describe("Feishu monitor startup preflight", () => { } as never, { runtime }, ), - ).resolves.toEqual({ botOpenId: "bot_alpha", botName: "Alpha" }); + ).resolves.toEqual({ botOpenId: "bot_alpha", botName: "Alpha", source: "provider" }); await vi.waitFor(() => { expect(runtime.log).toHaveBeenCalledWith( "feishu[alpha]: AI-agent registration unavailable (api-error); continuing with standard bot identity", @@ -247,6 +262,110 @@ describe("Feishu monitor startup preflight", () => { }); }); + it("falls back to cached identity without treating it as a fresh provider result", async () => { + probeFeishuMock.mockResolvedValue({ ok: false, error: "rate limited" }); + readCachedFeishuBotIdentityMock.mockResolvedValue({ + botOpenId: "bot_alpha", + botName: "Alpha", + fetchedAt: "2026-07-22T23:00:00.000Z", + }); + const runtime = createNonExitingRuntimeEnv(); + + await expect( + fetchBotIdentityForMonitor( + { + accountId: "alpha", + appId: "cli_alpha", + appSecret: "rotated_secret_alpha", // pragma: allowlist secret + } as never, + { runtime }, + ), + ).resolves.toEqual({ botOpenId: "bot_alpha", botName: "Alpha", source: "cache" }); + expect(writeCachedFeishuBotIdentityMock).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith( + "feishu[alpha]: using cached provider-verified bot identity while the fresh probe is unavailable", + ); + }); + + it("bypasses cached identity during background provider refresh", async () => { + probeFeishuMock.mockResolvedValue({ ok: false, error: "rate limited" }); + + await expect( + fetchBotIdentityForMonitor( + { + accountId: "alpha", + appId: "cli_alpha", + appSecret: "secret_alpha", // pragma: allowlist secret + } as never, + { allowCachedFallback: false }, + ), + ).resolves.toEqual({}); + expect(readCachedFeishuBotIdentityMock).not.toHaveBeenCalled(); + }); + + it("keeps cache read and write failures best-effort", async () => { + const runtime = createNonExitingRuntimeEnv(); + probeFeishuMock.mockResolvedValueOnce({ + ok: true, + botOpenId: "bot_alpha", + botName: "Alpha", + }); + writeCachedFeishuBotIdentityMock.mockRejectedValueOnce(new Error("state unavailable")); + + await expect( + fetchBotIdentityForMonitor( + { + accountId: "alpha", + appId: "cli_alpha", + appSecret: "secret_alpha", // pragma: allowlist secret + } as never, + { runtime }, + ), + ).resolves.toEqual({ botOpenId: "bot_alpha", botName: "Alpha", source: "provider" }); + + probeFeishuMock.mockResolvedValueOnce({ ok: false, error: "rate limited" }); + readCachedFeishuBotIdentityMock.mockRejectedValueOnce(new Error("state unavailable")); + await expect( + fetchBotIdentityForMonitor( + { + accountId: "alpha", + appId: "cli_alpha", + appSecret: "secret_alpha", // pragma: allowlist secret + } as never, + { runtime }, + ), + ).resolves.toEqual({}); + }); + + it("starts a provider refresh while a cached identity keeps ingress available", async () => { + probeFeishuMock.mockResolvedValue({ ok: false, error: "rate limited" }); + readCachedFeishuBotIdentityMock.mockResolvedValue({ + botOpenId: "bot_alpha", + botName: "Alpha", + fetchedAt: "2026-07-22T23:00:00.000Z", + }); + const abortController = new AbortController(); + const runtime = createNonExitingRuntimeEnv(); + const monitorPromise = monitorFeishuProvider({ + config: buildMultiAccountWebsocketConfig(["alpha"]), + runtime, + abortSignal: abortController.signal, + }); + + try { + await vi.waitFor(() => { + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining( + "feishu[alpha]: bot open_id loaded from cache; starting background provider refresh", + ), + ); + }); + } finally { + abortController.abort(); + await monitorPromise; + } + }); + it("stops sequential preflight when aborted during probe", async () => { const started: string[] = []; probeFeishuMock.mockImplementation( diff --git a/extensions/feishu/src/monitor.startup.ts b/extensions/feishu/src/monitor.startup.ts index f3cf538f5ecd..9f866c6282e9 100644 --- a/extensions/feishu/src/monitor.startup.ts +++ b/extensions/feishu/src/monitor.startup.ts @@ -1,6 +1,10 @@ // Feishu plugin module implements monitor.startup behavior. -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { RuntimeEnv } from "../runtime-api.js"; +import { readCachedFeishuBotIdentity, writeCachedFeishuBotIdentity } from "./bot-identity-cache.js"; import { resolveStartupProbeTimeoutMs } from "./monitor-startup-timeout.js"; import { probeFeishu, registerFeishuAiAgent } from "./probe.js"; import type { ResolvedFeishuAccount } from "./types.js"; @@ -11,11 +15,13 @@ type FetchBotOpenIdOptions = { runtime?: RuntimeEnv; abortSignal?: AbortSignal; timeoutMs?: number; + allowCachedFallback?: boolean; }; export type FeishuMonitorBotIdentity = { botOpenId?: string; botName?: string; + source?: "provider" | "cache"; }; function isTimeoutErrorMessage(message: string | undefined): boolean { @@ -27,6 +33,50 @@ function isAbortErrorMessage(message: string | undefined): boolean { return normalizeLowercaseStringOrEmpty(message).includes("aborted"); } +async function writeProviderBotIdentityCache(params: { + account: ResolvedFeishuAccount; + botOpenId?: string; + botName?: string; + runtime?: RuntimeEnv; +}): Promise { + try { + await writeCachedFeishuBotIdentity({ + accountId: params.account.accountId, + appId: params.account.appId, + botOpenId: params.botOpenId, + botName: params.botName, + }); + } catch { + params.runtime?.log?.( + `feishu[${params.account.accountId}]: bot identity cache write failed; continuing startup`, + ); + } +} + +async function readProviderBotIdentityCache(params: { + account: ResolvedFeishuAccount; + runtime?: RuntimeEnv; +}): Promise { + try { + const cached = await readCachedFeishuBotIdentity({ + accountId: params.account.accountId, + appId: params.account.appId, + }); + if (!cached) { + return {}; + } + params.runtime?.log?.( + `feishu[${params.account.accountId}]: using cached provider-verified bot identity while the fresh probe is unavailable`, + ); + return { botOpenId: cached.botOpenId, botName: cached.botName, source: "cache" }; + } catch { + params.runtime?.log?.( + `feishu[${params.account.accountId}]: bot identity cache read failed; continuing without cached identity`, + ); + return {}; + } +} + export async function fetchBotIdentityForMonitor( account: ResolvedFeishuAccount, options: FetchBotOpenIdOptions = {}, @@ -58,7 +108,17 @@ export async function fetchBotIdentityForMonitor( `feishu[${account.accountId}]: AI-agent registration failed unexpectedly; continuing with standard bot identity`, ); }); - return { botOpenId: result.botOpenId, botName: result.botName }; + await writeProviderBotIdentityCache({ + account, + botOpenId: result.botOpenId, + botName: result.botName, + runtime: options.runtime, + }); + return { + botOpenId: normalizeOptionalString(result.botOpenId), + botName: normalizeOptionalString(result.botName), + source: "provider", + }; } const probeError = result.error ?? undefined; @@ -72,5 +132,8 @@ export async function fetchBotIdentityForMonitor( `feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`, ); } - return {}; + if (options.allowCachedFallback === false) { + return {}; + } + return readProviderBotIdentityCache({ account, runtime: options.runtime }); } diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index 7b8aae3e7d08..009811236b0a 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -82,7 +82,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi } // Probe sequentially so large multi-account startups do not burst Feishu's bot-info endpoint. - const { botOpenId, botName } = await fetchBotIdentityForMonitor(account, { + const { botOpenId, botName, source } = await fetchBotIdentityForMonitor(account, { runtime: opts.runtime, abortSignal: opts.abortSignal, }); @@ -99,7 +99,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi channelRuntime: opts.channelRuntime, runtime: opts.runtime, abortSignal: opts.abortSignal, - botOpenIdSource: { kind: "prefetched", botOpenId, botName }, + botOpenIdSource: { kind: "prefetched", botOpenId, botName, source }, ...(opts.statusSink ? { statusSink: opts.statusSink } : {}), }), );