From b014accd420ca0da693ed6620231d858c7d575a0 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 09:18:06 +0800 Subject: [PATCH 01/21] feat(task): add task shortcuts with skill docs and tests --- shortcuts/task/shortcuts.go | 5 + shortcuts/task/task_get_related_tasks.go | 155 +++++++++ shortcuts/task/task_get_related_tasks_test.go | 207 ++++++++++++ shortcuts/task/task_query_helpers.go | 198 ++++++++++++ shortcuts/task/task_query_helpers_test.go | 273 ++++++++++++++++ shortcuts/task/task_search.go | 222 +++++++++++++ shortcuts/task/task_search_test.go | 298 ++++++++++++++++++ shortcuts/task/task_set_ancestor.go | 77 +++++ shortcuts/task/task_set_ancestor_test.go | 126 ++++++++ shortcuts/task/task_subscribe_event.go | 48 +++ shortcuts/task/task_subscribe_event_test.go | 75 +++++ shortcuts/task/task_tasklist_search.go | 205 ++++++++++++ shortcuts/task/task_tasklist_search_test.go | 236 ++++++++++++++ skills/lark-task/SKILL.md | 11 +- .../references/lark-task-get-related-tasks.md | 51 +++ .../lark-task/references/lark-task-search.md | 41 +++ .../references/lark-task-set-ancestor.md | 32 ++ .../references/lark-task-subscribe-event.md | 49 +++ .../references/lark-task-tasklist-search.md | 38 +++ 19 files changed, 2344 insertions(+), 3 deletions(-) create mode 100644 shortcuts/task/task_get_related_tasks.go create mode 100644 shortcuts/task/task_get_related_tasks_test.go create mode 100644 shortcuts/task/task_query_helpers.go create mode 100644 shortcuts/task/task_query_helpers_test.go create mode 100644 shortcuts/task/task_search.go create mode 100644 shortcuts/task/task_search_test.go create mode 100644 shortcuts/task/task_set_ancestor.go create mode 100644 shortcuts/task/task_set_ancestor_test.go create mode 100644 shortcuts/task/task_subscribe_event.go create mode 100644 shortcuts/task/task_subscribe_event_test.go create mode 100644 shortcuts/task/task_tasklist_search.go create mode 100644 shortcuts/task/task_tasklist_search_test.go create mode 100644 skills/lark-task/references/lark-task-get-related-tasks.md create mode 100644 skills/lark-task/references/lark-task-search.md create mode 100644 skills/lark-task/references/lark-task-set-ancestor.md create mode 100644 skills/lark-task/references/lark-task-subscribe-event.md create mode 100644 skills/lark-task/references/lark-task-tasklist-search.md diff --git a/shortcuts/task/shortcuts.go b/shortcuts/task/shortcuts.go index c15f09c2..423aff68 100644 --- a/shortcuts/task/shortcuts.go +++ b/shortcuts/task/shortcuts.go @@ -223,6 +223,7 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ CreateTask, UpdateTask, + SetAncestorTask, CommentTask, CompleteTask, ReopenTask, @@ -230,7 +231,11 @@ func Shortcuts() []common.Shortcut { FollowersTask, ReminderTask, GetMyTasks, + GetRelatedTasks, + SearchTask, + SubscribeTaskEvent, CreateTasklist, + SearchTasklist, AddTaskToTasklist, MembersTasklist, } diff --git a/shortcuts/task/task_get_related_tasks.go b/shortcuts/task/task_get_related_tasks.go new file mode 100644 index 00000000..88c66850 --- /dev/null +++ b/shortcuts/task/task_get_related_tasks.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + relatedTasksDefaultPageLimit = 20 + relatedTasksMaxPageLimit = 40 + relatedTasksPageSize = 100 +) + +var GetRelatedTasks = common.Shortcut{ + Service: "task", + Command: "+get-related-tasks", + Description: "list tasks related to me", + Risk: "read", + Scopes: []string{"task:task:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"}, + {Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"}, + {Name: "page-token", Desc: "page token / updated_at cursor in milliseconds"}, + {Name: "created-by-me", Type: "bool", Desc: "filter to tasks created by me"}, + {Name: "followed-by-me", Type: "bool", Desc: "filter to tasks followed by me"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{ + "user_id_type": "open_id", + "page_size": relatedTasksPageSize, + } + if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") { + params["completed"] = false + } + if pageToken := runtime.Str("page-token"); pageToken != "" { + params["page_token"] = pageToken + } + return common.NewDryRunAPI(). + GET("/open-apis/task/v2/task_v2/list_related_task"). + Params(params) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize)) + if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") { + queryParams.Set("completed", "false") + } + if pageToken := runtime.Str("page-token"); pageToken != "" { + queryParams.Set("page_token", pageToken) + } + + pageLimit := runtime.Int("page-limit") + if pageLimit <= 0 { + pageLimit = relatedTasksDefaultPageLimit + } + if runtime.Bool("page-all") { + pageLimit = relatedTasksMaxPageLimit + } + if pageLimit > relatedTasksMaxPageLimit { + pageLimit = relatedTasksMaxPageLimit + } + + var allItems []interface{} + var lastPageToken string + var lastHasMore bool + for page := 0; page < pageLimit; page++ { + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/task/v2/task_v2/list_related_task", + QueryParams: queryParams, + }) + 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 related tasks") + } + } + data, err := HandleTaskApiResult(result, err, "list related tasks") + if err != nil { + return err + } + + items, _ := data["items"].([]interface{}) + allItems = append(allItems, items...) + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + if !lastHasMore || lastPageToken == "" { + break + } + queryParams.Set("page_token", lastPageToken) + } + + userOpenID := runtime.UserOpenId() + filtered := make([]map[string]interface{}, 0, len(allItems)) + for _, item := range allItems { + task, ok := item.(map[string]interface{}) + if !ok { + continue + } + if runtime.Bool("created-by-me") { + creator, _ := task["creator"].(map[string]interface{}) + if creatorID, _ := creator["id"].(string); creatorID != userOpenID { + continue + } + } + if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) { + continue + } + filtered = append(filtered, outputRelatedTask(task)) + } + + outData := map[string]interface{}{ + "items": filtered, + "page_token": lastPageToken, + "has_more": lastHasMore, + } + runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) { + if len(filtered) == 0 { + fmt.Fprintln(w, "No related tasks found.") + return + } + io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken)) + }) + return nil + }, +} + +func taskFollowedBy(task map[string]interface{}, userOpenID string) bool { + members, _ := task["members"].([]interface{}) + for _, member := range members { + memberObj, _ := member.(map[string]interface{}) + role, _ := memberObj["role"].(string) + id, _ := memberObj["id"].(string) + if strings.EqualFold(role, "follower") && id == userOpenID { + return true + } + } + return false +} diff --git a/shortcuts/task/task_get_related_tasks_test.go b/shortcuts/task/task_get_related_tasks_test.go new file mode 100644 index 00000000..18d9de78 --- /dev/null +++ b/shortcuts/task/task_get_related_tasks_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestTaskFollowedBy(t *testing.T) { + tests := []struct { + name string + task map[string]interface{} + userOpenID string + want bool + }{ + { + name: "contains follower", + task: map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"id": "ou_1", "role": "assignee"}, + map[string]interface{}{"id": "ou_2", "role": "follower"}, + }, + }, + userOpenID: "ou_2", + want: true, + }, + { + name: "missing follower", + task: map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"id": "ou_1", "role": "assignee"}, + }, + }, + userOpenID: "ou_3", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := taskFollowedBy(tt.task, tt.userOpenID) + if got != tt.want { + t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetRelatedTasks_DryRun(t *testing.T) { + tests := []struct { + name string + setup func(*cobra.Command) + wantParts []string + }{ + { + name: "with page token and incomplete filter", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("include-complete", "false") + _ = cmd.Flags().Set("page-token", "pt_001") + }, + wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"}, + }, + { + name: "default query params", + setup: func(cmd *cobra.Command) {}, + wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Bool("include-complete", true, "") + cmd.Flags().String("page-token", "", "") + tt.setup(cmd) + runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user") + out := GetRelatedTasks.DryRun(nil, runtime).Format() + for _, want := range tt.wantParts { + if !strings.Contains(out, want) { + t.Fatalf("dry run output missing %q: %s", want, out) + } + } + }) + } +} + +func TestGetRelatedTasks_Execute(t *testing.T) { + tests := []struct { + name string + args []string + register func(*httpmock.Registry) + wantParts []string + }{ + { + name: "json created by me", + args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/task_v2/list_related_task", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-123", + "summary": "Related Task", + "description": "desc", + "status": "done", + "source": 1, + "mode": 2, + "subtask_count": 0, + "tasklists": []interface{}{}, + "url": "https://example.com/task-123", + "creator": map[string]interface{}{"id": "ou_testuser", "type": "user"}, + }, + }, + }, + }, + }) + }, + wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`}, + }, + { + name: "pretty pagination followed by me", + args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/task_v2/list_related_task", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": true, + "page_token": "pt_2", + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-1", + "summary": "Task One", + "url": "https://example.com/task-1", + "creator": map[string]interface{}{"id": "ou_other", "type": "user"}, + "members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}}, + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "page_token=pt_2", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-2", + "summary": "Task Two", + "url": "https://example.com/task-2", + "creator": map[string]interface{}{"id": "ou_other", "type": "user"}, + "members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}}, + }, + }, + }, + }, + }) + }, + wantParts: []string{"Task One", "Task Two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + tt.register(reg) + + s := GetRelatedTasks + s.AuthTypes = []string{"bot", "user"} + err := runMountedTaskShortcut(t, s, tt.args, f, stdout) + if err != nil { + t.Fatalf("runMountedTaskShortcut() error = %v", err) + } + + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + for _, want := range tt.wantParts { + if !strings.Contains(out, want) && !strings.Contains(outNorm, want) { + t.Fatalf("output missing %q: %s", want, out) + } + } + }) + } +} diff --git a/shortcuts/task/task_query_helpers.go b/shortcuts/task/task_query_helpers.go new file mode 100644 index 00000000..3b2386c9 --- /dev/null +++ b/shortcuts/task/task_query_helpers.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +func splitAndTrimCSV(input string) []string { + parts := strings.Split(input, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func buildUserIDs(ids []string) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]interface{}{ + "id": id, + "type": "user", + }) + } + return out +} + +func parseTimeRangeMillis(input string) (string, string, error) { + if strings.TrimSpace(input) == "" { + return "", "", nil + } + + parts := strings.SplitN(input, ",", 2) + startInput := strings.TrimSpace(parts[0]) + endInput := "" + if len(parts) == 2 { + endInput = strings.TrimSpace(parts[1]) + } + + var startMillis, endMillis string + if startInput != "" { + startSec, err := parseTimeFlagSec(startInput, "start") + if err != nil { + return "", "", err + } + startMillis = startSec + "000" + } + if endInput != "" { + endSec, err := parseTimeFlagSec(endInput, "end") + if err != nil { + return "", "", err + } + endMillis = endSec + "000" + } + return startMillis, endMillis, nil +} + +func formatTaskDateTimeMillis(msStr string) string { + if msStr == "" || msStr == "0" { + return "" + } + ms, err := strconv.ParseInt(msStr, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(ms).Local().Format(time.DateTime) +} + +func outputTaskSummary(task map[string]interface{}) map[string]interface{} { + urlVal, _ := task["url"].(string) + urlVal = truncateTaskURL(urlVal) + + out := map[string]interface{}{ + "guid": task["guid"], + "summary": task["summary"], + "url": urlVal, + } + if createdAt, _ := task["created_at"].(string); createdAt != "" { + if created := formatTaskDateTimeMillis(createdAt); created != "" { + out["created_at"] = created + } + } + if completedAt, _ := task["completed_at"].(string); completedAt != "" { + if completed := formatTaskDateTimeMillis(completedAt); completed != "" { + out["completed_at"] = completed + } + } + if updatedAt, _ := task["updated_at"].(string); updatedAt != "" { + if updated := formatTaskDateTimeMillis(updatedAt); updated != "" { + out["updated_at"] = updated + } + } + if dueObj, ok := task["due"].(map[string]interface{}); ok { + if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" { + if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" { + out["due_at"] = dueAt + } + } + } + return out +} + +func outputRelatedTask(task map[string]interface{}) map[string]interface{} { + urlVal, _ := task["url"].(string) + urlVal = truncateTaskURL(urlVal) + + out := map[string]interface{}{ + "guid": task["guid"], + "summary": task["summary"], + "description": task["description"], + "status": task["status"], + "source": task["source"], + "mode": task["mode"], + "subtask_count": task["subtask_count"], + "tasklists": task["tasklists"], + "url": urlVal, + } + if creator, ok := task["creator"].(map[string]interface{}); ok { + out["creator"] = creator + } + if members, ok := task["members"].([]interface{}); ok { + out["members"] = members + } + if createdAt, _ := task["created_at"].(string); createdAt != "" { + if created := formatTaskDateTimeMillis(createdAt); created != "" { + out["created_at"] = created + } + } + if completedAt, _ := task["completed_at"].(string); completedAt != "" { + if completed := formatTaskDateTimeMillis(completedAt); completed != "" { + out["completed_at"] = completed + } + } + return out +} + +func buildTimeRangeFilter(key, start, end string) map[string]interface{} { + timeRange := map[string]interface{}{} + if start != "" { + timeRange["start_time"] = start + } + if end != "" { + timeRange["end_time"] = end + } + if len(timeRange) == 0 { + return nil + } + return map[string]interface{}{key: timeRange} +} + +func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) { + for k, v := range src { + dst[k] = v + } +} + +func requireSearchFilter(query string, filter map[string]interface{}, action string) error { + if strings.TrimSpace(query) != "" { + return nil + } + if len(filter) > 0 { + return nil + } + return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action) +} + +func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string { + var b strings.Builder + for i, item := range items { + fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"]) + fmt.Fprintf(&b, " GUID: %v\n", item["guid"]) + if status, _ := item["status"].(string); status != "" { + fmt.Fprintf(&b, " Status: %s\n", status) + } + if created, _ := item["created_at"].(string); created != "" { + fmt.Fprintf(&b, " Created: %s\n", created) + } + if completed, _ := item["completed_at"].(string); completed != "" { + fmt.Fprintf(&b, " Completed: %s\n", completed) + } + if urlVal, _ := item["url"].(string); urlVal != "" { + fmt.Fprintf(&b, " URL: %s\n", urlVal) + } + b.WriteString("\n") + } + if hasMore && pageToken != "" { + fmt.Fprintf(&b, "Next page token: %s\n", pageToken) + } + return b.String() +} diff --git a/shortcuts/task/task_query_helpers_test.go b/shortcuts/task/task_query_helpers_test.go new file mode 100644 index 00000000..ab831d2d --- /dev/null +++ b/shortcuts/task/task_query_helpers_test.go @@ -0,0 +1,273 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" +) + +func TestSplitAndTrimCSV(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}}, + {name: "empty input", input: "", want: []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitAndTrimCSV(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestBuildUserIDs(t *testing.T) { + tests := []struct { + name string + ids []string + want []map[string]interface{} + }{ + { + name: "multiple ids", + ids: []string{"ou_1", "ou_2"}, + want: []map[string]interface{}{ + {"id": "ou_1", "type": "user"}, + {"id": "ou_2", "type": "user"}, + }, + }, + {name: "empty ids", ids: []string{}, want: []map[string]interface{}{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildUserIDs(tt.ids) + if len(got) != len(tt.want) { + t.Fatalf("len(buildUserIDs()) = %d, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i]["id"] != tt.want[i]["id"] || got[i]["type"] != tt.want[i]["type"] { + t.Fatalf("buildUserIDs()[%d] = %#v, want %#v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestOutputTaskSummary(t *testing.T) { + tests := []struct { + name string + task map[string]interface{} + }{ + { + name: "with timestamps and due", + task: map[string]interface{}{ + "guid": "task-123", + "summary": "summary", + "url": "https://example.com/task-123&suite_entity_num=t1", + "created_at": "1775174400000", + "due": map[string]interface{}{ + "timestamp": "1775174400000", + }, + }, + }, + { + name: "with completed and updated", + task: map[string]interface{}{ + "guid": "task-456", + "summary": "done", + "url": "https://example.com/task-456", + "completed_at": "1775174400000", + "updated_at": "1775174400000", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := outputTaskSummary(tt.task) + if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] { + t.Fatalf("unexpected summary output: %#v", got) + } + if got["url"] == "" { + t.Fatalf("expected url in output, got %#v", got) + } + }) + } +} + +func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) { + timeTests := []struct { + name string + input string + wantErr bool + wantStart string + wantEnd string + }{ + {name: "empty input", input: "", wantStart: "", wantEnd: ""}, + {name: "invalid input", input: "bad-time", wantErr: true}, + {name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"}, + } + for _, tt := range timeTests { + t.Run("parse:"+tt.name, func(t *testing.T) { + start, end, err := parseTimeRangeMillis(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err) + } + if tt.wantStart == "" && start != "" { + t.Fatalf("start = %q, want empty", start) + } + if tt.wantEnd == "" && end != "" { + t.Fatalf("end = %q, want empty", end) + } + if tt.wantStart == "non-empty" && start == "" { + t.Fatalf("start should not be empty") + } + if tt.wantEnd == "non-empty" && end == "" { + t.Fatalf("end should not be empty") + } + }) + } + + filterTests := []struct { + name string + query string + filter map[string]interface{} + wantErr bool + }{ + {name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true}, + {name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false}, + {name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false}, + } + for _, tt := range filterTests { + t.Run("filter:"+tt.name, func(t *testing.T) { + err := requireSearchFilter(tt.query, tt.filter, "search") + if tt.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) { + outputTests := []struct { + name string + task map[string]interface{} + }{ + { + name: "full related task", + task: map[string]interface{}{ + "guid": "task-123", + "summary": "Related Task", + "description": "desc", + "status": "todo", + "source": 1, + "mode": 2, + "subtask_count": 0, + "tasklists": []interface{}{}, + "url": "https://example.com/task-123&suite_entity_num=t1", + "creator": map[string]interface{}{"id": "ou_1"}, + "members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}}, + "created_at": "1775174400000", + "completed_at": "1775174400000", + }, + }, + { + name: "minimal related task", + task: map[string]interface{}{ + "guid": "task-456", + "summary": "Minimal", + "url": "https://example.com/task-456", + }, + }, + } + for _, tt := range outputTests { + t.Run("output:"+tt.name, func(t *testing.T) { + got := outputRelatedTask(tt.task) + if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] { + t.Fatalf("unexpected related task output: %#v", got) + } + }) + } + + rangeTests := []struct { + name string + start string + end string + wantNil bool + }{ + {name: "empty range", start: "", end: "", wantNil: true}, + {name: "full range", start: "1", end: "2", wantNil: false}, + } + for _, tt := range rangeTests { + t.Run("range:"+tt.name, func(t *testing.T) { + got := buildTimeRangeFilter("due_time", tt.start, tt.end) + if tt.wantNil && got != nil { + t.Fatalf("expected nil, got %#v", got) + } + if !tt.wantNil && got == nil { + t.Fatalf("expected range filter, got nil") + } + }) + } +} + +func TestRenderRelatedTasksPretty(t *testing.T) { + tests := []struct { + name string + items []map[string]interface{} + hasMore bool + pageToken string + wantParts []string + }{ + { + name: "includes next token", + items: []map[string]interface{}{ + {"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"}, + }, + hasMore: true, + pageToken: "pt_123", + wantParts: []string{"Related Task", "Next page token: pt_123"}, + }, + { + name: "without next token", + items: []map[string]interface{}{ + {"guid": "task-456", "summary": "Another Task"}, + }, + hasMore: false, + pageToken: "", + wantParts: []string{"Another Task"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken) + for _, want := range tt.wantParts { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q: %s", want, out) + } + } + }) + } +} diff --git a/shortcuts/task/task_search.go b/shortcuts/task/task_search.go new file mode 100644 index 00000000..48f686b9 --- /dev/null +++ b/shortcuts/task/task_search.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + taskSearchDefaultPageLimit = 20 + taskSearchMaxPageLimit = 40 +) + +var SearchTask = common.Shortcut{ + Service: "task", + Command: "+search", + Description: "search tasks", + Risk: "read", + Scopes: []string{"task:task:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"}, + {Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"}, + {Name: "page-token", Desc: "page token"}, + {Name: "creator", Desc: "creator open_ids, comma-separated"}, + {Name: "assignee", Desc: "assignee open_ids, comma-separated"}, + {Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"}, + {Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"}, + {Name: "follower", Desc: "follower open_ids, comma-separated"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, err := buildTaskSearchBody(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/task/v2/tasks/search"). + Body(body). + Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output") + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := buildTaskSearchBody(runtime) + return err + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildTaskSearchBody(runtime) + if err != nil { + return err + } + + pageLimit := runtime.Int("page-limit") + if pageLimit <= 0 { + pageLimit = taskSearchDefaultPageLimit + } + if runtime.Bool("page-all") { + pageLimit = taskSearchMaxPageLimit + } + if pageLimit > taskSearchMaxPageLimit { + pageLimit = taskSearchMaxPageLimit + } + + var rawItems []interface{} + var lastPageToken string + var lastHasMore bool + currentBody := body + for page := 0; page < pageLimit; page++ { + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/task/v2/tasks/search", + Body: currentBody, + }) + 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 task search") + } + } + data, err := HandleTaskApiResult(result, err, "search tasks") + if err != nil { + return err + } + items, _ := data["items"].([]interface{}) + rawItems = append(rawItems, items...) + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + if !lastHasMore || lastPageToken == "" { + break + } + currentBody["page_token"] = lastPageToken + } + + enriched := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + itemMap, _ := item.(map[string]interface{}) + taskID, _ := itemMap["id"].(string) + if taskID == "" { + continue + } + + task, err := getTaskDetail(runtime, taskID) + if err != nil { + metaData, _ := itemMap["meta_data"].(map[string]interface{}) + appLink, _ := metaData["app_link"].(string) + enriched = append(enriched, map[string]interface{}{ + "guid": taskID, + "url": truncateTaskURL(appLink), + }) + continue + } + enriched = append(enriched, outputTaskSummary(task)) + } + + outData := map[string]interface{}{ + "items": enriched, + "page_token": lastPageToken, + "has_more": lastHasMore, + } + runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) { + if len(enriched) == 0 { + fmt.Fprintln(w, "No tasks found.") + return + } + for i, item := range enriched { + fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"]) + fmt.Fprintf(w, " GUID: %v\n", item["guid"]) + if created, _ := item["created_at"].(string); created != "" { + fmt.Fprintf(w, " Created: %s\n", created) + } + if dueAt, _ := item["due_at"].(string); dueAt != "" { + fmt.Fprintf(w, " Due: %s\n", dueAt) + } + if urlVal, _ := item["url"].(string); urlVal != "" { + fmt.Fprintf(w, " URL: %s\n", urlVal) + } + fmt.Fprintln(w) + } + if lastHasMore && lastPageToken != "" { + fmt.Fprintf(w, "Next page token: %s\n", lastPageToken) + } + }) + return nil + }, +} + +func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + filter := map[string]interface{}{} + + if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 { + filter["creator_ids"] = ids + } + if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 { + filter["assignee_ids"] = ids + } + if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 { + filter["follower_ids"] = ids + } + if runtime.Cmd.Flags().Changed("completed") { + filter["is_completed"] = runtime.Bool("completed") + } + if dueRange := runtime.Str("due"); dueRange != "" { + start, end, err := parseTimeRangeMillis(dueRange) + if err != nil { + return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search") + } + if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil { + mergeIntoFilter(filter, dueFilter) + } + } + if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil { + return nil, err + } + + body := map[string]interface{}{ + "query": runtime.Str("query"), + } + if len(filter) > 0 { + body["filter"] = filter + } + if pageToken := runtime.Str("page-token"); pageToken != "" { + body["page_token"] = pageToken + } + return body, nil +} + +func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) { + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID), + QueryParams: queryParams, + }) + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail") + } + } + data, err := HandleTaskApiResult(result, err, "get task detail "+taskID) + if err != nil { + return nil, err + } + task, _ := data["task"].(map[string]interface{}) + if task == nil { + return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail") + } + return task, nil +} diff --git a/shortcuts/task/task_search_test.go b/shortcuts/task/task_search_test.go new file mode 100644 index 00000000..4edd8342 --- /dev/null +++ b/shortcuts/task/task_search_test.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestBuildTaskSearchBody(t *testing.T) { + tests := []struct { + name string + setup func(*cobra.Command) + wantErr bool + check func(*testing.T, map[string]interface{}) + }{ + { + name: "query creator due and page token", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("query", "release") + _ = cmd.Flags().Set("creator", "ou_a,ou_b") + _ = cmd.Flags().Set("completed", "true") + _ = cmd.Flags().Set("due", "-1d,+1d") + _ = cmd.Flags().Set("page-token", "pt_123") + }, + check: func(t *testing.T, body map[string]interface{}) { + filter := body["filter"].(map[string]interface{}) + dueTime := filter["due_time"].(map[string]interface{}) + if body["query"] != "release" || body["page_token"] != "pt_123" { + t.Fatalf("unexpected body: %#v", body) + } + if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true { + t.Fatalf("unexpected filter: %#v", filter) + } + if dueTime["start_time"] == "" || dueTime["end_time"] == "" { + t.Fatalf("unexpected due_time: %#v", dueTime) + } + }, + }, + { + name: "requires query or filter", + setup: func(cmd *cobra.Command) {}, + wantErr: true, + }, + { + name: "assignee follower and incomplete", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("assignee", "ou_assignee") + _ = cmd.Flags().Set("follower", "ou_follower") + _ = cmd.Flags().Set("completed", "false") + }, + check: func(t *testing.T, body map[string]interface{}) { + filter := body["filter"].(map[string]interface{}) + if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" { + t.Fatalf("unexpected filter: %#v", filter) + } + if filter["is_completed"] != false { + t.Fatalf("expected is_completed false, got %#v", filter["is_completed"]) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("query", "", "") + cmd.Flags().String("creator", "", "") + cmd.Flags().String("assignee", "", "") + cmd.Flags().String("follower", "", "") + cmd.Flags().Bool("completed", false, "") + cmd.Flags().String("due", "", "") + cmd.Flags().String("page-token", "", "") + tt.setup(cmd) + + runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user") + body, err := buildTaskSearchBody(runtime) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("buildTaskSearchBody() error = %v", err) + } + tt.check(t, body) + }) + } +} + +func TestSearchTask_DryRun(t *testing.T) { + tests := []struct { + name string + setup func(*cobra.Command) + wantParts []string + }{ + { + name: "valid dry run", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("query", "demo") + _ = cmd.Flags().Set("page-token", "pt_demo") + }, + wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`}, + }, + { + name: "dry run error on invalid due", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("due", "bad-time") + }, + wantParts: []string{"error:"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("query", "", "") + cmd.Flags().String("creator", "", "") + cmd.Flags().String("assignee", "", "") + cmd.Flags().String("follower", "", "") + cmd.Flags().Bool("completed", false, "") + cmd.Flags().String("due", "", "") + cmd.Flags().String("page-token", "", "") + tt.setup(cmd) + + runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user") + if !strings.Contains(tt.name, "error") { + if err := SearchTask.Validate(nil, runtime); err != nil { + t.Fatalf("Validate() error = %v", err) + } + } + out := SearchTask.DryRun(nil, runtime).Format() + for _, want := range tt.wantParts { + if !strings.Contains(out, want) { + t.Fatalf("dry run output missing %q: %s", want, out) + } + } + }) + } +} + +func TestSearchTask_Execute(t *testing.T) { + tests := []struct { + name string + args []string + register func(*httpmock.Registry) + wantParts []string + }{ + { + name: "json success", + args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}}, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks/task-123", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"}, + }, + }, + }) + }, + wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`}, + }, + { + name: "fallback to app link", + args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}}, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks/task-999", + Body: map[string]interface{}{"code": 99991663, "msg": "not found"}, + }) + }, + wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`}, + }, + { + name: "empty pretty with pagination", + args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}}, + }, + }) + }, + wantParts: []string{"No tasks found."}, + }, + { + name: "pretty with next page token", + args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": true, + "page_token": "pt_next", + "items": []interface{}{ + map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}}, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks/task-321", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"}, + }, + }, + }) + }, + wantParts: []string{"Pretty Search", "Next page token: pt_next"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + tt.register(reg) + + s := SearchTask + s.AuthTypes = []string{"bot", "user"} + err := runMountedTaskShortcut(t, s, tt.args, f, stdout) + if err != nil { + t.Fatalf("runMountedTaskShortcut() error = %v", err) + } + + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + for _, want := range tt.wantParts { + if !strings.Contains(out, want) && !strings.Contains(outNorm, want) { + t.Fatalf("output missing %q: %s", want, out) + } + } + }) + } +} diff --git a/shortcuts/task/task_set_ancestor.go b/shortcuts/task/task_set_ancestor.go new file mode 100644 index 00000000..c7fd7e87 --- /dev/null +++ b/shortcuts/task/task_set_ancestor.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +var SetAncestorTask = common.Shortcut{ + Service: "task", + Command: "+set-ancestor", + Description: "set or clear a task ancestor", + Risk: "write", + Scopes: []string{"task:task:write"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "task-id", Desc: "task guid to update", Required: true}, + {Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + taskID := url.PathEscape(runtime.Str("task-id")) + return common.NewDryRunAPI(). + POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task"). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(buildSetAncestorBody(runtime.Str("ancestor-id"))) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + taskID := runtime.Str("task-id") + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + + _, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task", + QueryParams: queryParams, + Body: buildSetAncestorBody(runtime.Str("ancestor-id")), + }) + if err != nil { + return err + } + + outData := map[string]interface{}{ + "ok": true, + "data": map[string]interface{}{ + "guid": taskID, + }, + } + runtime.OutFormat(outData, nil, func(w io.Writer) { + fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID) + if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" { + fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID) + } else { + fmt.Fprintln(w, "Ancestor cleared: task is now independent") + } + }) + return nil + }, +} + +func buildSetAncestorBody(ancestorID string) map[string]interface{} { + if ancestorID == "" { + return map[string]interface{}{} + } + return map[string]interface{}{ + "ancestor_guid": ancestorID, + } +} diff --git a/shortcuts/task/task_set_ancestor_test.go b/shortcuts/task/task_set_ancestor_test.go new file mode 100644 index 00000000..cd28efb3 --- /dev/null +++ b/shortcuts/task/task_set_ancestor_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestBuildSetAncestorBody(t *testing.T) { + tests := []struct { + name string + ancestorID string + want map[string]interface{} + }{ + {name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}}, + {name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildSetAncestorBody(tt.ancestorID) + if len(got) != len(tt.want) { + t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want)) + } + for k, want := range tt.want { + if got[k] != want { + t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want) + } + } + }) + } +} + +func TestSetAncestorTask_DryRun(t *testing.T) { + tests := []struct { + name string + taskID string + ancestor string + wantParts []string + }{ + { + name: "with ancestor", + taskID: "task-123", + ancestor: "task-456", + wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`}, + }, + { + name: "clear ancestor", + taskID: "task-123", + wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("task-id", "", "") + cmd.Flags().String("ancestor-id", "", "") + _ = cmd.Flags().Set("task-id", tt.taskID) + if tt.ancestor != "" { + _ = cmd.Flags().Set("ancestor-id", tt.ancestor) + } + runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot") + out := SetAncestorTask.DryRun(nil, runtime).Format() + for _, want := range tt.wantParts { + if !strings.Contains(out, want) { + t.Fatalf("dry run output missing %q: %s", want, out) + } + } + }) + } +} + +func TestSetAncestorTask_Execute(t *testing.T) { + tests := []struct { + name string + args []string + wantParts []string + }{ + { + name: "json output with ancestor", + args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"}, + wantParts: []string{`"guid": "task-123"`}, + }, + { + name: "pretty output clears ancestor", + args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"}, + wantParts: []string{"Ancestor cleared", "Task ID: task-123"}, + }, + } + + 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: "POST", + URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + + err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout) + if err != nil { + t.Fatalf("runMountedTaskShortcut() error = %v", err) + } + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + for _, want := range tt.wantParts { + if !strings.Contains(out, want) && !strings.Contains(outNorm, want) { + t.Fatalf("output missing %q: %s", want, out) + } + } + }) + } +} diff --git a/shortcuts/task/task_subscribe_event.go b/shortcuts/task/task_subscribe_event.go new file mode 100644 index 00000000..cd852c62 --- /dev/null +++ b/shortcuts/task/task_subscribe_event.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "fmt" + "io" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +var SubscribeTaskEvent = common.Shortcut{ + Service: "task", + Command: "+subscribe_event", + Description: "subscribe to task events", + Risk: "write", + Scopes: []string{"task:task:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/task/v2/task_v2/task_subscription"). + Params(map[string]interface{}{"user_id_type": "open_id"}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + _, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/task/v2/task_v2/task_subscription", + QueryParams: queryParams, + }) + if err != nil { + return err + } + + outData := map[string]interface{}{"ok": true} + runtime.OutFormat(outData, nil, func(w io.Writer) { + fmt.Fprintln(w, "✅ Task event subscription created successfully!") + }) + return nil + }, +} diff --git a/shortcuts/task/task_subscribe_event_test.go b/shortcuts/task/task_subscribe_event_test.go new file mode 100644 index 00000000..c61bf76c --- /dev/null +++ b/shortcuts/task/task_subscribe_event_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestSubscribeTaskEvent(t *testing.T) { + tests := []struct { + name string + mode string + wantParts []string + }{ + { + name: "execute json", + mode: "execute", + wantParts: []string{`"ok": true`}, + }, + { + name: "dry run", + mode: "dryrun", + wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + switch tt.mode { + case "execute": + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/task_v2/task_subscription", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + + s := SubscribeTaskEvent + s.AuthTypes = []string{"bot", "user"} + err := runMountedTaskShortcut(t, s, []string{"+subscribe_event", "--as", "bot", "--format", "json"}, f, stdout) + if err != nil { + t.Fatalf("runMountedTaskShortcut() error = %v", err) + } + + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + for _, want := range tt.wantParts { + if !strings.Contains(out, want) && !strings.Contains(outNorm, want) { + t.Fatalf("output missing %q: %s", want, out) + } + } + case "dryrun": + runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user") + out := SubscribeTaskEvent.DryRun(nil, runtime).Format() + for _, want := range tt.wantParts { + if !strings.Contains(out, want) { + t.Fatalf("dry run output missing %q: %s", want, out) + } + } + } + }) + } +} diff --git a/shortcuts/task/task_tasklist_search.go b/shortcuts/task/task_tasklist_search.go new file mode 100644 index 00000000..ad0c8a0f --- /dev/null +++ b/shortcuts/task/task_tasklist_search.go @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + tasklistSearchDefaultPageLimit = 20 + tasklistSearchMaxPageLimit = 40 +) + +var SearchTasklist = common.Shortcut{ + Service: "task", + Command: "+tasklist-search", + Description: "search tasklists", + Risk: "read", + Scopes: []string{"task:tasklist:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"}, + {Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"}, + {Name: "page-token", Desc: "page token"}, + {Name: "creator", Desc: "creator open_ids, comma-separated"}, + {Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, err := buildTasklistSearchBody(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/task/v2/tasklists/search"). + Body(body). + Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output") + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := buildTasklistSearchBody(runtime) + return err + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildTasklistSearchBody(runtime) + if err != nil { + return err + } + + pageLimit := runtime.Int("page-limit") + if pageLimit <= 0 { + pageLimit = tasklistSearchDefaultPageLimit + } + if runtime.Bool("page-all") { + pageLimit = tasklistSearchMaxPageLimit + } + if pageLimit > tasklistSearchMaxPageLimit { + pageLimit = tasklistSearchMaxPageLimit + } + + var rawItems []interface{} + var lastPageToken string + var lastHasMore bool + currentBody := body + for page := 0; page < pageLimit; page++ { + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/task/v2/tasklists/search", + Body: currentBody, + }) + 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 tasklist search") + } + } + data, err := HandleTaskApiResult(result, err, "search tasklists") + if err != nil { + return err + } + items, _ := data["items"].([]interface{}) + rawItems = append(rawItems, items...) + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + if !lastHasMore || lastPageToken == "" { + break + } + currentBody["page_token"] = lastPageToken + } + + tasklists := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + itemMap, _ := item.(map[string]interface{}) + tasklistID, _ := itemMap["id"].(string) + if tasklistID == "" { + continue + } + + tasklist, err := getTasklistDetail(runtime, tasklistID) + if err != nil { + tasklists = append(tasklists, map[string]interface{}{"guid": tasklistID}) + continue + } + urlVal, _ := tasklist["url"].(string) + urlVal = truncateTaskURL(urlVal) + tasklists = append(tasklists, map[string]interface{}{ + "guid": tasklist["guid"], + "name": tasklist["name"], + "url": urlVal, + "creator": tasklist["creator"], + }) + } + + outData := map[string]interface{}{ + "items": tasklists, + "page_token": lastPageToken, + "has_more": lastHasMore, + } + runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) { + if len(tasklists) == 0 { + fmt.Fprintln(w, "No tasklists found.") + return + } + for i, tasklist := range tasklists { + fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"]) + fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"]) + if urlVal, _ := tasklist["url"].(string); urlVal != "" { + fmt.Fprintf(w, " URL: %s\n", urlVal) + } + fmt.Fprintln(w) + } + if lastHasMore && lastPageToken != "" { + fmt.Fprintf(w, "Next page token: %s\n", lastPageToken) + } + }) + return nil + }, +} + +func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + filter := map[string]interface{}{} + if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 { + filter["user_id"] = ids + } + if createTime := runtime.Str("create-time"); createTime != "" { + start, end, err := parseTimeRangeMillis(createTime) + if err != nil { + return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search") + } + if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil { + mergeIntoFilter(filter, timeFilter) + } + } + if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil { + return nil, err + } + + body := map[string]interface{}{ + "query": runtime.Str("query"), + } + if len(filter) > 0 { + body["filter"] = filter + } + if pageToken := runtime.Str("page-token"); pageToken != "" { + body["page_token"] = pageToken + } + return body, nil +} + +func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) { + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID), + QueryParams: queryParams, + }) + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail") + } + } + data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID) + if err != nil { + return nil, err + } + tasklist, _ := data["tasklist"].(map[string]interface{}) + if tasklist == nil { + return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail") + } + return tasklist, nil +} diff --git a/shortcuts/task/task_tasklist_search_test.go b/shortcuts/task/task_tasklist_search_test.go new file mode 100644 index 00000000..b3651019 --- /dev/null +++ b/shortcuts/task/task_tasklist_search_test.go @@ -0,0 +1,236 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestBuildTasklistSearchBody(t *testing.T) { + tests := []struct { + name string + setup func(*cobra.Command) + wantErr bool + check func(*testing.T, map[string]interface{}) + }{ + { + name: "creator create-time and page token", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("creator", "ou_creator") + _ = cmd.Flags().Set("create-time", "-7d,+0d") + _ = cmd.Flags().Set("page-token", "pt_tl") + }, + check: func(t *testing.T, body map[string]interface{}) { + filter := body["filter"].(map[string]interface{}) + createTime := filter["create_time"].(map[string]interface{}) + if body["page_token"] != "pt_tl" { + t.Fatalf("unexpected body: %#v", body) + } + if filter["user_id"].([]string)[0] != "ou_creator" { + t.Fatalf("unexpected filter: %#v", filter) + } + if createTime["start_time"] == "" || createTime["end_time"] == "" { + t.Fatalf("unexpected create_time: %#v", createTime) + } + }, + }, + { + name: "requires query or filter", + setup: func(cmd *cobra.Command) {}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("query", "", "") + cmd.Flags().String("creator", "", "") + cmd.Flags().String("create-time", "", "") + cmd.Flags().String("page-token", "", "") + tt.setup(cmd) + + runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user") + body, err := buildTasklistSearchBody(runtime) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("buildTasklistSearchBody() error = %v", err) + } + tt.check(t, body) + }) + } +} + +func TestSearchTasklist_DryRun(t *testing.T) { + tests := []struct { + name string + setup func(*cobra.Command) + wantParts []string + }{ + { + name: "valid dry run", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("query", "Q2") + _ = cmd.Flags().Set("page-token", "pt_tl") + }, + wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`}, + }, + { + name: "dry run error on invalid create time", + setup: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("create-time", "bad-time") + }, + wantParts: []string{"error:"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("query", "", "") + cmd.Flags().String("creator", "", "") + cmd.Flags().String("create-time", "", "") + cmd.Flags().String("page-token", "", "") + tt.setup(cmd) + + runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user") + if !strings.Contains(tt.name, "error") { + if err := SearchTasklist.Validate(nil, runtime); err != nil { + t.Fatalf("Validate() error = %v", err) + } + } + out := SearchTasklist.DryRun(nil, runtime).Format() + for _, want := range tt.wantParts { + if !strings.Contains(out, want) { + t.Fatalf("dry run output missing %q: %s", want, out) + } + } + }) + } +} + +func TestSearchTasklist_Execute(t *testing.T) { + tests := []struct { + name string + args []string + register func(*httpmock.Registry) + wantParts []string + }{ + { + name: "json success", + args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasklists/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{map[string]interface{}{"id": "tl-123"}}, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasklists/tl-123", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"}, + }, + }, + }) + }, + wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`}, + }, + { + name: "fallback on detail error", + args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasklists/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{map[string]interface{}{"id": "tl-fallback"}}, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasklists/tl-fallback", + Body: map[string]interface{}{"code": 99991663, "msg": "not found"}, + }) + }, + wantParts: []string{`"guid": "tl-fallback"`}, + }, + { + name: "empty pretty with pagination", + args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasklists/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasklists/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}}, + }, + }) + }, + wantParts: []string{"No tasklists found."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + tt.register(reg) + + s := SearchTasklist + s.AuthTypes = []string{"bot", "user"} + err := runMountedTaskShortcut(t, s, tt.args, f, stdout) + if err != nil { + t.Fatalf("runMountedTaskShortcut() error = %v", err) + } + + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + for _, want := range tt.wantParts { + if !strings.Contains(out, want) && !strings.Contains(outNorm, want) { + t.Fatalf("output missing %q: %s", want, out) + } + } + }) + } +} diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index 23646a62..b05126e8 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -12,7 +12,7 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** -> **搜索技巧**:如果用户的查询只指定了任务名称(例如“完成任务龙虾一号”),请直接使用 `+get-my-tasks --query "龙虾一号"` 命令搜索(不要带 `--complete` 参数,这样可以同时搜索未完成和已完成的任务)。 +> **搜索技巧**:如果用户提供了模糊任务查询词(例如任务名称、关键词、片段描述),统一优先使用 `+search --query ...`。只有当用户没有提供 `query`,而是明确表达“我负责的任务”或“与我相关的任务”时,才分别考虑使用 `+get-my-tasks` 或 `+get-related-tasks`。 > **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。 > **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 > **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。 @@ -25,7 +25,8 @@ metadata: > **查询注意**: > 1. 在输出任务详情时,如果需要渲染负责人、创建人等人员字段,除了展示 `id` (例如 open_id) 外,还必须通过其他方式(例如调用通讯录技能)尝试获取并展示这个人的真实名字,以便用户更容易识别。 -> 2. 在输出任务详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。 +> 2. 在输出清单详情时,如果需要渲染 owner、member、角色成员等人员字段,也必须像任务成员展示一样,除了展示 `id` 外,尽量解析并展示对应人员的真实名字。 +> 3. 在输出任务或清单详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。 ## Shortcuts @@ -39,7 +40,12 @@ metadata: - [`+followers`](./references/lark-task-followers.md) — Manage task followers - [`+reminder`](./references/lark-task-reminder.md) — Manage task reminders - [`+get-my-tasks`](./references/lark-task-get-my-tasks.md) — List tasks assigned to me +- [`+get-related-tasks`](./references/lark-task-get-related-tasks.md) — List tasks related to me +- [`+search`](./references/lark-task-search.md) — Search tasks +- [`+subscribe_event`](./references/lark-task-subscribe-event.md) — Subscribe to task events +- [`+set-ancestor`](./references/lark-task-set-ancestor.md) — Set or clear a task ancestor - [`+tasklist-create`](./references/lark-task-tasklist-create.md) — Create a tasklist and batch add tasks +- [`+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 @@ -102,4 +108,3 @@ lark-cli task [flags] # 调用 API | `subtasks.list` | `task:task:read` | | `members.add` | `task:task:write` | | `members.remove` | `task:task:write` | - diff --git a/skills/lark-task/references/lark-task-get-related-tasks.md b/skills/lark-task/references/lark-task-get-related-tasks.md new file mode 100644 index 00000000..b55b2e6b --- /dev/null +++ b/skills/lark-task/references/lark-task-get-related-tasks.md @@ -0,0 +1,51 @@ +# task +get-related-tasks + +> **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.** +> +> **Pagination / Time Cursor Rule:** +> In `+get-related-tasks`, `page_token` is the task `updated_at` cursor in microseconds. +> +> **Execution Priority:** +> 1. If the request contains a start/end time boundary (for example, "今年以来", "最近一个月", "从 3 月 1 日开始"), first convert the **start time** boundary to a microsecond `page_token` and query from that token. +> 2. Continue pagination using returned `page_token` until `has_more=false`, but never exceed 40 total page fetches. +> 3. Do NOT default to `--page-all` for time-bounded queries. +> +> Only use `--page-all` from the beginning when: +> 1. the user explicitly asks for a full scan of all related tasks, or +> 2. no time boundary can be inferred from the request. + +List tasks related to the current user. + +## Recommended Commands + +```bash +# List all related tasks +lark-cli task +get-related-tasks + +# List incomplete related tasks starting from a page token +lark-cli task +get-related-tasks --include-complete=false --page-token "1752730590582902" + +# Show only tasks created by me +lark-cli task +get-related-tasks --created-by-me +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--include-complete=` | No | Default behavior includes completed tasks. Set to `false` to keep only incomplete tasks. | +| `--page-all` | No | Automatically paginate through all pages (max 40). | +| `--page-limit ` | No | Max page limit (default 20). | +| `--page-token ` | No | Start from the specified page token. This token is the task's last update time cursor in microseconds. | +| `--created-by-me` | No | Keep only tasks whose creator is the current user. | +| `--followed-by-me` | No | Keep only tasks followed by the current user. | + +> **Page Token Note:** In `+get-related-tasks`, the `page_token` is a microsecond-level cursor representing the task's last update time. For example, `1752730590582902` should be treated as an updated-at cursor, not a task ID. + +## Workflow + +1. Determine whether the user needs all related tasks or a filtered subset. +2. Execute `lark-cli task +get-related-tasks ...` +3. Report the matching tasks and, if present, the next `page_token`. diff --git a/skills/lark-task/references/lark-task-search.md b/skills/lark-task/references/lark-task-search.md new file mode 100644 index 00000000..991b973d --- /dev/null +++ b/skills/lark-task/references/lark-task-search.md @@ -0,0 +1,41 @@ +# task +search + +> **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.** + +Search tasks by keyword and optional filters. + +## Recommended Commands + +```bash +# Search by keyword +lark-cli task +search --query "test" + +# Search incomplete tasks assigned to specific users +lark-cli task +search --assignee "ou_xxx,ou_yyy" --completed=false + +# Search by due time range +lark-cli task +search --query "release" --due "-1d,+7d" +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--query ` | No | Search keyword. If omitted, at least one filter must be provided. | +| `--creator ` | No | Creator open_ids, comma-separated. | +| `--assignee ` | No | Assignee open_ids, comma-separated. | +| `--follower ` | No | Follower open_ids, comma-separated. | +| `--completed=` | No | Filter by completion state. | +| `--due ` | No | Due time range in `start,end` form. Each side supports ISO/date/relative/ms input. | +| `--page-token ` | No | Page token for pagination. | +| `--page-all` | No | Automatically paginate through all pages (max 40). | +| `--page-limit ` | No | Max page limit (default 20). | + +## Workflow + +1. Build the keyword and filters from the user's request. +2. Execute `lark-cli task +search ...` +3. Report the matched tasks and include the next `page_token` if more results exist. + diff --git a/skills/lark-task/references/lark-task-set-ancestor.md b/skills/lark-task/references/lark-task-set-ancestor.md new file mode 100644 index 00000000..ae481fd9 --- /dev/null +++ b/skills/lark-task/references/lark-task-set-ancestor.md @@ -0,0 +1,32 @@ +# task +set-ancestor + +> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules. + +Set a parent task for a task, or clear the parent to make it independent. + +## Recommended Commands + +```bash +# Set a parent task +lark-cli task +set-ancestor --task-id "guid_1" --ancestor-id "guid_2" + +# Clear the parent task +lark-cli task +set-ancestor --task-id "guid_1" +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--task-id ` | Yes | The task GUID to update. | +| `--ancestor-id ` | No | The parent task GUID. Omit it to clear the ancestor. | + +## Workflow + +1. Confirm the child task and, if applicable, the ancestor task. +2. Execute `lark-cli task +set-ancestor ...` +3. Report the updated task GUID and whether the ancestor was set or cleared. + +> [!CAUTION] +> This is a **Write Operation** -- You must confirm the user's intent before executing. + diff --git a/skills/lark-task/references/lark-task-subscribe-event.md b/skills/lark-task/references/lark-task-subscribe-event.md new file mode 100644 index 00000000..4e513841 --- /dev/null +++ b/skills/lark-task/references/lark-task-subscribe-event.md @@ -0,0 +1,49 @@ +# task +subscribe_event + +> **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.** + +Subscribe the current user to task update events for tasks they can access. + +This shortcut is different from `event +subscribe`: +- `task +subscribe_event` uses a **user identity** +- it subscribes the **current user** to task events for tasks they created, are responsible for, or follow +- it is scoped to the user's task access, not a bot-level global event stream + +The task event type is: + +```text +task.task.update_user_access_v2 +``` + +In practice, this means the subscribed user can receive updates for tasks that are visible to them through authorship, assignment, or following. + +To actually receive the subscribed events, use the standard event WebSocket receiver: + +```bash +lark-cli event +subscribe --event-types task.task.update_user_access_v2 --compact --quiet +``` + +The full flow is: +1. Register the user-facing subscription with `lark-cli task +subscribe_event` +2. Receive those events with `lark-cli event +subscribe --event-types task.task.update_user_access_v2 ...` + +## Recommended Commands + +```bash +lark-cli task +subscribe_event +``` + +## Parameters + +This shortcut has no additional parameters. + +## Workflow + +1. Confirm the user wants to subscribe their own account to task update events. +2. Execute `lark-cli task +subscribe_event` +3. Report whether the subscription succeeded, and clarify that this applies to the user's own accessible tasks. + +> [!CAUTION] +> This is a **Write Operation** -- You must confirm the user's intent before executing. diff --git a/skills/lark-task/references/lark-task-tasklist-search.md b/skills/lark-task/references/lark-task-tasklist-search.md new file mode 100644 index 00000000..ea4fc24b --- /dev/null +++ b/skills/lark-task/references/lark-task-tasklist-search.md @@ -0,0 +1,38 @@ +# task +tasklist-search + +> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules. +> +> **⚠️ Note:** This shortcut uses tasklist search followed by tasklist detail queries to render the final output. + +Search tasklists by keyword and optional filters. + +## Recommended Commands + +```bash +# Search by keyword +lark-cli task +tasklist-search --query "测试" + +# Search tasklists created by specific users +lark-cli task +tasklist-search --creator "ou_xxx,ou_yyy" + +# Search by creation time range +lark-cli task +tasklist-search --query "Q2" --create-time "-30d,+0d" +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--query ` | No | Search keyword. If omitted, at least one filter must be provided. | +| `--creator ` | No | Creator open_ids, comma-separated. | +| `--create-time ` | No | Creation time range in `start,end` form. Each side supports ISO/date/relative/ms input. | +| `--page-token ` | No | Page token for pagination. | +| `--page-all` | No | Automatically paginate through all pages (max 40). | +| `--page-limit ` | No | Max page limit (default 20). | + +## Workflow + +1. Build the search keyword and filters from the user's request. +2. Execute `lark-cli task +tasklist-search ...` +3. Report the matched tasklists and the next `page_token` if more results exist. + From a60d16da8e6775edd9874f346a5251e32b555999 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 09:23:00 +0800 Subject: [PATCH 02/21] fix(task): rename subscribe-event shortcut --- shortcuts/task/task_subscribe_event.go | 2 +- shortcuts/task/task_subscribe_event_test.go | 2 +- skills/lark-task/SKILL.md | 2 +- .../lark-task/references/lark-task-subscribe-event.md | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/shortcuts/task/task_subscribe_event.go b/shortcuts/task/task_subscribe_event.go index cd852c62..a81092c8 100644 --- a/shortcuts/task/task_subscribe_event.go +++ b/shortcuts/task/task_subscribe_event.go @@ -16,7 +16,7 @@ import ( var SubscribeTaskEvent = common.Shortcut{ Service: "task", - Command: "+subscribe_event", + Command: "+subscribe-event", Description: "subscribe to task events", Risk: "write", Scopes: []string{"task:task:read"}, diff --git a/shortcuts/task/task_subscribe_event_test.go b/shortcuts/task/task_subscribe_event_test.go index c61bf76c..b9575fad 100644 --- a/shortcuts/task/task_subscribe_event_test.go +++ b/shortcuts/task/task_subscribe_event_test.go @@ -49,7 +49,7 @@ func TestSubscribeTaskEvent(t *testing.T) { s := SubscribeTaskEvent s.AuthTypes = []string{"bot", "user"} - err := runMountedTaskShortcut(t, s, []string{"+subscribe_event", "--as", "bot", "--format", "json"}, f, stdout) + err := runMountedTaskShortcut(t, s, []string{"+subscribe-event", "--as", "bot", "--format", "json"}, f, stdout) if err != nil { t.Fatalf("runMountedTaskShortcut() error = %v", err) } diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index b05126e8..8df998e3 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -42,7 +42,7 @@ metadata: - [`+get-my-tasks`](./references/lark-task-get-my-tasks.md) — List tasks assigned to me - [`+get-related-tasks`](./references/lark-task-get-related-tasks.md) — List tasks related to me - [`+search`](./references/lark-task-search.md) — Search tasks -- [`+subscribe_event`](./references/lark-task-subscribe-event.md) — Subscribe to task events +- [`+subscribe-event`](./references/lark-task-subscribe-event.md) — Subscribe to task events - [`+set-ancestor`](./references/lark-task-set-ancestor.md) — Set or clear a task ancestor - [`+tasklist-create`](./references/lark-task-tasklist-create.md) — Create a tasklist and batch add tasks - [`+tasklist-search`](./references/lark-task-tasklist-search.md) — Search tasklists diff --git a/skills/lark-task/references/lark-task-subscribe-event.md b/skills/lark-task/references/lark-task-subscribe-event.md index 4e513841..05645a8f 100644 --- a/skills/lark-task/references/lark-task-subscribe-event.md +++ b/skills/lark-task/references/lark-task-subscribe-event.md @@ -1,4 +1,4 @@ -# task +subscribe_event +# task +subscribe-event > **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules. > @@ -7,7 +7,7 @@ Subscribe the current user to task update events for tasks they can access. This shortcut is different from `event +subscribe`: -- `task +subscribe_event` uses a **user identity** +- `task +subscribe-event` uses a **user identity** - it subscribes the **current user** to task events for tasks they created, are responsible for, or follow - it is scoped to the user's task access, not a bot-level global event stream @@ -26,13 +26,13 @@ lark-cli event +subscribe --event-types task.task.update_user_access_v2 --compac ``` The full flow is: -1. Register the user-facing subscription with `lark-cli task +subscribe_event` +1. Register the user-facing subscription with `lark-cli task +subscribe-event` 2. Receive those events with `lark-cli event +subscribe --event-types task.task.update_user_access_v2 ...` ## Recommended Commands ```bash -lark-cli task +subscribe_event +lark-cli task +subscribe-event ``` ## Parameters @@ -42,7 +42,7 @@ This shortcut has no additional parameters. ## Workflow 1. Confirm the user wants to subscribe their own account to task update events. -2. Execute `lark-cli task +subscribe_event` +2. Execute `lark-cli task +subscribe-event` 3. Report whether the subscription succeeded, and clarify that this applies to the user's own accessible tasks. > [!CAUTION] From 32c80430134917bb73323e4b3eb6984456219610 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 11:59:41 +0800 Subject: [PATCH 03/21] docs(task): document task event payload shape --- .../references/lark-task-subscribe-event.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/skills/lark-task/references/lark-task-subscribe-event.md b/skills/lark-task/references/lark-task-subscribe-event.md index 05645a8f..f78722d8 100644 --- a/skills/lark-task/references/lark-task-subscribe-event.md +++ b/skills/lark-task/references/lark-task-subscribe-event.md @@ -17,6 +17,38 @@ The task event type is: task.task.update_user_access_v2 ``` +Within this event, task changes are represented by commit types (string values). Deduped list: + +```text +task_assignees_update +task_completed_update +task_create +task_deleted +task_desc_update +task_followers_update +task_reminders_update +task_start_due_update +task_summary_update +``` + +Event payload shape (example): + +```json +{ + "event_id": "evt_xxx", + "event_types": ["task_summary_update"], + "task_guid": "task_guid_xxx", + "timestamp": "1775793266152", + "type": "task.task.update_user_access_v2" +} +``` + +- `type`: event type, should be `task.task.update_user_access_v2` +- `event_id`: unique event id (useful for dedup) +- `event_types`: list of commit types (see the deduped list above) +- `task_guid`: the task GUID that changed +- `timestamp`: event timestamp (ms) + In practice, this means the subscribed user can receive updates for tasks that are visible to them through authorship, assignment, or following. To actually receive the subscribed events, use the standard event WebSocket receiver: From 9bc54e92170176c31d1035aaccf48a124b9b45ab Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 14:29:42 +0800 Subject: [PATCH 04/21] fix(task): validate subscribe-event api response --- shortcuts/task/task_subscribe_event.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/shortcuts/task/task_subscribe_event.go b/shortcuts/task/task_subscribe_event.go index a81092c8..1f13f881 100644 --- a/shortcuts/task/task_subscribe_event.go +++ b/shortcuts/task/task_subscribe_event.go @@ -5,6 +5,7 @@ package task import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -30,12 +31,21 @@ var SubscribeTaskEvent = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { queryParams := make(larkcore.QueryParams) queryParams.Set("user_id_type", "open_id") - _, err := runtime.DoAPI(&larkcore.ApiReq{ + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, ApiPath: "/open-apis/task/v2/task_v2/task_subscription", QueryParams: queryParams, }) - if err != nil { + + // DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code". + // Parse and validate the envelope to avoid false-success output. + 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), "subscribe task events") + } + } + if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil { return err } From a09c568838133561dd6907e4b4b0acaa785ba9b9 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 14:59:43 +0800 Subject: [PATCH 05/21] refactor(task): remove unused buildUserIDs helper --- shortcuts/task/task_query_helpers.go | 11 -------- shortcuts/task/task_query_helpers_test.go | 32 ----------------------- 2 files changed, 43 deletions(-) diff --git a/shortcuts/task/task_query_helpers.go b/shortcuts/task/task_query_helpers.go index 3b2386c9..a57dd76e 100644 --- a/shortcuts/task/task_query_helpers.go +++ b/shortcuts/task/task_query_helpers.go @@ -22,17 +22,6 @@ func splitAndTrimCSV(input string) []string { return out } -func buildUserIDs(ids []string) []map[string]interface{} { - out := make([]map[string]interface{}, 0, len(ids)) - for _, id := range ids { - out = append(out, map[string]interface{}{ - "id": id, - "type": "user", - }) - } - return out -} - func parseTimeRangeMillis(input string) (string, string, error) { if strings.TrimSpace(input) == "" { return "", "", nil diff --git a/shortcuts/task/task_query_helpers_test.go b/shortcuts/task/task_query_helpers_test.go index ab831d2d..da21ea68 100644 --- a/shortcuts/task/task_query_helpers_test.go +++ b/shortcuts/task/task_query_helpers_test.go @@ -33,38 +33,6 @@ func TestSplitAndTrimCSV(t *testing.T) { } } -func TestBuildUserIDs(t *testing.T) { - tests := []struct { - name string - ids []string - want []map[string]interface{} - }{ - { - name: "multiple ids", - ids: []string{"ou_1", "ou_2"}, - want: []map[string]interface{}{ - {"id": "ou_1", "type": "user"}, - {"id": "ou_2", "type": "user"}, - }, - }, - {name: "empty ids", ids: []string{}, want: []map[string]interface{}{}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildUserIDs(tt.ids) - if len(got) != len(tt.want) { - t.Fatalf("len(buildUserIDs()) = %d, want %d", len(got), len(tt.want)) - } - for i := range got { - if got[i]["id"] != tt.want[i]["id"] || got[i]["type"] != tt.want[i]["type"] { - t.Fatalf("buildUserIDs()[%d] = %#v, want %#v", i, got[i], tt.want[i]) - } - } - }) - } -} - func TestOutputTaskSummary(t *testing.T) { tests := []struct { name string From 83ff0e6082a84467151e52b1c0c012ee1c4557bf Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 15:04:37 +0800 Subject: [PATCH 06/21] fix(task): handle api error codes in set-ancestor --- shortcuts/task/task_set_ancestor.go | 11 +++- shortcuts/task/task_set_ancestor_test.go | 66 +++++++++++++++++++----- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/shortcuts/task/task_set_ancestor.go b/shortcuts/task/task_set_ancestor.go index c7fd7e87..ecc27813 100644 --- a/shortcuts/task/task_set_ancestor.go +++ b/shortcuts/task/task_set_ancestor.go @@ -5,6 +5,7 @@ package task import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -39,13 +40,19 @@ var SetAncestorTask = common.Shortcut{ queryParams := make(larkcore.QueryParams) queryParams.Set("user_id_type", "open_id") - _, err := runtime.DoAPI(&larkcore.ApiReq{ + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task", QueryParams: queryParams, Body: buildSetAncestorBody(runtime.Str("ancestor-id")), }) - if err != nil { + 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), "set ancestor task") + } + } + if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil { return err } diff --git a/shortcuts/task/task_set_ancestor_test.go b/shortcuts/task/task_set_ancestor_test.go index cd28efb3..1115f200 100644 --- a/shortcuts/task/task_set_ancestor_test.go +++ b/shortcuts/task/task_set_ancestor_test.go @@ -82,35 +82,75 @@ func TestSetAncestorTask_Execute(t *testing.T) { tests := []struct { name string args []string + register func(*httpmock.Registry) + wantErr bool wantParts []string }{ { - name: "json output with ancestor", - args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"}, + name: "json output with ancestor", + args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + }, wantParts: []string{`"guid": "task-123"`}, }, { - name: "pretty output clears ancestor", - args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"}, + name: "pretty output clears ancestor", + args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + }, wantParts: []string{"Ancestor cleared", "Task ID: task-123"}, }, + { + name: "api-level error (code!=0) returns error", + args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task", + Body: map[string]interface{}{ + "code": 10003, + "msg": "permission denied", + }, + }) + }, + wantErr: true, + }, } 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: "POST", - URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{}, - }, - }) + tt.register(reg) err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if out := stdout.String(); out != "" { + t.Fatalf("expected empty stdout on error, got: %s", out) + } + return + } if err != nil { t.Fatalf("runMountedTaskShortcut() error = %v", err) } From 8741e7c6ffcf2ed40a16b6d48a8c3dc8ce85b73a Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 15:09:58 +0800 Subject: [PATCH 07/21] test(task): avoid widening auth types in subscribe-event --- shortcuts/task/task_subscribe_event_test.go | 55 +++++++++++++++------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/shortcuts/task/task_subscribe_event_test.go b/shortcuts/task/task_subscribe_event_test.go index b9575fad..fd31c5f1 100644 --- a/shortcuts/task/task_subscribe_event_test.go +++ b/shortcuts/task/task_subscribe_event_test.go @@ -17,13 +17,36 @@ func TestSubscribeTaskEvent(t *testing.T) { tests := []struct { name string mode string + args []string + register func(*httpmock.Registry) + wantErr bool wantParts []string }{ { - name: "execute json", - mode: "execute", + name: "execute json (user identity)", + mode: "execute", + args: []string{"+subscribe-event", "--as", "user", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/task_v2/task_subscription", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + }, wantParts: []string{`"ok": true`}, }, + { + name: "execute refuses bot identity", + mode: "execute", + args: []string{"+subscribe-event", "--as", "bot", "--format", "json"}, + wantErr: true, + // This error is raised before network calls; it is ok to only assert a stable substring. + wantParts: []string{"--as bot is not supported"}, + }, { name: "dry run", mode: "dryrun", @@ -37,19 +60,23 @@ func TestSubscribeTaskEvent(t *testing.T) { case "execute": f, stdout, _, reg := taskShortcutTestFactory(t) warmTenantToken(t, f, reg) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/task/v2/task_v2/task_subscription", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{}, - }, - }) + if tt.register != nil { + tt.register(reg) + } - s := SubscribeTaskEvent - s.AuthTypes = []string{"bot", "user"} - err := runMountedTaskShortcut(t, s, []string{"+subscribe-event", "--as", "bot", "--format", "json"}, f, stdout) + err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + out := err.Error() + for _, want := range tt.wantParts { + if !strings.Contains(out, want) { + t.Fatalf("error missing %q: %s", want, out) + } + } + return + } if err != nil { t.Fatalf("runMountedTaskShortcut() error = %v", err) } From b0d4b97848bcf76b885a4ac3f1ecec5c8bde82ad Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 15:11:56 +0800 Subject: [PATCH 08/21] docs(task): clarify get-related-tasks page-token unit --- shortcuts/task/task_get_related_tasks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/task/task_get_related_tasks.go b/shortcuts/task/task_get_related_tasks.go index 88c66850..3b32ef41 100644 --- a/shortcuts/task/task_get_related_tasks.go +++ b/shortcuts/task/task_get_related_tasks.go @@ -35,7 +35,7 @@ var GetRelatedTasks = common.Shortcut{ {Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"}, - {Name: "page-token", Desc: "page token / updated_at cursor in milliseconds"}, + {Name: "page-token", Desc: "page token / updated_at cursor in microseconds"}, {Name: "created-by-me", Type: "bool", Desc: "filter to tasks created by me"}, {Name: "followed-by-me", Type: "bool", Desc: "filter to tasks followed by me"}, }, From c6d3bdf0bd9ba1cec50483b2739cc53a7bb8c000 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 15:16:41 +0800 Subject: [PATCH 09/21] fix(task): avoid nil names in tasklist-search pretty output --- shortcuts/task/task_tasklist_search.go | 6 ++++- shortcuts/task/task_tasklist_search_test.go | 25 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/shortcuts/task/task_tasklist_search.go b/shortcuts/task/task_tasklist_search.go index ad0c8a0f..28f76ac8 100644 --- a/shortcuts/task/task_tasklist_search.go +++ b/shortcuts/task/task_tasklist_search.go @@ -109,7 +109,11 @@ var SearchTasklist = common.Shortcut{ tasklist, err := getTasklistDetail(runtime, tasklistID) if err != nil { - tasklists = append(tasklists, map[string]interface{}{"guid": tasklistID}) + // Keep a stable identifier and avoid rendering "" in pretty output. + tasklists = append(tasklists, map[string]interface{}{ + "guid": tasklistID, + "name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID), + }) continue } urlVal, _ := tasklist["url"].(string) diff --git a/shortcuts/task/task_tasklist_search_test.go b/shortcuts/task/task_tasklist_search_test.go index b3651019..78d364a4 100644 --- a/shortcuts/task/task_tasklist_search_test.go +++ b/shortcuts/task/task_tasklist_search_test.go @@ -184,6 +184,31 @@ func TestSearchTasklist_Execute(t *testing.T) { }, wantParts: []string{`"guid": "tl-fallback"`}, }, + { + name: "pretty fallback avoids nil name", + args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/tasklists/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{map[string]interface{}{"id": "tl-fallback"}}, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasklists/tl-fallback", + Body: map[string]interface{}{"code": 99991663, "msg": "not found"}, + }) + }, + wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"}, + }, { name: "empty pretty with pagination", args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"}, From ff5b9278373342355f72e6e060a1982999d1acd8 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 15:19:56 +0800 Subject: [PATCH 10/21] fix(task): reject reversed time ranges --- shortcuts/task/task_query_helpers.go | 15 +++++++++++++++ shortcuts/task/task_query_helpers_test.go | 1 + 2 files changed, 16 insertions(+) diff --git a/shortcuts/task/task_query_helpers.go b/shortcuts/task/task_query_helpers.go index a57dd76e..559c7697 100644 --- a/shortcuts/task/task_query_helpers.go +++ b/shortcuts/task/task_query_helpers.go @@ -35,11 +35,18 @@ func parseTimeRangeMillis(input string) (string, string, error) { } var startMillis, endMillis string + var startSecInt, endSecInt int64 + var hasStart, hasEnd bool if startInput != "" { startSec, err := parseTimeFlagSec(startInput, "start") if err != nil { return "", "", err } + startSecInt, err = strconv.ParseInt(startSec, 10, 64) + if err != nil { + return "", "", fmt.Errorf("invalid start timestamp: %w", err) + } + hasStart = true startMillis = startSec + "000" } if endInput != "" { @@ -47,8 +54,16 @@ func parseTimeRangeMillis(input string) (string, string, error) { if err != nil { return "", "", err } + endSecInt, err = strconv.ParseInt(endSec, 10, 64) + if err != nil { + return "", "", fmt.Errorf("invalid end timestamp: %w", err) + } + hasEnd = true endMillis = endSec + "000" } + if hasStart && hasEnd && startSecInt > endSecInt { + return "", "", fmt.Errorf("start time must be earlier than or equal to end time") + } return startMillis, endMillis, nil } diff --git a/shortcuts/task/task_query_helpers_test.go b/shortcuts/task/task_query_helpers_test.go index da21ea68..eef30316 100644 --- a/shortcuts/task/task_query_helpers_test.go +++ b/shortcuts/task/task_query_helpers_test.go @@ -86,6 +86,7 @@ func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) { {name: "empty input", input: "", wantStart: "", wantEnd: ""}, {name: "invalid input", input: "bad-time", wantErr: true}, {name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"}, + {name: "reversed range fails fast", input: "+1d,-1d", wantErr: true}, } for _, tt := range timeTests { t.Run("parse:"+tt.name, func(t *testing.T) { From f766d320e2fd578e418326f2460266fa97033c88 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 16:38:10 +0800 Subject: [PATCH 11/21] feat(task): support bot identity for subscribe-event --- shortcuts/task/task_subscribe_event.go | 2 +- shortcuts/task/task_subscribe_event_test.go | 41 ++++++++++++++++--- .../references/lark-task-subscribe-event.md | 23 +++++++---- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/shortcuts/task/task_subscribe_event.go b/shortcuts/task/task_subscribe_event.go index 1f13f881..f8afe6c2 100644 --- a/shortcuts/task/task_subscribe_event.go +++ b/shortcuts/task/task_subscribe_event.go @@ -21,7 +21,7 @@ var SubscribeTaskEvent = common.Shortcut{ Description: "subscribe to task events", Risk: "write", Scopes: []string{"task:task:read"}, - AuthTypes: []string{"user"}, + AuthTypes: []string{"user", "bot"}, HasFormat: true, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). diff --git a/shortcuts/task/task_subscribe_event_test.go b/shortcuts/task/task_subscribe_event_test.go index fd31c5f1..2cd4323e 100644 --- a/shortcuts/task/task_subscribe_event_test.go +++ b/shortcuts/task/task_subscribe_event_test.go @@ -40,12 +40,41 @@ func TestSubscribeTaskEvent(t *testing.T) { wantParts: []string{`"ok": true`}, }, { - name: "execute refuses bot identity", - mode: "execute", - args: []string{"+subscribe-event", "--as", "bot", "--format", "json"}, - wantErr: true, - // This error is raised before network calls; it is ok to only assert a stable substring. - wantParts: []string{"--as bot is not supported"}, + name: "execute json (bot identity)", + mode: "execute", + args: []string{"+subscribe-event", "--as", "bot", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/task_v2/task_subscription", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + }, + wantParts: []string{`"ok": true`}, + }, + { + name: "execute api error", + mode: "execute", + args: []string{"+subscribe-event", "--as", "bot", "--format", "json"}, + register: func(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/task/v2/task_v2/task_subscription", + Body: map[string]interface{}{ + "code": 401, + "msg": "Unauthorized", + "error": map[string]interface{}{ + "log_id": "test-log-id", + }, + }, + }) + }, + wantErr: true, + wantParts: []string{"Unauthorized"}, }, { name: "dry run", diff --git a/skills/lark-task/references/lark-task-subscribe-event.md b/skills/lark-task/references/lark-task-subscribe-event.md index f78722d8..80490641 100644 --- a/skills/lark-task/references/lark-task-subscribe-event.md +++ b/skills/lark-task/references/lark-task-subscribe-event.md @@ -2,14 +2,14 @@ > **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.** +> **⚠️ Note:** This API supports both `user` and `bot` identities. Use `user` to subscribe the current user's accessible tasks; use `bot` to subscribe with the current application identity. -Subscribe the current user to task update events for tasks they can access. +Subscribe task update events with the current identity. This shortcut is different from `event +subscribe`: -- `task +subscribe-event` uses a **user identity** -- it subscribes the **current user** to task events for tasks they created, are responsible for, or follow -- it is scoped to the user's task access, not a bot-level global event stream +- `task +subscribe-event` registers task-event access for the **current identity** +- with `--as user`, it subscribes the **current user** to task events for tasks they created, are responsible for, or follow +- with `--as bot`, it subscribes using the **current application identity** The task event type is: @@ -49,7 +49,9 @@ Event payload shape (example): - `task_guid`: the task GUID that changed - `timestamp`: event timestamp (ms) -In practice, this means the subscribed user can receive updates for tasks that are visible to them through authorship, assignment, or following. +In practice, this means: +- with `--as user`, the subscribed user can receive updates for tasks visible to them through authorship, assignment, or following +- with `--as bot`, the subscription is created with the current app identity To actually receive the subscribed events, use the standard event WebSocket receiver: @@ -58,7 +60,7 @@ lark-cli event +subscribe --event-types task.task.update_user_access_v2 --compac ``` The full flow is: -1. Register the user-facing subscription with `lark-cli task +subscribe-event` +1. Register the subscription with `lark-cli task +subscribe-event [--as user|bot]` 2. Receive those events with `lark-cli event +subscribe --event-types task.task.update_user_access_v2 ...` ## Recommended Commands @@ -66,6 +68,9 @@ The full flow is: ```bash lark-cli task +subscribe-event ``` +# Subscribe with app identity +lark-cli task +subscribe-event --as bot + ## Parameters @@ -73,9 +78,9 @@ This shortcut has no additional parameters. ## Workflow -1. Confirm the user wants to subscribe their own account to task update events. +1. Confirm whether the user wants to subscribe with `user` identity or `bot` identity. 2. Execute `lark-cli task +subscribe-event` -3. Report whether the subscription succeeded, and clarify that this applies to the user's own accessible tasks. +3. Report whether the subscription succeeded, and clarify which identity the subscription applies to. > [!CAUTION] > This is a **Write Operation** -- You must confirm the user's intent before executing. From 84b729f8295754ec550e04ffdb372d74fdfff212 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 17:35:29 +0800 Subject: [PATCH 12/21] docs(task): clarify bot subscribe-event scope --- skills/lark-task/references/lark-task-subscribe-event.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/lark-task/references/lark-task-subscribe-event.md b/skills/lark-task/references/lark-task-subscribe-event.md index 80490641..e23a4d80 100644 --- a/skills/lark-task/references/lark-task-subscribe-event.md +++ b/skills/lark-task/references/lark-task-subscribe-event.md @@ -2,14 +2,14 @@ > **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules. > -> **⚠️ Note:** This API supports both `user` and `bot` identities. Use `user` to subscribe the current user's accessible tasks; use `bot` to subscribe with the current application identity. +> **⚠️ Note:** This API supports both `user` and `bot` identities. Use `user` to subscribe the current user's accessible tasks; use `bot` to subscribe tasks the **application is responsible for**. Subscribe task update events with the current identity. This shortcut is different from `event +subscribe`: - `task +subscribe-event` registers task-event access for the **current identity** - with `--as user`, it subscribes the **current user** to task events for tasks they created, are responsible for, or follow -- with `--as bot`, it subscribes using the **current application identity** +- with `--as bot`, it subscribes using the **application identity** for tasks the application is responsible for The task event type is: @@ -51,7 +51,7 @@ Event payload shape (example): In practice, this means: - with `--as user`, the subscribed user can receive updates for tasks visible to them through authorship, assignment, or following -- with `--as bot`, the subscription is created with the current app identity +- with `--as bot`, the subscription covers tasks the application is responsible for To actually receive the subscribed events, use the standard event WebSocket receiver: From e3bfd261206236c97ae88ff4acdd1d12c8c2305c Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 18:24:32 +0800 Subject: [PATCH 13/21] docs(task): clarify related-task pagination semantics --- shortcuts/task/task_get_related_tasks.go | 4 ++-- skills/lark-task/references/lark-task-get-related-tasks.md | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/shortcuts/task/task_get_related_tasks.go b/shortcuts/task/task_get_related_tasks.go index 3b32ef41..8a9bf295 100644 --- a/shortcuts/task/task_get_related_tasks.go +++ b/shortcuts/task/task_get_related_tasks.go @@ -36,8 +36,8 @@ var GetRelatedTasks = common.Shortcut{ {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"}, {Name: "page-token", Desc: "page token / updated_at cursor in microseconds"}, - {Name: "created-by-me", Type: "bool", Desc: "filter to tasks created by me"}, - {Name: "followed-by-me", Type: "bool", Desc: "filter to tasks followed by me"}, + {Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"}, + {Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { params := map[string]interface{}{ diff --git a/skills/lark-task/references/lark-task-get-related-tasks.md b/skills/lark-task/references/lark-task-get-related-tasks.md index b55b2e6b..5f55d0b9 100644 --- a/skills/lark-task/references/lark-task-get-related-tasks.md +++ b/skills/lark-task/references/lark-task-get-related-tasks.md @@ -39,10 +39,12 @@ lark-cli task +get-related-tasks --created-by-me | `--page-all` | No | Automatically paginate through all pages (max 40). | | `--page-limit ` | No | Max page limit (default 20). | | `--page-token ` | No | Start from the specified page token. This token is the task's last update time cursor in microseconds. | -| `--created-by-me` | No | Keep only tasks whose creator is the current user. | -| `--followed-by-me` | No | Keep only tasks followed by the current user. | +| `--created-by-me` | No | Keep only tasks whose creator is the current user. This is a client-side filter applied after fetching related-task pages. | +| `--followed-by-me` | No | Keep only tasks followed by the current user. This is a client-side filter applied after fetching related-task pages. | > **Page Token Note:** In `+get-related-tasks`, the `page_token` is a microsecond-level cursor representing the task's last update time. For example, `1752730590582902` should be treated as an updated-at cursor, not a task ID. +> +> **Pagination Note for Client-side Filters:** When `--created-by-me` or `--followed-by-me` is used, filtering happens locally after each upstream related-task page is fetched. The returned `has_more` and `page_token` still describe the upstream cursor, so later pages may contain more matching tasks, or may contain none. ## Workflow From 168b01c7ef4b0f5deb4370f60f747a47f759a32d Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 20:00:28 +0800 Subject: [PATCH 14/21] docs(task): add BOE selftest report (boe_task_tasklist_oapi_support) --- SELFTEST_TASK_BOE.md | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 SELFTEST_TASK_BOE.md diff --git a/SELFTEST_TASK_BOE.md b/SELFTEST_TASK_BOE.md new file mode 100644 index 00000000..2a3a781a --- /dev/null +++ b/SELFTEST_TASK_BOE.md @@ -0,0 +1,74 @@ +# Task Shortcuts — BOE 自测报告(boe_task_tasklist_oapi_support) + +- 执行时间:2026-04-10 19:59:37 +0800 +- 环境切换:`. ./lark-env.sh boe --boe-env-name boe_task_tasklist_oapi_support` +- 代理提示:`HTTPS_PROXY=http://127.0.0.1:8899` + +## 覆盖命令 + +- 读: + - `task +get-related-tasks` + - `task +search` + - `task +tasklist-search` + - `task +get-my-tasks`(cursor 自测) +- 写: + - `task +subscribe-event`(user / bot) + - `task +set-ancestor` + - `task +create`(用于 set-ancestor 验证) + +## 结果摘要 + +- user 路径:全部返回 `need_user_authorization (user: ou_...)`,当前 BOE 用户未完成授权,网络链路正常但鉴权阻塞。 +- bot 路径:命令链路正常,见下述详情。 + +## 详细记录 + +### Subscribe Event + +- 命令:`task +subscribe-event --as bot --format json` +- 输出:`{"ok": true, "identity": "bot", "data": {"ok": true}}` +- 结论:成功注册应用身份的任务事件订阅。 + +### Create Tasks(用于 set-ancestor) + +- 命令:`task +create --as bot --summary "CLI_SELFTEST_PARENT_20260410_2000" --format json` +- 输出:返回 `guid` 与 `url`,示例 `guid=1af0da5e-a8ae-40fa-846e-b897f3b4ac02` +- 命令:`task +create --as bot --summary "CLI_SELFTEST_CHILD_20260410_2000" --format json` +- 输出:返回 `guid` 与 `url`,示例 `guid=c89d2d7d-9958-43c3-b583-06521022e5cd` + +### Set Ancestor(设置与清空) + +- 命令:`task +set-ancestor --as bot --task-id c89d2d7d-... --ancestor-id 1af0da5e-... --format json` +- 输出:`{"ok": true, "data": {"guid": "c89d2d7d-..."}}` +- 命令:`task +set-ancestor --as bot --task-id c89d2d7d-... --format json`(清空) +- 输出:`{"ok": true, "data": {"guid": "c89d2d7d-..."}}` +- 结论:设置/清空祖先链路正常,返回值与预期一致。 + +### Dry-Run(user 路径形状验证) + +- `task +get-related-tasks --as user --page-token 1752730590582902 --created-by-me --dry-run` + - 请求:`GET /open-apis/task/v2/task_v2/list_related_task`,带 `page_size=100`、`page_token=1752730590582902`、`user_id_type=open_id` +- `task +search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --dry-run` + - 请求:`POST /open-apis/task/v2/tasks/search`,`{"query":"cli-selftest-nohit-20260410"}` +- `task +tasklist-search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --dry-run` + - 请求:`POST /open-apis/task/v2/tasklists/search`,`{"query":"cli-selftest-nohit-20260410"}` +- `task +get-my-tasks --as user --page-token 1752730590582902 --page-limit 1 --dry-run` + - 请求:`GET /open-apis/task/v2/tasks`,带 `type=my_tasks`、`page_token=1752730590582902` 等 + +### user 路径实跑(均被鉴权拦截) + +- `task +get-related-tasks --as user --page-limit 1 --format json` +- `task +search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --format json` +- `task +tasklist-search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --format json` +- `task +get-my-tasks --as user --page-token 1752730590582902 --page-limit 1 --format json` +- `task +subscribe-event --as user --format json` +- 统一失败:`need_user_authorization (user: ou_...)` + +## 结论与后续 + +- Bot 路径(subscribe-event、create、set-ancestor)功能验证通过。 +- User 路径当前 BOE 需要先完成授权;建议用 `lark-cli auth login --as user --scope "task:task:read task:task:write task:tasklist:read task:tasklist:write"` 完成授权后再跑实测。 +- 文档已澄清: + - `+subscribe-event` 为身份级订阅注册,不需要 task GUID + - `+get-related-tasks` 的 `has_more/page_token` 是上游游标;`--created-by-me` / `--followed-by-me` 为本地过滤 + From b1f4c2c0cd8420aa06416d58c9c2c39177f9f1d4 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 10 Apr 2026 20:24:22 +0800 Subject: [PATCH 15/21] fix(task): use rfc3339 time filters for search endpoints --- shortcuts/task/task_query_helpers.go | 45 +++++++++++++++++++++ shortcuts/task/task_query_helpers_test.go | 44 ++++++++++++++++++++ shortcuts/task/task_search.go | 2 +- shortcuts/task/task_search_test.go | 4 +- shortcuts/task/task_tasklist_search.go | 2 +- shortcuts/task/task_tasklist_search_test.go | 4 +- 6 files changed, 97 insertions(+), 4 deletions(-) diff --git a/shortcuts/task/task_query_helpers.go b/shortcuts/task/task_query_helpers.go index 559c7697..5db95012 100644 --- a/shortcuts/task/task_query_helpers.go +++ b/shortcuts/task/task_query_helpers.go @@ -67,6 +67,51 @@ func parseTimeRangeMillis(input string) (string, string, error) { return startMillis, endMillis, nil } +func parseTimeRangeRFC3339(input string) (string, string, error) { + if strings.TrimSpace(input) == "" { + return "", "", nil + } + + parts := strings.SplitN(input, ",", 2) + startInput := strings.TrimSpace(parts[0]) + endInput := "" + if len(parts) == 2 { + endInput = strings.TrimSpace(parts[1]) + } + + var startTime, endTime string + var startSecInt, endSecInt int64 + var hasStart, hasEnd bool + if startInput != "" { + startSec, err := parseTimeFlagSec(startInput, "start") + if err != nil { + return "", "", err + } + startSecInt, err = strconv.ParseInt(startSec, 10, 64) + if err != nil { + return "", "", fmt.Errorf("invalid start timestamp: %w", err) + } + hasStart = true + startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339) + } + if endInput != "" { + endSec, err := parseTimeFlagSec(endInput, "end") + if err != nil { + return "", "", err + } + endSecInt, err = strconv.ParseInt(endSec, 10, 64) + if err != nil { + return "", "", fmt.Errorf("invalid end timestamp: %w", err) + } + hasEnd = true + endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339) + } + if hasStart && hasEnd && startSecInt > endSecInt { + return "", "", fmt.Errorf("start time must be earlier than or equal to end time") + } + return startTime, endTime, nil +} + func formatTaskDateTimeMillis(msStr string) string { if msStr == "" || msStr == "0" { return "" diff --git a/shortcuts/task/task_query_helpers_test.go b/shortcuts/task/task_query_helpers_test.go index eef30316..07c6c77f 100644 --- a/shortcuts/task/task_query_helpers_test.go +++ b/shortcuts/task/task_query_helpers_test.go @@ -238,5 +238,49 @@ func TestRenderRelatedTasksPretty(t *testing.T) { } } }) + + t.Run("parseTimeRangeRFC3339", func(t *testing.T) { + timeTests := []struct { + name string + input string + wantErr bool + wantStart string + wantEnd string + }{ + {name: "empty input", input: "", wantStart: "", wantEnd: ""}, + {name: "invalid input", input: "bad-time", wantErr: true}, + {name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"}, + {name: "reversed range fails fast", input: "+1d,-1d", wantErr: true}, + } + + for _, tt := range timeTests { + t.Run(tt.name, func(t *testing.T) { + start, end, err := parseTimeRangeRFC3339(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("parseTimeRangeRFC3339() error = %v", err) + } + if tt.wantStart == "rfc3339" { + if !strings.Contains(start, "T") || !strings.Contains(start, ":") { + t.Fatalf("expected RFC3339 start, got %q", start) + } + } else if start != tt.wantStart { + t.Fatalf("unexpected start: %q", start) + } + if tt.wantEnd == "rfc3339" { + if !strings.Contains(end, "T") || !strings.Contains(end, ":") { + t.Fatalf("expected RFC3339 end, got %q", end) + } + } else if end != tt.wantEnd { + t.Fatalf("unexpected end: %q", end) + } + }) + } + }) } } diff --git a/shortcuts/task/task_search.go b/shortcuts/task/task_search.go index 48f686b9..0d30b02c 100644 --- a/shortcuts/task/task_search.go +++ b/shortcuts/task/task_search.go @@ -171,7 +171,7 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{} filter["is_completed"] = runtime.Bool("completed") } if dueRange := runtime.Str("due"); dueRange != "" { - start, end, err := parseTimeRangeMillis(dueRange) + start, end, err := parseTimeRangeRFC3339(dueRange) if err != nil { return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search") } diff --git a/shortcuts/task/task_search_test.go b/shortcuts/task/task_search_test.go index 4edd8342..d2bb1384 100644 --- a/shortcuts/task/task_search_test.go +++ b/shortcuts/task/task_search_test.go @@ -38,7 +38,9 @@ func TestBuildTaskSearchBody(t *testing.T) { if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true { t.Fatalf("unexpected filter: %#v", filter) } - if dueTime["start_time"] == "" || dueTime["end_time"] == "" { + startTime, _ := dueTime["start_time"].(string) + endTime, _ := dueTime["end_time"].(string) + if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") { t.Fatalf("unexpected due_time: %#v", dueTime) } }, diff --git a/shortcuts/task/task_tasklist_search.go b/shortcuts/task/task_tasklist_search.go index 28f76ac8..17d126ab 100644 --- a/shortcuts/task/task_tasklist_search.go +++ b/shortcuts/task/task_tasklist_search.go @@ -158,7 +158,7 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa filter["user_id"] = ids } if createTime := runtime.Str("create-time"); createTime != "" { - start, end, err := parseTimeRangeMillis(createTime) + start, end, err := parseTimeRangeRFC3339(createTime) if err != nil { return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search") } diff --git a/shortcuts/task/task_tasklist_search_test.go b/shortcuts/task/task_tasklist_search_test.go index 78d364a4..288793f6 100644 --- a/shortcuts/task/task_tasklist_search_test.go +++ b/shortcuts/task/task_tasklist_search_test.go @@ -36,7 +36,9 @@ func TestBuildTasklistSearchBody(t *testing.T) { if filter["user_id"].([]string)[0] != "ou_creator" { t.Fatalf("unexpected filter: %#v", filter) } - if createTime["start_time"] == "" || createTime["end_time"] == "" { + startTime, _ := createTime["start_time"].(string) + endTime, _ := createTime["end_time"].(string) + if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") { t.Fatalf("unexpected create_time: %#v", createTime) } }, From 920d870632112e322bd03d7a3d531f461f26af7f Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Sat, 11 Apr 2026 17:36:29 +0800 Subject: [PATCH 16/21] docs(task): prefer related-task shortcuts over search for scoped queries --- skills/lark-task/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index 0a3c2ac8..a6a59970 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -12,7 +12,7 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** -> **搜索技巧**:如果用户提供了模糊任务查询词(例如任务名称、关键词、片段描述),统一优先使用 `+search --query ...`。只有当用户没有提供 `query`,而是明确表达“我负责的任务”或“与我相关的任务”时,才分别考虑使用 `+get-my-tasks` 或 `+get-related-tasks`。 +> **搜索技巧**:只有当用户明确带有“搜索 / 查找 / 模糊匹配关键词 / 任务名称片段”这类搜索意图,或显式指定要使用搜索能力时,才优先使用 `+search --query ...`。如果用户没有特地指定搜索,只是在描述范围条件(例如“今年以来”“最近一个月”“已完成”“由我创建”“我关注的”“与我相关的”“我负责的”),且 `+search` 与 `+get-related-tasks` / `+get-my-tasks` 都可能完成需求时,应优先使用 `+get-related-tasks` 或 `+get-my-tasks`。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。 > **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。 > **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 > **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。 From a2fc95c62e6c351d349bc18f87e4fe1cfca38dda Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Sat, 11 Apr 2026 17:47:09 +0800 Subject: [PATCH 17/21] docs(task): clarify tasklist search routing --- skills/lark-task/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index a6a59970..362da270 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -12,7 +12,9 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** -> **搜索技巧**:只有当用户明确带有“搜索 / 查找 / 模糊匹配关键词 / 任务名称片段”这类搜索意图,或显式指定要使用搜索能力时,才优先使用 `+search --query ...`。如果用户没有特地指定搜索,只是在描述范围条件(例如“今年以来”“最近一个月”“已完成”“由我创建”“我关注的”“与我相关的”“我负责的”),且 `+search` 与 `+get-related-tasks` / `+get-my-tasks` 都可能完成需求时,应优先使用 `+get-related-tasks` 或 `+get-my-tasks`。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。 +> **任务搜索技巧**:如果用户明确说了“搜索 / 查找 / 搜一下”任务,或显式指定使用搜索能力,且目标是**任务**,则优先使用 `+search`。如果有任务名称、关键词、片段描述,就放到 `--query`;如果没有明确关键词,但有过滤条件(例如“由我创建”“已完成”“我关注的”“今年以来”),也应继续使用 `+search` 并叠加对应过滤参数,不要因为没有 `query` 就退回到列表型 shortcut。 +> **任务清单搜索技巧**:如果用户明确说了“搜索 / 查找 / 搜一下”**任务清单 / 清单**,则优先使用 `+tasklist-search`,不要误用任务搜索或列表型 shortcut。即使没有清单名称关键词,只给了过滤条件(例如“由我创建的任务清单”“今年以来创建的清单”),也应使用 `+tasklist-search` 并叠加 `--creator`、`--create-time` 等参数。例如“搜索飞书中由我创建的任务清单”应理解为“搜索清单”,优先走 `+tasklist-search --creator <当前用户 open_id>`。 +> **列表优先规则**:只有当用户**没有明确搜索意图**,只是想查看某个范围内的任务列表时,且搜索型 shortcut 与列表型 shortcut 都可能完成需求,才优先使用 `+get-related-tasks` 或 `+get-my-tasks`。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。 > **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。 > **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 > **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。 From 3fcdfaaf1272ff159008fb33851552c3e3167361 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Sat, 11 Apr 2026 17:49:54 +0800 Subject: [PATCH 18/21] docs(task): route keywordless tasklist queries to list API --- skills/lark-task/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index 362da270..bbeb9c2d 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -13,7 +13,7 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** > **任务搜索技巧**:如果用户明确说了“搜索 / 查找 / 搜一下”任务,或显式指定使用搜索能力,且目标是**任务**,则优先使用 `+search`。如果有任务名称、关键词、片段描述,就放到 `--query`;如果没有明确关键词,但有过滤条件(例如“由我创建”“已完成”“我关注的”“今年以来”),也应继续使用 `+search` 并叠加对应过滤参数,不要因为没有 `query` 就退回到列表型 shortcut。 -> **任务清单搜索技巧**:如果用户明确说了“搜索 / 查找 / 搜一下”**任务清单 / 清单**,则优先使用 `+tasklist-search`,不要误用任务搜索或列表型 shortcut。即使没有清单名称关键词,只给了过滤条件(例如“由我创建的任务清单”“今年以来创建的清单”),也应使用 `+tasklist-search` 并叠加 `--creator`、`--create-time` 等参数。例如“搜索飞书中由我创建的任务清单”应理解为“搜索清单”,优先走 `+tasklist-search --creator <当前用户 open_id>`。 +> **任务清单搜索技巧**:如果用户明确说了“搜索 / 查找 / 搜一下”**任务清单 / 清单**,需要先区分是否提供了清单关键词。若用户提供了清单名称、关键词、片段描述,则优先使用 `+tasklist-search`。若用户**没有提供关键词**,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),则不要误用 `+tasklist-search`,而应先通过原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。例如“搜索飞书中由我创建的任务清单”虽然带“搜索”字样,但没有清单关键词,应理解为“列取清单后筛选”,而不是关键词搜索。 > **列表优先规则**:只有当用户**没有明确搜索意图**,只是想查看某个范围内的任务列表时,且搜索型 shortcut 与列表型 shortcut 都可能完成需求,才优先使用 `+get-related-tasks` 或 `+get-my-tasks`。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。 > **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。 > **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 From a470a2677bdafd8c5790beb424297ac8239b56c1 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Sat, 11 Apr 2026 17:54:46 +0800 Subject: [PATCH 19/21] docs(task): refine search routing heuristics --- skills/lark-task/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index bbeb9c2d..08ff3f2d 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -12,9 +12,9 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** -> **任务搜索技巧**:如果用户明确说了“搜索 / 查找 / 搜一下”任务,或显式指定使用搜索能力,且目标是**任务**,则优先使用 `+search`。如果有任务名称、关键词、片段描述,就放到 `--query`;如果没有明确关键词,但有过滤条件(例如“由我创建”“已完成”“我关注的”“今年以来”),也应继续使用 `+search` 并叠加对应过滤参数,不要因为没有 `query` 就退回到列表型 shortcut。 -> **任务清单搜索技巧**:如果用户明确说了“搜索 / 查找 / 搜一下”**任务清单 / 清单**,需要先区分是否提供了清单关键词。若用户提供了清单名称、关键词、片段描述,则优先使用 `+tasklist-search`。若用户**没有提供关键词**,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),则不要误用 `+tasklist-search`,而应先通过原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。例如“搜索飞书中由我创建的任务清单”虽然带“搜索”字样,但没有清单关键词,应理解为“列取清单后筛选”,而不是关键词搜索。 -> **列表优先规则**:只有当用户**没有明确搜索意图**,只是想查看某个范围内的任务列表时,且搜索型 shortcut 与列表型 shortcut 都可能完成需求,才优先使用 `+get-related-tasks` 或 `+get-my-tasks`。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。 +> **任务搜索技巧**:先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**查询关键字**(例如任务名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了任务查询关键字,则目标是**任务**时优先使用 `+search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“今年以来”“已完成”“由我创建”“我关注的”),并且使用 `+search` 与 `+get-related-tasks` / `+get-my-tasks` 都能达到目的时,应优先使用列表型能力,而不是搜索型能力。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。 +> **任务清单搜索技巧**:任务清单也遵循同样的判断逻辑。先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**清单查询关键字**(例如清单名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了清单查询关键字,则优先使用 `+tasklist-search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),并且使用搜索或原生列取清单都能达到目的时,应优先使用原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。 +> **意图区分补充**:像“搜索飞书中今年以来我关注的任务”这类表达,虽然字面带有“搜索”,但如果没有真正的查询关键字,且本质是在限定“与我相关 + 时间范围”,则应优先走 `+get-related-tasks`;像“搜索飞书中由我创建的任务清单”这类表达,如果没有清单关键字,且本质是在限定“清单范围 + 创建者”,则应优先走原生 `tasklists.list` 后筛选,而不是直接走搜索型 shortcut。 > **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。 > **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 > **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。 From 893aeca3a59433bbbdc2e1c8df6d15b0131db957 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Sun, 12 Apr 2026 08:27:14 +0800 Subject: [PATCH 20/21] feat(event): include task user-access updates in catch-all subscribe --- SELFTEST_TASK_BOE.md | 74 ------------------- shortcuts/event/subscribe.go | 1 + .../references/lark-event-subscribe.md | 3 +- 3 files changed, 3 insertions(+), 75 deletions(-) delete mode 100644 SELFTEST_TASK_BOE.md diff --git a/SELFTEST_TASK_BOE.md b/SELFTEST_TASK_BOE.md deleted file mode 100644 index 2a3a781a..00000000 --- a/SELFTEST_TASK_BOE.md +++ /dev/null @@ -1,74 +0,0 @@ -# Task Shortcuts — BOE 自测报告(boe_task_tasklist_oapi_support) - -- 执行时间:2026-04-10 19:59:37 +0800 -- 环境切换:`. ./lark-env.sh boe --boe-env-name boe_task_tasklist_oapi_support` -- 代理提示:`HTTPS_PROXY=http://127.0.0.1:8899` - -## 覆盖命令 - -- 读: - - `task +get-related-tasks` - - `task +search` - - `task +tasklist-search` - - `task +get-my-tasks`(cursor 自测) -- 写: - - `task +subscribe-event`(user / bot) - - `task +set-ancestor` - - `task +create`(用于 set-ancestor 验证) - -## 结果摘要 - -- user 路径:全部返回 `need_user_authorization (user: ou_...)`,当前 BOE 用户未完成授权,网络链路正常但鉴权阻塞。 -- bot 路径:命令链路正常,见下述详情。 - -## 详细记录 - -### Subscribe Event - -- 命令:`task +subscribe-event --as bot --format json` -- 输出:`{"ok": true, "identity": "bot", "data": {"ok": true}}` -- 结论:成功注册应用身份的任务事件订阅。 - -### Create Tasks(用于 set-ancestor) - -- 命令:`task +create --as bot --summary "CLI_SELFTEST_PARENT_20260410_2000" --format json` -- 输出:返回 `guid` 与 `url`,示例 `guid=1af0da5e-a8ae-40fa-846e-b897f3b4ac02` -- 命令:`task +create --as bot --summary "CLI_SELFTEST_CHILD_20260410_2000" --format json` -- 输出:返回 `guid` 与 `url`,示例 `guid=c89d2d7d-9958-43c3-b583-06521022e5cd` - -### Set Ancestor(设置与清空) - -- 命令:`task +set-ancestor --as bot --task-id c89d2d7d-... --ancestor-id 1af0da5e-... --format json` -- 输出:`{"ok": true, "data": {"guid": "c89d2d7d-..."}}` -- 命令:`task +set-ancestor --as bot --task-id c89d2d7d-... --format json`(清空) -- 输出:`{"ok": true, "data": {"guid": "c89d2d7d-..."}}` -- 结论:设置/清空祖先链路正常,返回值与预期一致。 - -### Dry-Run(user 路径形状验证) - -- `task +get-related-tasks --as user --page-token 1752730590582902 --created-by-me --dry-run` - - 请求:`GET /open-apis/task/v2/task_v2/list_related_task`,带 `page_size=100`、`page_token=1752730590582902`、`user_id_type=open_id` -- `task +search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --dry-run` - - 请求:`POST /open-apis/task/v2/tasks/search`,`{"query":"cli-selftest-nohit-20260410"}` -- `task +tasklist-search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --dry-run` - - 请求:`POST /open-apis/task/v2/tasklists/search`,`{"query":"cli-selftest-nohit-20260410"}` -- `task +get-my-tasks --as user --page-token 1752730590582902 --page-limit 1 --dry-run` - - 请求:`GET /open-apis/task/v2/tasks`,带 `type=my_tasks`、`page_token=1752730590582902` 等 - -### user 路径实跑(均被鉴权拦截) - -- `task +get-related-tasks --as user --page-limit 1 --format json` -- `task +search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --format json` -- `task +tasklist-search --as user --query cli-selftest-nohit-20260410 --page-limit 1 --format json` -- `task +get-my-tasks --as user --page-token 1752730590582902 --page-limit 1 --format json` -- `task +subscribe-event --as user --format json` -- 统一失败:`need_user_authorization (user: ou_...)` - -## 结论与后续 - -- Bot 路径(subscribe-event、create、set-ancestor)功能验证通过。 -- User 路径当前 BOE 需要先完成授权;建议用 `lark-cli auth login --as user --scope "task:task:read task:task:write task:tasklist:read task:tasklist:write"` 完成授权后再跑实测。 -- 文档已澄清: - - `+subscribe-event` 为身份级订阅注册,不需要 task GUID - - `+get-related-tasks` 的 `has_more/page_token` 是上游游标;`--created-by-me` / `--followed-by-me` 为本地过滤 - diff --git a/shortcuts/event/subscribe.go b/shortcuts/event/subscribe.go index 5b3022e6..7bc48b94 100644 --- a/shortcuts/event/subscribe.go +++ b/shortcuts/event/subscribe.go @@ -74,6 +74,7 @@ var commonEventTypes = []string{ "approval.approval.updated", "application.application.visibility.added_v6", "task.task.update_tenant_v1", + "task.task.update_user_access_v2", "task.task.comment_updated_v1", "drive.notice.comment_add_v1", } diff --git a/skills/lark-event/references/lark-event-subscribe.md b/skills/lark-event/references/lark-event-subscribe.md index 09ab25d3..cde27b12 100644 --- a/skills/lark-event/references/lark-event-subscribe.md +++ b/skills/lark-event/references/lark-event-subscribe.md @@ -17,7 +17,7 @@ Subscribe to Lark events via WebSocket long connection, outputting NDJSON to std ## Commands ```bash -# Subscribe to all registered events (catch-all mode, 24 common event types) +# Subscribe to all registered events (catch-all mode, 25 common event types) lark-cli event +subscribe # Subscribe to specific event types only @@ -153,6 +153,7 @@ The following 24 event types are registered in catch-all mode (when `--event-typ | Event Type | Description | Required Scope | |-----------|-------------|---------------| | `task.task.update_tenant_v1` | Task updated (tenant) | `task:task:readonly` | +| `task.task.update_user_access_v2` | Task updated (user access) | `task:task:readonly` | | `task.task.comment_updated_v1` | Task comment updated | `task:task:readonly` | ### Drive From 9dd7bba9727b8355e4e1f42e305166317b4bc1bc Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Sun, 12 Apr 2026 08:38:09 +0800 Subject: [PATCH 21/21] docs(task): remove auth status --json guidance --- skills/lark-task/references/lark-task-create.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-task/references/lark-task-create.md b/skills/lark-task/references/lark-task-create.md index c23126a9..5bf3dec1 100644 --- a/skills/lark-task/references/lark-task-create.md +++ b/skills/lark-task/references/lark-task-create.md @@ -38,7 +38,7 @@ lark-cli task +create --summary "Test Task" --dry-run ## Workflow 1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary. - - **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status --json` or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter. + - **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter. 2. Execute `lark-cli task +create --summary "..." ...` 3. Report the result: task ID and summary.