From c61cbb8bf6733ef3e810a9e26f31e0ecaa611876 Mon Sep 17 00:00:00 2001 From: zhumiaoxin Date: Tue, 28 Apr 2026 14:19:21 +0800 Subject: [PATCH] feat: add flag shortcuts for im Change-Id: I8f87f0eadf83fb59b024a3b9fe67b23d363abe0a --- shortcuts/im/helpers.go | 233 +++ shortcuts/im/helpers_test.go | 3 + shortcuts/im/im_flag_cancel.go | 247 +++ shortcuts/im/im_flag_create.go | 212 ++ shortcuts/im/im_flag_list.go | 300 +++ shortcuts/im/im_flag_test.go | 1812 +++++++++++++++++ shortcuts/im/shortcuts.go | 3 + skill-template/domains/im.md | 12 + skills/lark-im/SKILL.md | 20 +- .../lark-im/references/lark-im-flag-cancel.md | 67 + .../lark-im/references/lark-im-flag-create.md | 67 + .../lark-im/references/lark-im-flag-list.md | 100 + tests/cli_e2e/im/flag_workflow_test.go | 304 +++ 13 files changed, 3378 insertions(+), 2 deletions(-) create mode 100644 shortcuts/im/im_flag_cancel.go create mode 100644 shortcuts/im/im_flag_create.go create mode 100644 shortcuts/im/im_flag_list.go create mode 100644 shortcuts/im/im_flag_test.go create mode 100644 skills/lark-im/references/lark-im-flag-cancel.md create mode 100644 skills/lark-im/references/lark-im-flag-create.md create mode 100644 skills/lark-im/references/lark-im-flag-list.md create mode 100644 tests/cli_e2e/im/flag_workflow_test.go diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 9086b0688..5b42c32b7 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -20,6 +20,8 @@ import ( "strings" "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -32,6 +34,18 @@ var mentionFixRe = regexp.MustCompile(`]+ var threadIDRe = regexp.MustCompile(`^omt_`) var messageIDRe = regexp.MustCompile(`^om_`) +func flagMessageID(rt *common.RuntimeContext) (string, error) { + id := strings.TrimSpace(rt.Str("message-id")) + if id == "" { + return "", output.ErrValidation("--message-id is required") + } + if strings.HasPrefix(id, "omt_") { + return "", output.ErrValidation( + "invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id) + } + return validateMessageID(id) +} + func normalizeAtMentions(content string) string { return mentionFixRe.ReplaceAllString(content, ``) } @@ -1432,3 +1446,222 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r } return fileKey, nil } + +// FlagType enumerates the kind of bookmark. +// Aligned with server-side constants: Unknown=0, Feed=1, Message=2. +type FlagType int + +const ( + FlagTypeUnknown FlagType = 0 + FlagTypeFeed FlagType = 1 + FlagTypeMessage FlagType = 2 +) + +// ItemType enumerates the kind of thing being bookmarked. +// Server-side constants (only the types used by IM flags): +// +// default=0, thread=4, msg_thread=11. +// +// Note on the two thread-shaped item types: +// - ItemTypeThread (4) — thread inside a topic-style chat +// - ItemTypeMsgThread (11) — thread inside a regular chat +type ItemType int + +const ( + ItemTypeDefault ItemType = 0 + ItemTypeThread ItemType = 4 // thread in a topic-style chat + ItemTypeMsgThread ItemType = 11 // thread in a regular chat +) + +const ( + flagWriteScope = "im:feed.flag:write" + flagReadScope = "im:feed.flag:read" +) + +var ( + flagWriteLookupScopes = append([]string{flagWriteScope}, flagLookupScopes...) + flagMessageReadScopes = []string{ + "im:message.group_msg:get_as_user", + "im:message.p2p_msg:get_as_user", + } + flagLookupScopes = []string{ + "im:message.group_msg:get_as_user", + "im:message.p2p_msg:get_as_user", + "im:chat:read", + } +) + +func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, required []string) error { + if len(required) == 0 { + return nil + } + result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID)) + if err != nil { + return output.ErrWithHint(output.ExitAuth, "auth", + fmt.Sprintf("cannot verify required scope(s): %v", err), + flagScopeLoginHint(required)) + } + if result == nil || result.Scopes == "" { + fmt.Fprintf(rt.IO().ErrOut, + "warning: cannot verify required scope(s) because token scope metadata is unavailable; API may fail if missing: %s\n", + strings.Join(required, " ")) + return nil + } + if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + flagScopeLoginHint(missing)) + } + return nil +} + +func flagScopeLoginHint(scopes []string) string { + return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(scopes, " ")) +} + +// flagItem is one entry in the flags API body. The server expects numeric +// enums serialized as strings. +type flagItem struct { + ItemID string `json:"item_id"` + ItemType string `json:"item_type"` + FlagType string `json:"flag_type"` +} + +// parseItemID inspects an om_ prefix and returns a best-guess +// (itemType, flagType) pair. Used when the user omits the explicit enums. +// - om_xxx → (default, message) +func parseItemID(id string) (ItemType, FlagType, error) { + id = strings.TrimSpace(id) + switch { + case strings.HasPrefix(id, "om_"): + return ItemTypeDefault, FlagTypeMessage, nil + case id == "": + return 0, 0, output.ErrValidation("--message-id cannot be empty") + default: + return 0, 0, output.ErrValidation( + "cannot infer item type from id %q: expected om_ (message) prefix; "+ + "pass --item-type and --flag-type explicitly if you are using a different id format", id) + } +} + +// parseItemType converts a user-facing string to the server enum. +func parseItemType(s string) (ItemType, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "default": + return ItemTypeDefault, nil + case "thread": + return ItemTypeThread, nil + case "msg_thread": + return ItemTypeMsgThread, nil + } + return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s) +} + +// parseFlagType converts a user-facing string to the server enum. +func parseFlagType(s string) (FlagType, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "message": + return FlagTypeMessage, nil + case "feed": + return FlagTypeFeed, nil + } + return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s) +} + +// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server. +// Note: (ItemType, FlagType) is shorthand for (item_type, flag_type) — the two +// enum fields that determine which layer the flag operates on. +// +// Valid combinations are: +// - (default, message) — regular chat message (message-layer flag) +// - (thread, feed) — thread as feed-layer flag (topic-style chat) +// - (msg_thread, feed) — message-thread as feed-layer flag (regular chat) +func isValidCombo(it ItemType, ft FlagType) bool { + return (it == ItemTypeDefault && ft == FlagTypeMessage) || + (it == ItemTypeThread && ft == FlagTypeFeed) || + (it == ItemTypeMsgThread && ft == FlagTypeFeed) +} + +// parseItemTypeFromRaw parses a stringified numeric item_type back to ItemType. +// Used when re-parsing the serialized enum for combo-validity checks. +// Note: Unknown values return ItemTypeDefault (0). This is safe because: +// 1. This function only parses values we serialized ourselves via newFlagItem +// 2. Unknown server values would fail combo validation or be rejected by the server +func parseItemTypeFromRaw(s string) ItemType { + switch s { + case "0": + return ItemTypeDefault + case "4": + return ItemTypeThread + case "11": + return ItemTypeMsgThread + } + return ItemTypeDefault +} + +// parseFlagTypeFromRaw parses a stringified numeric flag_type back to FlagType. +// Used when re-parsing the serialized enum for combo-validity checks. +func parseFlagTypeFromRaw(s string) FlagType { + switch s { + case "1": + return FlagTypeFeed + case "2": + return FlagTypeMessage + } + return FlagTypeUnknown +} + +// newFlagItem builds a payload entry with numeric-stringified enums. +func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem { + return flagItem{ + ItemID: itemID, + ItemType: fmt.Sprintf("%d", int(it)), + FlagType: fmt.Sprintf("%d", int(ft)), + } +} + +// getMessageChatID queries the message API to get the chat_id. +// Used by flag-create to determine the chat type for feed-layer flags. +func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) { + data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil) + if err != nil { + return "", err + } + + items, ok := data["items"].([]any) + if !ok || len(items) == 0 { + return "", output.ErrValidation("message not found or unexpected API response format") + } + + msg, ok := items[0].(map[string]any) + if !ok { + return "", output.ErrValidation("unexpected message format in API response") + } + + chatID, ok := msg["chat_id"].(string) + if !ok { + return "", output.ErrValidation("message response missing chat_id field") + } + return chatID, nil +} + +// resolveThreadFeedItemType determines the correct feed-layer ItemType for a thread +// by querying the chat API for chat_mode. +// - topic-style chat → ItemTypeThread +// - regular chat → ItemTypeMsgThread +// +// Returns an error if the chat query fails, since guessing the wrong item_type +// can cause silent failures in flag operations. +func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) { + data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil) + if err != nil { + return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err) + } + + // DoAPIJSON returns envelope.Data, so chat_mode is at the top level + chatMode, _ := data["chat_mode"].(string) + if chatMode == "topic" { + return ItemTypeThread, nil + } + return ItemTypeMsgThread, nil +} diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 0bb39049f..853d5e9f0 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -868,6 +868,9 @@ func TestShortcuts(t *testing.T) { "+messages-search", "+messages-send", "+threads-messages-list", + "+flag-create", + "+flag-cancel", + "+flag-list", } if !reflect.DeepEqual(commands, want) { t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want) diff --git a/shortcuts/im/im_flag_cancel.go b/shortcuts/im/im_flag_cancel.go new file mode 100644 index 000000000..4539d1ad0 --- /dev/null +++ b/shortcuts/im/im_flag_cancel.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// 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, + Flags: []common.Flag{ + {Name: "message-id", Desc: "message ID (om_xxx)"}, + {Name: "item-type", Desc: "item type override: default|thread|msg_thread"}, + {Name: "flag-type", Desc: "flag type override: message|feed; omit to double-cancel both layers"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, _, err := buildCancelItemsForPreview(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + items, _, err := buildCancelItemsForPreview(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + d := common.NewDryRunAPI(). + POST("/open-apis/im/v1/flags/cancel"). + Body(map[string]any{"flag_items": items}) + if len(items) > 1 { + d.Desc("double-cancel: tries both message and feed layers (best-effort); feed-layer skipped if chat_type undeterminable") + } + return d + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + items, err := buildCancelItems(runtime) + if err != nil { + return err + } + + // Make separate API calls for each item so they are independent. + // If one fails, the other can still succeed. + results := make([]map[string]any, 0, len(items)) + var lastErr error + for _, item := range items { + itemType := itemTypeString(parseItemTypeFromRaw(item.ItemType)) + flagType := flagTypeString(parseFlagTypeFromRaw(item.FlagType)) + result := map[string]any{ + "item_id": item.ItemID, + "item_type": itemType, + "flag_type": flagType, + } + data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil, + map[string]any{"flag_items": []flagItem{item}}) + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n", + itemType, flagType, err) + result["status"] = "failed" + result["error"] = err.Error() + lastErr = err + } else { + result["status"] = "ok" + result["response"] = data + } + results = append(results, result) + } + + runtime.Out(map[string]any{"results": results}, nil) + return lastErr + }, +} + +// buildCancelItemsForPreview builds cancel items without API calls. +// It shows double-cancel when no explicit flags are provided. +// DryRun cannot query chat_mode, so feed-layer item_type is represented with +// the same auto-detect placeholder used by +flag-create. +func buildCancelItemsForPreview(rt *common.RuntimeContext) ([]any, bool, error) { + id, err := flagMessageID(rt) + if err != nil { + return nil, false, err + } + + itOverride := strings.TrimSpace(rt.Str("item-type")) + ftOverride := strings.TrimSpace(rt.Str("flag-type")) + + // Explicit override provided → single targeted delete + if itOverride != "" || ftOverride != "" { + item, err := buildSingleCancelItem(id, itOverride, ftOverride) + if err != nil { + return nil, false, err + } + return []any{item}, false, nil + } + + // No override: show double-cancel (message + feed layers) + // Dry-run shows both layers; actual execution is best-effort. + return []any{ + newFlagItem(id, ItemTypeDefault, FlagTypeMessage), + map[string]string{ + "item_id": id, + "item_type": "", + "flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)), + }, + }, true, nil +} + +// buildCancelItems picks the (item_type, flag_type) pairs to cancel. +// +// Logic: +// 1. If --flag-type is explicitly provided, do a single targeted delete. +// 2. Otherwise, perform double-cancel: remove both message layer and feed layer. +// - Message layer is always included (uses known message_id with ItemTypeDefault) +// - Feed layer is best-effort: if chat_type cannot be determined, skip with warning +// - Each layer is independent; failure to cancel one doesn't block the other +func buildCancelItems(rt *common.RuntimeContext) ([]flagItem, error) { + id, err := flagMessageID(rt) + if err != nil { + return nil, err + } + + itOverride := strings.TrimSpace(rt.Str("item-type")) + ftOverride := strings.TrimSpace(rt.Str("flag-type")) + + // Explicit override provided → single targeted delete + if itOverride != "" || ftOverride != "" { + item, err := buildSingleCancelItem(id, itOverride, ftOverride) + if err != nil { + return nil, err + } + return []flagItem{item}, nil + } + + // Double-cancel: message layer + feed layer (best effort) + // Message layer is always included - we have the message_id and know the combo is valid. + items := []flagItem{newFlagItem(id, ItemTypeDefault, FlagTypeMessage)} + + // Feed layer: try to determine chat_type, but don't fail if we can't. + // Most messages only have one layer flagged, so this is best-effort cleanup. + chatID, err := getMessageChatID(rt, id) + if err != nil { + // Can't get chat_id, warn and skip feed layer + fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err) + return items, nil + } + + feedIT, err := resolveThreadFeedItemType(rt, chatID) + if err != nil { + // Can't determine chat_type, warn and skip feed layer + fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err) + return items, nil + } + + // Include feed layer + items = append(items, newFlagItem(id, feedIT, FlagTypeFeed)) + return items, nil +} + +// buildSingleCancelItem builds a single cancel item when user provides explicit flags. +func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error) { + var itemType ItemType + var flagType FlagType + + if itOverride != "" { + it, err := parseItemType(itOverride) + if err != nil { + return flagItem{}, err + } + itemType = it + } + if ftOverride != "" { + ft, err := parseFlagType(ftOverride) + if err != nil { + return flagItem{}, err + } + flagType = ft + } + if itOverride == "" || ftOverride == "" { + inferIT, inferFT, err := parseItemID(id) + if err != nil { + return flagItem{}, err + } + if itOverride == "" { + itemType = inferIT + } + if ftOverride == "" { + flagType = inferFT + } + } + if !isValidCombo(itemType, flagType) { + // Provide more specific hints for common mistakes + if itOverride != "" && ftOverride == "" { + if itemType == ItemTypeThread || itemType == ItemTypeMsgThread { + return flagItem{}, output.ErrValidation( + "invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)", + itOverride) + } + return flagItem{}, output.ErrValidation( + "invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override", + itOverride, flagTypeString(flagType)) + } + if itOverride == "" && ftOverride != "" { + return flagItem{}, output.ErrValidation( + "invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override", + ftOverride, itemTypeString(itemType)) + } + return flagItem{}, output.ErrValidation( + "invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed") + } + return newFlagItem(id, itemType, flagType), nil +} + +// itemTypeString converts ItemType to a user-facing string. +func itemTypeString(it ItemType) string { + switch it { + case ItemTypeDefault: + return "default" + case ItemTypeThread: + return "thread" + case ItemTypeMsgThread: + return "msg_thread" + } + return "unknown" +} + +// flagTypeString converts FlagType to a user-facing string. +func flagTypeString(ft FlagType) string { + switch ft { + case FlagTypeFeed: + return "feed" + case FlagTypeMessage: + return "message" + } + return "unknown" +} diff --git a/shortcuts/im/im_flag_create.go b/shortcuts/im/im_flag_create.go new file mode 100644 index 000000000..9ed2cb399 --- /dev/null +++ b/shortcuts/im/im_flag_create.go @@ -0,0 +1,212 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// ImFlagCreate provides the +flag-create shortcut for creating a bookmark on a message. +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)", + 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 (rarely needed)"}, + {Name: "flag-type", Desc: "flag type: message (default) or feed"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := buildCreateItemForPreview(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + item, err := buildCreateItemForPreview(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + d := common.NewDryRunAPI(). + POST("/open-apis/im/v1/flags"). + Body(map[string]any{"flag_items": []any{item}}) + if m, ok := item.(map[string]string); ok && m["item_type"] == "" { + d.Desc("feed-layer item_type is auto-detected at execution time by reading the message chat and chat_mode") + } + return d + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + item, err := buildCreateItem(runtime) + if err != nil { + return err + } + // Combo validation already done in Validate, but double-check as a safety net. + if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) { + return output.ErrValidation( + "invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+ + "(default, message), (thread, feed), or (msg_thread, feed)", + item.ItemType, item.FlagType) + } + data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil, + map[string]any{"flag_items": []flagItem{item}}) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// buildCreateItemForPreview derives a preview payload without making network calls. +// Feed-layer execution auto-detects item_type from chat_mode, but dry-run must +// not query the message or chat APIs, so it uses an explicit placeholder. +func buildCreateItemForPreview(rt *common.RuntimeContext) (any, error) { + id, err := flagMessageID(rt) + if err != nil { + return nil, err + } + + itOverride := strings.TrimSpace(rt.Str("item-type")) + ftOverride := strings.TrimSpace(rt.Str("flag-type")) + combo, err := parseExplicitFlagCombo(itOverride, ftOverride) + if err != nil { + return nil, err + } + + flagType := FlagTypeMessage + if combo.FlagTypeSet { + flagType = combo.FlagType + } + if flagType == FlagTypeMessage { + return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil + } + + if combo.ItemTypeSet { + return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil + } + + return map[string]string{ + "item_id": id, + "item_type": "", + "flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)), + }, nil +} + +// buildCreateItem derives a flagItem for the create path. +// +// Resolution logic: +// 1. No --flag-type or --flag-type=message → (default, message) +// 2. --flag-type=feed (no --item-type) → query message to get chat_id, +// then query chat_mode to determine: topic-style → (thread, feed), regular → (msg_thread, feed) +// 3. Both --item-type and --flag-type provided → honor verbatim (for edge cases) +func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) { + id, err := flagMessageID(rt) + if err != nil { + return flagItem{}, err + } + + itOverride := strings.TrimSpace(rt.Str("item-type")) + ftOverride := strings.TrimSpace(rt.Str("flag-type")) + combo, err := parseExplicitFlagCombo(itOverride, ftOverride) + if err != nil { + return flagItem{}, err + } + + flagType := FlagTypeMessage + if combo.FlagTypeSet { + flagType = combo.FlagType + } + + // Message-layer flag: always (default, message) + if flagType == FlagTypeMessage { + return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil + } + + // Feed-layer flag: need to determine item_type from chat_mode + if combo.ItemTypeSet { + // User explicitly specified item-type, honor it + return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil + } + + chatID, err := getMessageChatID(rt, id) + if err != nil { + return flagItem{}, output.ErrValidation( + "failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err) + } + if chatID == "" { + return flagItem{}, output.ErrValidation( + "message does not belong to a chat; feed-layer flags are only for messages in chats") + } + + feedIT, err := resolveThreadFeedItemType(rt, chatID) + if err != nil { + return flagItem{}, output.ErrValidation( + "failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err) + } + return newFlagItem(id, feedIT, FlagTypeFeed), nil +} + +type explicitFlagCombo struct { + ItemType ItemType + FlagType FlagType + ItemTypeSet bool + FlagTypeSet bool +} + +func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, error) { + itOverride = strings.TrimSpace(itOverride) + ftOverride = strings.TrimSpace(ftOverride) + + var combo explicitFlagCombo + if itOverride != "" { + it, err := parseItemType(itOverride) + if err != nil { + return explicitFlagCombo{}, err + } + combo.ItemType = it + combo.ItemTypeSet = true + } + if ftOverride != "" { + ft, err := parseFlagType(ftOverride) + if err != nil { + return explicitFlagCombo{}, err + } + combo.FlagType = ft + combo.FlagTypeSet = true + } + + if combo.ItemTypeSet && !combo.FlagTypeSet { + switch combo.ItemType { + case ItemTypeThread, ItemTypeMsgThread: + return explicitFlagCombo{}, output.ErrValidation( + "--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride) + case ItemTypeDefault: + return explicitFlagCombo{}, output.ErrValidation( + "--item-type=default requires --flag-type=message; or omit both to use default behavior") + } + } + + if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) { + return explicitFlagCombo{}, output.ErrValidation( + "invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed", + itOverride, ftOverride) + } + + return combo, nil +} + +// validateExplicitCombo validates the (item_type, flag_type) combination when +// the user explicitly provides flags. It does not make API calls - it only +// validates the logic for what the user explicitly specified. +func validateExplicitCombo(itOverride, ftOverride string) error { + _, err := parseExplicitFlagCombo(itOverride, ftOverride) + return err +} diff --git a/shortcuts/im/im_flag_list.go b/shortcuts/im/im_flag_list.go new file mode 100644 index 000000000..6599bd024 --- /dev/null +++ b/shortcuts/im/im_flag_list.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// ImFlagList provides the +flag-list shortcut for listing bookmarks. +// Feed-type thread entries are auto-enriched with message content. +var ImFlagList = common.Shortcut{ + Service: "im", + Command: "+flag-list", + Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination", + Risk: "read", + UserScopes: []string{flagReadScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {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: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateListOptions(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if err := validateListOptions(runtime); err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + d := common.NewDryRunAPI(). + GET("/open-apis/im/v1/flags"). + Params(map[string]any{ + "page_size": strconv.Itoa(runtime.Int("page-size")), + "page_token": runtime.Str("page-token"), + }) + if runtime.Bool("enrich-feed-thread") { + d.Desc("conditional enrichment: if feed/thread flag items are missing message content, execution may also call GET /open-apis/im/v1/messages/mget and requires scopes im:message.group_msg:get_as_user im:message.p2p_msg:get_as_user; pass --enrich-feed-thread=false to skip this extra call and extra scopes") + } + return d + }, + 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 executeListAllPages(runtime) + } + + data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil) + if err != nil { + return err + } + if runtime.Bool("enrich-feed-thread") { + if err := enrichFeedThreadItems(runtime, data); err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err) + } + } + runtime.Out(data, nil) + return nil + }, +} + +func validateListOptions(rt *common.RuntimeContext) error { + 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") + } + return nil +} + +// listQuery builds the query parameters for the flag list API call. +// page_token is required by the server even on the first page — pass empty +// string when the user hasn't supplied one. +func listQuery(rt *common.RuntimeContext) larkcore.QueryParams { + return larkcore.QueryParams{ + "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, + "page_token": []string{rt.Str("page-token")}, + } +} + +// enrichFeedThreadItems attaches message body to feed-shape thread entries +// by calling messages/mget. The list API returns only IDs for feed-shape entries, +// so this enrichment is needed to provide full message content. +// +// NOTE: This function modifies data["flag_items"] in place by adding a "message" key +// to each feed-thread entry. +func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error { + // Only enrich active flags (flag_items), not canceled flags (delete_flag_items). + // Canceled message-type flags don't show message content, so thread-type flags don't need it either. + items, _ := data["flag_items"].([]any) + if len(items) == 0 { + return nil + } + + // Index any messages the server already returned — saves a mget round-trip + // (ItemType=default+FlagType=Message responses already carry the message body). + byID := make(map[string]map[string]any) + if inline, ok := data["messages"].([]any); ok { + for _, m := range inline { + mm, _ := m.(map[string]any) + if mm == nil { + continue + } + if id := asString(mm["message_id"]); id != "" { + byID[id] = mm + } + } + } + + // Collect feed-thread ids whose message body wasn't inlined — dedup to cut mget calls. + need := map[string]bool{} + for _, it := range items { + m, _ := it.(map[string]any) + if m == nil { + continue + } + ft := asString(m["flag_type"]) + itStr := asString(m["item_type"]) + if ft != strconv.Itoa(int(FlagTypeFeed)) { + continue + } + if itStr != strconv.Itoa(int(ItemTypeThread)) && itStr != strconv.Itoa(int(ItemTypeMsgThread)) { + continue + } + id := asString(m["item_id"]) + if id == "" { + continue + } + if _, inlined := byID[id]; !inlined { + need[id] = true + } + } + + if len(need) > 0 { + if err := checkFlagRequiredScopes(rt.Ctx(), rt, flagMessageReadScopes); err != nil { + return err + } + ids := make([]string, 0, len(need)) + for id := range need { + ids = append(ids, id) + } + // /messages/mget accepts max 50 IDs per request — batch if needed. + const mgetBatchSize = 50 + for i := 0; i < len(ids); i += mgetBatchSize { + end := i + mgetBatchSize + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget", + larkcore.QueryParams{"message_ids": batch}, nil) + if err != nil { + return err + } + fetched, _ := got["items"].([]any) + for _, m := range fetched { + mm, _ := m.(map[string]any) + if mm == nil { + continue + } + if id := asString(mm["message_id"]); id != "" { + byID[id] = mm + } + } + } + } + + if len(byID) == 0 { + return nil + } + // Attach message payload to the matching list entries. + for _, it := range items { + m, _ := it.(map[string]any) + if m == nil { + continue + } + ft := asString(m["flag_type"]) + itType := asString(m["item_type"]) + if ft != strconv.Itoa(int(FlagTypeFeed)) { + continue + } + if itType != strconv.Itoa(int(ItemTypeThread)) && itType != strconv.Itoa(int(ItemTypeMsgThread)) { + continue + } + if msg, ok := byID[asString(m["item_id"])]; ok { + m["message"] = msg + } + } + return nil +} + +// asString converts an arbitrary value to its string representation. +// Handles string, float64, int, int64, and json.Number types; returns empty string for other types. +func asString(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + case json.Number: + return x.String() + } + return "" +} + +// executeListAllPages fetches all pages and merges the results into a single response. +// The flag list API returns items sorted by update_time ascending, so the last page +// contains the newest items. +func executeListAllPages(rt *common.RuntimeContext) error { + maxPages := rt.Int("page-limit") + if maxPages < 1 { + maxPages = 20 + } + if maxPages > 1000 { + maxPages = 1000 + } + + // Use make([]any, 0) to ensure empty arrays serialize as [] not null + allFlagItems := make([]any, 0) + allDeleteFlagItems := make([]any, 0) + allMessages := make([]any, 0) + var lastHasMore bool + var lastPageToken string + prevPageToken := "__START__" // Sentinel to detect unchanged token + + for page := 0; page < maxPages; page++ { + token := "" + if page > 0 { + token = lastPageToken + } + data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags", + larkcore.QueryParams{ + "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, + "page_token": []string{token}, + }, nil) + if err != nil { + return err + } + + if v, ok := data["flag_items"].([]any); ok { + allFlagItems = append(allFlagItems, v...) + } + if v, ok := data["delete_flag_items"].([]any); ok { + allDeleteFlagItems = append(allDeleteFlagItems, v...) + } + if v, ok := data["messages"].([]any); ok { + allMessages = append(allMessages, v...) + } + + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + + // Progress output to stderr + fmt.Fprintf(rt.IO().ErrOut, "page %d: %d flags, %d deleted\n", + page+1, len(allFlagItems), len(allDeleteFlagItems)) + + if !lastHasMore || lastPageToken == "" { + break + } + // Detect server anomaly: same token returned twice means infinite loop + 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{ + "flag_items": allFlagItems, + "delete_flag_items": allDeleteFlagItems, + "messages": allMessages, + "has_more": lastHasMore, + "page_token": lastPageToken, + } + + if rt.Bool("enrich-feed-thread") { + if err := enrichFeedThreadItems(rt, merged); err != nil { + fmt.Fprintf(rt.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err) + } + } + + rt.Out(merged, nil) + return nil +} diff --git a/shortcuts/im/im_flag_test.go b/shortcuts/im/im_flag_test.go new file mode 100644 index 000000000..dbf3ad832 --- /dev/null +++ b/shortcuts/im/im_flag_test.go @@ -0,0 +1,1812 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "testing" + + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestParseItemType(t *testing.T) { + tests := []struct { + name string + input string + want ItemType + wantErr bool + }{ + {name: "default", input: "default", want: ItemTypeDefault}, + {name: "empty string defaults to default", input: "", want: ItemTypeDefault}, + {name: "thread", input: "thread", want: ItemTypeThread}, + {name: "msg_thread", input: "msg_thread", want: ItemTypeMsgThread}, + {name: "case insensitive", input: "THREAD", want: ItemTypeThread}, + {name: "with whitespace", input: " thread ", want: ItemTypeThread}, + {name: "invalid", input: "invalid_type", wantErr: true}, + {name: "message is not a valid item type", input: "message", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseItemType(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("parseItemType(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("parseItemType(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("parseItemType(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseFlagType(t *testing.T) { + tests := []struct { + name string + input string + want FlagType + wantErr bool + }{ + {name: "message", input: "message", want: FlagTypeMessage}, + {name: "empty string defaults to message", input: "", want: FlagTypeMessage}, + {name: "feed", input: "feed", want: FlagTypeFeed}, + {name: "case insensitive", input: "FEED", want: FlagTypeFeed}, + {name: "with whitespace", input: " feed ", want: FlagTypeFeed}, + {name: "invalid", input: "invalid_type", wantErr: true}, + {name: "unknown is not a valid flag type", input: "unknown", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseFlagType(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("parseFlagType(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("parseFlagType(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("parseFlagType(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseItemID(t *testing.T) { + tests := []struct { + name string + input string + wantIT ItemType + wantFT FlagType + wantErr bool + errContain string + }{ + {name: "om prefix", input: "om_abc123", wantIT: ItemTypeDefault, wantFT: FlagTypeMessage}, + {name: "with whitespace", input: " om_abc123 ", wantIT: ItemTypeDefault, wantFT: FlagTypeMessage}, + {name: "empty string", input: "", wantErr: true, errContain: "cannot be empty"}, + {name: "unknown prefix", input: "oc_xxx", wantErr: true, errContain: "cannot infer"}, + {name: "omt prefix is not special", input: "omt_xyz789", wantErr: true, errContain: "cannot infer"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIT, gotFT, err := parseItemID(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("parseItemID(%q) expected error, got nil", tt.input) + } + if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) { + t.Fatalf("parseItemID(%q) error = %q, want to contain %q", tt.input, err.Error(), tt.errContain) + } + return + } + if err != nil { + t.Fatalf("parseItemID(%q) unexpected error: %v", tt.input, err) + } + if gotIT != tt.wantIT { + t.Fatalf("parseItemID(%q) itemType = %v, want %v", tt.input, gotIT, tt.wantIT) + } + if gotFT != tt.wantFT { + t.Fatalf("parseItemID(%q) flagType = %v, want %v", tt.input, gotFT, tt.wantFT) + } + }) + } +} + +func TestIsValidCombo(t *testing.T) { + tests := []struct { + name string + it ItemType + ft FlagType + want bool + }{ + {name: "default+message valid", it: ItemTypeDefault, ft: FlagTypeMessage, want: true}, + {name: "thread+feed valid", it: ItemTypeThread, ft: FlagTypeFeed, want: true}, + {name: "msg_thread+feed valid", it: ItemTypeMsgThread, ft: FlagTypeFeed, want: true}, + {name: "default+feed invalid", it: ItemTypeDefault, ft: FlagTypeFeed, want: false}, + {name: "thread+message invalid", it: ItemTypeThread, ft: FlagTypeMessage, want: false}, + {name: "msg_thread+message invalid", it: ItemTypeMsgThread, ft: FlagTypeMessage, want: false}, + {name: "unknown flag type", it: ItemTypeDefault, ft: FlagTypeUnknown, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isValidCombo(tt.it, tt.ft); got != tt.want { + t.Fatalf("isValidCombo(%v, %v) = %v, want %v", tt.it, tt.ft, got, tt.want) + } + }) + } +} + +func TestNewFlagItem(t *testing.T) { + tests := []struct { + name string + itemID string + it ItemType + ft FlagType + wantJSON string + }{ + { + name: "default+message", + itemID: "om_abc123", + it: ItemTypeDefault, + ft: FlagTypeMessage, + wantJSON: `{"item_id":"om_abc123","item_type":"0","flag_type":"2"}`, + }, + { + name: "thread+feed", + itemID: "om_xyz789", + it: ItemTypeThread, + ft: FlagTypeFeed, + wantJSON: `{"item_id":"om_xyz789","item_type":"4","flag_type":"1"}`, + }, + { + name: "msg_thread+feed", + itemID: "om_123", + it: ItemTypeMsgThread, + ft: FlagTypeFeed, + wantJSON: `{"item_id":"om_123","item_type":"11","flag_type":"1"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := newFlagItem(tt.itemID, tt.it, tt.ft) + if got.ItemID != tt.itemID { + t.Fatalf("newFlagItem().ItemID = %q, want %q", got.ItemID, tt.itemID) + } + if got.ItemType != stringInt(int(tt.it)) { + t.Fatalf("newFlagItem().ItemType = %q, want %q", got.ItemType, stringInt(int(tt.it))) + } + if got.FlagType != stringInt(int(tt.ft)) { + t.Fatalf("newFlagItem().FlagType = %q, want %q", got.FlagType, stringInt(int(tt.ft))) + } + }) + } +} + +func TestParseItemTypeFromRaw(t *testing.T) { + tests := []struct { + name string + input string + want ItemType + }{ + {name: "default", input: "0", want: ItemTypeDefault}, + {name: "thread", input: "4", want: ItemTypeThread}, + {name: "msg_thread", input: "11", want: ItemTypeMsgThread}, + {name: "unknown defaults to default", input: "999", want: ItemTypeDefault}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseItemTypeFromRaw(tt.input); got != tt.want { + t.Fatalf("parseItemTypeFromRaw(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseFlagTypeFromRaw(t *testing.T) { + tests := []struct { + input string + want FlagType + }{ + {input: "1", want: FlagTypeFeed}, + {input: "2", want: FlagTypeMessage}, + {input: "999", want: FlagTypeUnknown}, // unknown + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := parseFlagTypeFromRaw(tt.input); got != tt.want { + t.Fatalf("parseFlagTypeFromRaw(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// helper +func stringInt(v int) string { + return strconv.Itoa(v) +} + +func setFlag(t *testing.T, cmd *cobra.Command, name, value string) { + t.Helper() + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Set %s error = %v", name, err) + } +} + +func newFlagScopeTestCmd(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + return cmd +} + +type scopedTokenResolver struct { + scopes string +} + +func (r scopedTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) { + return &credential.TokenResult{Token: "user-token", Scopes: r.scopes}, nil +} + +type errorTokenResolver struct { + err error +} + +func (r errorTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) { + return nil, r.err +} + +func setRuntimeScopes(t *testing.T, rt *common.RuntimeContext, scopes string) { + t.Helper() + rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, scopedTokenResolver{scopes: scopes}, nil) +} + +func setRuntimeTokenError(t *testing.T, rt *common.RuntimeContext, err error) { + t.Helper() + rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, errorTokenResolver{err: err}, nil) +} + +func TestFlagMessageID(t *testing.T) { + tests := []struct { + name string + id string + want string + wantErr bool + errContain string + }{ + {name: "trims message id", id: " om_abc ", want: "om_abc"}, + {name: "missing message id", id: "", wantErr: true, errContain: "--message-id is required"}, + {name: "thread id rejected", id: "omt_abc", wantErr: true, errContain: "omt_ prefix is a thread ID"}, + {name: "non message id rejected", id: "oc_abc", wantErr: true, errContain: "must start with om_"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", tt.id) + got, err := flagMessageID(&common.RuntimeContext{Cmd: cmd}) + if tt.wantErr { + if err == nil { + t.Fatalf("flagMessageID() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errContain) { + t.Fatalf("flagMessageID() error = %q, want %q", err.Error(), tt.errContain) + } + return + } + if err != nil { + t.Fatalf("flagMessageID() error = %v", err) + } + if got != tt.want { + t.Fatalf("flagMessageID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseExplicitFlagCombo(t *testing.T) { + tests := []struct { + name string + itemType string + flagType string + wantItem ItemType + wantFlag FlagType + wantItemOK bool + wantFlagOK bool + wantErr bool + errContain string + }{ + {name: "no overrides"}, + {name: "valid feed thread", itemType: "thread", flagType: "feed", wantItem: ItemTypeThread, wantFlag: FlagTypeFeed, wantItemOK: true, wantFlagOK: true}, + {name: "valid message default", itemType: "default", flagType: "message", wantItem: ItemTypeDefault, wantFlag: FlagTypeMessage, wantItemOK: true, wantFlagOK: true}, + {name: "flag only", flagType: "feed", wantFlag: FlagTypeFeed, wantFlagOK: true}, + {name: "item only requires flag", itemType: "thread", wantErr: true, errContain: "requires --flag-type=feed"}, + {name: "invalid pair", itemType: "thread", flagType: "message", wantErr: true, errContain: "invalid --item-type=thread --flag-type=message combination"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseExplicitFlagCombo(tt.itemType, tt.flagType) + if tt.wantErr { + if err == nil { + t.Fatalf("parseExplicitFlagCombo() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errContain) { + t.Fatalf("parseExplicitFlagCombo() error = %q, want %q", err.Error(), tt.errContain) + } + return + } + if err != nil { + t.Fatalf("parseExplicitFlagCombo() error = %v", err) + } + if got.ItemTypeSet != tt.wantItemOK { + t.Fatalf("ItemTypeSet = %v, want %v", got.ItemTypeSet, tt.wantItemOK) + } + if got.FlagTypeSet != tt.wantFlagOK { + t.Fatalf("FlagTypeSet = %v, want %v", got.FlagTypeSet, tt.wantFlagOK) + } + if got.ItemTypeSet && got.ItemType != tt.wantItem { + t.Fatalf("ItemType = %v, want %v", got.ItemType, tt.wantItem) + } + if got.FlagTypeSet && got.FlagType != tt.wantFlag { + t.Fatalf("FlagType = %v, want %v", got.FlagType, tt.wantFlag) + } + }) + } +} + +func TestBuildCreateItem(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantItem flagItem + wantErr bool + errContain string + }{ + { + name: "message id defaults to message type", + flags: map[string]string{ + "message-id": "om_abc123", + }, + wantItem: newFlagItem("om_abc123", ItemTypeDefault, FlagTypeMessage), + }, + { + name: "explicit item-type and flag-type", + flags: map[string]string{ + "message-id": "om_xyz789", + "item-type": "thread", + "flag-type": "feed", + }, + wantItem: newFlagItem("om_xyz789", ItemTypeThread, FlagTypeFeed), + }, + { + name: "explicit msg_thread type", + flags: map[string]string{ + "message-id": "om_abc", + "item-type": "msg_thread", + "flag-type": "feed", + }, + wantItem: newFlagItem("om_abc", ItemTypeMsgThread, FlagTypeFeed), + }, + { + name: "explicit flag-type message", + flags: map[string]string{ + "message-id": "om_abc", + "flag-type": "message", + }, + wantItem: newFlagItem("om_abc", ItemTypeDefault, FlagTypeMessage), + }, + { + name: "missing message-id", + flags: map[string]string{ + "item-type": "default", + }, + wantErr: true, + errContain: "--message-id is required", + }, + { + name: "only item-type thread without flag-type should error", + flags: map[string]string{ + "message-id": "om_abc", + "item-type": "thread", + }, + wantErr: true, + errContain: "--item-type=thread requires --flag-type=feed", + }, + { + name: "only item-type msg_thread without flag-type should error", + flags: map[string]string{ + "message-id": "om_abc", + "item-type": "msg_thread", + }, + wantErr: true, + errContain: "--item-type=msg_thread requires --flag-type=feed", + }, + { + name: "only item-type default without flag-type should error", + flags: map[string]string{ + "message-id": "om_abc", + "item-type": "default", + }, + wantErr: true, + errContain: "--item-type=default requires --flag-type=message", + }, + { + name: "invalid item-type", + flags: map[string]string{ + "message-id": "om_abc", + "item-type": "invalid", + "flag-type": "feed", + }, + wantErr: true, + errContain: "invalid --item-type", + }, + { + name: "invalid flag-type", + flags: map[string]string{ + "message-id": "om_abc", + "item-type": "thread", + "flag-type": "invalid", + }, + wantErr: true, + errContain: "invalid --flag-type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + for name := range tt.flags { + cmd.Flags().String(name, "", "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, val := range tt.flags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + runtime := &common.RuntimeContext{Cmd: cmd} + + got, err := buildCreateItem(runtime) + if tt.wantErr { + if err == nil { + t.Fatalf("buildCreateItem() expected error, got nil") + } + if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) { + t.Fatalf("buildCreateItem() error = %q, want to contain %q", err.Error(), tt.errContain) + } + return + } + if err != nil { + t.Fatalf("buildCreateItem() unexpected error: %v", err) + } + if got.ItemID != tt.wantItem.ItemID { + t.Fatalf("buildCreateItem().ItemID = %q, want %q", got.ItemID, tt.wantItem.ItemID) + } + if got.ItemType != tt.wantItem.ItemType { + t.Fatalf("buildCreateItem().ItemType = %q, want %q", got.ItemType, tt.wantItem.ItemType) + } + if got.FlagType != tt.wantItem.FlagType { + t.Fatalf("buildCreateItem().FlagType = %q, want %q", got.FlagType, tt.wantItem.FlagType) + } + }) + } +} + +func TestFlagShortcutStaticScopesIncludeLookupRequirements(t *testing.T) { + wantWriteLookup := append([]string{flagWriteScope}, flagLookupScopes...) + if got := ImFlagCreate.ScopesForIdentity("user"); strings.Join(got, ",") != strings.Join(wantWriteLookup, ",") { + t.Fatalf("ImFlagCreate scopes = %#v, want %#v", got, wantWriteLookup) + } + if got := ImFlagCancel.ScopesForIdentity("user"); strings.Join(got, ",") != strings.Join(wantWriteLookup, ",") { + t.Fatalf("ImFlagCancel scopes = %#v, want %#v", got, wantWriteLookup) + } + if got := ImFlagList.ScopesForIdentity("user"); len(got) != 1 || got[0] != flagReadScope { + t.Fatalf("ImFlagList scopes = %#v, want only %s", got, flagReadScope) + } +} + +func TestFlagCreateExplicitFeedTypeDoesNotRequireLookupScopes(t *testing.T) { + var calls int + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("explicit type should not call lookup API: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, flagWriteScope) + + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", "om_123") + setFlag(t, cmd, "flag-type", "feed") + setFlag(t, cmd, "item-type", "msg_thread") + setRuntimeField(t, rt, "Cmd", cmd) + + got, err := buildCreateItem(rt) + if err != nil { + t.Fatalf("buildCreateItem() error = %v", err) + } + if got.ItemType != "11" || got.FlagType != "1" { + t.Fatalf("buildCreateItem() = %+v, want msg_thread/feed", got) + } + if calls != 0 { + t.Fatalf("buildCreateItem() made %d lookup call(s), want 0", calls) + } +} + +func TestFlagCreateAutoDetectReliesOnDeclaredLookupScopes(t *testing.T) { + var calls int + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("should fail scope check before lookup API: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, flagWriteScope) + + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", "om_123") + setFlag(t, cmd, "flag-type", "feed") + setRuntimeField(t, rt, "Cmd", cmd) + + _, err := buildCreateItem(rt) + if err == nil || !strings.Contains(err.Error(), "should fail scope check before lookup API") { + t.Fatalf("buildCreateItem() error = %v, want lookup API attempt", err) + } + if calls != 1 { + t.Fatalf("buildCreateItem() made %d lookup call(s), want 1", calls) + } +} + +func TestCheckFlagRequiredScopesReportsTokenResolutionError(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("checkFlagRequiredScopes should not call API") + return nil, nil + })) + setRuntimeTokenError(t, rt, errors.New("token cache unavailable")) + + err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("checkFlagRequiredScopes() error = %T %v, want ExitError", err, err) + } + if exitErr.Code != output.ExitAuth || exitErr.Detail.Type != "auth" { + t.Fatalf("checkFlagRequiredScopes() detail = %+v code=%d, want auth exit", exitErr.Detail, exitErr.Code) + } + if !strings.Contains(exitErr.Detail.Message, "cannot verify required scope") { + t.Fatalf("message = %q, want scope verification context", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, strings.Join(flagMessageReadScopes, " ")) { + t.Fatalf("hint = %q, want required scopes", exitErr.Detail.Hint) + } +} + +func TestCheckFlagRequiredScopesAllowsMissingScopeMetadata(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("checkFlagRequiredScopes should not call API") + return nil, nil + })) + setRuntimeScopes(t, rt, "") + + err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes) + if err != nil { + t.Fatalf("checkFlagRequiredScopes() error = %v, want nil for missing scope metadata", err) + } + errOut := rt.Factory.IOStreams.ErrOut.(*bytes.Buffer).String() + if !strings.Contains(errOut, "warning: cannot verify required scope(s)") { + t.Fatalf("stderr = %q, want scope metadata warning", errOut) + } + if !strings.Contains(errOut, strings.Join(flagMessageReadScopes, " ")) { + t.Fatalf("stderr = %q, want required scopes", errOut) + } +} + +func TestFlagCancelExplicitTypeDoesNotRequireLookupScopes(t *testing.T) { + var calls int + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("explicit type should not call lookup API: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, flagWriteScope) + + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", "om_123") + setFlag(t, cmd, "flag-type", "feed") + setFlag(t, cmd, "item-type", "msg_thread") + setRuntimeField(t, rt, "Cmd", cmd) + + got, err := buildCancelItems(rt) + if err != nil { + t.Fatalf("buildCancelItems() error = %v", err) + } + if len(got) != 1 || got[0].ItemType != "11" || got[0].FlagType != "1" { + t.Fatalf("buildCancelItems() = %+v, want single msg_thread/feed item", got) + } + if calls != 0 { + t.Fatalf("buildCancelItems() made %d lookup call(s), want 0", calls) + } +} + +func TestFlagCancelDefaultReliesOnDeclaredLookupScopes(t *testing.T) { + var calls int + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("should fail scope check before lookup API: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, flagWriteScope) + + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", "om_123") + setRuntimeField(t, rt, "Cmd", cmd) + + got, err := buildCancelItems(rt) + if err != nil { + t.Fatalf("buildCancelItems() error = %v", err) + } + if len(got) != 1 || got[0].ItemType != "0" || got[0].FlagType != "2" { + t.Fatalf("buildCancelItems() = %+v, want default/message fallback", got) + } + if calls != 1 { + t.Fatalf("buildCancelItems() made %d lookup call(s), want 1", calls) + } +} + +func TestFlagCreateDryRunFeedDoesNotCallAPI(t *testing.T) { + var calls int + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("dry-run must not call API: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + if err := cmd.Flags().Set("message-id", "om_123"); err != nil { + t.Fatalf("Set message-id error = %v", err) + } + if err := cmd.Flags().Set("flag-type", "feed"); err != nil { + t.Fatalf("Set flag-type error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + got := ImFlagCreate.DryRun(context.Background(), rt).Format() + if calls != 0 { + t.Fatalf("DryRun made %d API call(s), want 0", calls) + } + if !strings.Contains(got, "auto:thread|msg_thread") { + t.Fatalf("DryRun output = %s, want auto-detect placeholder", got) + } +} + +func TestFlagListSkillDocUsesExistingMessageFetchShortcut(t *testing.T) { + b, err := os.ReadFile("../../skills/lark-im/references/lark-im-flag-list.md") + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + doc := string(b) + if strings.Contains(doc, "+message-get") { + t.Fatalf("flag-list skill doc references nonexistent +message-get shortcut") + } + if !strings.Contains(doc, "+messages-mget") { + t.Fatalf("flag-list skill doc should mention +messages-mget") + } +} + +func TestBuildCancelItemsForPreview(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantLen int + wantDouble bool + wantErr bool + }{ + { + name: "om prefix dry-run assumes double-cancel", + flags: map[string]string{"message-id": "om_abc"}, + wantLen: 2, + wantDouble: true, + }, + { + name: "explicit flag-type single cancel", + flags: map[string]string{"message-id": "om_abc", "flag-type": "message"}, + wantLen: 1, + wantDouble: false, + }, + { + name: "explicit item-type single cancel", + flags: map[string]string{"message-id": "om_abc", "item-type": "default"}, + wantLen: 1, + wantDouble: false, + }, + { + name: "missing id", + flags: map[string]string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + for name := range tt.flags { + cmd.Flags().String(name, "", "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, val := range tt.flags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + runtime := &common.RuntimeContext{Cmd: cmd} + + got, isDouble, err := buildCancelItemsForPreview(runtime) + if tt.wantErr { + if err == nil { + t.Fatalf("buildCancelItemsForPreview() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("buildCancelItemsForPreview() unexpected error: %v", err) + } + if len(got) != tt.wantLen { + t.Fatalf("buildCancelItemsForPreview() returned %d items, want %d", len(got), tt.wantLen) + } + if isDouble != tt.wantDouble { + t.Fatalf("buildCancelItemsForPreview() isDouble = %v, want %v", isDouble, tt.wantDouble) + } + }) + } +} + +func TestFlagCancelDryRunReportsValidationError(t *testing.T) { + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", "om_123") + setFlag(t, cmd, "flag-type", "invalid") + + runtime := &common.RuntimeContext{Cmd: cmd} + got := ImFlagCancel.DryRun(context.Background(), runtime).Format() + + if !strings.Contains(got, "invalid --flag-type") { + t.Fatalf("DryRun output = %q, want validation error", got) + } + if strings.Contains(got, "flag_items") { + t.Fatalf("DryRun output = %q, should not include request body for invalid input", got) + } +} + +func TestFlagCancelDryRunUsesAutoDetectPlaceholderForFeedLayer(t *testing.T) { + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", "om_123") + + runtime := &common.RuntimeContext{Cmd: cmd} + got := ImFlagCancel.DryRun(context.Background(), runtime).Format() + + if !strings.Contains(got, "auto:thread|msg_thread") { + t.Fatalf("DryRun output = %q, want auto-detect placeholder", got) + } + if strings.Contains(got, `"item_type":"11"`) { + t.Fatalf("DryRun output = %q, should not hard-code msg_thread item_type", got) + } +} + +func TestBuildSingleCancelItem(t *testing.T) { + tests := []struct { + name string + id string + itOverride string + ftOverride string + wantIT ItemType + wantFT FlagType + wantErr bool + }{ + { + name: "om id infers default+message", + id: "om_abc", + wantIT: ItemTypeDefault, + wantFT: FlagTypeMessage, + }, + { + name: "explicit override", + id: "om_xyz", + itOverride: "msg_thread", + ftOverride: "feed", + wantIT: ItemTypeMsgThread, + wantFT: FlagTypeFeed, + }, + { + name: "only item-type override", + id: "om_xyz", + itOverride: "msg_thread", + wantErr: true, // msg_thread + message (inferred from om_) is invalid + }, + { + name: "only flag-type override", + id: "om_xyz", + ftOverride: "feed", + wantErr: true, // default (inferred from om_) + feed is invalid + }, + { + name: "invalid combo: thread+message", + id: "om_abc", + itOverride: "thread", + wantErr: true, // thread + message (inferred from om_) is invalid + }, + { + name: "invalid combo: default+feed", + id: "om_xyz", + ftOverride: "feed", + wantErr: true, // default (inferred) + feed is invalid + }, + { + name: "empty id", + id: "", + wantErr: true, + }, + { + name: "invalid item-type override", + id: "om_abc", + itOverride: "invalid", + wantErr: true, + }, + { + name: "invalid flag-type override", + id: "om_abc", + ftOverride: "invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildSingleCancelItem(tt.id, tt.itOverride, tt.ftOverride) + if tt.wantErr { + if err == nil { + t.Fatalf("buildSingleCancelItem() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("buildSingleCancelItem() unexpected error: %v", err) + } + if got.ItemID != tt.id { + t.Fatalf("buildSingleCancelItem().ItemID = %q, want %q", got.ItemID, tt.id) + } + if got.ItemType != stringInt(int(tt.wantIT)) { + t.Fatalf("buildSingleCancelItem().ItemType = %q, want %q", got.ItemType, stringInt(int(tt.wantIT))) + } + if got.FlagType != stringInt(int(tt.wantFT)) { + t.Fatalf("buildSingleCancelItem().FlagType = %q, want %q", got.FlagType, stringInt(int(tt.wantFT))) + } + }) + } +} + +func TestListQuery(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().String("page-token", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + if err := cmd.Flags().Set("page-size", "20"); err != nil { + t.Fatalf("Set page-size error = %v", err) + } + if err := cmd.Flags().Set("page-token", "next_token"); err != nil { + t.Fatalf("Set page-token error = %v", err) + } + runtime := &common.RuntimeContext{Cmd: cmd} + + got := listQuery(runtime) + if got["page_size"][0] != "20" { + t.Fatalf("listQuery() page_size = %q, want %q", got["page_size"][0], "20") + } + if got["page_token"][0] != "next_token" { + t.Fatalf("listQuery() page_token = %q, want %q", got["page_token"][0], "next_token") + } +} + +func TestFlagListRejectsInvalidPageLimit(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().Bool("page-all", false, "") + cmd.Flags().Int("page-limit", 20, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + if err := cmd.Flags().Set("page-limit", "0"); err != nil { + t.Fatalf("Set page-limit error = %v", err) + } + runtime := &common.RuntimeContext{Cmd: cmd} + + if err := ImFlagList.Validate(context.Background(), runtime); err == nil { + t.Fatalf("Validate() expected page-limit error, got nil") + } + + got := ImFlagList.DryRun(context.Background(), runtime).Format() + if !strings.Contains(got, "--page-limit") { + t.Fatalf("DryRun output = %q, want page-limit validation error", got) + } + if strings.Contains(got, "/open-apis/im/v1/flags") { + t.Fatalf("DryRun output = %q, should not include request for invalid input", got) + } +} + +func TestFlagListDryRunMentionsConditionalEnrichmentScopes(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().Bool("page-all", false, "") + cmd.Flags().Int("page-limit", 20, "") + cmd.Flags().Bool("enrich-feed-thread", true, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + runtime := &common.RuntimeContext{Cmd: cmd} + + got := ImFlagList.DryRun(context.Background(), runtime).Format() + for _, want := range []string{ + "im:message.group_msg:get_as_user", + "im:message.p2p_msg:get_as_user", + "--enrich-feed-thread=false", + } { + if !strings.Contains(got, want) { + t.Fatalf("DryRun output = %q, want %q", got, want) + } + } +} + +func TestAsString(t *testing.T) { + tests := []struct { + input any + want string + }{ + {input: "hello", want: "hello"}, + {input: "", want: ""}, + {input: 123, want: "123"}, + {input: int(456), want: "456"}, + {input: float64(78.9), want: "78.9"}, + {input: nil, want: ""}, + {input: []string{"a"}, want: ""}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + if got := asString(tt.input); got != tt.want { + t.Fatalf("asString(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestEnrichFeedThreadItems(t *testing.T) { + tests := []struct { + name string + data map[string]any + wantMsg bool // whether message should be attached + wantErr bool + mockMGet map[string]any // mock mget response + mockMGetOK bool + }{ + { + name: "empty flag_items", + data: map[string]any{ + "flag_items": []any{}, + }, + wantErr: false, + }, + { + name: "non-feed item skipped", + data: map[string]any{ + "flag_items": []any{ + map[string]any{ + "item_id": "om_123", + "item_type": "0", + "flag_type": "2", // message type + }, + }, + }, + wantErr: false, + }, + { + name: "feed-thread item with inlined message", + data: map[string]any{ + "flag_items": []any{ + map[string]any{ + "item_id": "omt_123", + "item_type": "4", + "flag_type": "1", // feed type + }, + }, + "messages": []any{ + map[string]any{ + "message_id": "omt_123", + "content": "hello", + }, + }, + }, + wantErr: false, + wantMsg: true, + }, + { + name: "feed-thread item needs mget", + data: map[string]any{ + "flag_items": []any{ + map[string]any{ + "item_id": "omt_456", + "item_type": "4", + "flag_type": "1", + }, + }, + }, + mockMGetOK: true, + mockMGet: map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "omt_456", + "content": "fetched content", + }, + }, + }, + wantErr: false, + wantMsg: true, + }, + { + name: "msg_thread item needs mget", + data: map[string]any{ + "flag_items": []any{ + map[string]any{ + "item_id": "om_789", + "item_type": "11", // msg_thread + "flag_type": "1", + }, + }, + }, + mockMGetOK: true, + mockMGet: map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "om_789", + "content": "msg_thread content", + }, + }, + }, + wantErr: false, + wantMsg: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") { + if !tt.mockMGetOK { + return nil, fmt.Errorf("unexpected mget call") + } + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": tt.mockMGet, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, strings.Join(flagMessageReadScopes, " ")) + + err := enrichFeedThreadItems(rt, tt.data) + if tt.wantErr { + if err == nil { + t.Fatalf("enrichFeedThreadItems() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("enrichFeedThreadItems() unexpected error: %v", err) + } + + if tt.wantMsg { + items := tt.data["flag_items"].([]any) + if len(items) == 0 { + t.Fatalf("expected flag_items") + } + item := items[0].(map[string]any) + if _, ok := item["message"]; !ok { + t.Fatalf("expected message to be attached to item") + } + } + }) + } +} + +func TestEnrichFeedThreadItems_BatchMGet(t *testing.T) { + // Test that batched mget works when > 50 items + var feedItems []any + for i := 0; i < 60; i++ { + feedItems = append(feedItems, map[string]any{ + "item_id": fmt.Sprintf("omt_%d", i), + "item_type": "4", + "flag_type": "1", + }) + } + + callCount := 0 + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") { + callCount++ + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{}, + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, strings.Join(flagMessageReadScopes, " ")) + + data := map[string]any{"flag_items": feedItems} + err := enrichFeedThreadItems(rt, data) + if err != nil { + t.Fatalf("enrichFeedThreadItems() error = %v", err) + } + // Should make 2 calls: 50 + 10 items + if callCount != 2 { + t.Fatalf("expected 2 mget calls, got %d", callCount) + } +} + +func TestEnrichFeedThreadItems_MGetError(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") { + return nil, fmt.Errorf("mget failed") + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + setRuntimeScopes(t, rt, strings.Join(flagMessageReadScopes, " ")) + + data := map[string]any{ + "flag_items": []any{ + map[string]any{ + "item_id": "omt_123", + "item_type": "4", + "flag_type": "1", + }, + }, + } + + err := enrichFeedThreadItems(rt, data) + if err == nil { + t.Fatalf("enrichFeedThreadItems() expected error, got nil") + } +} + +func TestAsStringFloat(t *testing.T) { + // Test float64 conversion specifically (JSON numbers come as float64) + tests := []struct { + input float64 + want string + }{ + {input: 1.0, want: "1"}, + {input: 123.456, want: "123.456"}, + {input: 0.0, want: "0"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := asString(tt.input); got != tt.want { + t.Fatalf("asString(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAsStringInt(t *testing.T) { + // Test int conversion + tests := []struct { + input int + want string + }{ + {input: 1, want: "1"}, + {input: 123, want: "123"}, + {input: 0, want: "0"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := asString(tt.input); got != tt.want { + t.Fatalf("asString(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBuildCancelItems_ExplicitOverride(t *testing.T) { + // Test buildCancelItems with explicit item-type and flag-type override + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + if err := cmd.Flags().Set("message-id", "om_xyz"); err != nil { + t.Fatalf("Set message-id error = %v", err) + } + if err := cmd.Flags().Set("item-type", "msg_thread"); err != nil { + t.Fatalf("Set item-type error = %v", err) + } + if err := cmd.Flags().Set("flag-type", "feed"); err != nil { + t.Fatalf("Set flag-type error = %v", err) + } + runtime := &common.RuntimeContext{Cmd: cmd} + + got, err := buildCancelItems(runtime) + if err != nil { + t.Fatalf("buildCancelItems() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("buildCancelItems() returned %d items, want 1", len(got)) + } + if got[0].ItemID != "om_xyz" { + t.Fatalf("buildCancelItems().ItemID = %q, want %q", got[0].ItemID, "om_xyz") + } + if got[0].ItemType != "11" { + t.Fatalf("buildCancelItems().ItemType = %q, want 11", got[0].ItemType) + } + if got[0].FlagType != "1" { + t.Fatalf("buildCancelItems().FlagType = %q, want 1", got[0].FlagType) + } +} + +func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) { + var cancelCalls int + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_123"): + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "om_123", + "chat_id": "oc_chat", + }, + }, + }, + }), nil + case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_chat"): + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{"chat_mode": "group"}, + }), nil + case strings.Contains(req.URL.Path, "/open-apis/im/v1/flags/cancel"): + cancelCalls++ + if cancelCalls == 1 { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{"request_id": "message-ok"}, + }), nil + } + return shortcutJSONResponse(200, map[string]any{ + "code": 999, + "msg": "feed failed", + }), nil + default: + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + } + })) + + cmd := newFlagScopeTestCmd(t) + setFlag(t, cmd, "message-id", "om_123") + setRuntimeField(t, rt, "Cmd", cmd) + + err := ImFlagCancel.Execute(context.Background(), rt) + if err == nil { + t.Fatalf("Execute() expected partial failure error, got nil") + } + + out := rt.Factory.IOStreams.Out.(*bytes.Buffer).String() + for _, want := range []string{`"results"`, `"item_type": "default"`, `"flag_type": "message"`, `"status": "ok"`, `"item_type": "msg_thread"`, `"flag_type": "feed"`, `"status": "failed"`, "feed failed"} { + if !strings.Contains(out, want) { + t.Fatalf("stdout = %s, want %q", out, want) + } + } + + var envelope struct { + Data struct { + Results []map[string]any `json:"results"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &envelope); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, out) + } + if len(envelope.Data.Results) != 2 { + t.Fatalf("results len = %d, want 2", len(envelope.Data.Results)) + } +} + +func TestBuildCancelItems_OnlyItemTypeOverride(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + if err := cmd.Flags().Set("message-id", "om_xyz"); err != nil { + t.Fatalf("Set message-id error = %v", err) + } + if err := cmd.Flags().Set("item-type", "thread"); err != nil { + t.Fatalf("Set item-type error = %v", err) + } + runtime := &common.RuntimeContext{Cmd: cmd} + + _, err := buildCancelItems(runtime) + // om_xyz + thread -> inferred flag-type=message from om_ prefix + // thread + message is invalid combo, should error + if err == nil { + t.Fatalf("buildCancelItems() expected error for invalid combo, got nil") + } +} + +func TestBuildCancelItems_OmPrefixThreadRoot(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_123") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "om_123", + + "chat_id": "oc_chat", + }, + }, + }, + }), nil + } + if strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "chat_mode": "group", + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + if err := cmd.Flags().Set("message-id", "om_123"); err != nil { + t.Fatalf("Set message-id error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + got, err := buildCancelItems(rt) + if err != nil { + t.Fatalf("buildCancelItems() error = %v", err) + } + // Thread root message should produce double-cancel + if len(got) != 2 { + t.Fatalf("buildCancelItems() returned %d items, want 2", len(got)) + } + // First item should be default+message + if got[0].ItemType != "0" || got[0].FlagType != "2" { + t.Fatalf("first item = %+v, want default+message", got[0]) + } + // Second item should be msg_thread+feed (group chat) + if got[1].ItemType != "11" || got[1].FlagType != "1" { + t.Fatalf("second item = %+v, want msg_thread+feed", got[1]) + } +} + +func TestBuildCancelItems_MessageQueryFails(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("API error") + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + if err := cmd.Flags().Set("message-id", "om_789"); err != nil { + t.Fatalf("Set message-id error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + got, err := buildCancelItems(rt) + if err != nil { + t.Fatalf("buildCancelItems() error = %v", err) + } + // When message query fails, should still cancel message layer (best effort) + // Feed layer is skipped since we can't determine chat_type + if len(got) != 1 { + t.Fatalf("buildCancelItems() returned %d items, want 1", len(got)) + } + if got[0].ItemType != "0" || got[0].FlagType != "2" { + t.Fatalf("item = %+v, want default+message", got[0]) + } +} + +func TestBuildCancelItems_MissingID(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + runtime := &common.RuntimeContext{Cmd: cmd} + + _, err := buildCancelItems(runtime) + if err == nil { + t.Fatalf("buildCancelItems() expected error, got nil") + } +} + +func TestExecuteListAllPages(t *testing.T) { + callCount := 0 + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") { + callCount++ + hasMore := callCount < 2 + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "flag_items": []any{ + map[string]any{"item_id": fmt.Sprintf("om_%d", callCount)}, + }, + "delete_flag_items": []any{}, + "messages": []any{}, + "has_more": hasMore, + "page_token": fmt.Sprintf("token_%d", callCount), + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().Int("page-limit", 10, "") + cmd.Flags().Bool("enrich-feed-thread", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + err := executeListAllPages(rt) + if err != nil { + t.Fatalf("executeListAllPages() error = %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 API calls, got %d", callCount) + } +} + +func TestExecuteListAllPages_EnrichFeedThread(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "flag_items": []any{ + map[string]any{ + "item_id": "omt_123", + "item_type": "4", + "flag_type": "1", + }, + }, + "delete_flag_items": []any{}, + "messages": []any{}, + "has_more": false, + "page_token": "", + }, + }), nil + } + if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "omt_123", + "content": "test content", + }, + }, + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().Int("page-limit", 10, "") + cmd.Flags().Bool("enrich-feed-thread", true, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + err := executeListAllPages(rt) + if err != nil { + t.Fatalf("executeListAllPages() error = %v", err) + } +} + +func TestExecuteListAllPages_PageLimit(t *testing.T) { + callCount := 0 + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") { + callCount++ + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "flag_items": []any{}, + "delete_flag_items": []any{}, + "messages": []any{}, + "has_more": true, // always has more + "page_token": fmt.Sprintf("token_%d", callCount), + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().Int("page-limit", 3, "") // limit to 3 pages + cmd.Flags().Bool("enrich-feed-thread", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + err := executeListAllPages(rt) + if err != nil { + t.Fatalf("executeListAllPages() error = %v", err) + } + // Should stop at page-limit + if callCount != 3 { + t.Fatalf("expected 3 API calls (page limit), got %d", callCount) + } +} + +func TestExecuteListAllPages_APIError(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("API error") + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().Int("page-limit", 10, "") + cmd.Flags().Bool("enrich-feed-thread", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + err := executeListAllPages(rt) + if err == nil { + t.Fatalf("executeListAllPages() expected error, got nil") + } +} + +func TestBuildCreateItem_FeedAutoDetect(t *testing.T) { + // Test --flag-type feed auto-detects item_type from chat_mode + t.Run("topic-style chat", func(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_123") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "om_123", + "chat_id": "oc_chat", + }, + }, + }, + }), nil + } + if strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "chat_mode": "topic", + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + cmd.Flags().Set("message-id", "om_123") + cmd.Flags().Set("flag-type", "feed") + setRuntimeField(t, rt, "Cmd", cmd) + + got, err := buildCreateItem(rt) + if err != nil { + t.Fatalf("buildCreateItem() error = %v", err) + } + if got.ItemType != "4" { + t.Fatalf("ItemType = %q, want 4 (thread)", got.ItemType) + } + if got.FlagType != "1" { + t.Fatalf("FlagType = %q, want 1 (feed)", got.FlagType) + } + }) + + t.Run("regular chat", func(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_456") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "om_456", + "chat_id": "oc_chat", + }, + }, + }, + }), nil + } + if strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/") { + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "chat_mode": "group", + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("message-id", "", "") + cmd.Flags().String("item-type", "", "") + cmd.Flags().String("flag-type", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + cmd.Flags().Set("message-id", "om_456") + cmd.Flags().Set("flag-type", "feed") + setRuntimeField(t, rt, "Cmd", cmd) + + got, err := buildCreateItem(rt) + if err != nil { + t.Fatalf("buildCreateItem() error = %v", err) + } + if got.ItemType != "11" { + t.Fatalf("ItemType = %q, want 11 (msg_thread)", got.ItemType) + } + if got.FlagType != "1" { + t.Fatalf("FlagType = %q, want 1 (feed)", got.FlagType) + } + }) +} + +func TestValidateExplicitCombo(t *testing.T) { + tests := []struct { + name string + itOverride string + ftOverride string + wantErr bool + errContain string + }{ + {name: "no overrides", itOverride: "", ftOverride: "", wantErr: false}, + {name: "both valid", itOverride: "thread", ftOverride: "feed", wantErr: false}, + {name: "default+message", itOverride: "default", ftOverride: "message", wantErr: false}, + {name: "invalid combo", itOverride: "thread", ftOverride: "message", wantErr: true, errContain: "invalid"}, + {name: "item-type thread without flag-type", itOverride: "thread", ftOverride: "", wantErr: true, errContain: "requires --flag-type=feed"}, + {name: "item-type default without flag-type", itOverride: "default", ftOverride: "", wantErr: true, errContain: "requires --flag-type=message"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateExplicitCombo(tt.itOverride, tt.ftOverride) + if tt.wantErr { + if err == nil { + t.Fatalf("validateExplicitCombo() expected error, got nil") + } + if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) { + t.Fatalf("error = %q, want to contain %q", err.Error(), tt.errContain) + } + return + } + if err != nil { + t.Fatalf("validateExplicitCombo() unexpected error: %v", err) + } + }) + } +} + +func TestItemTypeString(t *testing.T) { + tests := []struct { + input ItemType + want string + }{ + {input: ItemTypeDefault, want: "default"}, + {input: ItemTypeThread, want: "thread"}, + {input: ItemTypeMsgThread, want: "msg_thread"}, + {input: ItemType(999), want: "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := itemTypeString(tt.input); got != tt.want { + t.Fatalf("itemTypeString(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestFlagTypeString(t *testing.T) { + tests := []struct { + input FlagType + want string + }{ + {input: FlagTypeMessage, want: "message"}, + {input: FlagTypeFeed, want: "feed"}, + {input: FlagTypeUnknown, want: "unknown"}, + {input: FlagType(999), want: "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := flagTypeString(tt.input); got != tt.want { + t.Fatalf("flagTypeString(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index e2fff3ba4..7422da7e0 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -18,5 +18,8 @@ func Shortcuts() []common.Shortcut { ImMessagesSearch, ImMessagesSend, ImThreadsMessagesList, + ImFlagCreate, + ImFlagCancel, + ImFlagList, } } diff --git a/skill-template/domains/im.md b/skill-template/domains/im.md index 462d0b513..7e7dce0d7 100644 --- a/skill-template/domains/im.md +++ b/skill-template/domains/im.md @@ -4,6 +4,7 @@ - **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx). - **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. ## Resource Relationships @@ -35,3 +36,14 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis ### Card Messages (Interactive) Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr. + +### Flag Types + +Flags support two layers: + +- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark +- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark + +Item types for feed-layer flags: +- **ItemTypeThread** (4) = thread in a topic-style chat +- **ItemTypeMsgThread** (11) = thread in a regular chat diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 7388067fc..551793179 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: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。" metadata: requires: bins: ["lark-cli"] @@ -18,6 +18,7 @@ metadata: - **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx). - **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. ## Resource Relationships @@ -50,6 +51,17 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr. +### Flag Types + +Flags support two layers: + +- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark +- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark + +Item types for feed-layer flags: +- **ItemTypeThread** (4) = thread in a topic-style chat +- **ItemTypeMsgThread** (11) = thread in a regular chat + ## Shortcuts(推荐优先使用) Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。有 Shortcut 的操作优先使用。 @@ -66,6 +78,9 @@ 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-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 | ## API Resources @@ -86,7 +101,7 @@ lark-cli im [flags] # 调用 API ### chat.members - - `bots` — 获取群内机器人列表。 Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats. + - `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats. - `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`. - `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request. - `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats. @@ -140,3 +155,4 @@ 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-flag-cancel.md b/skills/lark-im/references/lark-im-flag-cancel.md new file mode 100644 index 000000000..79aaeccc6 --- /dev/null +++ b/skills/lark-im/references/lark-im-flag-cancel.md @@ -0,0 +1,67 @@ +# im +flag-cancel + +> **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 +flag-cancel`. Underlying API: `POST /open-apis/im/v1/flags/cancel`. + +## Double-Cancel Behavior (Important) + +A message can have flags on both layers simultaneously: +- Message layer: `(default, message)` +- Feed layer: `(thread, feed)` or `(msg_thread, feed)` depending on chat type + +**When no `--flag-type` is specified, the shortcut performs double-cancel**: removes both message layer and feed layer flags. The server handles cancel requests for non-existent flags idempotently, so this is safe. + +**Feed layer item_type is determined by chat_mode**: +- Topic-style chat (`chat_mode=topic`) → `item_type=thread` +- Regular chat (`chat_mode=group`) → `item_type=msg_thread` + +## Commands + +```bash +# Double-cancel both layers (recommended default) +lark-cli im +flag-cancel --as user --message-id om_xxx + +# Only cancel message layer +lark-cli im +flag-cancel --as user --message-id om_xxx --flag-type message + +# Only cancel feed layer (need to specify item-type) +lark-cli im +flag-cancel --as user --message-id om_xxx --item-type thread --flag-type feed + +# Preview request +lark-cli im +flag-cancel --as user --message-id om_xxx --dry-run +``` + +## Parameters + +| Parameter | Required | Description | +|------|------|------| +| `--message-id ` | Required | Message ID | +| `--flag-type ` | No | `message` or `feed`; **when omitted, double-cancels both layers** | +| `--item-type ` | No | `default\|thread\|msg_thread`; required when `--flag-type feed` | +| `--as user` | Required | Currently only supports user identity | + +## Idempotency + +The server doesn't return an error for cancel requests when the flag doesn't exist, so repeated `+cancel` calls are idempotent. + +## Permissions + +- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read` +- The message/chat read scopes are used by the default double-cancel path to auto-detect the feed-layer item type. + +## Note + +- **Do not call +flag-list for verification**: If the cancel API returns success, the flag is removed. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary. + +## Finding Message ID Efficiently + +If you have message content but not the message ID: + +1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result +2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient + +```bash +# Search by message content to find message_id +lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id' +``` diff --git a/skills/lark-im/references/lark-im-flag-create.md b/skills/lark-im/references/lark-im-flag-create.md new file mode 100644 index 000000000..deb19e733 --- /dev/null +++ b/skills/lark-im/references/lark-im-flag-create.md @@ -0,0 +1,67 @@ +# im +flag-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 +flag-create`. Underlying API: `POST /open-apis/im/v1/flags`. + +## Default Behavior + +- **Message-layer flag** (default): `item_type=default, flag_type=message` +- **Feed-layer flag**: Use `--flag-type feed` — automatically detects chat type to determine `item_type`: + - Topic-style chat (`chat_mode=topic`) → `item_type=thread` + - Regular chat (`chat_mode=group`) → `item_type=msg_thread` + +## Commands + +```bash +# Flag a message (default: message-layer) +lark-cli im +flag-create --as user --message-id om_xxx + +# Create feed-layer flag (auto-detects chat type) +lark-cli im +flag-create --as user --message-id om_xxx --flag-type feed + +# Explicit item-type override (rarely needed) +lark-cli im +flag-create --as user --message-id om_xxx --item-type thread --flag-type feed + +# Preview request (dry-run, doesn't send) +lark-cli im +flag-create --as user --message-id om_xxx --dry-run +``` + +## Parameters + +| Parameter | Required | Description | +|------|------|------| +| `--message-id ` | Required | Message ID | +| `--flag-type ` | No | `message` (default) or `feed` | +| `--item-type ` | No | Override auto-detection: `default\|thread\|msg_thread` (rarely needed) | +| `--as user` | Required | Currently only supports user identity | + +## Valid Combinations + +The server only accepts these `(item_type, flag_type)` pairs: + +- `(default, message)` — regular message flag +- `(thread, feed)` — feed flag in topic-style chat +- `(msg_thread, feed)` — feed flag in regular chat + +## Permissions + +- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read` +- The message/chat read scopes are used when `--flag-type feed` is used without explicit `--item-type` so the CLI can auto-detect chat type. +- If missing, CLI will prompt with `lark-cli auth login --scope "..."` + +## Note + +- **Do not call +flag-list for verification**: If the create API returns success, the flag is created. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary. + +## Finding Message ID Efficiently + +If you have message content but not the message ID: + +1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result +2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient + +```bash +# Search by message content to find message_id +lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id' +``` diff --git a/skills/lark-im/references/lark-im-flag-list.md b/skills/lark-im/references/lark-im-flag-list.md new file mode 100644 index 000000000..913a89056 --- /dev/null +++ b/skills/lark-im/references/lark-im-flag-list.md @@ -0,0 +1,100 @@ +# im +flag-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 +flag-list`. Underlying API: `GET /open-apis/im/v1/flags`. + +## Sorting Rules (Important) + +The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, you cannot simply take the first page's items as the latest flags — you must paginate through all pages and take the last item on the last page as the newest. + +Recommended: use `--page-all` for auto-pagination to get the complete list, then use `-q '.data.flag_items[-1]'` to get the latest item. + +## Commands + +```bash +# Fetch first page (default page-size=50) +lark-cli im +flag-list --as user + +# Manual pagination with custom page size +lark-cli im +flag-list --as user --page-size 30 --page-token + +# Auto-paginate to get all flags (recommended) +lark-cli im +flag-list --as user --page-all + +# Auto-paginate + get the latest flag +lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]' + +# Auto-paginate + get only item_id list +lark-cli im +flag-list --as user --page-all -q '.data.flag_items[].item_id' + +# Disable auto-enrichment of message content (enabled by default) +lark-cli im +flag-list --as user --page-all --enrich-feed-thread=false + +# Limit max pages (default 20, max 1000) +lark-cli im +flag-list --as user --page-all --page-limit 10 +``` + +## Parameters + +| Parameter | Default | Description | +|------|------|------| +| `--page-size ` | 50 | Range 1-50 (server max is 50) | +| `--page-token ` | empty | Pagination token from previous page; empty string must still be provided | +| `--page-all` | false | Auto-paginate to fetch all pages and merge results | +| `--page-limit ` | 20 | Max pages in `--page-all` mode (max 1000) | +| `--enrich-feed-thread` | true | Auto-enrich feed-layer thread entries with message content (calls `im.messages.mget`) | +| `--as user` | Required | Currently only supports user identity | + +## Response Structure + +The response has `data` as the main body, with fields described below: + +| Field | Type | Description | +|------|------|------| +| `flag_items` | array | List of currently existing (not canceled) flags, sorted by `update_time` ascending | +| `delete_flag_items` | array | List of previously canceled flags, sorted by `update_time` ascending | +| `messages` | array | Message content inlined by the server for `(default, message)` type flags | +| `has_more` | boolean | Whether there's a next page | +| `page_token` | string | Pagination token for the next page | + +Note: `(thread, feed)` / `(msg_thread, feed)` entries are automatically enriched via `mget` by the shortcut, and written to the corresponding entry's `message` field. + +## Limitations + +- **delete_flag_items are not enriched**: Message content is only fetched for active flags (`flag_items`), not canceled flags (`delete_flag_items`). If you need message content for a canceled flag, query the message separately using `+messages-mget --message-ids `. + +## Response Example (Sanitized) + +```json +{ + "data": { + "delete_flag_items": [ + { + "create_time": "xxx", + "flag_type": "xxx", + "item_id": "xxx", + "item_type": "xxx", + "update_time": "xxx" + } + ], + "flag_items": [ + { + "create_time": "xxx", + "flag_type": "xxx", + "item_id": "xxx", + "item_type": "xxx", + "update_time": "xxx" + } + ], + "has_more": false, + "messages": [], + "page_token": "xxx" + } +} +``` + +## Permissions + +- Base scope: `im:feed.flag:read` +- Additional scopes only when `--enrich-feed-thread=true` needs to fetch missing message content: `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user` diff --git a/tests/cli_e2e/im/flag_workflow_test.go b/tests/cli_e2e/im/flag_workflow_test.go new file mode 100644 index 000000000..e6bfbd8d7 --- /dev/null +++ b/tests/cli_e2e/im/flag_workflow_test.go @@ -0,0 +1,304 @@ +// 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" +) + +func TestIM_FlagWorkflowAsUser(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-flag-" + suffix + messageText := "flag-test-msg-" + suffix + var chatID string + var messageID string + + t.Run("create chat as user", func(t *testing.T) { + chatID = createChatAs(t, parentT, ctx, chatName, "user") + }) + + t.Run("send message as user", func(t *testing.T) { + messageID = sendMessageAs(t, ctx, chatID, messageText, "user") + }) + + t.Run("create flag as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-create", + "--message-id", messageID, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("list flags as user", func(t *testing.T) { + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-list", + "--page-size", "10", + "--page-all", + }, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + // Check if our message is in the list + for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() { + if item.Get("item_id").String() == messageID { + return false + } + } + return true + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + // Verify our flagged message is in the list + var found bool + for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() { + if item.Get("item_id").String() == messageID { + found = true + // Verify it's a message-type flag (flag_type=2) + require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)") + break + } + } + require.True(t, found, "expected message %s in flag list", messageID) + }) + + t.Run("cancel flag as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-cancel", + "--message-id", messageID, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("verify flag removed", func(t *testing.T) { + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-list", + "--page-size", "10", + "--page-all", + }, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + // Check if our message is still in the list + for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() { + if item.Get("item_id").String() == messageID { + return true // Still there, retry + } + } + return false // Not found, success + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + // Verify our message is NOT in the list + for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() { + require.NotEqual(t, messageID, item.Get("item_id").String(), "message should not be in flag list after cancel") + } + }) +} + +func TestIM_FlagCreateWithExplicitTypeAsUser(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-flag-explicit-" + suffix + messageText := "flag-explicit-msg-" + suffix + var chatID string + var messageID string + + t.Run("create chat as user", func(t *testing.T) { + chatID = createChatAs(t, parentT, ctx, chatName, "user") + }) + + t.Run("send message as user", func(t *testing.T) { + messageID = sendMessageAs(t, ctx, chatID, messageText, "user") + }) + + t.Run("create flag with explicit types as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-create", + "--message-id", messageID, + "--item-type", "default", + "--flag-type", "message", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("list flags to verify explicit types as user", func(t *testing.T) { + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-list", + "--page-size", "10", + "--page-all", + }, + 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.flag_items").Array() { + if item.Get("item_id").String() == messageID { + return false + } + } + return true + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + // Verify explicit types were applied + var found bool + for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() { + if item.Get("item_id").String() == messageID { + found = true + require.Equal(t, "0", item.Get("item_type").String(), "expected item_type=0 (default)") + require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)") + break + } + } + require.True(t, found, "expected message %s in flag list", messageID) + }) + + t.Run("cancel flag with explicit types as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-cancel", + "--message-id", messageID, + "--flag-type", "message", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) +} + +func TestIM_FlagListPaginationAsUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + clie2e.SkipWithoutUserToken(t) + + t.Run("list flags with page-all as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-list", + "--page-size", "5", + "--page-all", + "--page-limit", "3", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) +} + +func TestIM_FlagDryRun(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 flag dry-run", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-create", + "--message-id", "om_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/v1/flags") + require.Contains(t, result.Stdout, "flag_items") + require.Contains(t, result.Stdout, "om_test_dry_run") + }) + + t.Run("cancel flag dry-run with om", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-cancel", + "--message-id", "om_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/v1/flags/cancel") + require.Contains(t, result.Stdout, "flag_items") + require.Contains(t, result.Stdout, "om_test_dry_run") + }) + + t.Run("list flag dry-run", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-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/v1/flags") + require.Contains(t, result.Stdout, "page_size") + }) +}