diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index 00f41732a10a..0f1e9805a649 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -10,6 +10,8 @@ const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; type CreateFeishuClient = typeof import("./client.js").createFeishuClient; type CreateFeishuWSClient = typeof import("./client.js").createFeishuWSClient; type GetFeishuUserAgent = typeof import("./client.js").getFeishuUserAgent; +type InvalidateFeishuTenantAccessToken = + typeof import("./client.js").invalidateFeishuTenantAccessToken; type ResetFeishuProxyAgentForTest = typeof import("./client.js").resetFeishuProxyAgentForTest; const requestInterceptorState = vi.hoisted(() => { @@ -28,6 +30,15 @@ const clientCtorMock = vi.hoisted(() => return { connected: true }; }), ); +const tenantAccessTokenSymbol = Symbol("tenant-access-token"); +const cacheInstances = vi.hoisted(() => [] as Array<{ set: ReturnType }>); +const defaultCacheCtorMock = vi.hoisted(() => + vi.fn(function defaultCacheCtor() { + const cache = { get: vi.fn(), set: vi.fn().mockResolvedValue(true) }; + cacheInstances.push(cache); + return cache; + }), +); const wsClientCtorMock = vi.hoisted(() => vi.fn(function wsClientCtor() { return { connected: true }; @@ -81,6 +92,7 @@ const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn()); let createFeishuClient: CreateFeishuClient; let createFeishuWSClient: CreateFeishuWSClient; let getFeishuUserAgent: GetFeishuUserAgent; +let invalidateFeishuTenantAccessToken: InvalidateFeishuTenantAccessToken; let resetFeishuProxyAgentForTest: ResetFeishuProxyAgentForTest; let priorProxyEnv: Partial> = {}; @@ -203,6 +215,8 @@ beforeAll(async () => { Domain: { Feishu: 0, Lark: 1 }, LoggerLevel: { info: "info" }, Client: clientCtorMock, + CTenantAccessToken: tenantAccessTokenSymbol, + DefaultCache: defaultCacheCtorMock, WSClient: wsClientCtorMock, EventDispatcher: vi.fn(), defaultHttpInstance: mockBaseHttpInstance, @@ -219,8 +233,13 @@ beforeAll(async () => { ), })); - ({ createFeishuClient, createFeishuWSClient, getFeishuUserAgent, resetFeishuProxyAgentForTest } = - await import("./client.js")); + ({ + createFeishuClient, + createFeishuWSClient, + getFeishuUserAgent, + invalidateFeishuTenantAccessToken, + resetFeishuProxyAgentForTest, + } = await import("./client.js")); }); beforeEach(() => { @@ -258,6 +277,67 @@ afterAll(() => { vi.resetModules(); }); +describe("tenant token cache ownership", () => { + it("shares one account cache across timeout-specific clients and isolates accounts", () => { + createFeishuClient({ + accountId: "cache-a", + appId: "app-a", + appSecret: "secret-a", // pragma: allowlist secret + httpTimeoutMs: 1_000, + }); + createFeishuClient({ + accountId: "cache-a", + appId: "app-a", + appSecret: "secret-a", // pragma: allowlist secret + httpTimeoutMs: 2_000, + }); + createFeishuClient({ + accountId: "cache-b", + appId: "app-a", + appSecret: "secret-a", // pragma: allowlist secret + }); + + const first = readCallOptions(clientCtorMock, -3).cache; + expect(readCallOptions(clientCtorMock, -2).cache).toBe(first); + expect(readCallOptions(clientCtorMock, -1).cache).not.toBe(first); + }); + + it("invalidates only the current account identity and SDK namespace", async () => { + const oldCreds = { + accountId: "rotating", + appId: "app-old", + appSecret: "secret-old", // pragma: allowlist secret + domain: "feishu" as const, + }; + createFeishuClient(oldCreds); + const oldCache = readCallOptions(clientCtorMock).cache as { + set: ReturnType; + }; + const newCreds = { + ...oldCreds, + appId: "app-new", + appSecret: "secret-new", // pragma: allowlist secret + domain: "lark" as const, + }; + createFeishuClient(newCreds); + const newCache = readCallOptions(clientCtorMock).cache as { + set: ReturnType; + }; + + await invalidateFeishuTenantAccessToken(oldCreds); + expect(oldCache.set).not.toHaveBeenCalled(); + + const before = Date.now(); + await invalidateFeishuTenantAccessToken(newCreds); + expect(newCache.set).toHaveBeenCalledWith(tenantAccessTokenSymbol, "", expect.any(Number), { + namespace: "app-new", + }); + const expiry = newCache.set.mock.calls[0]?.[2] as number; + expect(expiry).toBeGreaterThan(0); + expect(expiry).toBeLessThan(before); + }); +}); + describe("Feishu default User-Agent interceptor", () => { it("registers through the public interceptor API and overrides the SDK User-Agent", () => { expect(requestInterceptorState.registered).toBeTypeOf("function"); diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index 525b5fe55526..ec161fdd536e 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -29,7 +29,9 @@ type FeishuClientSdk = Pick< typeof Lark, | "AppType" | "Client" + | "CTenantAccessToken" | "defaultHttpInstance" + | "DefaultCache" | "Domain" | "EventDispatcher" | "LoggerLevel" @@ -39,7 +41,9 @@ type FeishuClientSdk = Pick< const feishuClientSdk: FeishuClientSdk = { AppType: Lark.AppType, Client: Lark.Client, + CTenantAccessToken: Lark.CTenantAccessToken, defaultHttpInstance: Lark.defaultHttpInstance, + DefaultCache: Lark.DefaultCache, Domain: Lark.Domain, EventDispatcher: Lark.EventDispatcher, LoggerLevel: Lark.LoggerLevel, @@ -260,6 +264,34 @@ const clientCache = new Map< config: { appId: string; appSecret: string; domain?: FeishuDomain; httpTimeoutMs: number }; } >(); +const tenantTokenCaches = new Map< + string, + { + cache: Lark.DefaultCache; + config: { appId: string; appSecret: string; domain?: FeishuDomain }; + } +>(); + +function resolveTenantTokenCache( + creds: Required> & + Pick, +): Lark.DefaultCache { + const accountId = creds.accountId ?? "default"; + const cached = tenantTokenCaches.get(accountId); + if ( + cached?.config.appId === creds.appId && + cached.config.appSecret === creds.appSecret && + cached.config.domain === creds.domain + ) { + return cached.cache; + } + const cache = new feishuClientSdk.DefaultCache(); + tenantTokenCaches.set(accountId, { + cache, + config: { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain }, + }); + return cache; +} function resolveSdkDomain(domain: FeishuDomain | undefined): Lark.Domain { // The SDK parses :port in its domain as an API route parameter; custom origins @@ -380,6 +412,7 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client appSecret, appType: feishuClientSdk.AppType.SelfBuild, domain: resolveSdkDomain(domain), + cache: resolveTenantTokenCache({ accountId, appId, appSecret, domain }), httpInstance: createFeishuHttpInstance(defaultHttpTimeoutMs, domain), }); @@ -392,6 +425,26 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client return client; } +export async function invalidateFeishuTenantAccessToken( + creds: FeishuClientCredentials, +): Promise { + const { accountId = "default", appId, appSecret, domain } = creds; + if (!appId || !appSecret) { + return; + } + const cached = tenantTokenCaches.get(accountId); + if ( + cached?.config.appId !== appId || + cached.config.appSecret !== appSecret || + cached.config.domain !== domain + ) { + return; + } + await cached.cache.set(feishuClientSdk.CTenantAccessToken, "", Date.now() - 1, { + namespace: appId, + }); +} + type FeishuWsClientCallbacks = Pick< ConstructorParameters[0], "onError" | "onReady" | "onReconnected" | "onReconnecting" diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index ca5de3eaae5d..e6b56903ddd4 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -88,9 +88,19 @@ function createFeishuApiError( return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error }); } +const FEISHU_INVALID_TENANT_TOKEN_CODE = 99991663; const FEISHU_SEND_MAX_RETRIES = 2; const FEISHU_SEND_RETRY_BASE_MS = 500; +function isFeishuInvalidTenantToken(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const response = isRecord(value.response) ? value.response : undefined; + const data = isRecord(response?.data) ? response.data : value; + return data.code === FEISHU_INVALID_TENANT_TOKEN_CODE; +} + export async function requestFeishuApi( request: () => Promise, errorPrefix: string, @@ -99,36 +109,51 @@ export async function requestFeishuApi( includeNestedErrorLogId?: boolean; /** Base retry delay in ms; doubles on the second retry. @internal */ retryDelayMs?: number; + invalidateTenantToken?: () => Promise; } = {}, ): Promise { - try { - return await retryAsync( - async () => { - const result = await request(); - // Feishu SDK may fulfill with a rate-limit body (e.g. { code: 11232, ... }) - // instead of throwing. Rethrow it in the AxiosError response shape so - // getFeishuSendRateLimitCode classifies it retryable and exhaustion - // wraps it exactly like an SDK throw. - const fulfilledRateLimit = getFeishuSendRateLimitCodeFromResponse(result); - if (fulfilledRateLimit !== undefined) { - throw Object.assign( - new Error(`Request fulfilled with rate-limit code ${fulfilledRateLimit}`), - { response: { status: 200, data: result } }, + let authRecoveryAttempted = false; + while (true) { + try { + return await retryAsync( + async () => { + const result = await request(); + const fulfilledCode = + getFeishuSendRateLimitCodeFromResponse(result) ?? + (isFeishuInvalidTenantToken(result) ? FEISHU_INVALID_TENANT_TOKEN_CODE : undefined); + if (fulfilledCode !== undefined) { + throw Object.assign(new Error(`Request fulfilled with code ${fulfilledCode}`), { + response: { status: 200, data: result }, + }); + } + return result; + }, + { + attempts: FEISHU_SEND_MAX_RETRIES + 1, + minDelayMs: options.retryDelayMs ?? FEISHU_SEND_RETRY_BASE_MS, + shouldRetry: (error) => getFeishuSendRateLimitCode(error) !== undefined, + }, + ); + } catch (error) { + if ( + !authRecoveryAttempted && + options.invalidateTenantToken && + isFeishuInvalidTenantToken(error) + ) { + authRecoveryAttempted = true; + try { + await options.invalidateTenantToken(); + } catch (invalidationError) { + const original = createFeishuApiError(error, errorPrefix, options); + throw new AggregateError( + [original, invalidationError], + `${original.message}; tenant token invalidation failed: ${String(invalidationError)}`, ); } - return result; - }, - { - attempts: FEISHU_SEND_MAX_RETRIES + 1, - // With a 2-retry budget the core exponential schedule (1x, 2x base) - // matches the previous linear attempt*base backoff exactly; revisit - // the delay curve if FEISHU_SEND_MAX_RETRIES grows. - minDelayMs: options.retryDelayMs ?? FEISHU_SEND_RETRY_BASE_MS, - shouldRetry: (error) => getFeishuSendRateLimitCode(error) !== undefined, - }, - ); - } catch (error) { - throw createFeishuApiError(error, errorPrefix, options); + continue; + } + throw createFeishuApiError(error, errorPrefix, options); + } } } diff --git a/extensions/feishu/src/dm-delivery-loopback.test.ts b/extensions/feishu/src/dm-delivery-loopback.test.ts index a2d2bde61128..f23740375506 100644 --- a/extensions/feishu/src/dm-delivery-loopback.test.ts +++ b/extensions/feishu/src/dm-delivery-loopback.test.ts @@ -19,6 +19,141 @@ type RecordedFeishuRequest = { }; describe("Feishu DM delivery over the real Lark SDK", () => { + it("reacquires one stale primary token without touching the secondary account", async () => { + const tokenCalls = new Map(); + const messageCalls = new Map(); + const visibleSends = new Map(); + const server = createServer((request, response) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as Record< + string, + unknown + >; + const sendJson = (status: number, payload: unknown) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); + }; + + if (url.pathname === "/open-apis/auth/v3/tenant_access_token/internal") { + const appId = String(body.app_id); + const count = (tokenCalls.get(appId) ?? 0) + 1; + tokenCalls.set(appId, count); + sendJson(200, { + code: 0, + msg: "ok", + tenant_access_token: + appId === "cli_stale_primary" && count === 1 + ? "tat-primary-stale" + : appId === "cli_stale_primary" + ? "tat-primary-fresh" + : "tat-secondary-valid", + expire: 7200, + }); + return; + } + + const authorization = String(request.headers.authorization); + messageCalls.set(authorization, (messageCalls.get(authorization) ?? 0) + 1); + if (authorization === "Bearer tat-primary-stale") { + sendJson(401, { code: 99991663, msg: "invalid tenant access token" }); + return; + } + const receiveId = String(body.receive_id); + visibleSends.set(receiveId, (visibleSends.get(receiveId) ?? 0) + 1); + sendJson(200, { + code: 0, + msg: "success", + data: { message_id: `om_${receiveId}` }, + }); + })().catch((error: unknown) => { + response.writeHead(500, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: String(error) })); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const loopbackOrigin = `http://127.0.0.1:${address.port}`; + const interceptor = Lark.defaultHttpInstance.interceptors.request.use( + (options) => { + const upstream = new URL(options.url ?? ""); + if (upstream.hostname === "open.feishu.cn") { + options.url = new URL( + `${upstream.pathname}${upstream.search}`, + loopbackOrigin, + ).toString(); + } + return options; + }, + undefined, + { synchronous: true }, + ); + + try { + const cfg = { + channels: { + feishu: { + enabled: true, + appId: "cli_stale_primary", + appSecret: "loopback-placeholder", // pragma: allowlist secret + domain: "feishu", + accounts: { + secondary: { + appId: "cli_valid_secondary", + appSecret: "loopback-placeholder", // pragma: allowlist secret + domain: "feishu", + }, + }, + }, + }, + } as ClawdbotConfig; + + await expect( + sendMessageFeishu({ cfg, to: "chat:oc_primary", text: "primary" }), + ).resolves.toMatchObject({ messageId: "om_oc_primary" }); + await expect( + sendMessageFeishu({ + cfg, + to: "chat:oc_secondary", + text: "secondary", + accountId: "secondary", + }), + ).resolves.toMatchObject({ messageId: "om_oc_secondary" }); + + expect(tokenCalls).toEqual( + new Map([ + ["cli_stale_primary", 2], + ["cli_valid_secondary", 1], + ]), + ); + expect(messageCalls).toEqual( + new Map([ + ["Bearer tat-primary-stale", 1], + ["Bearer tat-primary-fresh", 1], + ["Bearer tat-secondary-valid", 1], + ]), + ); + expect(visibleSends).toEqual( + new Map([ + ["oc_primary", 1], + ["oc_secondary", 1], + ]), + ); + } finally { + Lark.defaultHttpInstance.interceptors.request.eject(interceptor); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("authenticates conversation replies and preserves account, group, thread, and error boundaries", async () => { const requests: RecordedFeishuRequest[] = []; const server = createServer((request, response) => { diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 951a135d46be..a77cb5a014c9 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import type { ClawdbotConfig } from "../runtime-api.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const invalidateFeishuTenantAccessTokenMock = vi.hoisted(() => vi.fn()); const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); @@ -27,7 +28,10 @@ const validPngImage = Buffer.from( "hex", ); -vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, + invalidateFeishuTenantAccessToken: invalidateFeishuTenantAccessTokenMock, +})); vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock, diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 3e43d8e4af4e..50571dc56617 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -24,7 +24,7 @@ import { } from "openclaw/plugin-sdk/temp-path"; import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; +import { createFeishuClient, invalidateFeishuTenantAccessToken } from "./client.js"; import { requestFeishuApi } from "./comment-shared.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { saveMediaStreamWithIdleTimeout } from "./media-chunk-idle.js"; @@ -101,6 +101,7 @@ type SaveMessageResourceResult = { function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): { account: ReturnType; client: ReturnType; + invalidateTenantToken: () => Promise; } { const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) { @@ -113,6 +114,7 @@ function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accoun ...account, httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, }), + invalidateTenantToken: () => invalidateFeishuTenantAccessToken(account), }; } @@ -454,7 +456,7 @@ async function uploadImageFeishu(params: { accountId?: string; }): Promise { const { cfg, image, imageType = "message", accountId } = params; - const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); + const { client, invalidateTenantToken } = createConfiguredFeishuMediaClient({ cfg, accountId }); // SDK accepts Buffer directly. Keep string path support on this helper, but // verify the path as a regular local file before uploading it. @@ -471,7 +473,7 @@ async function uploadImageFeishu(params: { }, }), "Feishu image upload failed", - { includeNestedErrorLogId: true }, + { includeNestedErrorLogId: true, invalidateTenantToken }, ); return { @@ -509,7 +511,7 @@ async function uploadFileFeishu(params: { accountId?: string; }): Promise { const { cfg, file, fileName, fileType, duration, accountId } = params; - const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); + const { client, invalidateTenantToken } = createConfiguredFeishuMediaClient({ cfg, accountId }); // SDK accepts Buffer directly. Keep string path support on this helper, but // verify the path as a regular local file before uploading it. @@ -530,7 +532,7 @@ async function uploadFileFeishu(params: { }, }), "Feishu file upload failed", - { includeNestedErrorLogId: true }, + { includeNestedErrorLogId: true, invalidateTenantToken }, ); return { @@ -562,7 +564,7 @@ async function sendImageFeishu(params: { allowTopLevelReplyFallback, accountId, } = params; - const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + const { client, receiveId, receiveIdType, invalidateTenantToken } = resolveFeishuSendTarget({ cfg, to, accountId, @@ -584,6 +586,7 @@ async function sendImageFeishu(params: { }, directErrorPrefix: "Feishu image send failed", replyErrorPrefix: "Feishu image reply failed", + invalidateTenantToken, }); } @@ -598,7 +601,7 @@ async function sendImageFeishu(params: { }, }), "Feishu image send failed", - { includeNestedErrorLogId: true }, + { includeNestedErrorLogId: true, invalidateTenantToken }, ); assertFeishuMessageApiSuccess(response, "Feishu image send failed"); return toFeishuSendResult(response, receiveId, "media", "Feishu image send failed"); @@ -628,7 +631,7 @@ async function sendFileFeishu(params: { accountId, } = params; const msgType = params.msgType ?? "file"; - const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + const { client, receiveId, receiveIdType, invalidateTenantToken } = resolveFeishuSendTarget({ cfg, to, accountId, @@ -650,6 +653,7 @@ async function sendFileFeishu(params: { }, directErrorPrefix: "Feishu file send failed", replyErrorPrefix: "Feishu file reply failed", + invalidateTenantToken, }); } @@ -664,7 +668,7 @@ async function sendFileFeishu(params: { }, }), "Feishu file send failed", - { includeNestedErrorLogId: true }, + { includeNestedErrorLogId: true, invalidateTenantToken }, ); assertFeishuMessageApiSuccess(response, "Feishu file send failed"); return toFeishuSendResult( diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 385870eba357..a5a30c00029e 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; type StreamingSessionStub = { active: boolean; credentials: unknown; + deps: { invalidateTenantToken?: () => Promise } | undefined; start: ReturnType; update: ReturnType; close: ReturnType; @@ -27,6 +28,7 @@ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const invalidateFeishuTenantAccessTokenMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" }))); const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {})); @@ -107,7 +109,10 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { resolvePinnedHostnameWithPolicy: resolvePinnedHostnameWithPolicyMock, }; }); -vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, + invalidateFeishuTenantAccessToken: invalidateFeishuTenantAccessTokenMock, +})); vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock })); vi.mock("./typing.js", () => ({ addTypingIndicator: addTypingIndicatorMock, @@ -131,6 +136,7 @@ vi.mock("./streaming-card.js", () => { FeishuStreamingSession: class { active = false; credentials: unknown; + deps: { invalidateTenantToken?: () => Promise } | undefined; start = vi.fn(async () => { this.active = true; }); @@ -149,8 +155,14 @@ vi.mock("./streaming-card.js", () => { }); isActive = vi.fn(() => this.active); - constructor(_client: unknown, credentials: unknown) { + constructor( + _client: unknown, + credentials: unknown, + _log?: (message: string) => void, + deps?: { invalidateTenantToken?: () => Promise }, + ) { this.credentials = credentials; + this.deps = deps; streamingInstances.push(this); } }, @@ -410,6 +422,18 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ); } + it("propagates the account token invalidator into streaming sessions", async () => { + const { options } = createDispatcherHarness(); + await options.onReplyStart?.(); + await options.deliver({ text: "hello" }, { kind: "final" }); + + const invalidate = requireStreamingInstance(0).deps?.invalidateTenantToken; + await invalidate?.(); + expect(invalidateFeishuTenantAccessTokenMock).toHaveBeenCalledWith( + resolveFeishuAccountMock.mock.results[0]?.value, + ); + }); + it("skips typing indicator when account typingIndicator is disabled", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 9ef31a9c11cc..30613acd02ed 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -22,7 +22,7 @@ import { import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js"; -import { createFeishuClient } from "./client.js"; +import { createFeishuClient, invalidateFeishuTenantAccessToken } from "./client.js"; import { resolveFeishuIdentityEmoji } from "./identity-header.js"; import { chunkFeishuPostMarkdown, materializeFeishuPostMarkdownSoftBreaks } from "./markdown.js"; import { buildFeishuMediaFallbackText } from "./media-fallback.js"; @@ -460,8 +460,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP return; } - const session = new FeishuStreamingSession(createFeishuClient(account), creds, (message) => - params.runtime.log?.(`feishu[${account.accountId}] ${message}`), + const session = new FeishuStreamingSession( + createFeishuClient(account), + creds, + (message) => params.runtime.log?.(`feishu[${account.accountId}] ${message}`), + { invalidateTenantToken: () => invalidateFeishuTenantAccessToken(account) }, ); const generation = ++streamingGeneration; streaming = session; diff --git a/extensions/feishu/src/send-target.test.ts b/extensions/feishu/src/send-target.test.ts index c28639269e61..e2a0bd5eb7e0 100644 --- a/extensions/feishu/src/send-target.test.ts +++ b/extensions/feishu/src/send-target.test.ts @@ -4,6 +4,7 @@ import type { ClawdbotConfig } from "../runtime-api.js"; const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const invalidateFeishuTenantAccessTokenMock = vi.hoisted(() => vi.fn()); vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock, @@ -12,6 +13,7 @@ vi.mock("./accounts.js", () => ({ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock, + invalidateFeishuTenantAccessToken: invalidateFeishuTenantAccessTokenMock, })); let resolveFeishuSendTarget: typeof import("./send-target.js").resolveFeishuSendTarget; @@ -39,7 +41,7 @@ describe("resolveFeishuSendTarget", () => { createFeishuClientMock.mockReset().mockReturnValue(client); }); - it("keeps explicit group targets as chat_id even when ID shape is ambiguous", () => { + it("keeps explicit group targets as chat_id and propagates account invalidation", async () => { const result = resolveFeishuSendTarget({ cfg, to: "feishu:group:group_room_alpha", @@ -48,6 +50,10 @@ describe("resolveFeishuSendTarget", () => { expect(result.receiveId).toBe("group_room_alpha"); expect(result.receiveIdType).toBe("chat_id"); expect(result.client).toBe(client); + await result.invalidateTenantToken(); + expect(invalidateFeishuTenantAccessTokenMock).toHaveBeenCalledWith( + resolveFeishuAccountMock.mock.results[0]?.value, + ); }); it("maps dm-prefixed open IDs to open_id", () => { diff --git a/extensions/feishu/src/send-target.ts b/extensions/feishu/src/send-target.ts index a222c1f90690..fa59f1a0f103 100644 --- a/extensions/feishu/src/send-target.ts +++ b/extensions/feishu/src/send-target.ts @@ -1,11 +1,12 @@ // Feishu plugin module implements send target behavior. import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; +import { createFeishuClient, invalidateFeishuTenantAccessToken } from "./client.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; type FeishuSendTarget = { client: ReturnType; + invalidateTenantToken: () => Promise; receiveId: string; receiveIdType: ReturnType; }; @@ -30,6 +31,7 @@ export function resolveFeishuSendTarget(params: { const withoutProviderPrefix = target.replace(/^(feishu|lark):/i, ""); return { client, + invalidateTenantToken: () => invalidateFeishuTenantAccessToken(account), receiveId, receiveIdType: resolveReceiveIdType(withoutProviderPrefix), }; diff --git a/extensions/feishu/src/send.retry.test.ts b/extensions/feishu/src/send.retry.test.ts index 4c04fff27599..0ad731ad069e 100644 --- a/extensions/feishu/src/send.retry.test.ts +++ b/extensions/feishu/src/send.retry.test.ts @@ -321,3 +321,81 @@ describe("requestFeishuApi — retry on fulfilled rate-limit body (no throw)", ( expect(request).toHaveBeenCalledTimes(3); }); }); + +describe("requestFeishuApi — invalid tenant token recovery", () => { + it("invalidates and retries once for thrown 99991663", async () => { + const invalidateTenantToken = vi.fn().mockResolvedValue(undefined); + const request = vi + .fn() + .mockRejectedValueOnce(axiosError(99991663)) + .mockResolvedValueOnce({ code: 0 }); + + await expect( + requestFeishuApi(request, "prefix", { ...NO_DELAY, invalidateTenantToken }), + ).resolves.toEqual({ code: 0 }); + expect(invalidateTenantToken).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("invalidates and retries once for fulfilled 99991663", async () => { + const invalidateTenantToken = vi.fn().mockResolvedValue(undefined); + const request = vi + .fn() + .mockResolvedValueOnce({ code: 99991663, msg: "invalid tenant token" }) + .mockResolvedValueOnce({ code: 0 }); + + await requestFeishuApi(request, "prefix", { ...NO_DELAY, invalidateTenantToken }); + expect(invalidateTenantToken).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("surfaces persistent 99991663 after one recovery", async () => { + const invalidateTenantToken = vi.fn().mockResolvedValue(undefined); + const request = vi.fn().mockRejectedValue(axiosError(99991663)); + + await expect( + requestFeishuApi(request, "prefix", { ...NO_DELAY, invalidateTenantToken }), + ).rejects.toThrow(/99991663/); + expect(invalidateTenantToken).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); + }); + + it.each([99991664, 401])("does not recover unrelated auth signal %s", async (signal) => { + const invalidateTenantToken = vi.fn().mockResolvedValue(undefined); + const error = + signal === 401 + ? Object.assign(new Error("unauthorized"), { response: { status: 401, data: {} } }) + : axiosError(signal); + const request = vi.fn().mockRejectedValue(error); + + await expect( + requestFeishuApi(request, "prefix", { ...NO_DELAY, invalidateTenantToken }), + ).rejects.toThrow(); + expect(invalidateTenantToken).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("preserves the Feishu diagnostic when invalidation fails", async () => { + const invalidateTenantToken = vi.fn().mockRejectedValue(new Error("cache unavailable")); + + await expect( + requestFeishuApi(vi.fn().mockRejectedValue(axiosError(99991663)), "send failed", { + ...NO_DELAY, + invalidateTenantToken, + }), + ).rejects.toThrow(/99991663.*cache unavailable/); + }); + + it("restarts the complete rate-limit loop after invalidation", async () => { + const invalidateTenantToken = vi.fn().mockResolvedValue(undefined); + const request = vi + .fn() + .mockRejectedValueOnce(axiosError(230020)) + .mockRejectedValueOnce(axiosError(99991663)) + .mockResolvedValueOnce({ code: 0 }); + + await requestFeishuApi(request, "prefix", { ...NO_DELAY, invalidateTenantToken }); + expect(request).toHaveBeenCalledTimes(3); + expect(invalidateTenantToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 8366433bff67..abaf580b0952 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -114,6 +114,7 @@ async function sendFallbackDirect( msgType: string; }, errorPrefix: string, + invalidateTenantToken?: () => Promise, ): Promise { const response = await requestFeishuApi( () => @@ -126,7 +127,7 @@ async function sendFallbackDirect( }, }), errorPrefix, - { includeNestedErrorLogId: true }, + { includeNestedErrorLogId: true, invalidateTenantToken }, ); assertFeishuMessageApiSuccess(response, errorPrefix); return toFeishuSendResult( @@ -153,10 +154,16 @@ export async function sendReplyOrFallbackDirect( }; directErrorPrefix: string; replyErrorPrefix: string; + invalidateTenantToken?: () => Promise; }, ): Promise { if (!params.replyToMessageId) { - return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + return sendFallbackDirect( + client, + params.directParams, + params.directErrorPrefix, + params.invalidateTenantToken, + ); } const replyTargetFallbackError = @@ -179,7 +186,7 @@ export async function sendReplyOrFallbackDirect( }, }), params.replyErrorPrefix, - { includeNestedErrorLogId: true }, + { includeNestedErrorLogId: true, invalidateTenantToken: params.invalidateTenantToken }, ); } catch (err) { if (!isWithdrawnReplyError(err)) { @@ -188,13 +195,23 @@ export async function sendReplyOrFallbackDirect( if (replyTargetFallbackError) { throw replyTargetFallbackError; } - return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + return sendFallbackDirect( + client, + params.directParams, + params.directErrorPrefix, + params.invalidateTenantToken, + ); } if (shouldFallbackFromReplyTarget(response)) { if (replyTargetFallbackError) { throw replyTargetFallbackError; } - return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + return sendFallbackDirect( + client, + params.directParams, + params.directErrorPrefix, + params.invalidateTenantToken, + ); } assertFeishuMessageApiSuccess(response, params.replyErrorPrefix); return toFeishuSendResult( @@ -466,7 +483,11 @@ export async function sendMessageFeishu( mentions, accountId, } = params; - const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); + const { client, receiveId, receiveIdType, invalidateTenantToken } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "feishu", @@ -490,6 +511,7 @@ export async function sendMessageFeishu( directParams, directErrorPrefix: "Feishu send failed", replyErrorPrefix: "Feishu reply failed", + invalidateTenantToken, }); } @@ -507,7 +529,11 @@ type SendFeishuCardParams = { export async function sendCardFeishu(params: SendFeishuCardParams): Promise { const { cfg, to, card, replyToMessageId, replyInThread, allowTopLevelReplyFallback, accountId } = params; - const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); + const { client, receiveId, receiveIdType, invalidateTenantToken } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const content = JSON.stringify(card); const directParams = { receiveId, receiveIdType, content, msgType: "interactive" }; @@ -520,6 +546,7 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise { return { authTokens, client, deps }; } + type CardKitRequest = (params: { + path: string; + method: "POST" | "PUT" | "PATCH"; + body: string; + auditContext: string; + action: string; + }) => Promise<{ code?: number }>; + + function requestCardKit(session: FeishuStreamingSession): CardKitRequest { + return (session as unknown as { requestCardKit: CardKitRequest }).requestCardKit.bind(session); + } + + it.each([ + ["create", "/cardkit/v1/cards", "POST"], + ["update", "/cardkit/v1/cards/card/elements/content/content", "PUT"], + ["replace", "/cardkit/v1/cards/card/elements/content", "PUT"], + ["note", "/cardkit/v1/cards/card/elements/note/content", "PUT"], + ["close", "/cardkit/v1/cards/card/settings", "PATCH"], + ] as const)( + "recovers exact 99991663 once for CardKit %s and releases every request", + async (action, path, method) => { + let authCalls = 0; + let cardCalls = 0; + const releases: Array> = []; + const guardedFetch = vi.fn(async (params: { url: string }) => { + const release = vi.fn().mockResolvedValue(undefined); + releases.push(release); + if (params.url.includes("/auth/")) { + authCalls += 1; + return { + response: jsonResponse({ + code: 0, + msg: "ok", + tenant_access_token: `token-${authCalls}`, + expire: 7200, + }), + release, + }; + } + cardCalls += 1; + return { + response: jsonResponse( + cardCalls === 1 ? { code: 99991663, msg: "invalid token" } : { code: 0, msg: "ok" }, + ), + release, + }; + }); + const session = new FeishuStreamingSession( + {} as never, + { appId: `app-${action}`, appSecret: "secret" }, + undefined, + { guardedFetch: guardedFetch as never }, + ); + + await expect( + requestCardKit(session)({ + path, + method, + body: "{}", + auditContext: `test.${action}`, + action, + }), + ).resolves.toMatchObject({ code: 0 }); + expect({ authCalls, cardCalls }).toEqual({ authCalls: 2, cardCalls: 2 }); + expect(releases).toHaveLength(4); + expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); + }, + ); + + it.each([99991663, 99991664])( + "keeps persistent or unrelated CardKit auth code %s terminal after the allowed recovery", + async (code) => { + let authCalls = 0; + let cardCalls = 0; + const guardedFetch = vi.fn(async (params: { url: string }) => { + if (params.url.includes("/auth/")) { + authCalls += 1; + return { + response: jsonResponse({ + code: 0, + msg: "ok", + tenant_access_token: `token-${authCalls}`, + expire: 7200, + }), + release: vi.fn(), + }; + } + cardCalls += 1; + return { response: jsonResponse({ code, msg: "auth error" }), release: vi.fn() }; + }); + const session = new FeishuStreamingSession( + {} as never, + { appId: `app-terminal-${code}`, appSecret: "secret" }, + undefined, + { guardedFetch: guardedFetch as never }, + ); + + await expect( + requestCardKit(session)({ + path: "/cardkit/v1/cards", + method: "POST", + body: "{}", + auditContext: "test.terminal", + action: "create", + }), + ).rejects.toThrow(String(code)); + expect(cardCalls).toBe(code === 99991663 ? 2 : 1); + expect(authCalls).toBe(code === 99991663 ? 2 : 1); + }, + ); + + it("isolates raw CardKit tokens by domain and app id", async () => { + const authCalls = [0, 0]; + const sessions = ["feishu", "lark"].map((domain, index) => { + const guardedFetch = vi.fn(async (params: { url: string }) => { + if (params.url.includes("/auth/")) { + authCalls[index] = (authCalls[index] ?? 0) + 1; + return { + response: jsonResponse({ + code: 0, + msg: "ok", + tenant_access_token: `token-${domain}`, + expire: 7200, + }), + release: vi.fn(), + }; + } + return { response: jsonResponse({ code: 0 }), release: vi.fn() }; + }); + return new FeishuStreamingSession( + {} as never, + { appId: "same-app", appSecret: "secret", domain: domain as "feishu" | "lark" }, + undefined, + { guardedFetch: guardedFetch as never }, + ); + }); + + for (const session of sessions) { + await requestCardKit(session)({ + path: "/cardkit/v1/cards", + method: "POST", + body: "{}", + auditContext: "test.isolation", + action: "create", + }); + } + expect(authCalls).toEqual([1, 1]); + }); + it("rejects oversized streaming tenant-token JSON before buffering the full body", async () => { let streamState: | { diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 88c00f972b92..ae0c707ecaba 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -34,15 +34,18 @@ type CardState = { }; type FeishuStreamingFetch = typeof fetch; +type FeishuGuardedFetch = typeof fetchWithSsrFGuard; type FeishuStreamingDeps = { /** Override fetch for tests while preserving the real SSRF guard path. */ fetchImpl?: FeishuStreamingFetch; /** Override hostname lookup for hermetic SSRF-guard tests. */ lookupFn?: LookupFn; + invalidateTenantToken?: () => Promise; + guardedFetch?: FeishuGuardedFetch; }; -type CardKitResponse = { code?: number; msg?: string }; +type CardKitResponse = { code?: number; msg?: string; data?: unknown }; type FeishuStreamingCloseResult = { visibleReplySent: boolean; @@ -90,6 +93,14 @@ const FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS = 7200; // Token cache (keyed by domain + appId) const tokenCache = new Map(); +function resolveStreamingTokenCacheKey(creds: Credentials): string { + return `${creds.domain ?? "feishu"}|${creds.appId}`; +} + +function invalidateStreamingToken(creds: Credentials): void { + tokenCache.delete(resolveStreamingTokenCacheKey(creds)); +} + function resolveStreamingTokenExpiresAt(value: unknown, nowMs = Date.now()): number { const now = resolveDateTimestampMs(nowMs); if (typeof value === "number" && Number.isFinite(value) && value <= 0) { @@ -137,23 +148,8 @@ function cancelUnreadResponseBody(response: Response): void { } } -async function assertSuccessfulCardKitResponse( - response: Response, - auditContext: string, - action: string, -): Promise { - if (!response.ok) { - cancelUnreadResponseBody(response); - throw new Error(`${action} failed with HTTP ${response.status}`); - } - const data = await readFeishuJsonResponse(response, auditContext); - if (data.code !== 0) { - throw new Error(`${action} failed: ${data.msg ?? "unknown error"} (code=${String(data.code)})`); - } -} - async function getToken(creds: Credentials, deps?: FeishuStreamingDeps): Promise { - const key = `${creds.domain ?? "feishu"}|${creds.appId}`; + const key = resolveStreamingTokenCacheKey(creds); const cached = tokenCache.get(key); const rawNow = Date.now(); const hasValidClock = asDateTimestampMs(rawNow) !== undefined; @@ -163,7 +159,7 @@ async function getToken(creds: Credentials, deps?: FeishuStreamingDeps): Promise return cached.token; } - const { response, release } = await fetchWithSsrFGuard({ + const { response, release } = await (deps?.guardedFetch ?? fetchWithSsrFGuard)({ url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, init: { method: "POST", @@ -260,6 +256,8 @@ export class FeishuStreamingSession { private updateThrottleMs = STREAMING_UPDATE_THROTTLE_MS; private fetchImpl?: FeishuStreamingFetch; private lookupFn?: LookupFn; + private invalidateTenantToken?: () => Promise; + private guardedFetch: FeishuGuardedFetch; constructor( client: Client, @@ -272,6 +270,70 @@ export class FeishuStreamingSession { this.log = log; this.fetchImpl = deps?.fetchImpl; this.lookupFn = deps?.lookupFn; + this.invalidateTenantToken = deps?.invalidateTenantToken; + this.guardedFetch = deps?.guardedFetch ?? fetchWithSsrFGuard; + } + + private async requestCardKit(params: { + path: string; + method: "POST" | "PUT" | "PATCH"; + body: string; + auditContext: string; + action: string; + }): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + const { response, release } = await this.guardedFetch({ + url: `${resolveApiBase(this.creds.domain)}${params.path}`, + init: { + method: params.method, + headers: { + Authorization: `Bearer ${await getToken(this.creds, { + fetchImpl: this.fetchImpl, + lookupFn: this.lookupFn, + guardedFetch: this.guardedFetch, + })}`, + "Content-Type": "application/json", + "User-Agent": getFeishuUserAgent(), + }, + body: params.body, + }, + fetchImpl: this.fetchImpl, + lookupFn: this.lookupFn, + policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + auditContext: params.auditContext, + timeoutMs: this.creds.httpTimeoutMs ?? FEISHU_HTTP_TIMEOUT_MS, + }); + try { + let data: T; + try { + data = await readFeishuJsonResponse(response, params.auditContext); + } catch (error) { + if (!response.ok) { + cancelUnreadResponseBody(response); + throw new Error(`${params.action} failed with HTTP ${response.status}`, { + cause: error, + }); + } + throw error; + } + if (data.code === 99991663 && attempt === 0) { + invalidateStreamingToken(this.creds); + continue; + } + if (!response.ok) { + throw new Error(`${params.action} failed with HTTP ${response.status}`); + } + if (data.code !== 0) { + throw new Error( + `${params.action} failed: ${data.msg ?? "unknown error"} (code=${String(data.code)})`, + ); + } + return data; + } finally { + await release(); + } + } + throw new Error(`${params.action} failed after tenant token recovery`); } async start( @@ -283,7 +345,6 @@ export class FeishuStreamingSession { return; } - const apiBase = resolveApiBase(this.creds.domain); const elements: Record[] = [ { tag: "markdown", content: "", element_id: "content" }, ]; @@ -312,41 +373,18 @@ export class FeishuStreamingSession { } // Create card entity - const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({ - url: `${apiBase}/cardkit/v1/cards`, - init: { - method: "POST", - headers: { - Authorization: `Bearer ${await getToken(this.creds, { - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - })}`, - "Content-Type": "application/json", - "User-Agent": getFeishuUserAgent(), - }, - body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }), - }, - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, - auditContext: "feishu.streaming-card.create", - timeoutMs: this.creds.httpTimeoutMs ?? FEISHU_HTTP_TIMEOUT_MS, - }); - let createData: { + const createData = await this.requestCardKit<{ code: number; msg: string; data?: { card_id: string }; - }; - try { - if (!createRes.ok) { - cancelUnreadResponseBody(createRes); - throw new Error(`Create card request failed with HTTP ${createRes.status}`); - } - createData = await readFeishuJsonResponse(createRes, "feishu.streaming-card.create"); - } finally { - await releaseCreate(); - } - if (createData.code !== 0 || !createData.data?.card_id) { + }>({ + path: "/cardkit/v1/cards", + method: "POST", + body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }), + auditContext: "feishu.streaming-card.create", + action: "Create card request", + }); + if (!createData.data?.card_id) { throw new Error(`Create card failed: ${createData.msg}`); } const cardId = createData.data.card_id; @@ -371,6 +409,7 @@ export class FeishuStreamingSession { }, }), "Send card failed", + { invalidateTenantToken: this.invalidateTenantToken }, ); } else if (sendMode === "root_create") { // root_id is undeclared in the SDK types but accepted at runtime @@ -384,6 +423,7 @@ export class FeishuStreamingSession { ), }), "Send card failed", + { invalidateTenantToken: this.invalidateTenantToken }, ); } else { sendRes = await requestFeishuApi( @@ -397,6 +437,7 @@ export class FeishuStreamingSession { }, }), "Send card failed", + { invalidateTenantToken: this.invalidateTenantToken }, ); } if (sendRes.code !== 0 || !sendRes.data?.message_id) { @@ -421,42 +462,19 @@ export class FeishuStreamingSession { if (!this.state) { return false; } - const apiBase = resolveApiBase(this.creds.domain); this.state.sequence += 1; try { - const { response, release } = await fetchWithSsrFGuard({ - url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, - init: { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds, { - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - })}`, - "Content-Type": "application/json", - "User-Agent": getFeishuUserAgent(), - }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }, - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + await this.requestCardKit({ + path: `/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, + method: "PUT", + body: JSON.stringify({ + content: text, + sequence: this.state.sequence, + uuid: `s_${this.state.cardId}_${this.state.sequence}`, + }), auditContext: "feishu.streaming-card.update", - timeoutMs: this.creds.httpTimeoutMs ?? FEISHU_HTTP_TIMEOUT_MS, + action: "Update card content", }); - try { - await assertSuccessfulCardKitResponse( - response, - "feishu.streaming-card.update", - "Update card content", - ); - } finally { - await release(); - } return true; } catch (error) { onError?.(error); @@ -471,42 +489,19 @@ export class FeishuStreamingSession { if (!this.state) { return false; } - const apiBase = resolveApiBase(this.creds.domain); this.state.sequence += 1; try { - const { response, release } = await fetchWithSsrFGuard({ - url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content`, - init: { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds, { - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - })}`, - "Content-Type": "application/json", - "User-Agent": getFeishuUserAgent(), - }, - body: JSON.stringify({ - element: JSON.stringify({ tag: "markdown", content: text, element_id: "content" }), - sequence: this.state.sequence, - uuid: `r_${this.state.cardId}_${this.state.sequence}`, - }), - }, - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + await this.requestCardKit({ + path: `/cardkit/v1/cards/${this.state.cardId}/elements/content`, + method: "PUT", + body: JSON.stringify({ + element: JSON.stringify({ tag: "markdown", content: text, element_id: "content" }), + sequence: this.state.sequence, + uuid: `r_${this.state.cardId}_${this.state.sequence}`, + }), auditContext: "feishu.streaming-card.replace", - timeoutMs: this.creds.httpTimeoutMs ?? FEISHU_HTTP_TIMEOUT_MS, + action: "Replace card content", }); - try { - await assertSuccessfulCardKitResponse( - response, - "feishu.streaming-card.replace", - "Replace card content", - ); - } finally { - await release(); - } return true; } catch (error) { onError?.(error); @@ -585,44 +580,18 @@ export class FeishuStreamingSession { if (!this.state || !this.state.hasNote) { return; } - const apiBase = resolveApiBase(this.creds.domain); this.state.sequence += 1; - await fetchWithSsrFGuard({ - url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`, - init: { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds, { - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - })}`, - "Content-Type": "application/json", - "User-Agent": getFeishuUserAgent(), - }, - body: JSON.stringify({ - content: `${note}`, - sequence: this.state.sequence, - uuid: `n_${this.state.cardId}_${this.state.sequence}`, - }), - }, - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + await this.requestCardKit({ + path: `/cardkit/v1/cards/${this.state.cardId}/elements/note/content`, + method: "PUT", + body: JSON.stringify({ + content: `${note}`, + sequence: this.state.sequence, + uuid: `n_${this.state.cardId}_${this.state.sequence}`, + }), auditContext: "feishu.streaming-card.note-update", - timeoutMs: this.creds.httpTimeoutMs ?? FEISHU_HTTP_TIMEOUT_MS, - }) - .then(async ({ response, release }) => { - try { - await assertSuccessfulCardKitResponse( - response, - "feishu.streaming-card.note-update", - "Update card note", - ); - } finally { - await release(); - } - }) - .catch((e: unknown) => this.log?.(`Note update failed: ${String(e)}`)); + action: "Update card note", + }).catch((e: unknown) => this.log?.(`Note update failed: ${String(e)}`)); } async closeWithResult( @@ -637,7 +606,6 @@ export class FeishuStreamingSession { await this.queue; const text = finalText ?? this.pendingText ?? this.state.currentText; - const apiBase = resolveApiBase(this.creds.domain); // A failed final rewrite does not erase previously accepted visible content. // sentText advances only for an accepted write; the return value reports any visible content. let visibleContentSent = Boolean(this.state.sentText.trim()); @@ -673,44 +641,22 @@ export class FeishuStreamingSession { this.state.sequence += 1; let closeError: unknown; try { - const { response, release } = await fetchWithSsrFGuard({ - url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, - init: { - method: "PATCH", - headers: { - Authorization: `Bearer ${await getToken(this.creds, { - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - })}`, - "Content-Type": "application/json; charset=utf-8", - "User-Agent": getFeishuUserAgent(), - }, - body: JSON.stringify({ - settings: JSON.stringify({ - config: { - streaming_mode: false, - summary: { content: truncateSummary(acceptedText) }, - }, - }), - sequence: this.state.sequence, - uuid: `c_${this.state.cardId}_${this.state.sequence}`, + await this.requestCardKit({ + path: `/cardkit/v1/cards/${this.state.cardId}/settings`, + method: "PATCH", + body: JSON.stringify({ + settings: JSON.stringify({ + config: { + streaming_mode: false, + summary: { content: truncateSummary(acceptedText) }, + }, }), - }, - fetchImpl: this.fetchImpl, - lookupFn: this.lookupFn, - policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + sequence: this.state.sequence, + uuid: `c_${this.state.cardId}_${this.state.sequence}`, + }), auditContext: "feishu.streaming-card.close", - timeoutMs: this.creds.httpTimeoutMs ?? FEISHU_HTTP_TIMEOUT_MS, + action: "Close streaming card", }); - try { - await assertSuccessfulCardKitResponse( - response, - "feishu.streaming-card.close", - "Close streaming card", - ); - } finally { - await release(); - } } catch (error: unknown) { closeError = error; this.log?.(`Close failed: ${String(error)}`);