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/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..8a9bf295 --- /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 microseconds"}, + {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{}{ + "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..5db95012 --- /dev/null +++ b/shortcuts/task/task_query_helpers.go @@ -0,0 +1,247 @@ +// 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 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 + 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 != "" { + 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 + 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 +} + +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 "" + } + 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..07c6c77f --- /dev/null +++ b/shortcuts/task/task_query_helpers_test.go @@ -0,0 +1,286 @@ +// 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 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"}, + {name: "reversed range fails fast", input: "+1d,-1d", wantErr: true}, + } + 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) + } + } + }) + + 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 new file mode 100644 index 00000000..0d30b02c --- /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 := parseTimeRangeRFC3339(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..d2bb1384 --- /dev/null +++ b/shortcuts/task/task_search_test.go @@ -0,0 +1,300 @@ +// 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) + } + 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) + } + }, + }, + { + 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..ecc27813 --- /dev/null +++ b/shortcuts/task/task_set_ancestor.go @@ -0,0 +1,84 @@ +// 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/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") + + 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")), + }) + 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 + } + + 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..1115f200 --- /dev/null +++ b/shortcuts/task/task_set_ancestor_test.go @@ -0,0 +1,166 @@ +// 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 + 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"}, + 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"}, + 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) + 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) + } + 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..f8afe6c2 --- /dev/null +++ b/shortcuts/task/task_subscribe_event.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "encoding/json" + "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", "bot"}, + 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") + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/task/v2/task_v2/task_subscription", + QueryParams: queryParams, + }) + + // 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 + } + + 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..2cd4323e --- /dev/null +++ b/shortcuts/task/task_subscribe_event_test.go @@ -0,0 +1,131 @@ +// 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 + args []string + register func(*httpmock.Registry) + wantErr bool + wantParts []string + }{ + { + 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 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", + 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) + if tt.register != nil { + tt.register(reg) + } + + 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) + } + + 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..17d126ab --- /dev/null +++ b/shortcuts/task/task_tasklist_search.go @@ -0,0 +1,209 @@ +// 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 { + // 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) + 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 := parseTimeRangeRFC3339(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..288793f6 --- /dev/null +++ b/shortcuts/task/task_tasklist_search_test.go @@ -0,0 +1,263 @@ +// 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) + } + 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) + } + }, + }, + { + 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: "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"}, + 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-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 diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index c6d3b6c4..08ff3f2d 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),其中包含认证、权限处理** -> **搜索技巧**:如果用户的查询只指定了任务名称(例如“完成任务龙虾一号”),请直接使用 `+get-my-tasks --query "龙虾一号"` 命令搜索(不要带 `--complete` 参数,这样可以同时搜索未完成和已完成的任务)。 +> **任务搜索技巧**:先区分用户是否**特地指定使用搜索 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` 字段(任务链接),以便用户可以直接点击跳转查看详情。 @@ -24,7 +26,8 @@ metadata: > **查询注意**: > 1. 在输出任务详情时,如果需要渲染负责人、创建人等人员字段,除了展示 `id` (例如 open_id) 外,还必须通过其他方式(例如调用通讯录技能)尝试获取并展示这个人的真实名字,以便用户更容易识别。 -> 2. 在输出任务详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。 +> 2. 在输出清单详情时,如果需要渲染 owner、member、角色成员等人员字段,也必须像任务成员展示一样,除了展示 `id` 外,尽量解析并展示对应人员的真实名字。 +> 3. 在输出任务或清单详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。 > **Task GUID 定义**: > Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识(GUID),不是客户端展示的任务编号(例如 `t104121` / `suite_entity_num`)。 @@ -41,7 +44,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 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. 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..5f55d0b9 --- /dev/null +++ b/skills/lark-task/references/lark-task-get-related-tasks.md @@ -0,0 +1,53 @@ +# 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. 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 + +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..e23a4d80 --- /dev/null +++ b/skills/lark-task/references/lark-task-subscribe-event.md @@ -0,0 +1,86 @@ +# task +subscribe-event + +> **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 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 **application identity** for tasks the application is responsible for + +The task event type is: + +```text +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: +- with `--as user`, the subscribed user can receive updates for tasks visible to them through authorship, assignment, or following +- with `--as bot`, the subscription covers tasks the application is responsible for + +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 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 + +```bash +lark-cli task +subscribe-event +``` +# Subscribe with app identity +lark-cli task +subscribe-event --as bot + + +## Parameters + +This shortcut has no additional parameters. + +## Workflow + +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 which identity the subscription applies to. + +> [!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. +