im

package
v1.0.54 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 15, 2026 License: MIT Imports: 30 Imported by: 0

Documentation

Index

Constants

View Source
const (
	SkipReasonBotIdentity  = "bot_identity_no_mute_data"
	SkipReasonAllNonMember = "all_non_member_search_types"
)

SkipReason constants — written to filter.skip_reason when Skipped=true.

View Source
const BatchGetMuteStatusPath = "/open-apis/im/v1/chat_user_setting/batch_get_mute_status"

BatchGetMuteStatusPath is the upstream HTTP path.

View Source
const MaxMuteStatusBatchSize = 100

MaxMuteStatusBatchSize is the upstream cap for chat_ids per batch_get_mute_status call (after dedupe).

Variables

View Source
var ImChatCreate = common.Shortcut{
	Service:     "im",
	Command:     "+chat-create",
	Description: "Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager",
	Risk:        "write",
	UserScopes:  []string{"im:chat:create_by_user"},
	BotScopes:   []string{"im:chat:create"},
	AuthTypes:   []string{"bot", "user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "name", Desc: "group name (required for public groups, max 60 chars)"},
		{Name: "description", Desc: "group description (max 100 chars)"},
		{Name: "users", Desc: "comma-separated user open_ids (ou_xxx) to invite, max 50"},
		{Name: "bots", Desc: "comma-separated bot app IDs (cli_xxx) to invite, max 5"},
		{Name: "owner", Desc: "owner open_id (ou_xxx); defaults to bot (--as bot) or authorized user (--as user)"},
		{Name: "type", Default: "private", Desc: "chat type", Enum: []string{"private", "public"}},
		{Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}},
		{Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		body := buildCreateChatBody(runtime)
		params := map[string]interface{}{"user_id_type": "open_id"}
		if runtime.Bool("set-bot-manager") && runtime.IsBot() {
			params["set_bot_manager"] = true
		}
		return common.NewDryRunAPI().
			POST("/open-apis/im/v1/chats").
			Params(params).
			Body(body)
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("set-bot-manager") && !runtime.IsBot() {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--set-bot-manager is only supported with bot identity (--as bot)").WithParam("--set-bot-manager")
		}

		name := runtime.Str("name")
		chatType := runtime.Str("type")

		if chatType == "public" && len([]rune(name)) < 2 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required for public groups and must be at least 2 characters").WithParam("--name")
		}

		if len([]rune(name)) > 60 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
		}

		if desc := runtime.Str("description"); len([]rune(desc)) > 100 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
		}

		if users := runtime.Str("users"); users != "" {
			ids := common.SplitCSV(users)
			if len(ids) > 50 {
				return errs.NewValidationError(errs.SubtypeInvalidArgument, "--users exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--users")
			}
			for _, id := range ids {
				if _, err := common.ValidateUserIDTyped("--users", id); err != nil {
					return err
				}
			}
		}

		if bots := runtime.Str("bots"); bots != "" {
			ids := common.SplitCSV(bots)
			if len(ids) > 5 {
				return errs.NewValidationError(errs.SubtypeInvalidArgument, "--bots exceeds the maximum of 5 (got %d)", len(ids)).WithParam("--bots")
			}
			for _, id := range ids {
				if !strings.HasPrefix(id, "cli_") {
					return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bot id %q: expected app ID (cli_xxx)", id).WithParam("--bots")
				}
			}
		}

		if owner := runtime.Str("owner"); owner != "" {
			if _, err := common.ValidateUserIDTyped("--owner", owner); err != nil {
				return err
			}
		}
		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		body := buildCreateChatBody(runtime)

		qp := larkcore.QueryParams{"user_id_type": []string{"open_id"}}
		if runtime.Bool("set-bot-manager") {
			qp["set_bot_manager"] = []string{"true"}
		}
		resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats", qp, body)
		if err != nil {
			return err
		}

		outData := map[string]interface{}{
			"chat_id":   resData["chat_id"],
			"name":      resData["name"],
			"chat_type": resData["chat_type"],
			"owner_id":  resData["owner_id"],
			"external":  resData["external"],
		}

		if chatID, ok := resData["chat_id"].(string); ok && chatID != "" {
			linkData, err := runtime.DoAPIJSONTyped(http.MethodPost,
				fmt.Sprintf("/open-apis/im/v1/chats/%s/link", validate.EncodePathSegment(chatID)),
				nil, nil)
			if err == nil {
				outData["share_link"] = linkData["share_link"]
			}
		}

		runtime.OutFormat(outData, nil, func(w io.Writer) {
			fmt.Fprintf(w, "Group created successfully\n\n")
			output.PrintTable(w, []map[string]interface{}{outData})
			if link, ok := outData["share_link"].(string); ok && link != "" {
				fmt.Fprintf(w, "\nShare link: %s\n", link)
			}
		})
		return nil
	},
}

ImChatCreate is the +chat-create shortcut: creates a group chat or topic chat via POST /open-apis/im/v1/chats. Supports user and bot identities; --chat-mode selects group (default) or topic; --type selects private (default) or public; --users/--bots invite members at creation.

View Source
var ImChatList = common.Shortcut{
	Service:     "im",
	Command:     "+chat-list",
	Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
	Risk:        "read",
	Scopes:      []string{"im:chat:read"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
		{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
		{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
		{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
		{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
		{Name: "page-token", Desc: "pagination token for next page"},
		{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
	},

	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		effective, stripped, _ := resolveTypes(runtime)
		if stripped {
			writeBotStripP2pWarning(runtime.IO().ErrOut)
		}
		return common.NewDryRunAPI().
			GET(imChatListPath).
			Params(buildChatListParams(runtime, effective))
	},

	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if n := runtime.Int("page-size"); n < 1 || n > 100 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
		}
		parts, err := normalizeTypes(runtime.StrSlice("types"))
		if err != nil {
			return err
		}
		if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
			return errs.NewValidationError(errs.SubtypeInvalidArgument,
				`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`).WithParam("--types")
		}
		return nil
	},

	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		effective, stripped, _ := resolveTypes(runtime)
		if stripped {
			writeBotStripP2pWarning(runtime.IO().ErrOut)
		}
		params := buildChatListParams(runtime, effective)
		resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
		if err != nil {
			return err
		}

		rawItems, _ := resData["items"].([]interface{})
		hasMore, pageToken := common.PaginationMeta(resData)

		var items []map[string]interface{}
		for _, raw := range rawItems {
			item, _ := raw.(map[string]interface{})
			if item == nil {
				continue
			}
			items = append(items, item)
		}

		mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
			ExcludeMuted: runtime.Bool("exclude-muted"),
			IsBot:        runtime.IsBot(),
			Chats:        items,
			ChatIDKey:    "chat_id",
			HasMore:      hasMore,
		})
		if err != nil {
			return err
		}
		items = mfOut.Chats

		outData := map[string]interface{}{
			"chats":      items,
			"has_more":   hasMore,
			"page_token": pageToken,
		}
		if mfOut.Meta.Applied != "" {
			outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
		}
		if stripped {
			outData["notices"] = []map[string]interface{}{
				{"code": botStripP2pCode, "message": botStripP2pMessage},
			}
		}

		runtime.OutFormat(outData, nil, func(w io.Writer) {
			if len(items) == 0 {
				fmt.Fprintln(w, "No chats found.")
				if mfOut.Meta.Hint != "" {
					fmt.Fprintln(w, mfOut.Meta.Hint)
				}
				return
			}
			rows := make([]map[string]interface{}, 0, len(items))
			for _, m := range items {
				row := map[string]interface{}{
					"chat_id": m["chat_id"],
					"name":    m["name"],
				}
				if desc, _ := m["description"].(string); desc != "" {
					row["description"] = desc
				}
				if ownerID, _ := m["owner_id"].(string); ownerID != "" {
					row["owner_id"] = ownerID
				}
				if external, ok := m["external"].(bool); ok {
					row["external"] = external
				}
				if status, _ := m["chat_status"].(string); status != "" {
					row["chat_status"] = status
				}
				if chatMode, _ := m["chat_mode"].(string); chatMode != "" {
					row["chat_mode"] = chatMode
					if chatMode == "p2p" {
						if pt, _ := m["p2p_target_type"].(string); pt != "" {
							row["p2p_target_type"] = pt
						}
						if pid, _ := m["p2p_target_id"].(string); pid != "" {
							row["p2p_target_id"] = pid
						}
					}
				}
				rows = append(rows, row)
			}
			output.PrintTable(w, rows)
			fmt.Fprintf(w, "\n%d chat(s) listed", len(rows))
			if hasMore {
				fmt.Fprint(w, " (more available, use --page-token to fetch next page")
				if pageToken != "" {
					fmt.Fprintf(w, ", page_token: %s", pageToken)
				}
				fmt.Fprint(w, ")")
			}
			fmt.Fprintln(w)
			if mfOut.Meta.Hint != "" {
				fmt.Fprintln(w, mfOut.Meta.Hint)
			}
		})
		return nil
	},
}

ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to list groups the current user/bot is a member of. Supports sort order, pagination, and (user identity only) muted-chat filtering via --exclude-muted.

