diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go
index 9086b06884..5b42c32b75 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 0bb39049f0..853d5e9f0b 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 0000000000..4539d1ad00
--- /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 0000000000..9ed2cb3992
--- /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 0000000000..6599bd0243
--- /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 0000000000..dbf3ad832f
--- /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 e2fff3ba43..7422da7e01 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 462d0b513b..7e7dce0d70 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 7388067fc6..5517931798 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 0000000000..79aaeccc60
--- /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 0000000000..deb19e733f
--- /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 0000000000..913a890560
--- /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 0000000000..e6bfbd8d76
--- /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")
+ })
+}