im

package
v1.0.46 Latest Latest
Warning

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

Go to latest
Published: Jun 2, 2026 License: MIT Imports: 28 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 output.ErrValidation("--set-bot-manager is only supported with bot identity (--as bot)")
		}

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

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

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

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

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

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

		if owner := runtime.Str("owner"); owner != "" {
			if _, err := common.ValidateUserID(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.DoAPIJSON(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.DoAPIJSON(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-type", Default: "ByCreateTimeAsc", Desc: "sort order", 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 output.ErrValidation("--page-size must be an integer between 1 and 100")
		}
		parts, err := normalizeTypes(runtime.StrSlice("types"))
		if err != nil {
			return err
		}
		if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
			return output.ErrValidation(
				`--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.`)
		}
		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.CallAPI("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: "sort", Default: "desc", Desc: "sort order", 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)"},
	},
	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.")
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

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

		if chatFlag := runtime.Str("chat-id"); chatFlag != "" {
			if _, err := common.ValidateChatID(chatFlag); err != nil {
				return err
			}
		}
		if userFlag := runtime.Str("user-id"); userFlag != "" {
			if _, err := common.ValidateUserID(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.DoAPIJSON(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)

		messages := make([]map[string]interface{}, 0, len(rawItems))
		for _, item := range rawItems {
			m, _ := item.(map[string]interface{})
			messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
		}

		convertlib.ResolveSenderNames(runtime, messages, nameCache)
		convertlib.AttachSenderNames(messages, nameCache)
		convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
		if !runtime.Bool("no-reactions") {
			convertlib.EnrichReactions(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: "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-by", Desc: "sort field (descending)", 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 output.ErrValidation("--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 output.ErrValidation("--query exceeds the maximum of 64 characters (got %d)", len([]rune(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 output.ErrValidation("invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item)
				}
			}
		}
		if mi := runtime.Str("member-ids"); mi != "" {
			ids := common.SplitCSV(mi)
			if len(ids) > 50 {
				return output.ErrValidation("--member-ids exceeds the maximum of 50 (got %d)", len(ids))
			}
			for _, id := range ids {
				if _, err := common.ValidateUserID(id); err != nil {
					return err
				}
			}
		}
		if n := runtime.Int("page-size"); n < 1 || n > 100 {
			return output.ErrValidation("--page-size must be an integer between 1 and 100")
		}
		return nil
	},

	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		body := buildSearchChatBody(runtime)
		params := buildSearchChatParams(runtime)
		resData, err := runtime.CallAPI("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.ValidateChatID(chat); err != nil {
			return err
		}

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

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

		body := buildUpdateChatBody(runtime)
		if len(body) == 0 {
			return output.ErrValidation("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.DoAPIJSON(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 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
		}

		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
	},
}

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 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"] == "<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 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
	},
}

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.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
	},
}

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)"},
	},
	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.")
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		ids := common.SplitCSV(runtime.Str("message-ids"))
		if len(ids) == 0 {
			return output.ErrValidation("--message-ids is required (comma-separated om_xxx)")
		}
		if len(ids) > maxMGetMessageIDs {
			return output.ErrValidation("--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(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.DoAPIJSON(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)

		messages := make([]map[string]interface{}, 0, len(rawItems))
		for _, item := range rawItems {
			m, _ := item.(map[string]interface{})
			messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
		}

		convertlib.ResolveSenderNames(runtime, messages, nameCache)
		convertlib.AttachSenderNames(messages, nameCache)
		convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
		if !runtime.Bool("no-reactions") {
			convertlib.EnrichReactions(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 output.ErrValidation("--message-id is required (om_xxx)")
		}
		if _, err := validateMessageID(messageId); err != nil {
			return err
		}

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

		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.DoAPIJSON(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 output.ErrValidation("--message-id is required (om_xxx)")
		} else if _, err := validateMessageID(messageId); err != nil {
			return err
		}
		relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output"))
		if err != nil {
			return output.ErrValidation("%s", err)
		}
		if _, err := runtime.ResolveSavePath(relPath); err != nil {
			return output.ErrValidation("unsafe output path: %s", 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 output.ErrValidation("invalid output path: %s", err)
		}
		if _, err := runtime.ResolveSavePath(relPath); err != nil {
			return output.ErrValidation("unsafe output path: %s", err)
		}

		userSpecifiedOutput := runtime.Str("output") != ""
		finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath, userSpecifiedOutput)
		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.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
			return err
		}

		if chatFlag != "" {
			if _, err := common.ValidateChatID(chatFlag); err != nil {
				return err
			}
		}
		if userFlag != "" {
			if _, err := common.ValidateUserID(userFlag); err != nil {
				return err
			}
		}

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

		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.DoAPIJSON(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: "sort", Default: "asc", Desc: "sort order", 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)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		threadFlag := runtime.Str("thread")
		sortFlag := runtime.Str("sort")
		pageSizeStr := runtime.Str("page-size")
		pageToken := runtime.Str("page-token")

		sortType := "ByCreateTimeAsc"
		if sortFlag == "desc" {
			sortType = "ByCreateTimeDesc"
		}

		pageSize, _ := common.ValidatePageSize(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 := map[string]interface{}{
			"container_id_type":     "thread",
			"container_id":          containerID,
			"sort_type":             sortType,
			"page_size":             pageSize,
			"card_msg_content_type": "raw_card_content",
		}
		if pageToken != "" {
			params["page_token"] = pageToken
		}

		d = d.
			GET("/open-apis/im/v1/messages").
			Params(params).
			Set("thread", threadFlag).Set("sort", sortFlag).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.")
		}
		return d
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		threadId := runtime.Str("thread")
		if threadId == "" {
			return output.ErrValidation("--thread is required (om_xxx or omt_xxx)")
		}
		if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") {
			return output.ErrValidation("invalid --thread %q: must start with om_ or omt_", threadId)
		}
		_, err := common.ValidatePageSize(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
		}
		sortFlag := runtime.Str("sort")
		pageToken := runtime.Str("page-token")

		sortType := "ByCreateTimeAsc"
		if sortFlag == "desc" {
			sortType = "ByCreateTimeDesc"
		}

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

		params := map[string][]string{
			"container_id_type":     []string{"thread"},
			"container_id":          []string{threadId},
			"sort_type":             []string{sortType},
			"page_size":             []string{strconv.Itoa(pageSize)},
			"card_msg_content_type": []string{"raw_card_content"},
		}
		if pageToken != "" {
			params["page_token"] = []string{pageToken}
		}

		data, err := runtime.DoAPIJSON(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)

		messages := make([]map[string]interface{}, 0, len(rawItems))
		for _, item := range rawItems {
			m, _ := item.(map[string]interface{})
			messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
		}

		convertlib.ResolveSenderNames(runtime, messages, nameCache)
		convertlib.AttachSenderNames(messages, nameCache)
		if !runtime.Bool("no-reactions") {
			convertlib.EnrichReactions(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.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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