From 1eddd929c42d0104b80fecacd7caa2862fefd4fa Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 2 Apr 2026 17:51:18 +0800 Subject: [PATCH] fix: skip task completion when already completed --- shortcuts/task/task_complete.go | 57 +++++++-- shortcuts/task/task_complete_test.go | 111 ++++++++++++++++++ shortcuts/task/task_get_my_tasks.go | 9 +- shortcuts/task/task_get_my_tasks_test.go | 91 ++++++++++++++ shortcuts/task/task_shortcut_test.go | 88 ++++++++++++++ skills/lark-task/SKILL.md | 11 ++ .../references/lark-task-get-my-tasks.md | 4 + .../references/lark-task-reminder.md | 2 +- 8 files changed, 357 insertions(+), 16 deletions(-) create mode 100644 shortcuts/task/task_complete_test.go create mode 100644 shortcuts/task/task_get_my_tasks_test.go create mode 100644 shortcuts/task/task_shortcut_test.go diff --git a/shortcuts/task/task_complete.go b/shortcuts/task/task_complete.go index 8a0d261275..8f58c36020 100644 --- a/shortcuts/task/task_complete.go +++ b/shortcuts/task/task_complete.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +// CompleteTask marks a task as complete and skips the PATCH call if already completed. var CompleteTask = common.Shortcut{ Service: "task", Command: "+complete", @@ -34,35 +35,69 @@ var CompleteTask = common.Shortcut{ body := buildCompleteBody() taskId := url.PathEscape(runtime.Str("task-id")) return common.NewDryRunAPI(). + GET("/open-apis/task/v2/tasks/" + taskId). + Desc("get current task status"). + Params(map[string]interface{}{"user_id_type": "open_id"}). PATCH("/open-apis/task/v2/tasks/" + taskId). + Desc("complete task if not completed"). Params(map[string]interface{}{"user_id_type": "open_id"}). Body(body) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { taskId := url.PathEscape(runtime.Str("task-id")) - body := buildCompleteBody() queryParams := make(larkcore.QueryParams) queryParams.Set("user_id_type", "open_id") - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ - HttpMethod: http.MethodPatch, + var data map[string]interface{} + + // 1. Get current task status + getResp, getErr := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, ApiPath: "/open-apis/task/v2/tasks/" + taskId, QueryParams: queryParams, - Body: body, }) - var result map[string]interface{} - if err == nil { - if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { - return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse complete response") + var getResult map[string]interface{} + if getErr == nil { + if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil { + return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse get response: %v", parseErr), "parse get response") } } - data, err := HandleTaskApiResult(result, err, "complete task") - if err != nil { - return err + getData, getErr := HandleTaskApiResult(getResult, getErr, "get task") + if getErr != nil { + return getErr + } + + taskData, _ := getData["task"].(map[string]interface{}) + completedAtStr, _ := taskData["completed_at"].(string) + + // 2. If already completed, directly return success + if completedAtStr != "" && completedAtStr != "0" { + data = getData + } else { + // 3. Complete the task + body := buildCompleteBody() + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPatch, + ApiPath: "/open-apis/task/v2/tasks/" + taskId, + QueryParams: queryParams, + Body: body, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse complete response") + } + } + + data, err = HandleTaskApiResult(result, err, "complete task") + if err != nil { + return err + } } task, _ := data["task"].(map[string]interface{}) diff --git a/shortcuts/task/task_complete_test.go b/shortcuts/task/task_complete_test.go new file mode 100644 index 0000000000..36f0ef9289 --- /dev/null +++ b/shortcuts/task/task_complete_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestCompleteTask(t *testing.T) { + tests := []struct { + name string + taskId string + isCompleted bool + formatFlag string + expectedOutput []string + }{ + { + name: "task already completed", + taskId: "task-123", + isCompleted: true, + formatFlag: "pretty", + expectedOutput: []string{ + "✅ Task completed successfully!", + "Task ID: task-123", + }, + }, + { + name: "task not completed", + taskId: "task-456", + isCompleted: false, + formatFlag: "pretty", + expectedOutput: []string{ + "✅ Task completed successfully!", + "Task ID: task-456", + }, + }, + { + name: "task not completed json format", + taskId: "task-789", + isCompleted: false, + formatFlag: "json", + expectedOutput: []string{ + `"guid": "task-789"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + completedAt := "0" + if tt.isCompleted { + completedAt = "1775174400000" + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks/" + tt.taskId, + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "guid": tt.taskId, + "summary": "Test Task " + tt.taskId, + "completed_at": completedAt, + "url": "https://example.com/" + tt.taskId, + }, + }, + }, + }) + + if !tt.isCompleted { + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/task/v2/tasks/" + tt.taskId, + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "guid": tt.taskId, + "summary": "Test Task " + tt.taskId, + "completed_at": "1775174400000", + "url": "https://example.com/" + tt.taskId, + }, + }, + }, + }) + } + + err := runMountedTaskShortcut(t, CompleteTask, []string{"+complete", "--task-id", tt.taskId, "--format", tt.formatFlag, "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + + for _, expected := range tt.expectedOutput { + if !strings.Contains(outNorm, expected) && !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} diff --git a/shortcuts/task/task_get_my_tasks.go b/shortcuts/task/task_get_my_tasks.go index 5f738054f7..3de8ca0423 100644 --- a/shortcuts/task/task_get_my_tasks.go +++ b/shortcuts/task/task_get_my_tasks.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +// GetMyTasks lists tasks assigned to the current user. var GetMyTasks = common.Shortcut{ Service: "task", Command: "+get-my-tasks", @@ -214,13 +215,13 @@ var GetMyTasks = common.Shortcut{ } if createdAtStr, ok := item["created_at"].(string); ok { if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil { - outputItem["created_at"] = time.UnixMilli(ts).UTC().Format(time.RFC3339) + outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339) } } if dueObj, ok := item["due"].(map[string]interface{}); ok { if tsStr, ok := dueObj["timestamp"].(string); ok { if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { - outputItem["due_at"] = time.UnixMilli(ts).UTC().Format(time.RFC3339) + outputItem["due_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339) } } } @@ -249,7 +250,7 @@ var GetMyTasks = common.Shortcut{ if dueObj, ok := item["due"].(map[string]interface{}); ok { if tsStr, ok := dueObj["timestamp"].(string); ok { if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { - dueTimeStr = time.UnixMilli(ts).Format("2006-01-02 15:04") + dueTimeStr = time.UnixMilli(ts).Local().Format("2006-01-02 15:04") } } } @@ -257,7 +258,7 @@ var GetMyTasks = common.Shortcut{ var createdDateStr string if createdStr, ok := item["created_at"].(string); ok { if ts, err := strconv.ParseInt(createdStr, 10, 64); err == nil { - createdDateStr = time.UnixMilli(ts).Format("2006-01-02") + createdDateStr = time.UnixMilli(ts).Local().Format("2006-01-02") } } diff --git a/shortcuts/task/task_get_my_tasks_test.go b/shortcuts/task/task_get_my_tasks_test.go new file mode 100644 index 0000000000..7e569330d4 --- /dev/null +++ b/shortcuts/task/task_get_my_tasks_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strconv" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestGetMyTasks_LocalTimeFormatting(t *testing.T) { + tsMs := int64(1775174400000) + tsStr := strconv.FormatInt(tsMs, 10) + expectedDueTimeStr := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04") + expectedCreatedDateStr := time.UnixMilli(tsMs).Local().Format("2006-01-02") + expectedRFC3339 := time.UnixMilli(tsMs).Local().Format(time.RFC3339) + + tests := []struct { + name string + formatFlag string + expectedOutput []string + }{ + { + name: "pretty format", + formatFlag: "pretty", + expectedOutput: []string{ + "Due: " + expectedDueTimeStr, + "Created: " + expectedCreatedDateStr, + }, + }, + { + name: "json format", + formatFlag: "json", + expectedOutput: []string{ + `"due_at": "` + expectedRFC3339 + `"`, + `"created_at": "` + expectedRFC3339 + `"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-123", + "summary": "Test Task", + "created_at": tsStr, + "due": map[string]interface{}{ + "timestamp": tsStr, + }, + "url": "https://example.com/task-123", + }, + }, + "has_more": false, + "page_token": "", + }, + }, + }) + + s := GetMyTasks + s.AuthTypes = []string{"bot", "user"} + + err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", tt.formatFlag, "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + + for _, expected := range tt.expectedOutput { + if !strings.Contains(outNorm, expected) && !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} diff --git a/shortcuts/task/task_shortcut_test.go b/shortcuts/task/task_shortcut_test.go new file mode 100644 index 0000000000..f70cde485b --- /dev/null +++ b/shortcuts/task/task_shortcut_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func taskTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + suffix := strings.NewReplacer("/", "-", " ", "-", ":", "-", "\t", "-").Replace(t.Name()) + return &core.CliConfig{ + AppID: "test-app-" + suffix, + AppSecret: "test-secret-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + UserName: "Test User", + } +} + +func warmTenantToken(t *testing.T, f *cmdutil.Factory, reg *httpmock.Registry) { + t.Helper() + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", + "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+warm-token", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm-token", "--as", "bot"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("warm tenant token: %v", err) + } +} + +func taskShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + return cmdutil.TestFactory(t, taskTestConfig(t)) +} + +func runMountedTaskShortcut(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "test"} + shortcut.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index a1215508aa..23646a6204 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -17,6 +17,17 @@ metadata: > **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 > **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。 +> **创建/更新注意**: +> 1. 只有在设置了 `due`(截止时间)的情况下,才能设置 `repeat_rule`(重复规则)和 `reminder`(提醒时间)。 +> 2. 若同时设置了 `start`(开始时间)和 `due`(截止时间),开始时间必须小于或等于截止时间。 +> 3. 使用 tenant_access_token(应用身份)时,无法跨租户添加任务成员。 + + +> **查询注意**: +> 1. 在输出任务详情时,如果需要渲染负责人、创建人等人员字段,除了展示 `id` (例如 open_id) 外,还必须通过其他方式(例如调用通讯录技能)尝试获取并展示这个人的真实名字,以便用户更容易识别。 +> 2. 在输出任务详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。 + + ## Shortcuts - [`+create`](./references/lark-task-create.md) — Create a task diff --git a/skills/lark-task/references/lark-task-get-my-tasks.md b/skills/lark-task/references/lark-task-get-my-tasks.md index b3614f9bfd..a29a259b53 100644 --- a/skills/lark-task/references/lark-task-get-my-tasks.md +++ b/skills/lark-task/references/lark-task-get-my-tasks.md @@ -5,6 +5,10 @@ If the user query only specifies a task name (e.g., "Complete task Lobster No. 1 > **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules. > > **⚠️ Note:** This API must be called with a user identity. **Do NOT use an app identity, otherwise the call will fail.** +> +> **Output rendering note:** +> 1. If you need to present user fields (assignee, creator, etc.), do not only output the raw `id` (e.g. open_id). Also try to resolve and display the user's real name (e.g. via the contact skill) for readability. +> 2. When rendering timestamps (e.g. created time, due time), use the local timezone. Format is 2006-01-02 15:04:05 List tasks assigned to the current user, with support for filtering by completion status, creation time, and due date. By default, the command will automatically paginate up to 20 times. Use `--page-all` to fetch more (up to 40 pages). diff --git a/skills/lark-task/references/lark-task-reminder.md b/skills/lark-task/references/lark-task-reminder.md index 6a12d0fd3e..07c12d2030 100644 --- a/skills/lark-task/references/lark-task-reminder.md +++ b/skills/lark-task/references/lark-task-reminder.md @@ -3,7 +3,7 @@ > **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules. > **Priority:** For creating or modifying task reminder times, prioritize using this `+reminder` shortcut over other task update methods. It provides a more reliable and direct way to manage reminders. -Manage task reminders. Set new reminders or remove existing ones. +Manage task reminders. Set new reminders or remove existing ones. Note that setting a task reminder requires a due date. ## Recommended Commands