diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 58a577b107..70207b16b0 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -676,6 +676,24 @@ func TestShortcuts(t *testing.T) { } } +func TestMessageBodyFlagsAcceptFileAndStdin(t *testing.T) { + for _, shortcut := range []common.Shortcut{ImMessagesSend, ImMessagesReply} { + for _, name := range []string{"content", "text", "markdown"} { + var got []string + for _, flag := range shortcut.Flags { + if flag.Name == name { + got = flag.Input + break + } + } + want := []string{common.File, common.Stdin} + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s --%s Input = %#v, want %#v", shortcut.Command, name, got, want) + } + } + } +} + // TestSenderDisplay covers the human-readable sender column: a resolved name wins, // otherwise the sender id is shown (AC3 fallback), and a system/senderless message // with neither yields an empty string (no name is normal, not an error). diff --git a/shortcuts/im/im_messages_reply.go b/shortcuts/im/im_messages_reply.go index 1aaf4ba83b..d8d61a8d73 100644 --- a/shortcuts/im/im_messages_reply.go +++ b/shortcuts/im/im_messages_reply.go @@ -26,9 +26,9 @@ var ImMessagesReply = common.Shortcut{ 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: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON", Input: []string{common.File, common.Stdin}}, + {Name: "text", Desc: "plain text message (auto-wrapped as JSON)", Input: []string{common.File, common.Stdin}}, + {Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)", Input: []string{common.File, common.Stdin}}, {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"}, diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index 8633aab16d..ee092b9f38 100644 --- a/shortcuts/im/im_messages_send.go +++ b/shortcuts/im/im_messages_send.go @@ -29,9 +29,9 @@ var ImMessagesSend = common.Shortcut{ {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: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON", Input: []string{common.File, common.Stdin}}, + {Name: "text", Desc: "plain text message (auto-wrapped as JSON)", Input: []string{common.File, common.Stdin}}, + {Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)", Input: []string{common.File, common.Stdin}}, {Name: "idempotency-key", Desc: "idempotency key, max 50 characters (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)"}, diff --git a/skills/lark-im/references/lark-im-messages-reply.md b/skills/lark-im/references/lark-im-messages-reply.md index 70e3c87b28..ee95df2533 100644 --- a/skills/lark-im/references/lark-im-messages-reply.md +++ b/skills/lark-im/references/lark-im-messages-reply.md @@ -41,6 +41,8 @@ When using `--as user`, the reply is sent as the authorized end user and require - Use `--text` for exact plain text, especially logs, code, indentation, or literal Markdown characters. - Use `--content` when you need exact `post` JSON, a card, a title, multiple locales, or any structure that `--markdown` cannot express reliably. +For long or generated replies, pass `--markdown @./reply.md` or pipe the body with `--markdown -` so shell metacharacters are never interpreted. + ## What `--markdown` Really Does `--markdown` accepts Markdown-like input and converts it to the Feishu `post` payload required by the reply API. diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md index eb3a6b144b..e1f49c9c7e 100644 --- a/skills/lark-im/references/lark-im-messages-send.md +++ b/skills/lark-im/references/lark-im-messages-send.md @@ -129,6 +129,12 @@ lark-cli im +messages-send --user-id ou_xxx --text "Hello" # Send multi-line text while preserving formatting lark-cli im +messages-send --chat-id oc_xxx --text $'Line 1\nLine 2\n indented line' +# Safest option for long or generated content: read a cwd-relative file +lark-cli im +messages-send --chat-id oc_xxx --markdown @./message.md + +# Or pipe exactly one body flag through stdin +generate-message | lark-cli im +messages-send --chat-id oc_xxx --markdown - + # Send Markdown with an image (must pre-upload via images.create) lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png # Use the returned image_key in the markdown content diff --git a/tests/cli_e2e/im/message_markdown_input_dryrun_test.go b/tests/cli_e2e/im/message_markdown_input_dryrun_test.go new file mode 100644 index 0000000000..6d738cba9e --- /dev/null +++ b/tests/cli_e2e/im/message_markdown_input_dryrun_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestIMMessagesSendMarkdownFileDryRunPreservesShellSyntax(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "im_markdown_input_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_markdown_input_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + workDir := t.TempDir() + markdown := "## 发布清单\n\n```sh\necho $HOME && $(do-not-run)\n```\n\n中文内容保持完整。\n" + require.NoError(t, os.WriteFile(filepath.Join(workDir, "message.md"), []byte(markdown), 0o600)) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+messages-send", + "--chat-id", "oc_123", + "--markdown", "@./message.md", + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + content := gjson.Get(result.Stdout, "data.request.body.content").String() + require.Contains(t, content, "echo $HOME && $(do-not-run)") + require.Contains(t, content, "中文内容保持完整。") +}