From 322768a280132cff4c75e4e15fca2ebbf1eb2125 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 10 Jun 2026 11:18:02 +0800 Subject: [PATCH] feat(docs): add cover-get/cover-update/cover-delete for docx cover image meego 7332271137. Adds three docs shortcuts wrapping the docx OpenAPI so AI agents / developers can manage a docx document cover image without hand-writing raw OpenAPI: - docs +cover-get GET /open-apis/docx/v1/documents/:id -> data.document.cover - docs +cover-update PATCH update_cover.cover={token, offset_ratio_x?, offset_ratio_y?} - docs +cover-delete PATCH update_cover.cover=null Offsets are optional and only sent when explicitly provided (no default injected); client-side validation rejects non-finite values, range is left to the server. --doc accepts a docx URL/token; wiki/doc refs return a structured, actionable error. cover-update --token must have a docx_image relation to the doc (two-step: docs +media-upload then cover-update); a media-insert body image token is rejected by the server with a relation mismatch. lark-doc skill docs updated with usage + the token relation rule. Unit tests cover URL/id parsing, offset parse/validation, update/delete request bodies, and required-token. Spec source: active@8da405649f41fa65cc453c449f95dc15120c427fdc81a2c54ef169219eac0494 Co-Authored-By: Claude Opus 4.8 --- shortcuts/doc/docs_cover.go | 227 +++++++++++++++++++ shortcuts/doc/docs_cover_test.go | 155 +++++++++++++ shortcuts/doc/shortcuts.go | 3 + skills/lark-doc/SKILL.md | 3 + skills/lark-doc/references/lark-doc-cover.md | 57 +++++ 5 files changed, 445 insertions(+) create mode 100644 shortcuts/doc/docs_cover.go create mode 100644 shortcuts/doc/docs_cover_test.go create mode 100644 skills/lark-doc/references/lark-doc-cover.md diff --git a/shortcuts/doc/docs_cover.go b/shortcuts/doc/docs_cover.go new file mode 100644 index 000000000..2d94496bc --- /dev/null +++ b/shortcuts/doc/docs_cover.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "io" + "math" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// docxDocumentAPIPath is the docx v1 document endpoint used for cover GET/PATCH. +const docxDocumentAPIPath = "/open-apis/docx/v1/documents/%s" + +// resolveCoverDocumentID returns the docx document_id for cover operations. +// The cover OpenAPI (GET/PATCH /open-apis/docx/v1/documents/:document_id) only +// accepts a docx document_id. wiki/doc refs are rejected with a structured, +// actionable error — this iteration does not resolve wiki → docx. +func resolveCoverDocumentID(runtime *common.RuntimeContext) (string, error) { + ref, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { + return "", err + } + if ref.Kind != "docx" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--doc kind %q is not supported for cover operations; pass a docx document URL or token (the cover API needs a docx document_id)", ref.Kind).WithParam("--doc") + } + return ref.Token, nil +} + +// parseOptionalOffset reads an optional float flag. Returns (value, present, error). +// Not provided (empty) → present=false so the caller omits the field entirely +// (no default is injected). Provided → only finite numbers pass; NaN/Inf/non-numeric +// are rejected client-side. The accepted numeric range is left to the server. +func parseOptionalOffset(runtime *common.RuntimeContext, name string) (float64, bool, error) { + raw := strings.TrimSpace(runtime.Str(name)) + if raw == "" { + return 0, false, nil + } + v, err := strconv.ParseFloat(raw, 64) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { + return 0, false, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--%s must be a finite number, got %q", name, raw).WithParam("--" + name) + } + return v, true, nil +} + +// extractCover pulls data.document.cover out of the docx document response envelope. +func extractCover(data map[string]interface{}) interface{} { + doc, ok := data["document"].(map[string]interface{}) + if !ok { + return nil + } + return doc["cover"] +} + +// ---------------- cover-get ---------------- + +func validateCoverDoc(_ context.Context, runtime *common.RuntimeContext) error { + _, err := resolveCoverDocumentID(runtime) + return err +} + +func dryRunCoverGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + id, _ := resolveCoverDocumentID(runtime) + return common.NewDryRunAPI(). + GET(fmt.Sprintf(docxDocumentAPIPath, id)). + Desc("OpenAPI: get document (cover in data.document.cover)"). + Set("document_id", id) +} + +func executeCoverGet(_ context.Context, runtime *common.RuntimeContext) error { + id, _ := resolveCoverDocumentID(runtime) + data, err := doDocAPI(runtime, "GET", fmt.Sprintf(docxDocumentAPIPath, id), nil) + if err != nil { + return err + } + cover := extractCover(data) + runtime.OutFormatRaw(map[string]interface{}{"cover": cover}, nil, func(w io.Writer) { + if cover == nil { + fmt.Fprintln(w, "(no cover)") + return + } + if m, ok := cover.(map[string]interface{}); ok { + fmt.Fprintf(w, "token=%v offset_ratio_x=%v offset_ratio_y=%v\n", m["token"], m["offset_ratio_x"], m["offset_ratio_y"]) + } + }) + return nil +} + +var DocsCoverGet = common.Shortcut{ + Service: "docs", + Command: "+cover-get", + Description: "Get a docx document cover image (token + offset ratios)", + Risk: "read", + Scopes: []string{"docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "doc", Desc: "docx document URL or token", Required: true}, + }, + Validate: validateCoverDoc, + DryRun: dryRunCoverGet, + Execute: executeCoverGet, +} + +// ---------------- cover-update ---------------- + +func validateCoverUpdate(_ context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveCoverDocumentID(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("token")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") + } + if _, _, err := parseOptionalOffset(runtime, "offset-ratio-x"); err != nil { + return err + } + if _, _, err := parseOptionalOffset(runtime, "offset-ratio-y"); err != nil { + return err + } + return nil +} + +// buildCoverUpdateBody assembles {update_cover:{cover:{token, offset_ratio_x?, offset_ratio_y?}}}. +// Offsets are written only when explicitly provided; no default is injected so the +// server applies its existing default crop behavior when omitted. +func buildCoverUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { + cover := map[string]interface{}{"token": strings.TrimSpace(runtime.Str("token"))} + if v, ok, _ := parseOptionalOffset(runtime, "offset-ratio-x"); ok { + cover["offset_ratio_x"] = v + } + if v, ok, _ := parseOptionalOffset(runtime, "offset-ratio-y"); ok { + cover["offset_ratio_y"] = v + } + return map[string]interface{}{"update_cover": map[string]interface{}{"cover": cover}} +} + +func dryRunCoverUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + id, _ := resolveCoverDocumentID(runtime) + return common.NewDryRunAPI(). + PATCH(fmt.Sprintf(docxDocumentAPIPath, id)). + Desc("OpenAPI: update document cover"). + Body(buildCoverUpdateBody(runtime)). + Set("document_id", id) +} + +func executeCoverUpdate(_ context.Context, runtime *common.RuntimeContext) error { + id, _ := resolveCoverDocumentID(runtime) + data, err := doDocAPI(runtime, "PATCH", fmt.Sprintf(docxDocumentAPIPath, id), buildCoverUpdateBody(runtime)) + if err != nil { + return err + } + runtime.OutFormatRaw(map[string]interface{}{"cover": extractCover(data)}, nil, func(w io.Writer) { + fmt.Fprintln(w, "cover updated") + }) + return nil +} + +var DocsCoverUpdate = common.Shortcut{ + Service: "docs", + Command: "+cover-update", + Description: "Update a docx document cover image (token must have docx_image relation to the doc)", + Risk: "write", + Scopes: []string{"docx:document"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "doc", Desc: "docx document URL or token", Required: true}, + {Name: "token", Desc: "cover image file_token; must be uploaded with docx_image relation to this doc (use `docs +media-upload --parent-type docx_image --parent-node --doc-id `); a `docs +media-insert` body image token will be rejected with a relation mismatch", Required: true}, + {Name: "offset-ratio-x", Type: "float64", Desc: "optional horizontal cover offset ratio (aligns with Docx OpenAPI document.cover.offset_ratio_x); omit to keep server default; only finite numbers accepted, range validated server-side"}, + {Name: "offset-ratio-y", Type: "float64", Desc: "optional vertical cover offset ratio (aligns with Docx OpenAPI document.cover.offset_ratio_y); omit to keep server default; only finite numbers accepted, range validated server-side"}, + }, + Validate: validateCoverUpdate, + DryRun: dryRunCoverUpdate, + Execute: executeCoverUpdate, +} + +// ---------------- cover-delete ---------------- + +// buildCoverDeleteBody assembles {update_cover:{cover:null}} per the OpenAPI delete convention. +func buildCoverDeleteBody() map[string]interface{} { + return map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}} +} + +func dryRunCoverDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + id, _ := resolveCoverDocumentID(runtime) + return common.NewDryRunAPI(). + PATCH(fmt.Sprintf(docxDocumentAPIPath, id)). + Desc("OpenAPI: delete document cover (cover:null)"). + Body(buildCoverDeleteBody()). + Set("document_id", id) +} + +func executeCoverDelete(_ context.Context, runtime *common.RuntimeContext) error { + id, _ := resolveCoverDocumentID(runtime) + data, err := doDocAPI(runtime, "PATCH", fmt.Sprintf(docxDocumentAPIPath, id), buildCoverDeleteBody()) + if err != nil { + return err + } + runtime.OutFormatRaw(map[string]interface{}{"cover": extractCover(data)}, nil, func(w io.Writer) { + fmt.Fprintln(w, "cover deleted") + }) + return nil +} + +var DocsCoverDelete = common.Shortcut{ + Service: "docs", + Command: "+cover-delete", + Description: "Delete a docx document cover image (sends cover:null)", + Risk: "write", + Scopes: []string{"docx:document"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "doc", Desc: "docx document URL or token", Required: true}, + }, + Validate: validateCoverDoc, + DryRun: dryRunCoverDelete, + Execute: executeCoverDelete, +} diff --git a/shortcuts/doc/docs_cover_test.go b/shortcuts/doc/docs_cover_test.go new file mode 100644 index 000000000..a80bbdb2e --- /dev/null +++ b/shortcuts/doc/docs_cover_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newCoverTestRuntime() *common.RuntimeContext { + cmd := &cobra.Command{Use: "+cover"} + cmd.Flags().String("doc", "", "") + cmd.Flags().String("token", "", "") + cmd.Flags().String("offset-ratio-x", "", "") + cmd.Flags().String("offset-ratio-y", "", "") + return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) +} + +func TestResolveCoverDocumentID(t *testing.T) { + cases := []struct { + name string + doc string + wantID string + wantErr bool + }{ + {"raw token", "doxcnAbc123", "doxcnAbc123", false}, + {"docx url", "https://x.larkoffice.com/docx/doxcnAbc123", "doxcnAbc123", false}, + {"wiki url rejected", "https://x.larkoffice.com/wiki/wikAbc123", "", true}, + {"empty rejected", "", "", true}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + rt := newCoverTestRuntime() + _ = rt.Cmd.Flags().Set("doc", tt.doc) + id, err := resolveCoverDocumentID(rt) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got id=%q", tt.doc, id) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != tt.wantID { + t.Fatalf("id = %q, want %q", id, tt.wantID) + } + }) + } +} + +func TestParseOptionalOffset(t *testing.T) { + cases := []struct { + name string + val string + wantPresent bool + wantVal float64 + wantErr bool + }{ + {"not provided", "", false, 0, false}, + {"valid float", "0.25", true, 0.25, false}, + {"valid negative", "-0.5", true, -0.5, false}, + {"non-numeric", "abc", false, 0, true}, + {"NaN", "NaN", false, 0, true}, + {"Inf", "Inf", false, 0, true}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + rt := newCoverTestRuntime() + _ = rt.Cmd.Flags().Set("offset-ratio-x", tt.val) + v, present, err := parseOptionalOffset(rt, "offset-ratio-x") + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q", tt.val) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if present != tt.wantPresent { + t.Fatalf("present = %v, want %v", present, tt.wantPresent) + } + if present && v != tt.wantVal { + t.Fatalf("val = %v, want %v", v, tt.wantVal) + } + }) + } +} + +func TestBuildCoverUpdateBodyOmitsOffsetWhenUnset(t *testing.T) { + rt := newCoverTestRuntime() + _ = rt.Cmd.Flags().Set("token", "filetokenABC") + + body := buildCoverUpdateBody(rt) + cover := body["update_cover"].(map[string]interface{})["cover"].(map[string]interface{}) + if cover["token"] != "filetokenABC" { + t.Fatalf("token = %#v, want filetokenABC", cover["token"]) + } + if _, ok := cover["offset_ratio_x"]; ok { + t.Fatalf("offset_ratio_x must be omitted when unset: %#v", cover) + } + if _, ok := cover["offset_ratio_y"]; ok { + t.Fatalf("offset_ratio_y must be omitted when unset: %#v", cover) + } +} + +func TestBuildCoverUpdateBodyIncludesOffsetWhenSet(t *testing.T) { + rt := newCoverTestRuntime() + _ = rt.Cmd.Flags().Set("token", "filetokenABC") + _ = rt.Cmd.Flags().Set("offset-ratio-x", "0.1") + _ = rt.Cmd.Flags().Set("offset-ratio-y", "0.2") + + body := buildCoverUpdateBody(rt) + cover := body["update_cover"].(map[string]interface{})["cover"].(map[string]interface{}) + if cover["offset_ratio_x"] != 0.1 { + t.Fatalf("offset_ratio_x = %#v, want 0.1", cover["offset_ratio_x"]) + } + if cover["offset_ratio_y"] != 0.2 { + t.Fatalf("offset_ratio_y = %#v, want 0.2", cover["offset_ratio_y"]) + } +} + +func TestBuildCoverDeleteBodyIsNull(t *testing.T) { + body := buildCoverDeleteBody() + cover, ok := body["update_cover"].(map[string]interface{}) + if !ok { + t.Fatalf("update_cover missing: %#v", body) + } + v, present := cover["cover"] + if !present { + t.Fatalf("cover key must be present (explicit null): %#v", cover) + } + if v != nil { + t.Fatalf("cover must be nil for delete, got %#v", v) + } +} + +func TestValidateCoverUpdateRequiresToken(t *testing.T) { + rt := newCoverTestRuntime() + _ = rt.Cmd.Flags().Set("doc", "doxcnAbc123") + // no --token + if err := validateCoverUpdate(context.Background(), rt); err == nil { + t.Fatal("expected error when --token missing") + } + + _ = rt.Cmd.Flags().Set("token", "filetokenABC") + if err := validateCoverUpdate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error with token set: %v", err) + } +} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go index 515aaeb49..8634601a7 100644 --- a/shortcuts/doc/shortcuts.go +++ b/shortcuts/doc/shortcuts.go @@ -60,6 +60,9 @@ func Shortcuts() []common.Shortcut { DocMediaUpload, DocMediaPreview, DocMediaDownload, + DocsCoverGet, + DocsCoverUpdate, + DocsCoverDelete, } } diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 3a170e946..318577e0d 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -70,6 +70,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`) | [`+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). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. | | [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) | | [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) | +| [`+cover-get`](references/lark-doc-cover.md) | Get a docx document cover image (token + offset ratios) | +| [`+cover-update`](references/lark-doc-cover.md) | Update a docx document cover image (token must have docx_image relation to the doc) | +| [`+cover-delete`](references/lark-doc-cover.md) | Delete a docx document cover image (sends cover:null) | | [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. | ## 不在本 Skill 范围 diff --git a/skills/lark-doc/references/lark-doc-cover.md b/skills/lark-doc/references/lark-doc-cover.md new file mode 100644 index 000000000..864d5c048 --- /dev/null +++ b/skills/lark-doc/references/lark-doc-cover.md @@ -0,0 +1,57 @@ +# docs 封面图(cover-get / cover-update / cover-delete) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +新版文档 Docx 封面图的获取 / 更新 / 删除。底层走 docx OpenAPI: + +- 获取:`GET /open-apis/docx/v1/documents/:document_id`,封面在 `data.document.cover` +- 更新 / 删除:`PATCH /open-apis/docx/v1/documents/:document_id`,body `update_cover.cover`(删除时为 `null`) + +```bash +# 获取封面(输出 cover.token / offset_ratio_x / offset_ratio_y) +lark-cli docs +cover-get --doc "https://xxx.larkoffice.com/docx/Z1Fj...tnAc" + +# 更新封面(token 必须是与该 docx 建立 docx_image relation 的图片 token) +lark-cli docs +cover-update --doc Z1Fj...tnAc --token +# 可选偏移比例(不传则用服务端默认裁剪;只接受有限浮点数) +lark-cli docs +cover-update --doc Z1Fj...tnAc --token --offset-ratio-x 0.1 --offset-ratio-y 0.2 + +# 删除封面(发送 cover:null) +lark-cli docs +cover-delete --doc Z1Fj...tnAc +``` + +## ⚠️ 封面 token 的 relation 规则(关键) + +封面更新接口**只接受与目标 Docx 建立了 `docx_image` relation 的图片 token**。不能复用正文图片块 token、IM 图片 token、普通 Drive file token。 + +本地图片走**两步式**:先上传为绑定到目标文档的 docx_image 资源,再把返回的 file_token 传给 `+cover-update --token`: + +```bash +# 1) 上传封面图片,建立 docx_image relation +lark-cli docs +media-upload \ + --file ./cover.png \ + --parent-type docx_image \ + --parent-node \ + --doc-id +# 2) 用返回的 file_token 更新封面 +lark-cli docs +cover-update --doc --token +``` + +**不要**用 `docs +media-insert` 返回的 token 当封面——那是正文 image block 的 relation(parent_node=),调 cover-update 会被 OpenAPI 拒绝(relation mismatch)。 + +## 参数 + +| 参数 | 命令 | 必填 | 说明 | +|------|------|------|------| +| `--doc` | get/update/delete | 是 | docx 文档 URL 或 token;当前仅支持 docx,wiki/doc URL 会返回结构化错误(请传 docx document_id)| +| `--token` | update | 是 | 封面图 file_token,须有 docx_image relation(见上文)| +| `--offset-ratio-x` | update | 否 | 水平方向偏移比例(对齐 Docx OpenAPI `document.cover.offset_ratio_x`);不传则用服务端默认;只接受有限浮点数,范围由服务端校验 | +| `--offset-ratio-y` | update | 否 | 垂直方向偏移比例(同上)| + +## 输出与约定 + +- stdout 输出 JSON(`{"cover": {...}}`),stderr 给人读提示,AI Agent 友好。 +- `cover-get` 原样输出服务端返回的 `cover.token` / `offset_ratio_x` / `offset_ratio_y`,不补默认值。 +- 未传 offset 时,请求体 `update_cover.cover` 不写入 offset 字段(不替用户补 0 / 0.5)。 +- offset 非数值 / NaN / Inf 在 CLI 侧前置拒绝;数值范围由服务端校验,下游错误结构化透出。 +- 用 `--dry-run` 可只查看将要发出的 method / path / body,不真正调用。