diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index f75119219..ee0f54bb3 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -1405,3 +1405,302 @@ func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemTy } return ItemTypeMsgThread, nil } + +// ShortcutType enumerates the OpenAPI feed-shortcut types. +// Currently the server only opens CHAT (1) externally; other internal values +// (DOC, OPENAPP, etc.) are not yet whitelisted on the OAPI gateway. +type ShortcutType int + +const ( + ShortcutTypeUnknown ShortcutType = 0 + ShortcutTypeChat ShortcutType = 1 +) + +const ( + feedShortcutBatchLimit = 10 + feedShortcutWriteScope = "im:feed.shortcut:write" + feedShortcutReadScope = "im:feed.shortcut:read" +) + +// shortcutItem is one entry in the feed_shortcuts API body. +type shortcutItem struct { + FeedCardID string `json:"feed_card_id"` + Type int `json:"type"` +} + +// collectChatIDs reads --chat-id values (repeatable + comma-split) and +// returns deduped, validated oc_ IDs. The server batch limit is 10. +func collectChatIDs(rt *common.RuntimeContext) ([]string, error) { + raw := rt.StrSlice("chat-id") + if len(raw) == 0 { + return nil, output.ErrValidation("--chat-id is required (oc_xxx); repeat the flag or pass comma-separated values") + } + + seen := make(map[string]struct{}, len(raw)) + out := make([]string, 0, len(raw)) + for _, v := range raw { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if !strings.HasPrefix(v, "oc_") { + return nil, output.ErrValidation( + "invalid --chat-id %q: must be an open_chat_id starting with oc_", v) + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + if len(out) == 0 { + return nil, output.ErrValidation("--chat-id is required (oc_xxx)") + } + if len(out) > feedShortcutBatchLimit { + return nil, output.ErrValidation( + "too many --chat-id values (%d); the server accepts up to %d per request", + len(out), feedShortcutBatchLimit) + } + return out, nil +} + +// buildShortcutItems converts chat IDs to API payload entries (type=CHAT). +func buildShortcutItems(ids []string) []shortcutItem { + items := make([]shortcutItem, 0, len(ids)) + for _, id := range ids { + items = append(items, shortcutItem{FeedCardID: id, Type: int(ShortcutTypeChat)}) + } + return items +} + +// shortcutFailedReasonString converts the numeric failed-reason enum returned +// by the server into a human-readable label. Used to enrich the response +// when the API reports per-item failures. +func shortcutFailedReasonString(reason int) string { + switch reason { + case 0: + return "unknown" + case 1: + return "no_permission" + case 2: + return "invalid_item" + case 3: + return "has_pending_delete" + case 4: + return "type_not_support" + case 5: + return "internal_error" + } + return "unknown" +} + +// chatBatchQueryScope is the scope required by im.chats.batch_query, which +// the CHAT detail resolver depends on. Surfaced as a conditional scope on +// +feed-shortcut-list so the framework's scope diagnostics know about it. +const chatBatchQueryScope = "im:chat:read" + +// chatBatchQuerySize matches the server-side limit on /im/v1/chats/batch_query. +const chatBatchQuerySize = 50 + +// shortcutTypeFromValue parses the type field as returned by the v2 +// feed_shortcuts API. JSON numbers come back as float64 after generic +// unmarshal; we also tolerate the int form for forward-compat. +func shortcutTypeFromValue(v any) ShortcutType { + switch n := v.(type) { + case float64: + return ShortcutType(int(n)) + case int: + return ShortcutType(n) + } + return ShortcutTypeUnknown +} + +// queryChatBatch fetches one im.chats.batch_query page (at most +// chatBatchQuerySize ids) and merges the full chat objects into dst keyed by +// chat_id. Shared by feed-shortcut detail enrichment and message-search chat +// context lookup, which apply their own per-chunk error policies. +func queryChatBatch(rt *common.RuntimeContext, batch []string, dst map[string]map[string]any) error { + res, err := rt.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/chats/batch_query", + larkcore.QueryParams{"user_id_type": []string{"open_id"}}, + map[string]any{"chat_ids": batch}) + if err != nil { + return err + } + items, _ := res["items"].([]any) + for _, ci := range items { + cm, _ := ci.(map[string]any) + if cm == nil { + continue + } + if id := asString(cm["chat_id"]); id != "" { + dst[id] = cm + } + } + return nil +} + +// resolveChatDetail batch-fetches the full chat object via +// im.chats.batch_query (50 ids per request — server limit) and returns the +// objects keyed by chat_id, verbatim, so the caller can decide which fields +// to surface. The server's `name` field is empty for p2p chats (client UI +// shows the partner's display name there), but the full object still carries +// `chat_mode`, `p2p_target_id`, `description`, etc., so callers can render +// p2p entries however they want. +func resolveChatDetail(rt *common.RuntimeContext, ids []string) (map[string]map[string]any, error) { + out := map[string]map[string]any{} + if len(ids) == 0 { + return out, nil + } + if err := checkFlagRequiredScopes(rt.Ctx(), rt, []string{chatBatchQueryScope}); err != nil { + return nil, err + } + for _, batch := range chunkStrings(ids, chatBatchQuerySize) { + if err := queryChatBatch(rt, batch, out); err != nil { + return nil, err + } + } + return out, nil +} + +// enrichFeedShortcutDetail walks the list response and attaches the full chat +// object under `detail` for CHAT-type entries — the only type the OpenAPI +// gateway exposes today. Mutates data in place. +// +// Failures are returned to the caller so it can decide whether to hard-fail +// the command or downgrade to a warning. Listing the shortcuts succeeds even +// if enrichment is unavailable (missing scope, network error, etc.). +func enrichFeedShortcutDetail(rt *common.RuntimeContext, data map[string]any) error { + items, _ := data["shortcuts"].([]any) + if len(items) == 0 { + return nil + } + + seen := map[string]struct{}{} + ids := make([]string, 0, len(items)) + for _, it := range items { + m, _ := it.(map[string]any) + if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat { + continue + } + id := asString(m["feed_card_id"]) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil + } + + details, err := resolveChatDetail(rt, ids) + if err != nil { + return err + } + + // Missing items (server didn't return one for an id we asked about) are + // left untouched, so the presence of `detail` signals a successful lookup. + for _, it := range items { + m, _ := it.(map[string]any) + if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat { + continue + } + if info, ok := details[asString(m["feed_card_id"])]; ok { + m["detail"] = info + } + } + return nil +} + +// annotateFailedShortcuts walks the API response and attaches a +// reason_label string next to each numeric reason. Mutates data in place. +func annotateFailedShortcuts(data map[string]any) { + items, ok := data["failed_shortcuts"].([]any) + if !ok { + return + } + for _, it := range items { + m, _ := it.(map[string]any) + if m == nil { + continue + } + // reason is serialized as a JSON number → float64 after generic unmarshal. + switch r := m["reason"].(type) { + case float64: + m["reason_label"] = shortcutFailedReasonString(int(r)) + case int: + m["reason_label"] = shortcutFailedReasonString(r) + } + } +} + +// emitFeedShortcutWriteResult preserves the server payload while adding a +// batch ledger. A feed-shortcut write can return HTTP/API success with +// failed_shortcuts populated; callers still need a complete account of which +// requested entries succeeded and which failed. +func emitFeedShortcutWriteResult(rt *common.RuntimeContext, requested []shortcutItem, data map[string]any) error { + // A fully-successful write can come back as code:0 with data:null, in + // which case DoAPIJSON hands us a nil map; the caller is still owed a + // ledger, so start from an empty object instead of panicking on write. + if data == nil { + data = map[string]any{} + } + annotateFailedShortcuts(data) + addFeedShortcutWriteLedger(data, requested) + if hasFailedShortcuts(data) { + return rt.OutPartialFailure(data, nil) + } + rt.Out(data, nil) + return nil +} + +func addFeedShortcutWriteLedger(data map[string]any, requested []shortcutItem) { + failed := failedShortcutItems(data) + // Failed entries are matched back to requested items by feed_card_id + // alone: every requested item is CHAT-type, so the id is the identity, + // and a failed echo with a missing or zero type still excludes its item + // from the success list. + failedIDs := map[string]struct{}{} + for _, it := range failed { + m, _ := it.(map[string]any) + if m == nil { + continue + } + shortcut, _ := m["shortcut"].(map[string]any) + if shortcut == nil { + continue + } + if id := asString(shortcut["feed_card_id"]); id != "" { + failedIDs[id] = struct{}{} + } + } + + succeeded := make([]shortcutItem, 0, len(requested)) + for _, it := range requested { + if _, isFailed := failedIDs[it.FeedCardID]; isFailed { + continue + } + succeeded = append(succeeded, it) + } + + // Counts are derived from the requested-item accounting alone so the + // success+failure==total invariant holds even if the server echoes a + // failed entry twice or reports one we never asked about; + // failed_shortcuts still carries the raw server report. + data["total"] = len(requested) + data["success_count"] = len(succeeded) + data["failure_count"] = len(requested) - len(succeeded) + data["succeeded_shortcuts"] = succeeded +} + +func hasFailedShortcuts(data map[string]any) bool { + return len(failedShortcutItems(data)) > 0 +} + +func failedShortcutItems(data map[string]any) []any { + items, _ := data["failed_shortcuts"].([]any) + return items +} diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 3df5c3b54..eff32d0d5 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -606,6 +606,9 @@ func TestShortcuts(t *testing.T) { "+flag-create", "+flag-cancel", "+flag-list", + "+feed-shortcut-create", + "+feed-shortcut-remove", + "+feed-shortcut-list", } if !reflect.DeepEqual(commands, want) { t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want) diff --git a/shortcuts/im/im_feed_shortcut_create.go b/shortcuts/im/im_feed_shortcut_create.go new file mode 100644 index 000000000..3ff4a95ea --- /dev/null +++ b/shortcuts/im/im_feed_shortcut_create.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// ImFeedShortcutCreate provides the +feed-shortcut-create shortcut for adding +// chats to the user's feed shortcuts. Currently only CHAT-type shortcuts are +// exposed by the OpenAPI gateway; feed_card_id must be an open_chat_id +// (oc_xxx). +var ImFeedShortcutCreate = common.Shortcut{ + Service: "im", + Command: "+feed-shortcut-create", + Description: "Add chats to the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; --head/--tail controls insertion order", + Risk: "write", + UserScopes: []string{feedShortcutWriteScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + // chat-id is mandatory but intentionally not cobra-Required: the + // requiredness check lives in collectChatIDs so a missing flag is + // reported through the structured validation envelope (exit 2) + // instead of cobra's plain-text error. + {Name: "chat-id", Type: "string_slice", + Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"}, + {Name: "head", Type: "bool", + Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"}, + {Name: "tail", Type: "bool", + Desc: "append at the bottom of the shortcut list; mutually exclusive with --head"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := collectChatIDs(runtime); err != nil { + return err + } + _, err := resolveIsHeader(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ids, err := collectChatIDs(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + isHeader, err := resolveIsHeader(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/im/v2/feed_shortcuts"). + Body(map[string]any{ + "shortcuts": buildShortcutItems(ids), + "is_header": isHeader, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ids, err := collectChatIDs(runtime) + if err != nil { + return err + } + isHeader, err := resolveIsHeader(runtime) + if err != nil { + return err + } + items := buildShortcutItems(ids) + data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v2/feed_shortcuts", nil, + map[string]any{ + "shortcuts": items, + "is_header": isHeader, + }) + if err != nil { + return err + } + return emitFeedShortcutWriteResult(runtime, items, data) + }, +} + +// resolveIsHeader determines the insertion position. +// - default (neither flag set) → true (head) +// - --head → true +// - --tail → false +// - both set → error +func resolveIsHeader(rt *common.RuntimeContext) (bool, error) { + head := rt.Bool("head") + tail := rt.Bool("tail") + if head && tail { + return false, output.ErrValidation("--head and --tail are mutually exclusive") + } + if tail { + return false, nil + } + return true, nil +} diff --git a/shortcuts/im/im_feed_shortcut_list.go b/shortcuts/im/im_feed_shortcut_list.go new file mode 100644 index 000000000..43c981689 --- /dev/null +++ b/shortcuts/im/im_feed_shortcut_list.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// ImFeedShortcutList provides the +feed-shortcut-list shortcut for listing +// the user's feed shortcuts. The server-controlled page size covers the full +// list in practice, but pagination is version-locked: when the list changes +// between calls the server rejects the stale token and the caller has to +// restart by omitting --page-token. +// +// The shortcut is a thin one-page wrapper — there is no automatic walking. +// Callers are expected to drive their own loop when they actually need to +// paginate, because the version-lock means each page is a real checkpoint +// that the caller must consciously decide what to do with on failure. +var ImFeedShortcutList = common.Shortcut{ + Service: "im", + Command: "+feed-shortcut-list", + Description: "List one page of the user's feed shortcuts; user-only; first call omits --page-token, subsequent calls pass the previous response's page_token; each entry is auto-enriched with the full per-type info object attached as `detail` (pass --no-detail to skip)", + Risk: "read", + UserScopes: []string{feedShortcutReadScope}, + ConditionalUserScopes: []string{chatBatchQueryScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "page-token", + Desc: "opaque pagination token from the previous response; omit for the first page. If a token is rejected because the list changed, restart by omitting it."}, + {Name: "no-detail", Type: "bool", + Desc: "skip fetching the full info object for each shortcut (default: enrichment enabled — CHAT-type entries call im.chats.batch_query, require im:chat:read, and attach the object under the detail field)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + d := common.NewDryRunAPI(). + GET("/open-apis/im/v2/feed_shortcuts") + if token := runtime.Str("page-token"); token != "" { + d.Params(map[string]any{"page_token": token}) + } + if !runtime.Bool("no-detail") { + d.Desc("conditional enrichment: if CHAT-type entries exist, execution also calls POST /open-apis/im/v1/chats/batch_query and requires scope im:chat:read; pass --no-detail to skip this extra call and extra scope") + } + return d + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v2/feed_shortcuts", + feedShortcutListQuery(runtime.Str("page-token")), nil) + if err != nil { + return err + } + if !runtime.Bool("no-detail") { + if err := enrichFeedShortcutDetail(runtime, data); err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "warning: detail enrichment failed: %v\n", err) + // Mirror the warning into the data payload so stdout-only + // consumers can tell "enrichment skipped" from "nothing to + // enrich" (same convention as mail's data-level _notice). + if data != nil { + data["_notice"] = fmt.Sprintf("detail enrichment skipped: %v", err) + } + } + } + runtime.Out(data, nil) + return nil + }, +} + +// feedShortcutListQuery omits the page_token key entirely when the token is +// empty, so the server treats the call as a first-page request. +func feedShortcutListQuery(token string) larkcore.QueryParams { + if token == "" { + return larkcore.QueryParams{} + } + return larkcore.QueryParams{"page_token": []string{token}} +} diff --git a/shortcuts/im/im_feed_shortcut_remove.go b/shortcuts/im/im_feed_shortcut_remove.go new file mode 100644 index 000000000..21534fcd9 --- /dev/null +++ b/shortcuts/im/im_feed_shortcut_remove.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ImFeedShortcutRemove provides the +feed-shortcut-remove shortcut for +// removing chats from the user's feed shortcuts. Per-item failures are kept +// in stdout and returned as a partial-failure exit. +var ImFeedShortcutRemove = common.Shortcut{ + Service: "im", + Command: "+feed-shortcut-remove", + Description: "Remove chats from the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; per-item failures return ok:false with failed_shortcuts", + Risk: "write", + UserScopes: []string{feedShortcutWriteScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + // chat-id is mandatory but intentionally not cobra-Required: the + // requiredness check lives in collectChatIDs so a missing flag is + // reported through the structured validation envelope (exit 2) + // instead of cobra's plain-text error. + {Name: "chat-id", Type: "string_slice", + Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := collectChatIDs(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ids, err := collectChatIDs(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/im/v2/feed_shortcuts/remove"). + Body(map[string]any{"shortcuts": buildShortcutItems(ids)}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ids, err := collectChatIDs(runtime) + if err != nil { + return err + } + items := buildShortcutItems(ids) + data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil, + map[string]any{"shortcuts": items}) + if err != nil { + return err + } + return emitFeedShortcutWriteResult(runtime, items, data) + }, +} diff --git a/shortcuts/im/im_feed_shortcut_test.go b/shortcuts/im/im_feed_shortcut_test.go new file mode 100644 index 000000000..fad51359d --- /dev/null +++ b/shortcuts/im/im_feed_shortcut_test.go @@ -0,0 +1,1092 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// newFeedShortcutCreateCmd builds a cobra.Command pre-wired with the flags +// ImFeedShortcutCreate registers at runtime. Mirrors the helper used by other +// shortcut tests so tests can exercise the typed Bool/StrSlice accessors. +func newFeedShortcutCreateCmd(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringSlice("chat-id", nil, "") + cmd.Flags().Bool("head", false, "") + cmd.Flags().Bool("tail", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + return cmd +} + +func newFeedShortcutRemoveCmd(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringSlice("chat-id", nil, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + return cmd +} + +func newFeedShortcutListCmd(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("page-token", "", "") + // Default true (skip enrichment) in tests so non-enrichment-focused tests + // don't trigger the batch_query path; tests that exercise detail + // enrichment flip this off. + cmd.Flags().Bool("no-detail", true, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + return cmd +} + +func TestCollectChatIDs(t *testing.T) { + tests := []struct { + name string + input []string + want []string + wantErr bool + errSubstr string + }{ + {name: "single id", input: []string{"oc_abc"}, want: []string{"oc_abc"}}, + {name: "two repeated flags", input: []string{"oc_abc", "oc_def"}, want: []string{"oc_abc", "oc_def"}}, + // StringSlice handles comma splitting itself, but extra whitespace and + // duplicates should still be normalized inside collectChatIDs. + {name: "trims whitespace", input: []string{" oc_abc ", "oc_def"}, want: []string{"oc_abc", "oc_def"}}, + {name: "dedupes", input: []string{"oc_abc", "oc_abc", "oc_def"}, want: []string{"oc_abc", "oc_def"}}, + {name: "rejects empty list", input: nil, wantErr: true, errSubstr: "--chat-id is required"}, + {name: "rejects bad prefix", input: []string{"om_abc"}, wantErr: true, errSubstr: "must be an open_chat_id"}, + { + name: "rejects over limit", + input: []string{ + "oc_1", "oc_2", "oc_3", "oc_4", "oc_5", + "oc_6", "oc_7", "oc_8", "oc_9", "oc_10", "oc_11", + }, + wantErr: true, + errSubstr: "too many --chat-id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newFeedShortcutCreateCmd(t) + for _, v := range tt.input { + if err := cmd.Flags().Set("chat-id", v); err != nil { + t.Fatalf("Set chat-id %q error = %v", v, err) + } + } + runtime := &common.RuntimeContext{Cmd: cmd} + + got, err := collectChatIDs(runtime) + if tt.wantErr { + if err == nil { + t.Fatalf("collectChatIDs() expected error, got nil") + } + if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Fatalf("collectChatIDs() error = %q, want substring %q", err.Error(), tt.errSubstr) + } + return + } + if err != nil { + t.Fatalf("collectChatIDs() unexpected error: %v", err) + } + if strings.Join(got, ",") != strings.Join(tt.want, ",") { + t.Fatalf("collectChatIDs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBuildShortcutItems(t *testing.T) { + got := buildShortcutItems([]string{"oc_a", "oc_b"}) + if len(got) != 2 { + t.Fatalf("buildShortcutItems() len = %d, want 2", len(got)) + } + for i, it := range got { + if it.Type != int(ShortcutTypeChat) { + t.Fatalf("item %d type = %d, want %d", i, it.Type, ShortcutTypeChat) + } + } + if got[0].FeedCardID != "oc_a" || got[1].FeedCardID != "oc_b" { + t.Fatalf("buildShortcutItems() ids = %+v, want oc_a,oc_b", got) + } +} + +func TestShortcutFailedReasonString(t *testing.T) { + tests := []struct { + reason int + want string + }{ + {0, "unknown"}, + {1, "no_permission"}, + {2, "invalid_item"}, + {3, "has_pending_delete"}, + {4, "type_not_support"}, + {5, "internal_error"}, + {99, "unknown"}, + } + for _, tt := range tests { + if got := shortcutFailedReasonString(tt.reason); got != tt.want { + t.Fatalf("shortcutFailedReasonString(%d) = %q, want %q", tt.reason, got, tt.want) + } + } +} + +func TestAnnotateFailedShortcuts(t *testing.T) { + data := map[string]any{ + "failed_shortcuts": []any{ + map[string]any{"reason": float64(1)}, + map[string]any{"reason": float64(4)}, + map[string]any{"other": "field"}, // no reason field — should be left alone + }, + } + annotateFailedShortcuts(data) + + items := data["failed_shortcuts"].([]any) + if got := items[0].(map[string]any)["reason_label"]; got != "no_permission" { + t.Fatalf("item 0 reason_label = %v, want no_permission", got) + } + if got := items[1].(map[string]any)["reason_label"]; got != "type_not_support" { + t.Fatalf("item 1 reason_label = %v, want type_not_support", got) + } + if _, ok := items[2].(map[string]any)["reason_label"]; ok { + t.Fatalf("item 2 should not have reason_label set") + } +} + +func TestHasFailedShortcuts(t *testing.T) { + if hasFailedShortcuts(map[string]any{}) { + t.Fatalf("missing failed_shortcuts should not count as failure") + } + if hasFailedShortcuts(map[string]any{"failed_shortcuts": []any{}}) { + t.Fatalf("empty failed_shortcuts should not count as failure") + } + if !hasFailedShortcuts(map[string]any{"failed_shortcuts": []any{map[string]any{"reason": float64(2)}}}) { + t.Fatalf("non-empty failed_shortcuts should count as failure") + } +} + +func TestAddFeedShortcutWriteLedger(t *testing.T) { + data := map[string]any{ + "failed_shortcuts": []any{ + map[string]any{ + "reason": float64(2), + "shortcut": map[string]any{ + "feed_card_id": "oc_b", + "type": float64(1), + }, + }, + }, + } + addFeedShortcutWriteLedger(data, []shortcutItem{ + {FeedCardID: "oc_a", Type: int(ShortcutTypeChat)}, + {FeedCardID: "oc_b", Type: int(ShortcutTypeChat)}, + }) + + if data["total"] != 2 || data["success_count"] != 1 || data["failure_count"] != 1 { + t.Fatalf("ledger counts = total:%v success:%v failure:%v", + data["total"], data["success_count"], data["failure_count"]) + } + succeeded := data["succeeded_shortcuts"].([]shortcutItem) + if len(succeeded) != 1 || succeeded[0].FeedCardID != "oc_a" { + t.Fatalf("succeeded_shortcuts = %+v, want only oc_a", succeeded) + } +} + +func TestAddFeedShortcutWriteLedgerFailedEchoMissingType(t *testing.T) { + // A failed echo whose shortcut omits `type` (or sends 0) must still + // exclude its item from the success list: matching is by feed_card_id + // alone, so the ledger invariant success+failure==total holds. + data := map[string]any{ + "failed_shortcuts": []any{ + map[string]any{ + "reason": float64(4), + "shortcut": map[string]any{"feed_card_id": "oc_b"}, + }, + }, + } + addFeedShortcutWriteLedger(data, []shortcutItem{ + {FeedCardID: "oc_a", Type: int(ShortcutTypeChat)}, + {FeedCardID: "oc_b", Type: int(ShortcutTypeChat)}, + }) + + if data["total"] != 2 || data["success_count"] != 1 || data["failure_count"] != 1 { + t.Fatalf("ledger counts = total:%v success:%v failure:%v, want 2/1/1", + data["total"], data["success_count"], data["failure_count"]) + } + succeeded := data["succeeded_shortcuts"].([]shortcutItem) + if len(succeeded) != 1 || succeeded[0].FeedCardID != "oc_a" { + t.Fatalf("succeeded_shortcuts = %+v, want only oc_a", succeeded) + } +} + +func TestAddFeedShortcutWriteLedgerDuplicateFailedEcho(t *testing.T) { + // A server that echoes the same failed shortcut twice must not break the + // success+failure==total invariant: counts derive from requested-item + // accounting, while failed_shortcuts keeps the raw (duplicated) report. + dup := map[string]any{ + "reason": float64(2), + "shortcut": map[string]any{"feed_card_id": "oc_b", "type": float64(1)}, + } + data := map[string]any{"failed_shortcuts": []any{dup, dup}} + addFeedShortcutWriteLedger(data, []shortcutItem{ + {FeedCardID: "oc_a", Type: int(ShortcutTypeChat)}, + {FeedCardID: "oc_b", Type: int(ShortcutTypeChat)}, + }) + + if data["total"] != 2 || data["success_count"] != 1 || data["failure_count"] != 1 { + t.Fatalf("ledger counts = total:%v success:%v failure:%v, want 2/1/1", + data["total"], data["success_count"], data["failure_count"]) + } +} + +func TestAnnotateFailedShortcutsNoOpWhenMissing(t *testing.T) { + // Must not panic when failed_shortcuts is missing or wrong type. + annotateFailedShortcuts(map[string]any{}) + annotateFailedShortcuts(map[string]any{"failed_shortcuts": "not-a-list"}) +} + +func TestResolveIsHeader(t *testing.T) { + tests := []struct { + name string + head bool + tail bool + want bool + wantErr bool + }{ + {name: "default is head", want: true}, + {name: "--head explicit", head: true, want: true}, + {name: "--tail", tail: true, want: false}, + {name: "both set errors", head: true, tail: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newFeedShortcutCreateCmd(t) + if tt.head { + if err := cmd.Flags().Set("head", "true"); err != nil { + t.Fatalf("Set head error = %v", err) + } + } + if tt.tail { + if err := cmd.Flags().Set("tail", "true"); err != nil { + t.Fatalf("Set tail error = %v", err) + } + } + rt := &common.RuntimeContext{Cmd: cmd} + got, err := resolveIsHeader(rt) + if tt.wantErr { + if err == nil { + t.Fatalf("resolveIsHeader() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("resolveIsHeader() unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("resolveIsHeader() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFeedShortcutStaticScopes(t *testing.T) { + if got := ImFeedShortcutCreate.ScopesForIdentity("user"); len(got) != 1 || got[0] != feedShortcutWriteScope { + t.Fatalf("ImFeedShortcutCreate scopes = %v, want only %s", got, feedShortcutWriteScope) + } + if got := ImFeedShortcutRemove.ScopesForIdentity("user"); len(got) != 1 || got[0] != feedShortcutWriteScope { + t.Fatalf("ImFeedShortcutRemove scopes = %v, want only %s", got, feedShortcutWriteScope) + } + if got := ImFeedShortcutList.ScopesForIdentity("user"); len(got) != 1 || got[0] != feedShortcutReadScope { + t.Fatalf("ImFeedShortcutList scopes = %v, want only %s", got, feedShortcutReadScope) + } +} + +func TestFeedShortcutAuthTypesUserOnly(t *testing.T) { + for _, sc := range []common.Shortcut{ImFeedShortcutCreate, ImFeedShortcutRemove, ImFeedShortcutList} { + if len(sc.AuthTypes) != 1 || sc.AuthTypes[0] != "user" { + t.Fatalf("shortcut %s AuthTypes = %v, want [user]", sc.Command, sc.AuthTypes) + } + } +} + +func TestImFeedShortcutCreateDryRunReportsValidationError(t *testing.T) { + cmd := newFeedShortcutCreateCmd(t) + // no chat-id set → validation error surfaced in DryRun output + rt := &common.RuntimeContext{Cmd: cmd} + got := ImFeedShortcutCreate.DryRun(context.Background(), rt).Format() + if !strings.Contains(got, "--chat-id is required") { + t.Fatalf("DryRun output = %q, want validation error", got) + } + if strings.Contains(got, "feed_shortcuts") { + t.Fatalf("DryRun output = %q, should not include request for invalid input", got) + } +} + +func TestImFeedShortcutCreateDryRunRendersBody(t *testing.T) { + cmd := newFeedShortcutCreateCmd(t) + if err := cmd.Flags().Set("chat-id", "oc_abc"); err != nil { + t.Fatalf("Set chat-id error = %v", err) + } + if err := cmd.Flags().Set("chat-id", "oc_def"); err != nil { + t.Fatalf("Set chat-id error = %v", err) + } + if err := cmd.Flags().Set("tail", "true"); err != nil { + t.Fatalf("Set tail error = %v", err) + } + rt := &common.RuntimeContext{Cmd: cmd} + got := ImFeedShortcutCreate.DryRun(context.Background(), rt).Format() + for _, want := range []string{ + "POST", + "/open-apis/im/v2/feed_shortcuts", + `"feed_card_id"`, + "oc_abc", + "oc_def", + `"is_header"`, + `false`, + } { + if !strings.Contains(got, want) { + t.Fatalf("DryRun output = %s, want %q", got, want) + } + } +} + +func TestImFeedShortcutCreateExecuteCallsAPI(t *testing.T) { + var gotBody []byte + var gotPath string + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") { + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + } + // Reject the remove suffix — confirms create uses the bare path. + if strings.HasSuffix(req.URL.Path, "/remove") { + return nil, fmt.Errorf("create should not call /remove: %s", req.URL.Path) + } + body, _ := io.ReadAll(req.Body) + gotBody = body + gotPath = req.URL.Path + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "failed_shortcuts": []any{ + map[string]any{ + "reason": float64(2), + "shortcut": map[string]any{ + "feed_card_id": "oc_abc", + "type": float64(1), + }, + }, + }, + }, + }), nil + })) + + cmd := newFeedShortcutCreateCmd(t) + if err := cmd.Flags().Set("chat-id", "oc_abc"); err != nil { + t.Fatalf("Set chat-id error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + err := ImFeedShortcutCreate.Execute(context.Background(), rt) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("Execute() error = %T %v, want partial failure", err, err) + } + // Lock the documented exit-code contract: partial failure exits 1 (ExitAPI). + if pfErr.Code != output.ExitAPI { + t.Fatalf("partial failure exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + } + if !strings.HasSuffix(gotPath, "/open-apis/im/v2/feed_shortcuts") { + t.Fatalf("Execute() path = %q, want /open-apis/im/v2/feed_shortcuts", gotPath) + } + if !strings.Contains(string(gotBody), `"feed_card_id":"oc_abc"`) { + t.Fatalf("Execute() body = %s, want feed_card_id oc_abc", gotBody) + } + if !strings.Contains(string(gotBody), `"is_header":true`) { + t.Fatalf("Execute() body = %s, want is_header true (default)", gotBody) + } + + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + if !strings.Contains(out, `"ok": false`) { + t.Fatalf("stdout = %s, want ok:false partial-failure envelope", out) + } + for _, want := range []string{ + `"total": 1`, + `"success_count": 0`, + `"failure_count": 1`, + `"succeeded_shortcuts": []`, + `"reason_label": "invalid_item"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("stdout = %s, want %q", out, want) + } + } +} + +func TestEmitFeedShortcutWriteResultSuccess(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("must not call API") + return nil, nil + })) + setRuntimeField(t, rt, "Cmd", newFeedShortcutCreateCmd(t)) + err := emitFeedShortcutWriteResult(rt, []shortcutItem{ + {FeedCardID: "oc_a", Type: int(ShortcutTypeChat)}, + }, map[string]any{"failed_shortcuts": []any{}}) + if err != nil { + t.Fatalf("emitFeedShortcutWriteResult() error = %v, want nil", err) + } + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + for _, want := range []string{ + `"ok": true`, + `"total": 1`, + `"success_count": 1`, + `"failure_count": 0`, + `"feed_card_id": "oc_a"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("stdout = %s, want %q", out, want) + } + } +} + +func TestEmitFeedShortcutWriteResultNilData(t *testing.T) { + // A fully-successful write can come back as code:0 with data:null, which + // DoAPIJSON surfaces as a nil map. The emitter must still produce the + // ledger instead of panicking on a nil-map write. + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("must not call API") + return nil, nil + })) + setRuntimeField(t, rt, "Cmd", newFeedShortcutCreateCmd(t)) + err := emitFeedShortcutWriteResult(rt, []shortcutItem{ + {FeedCardID: "oc_a", Type: int(ShortcutTypeChat)}, + }, nil) + if err != nil { + t.Fatalf("emitFeedShortcutWriteResult(nil data) error = %v, want nil", err) + } + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + for _, want := range []string{ + `"ok": true`, + `"total": 1`, + `"success_count": 1`, + `"failure_count": 0`, + } { + if !strings.Contains(out, want) { + t.Fatalf("stdout = %s, want %q", out, want) + } + } +} + +func TestImFeedShortcutRemoveExecuteCallsRemovePath(t *testing.T) { + var gotPath string + var gotBody []byte + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts/remove") { + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + } + body, _ := io.ReadAll(req.Body) + gotBody = body + gotPath = req.URL.Path + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{"failed_shortcuts": []any{}}, + }), nil + })) + + cmd := newFeedShortcutRemoveCmd(t) + if err := cmd.Flags().Set("chat-id", "oc_abc,oc_def"); err != nil { + t.Fatalf("Set chat-id error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + if err := ImFeedShortcutRemove.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.HasSuffix(gotPath, "/open-apis/im/v2/feed_shortcuts/remove") { + t.Fatalf("Execute() path = %q, want /open-apis/im/v2/feed_shortcuts/remove", gotPath) + } + if !strings.Contains(string(gotBody), `"feed_card_id":"oc_abc"`) { + t.Fatalf("Execute() body = %s, want feed_card_id oc_abc", gotBody) + } + if !strings.Contains(string(gotBody), `"feed_card_id":"oc_def"`) { + t.Fatalf("Execute() body = %s, want feed_card_id oc_def", gotBody) + } + // Remove must not send is_header — that's a create-only field. + if strings.Contains(string(gotBody), "is_header") { + t.Fatalf("Execute() body = %s, should NOT contain is_header", gotBody) + } +} + +func TestImFeedShortcutListDryRunRendersGet(t *testing.T) { + cmd := newFeedShortcutListCmd(t) + rt := &common.RuntimeContext{Cmd: cmd} + got := ImFeedShortcutList.DryRun(context.Background(), rt).Format() + for _, want := range []string{ + "GET", + "/open-apis/im/v2/feed_shortcuts", + } { + if !strings.Contains(got, want) { + t.Fatalf("DryRun output = %s, want %q", got, want) + } + } + if strings.Contains(got, "page_token") { + t.Fatalf("DryRun output = %s, should omit page_token on first-page request", got) + } +} + +func TestImFeedShortcutListDryRunIncludesNonEmptyPageToken(t *testing.T) { + cmd := newFeedShortcutListCmd(t) + if err := cmd.Flags().Set("page-token", "tok1"); err != nil { + t.Fatalf("Set page-token error = %v", err) + } + rt := &common.RuntimeContext{Cmd: cmd} + got := ImFeedShortcutList.DryRun(context.Background(), rt).Format() + if !strings.Contains(got, "page_token=tok1") { + t.Fatalf("DryRun output = %s, want page_token=tok1", got) + } +} + +func TestImFeedShortcutListHelpDoesNotTreatDetailAsArgName(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "app", AppSecret: "secret", Brand: core.BrandFeishu, + }) + parent := &cobra.Command{Use: "im"} + ImFeedShortcutList.Mount(parent, f) + + cmd, _, err := parent.Find([]string{"+feed-shortcut-list"}) + if err != nil { + t.Fatalf("Find() error = %v", err) + } + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + if err := cmd.Help(); err != nil { + t.Fatalf("Help() error = %v", err) + } + got := out.String() + if strings.Contains(got, "--no-detail detail") { + t.Fatalf("help output treats `detail` as a flag arg name:\n%s", got) + } + if !strings.Contains(got, "--no-detail") { + t.Fatalf("help output missing --no-detail:\n%s", got) + } +} + +func TestImFeedShortcutListDryRunMentionsDetailScope(t *testing.T) { + cmd := newFeedShortcutListCmd(t) + if err := cmd.Flags().Set("no-detail", "false"); err != nil { + t.Fatalf("Set no-detail error = %v", err) + } + rt := &common.RuntimeContext{Cmd: cmd} + got := ImFeedShortcutList.DryRun(context.Background(), rt).Format() + for _, want := range []string{ + "im:chat:read", + "--no-detail", + "batch_query", + } { + if !strings.Contains(got, want) { + t.Fatalf("DryRun output = %s, want %q", got, want) + } + } +} + +func TestImFeedShortcutListDoesNotExposeAutoPaginationFlags(t *testing.T) { + // Locks in the design decision: this shortcut is a one-page wrapper. + // If any of these reappear, callers/AI agents will assume auto-walking + // is supported and write code that silently double-fetches. + banned := map[string]bool{"page-all": true, "page-limit": true, "page-size": true} + for _, fl := range ImFeedShortcutList.Flags { + if banned[fl.Name] { + t.Fatalf("ImFeedShortcutList must not expose --%s", fl.Name) + } + } +} + +func TestImFeedShortcutListPageTokenIsOptional(t *testing.T) { + // --page-token must NOT be Required: omitting it is the natural first-page + // signal (the server treats "missing" and "" the same). Forcing an empty + // string would just be noise. + for _, fl := range ImFeedShortcutList.Flags { + if fl.Name == "page-token" && fl.Required { + t.Fatalf("--page-token must be optional; omitting it should mean first page") + } + } +} + +func TestImFeedShortcutListDetailOnByDefault(t *testing.T) { + // The real flag definition must keep detail enrichment on by default: + // --no-detail is an opt-out bool with a false zero-value default. The + // test-helper command flips it for isolation, so this definition-level + // check is what actually locks the shipped default against a flip. + for _, fl := range ImFeedShortcutList.Flags { + if fl.Name == "no-detail" { + if fl.Default != "" && fl.Default != "false" { + t.Fatalf("--no-detail default = %q, want unset/false (enrichment on by default)", fl.Default) + } + return + } + } + t.Fatalf("--no-detail flag not found on ImFeedShortcutList") +} + +func TestFeedShortcutChatIDNotCobraRequired(t *testing.T) { + // --chat-id is mandatory, but must NOT be cobra-Required: cobra would + // intercept a missing flag before Validate runs and emit a plain-text + // "required flag(s) not set" error (exit 1) instead of collectChatIDs' + // structured validation envelope (exit 2). + for _, sc := range []common.Shortcut{ImFeedShortcutCreate, ImFeedShortcutRemove} { + for _, fl := range sc.Flags { + if fl.Name == "chat-id" && fl.Required { + t.Fatalf("%s: --chat-id must not be cobra-Required; requiredness is enforced by collectChatIDs", sc.Command) + } + } + } +} + +func TestFeedShortcutListQueryOmitsEmptyToken(t *testing.T) { + q := feedShortcutListQuery("") + if _, ok := q["page_token"]; ok { + t.Fatalf("feedShortcutListQuery(\"\") = %v, want no page_token key", q) + } + q = feedShortcutListQuery("next") + if v := q["page_token"]; len(v) != 1 || v[0] != "next" { + t.Fatalf("feedShortcutListQuery(\"next\") page_token = %v, want [next]", v) + } +} + +func TestImFeedShortcutListExecuteForwardsToken(t *testing.T) { + tests := []struct { + name string + token string + wantSent string // value the server should see in ?page_token= + wantKey bool // whether ?page_token should appear at all + }{ + {name: "first page omits param", token: "", wantSent: "", wantKey: false}, + {name: "explicit token is forwarded", token: "tok1", wantSent: "tok1", wantKey: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls int + var sawKey bool + var gotToken string + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") { + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + } + calls++ + _, sawKey = req.URL.Query()["page_token"] + gotToken = req.URL.Query().Get("page_token") + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "shortcuts": []any{map[string]any{"feed_card_id": "oc_a", "type": float64(1)}}, + "has_more": false, + "page_token": "end", + }, + }), nil + })) + + cmd := newFeedShortcutListCmd(t) + if err := cmd.Flags().Set("page-token", tt.token); err != nil { + t.Fatalf("Set page-token error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 API call, got %d", calls) + } + if sawKey != tt.wantKey { + t.Fatalf("page_token query key present = %v, want %v", sawKey, tt.wantKey) + } + if gotToken != tt.wantSent { + t.Fatalf("page_token sent = %q, want %q", gotToken, tt.wantSent) + } + }) + } +} + +func TestShortcutTypeFromValue(t *testing.T) { + tests := []struct { + name string + v any + want ShortcutType + }{ + {name: "float64 1 → chat", v: float64(1), want: ShortcutTypeChat}, + {name: "int 1 → chat", v: 1, want: ShortcutTypeChat}, + {name: "float64 0 → unknown", v: float64(0), want: ShortcutTypeUnknown}, + {name: "unknown numeric → unknown ShortcutType(99)", v: float64(99), want: ShortcutType(99)}, + {name: "string defaults to unknown", v: "1", want: ShortcutTypeUnknown}, + {name: "nil defaults to unknown", v: nil, want: ShortcutTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shortcutTypeFromValue(tt.v); got != tt.want { + t.Fatalf("shortcutTypeFromValue(%v) = %v, want %v", tt.v, got, tt.want) + } + }) + } +} + +func TestResolveChatDetailBatchesAt50(t *testing.T) { + var calls int + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") { + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + } + calls++ + // Echo each requested chat_id back with a synthetic name so we can + // confirm both that batching happened and that the response was + // parsed correctly. + body, _ := io.ReadAll(req.Body) + var parsed struct { + ChatIDs []string `json:"chat_ids"` + } + _ = json.Unmarshal(body, &parsed) + items := make([]any, 0, len(parsed.ChatIDs)) + for _, id := range parsed.ChatIDs { + items = append(items, map[string]any{"chat_id": id, "name": "group-" + id}) + } + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{"items": items}, + }), nil + })) + setRuntimeScopes(t, rt, chatBatchQueryScope) + + ids := make([]string, 120) // 50 + 50 + 20 → 3 batches + for i := range ids { + ids[i] = fmt.Sprintf("oc_%d", i) + } + got, err := resolveChatDetail(rt, ids) + if err != nil { + t.Fatalf("resolveChatDetail() error = %v", err) + } + if calls != 3 { + t.Fatalf("calls = %d, want 3 (120 ids / 50 batch size)", calls) + } + if len(got) != 120 { + t.Fatalf("resolved size = %d, want 120", len(got)) + } + first := got["oc_0"] + last := got["oc_119"] + if first == nil || last == nil { + t.Fatalf("Items missing boundary entries: first=%v last=%v", first, last) + } + if first["name"] != "group-oc_0" || last["name"] != "group-oc_119" { + t.Fatalf("expected name passthrough; got first=%v last=%v", first["name"], last["name"]) + } +} + +func TestResolveChatDetailIncludesP2PChats(t *testing.T) { + // Unlike the old title-only resolver, the detail resolver keeps p2p chats + // in the result map (their full object carries chat_mode/p2p_target_id); + // only `name` is empty. Locks in that the empty-name skip was removed + // when we switched from `title` (string) to `detail` (full object). + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{"chat_id": "oc_group", "name": "Engineering", "chat_mode": "group"}, + map[string]any{"chat_id": "oc_p2p", "name": "", "chat_mode": "p2p", "p2p_target_id": "ou_x"}, + }, + }, + }), nil + })) + setRuntimeScopes(t, rt, chatBatchQueryScope) + + got, err := resolveChatDetail(rt, []string{"oc_group", "oc_p2p"}) + if err != nil { + t.Fatalf("resolveChatDetail() error = %v", err) + } + if got["oc_group"]["name"] != "Engineering" { + t.Fatalf("oc_group name = %v, want Engineering", got["oc_group"]["name"]) + } + p2p, ok := got["oc_p2p"] + if !ok { + t.Fatalf("oc_p2p must be in Items even though name is empty (caller decides what to show)") + } + if p2p["chat_mode"] != "p2p" || p2p["p2p_target_id"] != "ou_x" { + t.Fatalf("p2p detail = %v, want chat_mode=p2p with p2p_target_id passthrough", p2p) + } +} + +func TestResolveChatDetailDropsItemsWithoutChatID(t *testing.T) { + // Defensive: the server should always echo chat_id back, but if it ever + // returns an item missing chat_id we must not write a "" → object entry + // into the map and end up attaching nonsense to entries. + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{"chat_id": "oc_ok", "name": "ok"}, + map[string]any{"name": "no chat_id"}, + }, + }, + }), nil + })) + setRuntimeScopes(t, rt, chatBatchQueryScope) + + got, err := resolveChatDetail(rt, []string{"oc_ok"}) + if err != nil { + t.Fatalf("resolveChatDetail() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("resolved size = %d, want 1 (entry without chat_id must be dropped)", len(got)) + } + if _, ok := got[""]; ok { + t.Fatalf("got[\"\"] must not exist; got %v", got[""]) + } +} + +func TestResolveChatDetailPropagatesScopeError(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("resolver should fail scope pre-flight before calling API: %s", req.URL.Path) + return nil, nil + })) + // Token resolves with a known-but-wrong scope set so the missing-scope + // branch (not the unknown-metadata warning branch) fires. + setRuntimeScopes(t, rt, "search:message") + + _, err := resolveChatDetail(rt, []string{"oc_abc"}) + if err == nil { + t.Fatalf("resolveChatDetail() expected scope error, got nil") + } + if !strings.Contains(err.Error(), chatBatchQueryScope) { + t.Fatalf("resolveChatDetail() error = %v, want mention of %s", err, chatBatchQueryScope) + } +} + +func TestEnrichFeedShortcutDetailAttachesAndDedupes(t *testing.T) { + var calls int + var capturedIDs []string + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") { + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + } + calls++ + body, _ := io.ReadAll(req.Body) + var parsed struct { + ChatIDs []string `json:"chat_ids"` + } + _ = json.Unmarshal(body, &parsed) + capturedIDs = append(capturedIDs, parsed.ChatIDs...) + items := make([]any, 0, len(parsed.ChatIDs)) + for _, id := range parsed.ChatIDs { + items = append(items, map[string]any{ + "chat_id": id, + "name": "name-of-" + id, + "chat_mode": "group", + }) + } + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{"items": items}, + }), nil + })) + setRuntimeScopes(t, rt, chatBatchQueryScope) + + data := map[string]any{ + "shortcuts": []any{ + map[string]any{"feed_card_id": "oc_a", "type": float64(1)}, + map[string]any{"feed_card_id": "oc_b", "type": float64(1)}, + map[string]any{"feed_card_id": "oc_a", "type": float64(1)}, // duplicate + // Unknown type — must be skipped without aborting the whole call. + map[string]any{"feed_card_id": "doc_xxx", "type": float64(3)}, + }, + } + if err := enrichFeedShortcutDetail(rt, data); err != nil { + t.Fatalf("enrichFeedShortcutDetail() error = %v", err) + } + if calls != 1 { + t.Fatalf("calls = %d, want 1 (single batch covers all CHAT ids)", calls) + } + if len(capturedIDs) != 2 { + t.Fatalf("server saw chat_ids = %v, want 2 dedup'd ids", capturedIDs) + } + + items := data["shortcuts"].([]any) + for _, ix := range []int{0, 1, 2} { // 2 is the duplicate of 0 + detail, ok := items[ix].(map[string]any)["detail"].(map[string]any) + if !ok { + t.Fatalf("item[%d] missing detail field; got %v", ix, items[ix]) + } + // The full chat object is passed through verbatim — not just a name. + if detail["chat_mode"] != "group" { + t.Fatalf("item[%d] detail.chat_mode = %v, want group (full object passthrough)", ix, detail["chat_mode"]) + } + wantName := "name-of-" + items[ix].(map[string]any)["feed_card_id"].(string) + if detail["name"] != wantName { + t.Fatalf("item[%d] detail.name = %v, want %q", ix, detail["name"], wantName) + } + } + if _, ok := items[3].(map[string]any)["detail"]; ok { + t.Fatalf("item[3] (unknown type) should not have detail set") + } +} + +func TestEnrichFeedShortcutDetailNoOpWhenEmpty(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("must not call API for empty list: %s", req.URL.Path) + return nil, nil + })) + if err := enrichFeedShortcutDetail(rt, map[string]any{}); err != nil { + t.Fatalf("enrichFeedShortcutDetail(empty data) error = %v", err) + } + if err := enrichFeedShortcutDetail(rt, map[string]any{"shortcuts": []any{}}); err != nil { + t.Fatalf("enrichFeedShortcutDetail(empty shortcuts) error = %v", err) + } +} + +func TestEnrichFeedShortcutDetailSkipsWhenNoSupportedType(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("must not call batch_query when no resolvable types: %s", req.URL.Path) + return nil, nil + })) + data := map[string]any{ + "shortcuts": []any{ + map[string]any{"feed_card_id": "doc_1", "type": float64(3)}, // DOC, not exposed + map[string]any{"feed_card_id": "app_1", "type": float64(4)}, // OPENAPP, not exposed + map[string]any{"feed_card_id": "biz_1", "type": float64(13)}, // APP_FEED, not exposed + }, + } + if err := enrichFeedShortcutDetail(rt, data); err != nil { + t.Fatalf("enrichFeedShortcutDetail() error = %v", err) + } + for i, it := range data["shortcuts"].([]any) { + if _, ok := it.(map[string]any)["detail"]; ok { + t.Fatalf("item[%d] should not have a detail (unknown type)", i) + } + } +} + +func TestImFeedShortcutListExecuteEnrichesDetailByDefault(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"): + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "shortcuts": []any{ + map[string]any{"feed_card_id": "oc_a", "type": float64(1)}, + }, + "has_more": false, + "page_token": "", + }, + }), nil + case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"): + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{ + "chat_id": "oc_a", + "name": "Team Alpha", + "chat_mode": "group", + }, + }, + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope) + + cmd := newFeedShortcutListCmd(t) + if err := cmd.Flags().Set("no-detail", "false"); err != nil { + t.Fatalf("Set no-detail error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + // Verify both the attach-field name and the full-object passthrough, + // so future regressions that drop fields (e.g. only keeping `name`) + // fail loudly here. + for _, want := range []string{ + `"detail":`, + `"chat_mode": "group"`, + `"name": "Team Alpha"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("stdout missing %q, got:\n%s", want, out) + } + } +} + +func TestImFeedShortcutListExecuteWarnsOnEnrichFailure(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"): + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "shortcuts": []any{ + map[string]any{"feed_card_id": "oc_a", "type": float64(1)}, + }, + "has_more": false, + "page_token": "", + }, + }), nil + case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"): + return nil, fmt.Errorf("batch_query network failure") + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope) + + cmd := newFeedShortcutListCmd(t) + if err := cmd.Flags().Set("no-detail", "false"); err != nil { + t.Fatalf("Set no-detail error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + // Listing should still succeed even when enrichment can't reach the API — + // failure becomes a stderr warning, not a hard exit. + if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + stderr := rt.Factory.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "detail enrichment failed") { + t.Fatalf("stderr = %q, want enrichment warning", stderr) + } + // And the shortcut itself still appears, just without `detail`. + stdout := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + if !strings.Contains(stdout, `"feed_card_id": "oc_a"`) { + t.Fatalf("stdout should still contain the bare shortcut entry; got:\n%s", stdout) + } + if strings.Contains(stdout, `"detail"`) { + t.Fatalf("stdout should NOT contain detail when enrichment failed; got:\n%s", stdout) + } + // The degradation is mirrored as a machine-readable data field so + // stdout-only consumers can tell "skipped" from "nothing to enrich". + if !strings.Contains(stdout, `"_notice": "detail enrichment skipped`) { + t.Fatalf("stdout should carry the _notice degradation marker; got:\n%s", stdout) + } +} diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index af0e9621b..14266a5c8 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -22,7 +22,6 @@ const ( messagesSearchDefaultPageLimit = 20 messagesSearchMaxPageLimit = 40 messagesSearchMGetBatchSize = 50 - messagesSearchChatBatchSize = 50 ) var ImMessagesSearch = common.Shortcut{ @@ -459,23 +458,9 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} { chatContexts := map[string]map[string]interface{}{} - for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) { - chatRes, chatErr := runtime.DoAPIJSON( - http.MethodPost, "/open-apis/im/v1/chats/batch_query", - larkcore.QueryParams{"user_id_type": []string{"open_id"}}, - map[string]interface{}{"chat_ids": batch}, - ) - if chatErr != nil { - continue - } - if chatItems, ok := chatRes["items"].([]interface{}); ok { - for _, ci := range chatItems { - cm, _ := ci.(map[string]interface{}) - if cid, _ := cm["chat_id"].(string); cid != "" { - chatContexts[cid] = cm - } - } - } + // Best-effort: a failed chunk only loses its own entries. + for _, batch := range chunkStrings(chatIds, chatBatchQuerySize) { + _ = queryChatBatch(runtime, batch, chatContexts) } return chatContexts } diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 3c8aadfbe..2df2aa48b 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -22,5 +22,8 @@ func Shortcuts() []common.Shortcut { ImFlagCreate, ImFlagCancel, ImFlagList, + ImFeedShortcutCreate, + ImFeedShortcutRemove, + ImFeedShortcutList, } } diff --git a/skill-template/domains/im.md b/skill-template/domains/im.md index 712e34be5..83c65679a 100644 --- a/skill-template/domains/im.md +++ b/skill-template/domains/im.md @@ -5,6 +5,7 @@ - **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx). - **Reaction**: An emoji reaction on a message. - **Flag**: A bookmark on a message or thread. +- **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type). ## Resource Relationships @@ -51,3 +52,15 @@ Flags support two layers: Item types for feed-layer flags: - **ItemTypeThread** (4) = thread in a topic-style chat - **ItemTypeMsgThread** (11) = thread in a regular chat + +### Feed Shortcut + +Feed shortcuts add chats to the **current user's** feed sidebar. They are distinct from flags: + +- **Flag** = bookmark on a message/thread, scoped to the user's bookmark list. +- **Feed shortcut** = entry in the user's feed sidebar (currently only chats). + +Key limits: +- Only **CHAT-type** (`feed_card_id` is `oc_xxx`) is exposed via OpenAPI; doc/app/subscription shortcuts exist internally but are not yet whitelisted. +- All three operations (create/remove/list) are **user-identity only** — they sign with `user_access_token`. +- Batch size is **10 per call** for create/remove; list is a one-page wrapper with opaque `page_token` pagination. diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 7ec3b7118..3c0ae0feb 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-im version: 1.0.0 -description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。" +description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)时使用。" metadata: requires: bins: ["lark-cli"] @@ -19,6 +19,7 @@ metadata: - **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx). - **Reaction**: An emoji reaction on a message. - **Flag**: A bookmark on a message or thread. +- **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type). ## Resource Relationships @@ -66,6 +67,18 @@ Item types for feed-layer flags: - **ItemTypeThread** (4) = thread in a topic-style chat - **ItemTypeMsgThread** (11) = thread in a regular chat +### Feed Shortcut + +Feed shortcuts add chats to the current user's feed sidebar. They are distinct from flags: + +- **Flag** = bookmark on a message/thread, scoped to the user's bookmark list. +- **Feed shortcut** = entry in the user's feed sidebar (currently only chats). + +Key limits: +- Only **CHAT-type** (`feed_card_id` is `oc_xxx`) is exposed via OpenAPI; doc/app/subscription shortcuts exist internally but are not yet whitelisted. +- All three operations (create/remove/list) are **user-identity only** — they sign with `user_access_token`. +- Batch size is **10 per call** for create/remove; list is a one-page wrapper with opaque `page_token` pagination. + ## Shortcuts(推荐优先使用) Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。有 Shortcut 的操作优先使用。 @@ -86,6 +99,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | [`+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-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-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger | +| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger | +| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope | ## API Resources @@ -169,4 +185,3 @@ 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` | - diff --git a/skills/lark-im/references/lark-im-feed-shortcut-create.md b/skills/lark-im/references/lark-im-feed-shortcut-create.md new file mode 100644 index 000000000..3eed0f6ba --- /dev/null +++ b/skills/lark-im/references/lark-im-feed-shortcut-create.md @@ -0,0 +1,97 @@ +# im +feed-shortcut-create + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules. + +This skill maps to shortcut: `lark-cli im +feed-shortcut-create`. Underlying API: `POST /open-apis/im/v2/feed_shortcuts`. + +## What it does + +Adds one or more chats to the **current user's** feed shortcuts — equivalent to right-clicking a chat in the Feishu client and pinning it to the feed sidebar. + +- Only **CHAT-type** shortcuts are exposed by the OpenAPI gateway right now (`feed_card_id` must be an `oc_xxx` open_chat_id). +- Batch up to **10 chat IDs per call**; pass more by issuing multiple calls. +- Currently only supports **user identity** (`--as user`); bot identity is not allowed by the server. +- If you only know a group name, resolve its `oc_xxx` first with `im +chat-search` or `im +chat-list`. + +## Commands + +```bash +# Add a single chat as a feed shortcut (defaults to head/top insertion) +lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx + +# Add multiple chats; comma-separated or repeated flag both work +lark-cli im +feed-shortcut-create --as user --chat-id oc_a,oc_b,oc_c +lark-cli im +feed-shortcut-create --as user --chat-id oc_a --chat-id oc_b + +# Append at the bottom of the shortcut list instead of the top +lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --tail + +# Preview the request without sending +lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --dry-run +``` + +## Parameters + +| Parameter | Default | Description | +|------|------|------| +| `--chat-id ` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **max 10 per call** | +| `--head` | true (implied) | Insert at the top of the shortcut list; mutually exclusive with `--tail` | +| `--tail` | false | Append at the bottom of the shortcut list | +| `--as user` | required | Server only accepts user_access_token for this API | + +## Response + +The response is a batch ledger. A full success exits `0` with `ok:true`. Any non-empty `failed_shortcuts` is a partial failure: the process exits non-zero (currently exit `1`), stdout carries `ok:false`, and the full ledger remains machine-readable: + +| Field | Meaning | +|------|------| +| `total` | Number of requested shortcuts | +| `success_count` | Number of requested shortcuts not reported in `failed_shortcuts` | +| `failure_count` | Number of requested shortcuts reported as failed; `failed_shortcuts` preserves the raw server failure list | +| `succeeded_shortcuts` | Requested shortcut entries that succeeded | +| `failed_shortcuts` | Per-item failures returned by the server, enriched with `reason_label` | + +The shortcut adds a `reason_label` field next to each numeric `reason`: + +| `reason` | `reason_label` | Meaning | +|---:|------|------| +| 1 | `no_permission` | User has no permission on the feed card | +| 2 | `invalid_item` | `feed_card_id` is invalid or type doesn't match | +| 3 | `has_pending_delete` | The chat is being deleted | +| 4 | `type_not_support` | Type is not whitelisted (only CHAT is open now) | +| 5 | `internal_error` | Server internal error | + +Example: + +```json +{ + "ok": false, + "data": { + "total": 2, + "success_count": 1, + "failure_count": 1, + "succeeded_shortcuts": [ + { "feed_card_id": "oc_good", "type": 1 } + ], + "failed_shortcuts": [ + { + "shortcut": { "feed_card_id": "oc_bad", "type": 1 }, + "reason": 2, + "reason_label": "invalid_item" + } + ] + } +} +``` + +## Permissions + +- Required scope: `im:feed.shortcut:write` +- Only available with user identity (`--as user`). The CLI will reject `--as bot` for this shortcut. + +## Note + +- The shortcut list is **per user**: the call adds shortcuts for the currently authenticated user only. +- Adding the same chat twice is **idempotent at the user level** (re-adding an existing shortcut is a no-op rather than an error). +- Scripts should check the process exit code, top-level `ok`, and ledger counts. Partial failures intentionally keep machine-readable success and failure details on stdout. +- To inspect the current shortcut list, use [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). To remove a shortcut, use [`+feed-shortcut-remove`](lark-im-feed-shortcut-remove.md). diff --git a/skills/lark-im/references/lark-im-feed-shortcut-list.md b/skills/lark-im/references/lark-im-feed-shortcut-list.md new file mode 100644 index 000000000..6a22893cd --- /dev/null +++ b/skills/lark-im/references/lark-im-feed-shortcut-list.md @@ -0,0 +1,103 @@ +# im +feed-shortcut-list + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules. + +This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API: `GET /open-apis/im/v2/feed_shortcuts`. + +## What it does + +Lists **one page** of the **current user's** feed shortcuts. + +- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted). +- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate. +- Server-side page size is controlled by the service; in normal use one page usually covers the list. +- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`. + +## Commands + +```bash +# First page (the only call most users ever need — --page-token omitted) +lark-cli im +feed-shortcut-list --as user + +# Continue from the previous response's page_token +lark-cli im +feed-shortcut-list --as user --page-token + +# Skip detail enrichment when only IDs are needed; avoids the extra im:chat:read lookup +lark-cli im +feed-shortcut-list --as user --no-detail -q '.data.shortcuts[].feed_card_id' +``` + +> If you need to walk every page, write the loop yourself: read `data.page_token` from each response and pass it back in until `has_more=false`. The shortcut intentionally does not auto-walk because page-token errors require the caller to decide whether to restart from the first page. + +## Parameters + +| Parameter | Required | Description | +|------|------|------| +| `--page-token ` | no | Opaque pagination token from the previous response. **Omit it for the first page.** | +| `--no-detail` | no (default `false`) | Skip fetching each entry's full info object. By default enrichment is enabled: CHAT-type entries call `im.chats.batch_query`, need `im:chat:read`, and attach the object under the `detail` field. Pass `--no-detail` to skip the extra call and scope. | +| `--as user` | yes | Server only accepts user_access_token for this API | + +## Response Structure + +| Field | Type | Description | +|------|------|------| +| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). By default (without `--no-detail`), each entry also has a `detail` field with the full per-type info object. | +| `has_more` | boolean | Whether more pages exist | +| `page_token` | string | Opaque token to pass to the next call when continuing pagination | + +Example (with detail enrichment, CHAT type): + +```json +{ + "data": { + "shortcuts": [ + { + "feed_card_id": "oc_092f0100fe59c35995727db1039777a8", + "type": 1, + "detail": { + "chat_id": "oc_092f0100fe59c35995727db1039777a8", + "chat_mode": "group", + "name": "Engineering", + "avatar": "https://...", + "description": "", + "external": false, + "owner_id": "ou_xxx", + "owner_id_type": "open_id", + "tenant_key": "..." + } + }, + { + "feed_card_id": "oc_c82061d126a06635aa3569587b134bb1", + "type": 1, + "detail": { + "chat_id": "oc_c82061d126a06635aa3569587b134bb1", + "chat_mode": "p2p", + "name": "", + "p2p_target_id": "ou_xxx", + "p2p_target_type": "user", + "avatar": "", + "description": "", + "external": false, + "tenant_key": "..." + } + } + ], + "has_more": false, + "page_token": "v1.example-opaque-token" + } +} +``` + +## Detail Enrichment + +The `detail` payload is dispatched **per `type`**. Today only CHAT is wired in; future shortcut types can attach different object shapes. Callers should `switch` on `type` before parsing `detail`. For CHAT (`type=1`): + +- **Source**: `POST /open-apis/im/v1/chats/batch_query` (50 ids per call, server limit). +- **Payload**: the **full chat object** is passed through verbatim — `chat_id`, `chat_mode` (`group` / `p2p` / `topic`), `name`, `avatar`, `description`, `external`, `tenant_key`, plus type-specific fields (`owner_id*` for groups, `p2p_target_*` for p2p). +- **P2P chats** return an empty `name` because the Feishu client renders the partner's display name there. The rest of the object (especially `p2p_target_id`) still flows through, so callers can resolve the partner via `+contact-search` if a display title is needed. +- **Lookup failure** (missing scope, network error) → the list still returns successfully; a warning is printed to stderr, the data payload carries a `_notice` field (`"detail enrichment skipped: ..."`), and affected entries simply lack the `detail` field. Check `_notice` to tell "enrichment skipped" from "nothing to enrich". + +## Permissions + +- Required scope: `im:feed.shortcut:read` +- Conditional scope (default detail path only): `im:chat:read`; pass `--no-detail` to avoid this extra scope and lookup. +- Only available with user identity (`--as user`). diff --git a/skills/lark-im/references/lark-im-feed-shortcut-remove.md b/skills/lark-im/references/lark-im-feed-shortcut-remove.md new file mode 100644 index 000000000..8e35adf4c --- /dev/null +++ b/skills/lark-im/references/lark-im-feed-shortcut-remove.md @@ -0,0 +1,48 @@ +# im +feed-shortcut-remove + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules. + +This skill maps to shortcut: `lark-cli im +feed-shortcut-remove`. Underlying API: `POST /open-apis/im/v2/feed_shortcuts/remove`. + +## What it does + +Removes one or more chats from the **current user's** feed shortcuts. + +- Only **CHAT-type** shortcuts are supported (`feed_card_id` must be an `oc_xxx`). +- Batch up to **10 chat IDs per call**. +- Currently only supports **user identity** (`--as user`). +- Removing a chat that is not currently in the shortcut list is idempotent success: the call returns `ok:true`, `failure_count=0`, and no `failed_shortcuts` entry for that chat. + +## Commands + +```bash +# Remove a single feed shortcut +lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx + +# Remove multiple feed shortcuts in one call +lark-cli im +feed-shortcut-remove --as user --chat-id oc_a,oc_b +lark-cli im +feed-shortcut-remove --as user --chat-id oc_a --chat-id oc_b + +# Preview the request +lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx --dry-run +``` + +## Parameters + +| Parameter | Required | Description | +|------|------|------| +| `--chat-id ` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; max 10 per call | +| `--as user` | yes | Server only accepts user_access_token for this API | + +## Response + +The response uses the same batch ledger as [`+feed-shortcut-create`](lark-im-feed-shortcut-create.md#response): `total`, `success_count`, `failure_count`, `succeeded_shortcuts`, and `failed_shortcuts`. A non-empty `failed_shortcuts` is a partial failure: stdout carries `ok:false` with the full ledger and the process exits non-zero (currently exit `1`). + +## Permissions + +- Required scope: `im:feed.shortcut:write` +- Only available with user identity (`--as user`). + +## Note + +- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). Use `--no-detail` when you only need the `feed_card_id` values. diff --git a/tests/cli_e2e/im/feed_shortcut_workflow_test.go b/tests/cli_e2e/im/feed_shortcut_workflow_test.go new file mode 100644 index 000000000..68fa6c252 --- /dev/null +++ b/tests/cli_e2e/im/feed_shortcut_workflow_test.go @@ -0,0 +1,445 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestIM_FeedShortcutWorkflowAsUser walks the full create → list → remove +// loop for a single CHAT-type feed shortcut, mirroring the +flag-* workflow +// test. The feed_shortcuts API is user-identity only and version-locked, so +// the assertion strategy uses RunCmdWithRetry against the list endpoint +// rather than assuming the index is updated synchronously. +func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + clie2e.SkipWithoutUserToken(t) + + parentT := t + suffix := clie2e.GenerateSuffix() + chatName := "im-feed-shortcut-" + suffix + var chatID string + t.Cleanup(func() { + cleanupFeedShortcuts(parentT, "user", chatID) + }) + + t.Run("create chat as user", func(t *testing.T) { + chatID = createChatAs(t, parentT, ctx, chatName, "user") + }) + + t.Run("pin chat to feed as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-create", + "--chat-id", chatID, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + // failed_shortcuts may be absent (server returns {} on full success) + // or present-and-empty — either way it must not contain our chat. + for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() { + require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(), + "create should not report our chat as failed") + } + }) + + t.Run("list feed shortcuts as user with detail enrichment", func(t *testing.T) { + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-list", + }, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() { + if item.Get("feed_card_id").String() == chatID { + return false + } + } + return true + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + var found bool + for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() { + if item.Get("feed_card_id").String() != chatID { + continue + } + found = true + require.Equal(t, int64(1), item.Get("type").Int(), "type should be 1 (CHAT)") + // detail enrichment is on by default — the chat we just created + // must come back with the chat info object attached. + require.True(t, item.Get("detail").Exists(), + "detail field should be attached when enrichment is enabled") + require.Equal(t, chatID, item.Get("detail.chat_id").String(), + "detail.chat_id should echo feed_card_id") + require.Equal(t, chatName, item.Get("detail.name").String(), + "detail.name should carry the chat's group name") + break + } + require.True(t, found, "expected chat %s in feed shortcut list", chatID) + }) + + t.Run("list feed shortcuts with --no-detail skips lookup", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-list", + "--no-detail", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + var foundEntry gjson.Result + for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() { + if item.Get("feed_card_id").String() == chatID { + foundEntry = item + break + } + } + require.True(t, foundEntry.Exists(), "expected our chat in the bare list") + require.False(t, foundEntry.Get("detail").Exists(), + "detail field should NOT be present with --no-detail") + }) + + t.Run("unpin chat from feed as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-remove", + "--chat-id", chatID, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() { + require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(), + "remove should not report our chat as failed") + } + }) + + t.Run("verify chat removed from feed", func(t *testing.T) { + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-list", + "--no-detail", + }, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() { + if item.Get("feed_card_id").String() == chatID { + return true // still there, retry + } + } + return false + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() { + require.NotEqual(t, chatID, item.Get("feed_card_id").String(), + "chat should not be in feed list after remove") + } + }) +} + +// TestIM_FeedShortcutBatchAsUser exercises batch create / remove with two +// chats in a single API call. The per-item failure path (failed_shortcuts) +// is checked by mixing a real chat with an obviously invalid id. +func TestIM_FeedShortcutBatchAsUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + clie2e.SkipWithoutUserToken(t) + + parentT := t + suffix := clie2e.GenerateSuffix() + var chatA, chatB string + t.Cleanup(func() { + cleanupFeedShortcuts(parentT, "user", chatA, chatB) + }) + + t.Run("create two chats as user", func(t *testing.T) { + chatA = createChatAs(t, parentT, ctx, "im-feed-batch-a-"+suffix, "user") + chatB = createChatAs(t, parentT, ctx, "im-feed-batch-b-"+suffix, "user") + }) + + t.Run("batch pin both with --tail", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-create", + "--chat-id", chatA + "," + chatB, + "--tail", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("batch pin with one invalid id reports per-item failure", func(t *testing.T) { + // Re-pinning chatA (already pinned) should still succeed without + // noting it as failure; the synthetic invalid id should land in + // failed_shortcuts with reason_label=invalid_item. + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-create", + "--chat-id", chatA, + "--chat-id", "oc_definitely_not_a_real_chat_id_" + suffix, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + // Partial failure exits with the generic API failure code while the + // full ledger stays machine-readable on stdout. + result.AssertExitCode(t, 1) + result.AssertStdoutStatus(t, false) + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.total").Int()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.failure_count").Int()) + + var sawInvalid bool + for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() { + require.NotEqual(t, chatA, item.Get("shortcut.feed_card_id").String(), + "real chat should not appear in failed_shortcuts") + if item.Get("shortcut.feed_card_id").String() == "oc_definitely_not_a_real_chat_id_"+suffix { + sawInvalid = true + require.Equal(t, "invalid_item", item.Get("reason_label").String(), + "invalid id should be labeled invalid_item") + } + } + require.True(t, sawInvalid, "expected the bogus chat id in failed_shortcuts") + var sawSucceeded bool + for _, item := range gjson.Get(result.Stdout, "data.succeeded_shortcuts").Array() { + if item.Get("feed_card_id").String() == chatA { + sawSucceeded = true + require.Equal(t, int64(1), item.Get("type").Int(), "succeeded shortcut type should be CHAT") + } + } + require.True(t, sawSucceeded, "expected the real chat id in succeeded_shortcuts") + }) + + t.Run("batch remove both", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-remove", + "--chat-id", chatA, + "--chat-id", chatB, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) +} + +func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...string) { + parentT.Helper() + var ids []string + for _, id := range chatIDs { + if id != "" { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return + } + + cleanupCtx, cancel := clie2e.CleanupContext() + defer cancel() + listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"im", "+feed-shortcut-list", "--no-detail"}, + DefaultAs: defaultAs, + }) + clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts list", listResult, listErr) + if listErr != nil || listResult == nil || listResult.ExitCode != 0 { + return + } + + present := make(map[string]bool, len(ids)) + for _, id := range ids { + present[id] = false + } + for _, item := range gjson.Get(listResult.Stdout, "data.shortcuts").Array() { + id := item.Get("feed_card_id").String() + if _, ok := present[id]; ok { + present[id] = true + } + } + + args := []string{"im", "+feed-shortcut-remove"} + for _, id := range ids { + if present[id] { + args = append(args, "--chat-id", id) + } + } + if len(args) == 2 { + return + } + result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: args, + DefaultAs: defaultAs, + }) + clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts", result, err) +} + +// TestIM_FeedShortcutDryRun covers all three shortcuts in --dry-run mode +// using fake credentials. This is the only test that runs without a real +// user token because dry-run short-circuits before any network call. +func TestIM_FeedShortcutDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "fake_user_token") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + t.Run("create dry-run", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-create", + "--chat-id", "oc_test_dry_run", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Contains(t, result.Stdout, "POST") + require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts") + require.Contains(t, result.Stdout, "oc_test_dry_run") + // --head is the default so the body should be is_header=true + require.Contains(t, result.Stdout, `"is_header"`) + }) + + t.Run("create dry-run with --tail flips is_header to false", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-create", + "--chat-id", "oc_test_dry_run", + "--tail", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Contains(t, result.Stdout, `"is_header": false`) + }) + + t.Run("create dry-run rejects --head + --tail", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-create", + "--chat-id", "oc_test_dry_run", + "--head", + "--tail", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + // Validation errors exit 2 with the structured error envelope; + // assert against combined output to stay channel-agnostic. + result.AssertExitCode(t, 2) + combined := result.Stdout + "\n" + result.Stderr + require.Contains(t, combined, "mutually exclusive") + }) + + t.Run("create dry-run rejects non-oc chat ids", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-create", + "--chat-id", "om_not_a_chat", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + combined := result.Stdout + "\n" + result.Stderr + require.Contains(t, combined, "must be an open_chat_id") + }) + + t.Run("remove dry-run hits /remove endpoint", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-remove", + "--chat-id", "oc_a,oc_b", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Contains(t, result.Stdout, "POST") + require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts/remove") + require.Contains(t, result.Stdout, "oc_a") + require.Contains(t, result.Stdout, "oc_b") + require.NotContains(t, result.Stdout, "is_header", "remove must not send is_header") + }) + + t.Run("list dry-run mentions detail enrichment path", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-list", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Contains(t, result.Stdout, "GET") + require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts") + // Enrichment is on by default → DryRun adds a desc about the extra + // chats.batch_query call and the conditional scope. + require.Contains(t, result.Stdout, "im:chat:read") + require.Contains(t, result.Stdout, "batch_query") + }) + + t.Run("list dry-run with --no-detail omits the extra-scope note", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+feed-shortcut-list", + "--no-detail", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.NotContains(t, result.Stdout, "im:chat:read", + "with --no-detail, dry-run must not mention im:chat:read") + }) +}