Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions shortcuts/doc/doc_media_preview.go
Original file line number Diff line number Diff line change
@@ -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",
}
Comment thread
wittam-01 marked this conversation as resolved.

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)
}
Comment thread
wittam-01 marked this conversation as resolved.

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},
},
Comment thread
greptile-apps[bot] marked this conversation as resolved.
})
if err != nil {
return output.ErrNetwork("preview failed: %v", err)
Comment thread
wittam-01 marked this conversation as resolved.
}
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
}
Comment thread
wittam-01 marked this conversation as resolved.

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
},
}
93 changes: 90 additions & 3 deletions shortcuts/doc/doc_media_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
1 change: 1 addition & 0 deletions shortcuts/doc/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func Shortcuts() []common.Shortcut {
DocsFetch,
DocsUpdate,
DocMediaInsert,
DocMediaPreview,
DocMediaDownload,
}
}
13 changes: 13 additions & 0 deletions shortcuts/register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
5 changes: 4 additions & 1 deletion skills/lark-doc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 做对象内部读取、筛选、写入等操作。

Expand All @@ -137,6 +140,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [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. |

10 changes: 8 additions & 2 deletions skills/lark-doc/references/lark-doc-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty

## 重要:图片、文件、画板的处理

**文档中的图片、文件、画板需要通过 `lark-doc-media-download`(docs +media-download)单独获取**
**文档中的图片、文件、画板需要通过独立的 media shortcut 单独获取**

### 识别格式

Expand All @@ -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. 如果用户明确要下载,或目标是 `<whiteboard token="..."/>`,调用 [`lark-doc-media-download`](lark-doc-media-download.md)(`docs +media-download`):
```bash
lark-cli docs +media-download --token "提取的token" --output ./downloaded_media
```
Expand All @@ -87,6 +91,7 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
| 需求 | 工具 |
|------|------|
| 获取文档文本 | `docs +fetch` |
| 预览图片/文件素材 | `docs +media-preview` |
| 下载图片/文件/画板 | `docs +media-download` |
| 创建新文档 | `docs +create` |
| 更新文档内容 | `docs +update` |
Expand All @@ -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) — 认证和全局参数
11 changes: 11 additions & 0 deletions skills/lark-doc/references/lark-doc-media-download.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,7 +39,12 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output
- 文件:`<file token="..." name="..."/>`
- 画板:`<whiteboard token="..."/>`

## 排障

- 如果报错返回的信息包含 `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) — 认证和全局参数
41 changes: 41 additions & 0 deletions skills/lark-doc/references/lark-doc-media-preview.md
Original file line number Diff line number Diff line change
@@ -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>` | 是 | 素材 token,即 `file_token` |
| `--output <path>` | 是 | 本地保存路径;不带扩展名会自动补全 |
Comment thread
wittam-01 marked this conversation as resolved.

## token 从哪里来

- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
- 图片:`<image token="..." .../>`
- 文件:`<file token="..." name="..."/>`

## 参考

- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容(用于提取 token)
- [lark-doc-media-download](lark-doc-media-download.md) — 明确下载素材,或下载画板缩略图
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
Loading