Documentation
¶
Index ¶
Constants ¶
const PreviewType_SOURCE_FILE = "16"
Variables ¶
var DocMediaDownload = common.Shortcut{ Service: "docs", Command: "+media-download", Description: "Download document media or whiteboard thumbnail (auto-detects extension)", Risk: "read", Scopes: []string{"docs:document.media:download"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "token", Desc: "resource token (file_token or whiteboard_id)", Required: true}, {Name: "output", Desc: "local save path", Required: true}, {Name: "type", Default: "media", Desc: "resource type: media (default) | whiteboard"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token := runtime.Str("token") outputPath := runtime.Str("output") mediaType := runtime.Str("type") if mediaType == "whiteboard" { return common.NewDryRunAPI(). GET("/open-apis/board/v1/whiteboards/:token/download_as_image"). Desc("(when --type=whiteboard) Download whiteboard as image"). Set("token", token).Set("output", outputPath) } return common.NewDryRunAPI(). GET("/open-apis/drive/v1/medias/:token/download"). Desc("(when --type=media) Download document media file"). Set("token", token).Set("output", outputPath) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token := runtime.Str("token") outputPath := runtime.Str("output") mediaType := runtime.Str("type") overwrite := runtime.Bool("overwrite") if err := validate.ResourceName(token, "--token"); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") } if _, err := runtime.ResolveSavePath(outputPath); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token)) encodedToken := validate.EncodePathSegment(token) var apiPath string if mediaType == "whiteboard" { apiPath = fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", encodedToken) } else { apiPath = fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", encodedToken) } resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: apiPath, }) if err != nil { return wrapDocNetworkErr(err, "download failed: %v", err) } defer resp.Body.Close() fallbackExt := "" if mediaType == "whiteboard" { fallbackExt = ".png" } finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, fallbackExt) if finalPath != outputPath { if _, err := runtime.ResolveSavePath(finalPath); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } } if !overwrite { if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil { return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output") } } result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: resp.ContentLength, }, resp.Body) if err != nil { return common.WrapSaveErrorTyped(err) } savedPath, _ := runtime.ResolveSavePath(finalPath) if savedPath == "" { savedPath = finalPath } runtime.Out(map[string]interface{}{ "saved_path": savedPath, "size_bytes": result.Size(), "content_type": resp.Header.Get("Content-Type"), }, nil) return nil }, }
var DocMediaInsert = common.Shortcut{ Service: "docs", Command: "+media-insert", Description: "Insert a local image or file into a Lark document (4-step orchestration + auto-rollback); appends to end by default, or inserts relative to a text selection with --selection-with-ellipsis", 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: "selection-with-ellipsis", Desc: "plain text (or 'start...end' to disambiguate) matching the target block's content. Media is inserted at the top-level ancestor of the matched block — i.e., when the selection is inside a callout, table cell, or nested list, media lands outside that container, not inside it. Pass 'start...end' (a unique prefix and suffix separated by '...') when the plain text appears in more than one block"}, {Name: "before", Type: "bool", Desc: "insert before the matched block instead of after (requires --selection-with-ellipsis)"}, {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") } rawSelection := runtime.Str("selection-with-ellipsis") trimmedSelection := strings.TrimSpace(rawSelection) if rawSelection != "" && trimmedSelection == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis must not be blank or whitespace-only").WithParam("--selection-with-ellipsis") } if runtime.Bool("before") && trimmedSelection == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--before requires --selection-with-ellipsis").WithParam("--before") } 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") selection := strings.TrimSpace(runtime.Str("selection-with-ellipsis")) hasSelection := selection != "" fileViewType := fileViewMap[runtime.Str("file-view")] parentType := parentTypeForMediaType(mediaType) createBlockData := buildCreateBlockData(mediaType, 0, fileViewType) if hasSelection { createBlockData["index"] = "<locate_index>" } else { createBlockData["index"] = "<children_len>" } 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 hasSelection { totalSteps++ } positionLabel := map[bool]string{true: "before", false: "after"}[runtime.Bool("before")] if docRef.Kind == "wiki" { documentID = "<resolved_docx_token>" stepBase = 2 d.Desc(fmt.Sprintf("%d-step orchestration: resolve wiki → query root →%s create block → upload file → bind to block (auto-rollback on failure)", totalSteps, map[bool]string{true: " locate-doc →", false: ""}[hasSelection])). 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 →%s create block → upload file → bind to block (auto-rollback on failure)", totalSteps, map[bool]string{true: " locate-doc →", false: ""}[hasSelection])) } d. GET("/open-apis/docx/v1/documents/:document_id/blocks/:document_id"). Desc(fmt.Sprintf("[%d] Get document root block", stepBase)) if hasSelection { mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand) mcpArgs := map[string]interface{}{ "doc_id": documentID, "selection_with_ellipsis": selection, "limit": 1, } d.POST(mcpEndpoint). Desc(fmt.Sprintf("[%d] MCP locate-doc: find block matching selection (%s)", stepBase+1, positionLabel)). Body(map[string]interface{}{ "method": "tools/call", "params": map[string]interface{}{ "name": "locate-doc", "arguments": mcpArgs, }, }). Set("mcp_tool", "locate-doc"). Set("args", mcpArgs) 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) 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") { fmt.Fprintf(runtime.IO().ErrOut, "Reading image from clipboard...\n") 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) } fmt.Fprintf(runtime.IO().ErrOut, "Inserting: %s -> document %s\n", fileName, common.MaskToken(documentID)) if fileSize > common.MaxDriveMediaUploadSinglePartSize { fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") } 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, rootChildren, err := extractAppendTarget(rootData, documentID) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Root block ready: %s (%d children)\n", parentBlockID, insertIndex) selection := strings.TrimSpace(runtime.Str("selection-with-ellipsis")) if selection != "" { before := runtime.Bool("before") fmt.Fprintf(runtime.IO().ErrOut, "Locating block matching selection (%s)\n", redactSelection(selection)) idx, err := locateInsertIndex(runtime, documentID, selection, rootChildren, before) if err != nil { return err } insertIndex = idx posLabel := "after" if before { posLabel = "before" } fmt.Fprintf(runtime.IO().ErrOut, "locate-doc matched: inserting %s at index %d\n", posLabel, insertIndex) } fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex) 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") } fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId) if uploadParentNode != blockId || replaceBlockID != blockId { fmt.Fprintf(runtime.IO().ErrOut, "Resolved file block targets: upload=%s replace=%s\n", uploadParentNode, replaceBlockID) } rollback := func() error { fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId) _, 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 } // 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 fmt.Fprintf(runtime.IO().ErrOut, "Image dimensions: %dx%d (native: %dx%d)\n", finalWidth, finalHeight, nativeW, nativeH) } } 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) } fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", fileToken) fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID) 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 withRollbackWarning(err) } 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 }, }
var DocMediaPreview = common.Shortcut{ Service: "docs", Command: "+media-preview", Description: "Preview document media file (auto-detects extension)", Risk: "read", Scopes: []string{"docs:document.media:download"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "token", Desc: "media file token", Required: true}, {Name: "output", Desc: "local save path", Required: true}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token := runtime.Str("token") outputPath := runtime.Str("output") return common.NewDryRunAPI(). GET("/open-apis/drive/v1/medias/:token/preview_download"). Desc("Preview document media file"). Params(map[string]interface{}{"preview_type": PreviewType_SOURCE_FILE}). Set("token", token).Set("output", outputPath) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token := runtime.Str("token") outputPath := runtime.Str("output") overwrite := runtime.Bool("overwrite") if err := validate.ResourceName(token, "--token"); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") } if _, err := runtime.ResolveSavePath(outputPath); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token)) encodedToken := validate.EncodePathSegment(token) apiPath := fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", encodedToken) resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: apiPath, QueryParams: larkcore.QueryParams{ "preview_type": []string{PreviewType_SOURCE_FILE}, }, }) if err != nil { return wrapDocNetworkErr(err, "preview failed: %v", err) } defer resp.Body.Close() finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "") if finalPath != outputPath { if _, err := runtime.ResolveSavePath(finalPath); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } } if !overwrite { if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil { return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output") } } result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: resp.ContentLength, }, resp.Body) if err != nil { return common.WrapSaveErrorTyped(err) } savedPath, _ := runtime.ResolveSavePath(finalPath) runtime.Out(map[string]interface{}{ "saved_path": savedPath, "size_bytes": result.Size(), "content_type": resp.Header.Get("Content-Type"), }, nil) return nil }, }
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") 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 }, }
var DocsCreate = common.Shortcut{ Service: "docs", Command: "+create", Description: "Create a Lark document", Risk: "write", AuthTypes: []string{"user", "bot"}, Scopes: []string{"docx:document:create"}, PostMount: installDocsShortcutHelp("+create"), Flags: concatFlags( []common.Flag{ docsAPIVersionCompatFlag(), }, v2CreateFlags(), v1CreateFlags(), ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateCreateV2(ctx, runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return dryRunCreateV2(ctx, runtime) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeCreateV2(ctx, runtime) }, }
var DocsFetch = common.Shortcut{ Service: "docs", Command: "+fetch", Description: "Fetch Lark document content", Risk: "read", Scopes: []string{"docx:document:readonly"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, PostMount: installDocsShortcutHelp("+fetch"), Flags: concatFlags( []common.Flag{ docsAPIVersionCompatFlag(), {Name: "doc", Desc: "document URL or token", Required: true}, }, v2FetchFlags(), v1FetchFlags(), ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateFetchV2(ctx, runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return dryRunFetchV2(ctx, runtime) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeFetchV2(ctx, runtime) }, }
var DocsSearch = common.Shortcut{ Service: "docs", Command: "+search", Description: "Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search)", Risk: "read", Scopes: []string{"search:docs:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "query", Desc: "search keyword"}, {Name: "filter", Desc: "filter conditions (JSON object)"}, {Name: "page-token", Desc: "page token"}, {Name: "page-size", Default: "15", Desc: "page size (default 15, max 20)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { requestData, err := buildDocsSearchRequest( runtime.Str("query"), runtime.Str("filter"), runtime.Str("page-token"), runtime.Str("page-size"), ) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } return common.NewDryRunAPI(). POST("/open-apis/search/v2/doc_wiki/search"). Body(requestData) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { requestData, err := buildDocsSearchRequest( runtime.Str("query"), runtime.Str("filter"), runtime.Str("page-token"), runtime.Str("page-size"), ) if err != nil { return err } data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData) if err != nil { return err } items, _ := data["res_units"].([]interface{}) normalizedItems := addIsoTimeFields(items) resultData := map[string]interface{}{ "total": data["total"], "has_more": data["has_more"], "page_token": data["page_token"], "results": normalizedItems, } runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) { if len(normalizedItems) == 0 { fmt.Fprintln(w, "No matching results found.") return } htmlTagRe := regexp.MustCompile(`</?h>`) var rows []map[string]interface{} for _, item := range normalizedItems { u, _ := item.(map[string]interface{}) if u == nil { continue } rawTitle := fmt.Sprintf("%v", u["title_highlighted"]) title := htmlTagRe.ReplaceAllString(rawTitle, "") title = common.TruncateStr(title, 50) resultMeta, _ := u["result_meta"].(map[string]interface{}) docTypes := "" if resultMeta != nil { docTypes = fmt.Sprintf("%v", resultMeta["doc_types"]) } entityType := fmt.Sprintf("%v", u["entity_type"]) typeStr := docTypes if typeStr == "" || typeStr == "<nil>" { typeStr = entityType } url := "" editTime := "" if resultMeta != nil { url = fmt.Sprintf("%v", resultMeta["url"]) editTime = fmt.Sprintf("%v", resultMeta["update_time_iso"]) } if len(url) > 80 { url = url[:80] } rows = append(rows, map[string]interface{}{ "type": typeStr, "title": title, "edit_time": editTime, "url": url, }) } output.PrintTable(w, rows) moreHint := "" hasMore, _ := data["has_more"].(bool) if hasMore { moreHint = " (more available, use --format json to get page_token, then --page-token to paginate)" } fmt.Fprintf(w, "\n%d result(s)%s\n", len(rows), moreHint) }) return nil }, }
var DocsUpdate = common.Shortcut{ Service: "docs", Command: "+update", Description: "Update a Lark document", Risk: "write", Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, AuthTypes: []string{"user", "bot"}, PostMount: installDocsShortcutHelp("+update"), Flags: concatFlags( []common.Flag{ docsAPIVersionCompatFlag(), {Name: "doc", Desc: "document URL or token", Required: true}, }, v2UpdateFlags(), v1UpdateFlags(), ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateUpdateV2(ctx, runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return dryRunUpdateV2(ctx, runtime) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeUpdateV2(ctx, runtime) }, }
Functions ¶
func ConfigureServiceHelp ¶ added in v1.0.23
ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
Types ¶
type UploadDocMediaFileConfig ¶ added in v1.0.18
type UploadDocMediaFileConfig struct {
FilePath string
Reader io.Reader
FileName string
FileSize int64
ParentType string
ParentNode string
DocID string
}
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.