mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
201e3e016f
Emit structured validation, API, network, file, and internal error envelopes for Doc shortcuts so users and agents can recover from failed document workflows using stable type, subtype, param, and code fields. Add Doc domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
186 lines
6.0 KiB
Go
186 lines
6.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package doc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
var DocMediaUpload = common.Shortcut{
|
|
Service: "docs",
|
|
Command: "+media-upload",
|
|
Description: "Upload media file (image/attachment) to a document block",
|
|
Risk: "write",
|
|
Scopes: []string{"docs:document.media:upload"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
Flags: []common.Flag{
|
|
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
|
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
|
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
|
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
filePath := runtime.Str("file")
|
|
parentType := runtime.Str("parent-type")
|
|
parentNode := runtime.Str("parent-node")
|
|
docId := runtime.Str("doc-id")
|
|
body := map[string]interface{}{
|
|
"file_name": filepath.Base(filePath),
|
|
"parent_type": parentType,
|
|
"parent_node": parentNode,
|
|
}
|
|
if docId != "" {
|
|
body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId)
|
|
}
|
|
dry := common.NewDryRunAPI()
|
|
if docMediaShouldUseMultipart(runtime.FileIO(), filePath) {
|
|
prepareBody := map[string]interface{}{
|
|
"file_name": filepath.Base(filePath),
|
|
"parent_type": parentType,
|
|
"parent_node": parentNode,
|
|
"size": "<file_size>",
|
|
}
|
|
if extra, ok := body["extra"]; ok {
|
|
prepareBody["extra"] = extra
|
|
}
|
|
dry.Desc("chunked media upload (files > 20MB)").
|
|
POST("/open-apis/drive/v1/medias/upload_prepare").
|
|
Body(prepareBody).
|
|
POST("/open-apis/drive/v1/medias/upload_part").
|
|
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").
|
|
Body(map[string]interface{}{
|
|
"upload_id": "<upload_id>",
|
|
"block_num": "<block_num>",
|
|
})
|
|
return dry
|
|
}
|
|
|
|
body["file"] = "@" + filePath
|
|
body["size"] = "<file_size>"
|
|
return dry.Desc("multipart/form-data upload").
|
|
POST("/open-apis/drive/v1/medias/upload_all").
|
|
Body(body)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
filePath := runtime.Str("file")
|
|
parentType := runtime.Str("parent-type")
|
|
parentNode := runtime.Str("parent-node")
|
|
docId := runtime.Str("doc-id")
|
|
|
|
// Validate file
|
|
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")
|
|
}
|
|
|
|
fileName := filepath.Base(filePath)
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%d bytes)\n", fileName, stat.Size())
|
|
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
|
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
|
}
|
|
|
|
fileToken, err := uploadDocMediaFile(runtime, UploadDocMediaFileConfig{
|
|
FilePath: filePath,
|
|
FileName: fileName,
|
|
FileSize: stat.Size(),
|
|
ParentType: parentType,
|
|
ParentNode: parentNode,
|
|
DocID: docId,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
runtime.Out(map[string]interface{}{
|
|
"file_token": fileToken,
|
|
"file_name": fileName,
|
|
"size": stat.Size(),
|
|
}, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// UploadDocMediaFileConfig groups the inputs to uploadDocMediaFile so the
|
|
// call site names each value at call time, avoiding the "8 positional
|
|
// params of mostly string/int64" ambiguity and mirroring the config-struct
|
|
// style already used by DriveMediaUploadAllConfig /
|
|
// DriveMediaMultipartUploadConfig downstream.
|
|
//
|
|
// Exactly one of FilePath (on-disk source) or Reader (in-memory source for
|
|
// the clipboard flow) should be set. Leave Reader at its zero value (nil
|
|
// interface) when the caller only has FilePath — passing a typed-nil
|
|
// pointer like (*bytes.Reader)(nil) here would make Reader compare
|
|
// non-nil downstream and skip the FilePath open, so the field type is
|
|
// deliberately an interface and the clipboard caller builds it only when
|
|
// it actually has bytes.
|
|
type UploadDocMediaFileConfig struct {
|
|
FilePath string
|
|
Reader io.Reader
|
|
FileName string
|
|
FileSize int64
|
|
ParentType string
|
|
ParentNode string
|
|
DocID string
|
|
}
|
|
|
|
func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileConfig) (string, error) {
|
|
var extra string
|
|
if cfg.DocID != "" {
|
|
var err error
|
|
extra, err = buildDriveRouteExtra(cfg.DocID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
// Doc media uploads share the generic Drive media transport. The doc-specific
|
|
// routing only shows up in parent_type/parent_node and optional route extra.
|
|
if cfg.FileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
|
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
|
FilePath: cfg.FilePath,
|
|
Reader: cfg.Reader,
|
|
FileName: cfg.FileName,
|
|
FileSize: cfg.FileSize,
|
|
ParentType: cfg.ParentType,
|
|
ParentNode: &cfg.ParentNode,
|
|
Extra: extra,
|
|
})
|
|
}
|
|
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
|
FilePath: cfg.FilePath,
|
|
Reader: cfg.Reader,
|
|
FileName: cfg.FileName,
|
|
FileSize: cfg.FileSize,
|
|
ParentType: cfg.ParentType,
|
|
ParentNode: cfg.ParentNode,
|
|
Extra: extra,
|
|
})
|
|
}
|
|
|
|
func docMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
|
// Dry-run uses local stat as a best-effort planning hint. Execute re-validates
|
|
// the file before choosing the actual upload path.
|
|
info, err := fio.Stat(filePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
|
}
|