Merge 7b9351f5adc1e22c88435cf71773dc85eabc20f2 into 9fb78453e088cd7b553d7779faa0de5c83708e70

This commit is contained in:
gaohongxiang 2026-03-21 13:13:43 +08:00 committed by GitHub
commit 507c5eb9f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 620 additions and 115 deletions

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { createTestPluginApi } from "../test-utils/plugin-api.js";
import { createPluginRuntimeMock } from "../test-utils/plugin-runtime-mock.js";
import plugin from "./index.js";
describe("feishu plugin register SecretRef regression", () => {
it("does not resolve SecretRefs while registering tools", () => {
const api = createTestPluginApi({
id: plugin.id,
name: plugin.name,
source: "extensions/feishu/index.ts",
config: {
channels: {
feishu: {
enabled: true,
accounts: {
main: {
appId: "app-id",
appSecret: { source: "file", provider: "default", id: "path/to/app-secret" },
tools: {
chat: true,
doc: true,
drive: true,
perm: true,
wiki: true,
},
},
},
},
},
} as never,
runtime: createPluginRuntimeMock(),
});
expect(() => plugin.register(api)).not.toThrow();
});
});

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
listEnabledFeishuAccountConfigs,
resolveDefaultFeishuAccountId,
resolveDefaultFeishuAccountSelection,
resolveFeishuAccount,
@ -369,3 +370,82 @@ describe("resolveFeishuAccount", () => {
).not.toThrow();
});
});
describe("listEnabledFeishuAccountConfigs", () => {
it("treats SecretRef-backed accounts as configured without resolving them", () => {
const accounts = listEnabledFeishuAccountConfigs({
channels: {
feishu: {
enabled: true,
appId: "app-id",
appSecret: { source: "file", provider: "default", id: "path/to/app-secret" },
accounts: {
main: {
enabled: true,
tools: { doc: true },
},
},
},
},
} as never);
expect(accounts).toHaveLength(1);
expect(accounts[0]).toEqual(
expect.objectContaining({
accountId: "main",
enabled: true,
configured: true,
config: expect.objectContaining({
tools: { doc: true },
}),
}),
);
});
it("does not treat SecretRef-style appId objects as configured in config-only preflight", () => {
const accounts = listEnabledFeishuAccountConfigs({
channels: {
feishu: {
enabled: true,
appId: { source: "file", provider: "default", id: "path/to/app-id" } as never,
appSecret: { source: "file", provider: "default", id: "path/to/app-secret" },
accounts: {
main: {
enabled: true,
tools: { doc: true },
},
},
},
},
} as never);
expect(accounts).toHaveLength(0);
});
it("preserves inherited tools flags when account tools only override a subset", () => {
const accounts = listEnabledFeishuAccountConfigs({
channels: {
feishu: {
enabled: true,
appId: "app",
appSecret: "secret", // pragma: allowlist secret
tools: {
chat: false,
},
accounts: {
main: {
enabled: true,
tools: { doc: true },
},
},
},
},
} as never);
expect(accounts).toHaveLength(1);
expect(accounts[0]?.config.tools).toEqual({
chat: false,
doc: true,
});
});
});

View File

