diff --git a/shortcuts/doc/doc_media_preview.go b/shortcuts/doc/doc_media_preview.go new file mode 100644 index 000000000..989732644 --- /dev/null +++ b/shortcuts/doc/doc_media_preview.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/shortcuts/common" +) + +var previewMimeToExt = map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "application/pdf": ".pdf", + "video/mp4": ".mp4", + "text/plain": ".txt", +} + +const PreviewType_SOURCE_FILE = "16" + +var DocMediaPreview = common.Shortcut{ + Service: "docs", + Command: "+media-preview", + Description: "Preview document media file (auto-detects extension)", + Risk: "read", + Scopes: []string{"docs:document.media:download"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "token", Desc: "media file token", Required: true}, + {Name: "output", Desc: "local save path", Required: true}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("token") + outputPath := runtime.Str("output") + return common.NewDryRunAPI(). + GET("/open-apis/drive/v1/medias/:token/preview_download"). + Desc("Preview document media file"). + Params(map[string]interface{}{"preview_type": PreviewType_SOURCE_FILE}). + Set("token", token).Set("output", outputPath) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("token") + outputPath := runtime.Str("output") + overwrite := runtime.Bool("overwrite") + + if err := validate.ResourceName(token, "--token"); err != nil { + return output.ErrValidation("%s", err) + } + // Early path validation before API call (final validation after auto-extension below) + if _, err := validate.SafeOutputPath(outputPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token)) + + encodedToken := validate.EncodePathSegment(token) + apiPath := fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", encodedToken) + + resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: apiPath, + QueryParams: larkcore.QueryParams{ + "preview_type": []string{PreviewType_SOURCE_FILE}, + }, + }) + if err != nil { + return output.ErrNetwork("preview failed: %v", err) + } + defer resp.Body.Close() + + finalPath := outputPath + currentExt := filepath.Ext(outputPath) + if currentExt == "" { + contentType := resp.Header.Get("Content-Type") + mimeType := strings.Split(contentType, ";")[0] + mimeType = strings.TrimSpace(mimeType) + if ext, ok := previewMimeToExt[mimeType]; ok { + finalPath = outputPath + ext + } + } + + safePath, err := validate.SafeOutputPath(finalPath) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + if err := common.EnsureWritableFile(safePath, overwrite); err != nil { + return err + } + + if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { + return output.Errorf(output.ExitInternal, "io", "cannot create parent directory: %v", err) + } + + sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) + if err != nil { + return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err) + } + + runtime.Out(map[string]interface{}{ + "saved_path": safePath, + "size_bytes": sizeBytes, + "content_type": resp.Header.Get("Content-Type"), + }, nil) + return nil + }, +} diff --git a/shortcuts/doc/doc_media_test.go b/shortcuts/doc/doc_media_test.go index ee810f676..f5d21e3ef 100644 --- a/shortcuts/doc/doc_media_test.go +++ b/shortcuts/doc/doc_media_test.go @@ -285,12 +285,99 @@ func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) { } } +func TestDocMediaPreviewDryRunUsesMediaEndpoint(t *testing.T) { + cmd := &cobra.Command{Use: "docs +media-preview"} + cmd.Flags().String("token", "", "") + cmd.Flags().String("output", "", "") + if err := cmd.Flags().Set("token", "tok_preview"); err != nil { + t.Fatalf("set --token: %v", err) + } + if err := cmd.Flags().Set("output", "./asset"); err != nil { + t.Fatalf("set --output: %v", err) + } + + dry := decodeDocDryRun(t, DocMediaPreview.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil))) + if len(dry.API) != 1 { + t.Fatalf("expected 1 API call, got %d", len(dry.API)) + } + if dry.API[0].Desc != "Preview document media file" { + t.Fatalf("dry-run api desc = %q", dry.API[0].Desc) + } + if dry.API[0].URL != "/open-apis/drive/v1/medias/tok_preview/preview_download" { + t.Fatalf("URL = %q, want media preview endpoint", dry.API[0].URL) + } + if got, _ := dry.API[0].Params["preview_type"].(string); got != PreviewType_SOURCE_FILE { + t.Fatalf("preview_type = %q, want %q", got, PreviewType_SOURCE_FILE) + } +} + +func TestDocMediaPreviewRejectsOverwriteWithoutFlag(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-overwrite-app")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE, + Status: 200, + Body: []byte("new"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + if err := os.WriteFile("preview.bin", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDocs(t, DocMediaPreview, []string{ + "+media-preview", + "--token", "tok_123", + "--output", "preview.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected overwrite protection error, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDocMediaPreviewRejectsHTTPErrorBeforeWrite(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-app")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE, + Status: 404, + Body: "not found", + Headers: http.Header{"Content-Type": []string{"text/plain"}}, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + + err := mountAndRunDocs(t, DocMediaPreview, []string{ + "+media-preview", + "--token", "tok_123", + "--output", "preview.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected HTTP error, got nil") + } + if !strings.Contains(err.Error(), "HTTP 404") { + t.Fatalf("unexpected error: %v", err) + } + if _, statErr := os.Stat(filepath.Join(tmpDir, "preview.bin")); !os.IsNotExist(statErr) { + t.Fatalf("preview target should not be created, statErr=%v", statErr) + } +} + type docDryRunOutput struct { Description string `json:"description"` API []struct { - Desc string `json:"desc"` - URL string `json:"url"` - Body map[string]interface{} `json:"body"` + Desc string `json:"desc"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` } `json:"api"` } diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go index 36ac18936..6f3f3ab6f 100644 --- a/shortcuts/doc/shortcuts.go +++ b/shortcuts/doc/shortcuts.go @@ -13,6 +13,7 @@ func Shortcuts() []common.Shortcut { DocsFetch, DocsUpdate, DocMediaInsert, + DocMediaPreview, DocMediaDownload, } } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 2d169617f..81d316fef 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -67,6 +67,19 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) { } } +func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, &cmdutil.Factory{}) + + previewCmd, _, err := program.Find([]string{"docs", "+media-preview"}) + if err != nil { + t.Fatalf("find docs media preview shortcut: %v", err) + } + if previewCmd == nil || previewCmd.Name() != "+media-preview" { + t.Fatalf("docs media preview shortcut not mounted: %#v", previewCmd) + } +} + func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) { program := &cobra.Command{Use: "root"} existingBase := &cobra.Command{Use: "base", Short: "existing base service"} diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 76ccdab45..ca3b35dec 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -120,6 +120,9 @@ Drive Folder (云空间文件夹) ## 快速决策 - 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。 +- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。 +- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。 +- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。 - `docs +search` 不是只搜文档 / Wiki;结果里会直接返回 `SHEET` 等云空间对象。 - 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。 @@ -137,6 +140,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`) | [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content | | [`+update`](references/lark-doc-update.md) | Update a Lark document | | [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) | +| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) | | [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) | | [`+whiteboard-update`](references/lark-doc-whiteboard-update.md) | Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details. | - diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index 53b583c77..38a24eb54 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -33,7 +33,7 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty ## 重要:图片、文件、画板的处理 -**文档中的图片、文件、画板需要通过 `lark-doc-media-download`(docs +media-download)单独获取!** +**文档中的图片、文件、画板需要通过独立的 media shortcut 单独获取。** ### 识别格式 @@ -60,7 +60,11 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty ### 获取步骤 1. 从 HTML 标签中提取 `token` 属性值 -2. 调用 lark-doc-media-download(docs +media-download): +2. 如果目标是图片/文件素材,且用户只是想查看/预览,调用 [`lark-doc-media-preview`](lark-doc-media-preview.md)(`docs +media-preview`): + ```bash + lark-cli docs +media-preview --token "提取的token" --output ./preview_media + ``` +3. 如果用户明确要下载,或目标是 ``,调用 [`lark-doc-media-download`](lark-doc-media-download.md)(`docs +media-download`): ```bash lark-cli docs +media-download --token "提取的token" --output ./downloaded_media ``` @@ -87,6 +91,7 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty | 需求 | 工具 | |------|------| | 获取文档文本 | `docs +fetch` | +| 预览图片/文件素材 | `docs +media-preview` | | 下载图片/文件/画板 | `docs +media-download` | | 创建新文档 | `docs +create` | | 更新文档内容 | `docs +update` | @@ -95,5 +100,6 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty - [lark-doc-create](lark-doc-create.md) — 创建文档 - [lark-doc-update](lark-doc-update.md) — 更新文档 +- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材 - [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图 - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-doc/references/lark-doc-media-download.md b/skills/lark-doc/references/lark-doc-media-download.md index 235460ac7..73f4946c8 100644 --- a/skills/lark-doc/references/lark-doc-media-download.md +++ b/skills/lark-doc/references/lark-doc-media-download.md @@ -5,6 +5,12 @@ 下载文档中的图片/文件素材(`file_token`),或下载画板缩略图(`whiteboard_id`)。当 `--output` 不带扩展名时,会根据响应的 `Content-Type` 自动补全扩展名。 +## 选择规则 + +- 用户明确说“下载素材”时,使用 `docs +media-download` +- 用户只是想查看、预览图片或文件素材时,优先使用 [`docs +media-preview`](lark-doc-media-preview.md) +- 如果目标明确是画板 / whiteboard / 画板缩略图,继续使用 `docs +media-download --type whiteboard`;`+media-preview` 不支持画板 + ## 命令 ```bash @@ -33,7 +39,12 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output - 文件:`` - 画板:`` +## 排障 + +- 如果报错返回的信息包含 `HTTP 403`,且目标是图片/文件素材,可以改成调用 [`docs +media-preview`](lark-doc-media-preview.md) 看是否能先预览内容 + ## 参考 - [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容(用于提取 token) +- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材 - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-doc/references/lark-doc-media-preview.md b/skills/lark-doc/references/lark-doc-media-preview.md new file mode 100644 index 000000000..b29bebffd --- /dev/null +++ b/skills/lark-doc/references/lark-doc-media-preview.md @@ -0,0 +1,41 @@ + +# docs +media-preview(预览文档素材) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +优先用于查看、预览文档中的图片或文件素材(`file_token`)。命令会把素材保存到本地路径,便于后续打开查看内容。 + +## 选择规则 + +- 用户说“看一下素材 / 图片 / 附件”“预览一下”时,优先使用 `docs +media-preview` +- 用户明确说“下载”时,使用 [`docs +media-download`](lark-doc-media-download.md) +- 如果目标明确是画板 / whiteboard / 画板缩略图,不要使用 `+media-preview`,改用 `docs +media-download --type whiteboard` + +## 命令 + +```bash +# 预览图片/文件素材 +lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset + +# 指定输出文件名(带扩展名则不会自动补全) +lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset.png +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token ` | 是 | 素材 token,即 `file_token` | +| `--output ` | 是 | 本地保存路径;不带扩展名会自动补全 | + +## token 从哪里来 + +- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含: + - 图片:`` + - 文件:`` + +## 参考 + +- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容(用于提取 token) +- [lark-doc-media-download](lark-doc-media-download.md) — 明确下载素材,或下载画板缩略图 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数