View Source
var ImChatMessageList = common.Shortcut{
	Service:     "im",
	Command:     "+chat-messages-list",
	Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
	Risk:        "read",
	Scopes:      []string{"im:message:readonly"},
	UserScopes:  []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
	BotScopes:   []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
		{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
		{Name: "start", Desc: "start time (ISO 8601)"},
		{Name: "end", Desc: "end time (ISO 8601)"},
		{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
		{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
		{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
		{Name: "page-token", Desc: "pagination token for next page"},
		{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
		downloadResourcesFlag,
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		d := common.NewDryRunAPI()
		chatId, err := resolveChatIDForMessagesList(runtime, true)
		if err != nil {
			return d.Desc(err.Error())
		}
		if runtime.Str("user-id") != "" {
			d.Desc("(--user-id provided) Will resolve P2P chat_id via POST /open-apis/im/v1/chat_p2p/batch_query at execution time")
		}
		params, err := buildChatMessageListRequest(runtime, chatId)
		if err != nil {
			return d.Desc(err.Error())
		}
		dryParams := make(map[string]interface{}, len(params))
		for k, vs := range params {
			if len(vs) > 0 {
				dryParams[k] = vs[0]
			}
		}
		d = d.GET("/open-apis/im/v1/messages").Params(dryParams)
		if !runtime.Bool("no-reactions") {
			d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
				Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.")
		}
		if runtime.Bool("download-resources") {
			d = d.Desc(downloadResourcesDryRunDesc)
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

		if runtime.IsBot() {
			if runtime.Str("user-id") != "" {
				return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id")
			}
			if runtime.Str("chat-id") == "" {
				return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --chat-id (bot identity does not support --user-id)").WithParam("--chat-id")
			}
		} else {
			if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
				if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
					return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --chat-id or --user-id")
				}
				return err
			}
		}

		if chatFlag := runtime.Str("chat-id"); chatFlag != "" {
			if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
				return err
			}
		}
		if userFlag := runtime.Str("user-id"); userFlag != "" {
			if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
				return err
			}
		}

		chatId := runtime.Str("chat-id")
		if chatId == "" {
			chatId = "<resolved_chat_id>"
		}
		_, err := buildChatMessageListRequest(runtime, chatId)
		return err
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		chatId, err := resolveChatIDForMessagesList(runtime, false)
		if err != nil {
			return err
		}
		params, err := buildChatMessageListRequest(runtime, chatId)
		if err != nil {
			return err
		}

		data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
		if err != nil {
			return err
		}
		rawItems, _ := data["items"].([]interface{})
		hasMore, nextPageToken := common.PaginationMeta(data)

		nameCache := make(map[string]string)

		mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)

		downloadResources := runtime.Bool("download-resources")
		messages := make([]map[string]interface{}, 0, len(rawItems))
		for _, item := range rawItems {
			m, _ := item.(map[string]interface{})
			messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
		}

		convertlib.ResolveSenderNames(runtime, messages, nameCache)
		convertlib.AttachSenderNames(messages, nameCache)
		convertlib.ExpandThreadRepliesWithResources(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit, downloadResources)
		if !runtime.Bool("no-reactions") {
			convertlib.EnrichReactions(runtime, messages)
		}
		if downloadResources {
			enrichMessageResourceDownloads(runtime, messages)
		}

		outData := map[string]interface{}{
			"messages":   messages,
			"total":      len(messages),
			"has_more":   hasMore,
			"page_token": nextPageToken,
		}
		runtime.OutFormat(outData, nil, func(w io.Writer) {
			if len(messages) == 0 {
				fmt.Fprintln(w, "No messages in this time range.")
				return
			}
			var rows []map[string]interface{}
			for _, msg := range messages {
				row := map[string]interface{}{
					"time": msg["create_time"],
					"type": msg["msg_type"],
				}
				if sender, ok := msg["sender"].(map[string]interface{}); ok {
					if name, _ := sender["name"].(string); name != "" {
						row["sender"] = name
					}
				}
				if content, _ := msg["content"].(string); content != "" {
					row["content"] = convertlib.TruncateContent(content, 40)
				}
				rows = append(rows, row)
			}
			output.PrintTable(w, rows)
			moreHint := ""
			if hasMore {
				moreHint = fmt.Sprintf(" (more available, page_token: %s)", nextPageToken)
			}
			fmt.Fprintf(w, "\n%d message(s)%s\ntip: use --format json to view full message content\n", len(messages), moreHint)
		})
		return nil
	},
}
View Source
var ImChatSearch = common.Shortcut{
	Service:     "im",
	Command:     "+chat-search",
	Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
	Risk:        "read",
	Scopes:      []string{"im:chat:read"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "query", Desc: "search keyword (max 64 chars)"},
		{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
		{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
		{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
		{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
		{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
		{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
		{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
		{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
		{Name: "page-token", Desc: "pagination token for next page"},
		{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
	},

	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		body := buildSearchChatBody(runtime)
		params := buildSearchChatParams(runtime)
		return common.NewDryRunAPI().
			POST("/open-apis/im/v2/chats/search").
			Params(params).
			Body(body)
	},

	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		query := runtime.Str("query")
		memberIDs := runtime.Str("member-ids")
		if query == "" && memberIDs == "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
		}
		if query != "" && len([]rune(query)) > 64 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
		}
		if st := runtime.Str("search-types"); st != "" {
			allowed := map[string]struct{}{
				"private":           {},
				"external":          {},
				"public_joined":     {},
				"public_not_joined": {},
			}
			for _, item := range common.SplitCSV(st) {
				if _, ok := allowed[item]; !ok {
					return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item).WithParam("--search-types")
				}
			}
		}
		if cm := runtime.Str("chat-modes"); cm != "" {
			for _, mode := range common.SplitCSV(cm) {
				if mode != "group" && mode != "topic" {
					return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-modes value %q: expected one of group, topic", mode).WithParam("--chat-modes")
				}
			}
		}
		if mi := runtime.Str("member-ids"); mi != "" {
			ids := common.SplitCSV(mi)
			if len(ids) > 50 {
				return errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-ids exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--member-ids")
			}
			for _, id := range ids {
				if _, err := common.ValidateUserIDTyped("--member-ids", id); err != nil {
					return err
				}
			}
		}
		if n := runtime.Int("page-size"); n < 1 || n > 100 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
		}
		return nil
	},

	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		body := buildSearchChatBody(runtime)
		params := buildSearchChatParams(runtime)
		resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
		if err != nil {
			return err
		}

		rawItems, _ := resData["items"].([]interface{})
		totalF, _ := util.ToFloat64(resData["total"])
		total := totalF
		hasMore, pageToken := common.PaginationMeta(resData)

		// Extract MetaData from each item
		var items []map[string]interface{}
		for _, raw := range rawItems {
			item, _ := raw.(map[string]interface{})
			if item == nil {
				continue
			}
			meta, _ := item["meta_data"].(map[string]interface{})
			if meta == nil {
				continue
			}
			items = append(items, meta)
		}

		preSkipReason := ""
		if runtime.Bool("exclude-muted") {
			preSkipReason = detectAllNonMemberPreSkip(runtime.Str("search-types"))
		}
		mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
			ExcludeMuted:  runtime.Bool("exclude-muted"),
			IsBot:         runtime.IsBot(),
			PreSkipReason: preSkipReason,
			Chats:         items,
			ChatIDKey:     "chat_id",
			HasMore:       hasMore,
		})
		if err != nil {
			return err
		}
		items = mfOut.Chats

		outData := map[string]interface{}{
			"chats":      items,
			"total":      int(total),
			"has_more":   hasMore,
			"page_token": pageToken,
		}
		if mfOut.Meta.Applied != "" {
			outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
		}

		runtime.OutFormat(outData, nil, func(w io.Writer) {
			if len(items) == 0 {
				fmt.Fprintln(w, "No matching group chats found.")
				if mfOut.Meta.Hint != "" {
					fmt.Fprintln(w, mfOut.Meta.Hint)
				}
				return
			}
			var rows []map[string]interface{}
			for _, m := range items {
				row := map[string]interface{}{
					"chat_id": m["chat_id"],
					"name":    m["name"],
				}
				if desc, _ := m["description"].(string); desc != "" {
					row["description"] = desc
				}
				if ownerID, _ := m["owner_id"].(string); ownerID != "" {
					row["owner_id"] = ownerID
				}
				if chatMode, _ := m["chat_mode"].(string); chatMode != "" {
					row["chat_mode"] = chatMode
				}
				if external, ok := m["external"].(bool); ok {
					row["external"] = external
				}
				if status, _ := m["chat_status"].(string); status != "" {
					row["chat_status"] = status
				}
				if createTime, _ := m["create_time"].(string); createTime != "" {
					row["create_time"] = createTime
				}
				rows = append(rows, row)
			}
			output.PrintTable(w, rows)
			moreHint := ""
			if hasMore {
				moreHint = " (more available, use --page-token to fetch next page"
				if pageToken != "" {
					moreHint += fmt.Sprintf(", page_token: %s", pageToken)
				}
				moreHint += ")"
			}
			fmt.Fprintf(w, "\n%d chat(s) found%s\n", int(total), moreHint)
			if mfOut.Meta.Hint != "" {
				fmt.Fprintln(w, mfOut.Meta.Hint)
			}
		})
		return nil
	},
}

