Files
larksuite-cli/shortcuts/im/im_messages_send.go
YangJunzhou-01 c2e737434c fix(im): clarify messages-send dry-run chat membership (#1150)
clarify messages-send dry-run chat membership
2026-05-28 16:39:00 +08:00

227 lines
8.3 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
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
}
// Validate ID formats
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
}
}
// Resolve content type
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
},
}
// isMediaKey returns true if the value looks like an existing API key rather than a local file path.
func isMediaKey(value string) bool {
return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_")
}
// validateMediaFlagPath validates a media flag value as a local file path via FileIO.
// Empty values, URLs, and media keys are skipped (not local files).
func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error {
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || isMediaKey(value) {
return nil
}
if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) {
return output.ErrValidation("%s: %v", flagName, err)
}
return nil
}