* Secrets: add inline allowlist review set * Secrets: narrow detect-secrets file exclusions * Secrets: exclude Docker fingerprint false positive * Secrets: allowlist test and docs false positives * Secrets: refresh baseline after allowlist updates * Secrets: fix gateway chat fixture pragma * Secrets: format pre-commit config * Android: keep talk mode fixture JSON valid * Feishu: rely on client timeout injection * Secrets: allowlist provider auth test fixtures * Secrets: allowlist onboard search fixtures * Secrets: allowlist onboard mode fixture * Secrets: allowlist gateway auth mode fixture * Secrets: allowlist APNS wake test key * Secrets: allowlist gateway reload fixtures * Secrets: allowlist moonshot video fixture * Secrets: allowlist auto audio fixture * Secrets: allowlist tiny audio fixture * Secrets: allowlist embeddings fixtures * Secrets: allowlist resolve fixtures * Secrets: allowlist target registry pattern fixtures * Secrets: allowlist gateway chat env fixture * Secrets: refresh baseline after fixture allowlists * Secrets: reapply gateway chat env allowlist * Secrets: reapply gateway chat env allowlist * Secrets: stabilize gateway chat env allowlist * Secrets: allowlist runtime snapshot save fixture * Secrets: allowlist oauth profile fixtures * Secrets: allowlist compaction identifier fixture * Secrets: allowlist model auth fixture * Secrets: allowlist model status fixtures * Secrets: allowlist custom onboarding fixture * Secrets: allowlist mattermost token summary fixtures * Secrets: allowlist gateway auth suite fixtures * Secrets: allowlist channel summary fixture * Secrets: allowlist provider usage auth fixtures * Secrets: allowlist media proxy fixture * Secrets: allowlist secrets audit fixtures * Secrets: refresh baseline after final fixture allowlists * Feishu: prefer explicit client timeout * Feishu: test direct timeout precedence
197 lines
6.0 KiB
TypeScript
197 lines
6.0 KiB
TypeScript
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
|
|
|
|
/** 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";
|
|
|
|
function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
|
|
const proxyUrl =
|
|
process.env.https_proxy ||
|
|
process.env.HTTPS_PROXY ||
|
|
process.env.http_proxy ||
|
|
process.env.HTTP_PROXY;
|
|
if (!proxyUrl) return undefined;
|
|
return new HttpsProxyAgent(proxyUrl);
|
|
}
|
|
|
|
// 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 Lark.Domain.Lark;
|
|
}
|
|
if (domain === "feishu" || !domain) {
|
|
return Lark.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 to prevent indefinite hangs
|
|
* (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
|
|
*/
|
|
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
|
|
const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance;
|
|
|
|
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 Lark.Client({
|
|
appId,
|
|
appSecret,
|
|
appType: Lark.AppType.SelfBuild,
|
|
domain: resolveDomain(domain),
|
|
httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
|
|
});
|
|
|
|
// Cache it
|
|
clientCache.set(accountId, {
|
|
client,
|
|
config: { appId, appSecret, domain, httpTimeoutMs: defaultHttpTimeoutMs },
|
|
});
|
|
|
|
return client;
|
|
}
|
|
|
|
/**
|
|
* Create a Feishu WebSocket client for an account.
|
|
* Note: WSClient is not cached since each call creates a new connection.
|
|
*/
|
|
export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient {
|
|
const { accountId, appId, appSecret, domain } = account;
|
|
|
|
if (!appId || !appSecret) {
|
|
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
|
|
}
|
|
|
|
const agent = getWsProxyAgent();
|
|
return new Lark.WSClient({
|
|
appId,
|
|
appSecret,
|
|
domain: resolveDomain(domain),
|
|
loggerLevel: Lark.LoggerLevel.info,
|
|
...(agent ? { agent } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create an event dispatcher for an account.
|
|
*/
|
|
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
|
|
return new Lark.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();
|
|
}
|
|
}
|