ImChatSearch is the +chat-search shortcut: wraps POST /open-apis/im/v2/chats/search to find visible group chats by keyword and/or member open_ids. Supports member/type filters, sort order, pagination, and (user identity only) the --exclude-muted client-side mute filter.

View Source
var ImChatUpdate = common.Shortcut{
	Service:     "im",
	Command:     "+chat-update",
	Description: "Update group chat name or description; user/bot; updates a chat's name or description",
	Risk:        "write",
	Scopes:      []string{"im:chat:update"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "chat-id", Desc: "chat ID (oc_xxx)", Required: true},
		{Name: "name", Desc: "group name (max 60 chars)"},
		{Name: "description", Desc: "group description (max 100 chars)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		chatID := runtime.Str("chat-id")
		body := buildUpdateChatBody(runtime)
		return common.NewDryRunAPI().
			PUT(fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID))).
			Params(map[string]interface{}{"user_id_type": "open_id"}).
			Body(body)
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		chat := runtime.Str("chat-id")
		if _, err := common.ValidateChatIDTyped("--chat-id", chat); err != nil {
			return err
		}

		name := runtime.Str("name")
		if name != "" && len([]rune(name)) > 60 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
		}

		if desc := runtime.Str("description"); desc != "" && len([]rune(desc)) > 100 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
		}

		body := buildUpdateChatBody(runtime)
		if len(body) == 0 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one field must be specified to update")
		}

		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		chatID := runtime.Str("chat-id")
		body := buildUpdateChatBody(runtime)

		_, err := runtime.DoAPIJSONTyped(http.MethodPut,
			fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)),
			larkcore.QueryParams{"user_id_type": []string{"open_id"}},
			body,
		)
		if err != nil {
			return err
		}

		runtime.OutFormat(map[string]interface{}{"chat_id": chatID}, nil, func(w io.Writer) {
			fmt.Fprintf(w, "Group updated successfully (chat_id: %s)\n", chatID)
		})
		return nil
	},
}
View Source
var ImFeedGroupList = common.Shortcut{
	Service:     "im",
	Command:     "+feed-group-list",
	Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination",
	Risk:        "read",
	UserScopes:  []string{feedGroupReadScope},
	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: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"},
		{Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		return validateFeedGroupListPageOptions(runtime)
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		if err := validateFeedGroupListPageOptions(runtime); err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		return common.NewDryRunAPI().
			GET(feedGroupListPath).
			Params(feedGroupListGroupsDryRunParams(runtime))
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

		if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
			return executeFeedGroupListGroupsAllPages(runtime)
		}

		data, err := runtime.DoAPIJSONTyped("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil)
		if err != nil {
			return err
		}

		hasMore, _ := data["has_more"].(bool)
		runtime.OutFormat(data, nil, func(w io.Writer) {
			renderFeedGroupsTable(w, data, hasMore)
		})
		return nil
	},
}

ImFeedGroupList provides the +feed-group-list shortcut: it lists the caller's feed groups (tags) with auto-pagination that correctly merges BOTH the live (groups) and soft-deleted (deleted_groups) lists across pages.

The raw `feed.groups list --page-all` goes through the generic paginator, which follows only one array field and silently drops the other list's later pages; this shortcut paginates the dual-list response itself.

