mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
5e6a3eb857
* feat(mail): return typed error envelopes across the mail domain Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text. - Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable. - API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved. - Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal. - Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls. - Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors. * fix(mail): report aborted draft-send batches as a single failure result When an account-level failure interrupts a batch send after some drafts already went out, the command previously produced two machine-readable failure results: the partial-failure ledger on stdout and a second error envelope on stderr. Consumers could not tell which one to recover from. The batch ledger is now the only failure result for that case: it gains aborted and abort_error fields carrying the typed cause, so callers can see which drafts were sent, which failed, why the batch stopped, and how to recover — all from stdout. A --stop-on-error stop keeps these fields unset because stopping early there is the caller's own choice.
487 lines
16 KiB
Go
487 lines
16 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/client"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
const MaxDriveMediaUploadSinglePartSize int64 = 20 * 1024 * 1024 // 20MB
|
|
|
|
const (
|
|
driveMediaUploadAllAction = "upload media failed"
|
|
driveMediaUploadPartAction = "upload media part failed"
|
|
driveMediaUploadFinishAction = "upload media finish failed"
|
|
)
|
|
|
|
type DriveMediaMultipartUploadSession struct {
|
|
UploadID string
|
|
BlockSize int64
|
|
BlockNum int
|
|
}
|
|
|
|
type DriveMediaUploadAllConfig struct {
|
|
FilePath string
|
|
FileName string
|
|
FileSize int64
|
|
ParentType string
|
|
ParentNode *string
|
|
Extra string
|
|
// Reader, when non-nil, is used as the upload source instead of opening
|
|
// FilePath. Callers must set FileName and FileSize explicitly. The reader
|
|
// is NOT closed by UploadDriveMediaAll; the caller owns its lifetime.
|
|
// Used by the clipboard path in docs +media-insert.
|
|
Reader io.Reader
|
|
}
|
|
|
|
type DriveMediaMultipartUploadConfig struct {
|
|
FilePath string
|
|
FileName string
|
|
FileSize int64
|
|
ParentType string
|
|
ParentNode string
|
|
Extra string
|
|
// Reader mirrors DriveMediaUploadAllConfig.Reader for chunked uploads.
|
|
Reader io.Reader
|
|
}
|
|
|
|
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
|
|
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
|
var fileReader io.Reader
|
|
if cfg.Reader != nil {
|
|
fileReader = cfg.Reader
|
|
} else {
|
|
f, err := runtime.FileIO().Open(cfg.FilePath)
|
|
if err != nil {
|
|
return "", WrapInputStatError(err)
|
|
}
|
|
defer f.Close()
|
|
fileReader = f
|
|
}
|
|
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("file_name", cfg.FileName)
|
|
fd.AddField("parent_type", cfg.ParentType)
|
|
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
|
|
if cfg.ParentNode != nil {
|
|
fd.AddField("parent_node", *cfg.ParentNode)
|
|
}
|
|
if cfg.Extra != "" {
|
|
fd.AddField("extra", cfg.Extra)
|
|
}
|
|
fd.AddFile("file", fileReader)
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if err != nil {
|
|
return "", WrapDriveMediaUploadRequestError(err, driveMediaUploadAllAction)
|
|
}
|
|
|
|
data, err := ParseDriveMediaUploadResponse(apiResp, driveMediaUploadAllAction)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
|
|
}
|
|
|
|
// UploadDriveMediaAllTyped is the typed-error counterpart of
|
|
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
|
|
// transport failures as typed network errors, and API failures are classified
|
|
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
|
|
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
|
var fileReader io.Reader
|
|
if cfg.Reader != nil {
|
|
fileReader = cfg.Reader
|
|
} else {
|
|
f, err := runtime.FileIO().Open(cfg.FilePath)
|
|
if err != nil {
|
|
return "", WrapInputStatErrorTyped(err)
|
|
}
|
|
defer f.Close()
|
|
fileReader = f
|
|
}
|
|
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("file_name", cfg.FileName)
|
|
fd.AddField("parent_type", cfg.ParentType)
|
|
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
|
|
if cfg.ParentNode != nil {
|
|
fd.AddField("parent_node", *cfg.ParentNode)
|
|
}
|
|
if cfg.Extra != "" {
|
|
fd.AddField("extra", cfg.Extra)
|
|
}
|
|
fd.AddFile("file", fileReader)
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if err != nil {
|
|
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
|
|
}
|
|
|
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
|
if err != nil {
|
|
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
|
|
}
|
|
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
|
|
}
|
|
|
|
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
|
|
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
|
// upload_prepare expects parent_node to be present even when the caller wants
|
|
// the service default/root behavior, so multipart callers pass an explicit
|
|
// string instead of relying on field omission like upload_all does.
|
|
prepareBody := map[string]interface{}{
|
|
"file_name": cfg.FileName,
|
|
"parent_type": cfg.ParentType,
|
|
"parent_node": cfg.ParentNode,
|
|
"size": cfg.FileSize,
|
|
}
|
|
if cfg.Extra != "" {
|
|
prepareBody["extra"] = cfg.Extra
|
|
}
|
|
|
|
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
session, err := ParseDriveMediaMultipartUploadSession(data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
|
|
|
|
if err = uploadDriveMediaMultipartParts(runtime, cfg, session); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
|
|
}
|
|
|
|
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
|
|
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
|
|
// CallAPITyped, malformed session plans surface as invalid-response internal
|
|
// errors, and per-part transport/API failures are classified the same way as
|
|
// UploadDriveMediaAllTyped.
|
|
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
|
// upload_prepare expects parent_node to be present even when the caller wants
|
|
// the service default/root behavior, so multipart callers pass an explicit
|
|
// string instead of relying on field omission like upload_all does.
|
|
prepareBody := map[string]interface{}{
|
|
"file_name": cfg.FileName,
|
|
"parent_type": cfg.ParentType,
|
|
"parent_node": cfg.ParentNode,
|
|
"size": cfg.FileSize,
|
|
}
|
|
if cfg.Extra != "" {
|
|
prepareBody["extra"] = cfg.Extra
|
|
}
|
|
|
|
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
|
|
|
|
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
|
|
}
|
|
|
|
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
|
// The backend chooses both chunk size and chunk count. Validate them once so
|
|
// the streaming loop can follow the returned plan without re-checking shape.
|
|
session := DriveMediaMultipartUploadSession{
|
|
UploadID: GetString(data, "upload_id"),
|
|
BlockSize: int64(GetFloat(data, "block_size")),
|
|
BlockNum: int(GetFloat(data, "block_num")),
|
|
}
|
|
if session.UploadID == "" {
|
|
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
|
|
}
|
|
if session.BlockSize <= 0 {
|
|
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
|
}
|
|
if session.BlockNum <= 0 {
|
|
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func WrapDriveMediaUploadRequestError(err error, action string) error {
|
|
// Preserve any already-classified error: legacy *output.ExitError or any
|
|
// typed errs.* error. Only un-classified errors get wrapped as network.
|
|
var exitErr *output.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return err
|
|
}
|
|
if _, ok := errs.ProblemOf(err); ok {
|
|
return err
|
|
}
|
|
return output.ErrNetwork("%s: %v", action, err)
|
|
}
|
|
|
|
func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
|
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
|
|
}
|
|
|
|
if larkCode := int(GetFloat(result, "code")); larkCode != 0 {
|
|
msg, _ := result["msg"].(string)
|
|
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), driveMediaUploadErrorDetail(apiResp, result["error"]))
|
|
}
|
|
|
|
data, _ := result["data"].(map[string]interface{})
|
|
return data, nil
|
|
}
|
|
|
|
func driveMediaUploadErrorDetail(apiResp *larkcore.ApiResp, detail interface{}) interface{} {
|
|
logID := ""
|
|
if apiResp != nil {
|
|
logID = strings.TrimSpace(apiResp.LogId())
|
|
}
|
|
if logID == "" {
|
|
return detail
|
|
}
|
|
detailMap, ok := detail.(map[string]interface{})
|
|
if !ok {
|
|
if detail == nil {
|
|
return map[string]interface{}{"log_id": logID}
|
|
}
|
|
return map[string]interface{}{"error": detail, "log_id": logID}
|
|
}
|
|
if _, exists := detailMap["log_id"]; !exists {
|
|
detailMap["log_id"] = logID
|
|
}
|
|
return detailMap
|
|
}
|
|
|
|
func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) {
|
|
fileToken := GetString(data, "file_token")
|
|
if fileToken == "" {
|
|
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
|
|
}
|
|
return fileToken, nil
|
|
}
|
|
|
|
func uploadDriveMediaMultipartParts(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
|
|
var r io.Reader
|
|
if cfg.Reader != nil {
|
|
r = cfg.Reader
|
|
} else {
|
|
f, err := runtime.FileIO().Open(cfg.FilePath)
|
|
if err != nil {
|
|
return WrapInputStatError(err)
|
|
}
|
|
defer f.Close()
|
|
r = f
|
|
}
|
|
|
|
maxInt := int64(^uint(0) >> 1)
|
|
bufferSize := session.BlockSize
|
|
if bufferSize <= 0 || bufferSize > maxInt {
|
|
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
|
}
|
|
buffer := make([]byte, int(bufferSize))
|
|
remaining := cfg.FileSize
|
|
// Follow the server-declared block plan exactly; upload_finish expects the
|
|
// same block count returned by upload_prepare.
|
|
for seq := 0; seq < session.BlockNum; seq++ {
|
|
chunkSize := session.BlockSize
|
|
if remaining > 0 && chunkSize > remaining {
|
|
chunkSize = remaining
|
|
}
|
|
|
|
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
|
|
if readErr != nil {
|
|
return output.ErrValidation("cannot read file: %s", readErr)
|
|
}
|
|
|
|
if err := uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
|
|
remaining -= int64(n)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func uploadDriveMediaMultipartPart(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("upload_id", uploadID)
|
|
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
|
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
|
|
fd.AddFile("file", bytes.NewReader(chunk))
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if err != nil {
|
|
return WrapDriveMediaUploadRequestError(err, driveMediaUploadPartAction)
|
|
}
|
|
|
|
_, err = ParseDriveMediaUploadResponse(apiResp, driveMediaUploadPartAction)
|
|
return err
|
|
}
|
|
|
|
func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
|
|
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
|
"upload_id": uploadID,
|
|
"block_num": blockNum,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
|
|
}
|
|
|
|
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
|
|
// message so callers see which upload step failed. Non-typed errors are
|
|
// returned unchanged.
|
|
func prefixDriveMediaUploadProblem(err error, action string) error {
|
|
if p, ok := errs.ProblemOf(err); ok {
|
|
p.Message = action + ": " + p.Message
|
|
}
|
|
return err
|
|
}
|
|
|
|
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
|
|
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
|
|
// malformed plan as a typed invalid-response internal error.
|
|
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
|
session := DriveMediaMultipartUploadSession{
|
|
UploadID: GetString(data, "upload_id"),
|
|
BlockSize: int64(GetFloat(data, "block_size")),
|
|
BlockNum: int(GetFloat(data, "block_num")),
|
|
}
|
|
if session.UploadID == "" {
|
|
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
|
|
}
|
|
if session.BlockSize <= 0 {
|
|
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
|
}
|
|
if session.BlockNum <= 0 {
|
|
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
|
|
// with a typed invalid-response internal error for a missing file_token.
|
|
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
|
|
fileToken := GetString(data, "file_token")
|
|
if fileToken == "" {
|
|
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
|
|
}
|
|
return fileToken, nil
|
|
}
|
|
|
|
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
|
|
// with typed errors for file-open, file-read, and per-part upload failures.
|
|
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
|
|
var r io.Reader
|
|
if cfg.Reader != nil {
|
|
r = cfg.Reader
|
|
} else {
|
|
f, err := runtime.FileIO().Open(cfg.FilePath)
|
|
if err != nil {
|
|
return WrapInputStatErrorTyped(err)
|
|
}
|
|
defer f.Close()
|
|
r = f
|
|
}
|
|
|
|
maxInt := int64(^uint(0) >> 1)
|
|
bufferSize := session.BlockSize
|
|
if bufferSize <= 0 || bufferSize > maxInt {
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
|
}
|
|
buffer := make([]byte, int(bufferSize))
|
|
remaining := cfg.FileSize
|
|
// Follow the server-declared block plan exactly; upload_finish expects the
|
|
// same block count returned by upload_prepare.
|
|
for seq := 0; seq < session.BlockNum; seq++ {
|
|
chunkSize := session.BlockSize
|
|
if remaining > 0 && chunkSize > remaining {
|
|
chunkSize = remaining
|
|
}
|
|
|
|
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
|
|
if readErr != nil {
|
|
return WrapInputStatErrorTyped(readErr)
|
|
}
|
|
|
|
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
|
|
remaining -= int64(n)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("upload_id", uploadID)
|
|
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
|
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
|
|
fd.AddFile("file", bytes.NewReader(chunk))
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if err != nil {
|
|
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
|
|
}
|
|
|
|
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
|
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
|
|
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
|
"upload_id": uploadID,
|
|
"block_num": blockNum,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
|
|
}
|