mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
refactor(feishu): unify account-aware tool routing and message body
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { createFeishuToolClient } from "./tool-account.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
@@ -64,10 +65,7 @@ function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki
|
||||
}
|
||||
|
||||
/** Get app_token from wiki node_token */
|
||||
async function getAppTokenFromWiki(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
nodeToken: string,
|
||||
): Promise<string> {
|
||||
async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Promise<string> {
|
||||
const res = await client.wiki.space.getNode({
|
||||
params: { token: nodeToken },
|
||||
});
|
||||
@@ -87,7 +85,7 @@ async function getAppTokenFromWiki(
|
||||
}
|
||||
|
||||
/** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */
|
||||
async function getBitableMeta(client: ReturnType<typeof createFeishuClient>, url: string) {
|
||||
async function getBitableMeta(client: Lark.Client, url: string) {
|
||||
const parsed = parseBitableUrl(url);
|
||||
if (!parsed) {
|
||||
throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL");
|
||||
@@ -134,11 +132,7 @@ async function getBitableMeta(client: ReturnType<typeof createFeishuClient>, url
|
||||
};
|
||||
}
|
||||
|
||||
async function listFields(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
) {
|
||||
async function listFields(client: Lark.Client, appToken: string, tableId: string) {
|
||||
const res = await client.bitable.appTableField.list({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
});
|
||||
@@ -161,7 +155,7 @@ async function listFields(
|
||||
}
|
||||
|
||||
async function listRecords(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
client: Lark.Client,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
pageSize?: number,
|
||||
@@ -186,12 +180,7 @@ async function listRecords(
|
||||
};
|
||||
}
|
||||
|
||||
async function getRecord(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
recordId: string,
|
||||
) {
|
||||
async function getRecord(client: Lark.Client, appToken: string, tableId: string, recordId: string) {
|
||||
const res = await client.bitable.appTableRecord.get({
|
||||
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
||||
});
|
||||
@@ -205,7 +194,7 @@ async function getRecord(
|
||||
}
|
||||
|
||||
async function createRecord(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
client: Lark.Client,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
fields: Record<string, unknown>,
|
||||
@@ -235,7 +224,7 @@ const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTi
|
||||
|
||||
/** Clean up default placeholder rows and fields in a newly created Bitable table */
|
||||
async function cleanupNewBitable(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
client: Lark.Client,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
tableName: string,
|
||||
@@ -334,7 +323,7 @@ async function cleanupNewBitable(
|
||||
}
|
||||
|
||||
async function createApp(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
client: Lark.Client,
|
||||
name: string,
|
||||
folderToken?: string,
|
||||
logger?: CleanupLogger,
|
||||
@@ -389,7 +378,7 @@ async function createApp(
|
||||
}
|
||||
|
||||
async function createField(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
client: Lark.Client,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
fieldName: string,
|
||||
@@ -417,7 +406,7 @@ async function createField(
|
||||
}
|
||||
|
||||
async function updateRecord(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
client: Lark.Client,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
recordId: string,
|
||||
@@ -543,203 +532,182 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
type AccountAwareParams = { accountId?: string };
|
||||
|
||||
// Tool 0: feishu_bitable_get_meta (helper to parse URLs)
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_get_meta",
|
||||
label: "Feishu Bitable Get Meta",
|
||||
description:
|
||||
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
|
||||
parameters: GetMetaSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { url } = params as { url: string };
|
||||
try {
|
||||
const result = await getBitableMeta(getClient(), url);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_get_meta" },
|
||||
);
|
||||
const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) =>
|
||||
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
|
||||
|
||||
// Tool 1: feishu_bitable_list_fields
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_list_fields",
|
||||
label: "Feishu Bitable List Fields",
|
||||
description: "List all fields (columns) in a Bitable table with their types and properties",
|
||||
parameters: ListFieldsSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id } = params as { app_token: string; table_id: string };
|
||||
try {
|
||||
const result = await listFields(getClient(), app_token, table_id);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_list_fields" },
|
||||
);
|
||||
const registerBitableTool = <TParams extends AccountAwareParams>(params: {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
execute: (args: { params: TParams; defaultAccountId?: string }) => Promise<unknown>;
|
||||
}) => {
|
||||
api.registerTool(
|
||||
(ctx) => ({
|
||||
name: params.name,
|
||||
label: params.label,
|
||||
description: params.description,
|
||||
parameters: params.parameters,
|
||||
async execute(_toolCallId, rawParams) {
|
||||
try {
|
||||
return json(
|
||||
await params.execute({
|
||||
params: rawParams as TParams,
|
||||
defaultAccountId: ctx.agentAccountId,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: params.name },
|
||||
);
|
||||
};
|
||||
|
||||
// Tool 2: feishu_bitable_list_records
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_list_records",
|
||||
label: "Feishu Bitable List Records",
|
||||
description: "List records (rows) from a Bitable table with pagination support",
|
||||
parameters: ListRecordsSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, page_size, page_token } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
page_size?: number;
|
||||
page_token?: string;
|
||||
};
|
||||
try {
|
||||
const result = await listRecords(getClient(), app_token, table_id, page_size, page_token);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
registerBitableTool<{ url: string; accountId?: string }>({
|
||||
name: "feishu_bitable_get_meta",
|
||||
label: "Feishu Bitable Get Meta",
|
||||
description:
|
||||
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
|
||||
parameters: GetMetaSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return getBitableMeta(getClient(params, defaultAccountId), params.url);
|
||||
},
|
||||
{ name: "feishu_bitable_list_records" },
|
||||
);
|
||||
});
|
||||
|
||||
// Tool 3: feishu_bitable_get_record
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_get_record",
|
||||
label: "Feishu Bitable Get Record",
|
||||
description: "Get a single record by ID from a Bitable table",
|
||||
parameters: GetRecordSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, record_id } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
record_id: string;
|
||||
};
|
||||
try {
|
||||
const result = await getRecord(getClient(), app_token, table_id, record_id);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
registerBitableTool<{ app_token: string; table_id: string; accountId?: string }>({
|
||||
name: "feishu_bitable_list_fields",
|
||||
label: "Feishu Bitable List Fields",
|
||||
description: "List all fields (columns) in a Bitable table with their types and properties",
|
||||
parameters: ListFieldsSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id);
|
||||
},
|
||||
{ name: "feishu_bitable_get_record" },
|
||||
);
|
||||
});
|
||||
|
||||
// Tool 4: feishu_bitable_create_record
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_create_record",
|
||||
label: "Feishu Bitable Create Record",
|
||||
description: "Create a new record (row) in a Bitable table",
|
||||
parameters: CreateRecordSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, fields } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
fields: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
const result = await createRecord(getClient(), app_token, table_id, fields);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
registerBitableTool<{
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
page_size?: number;
|
||||
page_token?: string;
|
||||
accountId?: string;
|
||||
}>({
|
||||
name: "feishu_bitable_list_records",
|
||||
label: "Feishu Bitable List Records",
|
||||
description: "List records (rows) from a Bitable table with pagination support",
|
||||
parameters: ListRecordsSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return listRecords(
|
||||
getClient(params, defaultAccountId),
|
||||
params.app_token,
|
||||
params.table_id,
|
||||
params.page_size,
|
||||
params.page_token,
|
||||
);
|
||||
},
|
||||
{ name: "feishu_bitable_create_record" },
|
||||
);
|
||||
});
|
||||
|
||||
// Tool 5: feishu_bitable_update_record
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_update_record",
|
||||
label: "Feishu Bitable Update Record",
|
||||
description: "Update an existing record (row) in a Bitable table",
|
||||
parameters: UpdateRecordSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, record_id, fields } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
record_id: string;
|
||||
fields: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
const result = await updateRecord(getClient(), app_token, table_id, record_id, fields);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
registerBitableTool<{
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
record_id: string;
|
||||
accountId?: string;
|
||||
}>({
|
||||
name: "feishu_bitable_get_record",
|
||||
label: "Feishu Bitable Get Record",
|
||||
description: "Get a single record by ID from a Bitable table",
|
||||
parameters: GetRecordSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return getRecord(
|
||||
getClient(params, defaultAccountId),
|
||||
params.app_token,
|
||||
params.table_id,
|
||||
params.record_id,
|
||||
);
|
||||
},
|
||||
{ name: "feishu_bitable_update_record" },
|
||||
);
|
||||
});
|
||||
|
||||
// Tool 6: feishu_bitable_create_app
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_create_app",
|
||||
label: "Feishu Bitable Create App",
|
||||
description: "Create a new Bitable (multidimensional table) application",
|
||||
parameters: CreateAppSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { name, folder_token } = params as { name: string; folder_token?: string };
|
||||
try {
|
||||
const result = await createApp(getClient(), name, folder_token, {
|
||||
debug: (msg) => api.logger.debug?.(msg),
|
||||
warn: (msg) => api.logger.warn?.(msg),
|
||||
});
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
registerBitableTool<{
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
fields: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}>({
|
||||
name: "feishu_bitable_create_record",
|
||||
label: "Feishu Bitable Create Record",
|
||||
description: "Create a new record (row) in a Bitable table",
|
||||
parameters: CreateRecordSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return createRecord(
|
||||
getClient(params, defaultAccountId),
|
||||
params.app_token,
|
||||
params.table_id,
|
||||
params.fields,
|
||||
);
|
||||
},
|
||||
{ name: "feishu_bitable_create_app" },
|
||||
);
|
||||
});
|
||||
|
||||
// Tool 7: feishu_bitable_create_field
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_create_field",
|
||||
label: "Feishu Bitable Create Field",
|
||||
description: "Create a new field (column) in a Bitable table",
|
||||
parameters: CreateFieldSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, field_name, field_type, property } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
field_name: string;
|
||||
field_type: number;
|
||||
property?: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
const result = await createField(
|
||||
getClient(),
|
||||
app_token,
|
||||
table_id,
|
||||
field_name,
|
||||
field_type,
|
||||
property,
|
||||
);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
registerBitableTool<{
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
record_id: string;
|
||||
fields: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}>({
|
||||
name: "feishu_bitable_update_record",
|
||||
label: "Feishu Bitable Update Record",
|
||||
description: "Update an existing record (row) in a Bitable table",
|
||||
parameters: UpdateRecordSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return updateRecord(
|
||||
getClient(params, defaultAccountId),
|
||||
params.app_token,
|
||||
params.table_id,
|
||||
params.record_id,
|
||||
params.fields,
|
||||
);
|
||||
},
|
||||
{ name: "feishu_bitable_create_field" },
|
||||
);
|
||||
});
|
||||
|
||||
registerBitableTool<{ name: string; folder_token?: string; accountId?: string }>({
|
||||
name: "feishu_bitable_create_app",
|
||||
label: "Feishu Bitable Create App",
|
||||
description: "Create a new Bitable (multidimensional table) application",
|
||||
parameters: CreateAppSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, {
|
||||
debug: (msg) => api.logger.debug?.(msg),
|
||||
warn: (msg) => api.logger.warn?.(msg),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
registerBitableTool<{
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
field_name: string;
|
||||
field_type: number;
|
||||
property?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}>({
|
||||
name: "feishu_bitable_create_field",
|
||||
label: "Feishu Bitable Create Field",
|
||||
description: "Create a new field (column) in a Bitable table",
|
||||
parameters: CreateFieldSchema,
|
||||
async execute({ params, defaultAccountId }) {
|
||||
return createField(
|
||||
getClient(params, defaultAccountId),
|
||||
params.app_token,
|
||||
params.table_id,
|
||||
params.field_name,
|
||||
params.field_type,
|
||||
params.property,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
api.logger.info?.("feishu_bitable: Registered bitable tools");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FeishuMessageEvent } from "./bot.js";
|
||||
import { handleFeishuMessage } from "./bot.js";
|
||||
import { buildFeishuAgentBody, handleFeishuMessage } from "./bot.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
|
||||
const {
|
||||
@@ -61,6 +61,30 @@ async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessa
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildFeishuAgentBody", () => {
|
||||
it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
|
||||
const body = buildFeishuAgentBody({
|
||||
ctx: {
|
||||
content: "hello world",
|
||||
senderName: "Sender Name",
|
||||
senderOpenId: "ou-sender",
|
||||
messageId: "msg-42",
|
||||
mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
|
||||
},
|
||||
quotedContent: "previous message",
|
||||
permissionErrorForAgent: {
|
||||
code: 99991672,
|
||||
message: "permission denied",
|
||||
grantUrl: "https://open.feishu.cn/app/cli_test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toBe(
|
||||
'[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage command authorization", () => {
|
||||
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
||||
const mockDispatchReplyFromConfig = vi
|
||||
|
||||
@@ -496,6 +496,40 @@ export function parseFeishuMessageEvent(
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function buildFeishuAgentBody(params: {
|
||||
ctx: Pick<
|
||||
FeishuMessageContext,
|
||||
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
|
||||
>;
|
||||
quotedContent?: string;
|
||||
permissionErrorForAgent?: PermissionError;
|
||||
}): string {
|
||||
const { ctx, quotedContent, permissionErrorForAgent } = params;
|
||||
let messageBody = ctx.content;
|
||||
if (quotedContent) {
|
||||
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
||||
}
|
||||
|
||||
// DMs already have per-sender sessions, but this label still improves attribution.
|
||||
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
||||
messageBody = `${speaker}: ${messageBody}`;
|
||||
|
||||
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
||||
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
||||
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
||||
}
|
||||
|
||||
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
|
||||
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
|
||||
|
||||
if (permissionErrorForAgent) {
|
||||
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
||||
messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
|
||||
}
|
||||
|
||||
return messageBody;
|
||||
}
|
||||
|
||||
export async function handleFeishuMessage(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuMessageEvent;
|
||||
@@ -823,35 +857,14 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
|
||||
// Build message body with quoted content if available
|
||||
let messageBody = ctx.content;
|
||||
if (quotedContent) {
|
||||
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
||||
}
|
||||
|
||||
// Include a readable speaker label so the model can attribute instructions.
|
||||
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
|
||||
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
||||
messageBody = `${speaker}: ${messageBody}`;
|
||||
|
||||
// If there are mention targets, inform the agent that replies will auto-mention them
|
||||
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
||||
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
||||
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
||||
}
|
||||
|
||||
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
|
||||
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
|
||||
|
||||
const messageBody = buildFeishuAgentBody({
|
||||
ctx,
|
||||
quotedContent,
|
||||
permissionErrorForAgent,
|
||||
});
|
||||
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
||||
|
||||
// Append permission error notice to the main message body instead of dispatching
|
||||
// a separate agent turn. A separate dispatch caused the bot to reply twice — once
|
||||
// for the permission notification and once for the actual user message (#27372).
|
||||
if (permissionErrorForAgent) {
|
||||
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
||||
messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
|
||||
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
|
||||
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { registerFeishuDocTools } from "./docx.js";
|
||||
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
|
||||
|
||||
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
|
||||
__appId: creds?.appId,
|
||||
@@ -19,54 +20,6 @@ vi.mock("@larksuiteoapi/node-sdk", () => {
|
||||
};
|
||||
});
|
||||
|
||||
type ToolLike = {
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type ToolContextLike = {
|
||||
agentAccountId?: string;
|
||||
};
|
||||
|
||||
type ToolFactoryLike = (ctx: ToolContextLike) => ToolLike | ToolLike[] | null | undefined;
|
||||
|
||||
function createApi(cfg: OpenClawPluginApi["config"]) {
|
||||
const registered: Array<{
|
||||
tool: ToolLike | ToolFactoryLike;
|
||||
opts?: { name?: string };
|
||||
}> = [];
|
||||
|
||||
const api: Partial<OpenClawPluginApi> = {
|
||||
config: cfg,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
registerTool: (tool, opts) => {
|
||||
registered.push({ tool, opts });
|
||||
},
|
||||
};
|
||||
|
||||
const resolveTool = (name: string, ctx: ToolContextLike): ToolLike => {
|
||||
const entry = registered.find((item) => item.opts?.name === name);
|
||||
if (!entry) {
|
||||
throw new Error(`Tool not registered: ${name}`);
|
||||
}
|
||||
if (typeof entry.tool === "function") {
|
||||
const built = entry.tool(ctx);
|
||||
if (!built || Array.isArray(built)) {
|
||||
throw new Error(`Unexpected tool factory output for ${name}`);
|
||||
}
|
||||
return built as ToolLike;
|
||||
}
|
||||
return entry.tool as ToolLike;
|
||||
};
|
||||
|
||||
return { api: api as OpenClawPluginApi, resolveTool };
|
||||
}
|
||||
|
||||
describe("feishu_doc account selection", () => {
|
||||
test("uses agentAccountId context when params omit accountId", async () => {
|
||||
const cfg = {
|
||||
@@ -81,7 +34,7 @@ describe("feishu_doc account selection", () => {
|
||||
},
|
||||
} as OpenClawPluginApi["config"];
|
||||
|
||||
const { api, resolveTool } = createApi(cfg);
|
||||
const { api, resolveTool } = createToolFactoryHarness(cfg);
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" });
|
||||
@@ -108,7 +61,7 @@ describe("feishu_doc account selection", () => {
|
||||
},
|
||||
} as OpenClawPluginApi["config"];
|
||||
|
||||
const { api, resolveTool } = createApi(cfg);
|
||||
const { api, resolveTool } = createToolFactoryHarness(cfg);
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
|
||||
|
||||
@@ -3,11 +3,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
import {
|
||||
createFeishuToolClient,
|
||||
resolveAnyEnabledFeishuToolsConfig,
|
||||
resolveFeishuToolAccount,
|
||||
} from "./tool-account.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
@@ -455,31 +457,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first account's config for tools configuration (registration-time defaults only)
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
// Register if enabled on any account; account routing is resolved per execution.
|
||||
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
||||
|
||||
const registered: string[] = [];
|
||||
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
|
||||
|
||||
const resolveAccount = (
|
||||
params: { accountId?: string } | undefined,
|
||||
defaultAccountId: string | undefined,
|
||||
) => {
|
||||
const accountId =
|
||||
typeof params?.accountId === "string" && params.accountId.trim().length > 0
|
||||
? params.accountId.trim()
|
||||
: defaultAccountId;
|
||||
return resolveFeishuAccount({ cfg: api.config!, accountId });
|
||||
};
|
||||
|
||||
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
|
||||
createFeishuClient(resolveAccount(params, defaultAccountId));
|
||||
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
|
||||
|
||||
const getMediaMaxBytes = (
|
||||
params: { accountId?: string } | undefined,
|
||||
defaultAccountId?: string,
|
||||
) => (resolveAccount(params, defaultAccountId).config?.mediaMaxMb ?? 30) * 1024 * 1024;
|
||||
) =>
|
||||
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
|
||||
?.mediaMaxMb ?? 30) *
|
||||
1024 *
|
||||
1024;
|
||||
|
||||
// Main document tool with action-based dispatch
|
||||
if (toolsCfg.doc) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
@@ -180,45 +179,51 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
||||
if (!toolsCfg.drive) {
|
||||
api.logger.debug?.("feishu_drive: drive tool disabled in config");
|
||||
return;
|
||||
}
|
||||
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_drive",
|
||||
label: "Feishu Drive",
|
||||
description:
|
||||
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
|
||||
parameters: FeishuDriveSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuDriveParams;
|
||||
try {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
return json(await listFolder(client, p.folder_token));
|
||||
case "info":
|
||||
return json(await getFileInfo(client, p.file_token));
|
||||
case "create_folder":
|
||||
return json(await createFolder(client, p.name, p.folder_token));
|
||||
case "move":
|
||||
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
|
||||
case "delete":
|
||||
return json(await deleteFile(client, p.file_token, p.type));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).action}` });
|
||||
(ctx) => {
|
||||
const defaultAccountId = ctx.agentAccountId;
|
||||
return {
|
||||
name: "feishu_drive",
|
||||
label: "Feishu Drive",
|
||||
description:
|
||||
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
|
||||
parameters: FeishuDriveSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuDriveExecuteParams;
|
||||
try {
|
||||
const client = createFeishuToolClient({
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
});
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
return json(await listFolder(client, p.folder_token));
|
||||
case "info":
|
||||
return json(await getFileInfo(client, p.file_token));
|
||||
case "create_folder":
|
||||
return json(await createFolder(client, p.name, p.folder_token));
|
||||
case "move":
|
||||
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
|
||||
case "delete":
|
||||
return json(await deleteFile(client, p.file_token, p.type));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).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_drive" },
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
@@ -129,42 +128,50 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
||||
if (!toolsCfg.perm) {
|
||||
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
|
||||
return;
|
||||
}
|
||||
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
type FeishuPermExecuteParams = FeishuPermParams & { accountId?: string };
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_perm",
|
||||
label: "Feishu Perm",
|
||||
description: "Feishu permission management. Actions: list, add, remove",
|
||||
parameters: FeishuPermSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuPermParams;
|
||||
try {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
return json(await listMembers(client, p.token, p.type));
|
||||
case "add":
|
||||
return json(
|
||||
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
|
||||
);
|
||||
case "remove":
|
||||
return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).action}` });
|
||||
(ctx) => {
|
||||
const defaultAccountId = ctx.agentAccountId;
|
||||
return {
|
||||
name: "feishu_perm",
|
||||
label: "Feishu Perm",
|
||||
description: "Feishu permission management. Actions: list, add, remove",
|
||||
parameters: FeishuPermSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuPermExecuteParams;
|
||||
try {
|
||||
const client = createFeishuToolClient({
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
});
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
return json(await listMembers(client, p.token, p.type));
|
||||
case "add":
|
||||
return json(
|
||||
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
|
||||
);
|
||||
case "remove":
|
||||
return json(
|
||||
await removeMember(client, p.token, p.type, p.member_type, p.member_id),
|
||||
);
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).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_perm" },
|
||||
);
|
||||
|
||||
111
extensions/feishu/src/tool-account-routing.test.ts
Normal file
111
extensions/feishu/src/tool-account-routing.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { registerFeishuBitableTools } from "./bitable.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 createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({
|
||||
__appId: account?.appId,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: (account: { appId?: string } | undefined) => createFeishuClientMock(account),
|
||||
}));
|
||||
|
||||
function createConfig(params: {
|
||||
toolsA?: {
|
||||
wiki?: boolean;
|
||||
drive?: boolean;
|
||||
perm?: boolean;
|
||||
};
|
||||
toolsB?: {
|
||||
wiki?: boolean;
|
||||
drive?: boolean;
|
||||
perm?: boolean;
|
||||
};
|
||||
}): OpenClawPluginApi["config"] {
|
||||
return {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
a: {
|
||||
appId: "app-a",
|
||||
appSecret: "sec-a",
|
||||
tools: params.toolsA,
|
||||
},
|
||||
b: {
|
||||
appId: "app-b",
|
||||
appSecret: "sec-b",
|
||||
tools: params.toolsB,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawPluginApi["config"];
|
||||
}
|
||||
|
||||
describe("feishu tool account routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("wiki tool registers when first account disables it and routes to agentAccountId", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { wiki: false },
|
||||
toolsB: { wiki: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuWikiTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
|
||||
await tool.execute("call", { action: "search" });
|
||||
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { drive: false },
|
||||
toolsB: { drive: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuDriveTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_drive", { agentAccountId: "b" });
|
||||
await tool.execute("call", { action: "unknown_action" });
|
||||
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { perm: false },
|
||||
toolsB: { perm: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuPermTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
|
||||
await tool.execute("call", { action: "unknown_action" });
|
||||
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
test("bitable tool routes to agentAccountId and allows explicit accountId override", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(createConfig({}));
|
||||
registerFeishuBitableTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_bitable_get_meta", { agentAccountId: "b" });
|
||||
await tool.execute("call-ctx", { url: "invalid-url" });
|
||||
await tool.execute("call-override", { url: "invalid-url", accountId: "a" });
|
||||
|
||||
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b");
|
||||
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a");
|
||||
});
|
||||
});
|
||||
58
extensions/feishu/src/tool-account.ts
Normal file
58
extensions/feishu/src/tool-account.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
type AccountAwareParams = { accountId?: string };
|
||||
|
||||
function normalizeOptionalAccountId(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveFeishuToolAccount(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
}): ResolvedFeishuAccount {
|
||||
if (!params.api.config) {
|
||||
throw new Error("Feishu config unavailable");
|
||||
}
|
||||
return resolveFeishuAccount({
|
||||
cfg: params.api.config,
|
||||
accountId:
|
||||
normalizeOptionalAccountId(params.executeParams?.accountId) ??
|
||||
normalizeOptionalAccountId(params.defaultAccountId),
|
||||
});
|
||||
}
|
||||
|
||||
export function createFeishuToolClient(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
}): Lark.Client {
|
||||
return createFeishuClient(resolveFeishuToolAccount(params));
|
||||
}
|
||||
|
||||
export function resolveAnyEnabledFeishuToolsConfig(
|
||||
accounts: ResolvedFeishuAccount[],
|
||||
): Required<FeishuToolsConfig> {
|
||||
const merged: Required<FeishuToolsConfig> = {
|
||||
doc: false,
|
||||
wiki: false,
|
||||
drive: false,
|
||||
perm: false,
|
||||
scopes: false,
|
||||
};
|
||||
for (const account of accounts) {
|
||||
const cfg = resolveToolsConfig(account.config.tools);
|
||||
merged.doc = merged.doc || cfg.doc;
|
||||
merged.wiki = merged.wiki || cfg.wiki;
|
||||
merged.drive = merged.drive || cfg.drive;
|
||||
merged.perm = merged.perm || cfg.perm;
|
||||
merged.scopes = merged.scopes || cfg.scopes;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
76
extensions/feishu/src/tool-factory-test-harness.ts
Normal file
76
extensions/feishu/src/tool-factory-test-harness.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
type ToolContextLike = {
|
||||
agentAccountId?: string;
|
||||
};
|
||||
|
||||
type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] | null | undefined;
|
||||
|
||||
export type ToolLike = {
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: unknown) => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
type RegisteredTool = {
|
||||
tool: AnyAgentTool | ToolFactoryLike;
|
||||
opts?: { name?: string };
|
||||
};
|
||||
|
||||
function toToolList(value: AnyAgentTool | AnyAgentTool[] | null | undefined): AnyAgentTool[] {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike {
|
||||
const candidate = tool as Partial<ToolLike>;
|
||||
const name = candidate.name ?? fallbackName;
|
||||
const execute = candidate.execute;
|
||||
if (!name || typeof execute !== "function") {
|
||||
throw new Error(`Resolved tool is missing required fields (name=${String(name)})`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
execute: (toolCallId, params) => execute(toolCallId, params),
|
||||
};
|
||||
}
|
||||
|
||||
export function createToolFactoryHarness(cfg: OpenClawPluginApi["config"]) {
|
||||
const registered: RegisteredTool[] = [];
|
||||
|
||||
const api: Pick<OpenClawPluginApi, "config" | "logger" | "registerTool"> = {
|
||||
config: cfg,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
registerTool: (tool, opts) => {
|
||||
registered.push({ tool, opts });
|
||||
},
|
||||
};
|
||||
|
||||
const resolveTool = (name: string, ctx: ToolContextLike = {}): ToolLike => {
|
||||
for (const entry of registered) {
|
||||
if (entry.opts?.name === name && typeof entry.tool !== "function") {
|
||||
return asToolLike(entry.tool, name);
|
||||
}
|
||||
|
||||
if (typeof entry.tool === "function") {
|
||||
const builtTools = toToolList(entry.tool(ctx));
|
||||
const hit = builtTools.find((tool) => (tool as { name?: string }).name === name);
|
||||
if (hit) {
|
||||
return asToolLike(hit, name);
|
||||
}
|
||||
} else if ((entry.tool as { name?: string }).name === name) {
|
||||
return asToolLike(entry.tool, name);
|
||||
}
|
||||
}
|
||||
throw new Error(`Tool not registered: ${name}`);
|
||||
};
|
||||
|
||||
return {
|
||||
api: api as OpenClawPluginApi,
|
||||
resolveTool,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
||||
import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
@@ -168,62 +167,68 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
||||
if (!toolsCfg.wiki) {
|
||||
api.logger.debug?.("feishu_wiki: wiki tool disabled in config");
|
||||
return;
|
||||
}
|
||||
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
type FeishuWikiExecuteParams = FeishuWikiParams & { accountId?: string };
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_wiki",
|
||||
label: "Feishu Wiki",
|
||||
description:
|
||||
"Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename",
|
||||
parameters: FeishuWikiSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuWikiParams;
|
||||
try {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "spaces":
|
||||
return json(await listSpaces(client));
|
||||
case "nodes":
|
||||
return json(await listNodes(client, p.space_id, p.parent_node_token));
|
||||
case "get":
|
||||
return json(await getNode(client, p.token));
|
||||
case "search":
|
||||
return json({
|
||||
error:
|
||||
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
|
||||
});
|
||||
case "create":
|
||||
return json(
|
||||
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
|
||||
);
|
||||
case "move":
|
||||
return json(
|
||||
await moveNode(
|
||||
client,
|
||||
p.space_id,
|
||||
p.node_token,
|
||||
p.target_space_id,
|
||||
p.target_parent_token,
|
||||
),
|
||||
);
|
||||
case "rename":
|
||||
return json(await renameNode(client, p.space_id, p.node_token, p.title));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).action}` });
|
||||
(ctx) => {
|
||||
const defaultAccountId = ctx.agentAccountId;
|
||||
return {
|
||||
name: "feishu_wiki",
|
||||
label: "Feishu Wiki",
|
||||
description:
|
||||
"Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename",
|
||||
parameters: FeishuWikiSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuWikiExecuteParams;
|
||||
try {
|
||||
const client = createFeishuToolClient({
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
});
|
||||
switch (p.action) {
|
||||
case "spaces":
|
||||
return json(await listSpaces(client));
|
||||
case "nodes":
|
||||
return json(await listNodes(client, p.space_id, p.parent_node_token));
|
||||
case "get":
|
||||
return json(await getNode(client, p.token));
|
||||
case "search":
|
||||
return json({
|
||||
error:
|
||||
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
|
||||
});
|
||||
case "create":
|
||||
return json(
|
||||
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
|
||||
);
|
||||
case "move":
|
||||
return json(
|
||||
await moveNode(
|
||||
client,
|
||||
p.space_id,
|
||||
p.node_token,
|
||||
p.target_space_id,
|
||||
p.target_parent_token,
|
||||
),
|
||||
);
|
||||
case "rename":
|
||||
return json(await renameNode(client, p.space_id, p.node_token, p.title));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).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_wiki" },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user