Compare commits

...

1 Commits

Author SHA1 Message Date
baiqing
322768a280 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 <noreply@anthropic.com>
2026-06-10 11:18:02 +08:00
5 changed files with 445 additions and 0 deletions

227
shortcuts/doc/docs_cover.go Normal file
View File

@@ -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> --doc-id <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,
}

View File

@@ -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)
}
}

View File

@@ -60,6 +60,9 @@ func Shortcuts() []common.Shortcut {
DocMediaUpload,
DocMediaPreview,
DocMediaDownload,
DocsCoverGet,
DocsCoverUpdate,
DocsCoverDelete,
}
}

View File

@@ -70,6 +70,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [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 范围

View File

@@ -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 <file_token>
# 可选偏移比例(不传则用服务端默认裁剪;只接受有限浮点数)
lark-cli docs +cover-update --doc Z1Fj...tnAc --token <file_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 <document_id> \
--doc-id <document_id>
# 2) 用返回的 file_token 更新封面
lark-cli docs +cover-update --doc <document_id> --token <file_token>
```
**不要**用 `docs +media-insert` 返回的 token 当封面——那是正文 image block 的 relationparent_node=<image_block_id>),调 cover-update 会被 OpenAPI 拒绝relation mismatch
## 参数
| 参数 | 命令 | 必填 | 说明 |
|------|------|------|------|
| `--doc` | get/update/delete | 是 | docx 文档 URL 或 token当前仅支持 docxwiki/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不真正调用。