feat(feishu): standardize request UA and register bot as AI agent (#63835)

- Set User-Agent to openclaw-feishu-builtin/{version}/{platform} for all
  Feishu API requests to comply with OAPI best practices
- Switch health-check probe to POST /bot/v1/openclaw_bot/ping to register
  the app as an AI agent (智能体) on the Feishu platform
- Update probe response parsing for new pingBotInfo response shape
This commit is contained in:
evandance
2026-04-10 22:57:38 +08:00
committed by GitHub
parent 407da8edfc
commit 4fb393980c
4 changed files with 74 additions and 24 deletions

View File

@@ -1,8 +1,22 @@
import type { Agent } from "node:https";
import { createRequire } from "node:module";
import * as Lark from "@larksuiteoapi/node-sdk";
import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
const require = createRequire(import.meta.url);
const { version: pluginVersion } = require("../package.json") as { version: string };
export { pluginVersion };
const FEISHU_USER_AGENT = `openclaw-feishu-builtin/${pluginVersion}/${process.platform}`;
export { FEISHU_USER_AGENT };
/** User-Agent header value for all Feishu API requests. */
export function getFeishuUserAgent(): string {
return FEISHU_USER_AGENT;
}
type FeishuClientSdk = Pick<
typeof Lark,
| "AppType"
@@ -26,6 +40,35 @@ const defaultFeishuClientSdk: FeishuClientSdk = {
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;
@@ -36,7 +79,7 @@ type FeishuHttpInstanceLike = Pick<
"request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options"
>;
async function getWsProxyAgent(): Promise<Agent | undefined> {
async function getWsProxyAgent() {
return resolveAmbientNodeProxyAgent<Agent>();
}
@@ -61,8 +104,8 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
/**
* 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).
* 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;

View File

@@ -10,7 +10,7 @@ vi.mock("./client.js", () => ({
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
const DEFAULT_SUCCESS_RESPONSE = {
code: 0,
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
data: { pingBotInfo: { botName: "TestBot", botID: "ou_abc123" } },
} as const;
const DEFAULT_SUCCESS_RESULT = {
ok: true,
@@ -20,7 +20,7 @@ const DEFAULT_SUCCESS_RESULT = {
} as const;
const BOT1_RESPONSE = {
code: 0,
bot: { bot_name: "Bot1", open_id: "ou_1" },
data: { pingBotInfo: { botName: "Bot1", botID: "ou_1" } },
} as const;
function makeRequestFn(response: Record<string, unknown>) {
@@ -135,8 +135,9 @@ describe("probeFeishu", () => {
expect(requestFn).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
url: "/open-apis/bot/v3/info",
method: "POST",
url: "/open-apis/bot/v1/openclaw_bot/ping",
data: { needBotInfo: true },
timeout: FEISHU_PROBE_REQUEST_TIMEOUT_MS,
}),
);
@@ -259,10 +260,10 @@ describe("probeFeishu", () => {
});
});
it("handles response.data.bot fallback path", async () => {
it("handles response with pingBotInfo in data", async () => {
setupClient({
code: 0,
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
data: { pingBotInfo: { botName: "DataBot", botID: "ou_data" } },
});
await expectDefaultSuccessResult(DEFAULT_CREDS, {

View File

@@ -18,20 +18,19 @@ export type ProbeFeishuOptions = {
abortSignal?: AbortSignal;
};
type FeishuBotInfoResponse = {
type FeishuPingResponse = {
code: number;
msg?: string;
bot?: { bot_name?: string; open_id?: string };
data?: { bot?: { bot_name?: string; open_id?: string } };
data?: { pingBotInfo?: { botID?: string; botName?: string } };
};
type FeishuRequestClient = ReturnType<typeof createFeishuClient> & {
request(params: {
method: "GET";
method: "POST";
url: string;
data: Record<string, never>;
data: Record<string, unknown>;
timeout: number;
}): Promise<FeishuBotInfoResponse>;
}): Promise<FeishuPingResponse>;
};
function setCachedProbeResult(
@@ -81,12 +80,14 @@ export async function probeFeishu(
try {
const client = createFeishuClient(creds) as FeishuRequestClient;
// Use bot/v3/info API to get bot information
const responseResult = await raceWithTimeoutAndAbort<FeishuBotInfoResponse>(
// Feishu-provided endpoint for OpenClaw, supported on both Feishu (CN)
// and Lark (international). No OAuth scopes required. Validates
// credentials and registers the app as an AI agent (智能体).
const responseResult = await raceWithTimeoutAndAbort<FeishuPingResponse>(
client.request({
method: "GET",
url: "/open-apis/bot/v3/info",
data: {},
method: "POST",
url: "/open-apis/bot/v1/openclaw_bot/ping",
data: { needBotInfo: true },
timeout: timeoutMs,
}),
{
@@ -135,14 +136,14 @@ export async function probeFeishu(
);
}
const bot = response.bot || response.data?.bot;
const botInfo = response.data?.pingBotInfo;
return setCachedProbeResult(
cacheKey,
{
ok: true,
appId: creds.appId,
botName: bot?.bot_name,
botOpenId: bot?.open_id,
botName: botInfo?.botName,
botOpenId: botInfo?.botID,
},
PROBE_SUCCESS_TTL_MS,
);

View File

@@ -4,6 +4,7 @@
import type { Client } from "@larksuiteoapi/node-sdk";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { getFeishuUserAgent } from "./client.js";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
import type { FeishuDomain } from "./types.js";
@@ -76,7 +77,7 @@ async function getToken(creds: Credentials): Promise<string> {
url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", "User-Agent": getFeishuUserAgent() },
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
},
policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
@@ -221,6 +222,7 @@ export class FeishuStreamingSession {
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
"User-Agent": getFeishuUserAgent(),
},
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
},
@@ -305,6 +307,7 @@ export class FeishuStreamingSession {
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
"User-Agent": getFeishuUserAgent(),
},
body: JSON.stringify({
content: text,
@@ -370,6 +373,7 @@ export class FeishuStreamingSession {
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
"User-Agent": getFeishuUserAgent(),
},
body: JSON.stringify({
content: `<font color='grey'>${note}</font>`,
@@ -421,6 +425,7 @@ export class FeishuStreamingSession {
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json; charset=utf-8",
"User-Agent": getFeishuUserAgent(),
},
body: JSON.stringify({
settings: JSON.stringify({