Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions shortcuts/task/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ func extractTasklistGuid(input string) string {
return input
}

// extractTaskGuid extracts a task GUID from either a raw GUID or a Feishu task
// applink URL (e.g. ".../client/todo/task?guid=..."). The URL query parameter
// is always named "guid" for both tasks and tasklists, so we delegate to the
// shared parsing logic.
func extractTaskGuid(input string) string {
return extractTasklistGuid(input)
}

func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := make(map[string]interface{})

Expand Down Expand Up @@ -251,6 +259,7 @@ func Shortcuts() []common.Shortcut {
GetRelatedTasks,
SearchTask,
SubscribeTaskEvent,
UploadAttachmentTask,
CreateTasklist,
SearchTasklist,
AddTaskToTasklist,
Expand Down
237 changes: 237 additions & 0 deletions shortcuts/task/task_upload_attachment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// 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/internal/client"
"github.com/larksuite/cli/internal/output"
"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)", 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 {
return output.ErrValidation("file operations require a FileIO provider")
}
stat, err := fio.Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
}
if stat.Size() > taskAttachmentUploadMaxSize {
return output.ErrValidation(
"attachment %s exceeds the 50MB per-file limit",
common.FormatSize(stat.Size()),
)
}

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 common.WrapInputStatError(err, "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 output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
}
if err := mw.WriteField("resource_id", resourceID); err != nil {
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
}
filePart, err := mw.CreateFormFile("file", fileName)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
}
if _, err := io.Copy(filePart, f); err != nil {
return output.Errorf(output.ExitInternal, "internal", "write file to multipart body: %s", err)
}
if err := mw.Close(); err != nil {
return output.Errorf(output.ExitInternal, "internal", "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 err
}
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 WrapTaskError(ErrCodeTaskInternalError,
fmt.Sprintf("failed to read response: %v", readErr),
"upload task attachment")
}

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 WrapTaskError(ErrCodeTaskInternalError,
fmt.Sprintf("failed to parse response: %v", parseErr),
"upload task attachment")
}

data, err := HandleTaskApiResult(result, nil, "upload task attachment")
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
},
}
Loading
Loading