mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(feishu): recover invalid tenant tokens
Punchcard-Session: cobalt-timber-orchard-dh
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
| {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user