Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
View Source
var ImChatCreate = common.Shortcut{ Service: "im", Command: "+chat-create", Description: "Create a group chat; user/bot; creates private/public chats, 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: "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 }, }
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", "contact:user.base:readonly"}, BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"}, 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 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"}, }, 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] } } return d.GET("/open-apis/im/v1/messages").Params(dryParams) }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { 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) messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache)) } convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit) 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 keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination", 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"}, }, 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) } outData := map[string]interface{}{ "chats": items, "total": int(total), "has_more": hasMore, "page_token": pageToken, } runtime.OutFormat(outData, nil, func(w io.Writer) { if len(items) == 0 { fmt.Fprintln(w, "No matching group chats found.") 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) }) return nil }, }
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 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", "contact:user.basic_profile:readonly"}, BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "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}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { ids := common.SplitCSV(runtime.Str("message-ids")) return common.NewDryRunAPI().GET(buildMGetURL(ids)) }, 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) messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache)) } convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit) 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, local file path"}, {Name: "file", Desc: "file_key, local file path"}, {Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"}, {Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"}, {Name: "audio", Desc: "audio file_key, local file path"}, {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") if !isMediaKey(imageKey) { if _, err := validate.SafeLocalFlagPath("--image", imageKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(fileKey) { if _, err := validate.SafeLocalFlagPath("--file", fileKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoKey) { if _, err := validate.SafeLocalFlagPath("--video", videoKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoCoverKey) { if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(audioKey) { if _, err := validate.SafeLocalFlagPath("--audio", audioKey); err != nil { return output.ErrValidation("%v", 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") if !isMediaKey(imageVal) { if _, err := validate.SafeLocalFlagPath("--image", imageVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(fileVal) { if _, err := validate.SafeLocalFlagPath("--file", fileVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoVal) { if _, err := validate.SafeLocalFlagPath("--video", videoVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoCoverVal) { if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(audioVal) { if _, err := validate.SafeLocalFlagPath("--audio", audioVal); err != nil { return output.ErrValidation("%v", 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; defaults to file_key)"}, }, 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 := validate.SafeOutputPath(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) } safePath, err := validate.SafeOutputPath(relPath) if err != nil { return output.ErrValidation("unsafe output path: %s", err) } finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, safePath) 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", "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: "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", 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)"}, }, 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") } return 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") }, 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) enriched := make([]map[string]interface{}, 0, len(msgItems)) for _, item := range msgItems { m, _ := item.(map[string]interface{}) chatId, _ := m["chat_id"].(string) msg := convertlib.FormatMessageItem(m, runtime, nameCache) 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) 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, local file path"}, {Name: "file", Desc: "file_key, local file path"}, {Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"}, {Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"}, {Name: "audio", Desc: "audio file_key, local file path"}, }, 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) } return d. POST("/open-apis/im/v1/messages"). Params(map[string]interface{}{"receive_id_type": receiveIdType}). Body(body) }, 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") if !isMediaKey(imageKey) { if _, err := validate.SafeLocalFlagPath("--image", imageKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(fileKey) { if _, err := validate.SafeLocalFlagPath("--file", fileKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoKey) { if _, err := validate.SafeLocalFlagPath("--video", videoKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoCoverKey) { if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverKey); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(audioKey) { if _, err := validate.SafeLocalFlagPath("--audio", audioKey); err != nil { return output.ErrValidation("%v", 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") if !isMediaKey(imageVal) { if _, err := validate.SafeLocalFlagPath("--image", imageVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(fileVal) { if _, err := validate.SafeLocalFlagPath("--file", fileVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoVal) { if _, err := validate.SafeLocalFlagPath("--video", videoVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(videoCoverVal) { if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverVal); err != nil { return output.ErrValidation("%v", err) } } if !isMediaKey(audioVal) { if _, err := validate.SafeLocalFlagPath("--audio", audioVal); err != nil { return output.ErrValidation("%v", 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", "contact:user.basic_profile:readonly"}, BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "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"}, }, 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 } return d. GET("/open-apis/im/v1/messages"). Params(params). Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr) }, 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) messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache)) } convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) 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 ¶
Types ¶
This section is empty.
Source Files
¶
Click to show internal directories.
Click to hide internal directories.