From 04de01f8cfe96b7e7fc212df8261d20cf6592f6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 13:28:34 -0400 Subject: [PATCH] fix(feishu): bound streaming token expiry --- extensions/feishu/src/streaming-card.test.ts | 59 ++++++++++++++++++++ extensions/feishu/src/streaming-card.ts | 14 ++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/extensions/feishu/src/streaming-card.test.ts b/extensions/feishu/src/streaming-card.test.ts index 99b58721312c..6e0a74439ee3 100644 --- a/extensions/feishu/src/streaming-card.test.ts +++ b/extensions/feishu/src/streaming-card.test.ts @@ -342,6 +342,65 @@ describe("FeishuStreamingSession", () => { "Final replace failed: Error: Replace card content failed with HTTP 500", ); }); + + it("bounds streaming token cache lifetime when token expiry overflows", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); + const release = vi.fn(async () => {}); + const authTokens: string[] = []; + fetchWithSsrFGuardMock.mockImplementation( + async ({ url }: { url: string; init?: { body?: string } }) => { + if (url.includes("/auth/")) { + const token = `token-${authTokens.length + 1}`; + authTokens.push(token); + return { + response: { + ok: true, + json: async () => ({ + code: 0, + msg: "ok", + tenant_access_token: token, + expire: Number.MAX_SAFE_INTEGER, + }), + }, + release, + }; + } + return { + response: { + ok: true, + json: async () => ({ + code: 0, + msg: "ok", + data: { card_id: `card-${authTokens.length}` }, + }), + }, + release, + }; + }, + ); + const client = { + im: { + message: { + create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })), + }, + }, + } as never; + + await new FeishuStreamingSession(client, { + appId: "app_unsafe_token_expiry", + appSecret: "secret", + }).start("chat_id", "open_id"); + expect(authTokens).toEqual(["token-1"]); + + vi.setSystemTime(Date.now() + 7200 * 1000 - 60_000 + 1); + await new FeishuStreamingSession(client, { + appId: "app_unsafe_token_expiry", + appSecret: "secret", + }).start("chat_id", "open_id"); + + expect(authTokens).toEqual(["token-1", "token-2"]); + }); }); describe("mergeStreamingText", () => { diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 92cc078b7a21..e88f5a424c12 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -3,6 +3,7 @@ */ import type { Client } from "@larksuiteoapi/node-sdk"; +import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { getFeishuUserAgent } from "./client.js"; import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js"; @@ -42,10 +43,21 @@ type StreamingStartOptions = { const STREAMING_UPDATE_THROTTLE_MS = 160; const STREAMING_SIGNIFICANT_DELTA_CHARS = 18; +const FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS = 7200; // Token cache (keyed by domain + appId) const tokenCache = new Map(); +function resolveStreamingTokenExpiresAt(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value <= 0) { + return Date.now(); + } + return ( + resolveExpiresAtMsFromDurationSeconds(value) ?? + Date.now() + FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS * 1000 + ); +} + function resolveApiBase(domain?: FeishuDomain): string { if (domain === "lark") { return "https://open.larksuite.com/open-apis"; @@ -103,7 +115,7 @@ async function getToken(creds: Credentials): Promise { } tokenCache.set(key, { token: data.tenant_access_token, - expiresAt: Date.now() + (data.expire ?? 7200) * 1000, + expiresAt: resolveStreamingTokenExpiresAt(data.expire), }); return data.tenant_access_token; }