diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 114ae524f..105243d33 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -720,6 +720,8 @@ func TestShortcuts(t *testing.T) { "+flag-create", "+flag-cancel", "+flag-list", + "+feed-group-list-item", + "+feed-group-query-item", } if !reflect.DeepEqual(commands, want) { t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want) diff --git a/shortcuts/im/im_feed_group_item_test.go b/shortcuts/im/im_feed_group_item_test.go new file mode 100644 index 000000000..8c7f5885a --- /dev/null +++ b/shortcuts/im/im_feed_group_item_test.go @@ -0,0 +1,516 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// recordedFGRequest captures one outbound request for assertion. +type recordedFGRequest struct { + method string + path string + query map[string][]string + body map[string]interface{} +} + +// fgResponder maps a URL path suffix to a JSON response body. +type fgResponder func(path string, page int) (int, interface{}) + +// newFGCmd builds a cobra command carrying the shortcut's flags, applying the +// provided overrides. +func newFGCmd(t *testing.T, sc common.Shortcut, flags map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: sc.Command} + for _, fl := range sc.Flags { + switch fl.Type { + case "bool": + cmd.Flags().Bool(fl.Name, fl.Default == "true", fl.Desc) + case "int": + def := 0 + if fl.Default != "" { + n, _ := strconv.Atoi(fl.Default) + def = n + } + cmd.Flags().Int(fl.Name, def, fl.Desc) + default: + cmd.Flags().String(fl.Name, fl.Default, fl.Desc) + } + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, val := range flags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("set flag %s=%s: %v", name, val, err) + } + } + return cmd +} + +// newFGRuntime wires a user-identity runtime with the shortcut's flags and an +// httpmock transport that records requests and replies via the responder. +func newFGRuntime(t *testing.T, sc common.Shortcut, flags map[string]string, recorded *[]recordedFGRequest, responder fgResponder) *common.RuntimeContext { + t.Helper() + pageByPath := map[string]int{} + rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + rec := recordedFGRequest{ + method: req.Method, + path: req.URL.Path, + query: req.URL.Query(), + } + if req.Body != nil { + data, _ := io.ReadAll(req.Body) + if len(data) > 0 { + _ = json.Unmarshal(data, &rec.body) + } + } + if recorded != nil { + *recorded = append(*recorded, rec) + } + pageByPath[req.URL.Path]++ + status, body := 200, interface{}(map[string]interface{}{"code": 0, "data": map[string]interface{}{}}) + if responder != nil { + status, body = responder(req.URL.Path, pageByPath[req.URL.Path]) + } + return shortcutJSONResponse(status, body), nil + }) + + runtime := newUserShortcutRuntime(t, rt) + runtime.Cmd = newFGCmd(t, sc, flags) + runtime.Format = "json" + return runtime +} + +func wrapData(d map[string]interface{}) map[string]interface{} { + return map[string]interface{}{"code": 0, "data": d} +} + +func findFGRequest(reqs []recordedFGRequest, pathSuffix string) *recordedFGRequest { + for i := range reqs { + if strings.HasSuffix(reqs[i].path, pathSuffix) { + return &reqs[i] + } + } + return nil +} + +func firstQueryValue(q map[string][]string, key string) string { + if v := q[key]; len(v) > 0 { + return v[0] + } + return "" +} + +// dryRunJSON marshals a DryRunAPI to its wire shape so tests can assert against +// the public JSON (calls/extra are unexported on the struct). +func dryRunJSON(t *testing.T, d *common.DryRunAPI) map[string]interface{} { + t.Helper() + b, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal dry-run: %v", err) + } + return m +} + +func dryRunCalls(t *testing.T, d *common.DryRunAPI) []map[string]interface{} { + t.Helper() + m := dryRunJSON(t, d) + raw, _ := m["api"].([]interface{}) + calls := make([]map[string]interface{}, 0, len(raw)) + for _, c := range raw { + cm, _ := c.(map[string]interface{}) + calls = append(calls, cm) + } + return calls +} + +func countFGRequests(reqs []recordedFGRequest, pathSuffix string) int { + n := 0 + for i := range reqs { + if strings.HasSuffix(reqs[i].path, pathSuffix) { + n++ + } + } + return n +} + +// ── list-item: happy path with enrichment of items + deleted_items ── + +func TestFeedGroupListItemEnrichesBothLists(t *testing.T) { + var reqs []recordedFGRequest + runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs, + func(path string, _ int) (int, interface{}) { + switch { + case strings.HasSuffix(path, "/list_item"): + return 200, wrapData(map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}}, + "deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}}, + "page_token": "", + "has_more": false, + }) + case strings.HasSuffix(path, "/chats/batch_query"): + return 200, wrapData(map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"}, + map[string]interface{}{"chat_id": "oc_def", "name": "Old Channel"}, + }}) + } + return 200, wrapData(map[string]interface{}{}) + }) + + if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + list := findFGRequest(reqs, "/list_item") + if list == nil { + t.Fatal("expected list_item request") + } + if list.method != http.MethodGet { + t.Errorf("list_item method = %s, want GET", list.method) + } + if !strings.HasSuffix(list.path, "/open-apis/im/v1/groups/ofg_x/list_item") { + t.Errorf("list_item path = %s", list.path) + } + if findFGRequest(reqs, "/chats/batch_query") == nil { + t.Error("expected chats/batch_query enrichment request") + } +} + +// ── list-item: empty items skips enrichment ── + +func TestFeedGroupListItemEmptySkipsEnrichment(t *testing.T) { + var reqs []recordedFGRequest + runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs, + func(path string, _ int) (int, interface{}) { + if strings.HasSuffix(path, "/list_item") { + return 200, wrapData(map[string]interface{}{ + "items": []interface{}{}, "deleted_items": []interface{}{}, + "page_token": "", "has_more": false, + }) + } + return 200, wrapData(map[string]interface{}{}) + }) + if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute error: %v", err) + } + if findFGRequest(reqs, "/chats/batch_query") != nil { + t.Error("did not expect batch_query when there are no items") + } +} + +// ── list-item: page-all merges across 2 pages, empty deleted serializes as [] ── + +func TestFeedGroupListItemPageAllMerges(t *testing.T) { + var reqs []recordedFGRequest + runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-all": "true"}, &reqs, + func(path string, page int) (int, interface{}) { + if strings.HasSuffix(path, "/list_item") { + if page == 1 { + return 200, wrapData(map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}}, + "deleted_items": []interface{}{}, + "page_token": "TKN", "has_more": true, + }) + } + return 200, wrapData(map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"feed_id": "oc_b", "feed_type": "chat", "update_time": "2"}}, + "deleted_items": []interface{}{}, + "page_token": "", "has_more": false, + }) + } + if strings.HasSuffix(path, "/chats/batch_query") { + return 200, wrapData(map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"chat_id": "oc_a", "name": "A"}, + map[string]interface{}{"chat_id": "oc_b", "name": "B"}, + }}) + } + return 200, wrapData(map[string]interface{}{}) + }) + if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute error: %v", err) + } + if got := countFGRequests(reqs, "/list_item"); got != 2 { + t.Errorf("expected 2 list_item requests, got %d", got) + } + // Second list_item page must carry the continuation token. + var second *recordedFGRequest + n := 0 + for i := range reqs { + if strings.HasSuffix(reqs[i].path, "/list_item") { + n++ + if n == 2 { + second = &reqs[i] + } + } + } + if second == nil || firstQueryValue(second.query, "page_token") != "TKN" { + t.Errorf("second page token = %q, want TKN", firstQueryValue(second.query, "page_token")) + } +} + +// ── list-item: explicit page-token ignores page-all (single page) ── + +func TestFeedGroupListItemPageTokenIgnoresPageAll(t *testing.T) { + var reqs []recordedFGRequest + runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{ + "feed-group-id": "ofg_x", "page-all": "true", "page-token": "SOMETOKEN", + }, &reqs, func(path string, _ int) (int, interface{}) { + if strings.HasSuffix(path, "/list_item") { + return 200, wrapData(map[string]interface{}{ + "items": []interface{}{}, "deleted_items": []interface{}{}, + "page_token": "NEXT", "has_more": true, + }) + } + return 200, wrapData(map[string]interface{}{}) + }) + if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute error: %v", err) + } + if got := countFGRequests(reqs, "/list_item"); got != 1 { + t.Errorf("expected 1 list_item request (page-token wins), got %d", got) + } + req := findFGRequest(reqs, "/list_item") + if got := firstQueryValue(req.query, "page_token"); got != "SOMETOKEN" { + t.Errorf("page_token query = %q, want SOMETOKEN", got) + } +} + +// ── query-item: builds correct body and enriches ── + +func TestFeedGroupQueryItemBuildsBody(t *testing.T) { + var reqs []recordedFGRequest + runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{ + "feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b", + }, &reqs, func(path string, _ int) (int, interface{}) { + switch { + case strings.HasSuffix(path, "/batch_query_item"): + return 200, wrapData(map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}}, + "deleted_items": []interface{}{}, + }) + case strings.HasSuffix(path, "/chats/batch_query"): + return 200, wrapData(map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"chat_id": "oc_a", "name": "Team A"}, + }}) + } + return 200, wrapData(map[string]interface{}{}) + }) + if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute error: %v", err) + } + req := findFGRequest(reqs, "/batch_query_item") + if req == nil { + t.Fatal("expected batch_query_item request") + } + if req.method != http.MethodPost { + t.Errorf("method = %s, want POST", req.method) + } + if !strings.HasSuffix(req.path, "/open-apis/im/v1/groups/ofg_x/batch_query_item") { + t.Errorf("path = %s", req.path) + } + items, ok := req.body["items"].([]interface{}) + if !ok || len(items) != 2 { + t.Fatalf("body items = %#v, want 2 entries", req.body["items"]) + } + first, _ := items[0].(map[string]interface{}) + if first["feed_id"] != "oc_a" || first["feed_type"] != "chat" { + t.Errorf("first item = %#v", first) + } +} + +// ── table output: renders feed_id / chat_name / update_time + summary lines ── + +func TestFeedGroupListItemTableOutput(t *testing.T) { + runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, nil, + func(path string, _ int) (int, interface{}) { + switch { + case strings.HasSuffix(path, "/list_item"): + return 200, wrapData(map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}}, + "deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}}, + "page_token": "TKN", "has_more": true, + }) + case strings.HasSuffix(path, "/chats/batch_query"): + return 200, wrapData(map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"}, + }}) + } + return 200, wrapData(map[string]interface{}{}) + }) + runtime.Format = "pretty" + + if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute error: %v", err) + } + out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer) + if out == nil { + t.Fatal("stdout buffer missing") + } + got := out.String() + for _, want := range []string{"feed_id", "chat_name", "update_time", "oc_abc", "Release Team", "1 item(s)", "more available", "(1 deleted)"} { + if !strings.Contains(got, want) { + t.Errorf("table output missing %q; got:\n%s", want, got) + } + } + // update_time must be rendered human-readable (RFC3339), not as raw Unix millis. + if strings.Contains(got, "1767196800000") { + t.Errorf("table output should not contain raw millis timestamp; got:\n%s", got) + } + wantTime := time.UnixMilli(1767196800000).Local().Format(time.RFC3339) + if !strings.Contains(got, wantTime) { + t.Errorf("table output should contain formatted update_time %q; got:\n%s", wantTime, got) + } +} + +// ── enrichment graceful degradation: unresolved feed_id keeps no chat_name ── + +func TestEnrichFeedGroupItemsGracefulDegradation(t *testing.T) { + runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{ + "feed-group-id": "ofg_x", "feed-id": "oc_known", + }, nil, func(path string, _ int) (int, interface{}) { + if strings.HasSuffix(path, "/chats/batch_query") { + // Only oc_known resolves; oc_gone is absent. + return 200, wrapData(map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"chat_id": "oc_known", "name": "Known"}, + }}) + } + return 200, wrapData(map[string]interface{}{}) + }) + data := map[string]any{ + "items": []any{ + map[string]any{"feed_id": "oc_known", "feed_type": "chat"}, + map[string]any{"feed_id": "oc_gone", "feed_type": "chat"}, + }, + "deleted_items": []any{}, + } + enrichFeedGroupItemsChatName(runtime, data) + items := data["items"].([]any) + known := items[0].(map[string]any) + gone := items[1].(map[string]any) + if known["chat_name"] != "Known" { + t.Errorf("oc_known chat_name = %v, want Known", known["chat_name"]) + } + if _, present := gone["chat_name"]; present { + t.Errorf("oc_gone should not have chat_name, got %v", gone["chat_name"]) + } +} + +// ── validation errors ── + +func TestFeedGroupValidationErrors(t *testing.T) { + cases := []struct { + name string + sc common.Shortcut + flags map[string]string + want string + }{ + {"list missing feed-group-id", ImFeedGroupListItem, map[string]string{}, "--feed-group-id is required"}, + {"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "--page-size must be an integer between 1 and 50"}, + {"list bad page-limit", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-limit": "2000"}, "--page-limit must be an integer between 1 and 1000"}, + {"query missing feed-group-id", ImFeedGroupQueryItem, map[string]string{"feed-id": "oc_a"}, "--feed-group-id is required"}, + {"query missing feed-id", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, "--feed-id is required (comma-separated chat IDs)"}, + {"query blank feed-id tokens", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x", "feed-id": ", ,"}, "--feed-id is required (comma-separated chat IDs)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runtime := newFGRuntime(t, tc.sc, tc.flags, nil, nil) + err := tc.sc.Validate(context.Background(), runtime) + if err == nil { + t.Fatalf("expected validation error %q, got nil", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want contains %q", err.Error(), tc.want) + } + }) + } +} + +// ── dry-run shapes ── + +func TestFeedGroupListItemDryRun(t *testing.T) { + runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{ + "feed-group-id": "ofg_x", "page-size": "10", "start-time": "100", + }, nil, nil) + d := ImFeedGroupListItem.DryRun(context.Background(), runtime) + calls := dryRunCalls(t, d) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + if calls[0]["method"] != "GET" { + t.Errorf("method = %v, want GET", calls[0]["method"]) + } + if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/list_item") { + t.Errorf("url = %s", url) + } + params, _ := calls[0]["params"].(map[string]interface{}) + if params["page_size"] != "10" { + t.Errorf("params page_size = %v, want 10", params["page_size"]) + } + if params["start_time"] != "100" { + t.Errorf("params start_time = %v, want 100", params["start_time"]) + } + if desc, _ := calls[0]["desc"].(string); !strings.Contains(desc, "im:chat:read") { + t.Errorf("desc = %q, want chat_name enrichment note", desc) + } +} + +func TestFeedGroupListItemDryRunValidationError(t *testing.T) { + runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{}, nil, nil) + d := ImFeedGroupListItem.DryRun(context.Background(), runtime) + m := dryRunJSON(t, d) + errMsg, _ := m["error"].(string) + if errMsg == "" { + t.Fatalf("expected error in dry-run output, got %#v", m) + } + if !strings.Contains(errMsg, "--feed-group-id is required") { + t.Errorf("error = %v", errMsg) + } +} + +func TestFeedGroupQueryItemDryRun(t *testing.T) { + runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{ + "feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b", + }, nil, nil) + d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime) + calls := dryRunCalls(t, d) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + if calls[0]["method"] != "POST" { + t.Errorf("method = %v, want POST", calls[0]["method"]) + } + if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/batch_query_item") { + t.Errorf("url = %s", url) + } + body, _ := calls[0]["body"].(map[string]interface{}) + items, _ := body["items"].([]interface{}) + if len(items) != 2 { + t.Fatalf("dry-run body items = %#v, want 2", body["items"]) + } +} + +func TestFeedGroupQueryItemDryRunValidationError(t *testing.T) { + runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, nil, nil) + d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime) + m := dryRunJSON(t, d) + if errMsg, _ := m["error"].(string); errMsg == "" { + t.Fatalf("expected error in dry-run output, got %#v", m) + } +} diff --git a/shortcuts/im/im_feed_group_items.go b/shortcuts/im/im_feed_group_items.go new file mode 100644 index 000000000..9f832a7bd --- /dev/null +++ b/shortcuts/im/im_feed_group_items.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "fmt" + "io" + "strconv" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + // feedGroupReadScope is required to read feed-group items. + feedGroupReadScope = "im:feed_group_v1:read" + // chatReadScope is required to resolve chat_name from feed_id via chats/batch_query. + chatReadScope = "im:chat:read" +) + +// enrichFeedGroupItemsChatName resolves a human-readable chat_name for each feed +// card in data["items"] and data["deleted_items"] using chats/batch_query. +// +// The feed_id of a v1 feed card is always a chat ID (oc_xxx), so the chat's name +// is the natural display label. Resolution degrades gracefully: any feed_id that +// cannot be resolved simply keeps no chat_name key, and the function never returns +// an error or alters the exit code. +// +// NOTE: This mutates the item maps in place by adding a "chat_name" key. +func enrichFeedGroupItemsChatName(rt *common.RuntimeContext, data map[string]any) { + if data == nil { + return + } + + items, _ := data["items"].([]any) + deletedItems, _ := data["deleted_items"].([]any) + + // Collect deduped, ordered feed_id strings from both lists. + ids := make([]string, 0, len(items)+len(deletedItems)) + seen := make(map[string]bool) + collect := func(list []any) { + for _, it := range list { + m, _ := it.(map[string]any) + if m == nil { + continue + } + id, _ := m["feed_id"].(string) + if id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + } + collect(items) + collect(deletedItems) + + if len(ids) == 0 { + return + } + + contexts := batchQueryChatContexts(rt, ids) + if len(contexts) == 0 { + // We had feed_ids to resolve but got nothing back — most likely the + // chats/batch_query call failed (it degrades silently). Tell the user so + // an empty chat_name column is not mistaken for chats that simply have no name. + fmt.Fprintf(rt.IO().ErrOut, "warning: could not resolve chat names for %d feed(s); chat_name will be empty\n", len(ids)) + return + } + + apply := func(list []any) { + for _, it := range list { + m, _ := it.(map[string]any) + if m == nil { + continue + } + id, _ := m["feed_id"].(string) + if id == "" { + continue + } + if ctx, ok := contexts[id]; ok { + if name, _ := ctx["name"].(string); name != "" { + m["chat_name"] = name + } + } + } + } + apply(items) + apply(deletedItems) +} + +// renderFeedGroupItemsTable prints the active items[] as a table (feed_id / +// chat_name / update_time), followed by a summary line. When hasMore is true a +// pagination hint is appended; when there are deleted items their count is noted. +func renderFeedGroupItemsTable(w io.Writer, data map[string]any, hasMore bool) { + items, _ := data["items"].([]any) + rows := make([]map[string]interface{}, 0, len(items)) + for _, it := range items { + m, _ := it.(map[string]any) + if m == nil { + continue + } + chatName, _ := m["chat_name"].(string) + updateTime, _ := m["update_time"].(string) + feedID, _ := m["feed_id"].(string) + rows = append(rows, map[string]interface{}{ + "feed_id": feedID, + "chat_name": chatName, + "update_time": formatFeedGroupUpdateTime(updateTime), + }) + } + output.PrintTable(w, rows) + + moreHint := "" + if hasMore { + moreHint = " (more available, use --page-token to fetch next page)" + } + fmt.Fprintf(w, "\n%d item(s)%s\n", len(items), moreHint) + + if deleted, _ := data["deleted_items"].([]any); len(deleted) > 0 { + fmt.Fprintf(w, "(%d deleted)\n", len(deleted)) + } +} + +// formatFeedGroupUpdateTime renders a Unix-millisecond timestamp string as a +// human-readable local time for the pretty table. The raw value is returned +// unchanged when it is empty or not a valid millisecond integer, so JSON output +// (which never calls this) keeps the original wire value. +func formatFeedGroupUpdateTime(raw string) string { + if raw == "" { + return raw + } + ms, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return raw + } + return time.UnixMilli(ms).Local().Format(time.RFC3339) +} diff --git a/shortcuts/im/im_feed_group_list_item.go b/shortcuts/im/im_feed_group_list_item.go new file mode 100644 index 000000000..f4e0d8082 --- /dev/null +++ b/shortcuts/im/im_feed_group_list_item.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "fmt" + "io" + "strconv" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// ImFeedGroupListItem provides the +feed-group-list-item shortcut: it lists the +// feed cards inside one feed group and enriches each item with chat_name resolved +// from its feed_id. +var ImFeedGroupListItem = common.Shortcut{ + Service: "im", + Command: "+feed-group-list-item", + Description: "List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination", + Risk: "read", + UserScopes: []string{feedGroupReadScope, chatReadScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"}, + {Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"}, + {Name: "page-token", Desc: "pagination token for next page"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"}, + {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"}, + {Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"}, + {Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFeedGroupListOptions(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if err := validateFeedGroupListOptions(runtime); err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + GET(feedGroupListItemPath(runtime)). + Params(feedGroupListDryRunParams(runtime)). + Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + // When --page-token is explicitly provided, the user wants a specific page — + // no auto-pagination regardless of --page-all. + if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") { + return executeFeedGroupListAllPages(runtime) + } + + data, err := runtime.DoAPIJSON("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil) + if err != nil { + return err + } + enrichFeedGroupItemsChatName(runtime, data) + + hasMore, _ := data["has_more"].(bool) + runtime.OutFormat(data, nil, func(w io.Writer) { + renderFeedGroupItemsTable(w, data, hasMore) + }) + return nil + }, +} + +func validateFeedGroupListOptions(rt *common.RuntimeContext) error { + if rt.Str("feed-group-id") == "" { + return output.ErrValidation("--feed-group-id is required") + } + if n := rt.Int("page-size"); n < 1 || n > 50 { + return output.ErrValidation("--page-size must be an integer between 1 and 50") + } + if n := rt.Int("page-limit"); n < 1 || n > 1000 { + return output.ErrValidation("--page-limit must be an integer between 1 and 1000") + } + if v := rt.Str("start-time"); v != "" { + if _, err := strconv.ParseInt(v, 10, 64); err != nil { + return output.ErrValidation("--start-time must be Unix milliseconds (a decimal integer string)") + } + } + if v := rt.Str("end-time"); v != "" { + if _, err := strconv.ParseInt(v, 10, 64); err != nil { + return output.ErrValidation("--end-time must be Unix milliseconds (a decimal integer string)") + } + } + return nil +} + +// feedGroupListItemPath builds the list_item endpoint path with the feed_group_id +// segment safely encoded. +func feedGroupListItemPath(rt *common.RuntimeContext) string { + return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/list_item" +} + +// feedGroupListQuery builds the query parameters, sending only non-empty values. +func feedGroupListQuery(rt *common.RuntimeContext) larkcore.QueryParams { + params := larkcore.QueryParams{ + "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, + } + if token := rt.Str("page-token"); token != "" { + params["page_token"] = []string{token} + } + if start := rt.Str("start-time"); start != "" { + params["start_time"] = []string{start} + } + if end := rt.Str("end-time"); end != "" { + params["end_time"] = []string{end} + } + return params +} + +// feedGroupListDryRunParams mirrors feedGroupListQuery for dry-run display. +func feedGroupListDryRunParams(rt *common.RuntimeContext) map[string]any { + params := map[string]any{ + "page_size": strconv.Itoa(rt.Int("page-size")), + } + if token := rt.Str("page-token"); token != "" { + params["page_token"] = token + } + if start := rt.Str("start-time"); start != "" { + params["start_time"] = start + } + if end := rt.Str("end-time"); end != "" { + params["end_time"] = end + } + return params +} + +// executeFeedGroupListAllPages fetches all pages and merges items/deleted_items +// into a single response, then enriches the merged result. +func executeFeedGroupListAllPages(rt *common.RuntimeContext) error { + maxPages := rt.Int("page-limit") + if maxPages < 1 { + maxPages = 20 + } + if maxPages > 1000 { + maxPages = 1000 + } + + // Use make([]any, 0) so empty arrays serialize as [] not null. + allItems := make([]any, 0) + allDeletedItems := make([]any, 0) + var lastHasMore bool + var lastPageToken string + prevPageToken := "__START__" + + for page := 0; page < maxPages; page++ { + params := larkcore.QueryParams{ + "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, + } + if page > 0 { + params["page_token"] = []string{lastPageToken} + } + if start := rt.Str("start-time"); start != "" { + params["start_time"] = []string{start} + } + if end := rt.Str("end-time"); end != "" { + params["end_time"] = []string{end} + } + + data, err := rt.DoAPIJSON("GET", feedGroupListItemPath(rt), params, nil) + if err != nil { + return err + } + + if v, ok := data["items"].([]any); ok { + allItems = append(allItems, v...) + } + if v, ok := data["deleted_items"].([]any); ok { + allDeletedItems = append(allDeletedItems, v...) + } + + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + + fmt.Fprintf(rt.IO().ErrOut, "page %d: %d items, %d deleted\n", + page+1, len(allItems), len(allDeletedItems)) + + if !lastHasMore || lastPageToken == "" { + break + } + if lastPageToken == prevPageToken { + fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n") + break + } + prevPageToken = lastPageToken + } + + merged := map[string]any{ + "items": allItems, + "deleted_items": allDeletedItems, + "has_more": lastHasMore, + "page_token": lastPageToken, + } + + enrichFeedGroupItemsChatName(rt, merged) + + rt.OutFormat(merged, nil, func(w io.Writer) { + renderFeedGroupItemsTable(w, merged, lastHasMore) + }) + return nil +} diff --git a/shortcuts/im/im_feed_group_query_item.go b/shortcuts/im/im_feed_group_query_item.go new file mode 100644 index 000000000..d35bebe7f --- /dev/null +++ b/shortcuts/im/im_feed_group_query_item.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ImFeedGroupQueryItem provides the +feed-group-query-item shortcut: it looks up +// specific feed cards in a feed group by ID and enriches each item with chat_name +// resolved from its feed_id. +var ImFeedGroupQueryItem = common.Shortcut{ + Service: "im", + Command: "+feed-group-query-item", + Description: "Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id", + Risk: "read", + UserScopes: []string{feedGroupReadScope, chatReadScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"}, + {Name: "feed-id", Desc: "comma-separated chat IDs (oc_xxx); feed_type is fixed to chat (required)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := buildFeedGroupQueryItemBody(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, err := buildFeedGroupQueryItemBody(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST(feedGroupQueryItemPath(runtime)). + Body(body). + Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildFeedGroupQueryItemBody(runtime) + if err != nil { + return err + } + + data, err := runtime.DoAPIJSON("POST", feedGroupQueryItemPath(runtime), nil, body) + if err != nil { + return err + } + enrichFeedGroupItemsChatName(runtime, data) + + runtime.OutFormat(data, nil, func(w io.Writer) { + renderFeedGroupItemsTable(w, data, false) + }) + return nil + }, +} + +// feedGroupQueryItemPath builds the batch_query_item endpoint path with the +// feed_group_id segment safely encoded. +func feedGroupQueryItemPath(rt *common.RuntimeContext) string { + return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/batch_query_item" +} + +// buildFeedGroupQueryItemBody validates the flags and constructs the request body +// {"items":[{"feed_id":"","feed_type":"chat"}, ...]}. +func buildFeedGroupQueryItemBody(rt *common.RuntimeContext) (map[string]any, error) { + if rt.Str("feed-group-id") == "" { + return nil, output.ErrValidation("--feed-group-id is required") + } + tokens := common.SplitCSV(rt.Str("feed-id")) + items := make([]any, 0, len(tokens)) + for _, tok := range tokens { + if tok == "" { + continue + } + items = append(items, map[string]any{ + "feed_id": tok, + "feed_type": "chat", + }) + } + if len(items) == 0 { + return nil, output.ErrValidation("--feed-id is required (comma-separated chat IDs)") + } + return map[string]any{"items": items}, nil +} diff --git a/shortcuts/im/im_flag_cancel.go b/shortcuts/im/im_flag_cancel.go index 4539d1ad0..7b6f67c09 100644 --- a/shortcuts/im/im_flag_cancel.go +++ b/shortcuts/im/im_flag_cancel.go @@ -15,14 +15,13 @@ import ( // ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark. // When no --flag-type is given, it performs double-cancel: removes both message and feed layers. var ImFlagCancel = common.Shortcut{ - Service: "im", - Command: "+flag-cancel", - Description: "Cancel (remove) a bookmark. When no --flag-type is given, " + - "performs double-cancel: removes both message and feed layers", - Risk: "write", - UserScopes: flagWriteLookupScopes, - AuthTypes: []string{"user"}, - HasFormat: true, + Service: "im", + Command: "+flag-cancel", + Description: "Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer", + Risk: "write", + UserScopes: flagWriteLookupScopes, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "message-id", Desc: "message ID (om_xxx)"}, {Name: "item-type", Desc: "item type override: default|thread|msg_thread"}, diff --git a/shortcuts/im/im_flag_create.go b/shortcuts/im/im_flag_create.go index 9ed2cb399..9628434a4 100644 --- a/shortcuts/im/im_flag_create.go +++ b/shortcuts/im/im_flag_create.go @@ -16,7 +16,7 @@ import ( var ImFlagCreate = common.Shortcut{ Service: "im", Command: "+flag-create", - Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)", + Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode)", Risk: "write", UserScopes: flagWriteLookupScopes, AuthTypes: []string{"user"}, diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 3c8aadfbe..d1e8eba65 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -22,5 +22,7 @@ func Shortcuts() []common.Shortcut { ImFlagCreate, ImFlagCancel, ImFlagList, + ImFeedGroupListItem, + ImFeedGroupQueryItem, } } diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 0d127b244..77527781a 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -79,9 +79,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query | | [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key | | [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination | -| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message or thread; user-only; defaults to message-layer flag; feed-layer flag requires explicit --item-type + --flag-type | -| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, checks if the message is a thread root message; if so, cancels both message and feed layers | +| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) | +| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer | | [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination | +| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination | +| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id | ## API Resources @@ -134,6 +136,16 @@ lark-cli im [flags] # 调用 API - `delete` — 移除 Pin 消息。Identity: supports `user` and `bot`. - `list` — 获取群内 Pin 消息。Identity: supports `user` and `bot`. +### feed.groups + + - `batch_add_item` — Batch add feed cards to a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) + - `batch_query` — Batch query feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) + - `batch_remove_item` — Batch remove feed cards from a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) + - `create` — Create a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) + - `delete` — Delete a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) + - `list` — List feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) + - `update` — Update a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) + ## 权限表 | 方法 | 所需 scope | @@ -159,4 +171,11 @@ lark-cli im [flags] # 调用 API | `pins.create` | `im:message.pins:write_only` | | `pins.delete` | `im:message.pins:write_only` | | `pins.list` | `im:message.pins:read` | +| `feed.groups.batch_add_item` | `im:feed_group_v1:write` | +| `feed.groups.batch_query` | `im:feed_group_v1:read` | +| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` | +| `feed.groups.create` | `im:feed_group_v1:write` | +| `feed.groups.delete` | `im:feed_group_v1:write` | +| `feed.groups.list` | `im:feed_group_v1:read` | +| `feed.groups.update` | `im:feed_group_v1:write` | diff --git a/skills/lark-im/references/lark-im-feed-group-list-item.md b/skills/lark-im/references/lark-im-feed-group-list-item.md new file mode 100644 index 000000000..0fdfb5bbc --- /dev/null +++ b/skills/lark-im/references/lark-im-feed-group-list-item.md @@ -0,0 +1,67 @@ +# +feed-group-list-item + +> Shortcut for `lark-cli im +feed-group-list-item`. List the feed cards inside one feed group (tag), enriched with a readable `chat_name`. + +`+feed-group-list-item` wraps the raw `feed.groups.list_item` read API. Unlike the raw command, it resolves a human-readable `chat_name` for every feed card it returns: a v1 feed card's `feed_id` is always a chat ID (`oc_xxx`), so the shortcut issues a follow-up `POST /open-apis/im/v1/chats/batch_query` and injects `chat_name` into each entry of both `items[]` and `deleted_items[]`. + +## Identity + +User-only. Run with `--as user`. + +## Scopes + +Because chat-name resolution always runs, this shortcut needs **two** user scopes unconditionally: + +- `im:feed_group_v1:read` — to read the items +- `im:chat:read` — to resolve names + +If you only need the raw, un-enriched list with a single scope, use the raw `feed.groups.list_item` command instead — see [lark-im-feed-groups.md](lark-im-feed-groups.md). + +## Usage + +```bash +# First page, enriched with chat names +lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx + +# Auto-paginate through everything within a time window +lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx \ + --page-all --start-time 1767196800000 --end-time 1767200000000 +``` + +## Flags + +| Flag | Required | Description | +|---|---|---| +| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter | +| `--page-size` | No | Records per page, 1–50 (default 50) | +| `--page-token` | No | Continuation token for a specific page | +| `--page-all` | No | Auto-paginate and merge all pages | +| `--page-limit` | No | Max pages when `--page-all` is set, 1–1000 (default 20) | +| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) | +| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) | + +When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page). + +## Output + +JSON keeps the raw envelope and adds `chat_name` to each resolvable item: + +```json +{ + "items": [ + { "feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000", "chat_name": "Release Team" } + ], + "deleted_items": [ + { "feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000", "chat_name": "Old Channel" } + ], + "page_token": "", + "has_more": false +} +``` + +A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. + +## See also + +- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance +- [lark-im-feed-group-query-item.md](lark-im-feed-group-query-item.md) — look up specific feed cards by ID diff --git a/skills/lark-im/references/lark-im-feed-group-query-item.md b/skills/lark-im/references/lark-im-feed-group-query-item.md new file mode 100644 index 000000000..252df7f52 --- /dev/null +++ b/skills/lark-im/references/lark-im-feed-group-query-item.md @@ -0,0 +1,43 @@ +# +feed-group-query-item + +> Shortcut for `lark-cli im +feed-group-query-item`. Look up specific feed cards inside one feed group (tag) by ID, enriched with a readable `chat_name`. + +`+feed-group-query-item` wraps the raw `feed.groups.batch_query_item` read API. Unlike the raw command, it resolves a human-readable `chat_name` for every feed card it returns: a v1 feed card's `feed_id` is always a chat ID (`oc_xxx`), so the shortcut issues a follow-up `POST /open-apis/im/v1/chats/batch_query` and injects `chat_name` into each entry of both `items[]` and `deleted_items[]`. + +## Identity + +User-only. Run with `--as user`. + +## Scopes + +Because chat-name resolution always runs, this shortcut needs **two** user scopes unconditionally: + +- `im:feed_group_v1:read` — to read the items +- `im:chat:read` — to resolve names + +If you only need the raw, un-enriched result with a single scope, use the raw `feed.groups.batch_query_item` command instead — see [lark-im-feed-groups.md](lark-im-feed-groups.md). + +## Usage + +```bash +lark-cli im +feed-group-query-item --as user \ + --feed-group-id ofg_xxx --feed-id oc_a,oc_b +``` + +## Flags + +| Flag | Required | Description | +|---|---|---| +| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter | +| `--feed-id` | Yes | Comma-separated chat IDs (`oc_xxx`); `feed_type` is fixed to `chat` | + +## Output + +The command sends `{"items":[{"feed_id":"oc_a","feed_type":"chat"},{"feed_id":"oc_b","feed_type":"chat"}]}`, then enriches the response (`items[]` and `deleted_items[]`) with `chat_name` exactly as `+feed-group-list-item` does. There is no pagination for this method. + +A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. + +## See also + +- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance +- [lark-im-feed-group-list-item.md](lark-im-feed-group-list-item.md) — list all feed cards in a group (paginated) diff --git a/skills/lark-im/references/lark-im-feed-groups.md b/skills/lark-im/references/lark-im-feed-groups.md new file mode 100644 index 000000000..9d65e2b5a --- /dev/null +++ b/skills/lark-im/references/lark-im-feed-groups.md @@ -0,0 +1,599 @@ +# im feed.groups + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. + +This reference is the shared annotation target for the IM feed-group (tag) APIs: + +- `feed.groups.create` +- `feed.groups.update` +- `feed.groups.delete` +- `feed.groups.batch_query` +- `feed.groups.list` +- `feed.groups.batch_add_item` +- `feed.groups.batch_remove_item` +- `feed.groups.batch_query_item` +- `feed.groups.list_item` + +It focuses on: + +- What each method does +- The request / response shape you need when calling the raw API commands +- The enum surface (group types, rule conditions, feed card types) used in payloads + +> **Important:** All `feed.groups.*` methods are user-only (`user_access_token`). The raw commands take structured input through `--params ''` and `--data ''` rather than typed flags. The two read-item methods (`list_item`, `batch_query_item`) also have typed `+` shortcut wrappers that do expose flags like `--feed-group-id` and enrich each feed card with a readable `chat_name` — see the [Shortcuts](#shortcuts) section. + +## Command Overview + +| Method | HTTP | Path | Purpose | +|---|---|---|---| +| `feed.groups.create` | `POST` | `/open-apis/im/v1/groups` | Create a new feed group (tag) | +| `feed.groups.update` | `PUT` | `/open-apis/im/v1/groups/{feed_group_id}` | Update a feed group's name and/or rules | +| `feed.groups.delete` | `DELETE` | `/open-apis/im/v1/groups/{feed_group_id}` | Delete one feed group | +| `feed.groups.batch_query` | `POST` | `/open-apis/im/v1/groups/batch_query` | Look up feed groups by ID list | +| `feed.groups.list` | `GET` | `/open-apis/im/v1/groups` | List the caller's feed groups with optional time-range filter | +| `feed.groups.batch_add_item` | `POST` | `/open-apis/im/v1/groups/{feed_group_id}/batch_add_item` | Add feed cards (chats) into a feed group | +| `feed.groups.batch_remove_item` | `POST` | `/open-apis/im/v1/groups/{feed_group_id}/batch_remove_item` | Remove feed cards from a feed group | +| `feed.groups.batch_query_item` | `POST` | `/open-apis/im/v1/groups/{feed_group_id}/batch_query_item` | Look up feed cards inside a group by ID list | +| `feed.groups.list_item` | `GET` | `/open-apis/im/v1/groups/{feed_group_id}/list_item` | List feed cards inside one feed group | + +## Shortcuts + +The two read-item methods have typed `+` shortcut wrappers that also resolve a human-readable `chat_name` for every feed card they return (via a follow-up `chats/batch_query`). They are user-only and need both `im:feed_group_v1:read` and `im:chat:read`. See: + +- [`+feed-group-list-item`](lark-im-feed-group-list-item.md) — list feed cards in a group (paginated) +- [`+feed-group-query-item`](lark-im-feed-group-query-item.md) — look up specific feed cards by ID + +If you only need the raw, un-enriched lists with a single scope, use the raw `feed.groups.list_item` / `feed.groups.batch_query_item` commands documented below. + +## Common Notes + +- `feed_group_id` is the feed-group identifier returned by `create`, typically formatted as `ofg_xxx`. In meta examples it appears as a string; on the wire it is the group's stable ID. +- `feed_id` is the identifier of one feed card inside a group. In v1 only the `chat` feed card type is supported (see `feed_card_type` below), so `feed_id` is currently a chat ID such as `oc_xxx`. +- All `feed.groups.*` methods require `user_access_token`. Run with `--as user`; bot/tenant tokens are rejected. +- Read APIs (`batch_query`, `list`, `batch_query_item`, `list_item`) return **two parallel lists**: a live list (`groups[]` or `items[]`) and a soft-deleted list (`deleted_groups[]` or `deleted_items[]`). Consumers tracking incremental sync should consume both. +- Time-range fields (`start_time`, `end_time`, `update_time`) are Unix timestamps **in milliseconds**, encoded as decimal strings (e.g. `1767196800000`). +- Rule-based feed groups (`type=rule`) auto-populate from the rules declared in `feed_group_creator.rules`. Normal feed groups (`type=normal`) are managed explicitly via `batch_add_item` / `batch_remove_item`. + +> **Choose the simplest group that fits** — it keeps `create` / `update` fast and predictable. Apply these in order: +> 1. **Prefer `type=normal`.** When the target chats are known up front, set membership explicitly with `batch_add_item` / `batch_remove_item`. Use `type=rule` only when membership must be derived automatically. +> 2. **Keep the rule set smallest.** Use the fewest `rules[]` and `condition_items[]` that express the intent (one condition is ideal). This outranks the style rules below — never split a rule or add conditions just to satisfy them (e.g. one `match_any` rule beats two single-condition rules for "A or B"). +> 3. **Within that, make each condition precise.** Prefer positive, specific conditions (`is`, or `contain` with a distinctive keyword) over exclusion (`is_not`, `not_contain`) or broad keywords, which capture more than intended. For a multi-condition rule, prefer `match_all` (narrower) over `match_any` (wider). + +## Inspect Schema + +```bash +lark-cli schema im.feed.groups +lark-cli schema im.feed.groups.create --format pretty +lark-cli schema im.feed.groups.list --format pretty +lark-cli schema im.feed.groups.batch_add_item --format pretty +lark-cli schema im.feed.groups.list_item --format pretty +``` + +## create + +Create a new feed group. Returns the new `group_id` on success. + +> **Prefer `type=normal`.** Use `type=rule` only when membership must be derived automatically, and keep the rule set small and precise — see the guidance under [Common Notes](#common-notes). + +```bash +# Normal (empty) group +lark-cli im feed.groups create --as user \ + --data '{"feed_group_creator":{"type":"normal","name":"Releases"}}' + +# Rule-based group: auto-add p2p chats with "release" in their name +lark-cli im feed.groups create --as user \ + --data '{ + "feed_group_creator":{ + "type":"rule", + "name":"Auto: release chats", + "rules":{ + "rules":[ + { + "condition":{ + "match_type":"match_all", + "condition_items":[ + {"type":"chat_type","operator":"is","chat_type":"p2p"}, + {"type":"keyword","operator":"contain","keyword":"release"} + ] + }, + "action":"add" + } + ] + } + } + }' +``` + +### Request + +#### `--params` + +| Parameter | Required | Description | +|---|---|---| +| `user_id_type` | No | ID type used when the request body contains `user_id` references inside rules. One of `open_id`, `union_id`, `user_id` | + +#### `--data` + +| Field | Required | Description | +|---|---|---| +| `feed_group_creator.type` | Yes | `normal` (empty group) or `rule` (auto-populated by rules) | +| `feed_group_creator.name` | Yes | Display name, e.g. `"标签名称测试"` | +| `feed_group_creator.rules` | No | Rule object (required when `type=rule`). See `feed_group_rules` section below | + +### Response + +```json +{ + "group_id": "ofg_xxx" +} +``` + +## update + +Update a feed group's name and/or rules. The `update_fields` array tells the server which fields are being updated. + +> **Scope each update to what actually changed.** If you only need to rename, pass `update_fields:["name"]` so the rules are left untouched. When you do change rules, the same guidance under [Common Notes](#common-notes) applies to the resulting set. + +```bash +# Rename only +lark-cli im feed.groups update --as user \ + --params '{"feed_group_id":"ofg_xxx"}' \ + --data '{"feed_group_updater":{"name":"测试标签名称","update_fields":["name"]}}' + +# Replace rules only (rules array uses the feed_group_rules shape — see that section) +lark-cli im feed.groups update --as user \ + --params '{"feed_group_id":"ofg_xxx"}' \ + --data '{ + "feed_group_updater":{ + "rules":{"rules":[]}, + "update_fields":["rules"] + } + }' +``` + +### Request + +#### `--params` + +| Parameter | Required | Description | +|---|---|---| +| `feed_group_id` | Yes | Path parameter — the feed group to update | +| `user_id_type` | No | ID type for any `user_id` fields inside `rules` | + +#### `--data` + +| Field | Required | Description | +|---|---|---| +| `feed_group_updater.name` | No | New display name | +| `feed_group_updater.rules` | No | Replacement rule object. Same structure as `create.feed_group_creator.rules` | +| `feed_group_updater.update_fields` | No | Array of update markers: `name`, `rules`. Server applies only the listed fields | + +### Response + +Empty body on success. Inspect the CLI exit code for status. + +## delete + +Delete one feed group. + +```bash +lark-cli im feed.groups delete --as user \ + --params '{"feed_group_id":"ofg_xxx"}' +``` + +### Request + +| Parameter | Required | Description | +|---|---|---| +| `feed_group_id` | Yes | Path parameter — the feed group to delete | + +### Response + +Empty body on success. + +## batch_query + +Look up feed groups by an explicit list of IDs. Returns both live and soft-deleted matches. + +```bash +lark-cli im feed.groups batch_query --as user \ + --params '{"user_id_type":"open_id"}' \ + --data '{"group_ids":["ofg_xxx","ofg_yyy"]}' +``` + +### Request + +#### `--params` + +| Parameter | Required | Description | +|---|---|---| +| `user_id_type` | No | ID type used when the response includes `user_id` references inside `groups[].rules` | + +#### `--data` + +| Field | Required | Description | +|---|---|---| +| `group_ids` | Yes | Array of feed group IDs to look up | + +### Response + +```json +{ + "groups": [ + { + "group_id": "ofg_xxx", + "type": "normal", + "name": "test", + "rules": { "rules": [] } + } + ], + "deleted_groups": [ + { + "group_id": "ofg_yyy", + "type": "rule", + "name": "test", + "rules": { "rules": [] } + } + ] +} +``` + +Each `rules.rules[]` element follows the `feed_group_rules` shape — see that section for the full structure. + +### Top-Level Fields + +| Field | Type | Meaning | +|---|---|---| +| `groups` | `array` | Live feed groups for the requested IDs | +| `deleted_groups` | `array` | Soft-deleted matches, returned for incremental-sync clients | + +Each element carries `group_id`, `type`, `name`, and (when defined) `rules`. + +## list + +List the caller's feed groups, optionally filtered by an update-time window and paginated. + +```bash +# First page +lark-cli im feed.groups list --as user + +# With pagination + time range +lark-cli im feed.groups list --as user \ + --params '{ + "page_size":50, + "page_token":"", + "start_time":"1767196800000", + "end_time":"1767200000000", + "user_id_type":"open_id" + }' +``` + +### Request (`--params`) + +| Parameter | Required | Description | +|---|---|---| +| `page_token` | No | Continuation token returned by the previous page | +| `page_size` | No | Records per page | +| `start_time` | No | Update-time window start (Unix milliseconds as a decimal string) | +| `end_time` | No | Update-time window end (Unix milliseconds as a decimal string) | +| `user_id_type` | No | ID type used when the response includes `user_id` references inside `groups[].rules` | + +### Response + +```json +{ + "groups": [ + { + "group_id": "ofg_xxx", + "type": "normal", + "name": "test", + "rules": { "rules": [] } + } + ], + "deleted_groups": [ + { "group_id": "ofg_yyy", "type": "rule", "name": "test", "rules": { "rules": [] } } + ], + "page_token": "YhljsPiGfUgnVAg9urvRFd-BvSqRLxxxx", + "has_more": true +} +``` + +`rules.rules[]` elements follow the `feed_group_rules` shape — see that section. + +| Field | Type | Meaning | +|---|---|---| +| `groups` | `array` | Live feed groups in this page | +| `deleted_groups` | `array` | Soft-deleted feed groups in this page | +| `page_token` | `string` | Token for the next page when `has_more=true` | +| `has_more` | `boolean` | Whether more pages exist | + +## batch_add_item + +Add feed cards (chats) into one feed group. Partial failures are reported in `failed_items`. + +```bash +lark-cli im feed.groups batch_add_item --as user \ + --params '{"feed_group_id":"ofg_xxx"}' \ + --data '{ + "items":[ + {"feed_id":"oc_xxx","feed_type":"chat"}, + {"feed_id":"oc_yyy","feed_type":"chat"} + ] + }' +``` + +### Request + +| Source | Field | Required | Description | +|---|---|---|---| +| `--params` | `feed_group_id` | Yes | Path parameter — the target feed group | +| `--data` | `items[]` | Yes | Array of feed cards to add | +| `--data` | `items[].feed_id` | No | The chat ID to add (e.g. `oc_xxx`) | +| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. | + +> Note: `items[].feed_id` is marked `Required: No` in the meta but every element of `items` must set it — a missing field yields an unusable entry. Always pass `{"feed_id": "oc_xxx", "feed_type": "chat"}` per item. + +### Response + +```json +{ + "failed_items": [ + { + "item": { "feed_id": "oc_xxx", "feed_type": "chat" }, + "error_code": 240001, + "error_message": "feed_id is invalid" + } + ] +} +``` + +| Field | Type | Meaning | +|---|---|---| +| `failed_items` | `array` | Items that failed; absent or empty means all succeeded | +| `failed_items[].item` | `object` | The original `{feed_id, feed_type}` element | +| `failed_items[].error_code` | `integer` | Numeric error code | +| `failed_items[].error_message` | `string` | Human-readable failure reason | + +## batch_remove_item + +Remove feed cards from one feed group. Same request and response shape as `batch_add_item`. + +```bash +lark-cli im feed.groups batch_remove_item --as user \ + --params '{"feed_group_id":"ofg_xxx"}' \ + --data '{ + "items":[ + {"feed_id":"oc_xxx","feed_type":"chat"} + ] + }' +``` + +### Request + +| Source | Field | Required | Description | +|---|---|---|---| +| `--params` | `feed_group_id` | Yes | Path parameter — the target feed group | +| `--data` | `items[]` | Yes | Array of feed cards to remove | +| `--data` | `items[].feed_id` | No | The chat ID to remove | +| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. | + +> Note: same caveat as `batch_add_item` — `items[].feed_id` is `Required: No` per the meta but must be present in practice. + +### Response + +Identical shape to `batch_add_item` — `failed_items[]` lists rows that did not remove cleanly. + +## batch_query_item + +Look up feed cards inside one feed group by an explicit list of IDs. + +```bash +lark-cli im feed.groups batch_query_item --as user \ + --params '{"feed_group_id":"ofg_xxx"}' \ + --data '{ + "items":[ + {"feed_id":"oc_xxx","feed_type":"chat"}, + {"feed_id":"oc_yyy","feed_type":"chat"} + ] + }' +``` + +### Request + +| Source | Field | Required | Description | +|---|---|---|---| +| `--params` | `feed_group_id` | Yes | Path parameter — the feed group to look in | +| `--data` | `items[]` | Yes | Array of feed cards to query | +| `--data` | `items[].feed_id` | No | The chat ID to query | +| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. | + +> Note: same caveat as `batch_add_item` — `items[].feed_id` is `Required: No` per the meta but must be present in practice. + +### Response + +```json +{ + "items": [ + { "feed_id": "oc_xxx", "feed_type": "chat", "update_time": "1767196800000" } + ], + "deleted_items": [ + { "feed_id": "oc_yyy", "feed_type": "chat", "update_time": "1767196800000" } + ] +} +``` + +| Field | Type | Meaning | +|---|---|---| +| `items` | `array` | Live feed cards matching the query | +| `deleted_items` | `array` | Soft-deleted matches | +| `items[].update_time` | `string` | Last update timestamp (Unix milliseconds as a decimal string) | + +## list_item + +List feed cards inside one feed group, with optional update-time window and pagination. + +```bash +lark-cli im feed.groups list_item --as user \ + --params '{ + "feed_group_id":"ofg_xxx", + "page_size":50, + "page_token":"", + "start_time":"1767196800000", + "end_time":"1767200000000" + }' +``` + +### Request (`--params`) + +| Parameter | Required | Description | +|---|---|---| +| `feed_group_id` | Yes | Path parameter — the feed group to list | +| `page_token` | No | Continuation token returned by the previous page | +| `page_size` | No | Records per page | +| `start_time` | No | Update-time window start (Unix milliseconds as a decimal string) | +| `end_time` | No | Update-time window end (Unix milliseconds as a decimal string) | + +### Response + +```json +{ + "items": [ + { "feed_id": "oc_xxx", "feed_type": "chat", "update_time": "1767196800000" } + ], + "deleted_items": [ + { "feed_id": "oc_yyy", "feed_type": "chat", "update_time": "1767196800000" } + ], + "page_token": "YhljsPiGfUgnVAg9urvRFd-BvSqRLxxxx", + "has_more": true +} +``` + +| Field | Type | Meaning | +|---|---|---| +| `items` | `array` | Live feed cards in this page | +| `deleted_items` | `array` | Soft-deleted feed cards in this page | +| `page_token` | `string` | Token for the next page when `has_more=true` | +| `has_more` | `boolean` | Whether more pages exist | + +## Enums + +The enums below are sourced from the internal datasync IDL (`lark.im.datasync.open.thrift`). All values listed here are exhaustive. + +### `feed_group_type` + +Used in `feed_group_creator.type` and the response `groups[].type`. + +- `normal` — empty group; members managed explicitly via `batch_add_item` / `batch_remove_item`. +- `rule` — auto-populated; `feed_group_creator.rules` must be supplied. + +### `feed_card_type` + +Used in `items[].feed_type` everywhere a feed card appears. Wire type is the open string alias `FeedCardTypeV1`. + +- `chat` — the only value the v1 OAPI service accepts. `feed_id` is therefore a chat ID such as `oc_xxx`. + +The CLI does not pre-validate this field — passing anything other than `chat` reaches the server and is rejected at runtime. Treat `chat` as effectively required. + +### `feed_group_rule_action` + +Used inside `feed_group_rules.rules[].action`. + +- `add` — when the condition matches, add the matching feed into this group. +- `remove` — when the condition matches, remove the matching feed from this group. + +### `feed_group_rule_cond_match_type` + +Used inside `feed_group_rules.rules[].condition.match_type`. + +- `match_all` — every condition item must match. +- `match_any` — at least one condition item must match. + +### `feed_group_rule_cond_item_type` + +Used inside `feed_group_rules.rules[].condition.condition_items[].type`. Determines which sibling field of the item is consulted. + +- `keyword` — match against a keyword; consult the `keyword` field. +- `chatter` — match against a user; consult the `user_id` field (interpreted per the request's `user_id_type`). +- `chat_type` — match against a chat type; consult the `chat_type` field. + +### `feed_group_rule_cond_item_operator` + +Used inside `feed_group_rules.rules[].condition.condition_items[].operator`. Typically paired with the relevant `type`: + +- `contain` — substring match; typically paired with `keyword`. +- `not_contain` — substring non-match; typically paired with `keyword`. +- `is` — equality; typically paired with `chatter` or `chat_type`. +- `is_not` — non-equality; typically paired with `chatter` or `chat_type`. + +### `feed_group_rule_cond_item_chat_type` + +Used inside `feed_group_rules.rules[].condition.condition_items[].chat_type` when `type=chat_type`. + +- `p2p` +- `group` +- `thread_group` +- `helpdesk` +- `bot` +- `mute` +- `flag` +- `cross_tenant` +- `any` + +### `update_fields` + +Used inside `feed_group_updater.update_fields`. Multiple values may be listed. + +- `name` — update name only. +- `rules` — update rules only. + +Wire form: lowercase strings (`"name"`, `"rules"`), even though the underlying IDL defines an integer enum (`FeedGroupUpdateField`). Omit the array (or pass an empty array) to make no field updates. + +## feed_group_rules + +The same nested object is used in `feed_group_creator.rules` (create), `feed_group_updater.rules` (update), and in read responses under `groups[].rules`. Shape: + +```json +{ + "rules": [ + { + "condition": { + "match_type": "match_all", + "condition_items": [ + { "type": "chat_type", "operator": "is", "chat_type": "group" }, + { "type": "keyword", "operator": "contain", "keyword": "release" } + ] + }, + "action": "add" + } + ] +} +``` + +Per-`type` required-field legend: + +- `type=keyword` → `keyword` is required; `user_id` and `chat_type` are ignored. +- `type=chatter` → `user_id` is required; the request's `user_id_type` query parameter tells the server how to interpret it. +- `type=chat_type` → `chat_type` is required. + +## Permissions + +| Method | Scope | +|---|---| +| `feed.groups.create` | `im:feed_group_v1:write` | +| `feed.groups.update` | `im:feed_group_v1:write` | +| `feed.groups.delete` | `im:feed_group_v1:write` | +| `feed.groups.batch_query` | `im:feed_group_v1:read` | +| `feed.groups.list` | `im:feed_group_v1:read` | +| `feed.groups.batch_add_item` | `im:feed_group_v1:write` | +| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` | +| `feed.groups.batch_query_item` | `im:feed_group_v1:read` | +| `feed.groups.list_item` | `im:feed_group_v1:read` | + +If a required scope is missing, the CLI surfaces a hint such as `lark-cli auth login --scope "im:feed_group_v1:write"`. + +## References + +- [lark-im](../SKILL.md) — all IM commands +- [lark-shared](../../lark-shared/SKILL.md) — authentication and global parameters +- Design wiki: `https://bytedance.larkoffice.com/wiki/LIdSwrCzaitg3MkH8oScLhBCnFQ` +- IDL source (internal): `lark.im.datasync.open.thrift`