mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Task commands now return structured, typed errors instead of the legacy exit-code envelope: every failure carries a stable category, subtype, and recovery hint, so callers can branch on the error class instead of parsing messages. Exit codes derive from the error category — input validation exits 2, a permission denial exits 3, other API errors exit 1. Batch operations (adding tasks to a tasklist, creating a tasklist with tasks) now report partial failure honestly: the per-item successes and failures stay on stdout and the command exits non-zero instead of masking failures as a success.
236 lines
8.2 KiB
Go
236 lines
8.2 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package task
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/client"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// taskAttachmentUploadMaxSize is the upper bound on a single attachment upload
|
|
// to the Task service (50MB, as documented by the open API).
|
|
const taskAttachmentUploadMaxSize int64 = 50 * 1024 * 1024
|
|
|
|
// taskAttachmentUploadPath is the Task open-api endpoint that accepts a single
|
|
// multipart/form-data upload per call.
|
|
const taskAttachmentUploadPath = "/open-apis/task/v2/attachments/upload"
|
|
|
|
// defaultTaskAttachmentResourceType is used when the caller does not pass an
|
|
// explicit --resource-type flag. Task is the only resource type documented for
|
|
// this endpoint today, but the flag is kept open so that future resource types
|
|
// can be targeted without a client upgrade.
|
|
const defaultTaskAttachmentResourceType = "task"
|
|
|
|
// UploadAttachmentTask uploads a single local file as an attachment to a task
|
|
// (or any other resource type accepted by the Task attachment endpoint).
|
|
var UploadAttachmentTask = common.Shortcut{
|
|
Service: "task",
|
|
Command: "+upload-attachment",
|
|
Description: "upload a local file as an attachment to a task",
|
|
Risk: "write",
|
|
Scopes: []string{"task:attachment:write"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
|
|
Flags: []common.Flag{
|
|
{Name: "resource-id", Desc: "task guid (or task applink URL)", Required: true},
|
|
{Name: "file", Desc: "local file path (single file, <= 50MB)", Required: true},
|
|
{Name: "resource-type", Desc: "owning resource type (default: task); use task_delivery when uploading to task agents", Default: defaultTaskAttachmentResourceType},
|
|
{Name: "user-id-type", Desc: "user id type (default: open_id)", Default: "open_id"},
|
|
},
|
|
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
resourceType := runtime.Str("resource-type")
|
|
if resourceType == "" {
|
|
resourceType = defaultTaskAttachmentResourceType
|
|
}
|
|
resourceID := extractTaskGuid(runtime.Str("resource-id"))
|
|
filePath := runtime.Str("file")
|
|
userIDType := runtime.Str("user-id-type")
|
|
if userIDType == "" {
|
|
userIDType = "open_id"
|
|
}
|
|
|
|
return common.NewDryRunAPI().
|
|
POST(taskAttachmentUploadPath).
|
|
Params(map[string]interface{}{"user_id_type": userIDType}).
|
|
Body(map[string]interface{}{
|
|
"resource_type": resourceType,
|
|
"resource_id": resourceID,
|
|
"file": map[string]string{
|
|
"field": "file",
|
|
"path": filePath,
|
|
"name": filepath.Base(filePath),
|
|
},
|
|
})
|
|
},
|
|
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
resourceType := runtime.Str("resource-type")
|
|
if resourceType == "" {
|
|
resourceType = defaultTaskAttachmentResourceType
|
|
}
|
|
resourceID := extractTaskGuid(runtime.Str("resource-id"))
|
|
filePath := runtime.Str("file")
|
|
userIDType := runtime.Str("user-id-type")
|
|
if userIDType == "" {
|
|
userIDType = "open_id"
|
|
}
|
|
|
|
fio := runtime.FileIO()
|
|
if fio == nil {
|
|
// A nil FileIO is a runtime wiring fault, not user input.
|
|
return errs.NewInternalError(errs.SubtypeUnknown, "file operations require a FileIO provider")
|
|
}
|
|
stat, err := fio.Stat(filePath)
|
|
if err != nil {
|
|
return taskInputStatError(err, "--file", "cannot access file")
|
|
}
|
|
if !stat.Mode().IsRegular() {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
|
}
|
|
if stat.Size() > taskAttachmentUploadMaxSize {
|
|
return errs.NewValidationError(
|
|
errs.SubtypeInvalidArgument,
|
|
"attachment %s exceeds the 50MB per-file limit",
|
|
common.FormatSize(stat.Size()),
|
|
).WithParam("--file")
|
|
}
|
|
|
|
fileName := filepath.Base(filePath)
|
|
|
|
// Observability: input parsed.
|
|
fmt.Fprintf(
|
|
runtime.IO().ErrOut,
|
|
"[+upload-attachment] input parsed: resource_type=%s resource_id=%s file=%s size=%s\n",
|
|
resourceType, resourceID, filePath, common.FormatSize(stat.Size()),
|
|
)
|
|
|
|
f, err := fio.Open(filePath)
|
|
if err != nil {
|
|
return taskInputStatError(err, "--file", "cannot open file")
|
|
}
|
|
defer f.Close()
|
|
|
|
// Build the multipart body manually so the real filename is preserved
|
|
// in the `file` part's Content-Disposition. The SDK's Formdata.AddFile
|
|
// hardcodes the filename to "unknown-file" (see oapi-sdk-go
|
|
// core/reqtranslator.go), which is what was showing up in the Task UI.
|
|
var bodyBuf bytes.Buffer
|
|
mw := common.NewMultipartWriter(&bodyBuf)
|
|
if err := mw.WriteField("resource_type", resourceType); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
|
}
|
|
if err := mw.WriteField("resource_id", resourceID); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
|
}
|
|
filePart, err := mw.CreateFormFile("file", fileName)
|
|
if err != nil {
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
|
}
|
|
if _, err := io.Copy(filePart, f); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "write file to multipart body: %s", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "finalize multipart body: %s", err)
|
|
}
|
|
|
|
queryParams := make(larkcore.QueryParams)
|
|
queryParams.Set("user_id_type", userIDType)
|
|
|
|
// Observability: HTTP call about to start.
|
|
fmt.Fprintf(
|
|
runtime.IO().ErrOut,
|
|
"[+upload-attachment] http call: POST %s user_id_type=%s\n",
|
|
taskAttachmentUploadPath, userIDType,
|
|
)
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
httpResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
|
HttpMethod: "POST",
|
|
ApiPath: taskAttachmentUploadPath,
|
|
QueryParams: queryParams,
|
|
Body: &bodyBuf,
|
|
}, client.WithHeaders(headers))
|
|
if err != nil {
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"[+upload-attachment] http response: error=%v\n", err)
|
|
return wrapTaskNetworkErr(err, "upload attachment request failed")
|
|
}
|
|
defer httpResp.Body.Close()
|
|
|
|
rawBody, readErr := io.ReadAll(httpResp.Body)
|
|
if readErr != nil {
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"[+upload-attachment] http response: read_error=%v\n", readErr)
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to read response: %v", readErr)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if parseErr := json.Unmarshal(rawBody, &result); parseErr != nil {
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"[+upload-attachment] http response: parse_error=%v\n", parseErr)
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
|
|
}
|
|
|
|
data, err := HandleTaskApiResultWithContext(result, nil, "upload task attachment", runtime.APIClassifyContext())
|
|
if err != nil {
|
|
code, _ := result["code"]
|
|
msg, _ := result["msg"].(string)
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"[+upload-attachment] http response: code=%v msg=%q error=%v\n",
|
|
code, msg, err)
|
|
return err
|
|
}
|
|
|
|
// The Task attachment upload endpoint returns `data.items` containing
|
|
// the freshly created attachment records. Since this shortcut uploads
|
|
// exactly one file per call, we surface the single record directly as
|
|
// the output envelope — all fields returned by the API (guid, name,
|
|
// size, url, resource_type, uploader, ...) are preserved verbatim.
|
|
items, _ := data["items"].([]interface{})
|
|
var first map[string]interface{}
|
|
if len(items) > 0 {
|
|
first, _ = items[0].(map[string]interface{})
|
|
}
|
|
if first == nil {
|
|
first = map[string]interface{}{}
|
|
}
|
|
guid, _ := first["guid"].(string)
|
|
|
|
code, _ := result["code"]
|
|
msg, _ := result["msg"].(string)
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"[+upload-attachment] http response: code=%v msg=%q attachment_guid=%s\n",
|
|
code, msg, guid)
|
|
|
|
runtime.OutFormat(first, nil, func(w io.Writer) {
|
|
fmt.Fprintf(w, "✅ Attachment uploaded successfully!\n")
|
|
fmt.Fprintf(w, "Resource: %s/%s\n", resourceType, resourceID)
|
|
name, _ := first["name"].(string)
|
|
if name == "" {
|
|
name = fileName
|
|
}
|
|
fmt.Fprintf(w, "File: %s (%s)\n", name, common.FormatSize(stat.Size()))
|
|
if guid != "" {
|
|
fmt.Fprintf(w, "Attachment GUID: %s\n", guid)
|
|
}
|
|
})
|
|
return nil
|
|
},
|
|
}
|