Skip to content

Commit 8bd5049

Browse files
authored
feat: normalize markdown message send/reply output (#28)
* feat(IM): im markdown send/reply Change-Id: I6b53eb2207d7c2393d3c7d108df3ba197b9eae46 * add Resolve content type Change-Id: I71a0cdb8b500ca1496b23fede1f2b2617f16ec63
1 parent 69bcdd9 commit 8bd5049

6 files changed

Lines changed: 258 additions & 84 deletions

File tree

shortcuts/im/coverage_additional_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ func TestResolveMarkdownAsPost(t *testing.T) {
101101
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
102102
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
103103
}
104+
if strings.Contains(got, `<br>`) {
105+
t.Fatalf("resolveMarkdownAsPost() = %q, want no literal <br>", got)
106+
}
104107
}
105108

106109
func TestValidateContentFlags(t *testing.T) {

shortcuts/im/helpers.go

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -619,31 +619,22 @@ func readMp4Duration(f *os.File, fileSize int64) int64 {
619619
// Steps:
620620
// 1. Extract code blocks with placeholders to protect them
621621
// 2. Downgrade headings: H1 → H4, H2~H6 → H5 (only when H1~H3 present)
622-
// 3. Add <br> between consecutive headings
623-
// 4. Add spacing around tables with <br>
624-
// 5. Restore code blocks with <br> wrappers
625-
// 6. Compress excess blank lines
626-
// 7. Strip invalid image references (keep only img_xxx keys)
622+
// 3. Normalize spacing between consecutive headings and tables with blank lines
623+
// 4. Restore code blocks
624+
// 5. Compress excess blank lines
625+
// 6. Strip invalid image references (keep only img_xxx keys)
627626
var (
628-
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
629-
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
630-
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
631-
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
632-
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
633-
reTableBefore = regexp.MustCompile(`\n\n((?:\|.+\|[^\S\n]*\n?)+)`)
634-
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
635-
reTableTxtPre = regexp.MustCompile(`(?m)^([^\n]+)\n\n(<br>)\n\n(\|)`)
636-
reTableBoldPre = regexp.MustCompile(`(?m)^(\*\*.+)\n\n(<br>)\n\n(\|)`)
637-
reTableTxtPost = regexp.MustCompile(`(?m)(\|[^\n]*\n)\n(<br>\n)([^\n]+)`)
638-
reExcessNL = regexp.MustCompile(`\n{3,}`)
639-
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
640-
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
627+
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
628+
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
629+
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
630+
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
631+
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
632+
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
633+
reExcessNL = regexp.MustCompile(`\n{3,}`)
634+
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
635+
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
641636
)
642637

643-
func isTableSpacingProtectedLine(line string) bool {
644-
return strings.HasPrefix(line, "#### ") || strings.HasPrefix(line, "##### ") || strings.HasPrefix(line, "**")
645-
}
646-
647638
func optimizeMarkdownStyle(text string) string {
648639
const mark = "___CB_"
649640
var codeBlocks []string
@@ -659,29 +650,13 @@ func optimizeMarkdownStyle(text string) string {
659650
r = reH1.ReplaceAllString(r, "#### $1")
660651
}
661652

662-
r = reConsecH.ReplaceAllString(r, "$1\n<br>\n$2")
653+
r = reConsecH.ReplaceAllString(r, "$1\n\n$2")
663654

664655
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
665-
r = reTableBefore.ReplaceAllString(r, "\n\n<br>\n\n$1")
666-
r = reTableAfter.ReplaceAllString(r, "$1\n<br>\n")
667-
r = reTableTxtPre.ReplaceAllStringFunc(r, func(m string) string {
668-
sub := reTableTxtPre.FindStringSubmatch(m)
669-
if len(sub) != 4 || isTableSpacingProtectedLine(sub[1]) {
670-
return m
671-
}
672-
return sub[1] + "\n" + sub[2] + "\n" + sub[3]
673-
})
674-
r = reTableBoldPre.ReplaceAllString(r, "$1\n$2\n\n$3")
675-
r = reTableTxtPost.ReplaceAllStringFunc(r, func(m string) string {
676-
sub := reTableTxtPost.FindStringSubmatch(m)
677-
if len(sub) != 4 || isTableSpacingProtectedLine(sub[3]) {
678-
return m
679-
}
680-
return sub[1] + sub[2] + sub[3]
681-
})
656+
r = reTableAfter.ReplaceAllString(r, "$1\n")
682657

683658
for i, block := range codeBlocks {
684-
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), "\n<br>\n"+block+"\n<br>\n", 1)
659+
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
685660
}
686661

687662
r = reExcessNL.ReplaceAllString(r, "\n\n")

shortcuts/im/helpers_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
282282
{
283283
name: "heading downgrade H1 and H2",
284284
input: "# Title\n## Section\ntext",
285-
want: "#### Title\n<br>\n##### Section\ntext",
285+
want: "#### Title\n\n##### Section\ntext",
286286
},
287287
{
288288
name: "no downgrade when no H1-H3",
@@ -292,17 +292,17 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
292292
{
293293
name: "code block protected",
294294
input: "# Title\n```\n# not a heading\n```\ntext",
295-
want: "#### Title\n\n<br>\n```\n# not a heading\n```\n<br>\n\ntext",
295+
want: "#### Title\n```\n# not a heading\n```\ntext",
296296
},
297297
{
298298
name: "table spacing",
299299
input: "text\n| A | B |\n| - | - |\n| 1 | 2 |\nafter",
300-
want: "text\n<br>\n| A | B |\n| - | - |\n| 1 | 2 |\n<br>\nafter",
300+
want: "text\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nafter",
301301
},
302302
{
303303
name: "table spacing keeps heading separation",
304304
input: "# Title\n| A | B |\n| - | - |\n| 1 | 2 |\n## Next",
305-
want: "#### Title\n\n<br>\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n<br>\n##### Next",
305+
want: "#### Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n##### Next",
306306
},
307307
{
308308
name: "excess blank lines compressed",

shortcuts/im/im_messages_send.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ var ImMessagesSend = common.Shortcut{
188188
return output.ErrValidation("%v", err)
189189
}
190190
}
191-
191+
// Resolve content type
192192
if markdown != "" {
193193
msgType, content = "post", resolveMarkdownAsPost(ctx, runtime, markdown)
194194
} else if mt, c, err := resolveMediaContent(ctx, runtime, text, imageVal, fileVal, videoVal, videoCoverVal, audioVal); err != nil {

skills/lark-im/references/lark-im-messages-reply.md

Lines changed: 117 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,88 @@ Replies sent by this tool are visible to other people. Before calling it, you **
1818

1919
When using `--as bot`, the reply is sent in the app's name, so make sure the app has already been added to the target chat.
2020

21+
## Choose The Right Content Flag
22+
23+
| Need | Recommended flag | Why |
24+
|------|------|------|
25+
| Reply with plain text exactly as written | `--text` | Wrapped directly to `{"text":"..."}` |
26+
| Reply with simple Markdown and accept conversion | `--markdown` | Automatically converted to `post` JSON |
27+
| Precisely control the reply payload | `--content` | You provide the exact JSON |
28+
| Reply with media | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads local files automatically |
29+
30+
### `--text` vs `--markdown`
31+
32+
- Use `--text` when the reply should remain plain text and you want exact control over line breaks, spacing, indentation, code samples, or literal Markdown characters.
33+
- Use `--markdown` when you want a lightweight formatted reply and you accept that the shortcut will normalize and rewrite parts of the content before sending.
34+
- Use `--content` when you need exact `post` JSON, a card, a title, multiple locales, or any structure that `--markdown` cannot express reliably.
35+
36+
## What `--markdown` Really Does
37+
38+
`--markdown` does **not** send arbitrary raw Markdown to the API.
39+
40+
The shortcut:
41+
42+
1. Forces `msg_type=post`
43+
2. Resolves remote Markdown images like `![x](https://...)`
44+
3. Normalizes the Markdown for Feishu post rendering
45+
4. Wraps the final content as:
46+
47+
```json
48+
{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
49+
```
50+
51+
So `--markdown` is a convenience mode, not a full Markdown compatibility layer.
52+
53+
### Current Markdown Caveats
54+
55+
- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
56+
- It always becomes a `post` payload with a single `zh_cn` locale.
57+
- It does **not** let you set a `post` title.
58+
- Headings are rewritten:
59+
- `# Title` becomes `#### Title`
60+
- `##` to `######` are normalized to `#####` when the content contains H1-H3
61+
- Consecutive headings are separated with blank lines after heading normalization.
62+
- Block spacing and line breaks may be normalized during conversion.
63+
- Code blocks are preserved as code blocks.
64+
- Excess blank lines are compressed.
65+
- Only remote `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably.
66+
- Local paths in Markdown image syntax like `![x](./a.png)` are **not** auto-uploaded by `--markdown`.
67+
- If remote Markdown image handling fails, that image is removed with a warning.
68+
69+
If you need exact output, use `--msg-type post --content ...` instead of `--markdown`.
70+
71+
## Preserving Formatting
72+
73+
If the reply contains multiple lines, code blocks, indentation, tabs, or a lot of escaping, prefer `$'...'`.
74+
75+
### When formatting must be preserved
76+
77+
Use `--text` plus `$'...'`:
78+
79+
```bash
80+
lark-cli im +messages-reply --message-id om_xxx --text $'Received\nI will check this today.\nOwner: alice'
81+
```
82+
83+
```bash
84+
lark-cli im +messages-reply --message-id om_xxx --text $'```sql\nselect * from jobs;\n```'
85+
```
86+
87+
This keeps the reply as plain text instead of converting it to a `post`.
88+
89+
### When formatting does not need exact preservation
90+
91+
Use `--markdown`:
92+
93+
```bash
94+
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Follow-up\n\n- I reproduced it\n- I am fixing it'
95+
```
96+
97+
This is better for quick readable formatting, but the final payload may still differ from the source text because headings and spacing are normalized before sending.
98+
2199
## Commands
22100

23101
```bash
24-
# Reply to a message (plain text, bot identity, --text is recommended)
102+
# Reply to a message (plain text, --text is recommended for normal replies)
25103
lark-cli im +messages-reply --message-id om_xxx --text "Received"
26104

27105
# Equivalent manual JSON
@@ -30,13 +108,16 @@ lark-cli im +messages-reply --message-id om_xxx --content '{"text":"Received"}'
30108
# Reply as a bot
31109
lark-cli im +messages-reply --message-id om_xxx --text "bot reply" --as bot
32110

111+
# Reply with preserved multi-line text
112+
lark-cli im +messages-reply --message-id om_xxx --text $'Line 1\nLine 2\n indented line'
113+
33114
# Reply inside the thread (message appears in the target thread)
34115
lark-cli im +messages-reply --message-id om_xxx --text "Let's discuss this" --reply-in-thread
35116

36-
# Bot identity + thread reply
37-
lark-cli im +messages-reply --message-id om_xxx --text "bot reply" --as bot --reply-in-thread
117+
# Reply with basic Markdown (will be converted to post JSON)
118+
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Reply\n\n- item 1\n- item 2'
38119

39-
# Reply with a rich-text message
120+
# If you need exact post structure, send JSON directly
40121
lark-cli im +messages-reply --message-id om_xxx --msg-type post --content '{"zh_cn":{"title":"Reply","content":[[{"tag":"text","text":"Detailed content"}]]}}'
41122

42123
# Reply with a local image (uploaded automatically before sending)
@@ -52,23 +133,23 @@ lark-cli im +messages-reply --message-id om_xxx --video ./demo.mp4 --video-cover
52133
lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency-key my-unique-id
53134

54135
# Preview the request without executing it
55-
lark-cli im +messages-reply --message-id om_xxx --text "Test" --dry-run
136+
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' --dry-run
56137
```
57138

58139
## Parameters
59140

60141
| Parameter | Required | Description |
61142
|------|------|------|
62143
| `--message-id <id>` | Yes | ID of the message being replied to (`om_xxx`) |
63-
| `--msg-type <type>` | No | Message type (default `text`): `text`, `post`, `image`, `file`, `audio`, `media`, `interactive`, `share_chat`, `share_user` |
64-
| `--content <json>` | One of content options | Reply content as a JSON string; format depends on `msg_type` |
65-
| `--text <string>` | One of content options | Plain text message (automatically wrapped as `{"text":"..."}` JSON) |
66-
| `--markdown <string>` | One of content options | Markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved) |
67-
| `--image <path\|key>` | One of content options | Local image path, `image_key` (`img_xxx`)|
68-
| `--file <path\|key>` | One of content options | Local file path, `file_key` (`file_xxx`)|
69-
| `--video <path\|key>` | One of content options | Local video path, `file_key`; **must be used together with `--video-cover`** |
70-
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path, `image_key` (`img_xxx`) |
71-
| `--audio <path\|key>` | One of content options | Local audio path, `file_key` |
144+
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
145+
| `--content <json>` | One content option | Exact reply content as JSON. The JSON must match the effective `--msg-type` |
146+
| `--text <string>` | One content option | Plain text reply. Best default when you need exact text and formatting preservation |
147+
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization |
148+
| `--image <path\|key>` | One content option | Local image path or `image_key` (`img_xxx`) |
149+
| `--file <path\|key>` | One content option | Local file path or `file_key` (`file_xxx`) |
150+
| `--video <path\|key>` | One content option | Local video path or `file_key`; **must be used together with `--video-cover`** |
151+
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path or `image_key` (`img_xxx`) |
152+
| `--audio <path\|key>` | One content option | Local audio path or `file_key` |
72153
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
73154
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
74155
| `--as <identity>` | No | Identity type: `bot` only |
@@ -78,6 +159,15 @@ lark-cli im +messages-reply --message-id om_xxx --text "Test" --dry-run
78159
>
79160
> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
80161
162+
## Common Mistakes
163+
164+
- Choosing `--markdown` when you actually need exact plain text. If exact line breaks and spacing matter, use `--text`, usually with `$'...'`.
165+
- Assuming `--markdown` supports all Markdown features. It does not; it is converted into a Feishu `post` payload and rewritten first.
166+
- Putting local image paths inside Markdown like `![x](./a.png)`. `--markdown` does not auto-upload those paths.
167+
- Using `--content` without making the JSON match the effective `--msg-type`.
168+
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
169+
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
170+
81171
## Return Value
82172

83173
```json
@@ -108,16 +198,22 @@ The reply appears in the target message's thread and does not show up in the mai
108198

109199
## @Mention Format (text / post)
110200

111-
- @specific user: `<at user_id="ou_xxx">name</at>`
201+
- Recommended format: `<at user_id="ou_xxx">name</at>`
112202
- @all: `<at user_id="all"></at>`
203+
- The shortcut normalizes common variants like `<at id=...>` and `<at open_id=...>` into `user_id`, but `user_id` remains the recommended documented form
113204

114205
## Notes
115206

116207
- `--message-id` must be a valid message ID in `om_xxx` format
117-
- `--content` must be a valid JSON string
118-
- `--reply-in-thread` is only meaningful in group chats
119-
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; use relative paths within the current working directory. The shortcut automatically uploads the file first and then sends the reply
120-
- If the provided value starts with `img_` or `file_`, it is treated as an existing key and used directly
121-
- When using `--video`, `--video-cover` is **required** as the video cover. Omitting `--video-cover` with `--video` will produce a validation error. `--video-cover` cannot be used without `--video`
208+
- `--content` must be valid JSON
209+
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
210+
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
211+
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
212+
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply
213+
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
214+
- `--markdown` always sends `msg_type=post`
215+
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
216+
- When using `--video`, `--video-cover` is required as the video cover
217+
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
122218
- Failures return error codes and messages
123-
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
219+
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope

0 commit comments

Comments
 (0)