fix(feishu): preserve bot identity through probe outages

This commit is contained in:
joshavant
2026-07-22 18:07:32 -05:00
committed by Josh Avant
parent 8b1d0278f4
commit 3b5fb27b8d
10 changed files with 547 additions and 25 deletions

View File

@@ -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<string, unknown>(),
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);
});
});

View File

@@ -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<FeishuBotIdentityCacheState>({
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<FeishuBotIdentityCacheState>;
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<CachedFeishuBotIdentity | null> {
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<void> {
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(),
});
}

View File

@@ -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";

View File

@@ -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",
});
});
});

View File

@@ -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);
}

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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(

View File

@@ -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<void> {
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<FeishuMonitorBotIdentity> {
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 });
}

View File

@@ -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 } : {}),
}),
);