View Source
var ImFeedGroupListItem = common.Shortcut{
	Service:     "im",
	Command:     "+feed-group-list-item",
	Description: "List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination",
	Risk:        "read",
	UserScopes:  []string{feedGroupReadScope, chatReadScope},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"},
		{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
		{Name: "page-token", Desc: "pagination token for next page"},
		{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
		{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
		{Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"},
		{Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		return validateFeedGroupListOptions(runtime)
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		if err := validateFeedGroupListOptions(runtime); err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		return common.NewDryRunAPI().
			GET(feedGroupListItemPath(runtime)).
			Params(feedGroupListDryRunParams(runtime)).
			Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read")
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

		if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
			return executeFeedGroupListAllPages(runtime)
		}

		data, err := runtime.DoAPIJSONTyped("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil)
		if err != nil {
			return err
		}
		enrichFeedGroupItemsChatName(runtime, data)

		hasMore, _ := data["has_more"].(bool)
		runtime.OutFormat(data, nil, func(w io.Writer) {
			renderFeedGroupItemsTable(w, data, hasMore)
		})
		return nil
	},
}

ImFeedGroupListItem provides the +feed-group-list-item shortcut: it lists the feed cards inside one feed group and enriches each item with chat_name resolved from its feed_id.

View Source
var ImFeedGroupQueryItem = common.Shortcut{
	Service:     "im",
	Command:     "+feed-group-query-item",
	Description: "Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id",
	Risk:        "read",
	UserScopes:  []string{feedGroupReadScope, chatReadScope},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"},
		{Name: "feed-id", Desc: "comma-separated chat IDs (oc_xxx); feed_type is fixed to chat (required)"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		_, err := buildFeedGroupQueryItemBody(runtime)
		return err
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		body, err := buildFeedGroupQueryItemBody(runtime)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		return common.NewDryRunAPI().
			POST(feedGroupQueryItemPath(runtime)).
			Body(body).
			Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read")
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		body, err := buildFeedGroupQueryItemBody(runtime)
		if err != nil {
			return err
		}

		data, err := runtime.DoAPIJSONTyped("POST", feedGroupQueryItemPath(runtime), nil, body)
		if err != nil {
			return err
		}
		enrichFeedGroupItemsChatName(runtime, data)

		runtime.OutFormat(data, nil, func(w io.Writer) {
			renderFeedGroupItemsTable(w, data, false)
		})
		return nil
	},
}

ImFeedGroupQueryItem provides the +feed-group-query-item shortcut: it looks up specific feed cards in a feed group by ID and enriches each item with chat_name resolved from its feed_id.

View Source
var ImFeedShortcutCreate = common.Shortcut{
	Service:     "im",
	Command:     "+feed-shortcut-create",
	Description: "Add chats to the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; --head/--tail controls insertion order",
	Risk:        "write",
	UserScopes:  []string{feedShortcutWriteScope},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{

		{Name: "chat-id", Type: "string_slice",
			Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
		{Name: "head", Type: "bool",
			Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
		{Name: "tail", Type: "bool",
			Desc: "append at the bottom of the shortcut list; mutually exclusive with --head"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if _, err := collectChatIDs(runtime); err != nil {
			return err
		}
		_, err := resolveIsHeader(runtime)
		return err
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		ids, err := collectChatIDs(runtime)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		isHeader, err := resolveIsHeader(runtime)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		return common.NewDryRunAPI().
			POST("/open-apis/im/v2/feed_shortcuts").
			Body(map[string]any{
				"shortcuts": buildShortcutItems(ids),
				"is_header": isHeader,
			})
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		ids, err := collectChatIDs(runtime)
		if err != nil {
			return err
		}
		isHeader, err := resolveIsHeader(runtime)
		if err != nil {
			return err
		}
		items := buildShortcutItems(ids)
		data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts", nil,
			map[string]any{
				"shortcuts": items,
				"is_header": isHeader,
			})
		if err != nil {
			return err
		}
		return emitFeedShortcutWriteResult(runtime, items, data)
	},
}

ImFeedShortcutCreate provides the +feed-shortcut-create shortcut for adding chats to the user's feed shortcuts. Currently only CHAT-type shortcuts are exposed by the OpenAPI gateway; feed_card_id must be an open_chat_id (oc_xxx).

View Source
var ImFeedShortcutList = common.Shortcut{
	Service:               "im",
	Command:               "+feed-shortcut-list",
	Description:           "List one page of the user's feed shortcuts; user-only; first call omits --page-token, subsequent calls pass the previous response's page_token; each entry is auto-enriched with the full per-type info object attached as `detail` (pass --no-detail to skip)",
	Risk:                  "read",
	UserScopes:            []string{feedShortcutReadScope},
	ConditionalUserScopes: []string{chatBatchQueryScope},
	AuthTypes:             []string{"user"},
	HasFormat:             true,
	Flags: []common.Flag{
		{Name: "page-token",
			Desc: "opaque pagination token from the previous response; omit for the first page. If a token is rejected because the list changed, restart by omitting it."},
		{Name: "no-detail", Type: "bool",
			Desc: "skip fetching the full info object for each shortcut (default: enrichment enabled — CHAT-type entries call im.chats.batch_query, require im:chat:read, and attach the object under the detail field)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		d := common.NewDryRunAPI().
			GET("/open-apis/im/v2/feed_shortcuts")
		if token := runtime.Str("page-token"); token != "" {
			d.Params(map[string]any{"page_token": token})
		}
		if !runtime.Bool("no-detail") {
			d.Desc("conditional enrichment: if CHAT-type entries exist, execution also calls POST /open-apis/im/v1/chats/batch_query and requires scope im:chat:read; pass --no-detail to skip this extra call and extra scope")
		}
		return d
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts",
			feedShortcutListQuery(runtime.Str("page-token")), nil)
		if err != nil {
			return err
		}
		if !runtime.Bool("no-detail") {
			if err := enrichFeedShortcutDetail(runtime, data); err != nil {
				fmt.Fprintf(runtime.IO().ErrOut, "warning: detail enrichment failed: %v\n", err)

				if data != nil {
					data["_notice"] = fmt.Sprintf("detail enrichment skipped: %v", err)
				}
			}
		}
		runtime.Out(data, nil)
		return nil
	},
}

ImFeedShortcutList provides the +feed-shortcut-list shortcut for listing the user's feed shortcuts. The server-controlled page size covers the full list in practice, but pagination is version-locked: when the list changes between calls the server rejects the stale token and the caller has to restart by omitting --page-token.

The shortcut is a thin one-page wrapper — there is no automatic walking. Callers are expected to drive their own loop when they actually need to paginate, because the version-lock means each page is a real checkpoint that the caller must consciously decide what to do with on failure.

View Source
var ImFeedShortcutRemove = common.Shortcut{
	Service:     "im",
	Command:     "+feed-shortcut-remove",
	Description: "Remove chats from the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
	Risk:        "write",
	UserScopes:  []string{feedShortcutWriteScope},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{

		{Name: "chat-id", Type: "string_slice",
			Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		_, err := collectChatIDs(runtime)
		return err
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		ids, err := collectChatIDs(runtime)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		return common.NewDryRunAPI().
			POST("/open-apis/im/v2/feed_shortcuts/remove").
			Body(map[string]any{"shortcuts": buildShortcutItems(ids)})
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		ids, err := collectChatIDs(runtime)
		if err != nil {
			return err
		}
		items := buildShortcutItems(ids)
		data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil,
			map[string]any{"shortcuts": items})
		if err != nil {
			return err
		}
		return emitFeedShortcutWriteResult(runtime, items, data)
	},
}

ImFeedShortcutRemove provides the +feed-shortcut-remove shortcut for removing chats from the user's feed shortcuts. Per-item failures are kept in stdout and returned as a partial-failure exit.

View Source
var ImFlagCancel = common.Shortcut{
	Service:     "im",
	Command:     "+flag-cancel",
	Description: "Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer",
	Risk:        "write",
	UserScopes:  flagWriteLookupScopes,
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "message-id", Desc: "message ID (om_xxx)"},
		{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
		{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
		}

		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.DoAPIJSONTyped("POST", "/open-apis/im/v1/flags/cancel", nil,
				map[string]any{"flag_items": []flagItem{item}})
			if err != nil {
				result["status"] = "failed"
				result["error"] = err.Error()
				lastErr = err
			} else {
				result["status"] = "ok"
				result["response"] = data
			}
			results = append(results, result)
		}

		payload := map[string]any{"results": results}
		if lastErr != nil {
			return runtime.OutPartialFailure(payload, nil)
		}
		runtime.Out(payload, nil)
		return nil
	},
}

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.

View Source
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 for feed-layer flag (item_type auto-detected from chat mode)",
	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"] == "<auto:thread|msg_thread>" {
			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
		}

		if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
			return errs.NewValidationError(errs.SubtypeInvalidArgument,
				"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
					"(default, message), (thread, feed), or (msg_thread, feed)",
				item.ItemType, item.FlagType).WithParams(
				errs.InvalidParam{Name: "--item-type", Reason: "unsupported with the given --flag-type"},
				errs.InvalidParam{Name: "--flag-type", Reason: "unsupported with the given --item-type"})
		}
		data, err := runtime.DoAPIJSONTyped("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
	},
}

ImFlagCreate provides the +flag-create shortcut for creating a bookmark on a message.

View Source
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 {

		if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
			return executeListAllPages(runtime)
		}

		data, err := runtime.DoAPIJSONTyped("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
	},
}

ImFlagList provides the +flag-list shortcut for listing bookmarks. Feed-type thread entries are auto-enriched with message content.

