Skip to content

Commit c582bc2

Browse files
committed
fix(feishu): drive info finds files past the first page
1 parent 982efe5 commit c582bc2

4 files changed

Lines changed: 196 additions & 9 deletions

File tree

docs/channels/feishu.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@ The plugin ships agent tools for Feishu documents, chats, knowledge base, cloud
324324

325325
`tools.base` is an alias for `tools.bitable`; the explicit `bitable` value wins when both are set. Per-account gates live under `accounts.<id>.tools`.
326326

327+
Grant `drive:drive.metadata:readonly` for direct `feishu_drive info` lookups outside the root
328+
directory, unless the app already has the full `drive:drive` scope. Without either scope, `info`
329+
keeps the legacy root-directory lookup available through `drive:drive:readonly`.
330+
327331
### ACP sessions
328332

329333
Feishu/Lark supports ACP for DMs and group thread messages. Feishu/Lark ACP is text-command driven - there are no native slash-command menus, so use `/acp ...` messages directly in the conversation.

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ root-list cursors are not forwarded.
4848
{ "action": "info", "file_token": "ABC123", "type": "docx" }
4949
```
5050

51-
Searches for the file in the root directory. Note: file must be in root or use `list` to browse folders first.
51+
Looks up file metadata directly by token and type, regardless of which shared folder contains it.
52+
Shortcuts are the exception: Feishu's metadata API does not support the `shortcut` type, so shortcut
53+
info retains the root-directory lookup behavior.
5254

5355
`type`: `doc`, `docx`, `sheet`, `bitable`, `folder`, `file`, `mindnote`, `shortcut`
5456

@@ -101,7 +103,8 @@ channels:
101103
## Permissions
102104
103105
- `drive:drive` - Full access (create, move, delete)
104-
- `drive:drive:readonly` - Read only (list, info)
106+
- `drive:drive:readonly` - Read only (list and root-level info fallback)
107+
- `drive:drive.metadata:readonly` - Direct `info` lookup outside the root (not needed with `drive:drive`)
105108

106109
## Known Limitations
107110

extensions/feishu/src/drive.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,137 @@ describe("registerFeishuDriveTools", () => {
436436
});
437437
});
438438

439+
it("looks up file info directly by token and type", async () => {
440+
const listFiles = vi.fn();
441+
const batchQuery = vi.fn().mockResolvedValue({
442+
code: 0,
443+
data: {
444+
metas: [
445+
{
446+
doc_token: "doc_1",
447+
doc_type: "docx",
448+
title: "Project Plan",
449+
url: "https://example.test/doc_1",
450+
create_time: "1710000000",
451+
latest_modify_time: "1710001000",
452+
owner_id: "ou_owner",
453+
},
454+
],
455+
},
456+
});
457+
createFeishuToolClientMock.mockReturnValue({
458+
drive: { file: { list: listFiles }, meta: { batchQuery } },
459+
});
460+
const tool = buildDriveTool();
461+
462+
const result = await tool.execute("call-info", {
463+
action: "info",
464+
file_token: "doc_1",
465+
type: "docx",
466+
});
467+
468+
expect(batchQuery).toHaveBeenCalledWith({
469+
data: {
470+
request_docs: [{ doc_token: "doc_1", doc_type: "docx" }],
471+
with_url: true,
472+
},
473+
});
474+
expect(listFiles).not.toHaveBeenCalled();
475+
expect(result.details).toEqual({
476+
token: "doc_1",
477+
name: "Project Plan",
478+
type: "docx",
479+
url: "https://example.test/doc_1",
480+
created_time: "1710000000",
481+
modified_time: "1710001000",
482+
owner_id: "ou_owner",
483+
});
484+
});
485+
486+
it("reports a missing file when metadata lookup returns a failed entry", async () => {
487+
const batchQuery = vi.fn().mockResolvedValue({
488+
code: 0,
489+
data: { metas: [], failed_list: [{ code: 970005, token: "missing_doc" }] },
490+
});
491+
createFeishuToolClientMock.mockReturnValue({
492+
drive: { meta: { batchQuery } },
493+
});
494+
const tool = buildDriveTool();
495+
496+
const result = await tool.execute("call-info-missing", {
497+
action: "info",
498+
file_token: "missing_doc",
499+
type: "docx",
500+
});
501+
502+
expect(result.details).toMatchObject({ error: "File not found: missing_doc" });
503+
});
504+
505+
it("keeps root-list lookup for shortcut info", async () => {
506+
const batchQuery = vi.fn();
507+
const listFiles = vi.fn().mockResolvedValue({
508+
code: 0,
509+
data: {
510+
files: [
511+
{
512+
token: "shortcut_1",
513+
name: "Project shortcut",
514+
type: "shortcut",
515+
url: "https://example.test/shortcut_1",
516+
},
517+
],
518+
},
519+
});
520+
createFeishuToolClientMock.mockReturnValue({
521+
drive: { file: { list: listFiles }, meta: { batchQuery } },
522+
});
523+
const tool = buildDriveTool();
524+
525+
const result = await tool.execute("call-info-shortcut", {
526+
action: "info",
527+
file_token: "shortcut_1",
528+
type: "shortcut",
529+
});
530+
531+
expect(listFiles).toHaveBeenCalledWith({ params: {} });
532+
expect(batchQuery).not.toHaveBeenCalled();
533+
expect(result.details).toMatchObject({
534+
token: "shortcut_1",
535+
name: "Project shortcut",
536+
type: "shortcut",
537+
});
538+
});
539+
540+
it("falls back to root lookup when the metadata scope is missing", async () => {
541+
const batchQuery = vi.fn().mockRejectedValue({
542+
response: {
543+
data: {
544+
code: 99991672,
545+
msg: "permission denied: drive:drive.metadata:readonly",
546+
},
547+
},
548+
});
549+
const listFiles = vi.fn().mockResolvedValue({
550+
code: 0,
551+
data: {
552+
files: [{ token: "doc_1", name: "Project Plan", type: "docx" }],
553+
},
554+
});
555+
createFeishuToolClientMock.mockReturnValue({
556+
drive: { file: { list: listFiles }, meta: { batchQuery } },
557+
});
558+
const tool = buildDriveTool();
559+
560+
const result = await tool.execute("call-info-missing-scope", {
561+
action: "info",
562+
file_token: "doc_1",
563+
type: "docx",
564+
});
565+
566+
expect(listFiles).toHaveBeenCalledWith({ params: {} });
567+
expect(result.details).toMatchObject({ token: "doc_1", name: "Project Plan", type: "docx" });
568+
});
569+
439570
it("normalizes folder pagination and suppresses it for root listings", async () => {
440571
const listFiles = vi.fn().mockResolvedValue({ code: 0, data: { files: [] } });
441572
createFeishuToolClientMock.mockReturnValue({ drive: { file: { list: listFiles } } });

extensions/feishu/src/drive.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -367,16 +367,13 @@ async function listFolder(client: Lark.Client, params: Record<string, unknown> =
367367
};
368368
}
369369

370-
async function getFileInfo(client: Lark.Client, fileToken: string, folderToken?: string) {
371-
// Use list with folder_token to find file info
372-
const res = await client.drive.file.list({
373-
params: folderToken ? { folder_token: folderToken } : {},
374-
});
370+
async function getRootFileInfo(client: Lark.Client, fileToken: string) {
371+
const res = await client.drive.file.list({ params: {} });
375372
if (res.code !== 0) {
376373
throw new Error(res.msg);
377374
}
378375

379-
const file = res.data?.files?.find((f) => f.token === fileToken);
376+
const file = res.data?.files?.find((candidate) => candidate.token === fileToken);
380377
if (!file) {
381378
throw new Error(`File not found: ${fileToken}`);
382379
}
@@ -392,6 +389,58 @@ async function getFileInfo(client: Lark.Client, fileToken: string, folderToken?:
392389
};
393390
}
394391

392+
async function getFileInfo(
393+
client: Lark.Client,
394+
fileToken: string,
395+
type: Extract<FeishuDriveParams, { action: "info" }>["type"],
396+
) {
397+
if (type === "shortcut") {
398+
// The metadata API does not accept shortcut as a document type. Keep the existing
399+
// root-list behavior so the advertised shortcut info contract does not regress.
400+
return getRootFileInfo(client, fileToken);
401+
}
402+
403+
let res: Awaited<ReturnType<Lark.Client["drive"]["meta"]["batchQuery"]>>;
404+
try {
405+
res = await client.drive.meta.batchQuery({
406+
data: {
407+
request_docs: [{ doc_token: fileToken, doc_type: type }],
408+
with_url: true,
409+
},
410+
});
411+
} catch (error) {
412+
if (extractDriveApiErrorMeta(error).feishuCode === 99991672) {
413+
// Existing read-only apps may not have the newer metadata scope. Preserve their
414+
// root-file lookup while allowing scoped apps to resolve files in any shared folder.
415+
return getRootFileInfo(client, fileToken);
416+
}
417+
throw error;
418+
}
419+
if (res.code === 99991672) {
420+
return getRootFileInfo(client, fileToken);
421+
}
422+
if (res.code !== 0) {
423+
throw new Error(res.msg);
424+
}
425+
426+
const file = res.data?.metas?.find(
427+
(meta) => meta.doc_token === fileToken || meta.request_doc_info?.doc_token === fileToken,
428+
);
429+
if (!file) {
430+
throw new Error(`File not found: ${fileToken}`);
431+
}
432+
433+
return {
434+
token: file.doc_token,
435+
name: file.title,
436+
type: file.doc_type,
437+
url: file.url,
438+
created_time: file.create_time,
439+
modified_time: file.latest_modify_time,
440+
owner_id: file.owner_id,
441+
};
442+
}
443+
395444
async function createFolder(client: Lark.Client, name: string, folderToken?: string) {
396445
// Feishu supports using folder_token="0" as the root folder.
397446
// We *try* to resolve the real root token (explorer API), but fall back to "0"
@@ -791,7 +840,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
791840
}),
792841
);
793842
case "info":
794-
return jsonResult(await getFileInfo(client, p.file_token));
843+
return jsonResult(await getFileInfo(client, p.file_token, p.type));
795844
case "create_folder":
796845
return jsonResult(await createFolder(client, p.name, p.folder_token));
797846
case "move":

0 commit comments

Comments
 (0)