Files
larksuite__cli/shortcuts/doc/doc_media_insert.go
caojie0621 8a7fa53355 fix(shortcuts): remove non-actionable stderr progress (#2532)
* fix(shortcuts): remove non-actionable stderr progress

Keep successful shortcut output machine-readable by removing lifecycle, retry, and completion progress from docs, wiki, drive, and shared multipart flows. Preserve actionable warnings, fallbacks, and interactive prompts, and update stderr regression assertions.

* test(wiki): distinguish warnings from progress
2026-09-01 17:53:59 +08:00

651 lines
24 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"path/filepath"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var alignMap = map[string]int{
"left": 1,
"center": 2,
"right": 3,
}
// readClipboardImage is the clipboard read function, swappable in tests to
// inject synthetic image bytes without depending on the host pasteboard.
var readClipboardImage = readClipboardImageBytes
// fileViewMap maps the user-facing --file-view value to the docx File block
// `view_type` enum. The underlying values come from the open platform spec:
//
// 1 = card view (default)
// 2 = preview view (renders audio/video files as an inline player)
// 3 = inline view
var fileViewMap = map[string]int{
"card": 1,
"preview": 2,
"inline": 3,
}
var DocMediaInsert = common.Shortcut{
Service: "docs",
Command: "+media-insert",
Description: "Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback)",
Risk: "write",
Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)"},
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file (macOS/Windows built-in; Linux requires xclip, xsel or wl-paste)"},
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: "image", Desc: "type: image | file"},
{Name: "align", Desc: "alignment: left | center | right"},
{Name: "caption", Desc: "image caption text"},
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
{Name: "width", Type: "int", Desc: "image display width in pixels (only for --type=image); if --height is omitted it is auto-computed from the source image aspect ratio"},
{Name: "height", Type: "int", Desc: "image display height in pixels (only for --type=image); if --width is omitted it is auto-computed from the source image aspect ratio"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
fromClipboard := runtime.Bool("from-clipboard")
if filePath == "" && !fromClipboard {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required").WithParams(
errs.InvalidParam{Name: "--file", Reason: "provide either --file or --from-clipboard"},
errs.InvalidParam{Name: "--from-clipboard", Reason: "provide either --file or --from-clipboard"},
)
}
if filePath != "" && fromClipboard {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --from-clipboard"},
errs.InvalidParam{Name: "--from-clipboard", Reason: "mutually exclusive with --file"},
)
}
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
if docRef.Kind == "doc" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
}
if view := runtime.Str("file-view"); view != "" {
if _, ok := fileViewMap[view]; !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-view value %q, expected one of: card | preview | inline", view).WithParam("--file-view")
}
if runtime.Str("type") != "file" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-view only applies when --type=file").WithParam("--file-view")
}
}
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
var params []errs.InvalidParam
if widthChanged {
params = append(params, errs.InvalidParam{Name: "--width", Reason: "only applies when --type=image"})
}
if heightChanged {
params = append(params, errs.InvalidParam{Name: "--height", Reason: "only applies when --type=image"})
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image").WithParams(params...)
}
if widthChanged && runtime.Int("width") <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be a positive integer").WithParam("--width")
}
if heightChanged && runtime.Int("height") <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be a positive integer").WithParam("--height")
}
const maxDimension = 10000
if widthChanged && runtime.Int("width") > maxDimension {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must not exceed %d pixels", maxDimension).WithParam("--width")
}
if heightChanged && runtime.Int("height") > maxDimension {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must not exceed %d pixels", maxDimension).WithParam("--height")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
documentID := docRef.Token
stepBase := 1
filePath := runtime.Str("file")
if runtime.Bool("from-clipboard") {
filePath = "<clipboard image>"
}
mediaType := runtime.Str("type")
caption := runtime.Str("caption")
fileViewType := fileViewMap[runtime.Str("file-view")]
parentType := parentTypeForMediaType(mediaType)
createBlockData := buildCreateBlockData(mediaType, 0, fileViewType)
createBlockData["index"] = "<children_len>"
// Best-effort dimension computation for dry-run.
dryWidth := runtime.Int("width")
dryHeight := runtime.Int("height")
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if (widthChanged || heightChanged) && !(widthChanged && heightChanged) {
if filePath == "<clipboard image>" {
fmt.Fprintf(runtime.IO().ErrOut, "Note: cannot detect clipboard image dimensions in dry-run; provide both --width and --height for accurate preview\n")
} else if nativeW, nativeH, err := detectImageDimensionsFromPath(runtime.FileIO(), filePath); err == nil {
dims := computeMissingDimension(dryWidth, dryHeight, nativeW, nativeH)
dryWidth = dims.width
dryHeight = dims.height
} else {
fmt.Fprintf(runtime.IO().ErrOut, "Note: unable to detect image dimensions from %s; provide both --width and --height to avoid failure at execution time\n", filePath)
}
}
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption, dryWidth, dryHeight)
d := common.NewDryRunAPI()
totalSteps := 4
if docRef.Kind == "wiki" {
totalSteps++
}
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
stepBase = 2
d.Desc(fmt.Sprintf("%d-step orchestration: resolve wiki → query root → create block → upload file → bind to block (auto-rollback on failure)", totalSteps)).
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
} else {
d.Desc(fmt.Sprintf("%d-step orchestration: query root → create block → upload file → bind to block (auto-rollback on failure)", totalSteps))
}
d.
GET("/open-apis/docx/v1/documents/:document_id/blocks/:document_id").
Desc(fmt.Sprintf("[%d] Get document root block", stepBase))
d.
POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children").
Desc(fmt.Sprintf("[%d] Create empty block at target position", stepBase+1)).
Body(createBlockData)
appendDocMediaInsertUploadDryRun(d, runtime.FileIO(), filePath, parentType, stepBase+2)
d.PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update").
Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)).
Body(batchUpdateData)
d.Set("document_id", documentID)
// Annotate dry-run when reading from the clipboard: DryRun never touches
// the pasteboard, so it cannot tell in advance whether the payload is
// above or below the 20MB single-part threshold. Execute will make the
// real decision once it reads the bytes.
if runtime.Bool("from-clipboard") {
d.Set("upload_size_note", "clipboard size unknown; single-part vs multipart decision deferred to runtime")
}
if runtime.Bool("from-clipboard") && (widthChanged || heightChanged) && !(widthChanged && heightChanged) {
d.Set("dimension_note", "clipboard dimensions unknown; aspect-ratio calculation deferred to runtime")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
docInput := runtime.Str("doc")
mediaType := runtime.Str("type")
alignStr := runtime.Str("align")
caption := runtime.Str("caption")
fileViewType := fileViewMap[runtime.Str("file-view")]
// Clipboard path: read image bytes into memory, bypassing FileIO path validation.
var clipboardContent []byte
if runtime.Bool("from-clipboard") {
var err error
clipboardContent, err = readClipboardImage()
if err != nil {
return err
}
}
documentID, err := resolveDocxDocumentID(runtime, docInput)
if err != nil {
return err
}
// Determine file size and name.
var fileSize int64
var fileName string
if clipboardContent != nil {
fileSize = int64(len(clipboardContent))
fileName = "clipboard.png"
} else {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return wrapDocInputFileErr(err, "file not found")
}
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
fileSize = stat.Size()
fileName = filepath.Base(filePath)
}
// Step 1: Get document root block to find where to insert
rootData, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)),
nil, nil)
if err != nil {
return err
}
parentBlockID, insertIndex, err := extractAppendTarget(rootData, documentID)
if err != nil {
return err
}
// Step 2: Create an empty block at the target position
createData, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
if err != nil {
return err
}
blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType)
if blockId == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create block: no block_id returned")
}
// The placeholder block is created before any upload starts, so failures in
// later steps should try to remove it instead of leaving an empty artifact.
rollback := func() error {
_, err := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
nil, buildDeleteBlockData(insertIndex))
return err
}
withRollbackWarning := func(opErr error) error {
rollbackErr := rollback()
if rollbackErr == nil {
return opErr
}
warning := fmt.Sprintf("rollback failed for block %s: %v", blockId, rollbackErr)
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
return opErr
}
withBindRollbackRecovery := func(opErr error, fileToken string) error {
rollbackErr := rollback()
rollbackStatus := "succeeded"
if rollbackErr != nil {
rollbackStatus = "failed"
}
hint := fmt.Sprintf(
"Document media upload succeeded but binding failed: phase=bind_media, document_id=%s, upload_succeeded=true, file_token=%s, block_id=%s, replace_block_id=%s, rollback=%s.",
documentID, fileToken, blockId, replaceBlockID, rollbackStatus,
)
if rollbackErr != nil {
hint += fmt.Sprintf(" rollback_error=%q. Do not blindly retry the upload; inspect or repair the existing block first.", rollbackErr.Error())
} else {
hint += " The placeholder block was removed; retry the original command if the operation is still needed."
}
return withDocRecoveryHint(opErr, hint)
}
// Step 3: Upload media file.
// Only materialize Content when clipboard bytes exist, so the `io.Reader`
// interface stays a true nil for the --file path. Passing a typed-nil
// *bytes.Reader here would make the downstream `if cfg.Content != nil`
// check incorrectly take the clipboard branch and crash on Read.
// Resolve display dimensions before upload to fail fast on unreadable images.
var finalWidth, finalHeight int
if mediaType == "image" {
userWidth := runtime.Int("width")
userHeight := runtime.Int("height")
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if widthChanged && heightChanged {
finalWidth = userWidth
finalHeight = userHeight
} else if widthChanged || heightChanged {
var nativeW, nativeH int
var dimErr error
if clipboardContent != nil {
nativeW, nativeH, dimErr = detectImageDimensions(bytes.NewReader(clipboardContent))
} else {
f, openErr := runtime.FileIO().Open(filePath)
if openErr != nil {
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(openErr).WithParams(
errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"},
errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"},
))
}
nativeW, nativeH, dimErr = detectImageDimensions(f)
f.Close()
}
if dimErr != nil {
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(dimErr).WithParams(
errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"},
errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"},
))
}
dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH)
finalWidth = dims.width
finalHeight = dims.height
}
}
uploadCfg := UploadDocMediaFileConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: parentTypeForMediaType(mediaType),
ParentNode: uploadParentNode,
DocID: documentID,
}
if clipboardContent != nil {
uploadCfg.Reader = bytes.NewReader(clipboardContent)
}
fileToken, err := uploadDocMediaFile(runtime, uploadCfg)
if err != nil {
return withRollbackWarning(err)
}
// Step 4: Bind file token to block via batch_update
if _, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)),
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil {
return withBindRollbackRecovery(err, fileToken)
}
outData := map[string]interface{}{
"document_id": documentID,
"block_id": blockId,
"file_token": fileToken,
"type": mediaType,
}
if finalWidth > 0 {
outData["width"] = finalWidth
}
if finalHeight > 0 {
outData["height"] = finalHeight
}
runtime.Out(outData, nil)
return nil
},
}
func blockTypeForMediaType(mediaType string) int {
if mediaType == "file" {
return 23
}
return 27
}
func parentTypeForMediaType(mediaType string) string {
if mediaType == "file" {
return "docx_file"
}
return "docx_image"
}
func buildCreateBlockData(mediaType string, index int, fileViewType int) map[string]interface{} {
child := map[string]interface{}{
"block_type": blockTypeForMediaType(mediaType),
}
if mediaType == "file" {
fileData := map[string]interface{}{}
// view_type can only be set at block creation time; the PATCH
// replace_file endpoint does not accept it, so if the caller wants
// preview/inline rendering we must wire it in here. Whitelist the
// concrete enum values so a stray positive int cannot produce a
// malformed payload if Validate is ever bypassed.
switch fileViewType {
case 1, 2, 3:
fileData["view_type"] = fileViewType
}
child["file"] = fileData
} else {
child["image"] = map[string]interface{}{}
}
return map[string]interface{}{
"children": []interface{}{
child,
},
"index": index,
}
}
func buildDeleteBlockData(index int) map[string]interface{} {
return map[string]interface{}{
"start_index": index,
"end_index": index + 1,
}
}
func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string, error) {
docRef, err := parseDocumentRef(input)
if err != nil {
return "", err
}
switch docRef.Kind {
case "docx":
return docRef.Token, nil
case "doc":
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
case "wiki":
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docRef.Token},
nil,
)
if err != nil {
return "", err
}
node := common.GetMap(data, "node")
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "docx" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but this document operation only supports docx documents", objType).WithParam("--doc")
}
return objToken, nil
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents").WithParam("--doc")
}
}
type imageDimensions struct {
width int
height int
}
func computeMissingDimension(userWidth, userHeight, nativeWidth, nativeHeight int) imageDimensions {
if nativeWidth <= 0 || nativeHeight <= 0 {
return imageDimensions{width: userWidth, height: userHeight}
}
if userWidth > 0 && userHeight == 0 {
return imageDimensions{
width: userWidth,
height: (userWidth*nativeHeight + nativeWidth/2) / nativeWidth,
}
}
if userHeight > 0 && userWidth == 0 {
return imageDimensions{
width: (userHeight*nativeWidth + nativeHeight/2) / nativeHeight,
height: userHeight,
}
}
return imageDimensions{width: userWidth, height: userHeight}
}
func detectImageDimensions(r io.Reader) (width, height int, err error) {
cfg, _, err := image.DecodeConfig(r)
if err != nil {
return 0, 0, err
}
return cfg.Width, cfg.Height, nil
}
func detectImageDimensionsFromPath(fio fileio.FileIO, filePath string) (int, int, error) {
width, height, _, err := detectImageConfigFromPath(fio, filePath)
return width, height, err
}
func detectImageConfigFromPath(fio fileio.FileIO, filePath string) (int, int, string, error) {
if _, err := validate.SafeInputPath(filePath); err != nil {
return 0, 0, "", err
}
f, err := fio.Open(filePath)
if err != nil {
return 0, 0, "", err
}
defer f.Close()
cfg, format, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, "", err
}
return cfg.Width, cfg.Height, format, nil
}
func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string, width, height int) map[string]interface{} {
request := map[string]interface{}{
"block_id": blockID,
}
if mediaType == "file" {
request["replace_file"] = map[string]interface{}{
"token": fileToken,
}
} else {
replaceImage := map[string]interface{}{
"token": fileToken,
}
if width > 0 {
replaceImage["width"] = width
}
if height > 0 {
replaceImage["height"] = height
}
if alignVal, ok := alignMap[alignStr]; ok {
replaceImage["align"] = alignVal
}
if caption != "" {
replaceImage["caption"] = map[string]interface{}{
"content": caption,
}
}
request["replace_image"] = replaceImage
}
return map[string]interface{}{
"requests": []interface{}{request},
}
}
func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (parentBlockID string, insertIndex int, err error) {
block, _ := rootData["block"].(map[string]interface{})
if len(block) == 0 {
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to query document root block")
}
parentBlockID = fallbackBlockID
if blockID, _ := block["block_id"].(string); blockID != "" {
parentBlockID = blockID
}
children, _ := block["children"].([]interface{})
return parentBlockID, len(children), nil
}
func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) {
children, _ := createData["children"].([]interface{})
if len(children) == 0 {
return "", "", ""
}
child, _ := children[0].(map[string]interface{})
blockID, _ = child["block_id"].(string)
uploadParentNode = blockID
replaceBlockID = blockID
if mediaType != "file" {
return blockID, uploadParentNode, replaceBlockID
}
// File blocks are wrapped: the created top-level block owns a nested child
// that is both the upload target and the replace_file target.
nestedChildren, _ := child["children"].([]interface{})
if len(nestedChildren) == 0 {
return blockID, uploadParentNode, replaceBlockID
}
if nestedBlockID, ok := nestedChildren[0].(string); ok && nestedBlockID != "" {
uploadParentNode = nestedBlockID
replaceBlockID = nestedBlockID
}
return blockID, uploadParentNode, replaceBlockID
}
func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, fio fileio.FileIO, filePath, parentType string, step int) {
// The upload step runs only after the empty placeholder block is created, so
// dry-run can refer to that future block ID only symbolically. For large
// files, keep multipart internals as substeps of the single user-facing
// "upload file" step.
if docMediaShouldUseMultipart(fio, filePath) {
d.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc(fmt.Sprintf("[%da] Initialize multipart upload", step)).
Body(map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
"parent_node": "<new_block_id>",
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Desc(fmt.Sprintf("[%db] Upload file parts (repeated)", step)).
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Desc(fmt.Sprintf("[%dc] Finalize multipart upload and get file_token", step)).
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return
}
d.POST("/open-apis/drive/v1/medias/upload_all").
Desc(fmt.Sprintf("[%d] Upload local file (multipart/form-data)", step)).
Body(map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
"parent_node": "<new_block_id>",
"size": "<file_size>",
"file": "@" + filePath,
})
}