Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions shortcuts/im/coverage_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ func TestResolveMarkdownAsPost(t *testing.T) {
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
}
if strings.Contains(got, `<br>`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want no literal <br>", got)
}
}

func TestValidateContentFlags(t *testing.T) {
Expand Down
57 changes: 16 additions & 41 deletions shortcuts/im/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -619,31 +619,22 @@ func readMp4Duration(f *os.File, fileSize int64) int64 {
// Steps:
// 1. Extract code blocks with placeholders to protect them
// 2. Downgrade headings: H1 → H4, H2~H6 → H5 (only when H1~H3 present)
// 3. Add <br> between consecutive headings
// 4. Add spacing around tables with <br>
// 5. Restore code blocks with <br> wrappers
// 6. Compress excess blank lines
// 7. Strip invalid image references (keep only img_xxx keys)
// 3. Normalize spacing between consecutive headings and tables with blank lines
// 4. Restore code blocks
// 5. Compress excess blank lines
// 6. Strip invalid image references (keep only img_xxx keys)
var (
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableBefore = regexp.MustCompile(`\n\n((?:\|.+\|[^\S\n]*\n?)+)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reTableTxtPre = regexp.MustCompile(`(?m)^([^\n]+)\n\n(<br>)\n\n(\|)`)
reTableBoldPre = regexp.MustCompile(`(?m)^(\*\*.+)\n\n(<br>)\n\n(\|)`)
reTableTxtPost = regexp.MustCompile(`(?m)(\|[^\n]*\n)\n(<br>\n)([^\n]+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
)

func isTableSpacingProtectedLine(line string) bool {
return strings.HasPrefix(line, "#### ") || strings.HasPrefix(line, "##### ") || strings.HasPrefix(line, "**")
}

func optimizeMarkdownStyle(text string) string {
const mark = "___CB_"
var codeBlocks []string
Expand All @@ -659,29 +650,13 @@ func optimizeMarkdownStyle(text string) string {
r = reH1.ReplaceAllString(r, "#### $1")
}

r = reConsecH.ReplaceAllString(r, "$1\n<br>\n$2")
r = reConsecH.ReplaceAllString(r, "$1\n\n$2")

r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
r = reTableBefore.ReplaceAllString(r, "\n\n<br>\n\n$1")
r = reTableAfter.ReplaceAllString(r, "$1\n<br>\n")
r = reTableTxtPre.ReplaceAllStringFunc(r, func(m string) string {
sub := reTableTxtPre.FindStringSubmatch(m)
if len(sub) != 4 || isTableSpacingProtectedLine(sub[1]) {
return m
}
return sub[1] + "\n" + sub[2] + "\n" + sub[3]
})
r = reTableBoldPre.ReplaceAllString(r, "$1\n$2\n\n$3")
r = reTableTxtPost.ReplaceAllStringFunc(r, func(m string) string {
sub := reTableTxtPost.FindStringSubmatch(m)
if len(sub) != 4 || isTableSpacingProtectedLine(sub[3]) {
return m
}
return sub[1] + sub[2] + sub[3]
})
r = reTableAfter.ReplaceAllString(r, "$1\n")

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

r = reExcessNL.ReplaceAllString(r, "\n\n")
Expand Down
8 changes: 4 additions & 4 deletions shortcuts/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
{
name: "heading downgrade H1 and H2",
input: "# Title\n## Section\ntext",
want: "#### Title\n<br>\n##### Section\ntext",
want: "#### Title\n\n##### Section\ntext",
},
{
name: "no downgrade when no H1-H3",
Expand All @@ -292,17 +292,17 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
{
name: "code block protected",
input: "# Title\n```\n# not a heading\n```\ntext",
want: "#### Title\n\n<br>\n```\n# not a heading\n```\n<br>\n\ntext",
want: "#### Title\n```\n# not a heading\n```\ntext",
},
{
name: "table spacing",
input: "text\n| A | B |\n| - | - |\n| 1 | 2 |\nafter",
want: "text\n<br>\n| A | B |\n| - | - |\n| 1 | 2 |\n<br>\nafter",
want: "text\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nafter",
},
{
name: "table spacing keeps heading separation",
input: "# Title\n| A | B |\n| - | - |\n| 1 | 2 |\n## Next",
want: "#### Title\n\n<br>\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n<br>\n##### Next",
want: "#### Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n##### Next",
},
{
name: "excess blank lines compressed",
Expand Down
138 changes: 117 additions & 21 deletions skills/lark-im/references/lark-im-messages-reply.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,88 @@ Replies sent by this tool are visible to other people. Before calling it, you **

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.

## Choose The Right Content Flag

| Need | Recommended flag | Why |
|------|------|------|
| Reply with plain text exactly as written | `--text` | Wrapped directly to `{"text":"..."}` |
| Reply with simple Markdown and accept conversion | `--markdown` | Automatically converted to `post` JSON |
| Precisely control the reply payload | `--content` | You provide the exact JSON |
| Reply with media | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads local files automatically |

### `--text` vs `--markdown`

- 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.
- 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.
- Use `--content` when you need exact `post` JSON, a card, a title, multiple locales, or any structure that `--markdown` cannot express reliably.

## What `--markdown` Really Does

`--markdown` does **not** send arbitrary raw Markdown to the API.

The shortcut:

1. Forces `msg_type=post`
2. Resolves remote Markdown images like `![x](https://...)`
3. Normalizes the Markdown for Feishu post rendering
4. Wraps the final content as:

```json
{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
```

So `--markdown` is a convenience mode, not a full Markdown compatibility layer.

### Current Markdown Caveats

- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
- It always becomes a `post` payload with a single `zh_cn` locale.
- It does **not** let you set a `post` title.
- Headings are rewritten:
- `# Title` becomes `#### Title`
- `##` to `######` are normalized to `#####` when the content contains H1-H3
- Consecutive headings are separated with blank lines after heading normalization.
- Block spacing and line breaks may be normalized during conversion.
- Code blocks are preserved as code blocks.
- Excess blank lines are compressed.
- Only remote `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably.
- Local paths in Markdown image syntax like `![x](./a.png)` are **not** auto-uploaded by `--markdown`.
- If remote Markdown image handling fails, that image is removed with a warning.

If you need exact output, use `--msg-type post --content ...` instead of `--markdown`.

## Preserving Formatting

If the reply contains multiple lines, code blocks, indentation, tabs, or a lot of escaping, prefer `$'...'`.

### When formatting must be preserved

Use `--text` plus `$'...'`:

```bash
lark-cli im +messages-reply --message-id om_xxx --text $'Received\nI will check this today.\nOwner: alice'
```

```bash
lark-cli im +messages-reply --message-id om_xxx --text $'```sql\nselect * from jobs;\n```'
```

This keeps the reply as plain text instead of converting it to a `post`.

### When formatting does not need exact preservation

Use `--markdown`:

```bash
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Follow-up\n\n- I reproduced it\n- I am fixing it'
```

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.

## Commands

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

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

# Reply with preserved multi-line text
lark-cli im +messages-reply --message-id om_xxx --text $'Line 1\nLine 2\n indented line'

# Reply inside the thread (message appears in the target thread)
lark-cli im +messages-reply --message-id om_xxx --text "Let's discuss this" --reply-in-thread

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

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

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

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

## Parameters

| Parameter | Required | Description |
|------|------|------|
| `--message-id <id>` | Yes | ID of the message being replied to (`om_xxx`) |
| `--msg-type <type>` | No | Message type (default `text`): `text`, `post`, `image`, `file`, `audio`, `media`, `interactive`, `share_chat`, `share_user` |
| `--content <json>` | One of content options | Reply content as a JSON string; format depends on `msg_type` |
| `--text <string>` | One of content options | Plain text message (automatically wrapped as `{"text":"..."}` JSON) |
| `--markdown <string>` | One of content options | Markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved) |
| `--image <path\|key>` | One of content options | Local image path, `image_key` (`img_xxx`)|
| `--file <path\|key>` | One of content options | Local file path, `file_key` (`file_xxx`)|
| `--video <path\|key>` | One of content options | Local video path, `file_key`; **must be used together with `--video-cover`** |
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path, `image_key` (`img_xxx`) |
| `--audio <path\|key>` | One of content options | Local audio path, `file_key` |
| `--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 |
| `--content <json>` | One content option | Exact reply content as JSON. The JSON must match the effective `--msg-type` |
| `--text <string>` | One content option | Plain text reply. Best default when you need exact text and formatting preservation |
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization |
| `--image <path\|key>` | One content option | Local image path or `image_key` (`img_xxx`) |
| `--file <path\|key>` | One content option | Local file path or `file_key` (`file_xxx`) |
| `--video <path\|key>` | One content option | Local video path or `file_key`; **must be used together with `--video-cover`** |
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path or `image_key` (`img_xxx`) |
| `--audio <path\|key>` | One content option | Local audio path or `file_key` |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` only |
Expand All @@ -78,6 +159,15 @@ lark-cli im +messages-reply --message-id om_xxx --text "Test" --dry-run
>
> **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`.

## Common Mistakes

- Choosing `--markdown` when you actually need exact plain text. If exact line breaks and spacing matter, use `--text`, usually with `$'...'`.
- Assuming `--markdown` supports all Markdown features. It does not; it is converted into a Feishu `post` payload and rewritten first.
- Putting local image paths inside Markdown like `![x](./a.png)`. `--markdown` does not auto-upload those paths.
- Using `--content` without making the JSON match the effective `--msg-type`.
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.

## Return Value

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

## @Mention Format (text / post)

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

## Notes

- `--message-id` must be a valid message ID in `om_xxx` format
- `--content` must be a valid JSON string
- `--reply-in-thread` is only meaningful in group chats
- `--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
- If the provided value starts with `img_` or `file_`, it is treated as an existing key and used directly
- 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`
- `--content` must be valid JSON
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
- `--markdown` always sends `msg_type=post`
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
- When using `--video`, `--video-cover` is required as the video cover
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
- Failures return error codes and messages
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
Loading