@ -1,9 +1,14 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { ClawdbotConfig } from "../runtime-api.js";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "./secret-input.js";
import type {
FeishuConfig,
FeishuAccountConfig,
FeishuAccountSelectionSource,
FeishuDefaultAccountSelectionSource,
FeishuDomain,
ResolvedFeishuAccount,
@ -95,8 +100,62 @@ function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): Feish
// Get account-specific overrides
const account = resolveAccountConfig(cfg, accountId) ?? {};
// Merge: account config overrides base config
return { ...base, ...account } as FeishuConfig;
// Merge account overrides over base config while preserving nested tools defaults.
const merged = { ...base, ...account } as FeishuConfig;
const baseTools = base.tools;
const accountTools = account.tools;
if (baseTools || accountTools) {
merged.tools = {
...(baseTools ?? {}),
...(accountTools ?? {}),
};
}
return merged;
}
export function resolveFeishuAccountConfigState(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): {
accountId: string;
selectionSource: FeishuAccountSelectionSource;
enabled: boolean;
configured: boolean;
name?: string;
domain: FeishuDomain;
config: FeishuConfig;
} {
const hasExplicitAccountId =
typeof params.accountId === "string" && params.accountId.trim() !== "";
const defaultSelection = hasExplicitAccountId
? null
: resolveDefaultFeishuAccountSelection(params.cfg);
const accountId = hasExplicitAccountId
? normalizeAccountId(params.accountId)
: (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
const selectionSource: FeishuAccountSelectionSource = hasExplicitAccountId
? "explicit"
: (defaultSelection?.source ?? "fallback");
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const baseEnabled = feishuCfg?.enabled !== false;
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const configured = Boolean(
normalizeSecretInputString(merged.appId) && hasConfiguredSecretInput(merged.appSecret),
);
const accountName = (merged as FeishuAccountConfig).name;
return {
accountId,
selectionSource,
enabled,
configured,
name: typeof accountName === "string" ? accountName.trim() || undefined : undefined,
domain: merged.domain ?? "feishu",
config: merged,
};
}
/**
@ -192,48 +251,42 @@ export function resolveFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
const hasExplicitAccountId =
typeof params.accountId === "string" && params.accountId.trim() !== "";
const defaultSelection = hasExplicitAccountId
? null
: resolveDefaultFeishuAccountSelection(params.cfg);
const accountId = hasExplicitAccountId
? normalizeAccountId(params.accountId)
: (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
const selectionSource = hasExplicitAccountId
? "explicit"
: (defaultSelection?.source ?? "fallback");
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
// Base enabled state (top-level)
const baseEnabled = feishuCfg?.enabled !== false;
// Merge configs
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
// Account-level enabled state
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
// Resolve credentials from merged config
const creds = resolveFeishuCredentials(merged);
const accountName = (merged as FeishuAccountConfig).name;
const state = resolveFeishuAccountConfigState(params);
const creds = resolveFeishuCredentials(state.config);
return {
accountId,
selectionSource,
enabled,
accountId: state.accountId,
selectionSource: state.selectionSource,
enabled: state.enabled,
configured: Boolean(creds),
name: typeof accountName === "string" ? accountName.trim() || undefined : undefined,
name: state.name,
appId: creds?.appId,
appSecret: creds?.appSecret,
encryptKey: creds?.encryptKey,
verificationToken: creds?.verificationToken,
domain: creds?.domain ?? "feishu",
config: merged,
domain: creds?.domain ?? state.domain,
config: state.config,
};
}
/**
* List all enabled accounts that appear configured from raw config input alone.
* This preflight path intentionally does not resolve SecretRefs.
*/
export function listEnabledFeishuAccountConfigs(cfg: ClawdbotConfig): Array<{
accountId: string;
selectionSource: FeishuAccountSelectionSource;
enabled: boolean;
configured: boolean;
name?: string;
domain: FeishuDomain;
config: FeishuConfig;
}> {
return listFeishuAccountIds(cfg)
.map((accountId) => resolveFeishuAccountConfigState({ cfg, accountId }))
.filter((account) => account.enabled && account.configured);
}
/**
* List all enabled and configured accounts.
*/

View File

@ -1,7 +1,7 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { listEnabledFeishuAccountConfigs } from "./accounts.js";
import { createFeishuToolClient } from "./tool-account.js";
// ============ Helpers ============
@ -538,7 +538,7 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
const accounts = listEnabledFeishuAccountConfigs(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
return;

View File

@ -9,6 +9,11 @@ export const FeishuChatSchema = Type.Object({
enum: [...CHAT_ACTION_VALUES],
description: "Action to run: members | info | member_info",
}),
accountId: Type.Optional(
Type.String({
description: "Optional Feishu account override for multi-account setups",
}),
),
chat_id: Type.Optional(Type.String({ description: "Chat ID (from URL or event payload)" })),
member_id: Type.Optional(Type.String({ description: "Member ID for member_info lookups" })),
page_size: Type.Optional(Type.Number({ description: "Page size (1-100, default 50)" })),

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerFeishuChatTools } from "./chat.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const chatGetMock = vi.hoisted(() => vi.fn());
@ -25,8 +26,7 @@ describe("registerFeishuChatTools", () => {
});
it("registers feishu_chat and handles info/members actions", async () => {
const registerTool = vi.fn();
registerFeishuChatTools({
const { api, resolveTool } = createToolFactoryHarness({
config: {
channels: {
feishu: {
@ -37,13 +37,10 @@ describe("registerFeishuChatTools", () => {
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
});
registerFeishuChatTools(api);
expect(registerTool).toHaveBeenCalledTimes(1);
const tool = registerTool.mock.calls[0]?.[0];
expect(tool?.name).toBe("feishu_chat");
const tool = resolveTool("feishu_chat");
chatGetMock.mockResolvedValueOnce({
code: 0,
@ -97,8 +94,7 @@ describe("registerFeishuChatTools", () => {
});
it("skips registration when chat tool is disabled", () => {
const registerTool = vi.fn();
registerFeishuChatTools({
const { api, resolveTool } = createToolFactoryHarness({
config: {
channels: {
feishu: {
@ -109,9 +105,8 @@ describe("registerFeishuChatTools", () => {
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
expect(registerTool).not.toHaveBeenCalled();
});
registerFeishuChatTools(api);
expect(() => resolveTool("feishu_chat")).toThrow("Tool not registered: feishu_chat");
});
});

View File

@ -1,9 +1,13 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { listEnabledFeishuAccountConfigs } from "./accounts.js";
import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
import {
createFeishuToolClient,
isFeishuToolEnabledForRoutedAccount,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccountConfigState,
} from "./tool-account.js";
function json(data: unknown) {
return {
@ -126,64 +130,87 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
const accounts = listEnabledFeishuAccountConfigs(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_chat: No Feishu accounts configured, skipping chat tools");
return;
}
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.chat) {
api.logger.debug?.("feishu_chat: chat tool disabled in config");
return;
}
const getClient = () => createFeishuClient(firstAccount);
type FeishuChatExecuteParams = FeishuChatParams & { accountId?: string };
api.registerTool(
{
name: "feishu_chat",
label: "Feishu Chat",
description: "Feishu chat operations. Actions: members, info, member_info",
parameters: FeishuChatSchema,
async execute(_toolCallId, params) {
const p = params as FeishuChatParams;
try {
const client = getClient();
switch (p.action) {
case "members":
if (!p.chat_id) {
return json({ error: "chat_id is required for action members" });
}
return json(
await getChatMembers(
client,
p.chat_id,
p.page_size,
p.page_token,
p.member_id_type,
),
);
case "info":
if (!p.chat_id) {
return json({ error: "chat_id is required for action info" });
}
return json(await getChatInfo(client, p.chat_id));
case "member_info":
if (!p.member_id) {
return json({ error: "member_id is required for action member_info" });
}
return json(
await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"),
);
default:
return json({ error: `Unknown action: ${String(p.action)}` });
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_chat",
label: "Feishu Chat",
description: "Feishu chat operations. Actions: members, info, member_info",
parameters: FeishuChatSchema,
async execute(_toolCallId, params) {
const p = params as FeishuChatExecuteParams;
try {
const account = resolveFeishuToolAccountConfigState({
api,
executeParams: p,
defaultAccountId,
});
if (
!isFeishuToolEnabledForRoutedAccount({
api,
executeParams: p,
defaultAccountId,
tool: "chat",
})
) {
return json({
error: `Feishu chat is disabled for account "${account.accountId}".`,
});
}
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
switch (p.action) {
case "members":
if (!p.chat_id) {
return json({ error: "chat_id is required for action members" });
}
return json(
await getChatMembers(
client,
p.chat_id,
p.page_size,
p.page_token,
p.member_id_type,
),
);
case "info":
if (!p.chat_id) {
return json({ error: "chat_id is required for action info" });
}
return json(await getChatInfo(client, p.chat_id));
case "member_info":
if (!p.member_id) {
return json({ error: "member_id is required for action member_info" });
}
return json(
await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"),
);
default:
return json({ error: `Unknown action: ${String(p.action)}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
};
},
{ name: "feishu_chat" },
);

View File

@ -5,6 +5,16 @@ import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
__appId: creds?.appId,
application: {
scope: {
list: vi.fn().mockResolvedValue({
code: 0,
data: {
scopes: [],
},
}),
},
},
}));
vi.mock("./client.js", () => {
@ -67,4 +77,68 @@ describe("feishu_doc account selection", () => {
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
test("blocks execution when configured defaultAccount disables doc", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
defaultAccount: "b",
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: false } }, // pragma: allowlist secret
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "a" });
const result = await docTool.execute("call-disabled", {
action: "list_blocks",
doc_token: "d",
});
expect(result).toEqual(
expect.objectContaining({
details: expect.objectContaining({
error: 'Feishu doc is disabled for account "b".',
}),
}),
);
expect(createFeishuClientMock).not.toHaveBeenCalled();
});
test("feishu_app_scopes allows explicit accountId override when defaultAccount disables scopes", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
defaultAccount: "b",
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { scopes: true } }, // pragma: allowlist secret
b: { appId: "app-b", appSecret: "sec-b", tools: { scopes: false } }, // pragma: allowlist secret
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const scopesTool = resolveTool("feishu_app_scopes", { agentAccountId: "a" });
await scopesTool.execute("call-enabled", { accountId: "a" });
const blocked = await scopesTool.execute("call-blocked", {});
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a");
expect(blocked).toEqual(
expect.objectContaining({
details: expect.objectContaining({
error: 'Feishu scopes are disabled for account "b".',
}),
}),
);
});
});

View File

@ -5,7 +5,7 @@ import { basename } from "node:path";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { listEnabledFeishuAccountConfigs } from "./accounts.js";
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
import { updateColorText } from "./docx-color-text.js";
@ -20,7 +20,9 @@ import {
import { getFeishuRuntime } from "./runtime.js";
import {
createFeishuToolClient,
isFeishuToolEnabledForRoutedAccount,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccountConfigState,
resolveFeishuToolAccount,
} from "./tool-account.js";
@ -1233,7 +1235,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
}
// Check if any account is configured
const accounts = listEnabledFeishuAccounts(api.config);
const accounts = listEnabledFeishuAccountConfigs(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
return;
@ -1273,6 +1275,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
async execute(_toolCallId, params) {
const p = params as FeishuDocExecuteParams;
try {
const account = resolveFeishuToolAccountConfigState({
api,
executeParams: p,
defaultAccountId,
});
if (
!isFeishuToolEnabledForRoutedAccount({
api,
executeParams: p,
defaultAccountId,
tool: "doc",
})
) {
return json({
error: `Feishu doc is disabled for account "${account.accountId}".`,
});
}
const client = getClient(p, defaultAccountId);
switch (p.action) {
case "read":
@ -1439,10 +1458,30 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
label: "Feishu App Scopes",
description:
"List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
parameters: Type.Object({}),
async execute() {
parameters: Type.Object({
accountId: Type.Optional(Type.String()),
}),
async execute(_toolCallId, params) {
const p = params as { accountId?: string };
try {
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
const account = resolveFeishuToolAccountConfigState({
api,
executeParams: p,
defaultAccountId: ctx.agentAccountId,
});
if (
!isFeishuToolEnabledForRoutedAccount({
api,
executeParams: p,
defaultAccountId: ctx.agentAccountId,
tool: "scopes",
})
) {
return json({
error: `Feishu scopes are disabled for account "${account.accountId}".`,
});
}
const result = await listAppScopes(getClient(p, ctx.agentAccountId));
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });

View File

@ -1,8 +1,13 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { listEnabledFeishuAccountConfigs } from "./accounts.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import {
createFeishuToolClient,
isFeishuToolEnabledForRoutedAccount,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccountConfigState,
} from "./tool-account.js";
import {
jsonToolResult,
toolExecutionErrorResult,
@ -169,7 +174,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
const accounts = listEnabledFeishuAccountConfigs(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
return;
@ -195,6 +200,23 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
async execute(_toolCallId, params) {
const p = params as FeishuDriveExecuteParams;
try {
const account = resolveFeishuToolAccountConfigState({
api,
executeParams: p,
defaultAccountId,
});
if (
!isFeishuToolEnabledForRoutedAccount({
api,
executeParams: p,
defaultAccountId,
tool: "drive",
})
) {
return jsonToolResult({
error: `Feishu drive is disabled for account "${account.accountId}".`,
});
}
const client = createFeishuToolClient({
api,
executeParams: p,

View File

@ -1,8 +1,13 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { listEnabledFeishuAccountConfigs } from "./accounts.js";
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import {
createFeishuToolClient,
isFeishuToolEnabledForRoutedAccount,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccountConfigState,
} from "./tool-account.js";
import {
jsonToolResult,
toolExecutionErrorResult,
@ -118,7 +123,7 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
const accounts = listEnabledFeishuAccountConfigs(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools");
return;
@ -143,6 +148,23 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
async execute(_toolCallId, params) {
const p = params as FeishuPermExecuteParams;
try {
const account = resolveFeishuToolAccountConfigState({
api,
executeParams: p,
defaultAccountId,
});
if (
!isFeishuToolEnabledForRoutedAccount({
api,
executeParams: p,
defaultAccountId,
tool: "perm",
})
) {
return jsonToolResult({
error: `Feishu perm is disabled for account "${account.accountId}".`,
});
}
const client = createFeishuToolClient({
api,
executeParams: p,

View File

@ -1,13 +1,18 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { registerFeishuBitableTools } from "./bitable.js";
import { registerFeishuChatTools } from "./chat.js";
import { registerFeishuDriveTools } from "./drive.js";
import { registerFeishuPermTools } from "./perm.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
import { registerFeishuWikiTools } from "./wiki.js";
const chatGetMock = vi.fn();
const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({
__appId: account?.appId,
im: {
chat: { get: chatGetMock },
},
}));
vi.mock("./client.js", () => ({
@ -16,11 +21,13 @@ vi.mock("./client.js", () => ({
function createConfig(params: {
toolsA?: {
chat?: boolean;
wiki?: boolean;
drive?: boolean;
perm?: boolean;
};
toolsB?: {
chat?: boolean;
wiki?: boolean;
drive?: boolean;
perm?: boolean;
@ -100,6 +107,101 @@ describe("feishu tool account routing", () => {
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("chat tool registers when first account disables it and routes to agentAccountId", async () => {
chatGetMock.mockResolvedValue({ code: 0, data: { name: "chat", user_count: 1 } });
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { chat: false },
toolsB: { chat: true },
}),
);
registerFeishuChatTools(api);
const tool = resolveTool("feishu_chat", { agentAccountId: "b" });
await tool.execute("call", { action: "info", chat_id: "oc_b" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("chat tool prefers configured defaultAccount over inherited default account context", async () => {
chatGetMock.mockResolvedValue({ code: 0, data: { name: "chat", user_count: 1 } });
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
defaultAccount: "b",
toolsA: { chat: true },
toolsB: { chat: true },
}),
);
registerFeishuChatTools(api);
const tool = resolveTool("feishu_chat", { agentAccountId: "a" });
await tool.execute("call", { action: "info", chat_id: "oc_b" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("chat tool allows explicit accountId override when defaultAccount disables chat", async () => {
chatGetMock.mockResolvedValue({ code: 0, data: { name: "chat", user_count: 1 } });
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
defaultAccount: "b",
toolsA: { chat: true },
toolsB: { chat: false },
}),
);
registerFeishuChatTools(api);
const tool = resolveTool("feishu_chat", { agentAccountId: "a" });
await tool.execute("call", { action: "info", chat_id: "oc_a", accountId: "a" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
test("chat tool blocks execution when the routed account disables chat", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { chat: true },
toolsB: { chat: false },
}),
);
registerFeishuChatTools(api);
const tool = resolveTool("feishu_chat", { agentAccountId: "b" });
const result = await tool.execute("call", { action: "info", chat_id: "oc_b" });
expect(result).toEqual(
expect.objectContaining({
details: expect.objectContaining({
error: 'Feishu chat is disabled for account "b".',
}),
}),
);
expect(createFeishuClientMock).not.toHaveBeenCalled();
});
test("chat tool checks the same routed account precedence as execution", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
defaultAccount: "b",
toolsA: { chat: true },
toolsB: { chat: false },
}),
);
registerFeishuChatTools(api);
const tool = resolveTool("feishu_chat", { agentAccountId: "a" });
const result = await tool.execute("call", { action: "info", chat_id: "oc_b" });
expect(result).toEqual(
expect.objectContaining({
details: expect.objectContaining({
error: 'Feishu chat is disabled for account "b".',
}),
}),
);
expect(createFeishuClientMock).not.toHaveBeenCalled();
});
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({

View File

@ -1,6 +1,6 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { resolveFeishuAccount, resolveFeishuAccountConfigState } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
@ -38,6 +38,33 @@ export function resolveFeishuToolAccount(params: {
});
}
export function resolveFeishuToolAccountConfigState(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
}) {
if (!params.api.config) {
throw new Error("Feishu config unavailable");
}
return resolveFeishuAccountConfigState({
cfg: params.api.config,
accountId:
normalizeOptionalAccountId(params.executeParams?.accountId) ??
readConfiguredDefaultAccountId(params.api.config) ??
normalizeOptionalAccountId(params.defaultAccountId),
});
}
export function isFeishuToolEnabledForRoutedAccount(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
tool: keyof Required<FeishuToolsConfig>;
}): boolean {
const account = resolveFeishuToolAccountConfigState(params);
return resolveToolsConfig(account.config.tools)[params.tool];
}
export function createFeishuToolClient(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
@ -47,7 +74,7 @@ export function createFeishuToolClient(params: {
}
export function resolveAnyEnabledFeishuToolsConfig(
accounts: ResolvedFeishuAccount[],
accounts: Array<{ config: { tools?: FeishuToolsConfig } }>,
): Required<FeishuToolsConfig> {
const merged: Required<FeishuToolsConfig> = {
doc: false,

View File

@ -1,7 +1,12 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import { listEnabledFeishuAccountConfigs } from "./accounts.js";
import {
createFeishuToolClient,
isFeishuToolEnabledForRoutedAccount,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccountConfigState,
} from "./tool-account.js";
import {
jsonToolResult,
toolExecutionErrorResult,
@ -157,7 +162,7 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
const accounts = listEnabledFeishuAccountConfigs(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_wiki: No Feishu accounts configured, skipping wiki tools");
return;
@ -183,6 +188,23 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
async execute(_toolCallId, params) {
const p = params as FeishuWikiExecuteParams;
try {
const account = resolveFeishuToolAccountConfigState({
api,
executeParams: p,
defaultAccountId,
});
if (
!isFeishuToolEnabledForRoutedAccount({
api,
executeParams: p,
defaultAccountId,
tool: "wiki",
})
) {
return jsonToolResult({
error: `Feishu wiki is disabled for account "${account.accountId}".`,
});
}
const client = createFeishuToolClient({
api,
executeParams: p,