fix(feishu): recover invalid tenant tokens

Punchcard-Session: cobalt-timber-orchard-dh
This commit is contained in:
Vincent Koc
2026-08-12 02:11:23 +08:00
parent 5bc7efe746
commit b55fa9b7c1
14 changed files with 775 additions and 239 deletions
+82 -2
View File
@@ -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<typeof vi.fn> }>);
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<Record<ProxyEnvKey, string | undefined>> = {};
@@ -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<typeof vi.fn>;
};
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<typeof vi.fn>;
};
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");
+53
View File
@@ -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<FeishuClientCredentials, "appId" | "appSecret">> &
Pick<FeishuClientCredentials, "accountId" | "domain">,
): 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<void> {
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<typeof feishuClientSdk.WSClient>[0],
"onError" | "onReady" | "onReconnected" | "onReconnecting"
+51 -26
View File
@@ -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<T>(
request: () => Promise<T>,
errorPrefix: string,
@@ -99,36 +109,51 @@ export async function requestFeishuApi<T>(
includeNestedErrorLogId?: boolean;
/** Base retry delay in ms; doubles on the second retry. @internal */
retryDelayMs?: number;
invalidateTenantToken?: () => Promise<void>;
} = {},
): Promise<T> {
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);
}
}
}
@@ -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<string, number>();
const messageCalls = new Map<string, number>();
const visibleSends = new Map<string, number>();
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<void>((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<void>((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) => {
+5 -1
View File
@@ -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,
+13 -9
View File
@@ -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<typeof resolveFeishuRuntimeAccount>;
client: ReturnType<typeof createFeishuClient>;
invalidateTenantToken: () => Promise<void>;
} {
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<UploadImageResult> {
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<UploadFileResult> {
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(
+26 -2
View File
@@ -11,6 +11,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
type StreamingSessionStub = {
active: boolean;
credentials: unknown;
deps: { invalidateTenantToken?: () => Promise<void> } | undefined;
start: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
@@ -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<void> } | 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<void> },
) {
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",
+6 -3
View File
@@ -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;
+7 -1
View File
@@ -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", () => {
+3 -1
View File
@@ -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<typeof createFeishuClient>;
invalidateTenantToken: () => Promise<void>;
receiveId: string;
receiveIdType: ReturnType<typeof resolveReceiveIdType>;
};
@@ -30,6 +31,7 @@ export function resolveFeishuSendTarget(params: {
const withoutProviderPrefix = target.replace(/^(feishu|lark):/i, "");
return {
client,
invalidateTenantToken: () => invalidateFeishuTenantAccessToken(account),
receiveId,
receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
};
+78
View File
@@ -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);
});
});
+34 -7
View File
@@ -114,6 +114,7 @@ async function sendFallbackDirect(
msgType: string;
},
errorPrefix: string,
invalidateTenantToken?: () => Promise<void>,
): Promise<FeishuSendResult> {
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<void>;
},
): Promise<FeishuSendResult> {
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<FeishuSendResult> {
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<Feis
directParams,
directErrorPrefix: "Feishu card send failed",
replyErrorPrefix: "Feishu card reply failed",
invalidateTenantToken,
});
}
@@ -287,6 +287,155 @@ describe("FeishuStreamingSession", () => {
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<ReturnType<typeof vi.fn>> = [];
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:
| {
+133 -187
View File
@@ -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<void>;
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<string, { token: string; expiresAt: number }>();
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<void> {
if (!response.ok) {
cancelUnreadResponseBody(response);
throw new Error(`${action} failed with HTTP ${response.status}`);
}
const data = await readFeishuJsonResponse<CardKitResponse>(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<string> {
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<void>;
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<T extends CardKitResponse>(params: {
path: string;
method: "POST" | "PUT" | "PATCH";
body: string;
auditContext: string;
action: string;
}): Promise<T> {
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<T>(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<string, unknown>[] = [
{ 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: `<font color='grey'>${note}</font>`,
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: `<font color='grey'>${note}</font>`,
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)}`);