View Source
var ImMessagesMGet = common.Shortcut{
	Service:     "im",
	Command:     "+messages-mget",
	Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
	Risk:        "read",
	Scopes:      []string{"im:message:readonly"},
	UserScopes:  []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
	BotScopes:   []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
		{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
		downloadResourcesFlag,
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		ids := common.SplitCSV(runtime.Str("message-ids"))
		d := common.NewDryRunAPI().GET(buildMGetURL(ids))
		if !runtime.Bool("no-reactions") {
			d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
				Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.")
		}
		if runtime.Bool("download-resources") {
			d = d.Desc(downloadResourcesDryRunDesc)
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		ids := common.SplitCSV(runtime.Str("message-ids"))
		if len(ids) == 0 {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids")
		}
		if len(ids) > maxMGetMessageIDs {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids")
		}
		for _, id := range ids {
			if _, err := validateMessageID(id); err != nil {
				return err
			}
		}
		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		ids := common.SplitCSV(runtime.Str("message-ids"))
		mgetURL := buildMGetURL(ids)

		data, err := runtime.DoAPIJSONTyped(http.MethodGet, mgetURL, nil, nil)
		if err != nil {
			return err
		}

		rawItems, _ := data["items"].([]interface{})

		nameCache := make(map[string]string)

		mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)

		downloadResources := runtime.Bool("download-resources")
		messages := make([]map[string]interface{}, 0, len(rawItems))
		for _, item := range rawItems {
			m, _ := item.(map[string]interface{})
			messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
		}

		convertlib.ResolveSenderNames(runtime, messages, nameCache)
		convertlib.AttachSenderNames(messages, nameCache)
		convertlib.ExpandThreadRepliesWithResources(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit, downloadResources)
		if !runtime.Bool("no-reactions") {
			convertlib.EnrichReactions(runtime, messages)
		}
		if downloadResources {
			enrichMessageResourceDownloads(runtime, messages)
		}

		outData := map[string]interface{}{
			"messages": messages,
			"total":    len(messages),
		}
		runtime.OutFormat(outData, nil, func(w io.Writer) {
			if len(messages) == 0 {
				fmt.Fprintln(w, "No messages found.")
				return
			}
			var rows []map[string]interface{}
			for _, msg := range messages {
				row := map[string]interface{}{
					"message_id": msg["message_id"],
					"time":       msg["create_time"],
					"type":       msg["msg_type"],
				}
				if sender, ok := msg["sender"].(map[string]interface{}); ok {
					if name, _ := sender["name"].(string); name != "" {
						row["sender"] = name
					}
				}
				if content, _ := msg["content"].(string); content != "" {
					row["content"] = convertlib.TruncateContent(content, 40)
				}
				rows = append(rows, row)
			}
			output.PrintTable(w, rows)
			fmt.Fprintf(w, "\n%d message(s)\ntip: use --format json to view full message content\n", len(messages))
		})
		return nil
	},
}
View Source
var ImMessagesReply = common.Shortcut{
	Service:     "im",
	Command:     "+messages-reply",
	Description: "Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key",
	Risk:        "write",
	Scopes:      []string{"im:message:send_as_bot"},
	UserScopes:  []string{"im:message.send_as_user", "im:message"},
	BotScopes:   []string{"im:message:send_as_bot"},
	AuthTypes:   []string{"bot", "user"},
	Flags: []common.Flag{
		{Name: "message-id", Desc: "message ID (om_xxx)", Required: true},
		{Name: "msg-type", Default: "text", Desc: "message type for --content JSON; when using --text/--markdown/--image/--file/--video/--audio, the effective type is inferred automatically", Enum: []string{"text", "post", "image", "file", "audio", "media", "interactive", "share_chat", "share_user"}},
		{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
		{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
		{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
		{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
		{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
		{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
		{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
		{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
		{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
		{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageId := runtime.Str("message-id")
		msgType := runtime.Str("msg-type")
		content := runtime.Str("content")
		desc := ""
		text := runtime.Str("text")
		markdown := runtime.Str("markdown")
		imageKey := runtime.Str("image")
		fileKey := runtime.Str("file")
		videoKey := runtime.Str("video")
		videoCoverKey := runtime.Str("video-cover")
		audioKey := runtime.Str("audio")
		replyInThread := runtime.Bool("reply-in-thread")
		idempotencyKey := runtime.Str("idempotency-key")

		if markdown != "" {
			msgType = "post"
			content, desc = wrapMarkdownAsPostForDryRun(markdown)
		} else if mt, c, d := buildMediaContentFromKey(text, imageKey, fileKey, videoKey, videoCoverKey, audioKey); mt != "" {
			msgType, content, desc = mt, c, d
		}
		if msgType == "text" || msgType == "post" {
			content = normalizeAtMentions(content)
		}

		body := map[string]interface{}{"msg_type": msgType, "content": content}
		if replyInThread {
			body["reply_in_thread"] = true
		}
		if idempotencyKey != "" {
			body["uuid"] = idempotencyKey
		}

		d := common.NewDryRunAPI()
		if desc != "" {
			d.Desc(desc)
		}
		return d.
			POST("/open-apis/im/v1/messages/:message_id/reply").
			Body(body).
			Set("message_id", messageId)
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		msgType := runtime.Str("msg-type")
		content := runtime.Str("content")
		text := runtime.Str("text")
		markdown := runtime.Str("markdown")
		imageKey := runtime.Str("image")
		fileKey := runtime.Str("file")
		videoKey := runtime.Str("video")
		videoCoverKey := runtime.Str("video-cover")
		audioKey := runtime.Str("audio")

		fio := runtime.FileIO()
		for _, mf := range []struct{ flag, val string }{
			{"--image", imageKey}, {"--file", fileKey}, {"--video", videoKey},
			{"--video-cover", videoCoverKey}, {"--audio", audioKey},
		} {
			if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
				return err
			}
		}

		if messageId == "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
		}
		if _, err := validateMessageID(messageId); err != nil {
			return err
		}

		if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
		}
		if content != "" && !json.Valid([]byte(content)) {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
		}
		if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).WithParam("--msg-type")
		}

		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		msgType := runtime.Str("msg-type")
		content := runtime.Str("content")
		text := runtime.Str("text")
		markdown := runtime.Str("markdown")
		imageVal := runtime.Str("image")
		fileVal := runtime.Str("file")
		videoVal := runtime.Str("video")
		videoCoverVal := runtime.Str("video-cover")
		audioVal := runtime.Str("audio")
		replyInThread := runtime.Bool("reply-in-thread")
		idempotencyKey := runtime.Str("idempotency-key")
		fio := runtime.FileIO()
		for _, mf := range []struct{ flag, val string }{
			{"--image", imageVal}, {"--file", fileVal}, {"--video", videoVal},
			{"--video-cover", videoCoverVal}, {"--audio", audioVal},
		} {
			if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
				return err
			}
		}

		if markdown != "" {
			msgType, content = "post", resolveMarkdownAsPost(ctx, runtime, markdown)
		} else if mt, c, err := resolveMediaContent(ctx, runtime, text, imageVal, fileVal, videoVal, videoCoverVal, audioVal); err != nil {
			return err
		} else if mt != "" {
			msgType, content = mt, c
		}

		normalizedContent := content
		if msgType == "text" || msgType == "post" {
			normalizedContent = normalizeAtMentions(content)
		}

		data := map[string]interface{}{
			"msg_type": msgType,
			"content":  normalizedContent,
		}
		if replyInThread {
			data["reply_in_thread"] = true
		}
		if idempotencyKey != "" {
			data["uuid"] = idempotencyKey
		}

		resData, err := runtime.DoAPIJSONTyped(http.MethodPost,
			fmt.Sprintf("/open-apis/im/v1/messages/%s/reply", validate.EncodePathSegment(messageId)),
			nil, data)
		if err != nil {
			return err
		}

		runtime.Out(map[string]interface{}{
			"message_id":  resData["message_id"],
			"chat_id":     resData["chat_id"],
			"create_time": common.FormatTimeWithSeconds(resData["create_time"]),
		}, nil)
		return nil
	},
}
View Source
var ImMessagesResourcesDownload = common.Shortcut{
	Service:     "im",
	Command:     "+messages-resources-download",
	Description: "Download images/files from a message; user/bot; downloads image/file resources by message-id and file-key to a safe relative output path",
	Risk:        "write",
	Scopes:      []string{"im:message:readonly"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "message-id", Desc: "message ID (om_xxx)", Required: true},
		{Name: "file-key", Desc: "resource key (img_xxx or file_xxx)", Required: true},
		{Name: "type", Desc: "resource type (image or file)", Required: true, Enum: []string{"image", "file"}},
		{Name: "output", Desc: "local save path (relative only, no .. traversal); when omitted, uses the server's Content-Disposition filename if available, otherwise file_key; extension is inferred from Content-Disposition or Content-Type if not provided"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		fileKey := runtime.Str("file-key")
		outputPath := runtime.Str("output")
		if outputPath == "" {
			outputPath = fileKey
		}
		return common.NewDryRunAPI().
			GET("/open-apis/im/v1/messages/:message_id/resources/:file_key").
			Params(map[string]interface{}{"type": runtime.Str("type")}).
			Set("message_id", runtime.Str("message-id")).Set("file_key", fileKey).
			Set("type", runtime.Str("type")).Set("output", outputPath)
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if messageId := runtime.Str("message-id"); messageId == "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
		} else if _, err := validateMessageID(messageId); err != nil {
			return err
		}
		relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output"))
		if err != nil {
			return err
		}
		if _, err := runtime.ResolveSavePath(relPath); err != nil {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
		}
		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		fileKey := runtime.Str("file-key")
		fileType := runtime.Str("type")
		relPath, err := normalizeDownloadOutputPath(fileKey, runtime.Str("output"))
		if err != nil {
			return err
		}
		if _, err := runtime.ResolveSavePath(relPath); err != nil {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
		}

		preserveBasename := runtime.Str("output") != ""
		finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath, preserveBasename)
		if err != nil {
			return err
		}

		runtime.Out(map[string]interface{}{"saved_path": finalPath, "size_bytes": sizeBytes}, nil)
		return nil
	},
}
View Source
var ImMessagesSearch = common.Shortcut{
	Service:     "im",
	Command:     "+messages-search",
	Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
	Risk:        "read",
	Scopes:      []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "query", Desc: "search keyword"},
		{Name: "chat-id", Desc: "limit to chat IDs, comma-separated"},
		{Name: "sender", Desc: "sender open_ids, comma-separated"},
		{Name: "include-attachment-type", Desc: "include attachment type filter", Enum: []string{"file", "image", "video", "link"}},
		{Name: "chat-type", Desc: "chat type", Enum: []string{"group", "p2p"}},
		{Name: "sender-type", Desc: "sender type", Enum: []string{"user", "bot"}},
		{Name: "exclude-sender-type", Desc: "exclude sender type", Enum: []string{"user", "bot"}},
		{Name: "is-at-me", Type: "bool", Desc: "only messages that @me"},
		{Name: "at-chatter-ids", Desc: "filter by @mentioned user open_ids, comma-separated (also matches messages that @all)"},
		{Name: "start", Desc: "start time(ISO 8601) with local timezone offset (e.g. 2026-03-24T00:00:00+08:00)"},
		{Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"},
		{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-50)"},
		{Name: "page-token", Desc: "page token"},
		{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
		{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
		{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		req, err := buildMessagesSearchRequest(runtime)
		if err != nil {
			return common.NewDryRunAPI().Desc(err.Error())
		}
		dryParams := make(map[string]interface{}, len(req.params))
		for k, vs := range req.params {
			if len(vs) > 0 {
				dryParams[k] = vs[0]
			}
		}
		autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
		d := common.NewDryRunAPI()
		if autoPaginate {
			d = d.Desc(fmt.Sprintf("Step 1: search messages (auto-paginates up to %d page(s))", pageLimit))
		} else {
			d = d.Desc("Step 1: search messages")
		}
		d = d.
			POST("/open-apis/im/v1/messages/search").
			Params(dryParams).
			Body(req.body).
			Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=...  — batch fetch message details (max 50)").
			Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query  — fetch chat names for context")
		if !runtime.Bool("no-reactions") {
			d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
				Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.")
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		_, err := buildMessagesSearchRequest(runtime)
		return err
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		req, err := buildMessagesSearchRequest(runtime)
		if err != nil {
			return err
		}

		rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
		if err != nil {
			return err
		}

		if len(rawItems) == 0 {
			outData := map[string]interface{}{
				"messages":   []interface{}{},
				"total":      0,
				"has_more":   hasMore,
				"page_token": nextPageToken,
			}
			runtime.OutFormat(outData, nil, func(w io.Writer) {
				fmt.Fprintln(w, "No matching messages found.")
			})
			return nil
		}

		messageIds := make([]string, 0, len(rawItems))
		for _, item := range rawItems {
			if itemMap, ok := item.(map[string]interface{}); ok {
				if metaData, ok := itemMap["meta_data"].(map[string]interface{}); ok {
					if id, ok := metaData["message_id"].(string); ok && id != "" {
						messageIds = append(messageIds, id)
					}
				}
			}
		}

		msgItems, err := batchMGetMessages(runtime, messageIds)
		if err != nil {

			outData := map[string]interface{}{
				"message_ids": messageIds,
				"total":       len(messageIds),
				"has_more":    hasMore,
				"page_token":  nextPageToken,
				"note":        "failed to fetch message details, returning ID list only",
			}
			runtime.OutFormat(outData, nil, func(w io.Writer) {
				fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
				for _, id := range messageIds {
					fmt.Fprintln(w, " ", id)
				}
			})
			return nil
		}

		chatIds := make([]string, 0, len(msgItems))
		chatSeen := make(map[string]bool)
		for _, item := range msgItems {
			m, _ := item.(map[string]interface{})
			if chatId, _ := m["chat_id"].(string); chatId != "" {
				if !chatSeen[chatId] {
					chatSeen[chatId] = true
					chatIds = append(chatIds, chatId)
				}
			}
		}
		chatContexts := map[string]map[string]interface{}{}
		if len(chatIds) > 0 {
			chatContexts = batchQueryChatContexts(runtime, chatIds)
		}

		nameCache := make(map[string]string)

		mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, msgItems, nameCache)
		enriched := make([]map[string]interface{}, 0, len(msgItems))
		for _, item := range msgItems {
			m, _ := item.(map[string]interface{})
			chatId, _ := m["chat_id"].(string)

			msg := convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch)
			if chatId != "" {
				msg["chat_id"] = chatId
			}
			if chatCtx, ok := chatContexts[chatId]; ok {
				chatMode, _ := chatCtx["chat_mode"].(string)
				chatName, _ := chatCtx["name"].(string)
				if chatMode == "p2p" {
					msg["chat_type"] = "p2p"
					if p2pId, _ := chatCtx["p2p_target_id"].(string); p2pId != "" {
						msg["chat_partner"] = map[string]interface{}{"open_id": p2pId}
					}
				} else {
					msg["chat_type"] = chatMode
					if chatName != "" {
						msg["chat_name"] = chatName
					}
				}
			}
			enriched = append(enriched, msg)
		}

		convertlib.ResolveSenderNames(runtime, enriched, nameCache)
		convertlib.AttachSenderNames(enriched, nameCache)
		if !runtime.Bool("no-reactions") {
			convertlib.EnrichReactions(runtime, enriched)
		}

		outData := map[string]interface{}{
			"messages":   enriched,
			"total":      len(enriched),
			"has_more":   hasMore,
			"page_token": nextPageToken,
		}
		runtime.OutFormat(outData, nil, func(w io.Writer) {
			if len(enriched) == 0 {
				fmt.Fprintln(w, "No matching messages found.")
				return
			}
			var rows []map[string]interface{}
			for _, msg := range enriched {
				row := map[string]interface{}{
					"time": msg["create_time"],
					"type": msg["msg_type"],
				}
				if sender, ok := msg["sender"].(map[string]interface{}); ok {
					if name, _ := sender["name"].(string); name != "" {
						row["sender"] = name
					}
				}
				if chatName, ok := msg["chat_name"].(string); ok && chatName != "" {
					row["chat"] = chatName
				} else if chatType, ok := msg["chat_type"].(string); ok && chatType == "p2p" {
					row["chat"] = "p2p"
				} else if cid, ok := msg["chat_id"].(string); ok {
					row["chat"] = cid
				}
				if content, _ := msg["content"].(string); content != "" {
					row["content"] = convertlib.TruncateContent(content, 30)
				}
				rows = append(rows, row)
			}
			output.PrintTable(w, rows)
			moreHint := ""
			if hasMore {
				moreHint = " (more available, use --page-token to fetch next page)"
			}
			fmt.Fprintf(w, "\n%d search result(s)%s\n", len(enriched), moreHint)
			if truncatedByLimit {
				fmt.Fprintf(w, "warning: stopped after fetching %d page(s); use --page-limit, --page-all, or --page-token to continue\n", pageLimit)
			}
		})
		return nil
	},
}
View Source
var ImMessagesSend = common.Shortcut{
	Service:     "im",
	Command:     "+messages-send",
	Description: "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",
	Risk:        "write",
	Scopes:      []string{"im:message:send_as_bot"},
	UserScopes:  []string{"im:message.send_as_user", "im:message"},
	BotScopes:   []string{"im:message:send_as_bot"},
	AuthTypes:   []string{"bot", "user"},
	Flags: []common.Flag{
		{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
		{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id) user open_id (ou_xxx)"},
		{Name: "msg-type", Default: "text", Desc: "message type for --content JSON; when using --text/--markdown/--image/--file/--video/--audio, the effective type is inferred automatically", Enum: []string{"text", "post", "image", "file", "audio", "media", "interactive", "share_chat", "share_user"}},
		{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
		{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
		{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
		{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
		{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
		{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
		{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
		{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
		{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		chatFlag := runtime.Str("chat-id")
		userFlag := runtime.Str("user-id")
		msgType := runtime.Str("msg-type")
		content := runtime.Str("content")
		desc := ""
		text := runtime.Str("text")
		markdown := runtime.Str("markdown")
		idempotencyKey := runtime.Str("idempotency-key")
		imageKey := runtime.Str("image")
		fileKey := runtime.Str("file")
		videoKey := runtime.Str("video")
		videoCoverKey := runtime.Str("video-cover")
		audioKey := runtime.Str("audio")

		if markdown != "" {
			msgType = "post"
			content, desc = wrapMarkdownAsPostForDryRun(markdown)
		} else if mt, c, d := buildMediaContentFromKey(text, imageKey, fileKey, videoKey, videoCoverKey, audioKey); mt != "" {
			msgType, content, desc = mt, c, d
		}

		receiveIdType := "chat_id"
		receiveId := chatFlag
		if userFlag != "" {
			receiveIdType = "open_id"
			receiveId = userFlag
		}

		if msgType == "text" || msgType == "post" {
			content = normalizeAtMentions(content)
		}

		body := map[string]interface{}{"receive_id": receiveId, "msg_type": msgType, "content": content}
		if idempotencyKey != "" {
			body["uuid"] = idempotencyKey
		}

		d := common.NewDryRunAPI()
		if desc != "" {
			d.Desc(desc)
		}
		d.
			POST("/open-apis/im/v1/messages").
			Params(map[string]interface{}{"receive_id_type": receiveIdType}).
			Body(body)
		if chatFlag != "" {
			d.Desc("NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with `Bot/User can NOT be out of the chat`.")
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		chatFlag := runtime.Str("chat-id")
		userFlag := runtime.Str("user-id")
		msgType := runtime.Str("msg-type")
		content := runtime.Str("content")
		text := runtime.Str("text")
		markdown := runtime.Str("markdown")
		imageKey := runtime.Str("image")
		fileKey := runtime.Str("file")
		videoKey := runtime.Str("video")
		videoCoverKey := runtime.Str("video-cover")
		audioKey := runtime.Str("audio")

		fio := runtime.FileIO()
		for _, mf := range []struct{ flag, val string }{
			{"--image", imageKey}, {"--file", fileKey}, {"--video", videoKey},
			{"--video-cover", videoCoverKey}, {"--audio", audioKey},
		} {
			if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
				return err
			}
		}

		if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
			return err
		}

		if chatFlag != "" {
			if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
				return err
			}
		}
		if userFlag != "" {
			if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
				return err
			}
		}

		if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
		}
		if content != "" && !json.Valid([]byte(content)) {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
		}
		if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, msg).WithParam("--msg-type")
		}

		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		chatFlag := runtime.Str("chat-id")
		userFlag := runtime.Str("user-id")
		msgType := runtime.Str("msg-type")
		content := runtime.Str("content")
		text := runtime.Str("text")
		markdown := runtime.Str("markdown")
		idempotencyKey := runtime.Str("idempotency-key")
		imageVal := runtime.Str("image")
		fileVal := runtime.Str("file")
		videoVal := runtime.Str("video")
		videoCoverVal := runtime.Str("video-cover")
		audioVal := runtime.Str("audio")
		fio := runtime.FileIO()
		for _, mf := range []struct{ flag, val string }{
			{"--image", imageVal}, {"--file", fileVal}, {"--video", videoVal},
			{"--video-cover", videoCoverVal}, {"--audio", audioVal},
		} {
			if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
				return err
			}
		}

		if markdown != "" {
			msgType, content = "post", resolveMarkdownAsPost(ctx, runtime, markdown)
		} else if mt, c, err := resolveMediaContent(ctx, runtime, text, imageVal, fileVal, videoVal, videoCoverVal, audioVal); err != nil {
			return err
		} else if mt != "" {
			msgType, content = mt, c
		}

		receiveIdType := "chat_id"
		receiveId := chatFlag
		if userFlag != "" {
			receiveIdType = "open_id"
			receiveId = userFlag
		}

		normalizedContent := content
		if msgType == "text" || msgType == "post" {
			normalizedContent = normalizeAtMentions(content)
		}

		data := map[string]interface{}{
			"receive_id": receiveId,
			"msg_type":   msgType,
			"content":    normalizedContent,
		}
		if idempotencyKey != "" {
			data["uuid"] = idempotencyKey
		}

		resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages",
			larkcore.QueryParams{"receive_id_type": []string{receiveIdType}}, data)
		if err != nil {
			return err
		}

		runtime.Out(map[string]interface{}{
			"message_id":  resData["message_id"],
			"chat_id":     resData["chat_id"],
			"create_time": common.FormatTimeWithSeconds(resData["create_time"]),
		}, nil)
		return nil
	},
}
View Source
var ImThreadsMessagesList = common.Shortcut{
	Service:     "im",
	Command:     "+threads-messages-list",
	Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
	Risk:        "read",
	Scopes:      []string{"im:message:readonly"},
	UserScopes:  []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
	BotScopes:   []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
		{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
		{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
		{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
		{Name: "page-token", Desc: "page token"},
		{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
		downloadResourcesFlag,
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		threadFlag := runtime.Str("thread")
		dir := resolveThreadsOrder(runtime)
		pageSizeStr := runtime.Str("page-size")
		pageToken := runtime.Str("page-token")

		pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)

		d := common.NewDryRunAPI()
		containerID := threadFlag
		if messageIDRe.MatchString(threadFlag) {
			d.Desc("(--thread provided as message ID) Will resolve thread_id via GET /open-apis/im/v1/messages/:message_id at execution time")
			containerID = "<resolved_thread_id>"
		}

		params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)

		d = d.
			GET("/open-apis/im/v1/messages").
			Params(toDryParams(params)).
			Set("thread", threadFlag).Set("order", dir).Set("page_size", pageSizeStr)
		if !runtime.Bool("no-reactions") {
			d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
				Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
		}
		if runtime.Bool("download-resources") {
			d = d.Desc(downloadResourcesDryRunDesc)
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		threadId := runtime.Str("thread")
		if threadId == "" {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread")
		}
		if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") {
			return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --thread %q: must start with om_ or omt_", threadId).WithParam("--thread")
		}
		_, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
		return err
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		threadId, err := resolveThreadID(runtime, runtime.Str("thread"))
		if err != nil {
			return err
		}
		dir := resolveThreadsOrder(runtime)
		pageToken := runtime.Str("page-token")

		pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)

		params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)

		data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
		if err != nil {
			return err
		}
		rawItems, _ := data["items"].([]interface{})
		hasMore, nextPageToken := common.PaginationMeta(data)

		nameCache := make(map[string]string)

		mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)

		downloadResources := runtime.Bool("download-resources")
		messages := make([]map[string]interface{}, 0, len(rawItems))
		for _, item := range rawItems {
			m, _ := item.(map[string]interface{})
			messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
		}

		convertlib.ResolveSenderNames(runtime, messages, nameCache)
		convertlib.AttachSenderNames(messages, nameCache)
		if !runtime.Bool("no-reactions") {
			convertlib.EnrichReactions(runtime, messages)
		}
		if downloadResources {
			enrichMessageResourceDownloads(runtime, messages)
		}

		outData := map[string]interface{}{
			"thread_id":  threadId,
			"messages":   messages,
			"total":      len(messages),
			"has_more":   hasMore,
			"page_token": nextPageToken,
		}
		runtime.OutFormat(outData, nil, func(w io.Writer) {
			if len(messages) == 0 {
				fmt.Fprintln(w, "No messages in this thread.")
				return
			}
			var rows []map[string]interface{}
			for _, msg := range messages {
				row := map[string]interface{}{
					"time": msg["create_time"],
					"type": msg["msg_type"],
				}
				if sender, ok := msg["sender"].(map[string]interface{}); ok {
					if name, _ := sender["name"].(string); name != "" {
						row["sender"] = name
					}
				}
				if content, _ := msg["content"].(string); content != "" {
					row["content"] = convertlib.TruncateContent(content, 40)
				}
				rows = append(rows, row)
			}
			output.PrintTable(w, rows)
			moreHint := ""
			if hasMore {
				moreHint = fmt.Sprintf(" (more available, page_token: %s)", nextPageToken)
			}
			fmt.Fprintf(w, "\n%d thread message(s)%s\ntip: use --format json to view full message content\n", len(messages), moreHint)
		})
		return nil
	},
}

Functions

func BuildBatchGetMuteStatusBody added in v1.0.31

func BuildBatchGetMuteStatusBody(chatIDs []string) map[string]interface{}

BuildBatchGetMuteStatusBody constructs the request body for POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status.

func BuildMuteFilterHint added in v1.0.31

func BuildMuteFilterHint(meta MuteFilterMeta, hasMore bool) string

BuildMuteFilterHint composes the user/AI-facing English hint for a finished filter run. hasMore is the underlying API's has_more (so we can suggest paging). Returns "" when the filter ran but had no effect (FilteredCount==0 and not skipped).

func ExtractChatIDs added in v1.0.31

func ExtractChatIDs(chats []map[string]interface{}, chatIDKey string) []string

ExtractChatIDs collects unique chat_ids (in input order) from a page of rows. Rows missing the key or with an empty value are skipped.

func FetchMuteStatus added in v1.0.31

func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[string]bool, []string, error)

FetchMuteStatus calls batch_get_mute_status for the given chat_ids and parses the result. Caller MUST ensure len(chatIDs) <= MaxMuteStatusBatchSize (the shortcuts already cap --page-size at 100, so a single page is safe).

Empty input is a no-op (avoids triggering the upstream "chat_ids is empty" InvalidParam).

func MuteFilterMetaToMap added in v1.0.31

func MuteFilterMetaToMap(meta MuteFilterMeta) map[string]interface{}

MuteFilterMetaToMap renders the meta as the "filter" sub-object the command writes into outData. The schema is fixed-shape: exactly 5 fields, regardless of skip state.

Skip context (bot identity / all-non-member search-types) is encoded entirely in the Hint string — consumers read the natural-language hint to understand why the filter did or did not apply. UnknownCount and the Skipped / SkipReason struct fields are internal-only (used to compose Hint) and are not exposed in JSON.

func ParseBatchGetMuteStatusResponse added in v1.0.31

func ParseBatchGetMuteStatusResponse(input []string, resp map[string]interface{}) (map[string]bool, []string)

ParseBatchGetMuteStatusResponse maps the API response to:

  • muted: chat_id -> is_muted, only for ids returned in items
  • unknown: chat_ids that came back in invalid_id_list (any msg) OR were in input but missing from both lists.

unknown preserves input order for stable hint output.

func Shortcuts

func Shortcuts() []common.Shortcut

Shortcuts returns all im shortcuts.

Types

type FlagType added in v1.0.28

type FlagType int

FlagType enumerates the kind of bookmark. Aligned with server-side constants: Unknown=0, Feed=1, Message=2.

const (
	FlagTypeUnknown FlagType = 0
	FlagTypeFeed    FlagType = 1
	FlagTypeMessage FlagType = 2
)

type ItemType added in v1.0.28

type ItemType int

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
const (
	ItemTypeDefault   ItemType = 0
	ItemTypeThread    ItemType = 4  // thread in a topic-style chat
	ItemTypeMsgThread ItemType = 11 // thread in a regular chat
)

type MuteFilterInput added in v1.0.31

type MuteFilterInput struct {
	ExcludeMuted  bool                     // value of --exclude-muted
	IsBot         bool                     // current identity
	PreSkipReason string                   // optional caller-supplied skip reason (e.g. SkipReasonAllNonMember); leave empty under bot — IsBot is handled separately
	Chats         []map[string]interface{} // page of result rows
	ChatIDKey     string                   // key in row holding the chat_id ("chat_id" for both v1 list and v2 search meta_data)
	HasMore       bool                     // for hint composition
}

MuteFilterInput captures everything the orchestrator needs from the calling shortcut.

type MuteFilterMeta added in v1.0.31

type MuteFilterMeta struct {
	Applied       string
	Skipped       bool
	SkipReason    string
	FetchedCount  int
	ReturnedCount int
	FilteredCount int
	UnknownCount  int
	Hint          string
}

MuteFilterMeta describes the outcome of a single page's mute filter run. UnknownCount is internal — used to compose the hint, not exposed in JSON.

func ApplyMuteFilter added in v1.0.31

func ApplyMuteFilter(
	chats []map[string]interface{},
	chatIDKey string,
	muted map[string]bool,
	unknown []string,
) ([]map[string]interface{}, MuteFilterMeta)

ApplyMuteFilter drops chats whose mute map entry is true. Chats whose id is in the unknown set, or which have no chatIDKey value, are retained (we have no basis to filter them) and counted as UnknownCount.

Pure function; no API calls. The caller is responsible for fetching the mute map via FetchMuteStatus.

Invariant: meta.FetchedCount == meta.ReturnedCount + meta.FilteredCount.

type MuteFilterOutput added in v1.0.31

type MuteFilterOutput struct {
	Chats []map[string]interface{} // filtered (or unchanged when not applied)
	Meta  MuteFilterMeta           // zero-valued when ExcludeMuted=false; callers detect via Meta.Applied != ""
}

MuteFilterOutput is what the shortcut writes back into outData.

func MaybeApplyMuteFilter added in v1.0.31

func MaybeApplyMuteFilter(runtime *common.RuntimeContext, in MuteFilterInput) (MuteFilterOutput, error)

MaybeApplyMuteFilter is the single entry point shortcuts call.

Behavior:

  • ExcludeMuted=false: returns chats unchanged, Meta is zero-valued (Applied=="")
  • ExcludeMuted=true && IsBot: skip the API call, mark Skipped with SkipReasonBotIdentity
  • ExcludeMuted=true && PreSkipReason!="" (not bot): skip the API call, mark Skipped with that reason
  • ExcludeMuted=true && len(chats)==0: skip the API call (avoids upstream InvalidParam on empty chat_ids); meta has zero counts, Skipped=false
  • ExcludeMuted=true && otherwise: fetch + apply; populate counts and Hint

Callers detect whether the filter ran via out.Meta.Applied != "". Callers compose the JSON map via MuteFilterMetaToMap(out.Meta) at the use site.

type ShortcutType added in v1.0.49

type ShortcutType int

ShortcutType enumerates the OpenAPI feed-shortcut types. Currently the server only opens CHAT (1) externally; other internal values (DOC, OPENAPP, etc.) are not yet whitelisted on the OAPI gateway.

const (
	ShortcutTypeUnknown ShortcutType = 0
	ShortcutTypeChat    ShortcutType = 1
)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL