Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

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 | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", 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.UploadDriveMediaAllTyped(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.UploadDriveMediaMultipartTyped(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
}