diff --git a/shortcuts/task/shortcuts.go b/shortcuts/task/shortcuts.go index 3d31d9ab7..878e1573f 100644 --- a/shortcuts/task/shortcuts.go +++ b/shortcuts/task/shortcuts.go @@ -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{}) @@ -251,6 +259,7 @@ func Shortcuts() []common.Shortcut { GetRelatedTasks, SearchTask, SubscribeTaskEvent, + UploadAttachmentTask, CreateTasklist, SearchTasklist, AddTaskToTasklist, diff --git a/shortcuts/task/task_upload_attachment.go b/shortcuts/task/task_upload_attachment.go new file mode 100644 index 000000000..1aa32945c --- /dev/null +++ b/shortcuts/task/task_upload_attachment.go @@ -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 + }, +} diff --git a/shortcuts/task/task_upload_attachment_test.go b/shortcuts/task/task_upload_attachment_test.go new file mode 100644 index 000000000..a2565fb12 --- /dev/null +++ b/shortcuts/task/task_upload_attachment_test.go @@ -0,0 +1,449 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "mime" + "mime/multipart" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +// writeTestFile creates a file at name (relative to cwd) with size bytes of +// ASCII data and returns the relative path it wrote. +func writeTestFile(t *testing.T, name string, size int) string { + t.Helper() + if err := os.WriteFile(name, bytes.Repeat([]byte("a"), size), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error: %v", name, err) + } + return name +} + +// writeSparseTestFile produces a sparse file of the requested size without +// allocating real disk space, useful for exercising the 50MB validation path. +func writeSparseTestFile(t *testing.T, name string, size int64) string { + t.Helper() + fh, err := os.Create(name) + if err != nil { + t.Fatalf("Create(%q) error: %v", name, err) + } + if err := fh.Truncate(size); err != nil { + t.Fatalf("Truncate(%q, %d) error: %v", name, size, err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close(%q) error: %v", name, err) + } + return name +} + +func TestUploadAttachmentTask_Success(t *testing.T) { + for _, tt := range []struct { + name string + format string + contains []string + }{ + { + name: "pretty format", + format: "pretty", + contains: []string{ + "✅ Attachment uploaded successfully!", + "Attachment GUID: att-guid-1", + }, + }, + { + name: "json format", + format: "json", + contains: []string{ + `"guid": "att-guid-1"`, + `"name": "note.txt"`, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + f, stdout, stderr, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + filePath := writeTestFile(t, "note.txt", 12) + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/attachments/upload", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "guid": "att-guid-1", + "name": "note.txt", + "size": 12, + }, + }, + }, + }, + } + reg.Register(uploadStub) + + args := []string{ + "+upload-attachment", + "--resource-id", "task-guid-123", + "--file", filePath, + "--as", "bot", + "--format", tt.format, + } + if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + // Normalize JSON whitespace so that both compact and indented forms match. + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + for _, want := range tt.contains { + if !strings.Contains(outNorm, want) && !strings.Contains(out, want) { + t.Errorf("stdout missing %q; got:\n%s", want, out) + } + } + + // Verify multipart body structure. + body := decodeTaskAttachmentMultipart(t, uploadStub) + if got := body.Fields["resource_type"]; got != "task" { + t.Errorf("resource_type = %q, want %q", got, "task") + } + if got := body.Fields["resource_id"]; got != "task-guid-123" { + t.Errorf("resource_id = %q, want %q", got, "task-guid-123") + } + if got, ok := body.Files["file"]; !ok { + t.Errorf("multipart missing file part") + } else if len(got) != 12 { + t.Errorf("file size = %d, want 12", len(got)) + } + if got := body.FileNames["file"]; got != "note.txt" { + t.Errorf("multipart file filename = %q, want %q", got, "note.txt") + } + + // Verify key observability logs on stderr. + errOut := stderr.String() + for _, log := range []string{ + "input parsed", + "http call: POST /open-apis/task/v2/attachments/upload", + "http response", + "att-guid-1", + } { + if !strings.Contains(errOut, log) { + t.Errorf("stderr missing log %q; got:\n%s", log, errOut) + } + } + }) + } +} + +func TestUploadAttachmentTask_ExplicitResourceTypePassthrough(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + filePath := writeTestFile(t, "note.txt", 5) + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/attachments/upload", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"guid": "att-guid-2"}}, + }, + }, + } + reg.Register(uploadStub) + + err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{ + "+upload-attachment", + "--resource-id", "task-guid-123", + "--resource-type", "custom_type", + "--file", filePath, + "--as", "bot", + "--format", "json", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeTaskAttachmentMultipart(t, uploadStub) + if got := body.Fields["resource_type"]; got != "custom_type" { + t.Fatalf("resource_type = %q, want custom_type", got) + } +} + +func TestUploadAttachmentTask_ResourceIDFromApplink(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + filePath := writeTestFile(t, "note.txt", 5) + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/attachments/upload", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"guid": "att-guid-3"}}, + }, + }, + } + reg.Register(uploadStub) + + applink := "https://applink.feishu.cn/client/todo/task?guid=task-from-url" + err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{ + "+upload-attachment", + "--resource-id", applink, + "--file", filePath, + "--as", "bot", + "--format", "json", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeTaskAttachmentMultipart(t, uploadStub) + if got := body.Fields["resource_id"]; got != "task-from-url" { + t.Fatalf("resource_id = %q, want task-from-url", got) + } +} + +func TestUploadAttachmentTask_SizeLimit(t *testing.T) { + f, stdout, _, _ := taskShortcutTestFactory(t) + + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + // 50MB + 1 byte; no HTTP stub registered — we must fail before any call. + filePath := writeSparseTestFile(t, "big.bin", 50*1024*1024+1) + + err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{ + "+upload-attachment", + "--resource-id", "task-guid-123", + "--file", filePath, + "--as", "bot", + "--format", "json", + }, f, stdout) + if err == nil { + t.Fatal("expected error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + } + if !strings.Contains(err.Error(), "50MB") { + t.Fatalf("error message should mention 50MB limit, got: %v", err) + } +} + +func TestUploadAttachmentTask_FileMissing(t *testing.T) { + f, stdout, _, _ := taskShortcutTestFactory(t) + + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{ + "+upload-attachment", + "--resource-id", "task-guid-123", + "--file", "does-not-exist.bin", + "--as", "bot", + "--format", "json", + }, f, stdout) + if err == nil { + t.Fatal("expected error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + } +} + +func TestUploadAttachmentTask_APIError(t *testing.T) { + f, stdout, stderr, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + filePath := writeTestFile(t, "note.txt", 3) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/attachments/upload", + Body: map[string]interface{}{ + "code": ErrCodeTaskPermissionDenied, + "msg": "no permission", + }, + }) + + err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{ + "+upload-attachment", + "--resource-id", "task-guid-123", + "--file", filePath, + "--as", "bot", + "--format", "json", + }, f, stdout) + if err == nil { + t.Fatal("expected error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if exitErr.Detail == nil || exitErr.Detail.Code != ErrCodeTaskPermissionDenied { + t.Fatalf("expected task permission denied code %d, got: %+v", ErrCodeTaskPermissionDenied, exitErr.Detail) + } + + // Key-path log should still be emitted on failure. + errOut := stderr.String() + for _, log := range []string{"input parsed", "http call", "http response"} { + if !strings.Contains(errOut, log) { + t.Errorf("stderr missing failure log %q; got:\n%s", log, errOut) + } + } +} + +func TestUploadAttachmentTask_DryRun(t *testing.T) { + for _, tt := range []struct { + name string + extraArgs []string + wantResourceType string + }{ + { + name: "default resource type", + extraArgs: nil, + wantResourceType: "task", + }, + { + name: "explicit resource type", + extraArgs: []string{"--resource-type", "custom_type"}, + wantResourceType: "custom_type", + }, + } { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, _ := taskShortcutTestFactory(t) + + args := []string{ + "+upload-attachment", + "--resource-id", "task-guid-123", + "--file", "./some.pdf", + "--as", "bot", + "--format", "json", + "--dry-run", + } + args = append(args, tt.extraArgs...) + if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + var dry map[string]interface{} + if err := json.Unmarshal([]byte(out), &dry); err != nil { + t.Fatalf("dry-run output is not JSON: %v\n%s", err, out) + } + calls, _ := dry["api"].([]interface{}) + if len(calls) != 1 { + t.Fatalf("expected 1 api call in dry-run, got %d: %v", len(calls), calls) + } + call := calls[0].(map[string]interface{}) + if got := call["method"]; got != "POST" { + t.Fatalf("method = %v, want POST", got) + } + if got := call["url"]; got != "/open-apis/task/v2/attachments/upload" { + t.Fatalf("url = %v, want upload path", got) + } + params, _ := call["params"].(map[string]interface{}) + if got := params["user_id_type"]; got != "open_id" { + t.Fatalf("params.user_id_type = %v, want open_id", got) + } + body := call["body"].(map[string]interface{}) + if got := body["resource_type"]; got != tt.wantResourceType { + t.Fatalf("resource_type = %v, want %v", got, tt.wantResourceType) + } + if got := body["resource_id"]; got != "task-guid-123" { + t.Fatalf("resource_id = %v, want task-guid-123", got) + } + fileDesc := body["file"].(map[string]interface{}) + if got := fileDesc["field"]; got != "file" { + t.Fatalf("file.field = %v, want file", got) + } + if got := fileDesc["path"]; got != "./some.pdf" { + t.Fatalf("file.path = %v, want ./some.pdf", got) + } + if got := fileDesc["name"]; got != "some.pdf" { + t.Fatalf("file.name = %v, want some.pdf", got) + } + }) + } +} + +// ── multipart body helper ────────────────────────────────────────────────── + +type capturedAttachmentMultipart struct { + Fields map[string]string + Files map[string][]byte + FileNames map[string]string +} + +func decodeTaskAttachmentMultipart(t *testing.T, stub *httpmock.Stub) capturedAttachmentMultipart { + t.Helper() + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content-type %q: %v", contentType, err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := capturedAttachmentMultipart{ + Fields: map[string]string{}, + Files: map[string][]byte{}, + FileNames: map[string]string{}, + } + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("read multipart part: %v", err) + } + data, err := io.ReadAll(part) + if err != nil { + t.Fatalf("read multipart data: %v", err) + } + if part.FileName() != "" { + body.Files[part.FormName()] = data + body.FileNames[part.FormName()] = part.FileName() + continue + } + body.Fields[part.FormName()] = string(data) + } + return body +} diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index e5288b5ee..477e66d3a 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-task version: 1.0.0 -description: "飞书任务:管理任务、清单和任务智能体。创建待办任务、查看和更新任务状态、拆分子任务、组织任务清单、分配协作成员、注册或注销任务智能体、更新任务智能体的主页数据、写入智能体任务记录。当用户需要创建待办事项、查看任务列表、跟踪任务进度、管理项目清单或给他人分配任务、注册注销任务智能体、更新智能体主页数据、写入任务记录时使用。" +description: "飞书任务:管理任务、清单和任务智能体。创建待办任务、查看和更新任务状态、拆分子任务、组织任务清单、分配协作成员、上传任务附件、注册或注销任务智能体、更新任务智能体的主页数据、写入智能体任务记录。当用户需要创建待办事项、查看任务列表、跟踪任务进度、管理项目清单或给他人分配任务、为任务上传附件文件、注册注销任务智能体、更新智能体主页数据、写入任务记录时使用。" metadata: requires: bins: ["lark-cli"] @@ -52,6 +52,7 @@ metadata: - [`+tasklist-search`](./references/lark-task-tasklist-search.md) — Search tasklists - [`+tasklist-task-add`](./references/lark-task-tasklist-task-add.md) — Add existing tasks to a tasklist - [`+tasklist-members`](./references/lark-task-tasklist-members.md) — Manage tasklist members +- [`+upload-attachment`](./references/lark-task-upload-attachment.md) — Upload a file as a task attachment ## API Resources @@ -161,3 +162,4 @@ lark-cli task [flags] # 调用 API | `agent.update_agent_profile` | `task:task:write` | | `agent.register_agent` | `task:task:write` | | `agent_task_step_info.append_task_steps` | `task:task:write` | +| `+upload-attachment` | `task:attachment:write` | diff --git a/skills/lark-task/references/lark-task-upload-attachment.md b/skills/lark-task/references/lark-task-upload-attachment.md new file mode 100644 index 000000000..26a99529b --- /dev/null +++ b/skills/lark-task/references/lark-task-upload-attachment.md @@ -0,0 +1,52 @@ +# task +upload-attachment + +> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules. + +Upload a single local file as an attachment to a task (or any resource type accepted by the Task attachment endpoint). Max file size per upload is **50 MB**. + +## Recommended Commands + +```bash +# Upload a local file as a task attachment (relative path required) +lark-cli task +upload-attachment \ + --resource-id "" \ + --file "./report.pdf" + +# Pass a Feishu task applink instead of a raw guid — the guid is extracted automatically +lark-cli task +upload-attachment \ + --resource-id "https://applink.feishu.cn/client/todo/task?guid=" \ + --file "./note.md" + +# Explicit resource type / user id type +lark-cli task +upload-attachment \ + --resource-id "" \ + --resource-type task \ + --user-id-type open_id \ + --file "./design.png" +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--resource-id ` | Yes | Target resource GUID. Accepts a raw task GUID or a Feishu task applink URL (`.../client/todo/task?guid=...`); the `guid` query parameter is extracted automatically. Do not use `suite_entity_num` / display IDs like `t104121`. | +| `--file ` | Yes | Local file path to upload. Must be a relative path within the current working directory; absolute paths and paths escaping the cwd are rejected. Single file only, ≤ 50 MB. | +| `--resource-type ` | No | Owning resource type. Defaults to `task`. | +| `--user-id-type ` | No | User ID type for the request. Defaults to `open_id`. | + +## Workflow + +1. Confirm the target task GUID (or applink) and the local file path with the user. +2. Ensure the file is within the current working directory and its size is ≤ 50 MB; otherwise ask the user to move/split the file. +3. Execute `lark-cli task +upload-attachment --resource-id "..." --file "..."`. +4. Report the returned attachment record. The output exposes all fields returned by the API (e.g. `guid`, `name`, `size`, `url`, `uploader`, ...); always surface the attachment `guid` and, if present, the `url` so the user can jump to the attachment directly. + +## Output + +The command returns the single created attachment record as a flat JSON object — every field returned by the API (`guid`, `name`, `size`, `url`, `resource_type`, `resource_id`, `uploader`, ...) is preserved verbatim. Pretty mode also prints a human-readable summary with the resource, file name, size, and attachment GUID. + +> [!CAUTION] +> This is a **Write Operation** -- You must confirm the user's intent before executing. + +> [!NOTE] +> The Task attachment upload endpoint accepts exactly one file per call. To upload multiple files, invoke the shortcut once per file. diff --git a/tests/cli_e2e/task/task_upload_attachment_dryrun_test.go b/tests/cli_e2e/task/task_upload_attachment_dryrun_test.go new file mode 100644 index 000000000..f0ac9e33c --- /dev/null +++ b/tests/cli_e2e/task/task_upload_attachment_dryrun_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestTask_UploadAttachmentDryRun validates the request shape emitted by +// task +upload-attachment under --dry-run: the full CLI binary is invoked +// end-to-end so flag parsing, validation, and the dry-run renderer all +// execute. Fake credentials are sufficient because --dry-run short-circuits +// before any network call. +func TestTask_UploadAttachmentDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "task_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantResourceType string + wantResourceID string + wantFilePath string + wantFileName string + wantUserIDType string + }{ + { + name: "default resource type and user id type", + args: []string{ + "task", "+upload-attachment", + "--resource-id", "task-guid-123", + "--file", "./note.pdf", + "--dry-run", + }, + wantResourceType: "task", + wantResourceID: "task-guid-123", + wantFilePath: "./note.pdf", + wantFileName: "note.pdf", + wantUserIDType: "open_id", + }, + { + name: "explicit resource type and user id type", + args: []string{ + "task", "+upload-attachment", + "--resource-id", "task-guid-456", + "--resource-type", "custom_type", + "--file", "./report.txt", + "--user-id-type", "union_id", + "--dry-run", + }, + wantResourceType: "custom_type", + wantResourceID: "task-guid-456", + wantFilePath: "./report.txt", + wantFileName: "report.txt", + wantUserIDType: "union_id", + }, + { + name: "applink URL resolves to guid", + args: []string{ + "task", "+upload-attachment", + "--resource-id", "https://applink.feishu.cn/client/todo/task?guid=task-from-url", + "--file", "./doc.md", + "--dry-run", + }, + wantResourceType: "task", + wantResourceID: "task-from-url", + wantFilePath: "./doc.md", + wantFileName: "doc.md", + wantUserIDType: "open_id", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if count := gjson.Get(out, "api.#").Int(); count != 1 { + t.Fatalf("expected 1 API call, got %d\nstdout:\n%s", count, out) + } + if method := gjson.Get(out, "api.0.method").String(); method != "POST" { + t.Fatalf("api[0].method = %q, want POST\nstdout:\n%s", method, out) + } + if url := gjson.Get(out, "api.0.url").String(); url != "/open-apis/task/v2/attachments/upload" { + t.Fatalf("api[0].url = %q, want /open-apis/task/v2/attachments/upload\nstdout:\n%s", url, out) + } + if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != tt.wantUserIDType { + t.Fatalf("api[0].params.user_id_type = %q, want %q\nstdout:\n%s", got, tt.wantUserIDType, out) + } + if got := gjson.Get(out, "api.0.body.resource_type").String(); got != tt.wantResourceType { + t.Fatalf("api[0].body.resource_type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out) + } + if got := gjson.Get(out, "api.0.body.resource_id").String(); got != tt.wantResourceID { + t.Fatalf("api[0].body.resource_id = %q, want %q\nstdout:\n%s", got, tt.wantResourceID, out) + } + if got := gjson.Get(out, "api.0.body.file.field").String(); got != "file" { + t.Fatalf("api[0].body.file.field = %q, want file\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.file.path").String(); got != tt.wantFilePath { + t.Fatalf("api[0].body.file.path = %q, want %q\nstdout:\n%s", got, tt.wantFilePath, out) + } + if got := gjson.Get(out, "api.0.body.file.name").String(); got != tt.wantFileName { + t.Fatalf("api[0].body.file.name = %q, want %q\nstdout:\n%s", got, tt.wantFileName, out) + } + }) + } +}