Files
openclaw/extensions/feishu/src/client.ts
Vincent Koc f9b47ad2a1 fix(feishu): recover WebSocket after SDK retry exhaustion (#73739)
* fix(feishu): recover WebSocket after SDK retry exhaustion

* fix(feishu): recover WebSocket after SDK retry exhaustion

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-05-01 06:27:26 -07:00

291 lines
8.8 KiB
TypeScript

import type { Agent } from "node:https";
import { createRequire } from "node:module";
import * as Lark from "@larksuiteoapi/node-sdk";
import {
readPluginPackageVersion,
resolveAmbientNodeProxyAgent,
} from "openclaw/plugin-sdk/extension-shared";
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
const require = createRequire(import.meta.url);
const pluginVersion = readPluginPackageVersion({ require });
export { pluginVersion };
const FEISHU_USER_AGENT = `openclaw-feishu-builtin/${pluginVersion}/${process.platform}`;
export { FEISHU_USER_AGENT };
const FEISHU_WS_CONFIG = {
PingInterval: 30,
PingTimeout: 3,
} as const;
/** User-Agent header value for all Feishu API requests. */
export function getFeishuUserAgent(): string {
return FEISHU_USER_AGENT;
}
type FeishuClientSdk = Pick<
typeof Lark,
| "AppType"
| "Client"
| "defaultHttpInstance"
| "Domain"
| "EventDispatcher"
| "LoggerLevel"
| "WSClient"
>;
const defaultFeishuClientSdk: FeishuClientSdk = {
AppType: Lark.AppType,
Client: Lark.Client,
defaultHttpInstance: Lark.defaultHttpInstance,
Domain: Lark.Domain,
EventDispatcher: Lark.EventDispatcher,
LoggerLevel: Lark.LoggerLevel,
WSClient: Lark.WSClient,
};
let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
// Override the SDK's default User-Agent interceptor.
// The Lark SDK registers an axios request interceptor that sets
// 'oapi-node-sdk/1.0.0'. Axios request interceptors execute in LIFO order
// (last-registered runs first), so simply appending ours doesn't work — the
// SDK's interceptor would run last and overwrite our UA. We must clear
// handlers[] first, then register our own as the sole interceptor.
//
// Risk is low: the SDK only registers one interceptor (UA) at init time, and
// we clear it at module load before any other code can register handlers.
// If a future SDK version adds more interceptors, the upgrade will need
// compatibility verification regardless.
{
const inst = Lark.defaultHttpInstance as {
interceptors?: {
request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
};
};
if (inst.interceptors?.request) {
inst.interceptors.request.handlers = [];
inst.interceptors.request.use((req: unknown) => {
const r = req as { headers?: Record<string, string> };
if (r.headers) {
r.headers["User-Agent"] = getFeishuUserAgent();
}
return req;
});
}
}
/** Default HTTP timeout for Feishu API requests (30 seconds). */
export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS";
type FeishuHttpInstanceLike = Pick<
typeof feishuClientSdk.defaultHttpInstance,
"request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options"
>;
async function getWsProxyAgent() {
return resolveAmbientNodeProxyAgent<Agent>();
}
// Multi-account client cache
const clientCache = new Map<
string,
{
client: Lark.Client;
config: { appId: string; appSecret: string; domain?: FeishuDomain; httpTimeoutMs: number };
}
>();
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
if (domain === "lark") {
return feishuClientSdk.Domain.Lark;
}
if (domain === "feishu" || !domain) {
return feishuClientSdk.Domain.Feishu;
}
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
}
/**
* Create an HTTP instance that delegates to the Lark SDK's default instance
* but injects a default request timeout and User-Agent header to prevent
* indefinite hangs and set a standardized User-Agent per OAPI best practices.
*/
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance;
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
}
return {
request: (opts) => base.request(injectTimeout(opts)),
get: (url, opts) => base.get(url, injectTimeout(opts)),
post: (url, data, opts) => base.post(url, data, injectTimeout(opts)),
put: (url, data, opts) => base.put(url, data, injectTimeout(opts)),
patch: (url, data, opts) => base.patch(url, data, injectTimeout(opts)),
delete: (url, opts) => base.delete(url, injectTimeout(opts)),
head: (url, opts) => base.head(url, injectTimeout(opts)),
options: (url, opts) => base.options(url, injectTimeout(opts)),
};
}
/**
* Credentials needed to create a Feishu client.
* Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
*/
export type FeishuClientCredentials = {
accountId?: string;
appId?: string;
appSecret?: string;
domain?: FeishuDomain;
httpTimeoutMs?: number;
config?: Pick<FeishuConfig, "httpTimeoutMs">;
};
function resolveConfiguredHttpTimeoutMs(creds: FeishuClientCredentials): number {
const clampTimeout = (value: number): number => {
const rounded = Math.floor(value);
return Math.min(Math.max(rounded, 1), FEISHU_HTTP_TIMEOUT_MAX_MS);
};
const fromDirectField = creds.httpTimeoutMs;
if (
typeof fromDirectField === "number" &&
Number.isFinite(fromDirectField) &&
fromDirectField > 0
) {
return clampTimeout(fromDirectField);
}
const envRaw = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
if (envRaw) {
const envValue = Number(envRaw);
if (Number.isFinite(envValue) && envValue > 0) {
return clampTimeout(envValue);
}
}
const fromConfig = creds.config?.httpTimeoutMs;
const timeout = fromConfig;
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
return FEISHU_HTTP_TIMEOUT_MS;
}
return clampTimeout(timeout);
}
/**
* Create or get a cached Feishu client for an account.
* Accepts any object with appId, appSecret, and optional domain/accountId.
*/
export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
const { accountId = "default", appId, appSecret, domain } = creds;
const defaultHttpTimeoutMs = resolveConfiguredHttpTimeoutMs(creds);
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
// Check cache
const cached = clientCache.get(accountId);
if (
cached &&
cached.config.appId === appId &&
cached.config.appSecret === appSecret &&
cached.config.domain === domain &&
cached.config.httpTimeoutMs === defaultHttpTimeoutMs
) {
return cached.client;
}
// Create new client with timeout-aware HTTP instance
const client = new feishuClientSdk.Client({
appId,
appSecret,
appType: feishuClientSdk.AppType.SelfBuild,
domain: resolveDomain(domain),
httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
});
// Cache it
clientCache.set(accountId, {
client,
config: { appId, appSecret, domain, httpTimeoutMs: defaultHttpTimeoutMs },
});
return client;
}
export type FeishuWsClientCallbacks = Pick<
ConstructorParameters<typeof feishuClientSdk.WSClient>[0],
"onError" | "onReady" | "onReconnected" | "onReconnecting"
>;
/**
* Create a Feishu WebSocket client for an account.
* Note: WSClient is not cached since each call creates a new connection.
*/
export async function createFeishuWSClient(
account: ResolvedFeishuAccount,
callbacks: FeishuWsClientCallbacks = {},
): Promise<Lark.WSClient> {
const { accountId, appId, appSecret, domain } = account;
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
const agent = await getWsProxyAgent();
return new feishuClientSdk.WSClient({
appId,
appSecret,
domain: resolveDomain(domain),
...callbacks,
loggerLevel: feishuClientSdk.LoggerLevel.info,
wsConfig: FEISHU_WS_CONFIG,
...(agent ? { agent } : {}),
} as ConstructorParameters<typeof feishuClientSdk.WSClient>[0] & {
wsConfig: typeof FEISHU_WS_CONFIG;
});
}
/**
* Create an event dispatcher for an account.
*/
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
return new feishuClientSdk.EventDispatcher({
encryptKey: account.encryptKey,
verificationToken: account.verificationToken,
});
}
/**
* Get a cached client for an account (if exists).
*/
export function getFeishuClient(accountId: string): Lark.Client | null {
return clientCache.get(accountId)?.client ?? null;
}
/**
* Clear client cache for a specific account or all accounts.
*/
export function clearClientCache(accountId?: string): void {
if (accountId) {
clientCache.delete(accountId);
} else {
clientCache.clear();
}
}
export function setFeishuClientRuntimeForTest(overrides?: {
sdk?: Partial<FeishuClientSdk>;
}): void {
feishuClientSdk = overrides?.sdk
? { ...defaultFeishuClientSdk, ...overrides.sdk }
: defaultFeishuClientSdk;
}