Skip to content

Commit b2b719c

Browse files
authored
Merge 729ba20 into cd5c3fc
2 parents cd5c3fc + 729ba20 commit b2b719c

4 files changed

Lines changed: 181 additions & 7 deletions

File tree

extensions/feishu/skills/feishu-drive/SKILL.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,27 @@ From URL `https://xxx.feishu.cn/drive/folder/ABC123` → `folder_token` = `ABC12
2020
{ "action": "list" }
2121
```
2222

23-
Root directory (no folder_token).
23+
Requests the account root (no `folder_token`). Bot credentials normally have no root folder, so
24+
use a folder that has been shared with the bot instead.
2425

2526
```json
26-
{ "action": "list", "folder_token": "fldcnXXX" }
27+
{ "action": "list", "folder_token": "fldcnXXX", "page_size": 100 }
2728
```
2829

29-
Returns: files with token, name, type, url, timestamps.
30+
Returns one page of files with token, name, type, url, timestamps, and `next_page_token` when
31+
another page is available. To continue, pass the returned token with the same folder token:
32+
33+
```json
34+
{
35+
"action": "list",
36+
"folder_token": "fldcnXXX",
37+
"page_size": 100,
38+
"page_token": "next-page-token"
39+
}
40+
```
41+
42+
`page_size` must be between 1 and 200. Pagination requires a concrete shared `folder_token`;
43+
root-list cursors are not forwarded.
3044

3145
### Get File Info
3246

