mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 04:31:10 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user