extensions/feishu/src/drive-schema.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ export const FeishuDriveSchema = Type.Union([
2626
folder_token: Type.Optional(
2727
Type.String({ description: "Folder token (optional, omit for root directory)" }),
2828
),
29+
page_size: Type.Optional(
30+
Type.Integer({
31+
minimum: 1,
32+
maximum: 200,
33+
description: "Items per folder page (1-200; requires folder_token)",
34+
}),
35+
),
36+
page_token: Type.Optional(
37+
Type.String({
38+
description: "Continuation token from a prior list result (requires the same folder_token)",
39+
}),
40+
),
2941
}),
3042
Type.Object({
3143
action: Type.Literal("info"),

extensions/feishu/src/drive.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function mockCallArg<T>(
6262
type FeishuDriveTool = {
6363
execute: (callId: string, input: Record<string, unknown>) => Promise<{ details?: unknown }>;
6464
name?: string;
65+
parameters?: unknown;
6566
};
6667

6768
type FeishuDriveToolFactory = (context: {
@@ -73,6 +74,26 @@ function firstToolFactory(mock: { mock: { calls: unknown[][] } }): FeishuDriveTo
7374
return mockCallArg<FeishuDriveToolFactory>(mock, 0, 0);
7475
}
7576

77+
function buildDriveTool(): FeishuDriveTool {
78+
const registerTool = vi.fn();
79+
registerFeishuDriveTools(
80+
createDriveToolApi({
81+
config: {
82+
channels: {
83+
feishu: {
84+
enabled: true,
85+
appId: "app_id",
86+
appSecret: "app_secret", // pragma: allowlist secret
87+
tools: { drive: true },
88+
},
89+
},
90+
},
91+
registerTool,
92+
}),
93+
);
94+
return firstToolFactory(registerTool)({ agentAccountId: undefined });
95+
}
96+
7697
function firstLogMessage(mock: { mock: { calls: unknown[][] } }): string {
7798
return String(mockCallArg<unknown>(mock, 0, 0));
7899
}
@@ -107,6 +128,25 @@ function expectRequestCall(
107128
}
108129
}
109130

131+
function schemaForAction(
132+
schema: unknown,
133+
action: string,
134+
): { properties?: Record<string, unknown> } {
135+
const variants =
136+
(schema as { anyOf?: unknown[]; oneOf?: unknown[] }).anyOf ??
137+
(schema as { anyOf?: unknown[]; oneOf?: unknown[] }).oneOf ??
138+
[];
139+
const variant = variants.find((entry) => {
140+
const actionSchema = (entry as { properties?: { action?: { const?: unknown } } }).properties
141+
?.action;
142+
return actionSchema?.const === action;
143+
});
144+
if (!variant) {
145+
throw new Error(`Missing schema variant for action ${action}`);
146+
}
147+
return variant as { properties?: Record<string, unknown> };
148+
}
149+
110150
describe("registerFeishuDriveTools", () => {
111151
const requestMock = vi.fn();
112152

@@ -360,6 +400,91 @@ describe("registerFeishuDriveTools", () => {
360400
expect((replyCommentResult.details as { reply_id?: string }).reply_id).toBe("r4");
361401
});
362402

403+
it("lists a folder continuation page when page_token is provided", async () => {
404+
const listFiles = vi.fn().mockResolvedValue({
405+
code: 0,
406+
data: {
407+
files: [{ token: "file_1", name: "File 1", type: "docx", url: "https://example.test/doc" }],
408+
next_page_token: "page-3",
409+
},
410+
});
411+
createFeishuToolClientMock.mockReturnValue({
412+
drive: { file: { list: listFiles } },
413+
});
414+
const tool = buildDriveTool();
415+
const listSchema = schemaForAction(tool.parameters, "list");
416+
expect(listSchema.properties?.page_token).toMatchObject({ type: "string" });
417+
expect(listSchema.properties?.page_size).toMatchObject({
418+
type: "integer",
419+
minimum: 1,
420+
maximum: 200,
421+
});
422+
423+
const result = await tool.execute("call-list-page-2", {
424+
action: "list",
425+
folder_token: "folder_1",
426+
page_size: 25,
427+
page_token: "page-2",
428+
});
429+
430+
expect(listFiles).toHaveBeenCalledWith({
431+
params: { folder_token: "folder_1", page_size: 25, page_token: "page-2" },
432+
});
433+
expect(result.details).toMatchObject({
434+
files: [{ token: "file_1", name: "File 1", type: "docx", url: "https://example.test/doc" }],
435+
next_page_token: "page-3",
436+
});
437+
});
438+
439+
it("normalizes folder pagination and suppresses it for root listings", async () => {
440+
const listFiles = vi.fn().mockResolvedValue({ code: 0, data: { files: [] } });
441+
createFeishuToolClientMock.mockReturnValue({ drive: { file: { list: listFiles } } });
442+
const tool = buildDriveTool();
443+
444+
await tool.execute("call-list-normalized", {
445+
action: "list",
446+
folder_token: " folder_1 ",
447+
page_size: "25",
448+
page_token: " ",
449+
});
450+
for (const [callId, folderToken] of [
451+
["call-list-root", undefined],
452+
["call-list-empty", " "],
453+
["call-list-zero", " 0 "],
454+
] as const) {
455+
await tool.execute(callId, {
456+
action: "list",
457+
folder_token: folderToken,
458+
page_size: 25,
459+
page_token: "page-2",
460+
});
461+
}
462+
463+
expect(listFiles).toHaveBeenNthCalledWith(1, {
464+
params: { folder_token: "folder_1", page_size: 25 },
465+
});
466+
for (const callIndex of [2, 3, 4]) {
467+
expect(listFiles).toHaveBeenNthCalledWith(callIndex, { params: {} });
468+
}
469+
});
470+
471+
it.each([0, 201, 1.5])("rejects invalid folder page_size %s", async (pageSize) => {
472+
const listFiles = vi.fn();
473+
createFeishuToolClientMock.mockReturnValue({ drive: { file: { list: listFiles } } });
474+
const tool = buildDriveTool();
475+
476+
const result = await tool.execute("call-list-invalid-page-size", {
477+
action: "list",
478+
folder_token: "folder_1",
479+
page_size: pageSize,
480+
});
481+
482+
expect(result.details).toMatchObject({
483+
error: "page_size must be a positive integer between 1 and 200",
484+
});
485+
expect(listFiles).not.toHaveBeenCalled();
486+
});
487+
363488
it("defaults add_comment file_type to docx when omitted", async () => {
364489
const registerTool = vi.fn();
365490
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});

extensions/feishu/src/drive.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Feishu plugin module implements drive behavior.
22
import type * as Lark from "@larksuiteoapi/node-sdk";
33
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4+
import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers";
45
import { jsonResult } from "openclaw/plugin-sdk/tool-results";
56
import type { OpenClawPluginApi } from "../runtime-api.js";
67
import { listEnabledFeishuAccounts } from "./accounts.js";
@@ -325,11 +326,27 @@ async function getRootFolderToken(client: Lark.Client): Promise<string> {
325326
return token;
326327
}
327328

328-
async function listFolder(client: Lark.Client, folderToken?: string) {
329-
// Filter out invalid folder_token values (empty, "0", etc.)
329+
async function listFolder(client: Lark.Client, params: Record<string, unknown> = {}) {
330+
const folderToken =
331+
typeof params.folder_token === "string" ? params.folder_token.trim() : undefined;
330332
const validFolderToken = folderToken && folderToken !== "0" ? folderToken : undefined;
333+
const pageSize = readPositiveIntegerParam(params, "page_size", {
334+
max: 200,
335+
message: "page_size must be a positive integer between 1 and 200",
336+
});
337+
const pageToken = typeof params.page_token === "string" ? params.page_token.trim() : undefined;
338+
339+
// Bot credentials have no browsable root. A continuation cursor is only valid with the
340+
// same concrete folder token that produced it, so do not forward pagination for root.
341+
const listParams = validFolderToken
342+
? {
343+
folder_token: validFolderToken,
344+
...(pageSize ? { page_size: pageSize } : {}),
345+
...(pageToken ? { page_token: pageToken } : {}),
346+
}
347+
: {};
331348
const res = await client.drive.file.list({
332-
params: validFolderToken ? { folder_token: validFolderToken } : {},
349+
params: listParams,
333350
});
334351
if (res.code !== 0) {
335352
throw new Error(res.msg);
@@ -766,7 +783,13 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
766783
});
767784
switch (p.action) {
768785
case "list":
769-
return jsonResult(await listFolder(client, p.folder_token));
786+
return jsonResult(
787+
await listFolder(client, {
788+
folder_token: p.folder_token,
789+
page_size: p.page_size,
790+
page_token: p.page_token,
791+
}),
792+
);
770793
case "info":
771794
return jsonResult(await getFileInfo(client, p.file_token));
772795
case "create_folder":

0 commit comments

Comments
 (0)