mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
This commit is contained in:
@@ -122,7 +122,7 @@ func TestExtractPostBlocksText(t *testing.T) {
|
||||
}
|
||||
|
||||
got := extractPostBlocksText(blocks)
|
||||
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
|
||||
want := "hello @Alice [docs](https://example.com)\n"
|
||||
if got != want {
|
||||
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -39,16 +39,16 @@ func (postConverter) Convert(ctx *ConvertContext) string {
|
||||
if title, _ := body["title"].(string); title != "" {
|
||||
parts = append(parts, title)
|
||||
}
|
||||
if blocks, _ := body["content"].([]interface{}); len(blocks) > 0 {
|
||||
for _, para := range blocks {
|
||||
elems, _ := para.([]interface{})
|
||||
var line strings.Builder
|
||||
for _, el := range elems {
|
||||
elem, _ := el.(map[string]interface{})
|
||||
line.WriteString(renderPostElem(elem))
|
||||
}
|
||||
parts = append(parts, line.String())
|
||||
// Prefer content_v2 blocks; fallback to content blocks
|
||||
blocks := selectContentBlocks(body)
|
||||
for _, para := range blocks {
|
||||
elems, _ := para.([]interface{})
|
||||
var line strings.Builder
|
||||
for _, el := range elems {
|
||||
elem, _ := el.(map[string]interface{})
|
||||
line.WriteString(renderPostElem(elem))
|
||||
}
|
||||
parts = append(parts, line.String())
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(strings.Join(parts, "\n"))
|
||||
@@ -58,6 +58,17 @@ func (postConverter) Convert(ctx *ConvertContext) string {
|
||||
return ResolveMentionKeys(result, ctx.MentionMap)
|
||||
}
|
||||
|
||||
// selectContentBlocks returns content_v2 blocks when present and non-empty;
|
||||
// otherwise falls back to content blocks. This implements the content_v2
|
||||
// priority rule for post messages.
|
||||
func selectContentBlocks(body map[string]interface{}) []interface{} {
|
||||
if v2, ok := body["content_v2"].([]interface{}); ok && len(v2) > 0 {
|
||||
return v2
|
||||
}
|
||||
blocks, _ := body["content"].([]interface{})
|
||||
return blocks
|
||||
}
|
||||
|
||||
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
|
||||
if _, ok := parsed["content"]; ok {
|
||||
return parsed
|
||||
@@ -114,10 +125,14 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
var rendered string
|
||||
switch {
|
||||
case userId == "@_all" || userId == "all":
|
||||
rendered = "@all"
|
||||
rendered = `<at user_id="all"></at>`
|
||||
default:
|
||||
if name, _ := el["user_name"].(string); name != "" {
|
||||
rendered = "@" + name
|
||||
if userId != "" && strings.HasPrefix(userId, "ou") {
|
||||
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
|
||||
} else {
|
||||
rendered = "@" + name
|
||||
}
|
||||
} else {
|
||||
rendered = "@" + userId
|
||||
}
|
||||
@@ -138,7 +153,7 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
case "img":
|
||||
key, _ := el["image_key"].(string)
|
||||
if key != "" {
|
||||
return fmt.Sprintf("[Image: %s]", key)
|
||||
return fmt.Sprintf("", key)
|
||||
}
|
||||
return "[Image]"
|
||||
case "media":
|
||||
|
||||
@@ -93,9 +93,13 @@ func TestRenderPostElem(t *testing.T) {
|
||||
}{
|
||||
{name: "text", el: map[string]interface{}{"tag": "text", "text": "hello"}, want: "hello"},
|
||||
{name: "link", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com"}, want: "[doc](https://example.com)"},
|
||||
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: "@all"},
|
||||
{name: "mention user", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
|
||||
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "[Image: img_123]"},
|
||||
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: `<at user_id="all"></at>`},
|
||||
{name: "mention user with id", el: map[string]interface{}{"tag": "at", "user_id": "ou_user_1", "user_name": "Alice"}, want: `<at user_id="ou_user_1">Alice</at>`},
|
||||
{name: "mention user name only", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
|
||||
{name: "mention user id only", el: map[string]interface{}{"tag": "at", "user_id": "@_user_1"}, want: "@@_user_1"},
|
||||
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: ""},
|
||||
{name: "image no key", el: map[string]interface{}{"tag": "img"}, want: "[Image]"},
|
||||
{name: "md text", el: map[string]interface{}{"tag": "md", "text": "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}, want: "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"},
|
||||
{name: "media", el: map[string]interface{}{"tag": "media", "file_key": "file_123"}, want: "[Media: file_123]"},
|
||||
{name: "code block", el: map[string]interface{}{"tag": "code_block", "language": "go", "text": "fmt.Println(1)"}, want: "\n```go\nfmt.Println(1)\n```\n"},
|
||||
{name: "hr", el: map[string]interface{}{"tag": "hr"}, want: "\n---\n"},
|
||||
@@ -144,3 +148,87 @@ func TestRenderPostElemEmotionStyleMd(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectContentBlocks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]interface{}
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "content_v2 present and non-empty",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": []interface{}{[]interface{}{map[string]interface{}{"tag": "md", "text": "new"}}},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 empty array",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": []interface{}{},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 nil",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 wrong type",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": "not_an_array",
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "both missing",
|
||||
body: map[string]interface{}{},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := selectContentBlocks(tt.body)
|
||||
if len(got) != tt.want {
|
||||
t.Fatalf("selectContentBlocks() len = %d, want %d", len(got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostConverterConvertContentV2(t *testing.T) {
|
||||
// AC-M1-H1: content_v2 present → use content_v2 blocks (md passthrough)
|
||||
ctx := &ConvertContext{
|
||||
RawContent: `{"content_v2":[[{"tag":"md","text":"##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}]],"content":[[{"tag":"text","text":"old path"}]]}`,
|
||||
}
|
||||
want := "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"
|
||||
if got := (postConverter{}).Convert(ctx); got != want {
|
||||
t.Fatalf("postConverter.Convert(content_v2) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// AC-M1-H2: no content_v2 → use content blocks with new at/img format
|
||||
ctx2 := &ConvertContext{
|
||||
RawContent: `{"content":[[{"tag":"at","user_id":"ou_xxx","user_name":"Bob"},{"tag":"text","text":" "},{"tag":"img","image_key":"img_123"}]]}`,
|
||||
Mentions: []interface{}{map[string]interface{}{"key": "ou_xxx", "id": "ou_bob", "name": "Bob"}},
|
||||
}
|
||||
want2 := `<at user_id="ou_xxx">Bob</at> `
|
||||
if got := (postConverter{}).Convert(ctx2); got != want2 {
|
||||
t.Fatalf("postConverter.Convert(content) = %q, want %q", got, want2)
|
||||
}
|
||||
|
||||
// AC-M1-E1: content_v2 empty → fallback to content
|
||||
ctx3 := &ConvertContext{
|
||||
RawContent: `{"content_v2":[],"content":[[{"tag":"text","text":"fallback path"}]]}`,
|
||||
}
|
||||
want3 := "fallback path"
|
||||
if got := (postConverter{}).Convert(ctx3); got != want3 {
|
||||
t.Fatalf("postConverter.Convert(empty content_v2) = %q, want %q", got, want3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
|
||||
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as ``; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
|
||||
|
||||
Two ways to get the binaries:
|
||||
- **In one pass:** add `--download-resources` to this command — every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) is downloaded into `./lark-im-resources/` and a `resources` block (`{message_id, key, type, local_path, size_bytes}`) is attached to each message. See [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in).
|
||||
@@ -61,7 +61,7 @@ Two ways to get the binaries:
|
||||
|
||||
| Resource Type | Marker in Content | Behavior |
|
||||
|---------|-------------|------|
|
||||
| Image | `[Image: img_xxx]` | `--download-resources`, or manually `im +messages-resources-download --type image` |
|
||||
| Image | `` | `--download-resources`, or manually `im +messages-resources-download --type image` |
|
||||
| File | `<file key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
|
||||
| Audio | `<audio key="file_xxx" duration="Xs"/>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
|
||||
| Video | `<video key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
|
||||
|
||||
@@ -32,7 +32,7 @@ When enabled:
|
||||
- Output paths are confined to `./lark-im-resources/` by the same guards as [`+messages-resources-download`](lark-im-messages-resources-download.md) (abnormal `file_key` with path separators / `..` / absolute paths is rejected).
|
||||
- **Scope**: the download uses `GET /open-apis/im/v1/messages/:message_id/resources/:file_key`, which requires `im:message:readonly` — already declared in each listing command's `Scopes`, so `--download-resources` needs **no extra scope** beyond what's required to read the messages (user identity also needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user`; bot identity needs `im:message.group_msg` / `im:message.p2p_msg:readonly`, all already declared). Works under both user and bot identity. If a bot was registered before `im:message:readonly` was granted, a single resource will fail-silently (`error: true` + stderr warning) rather than aborting the pull.
|
||||
|
||||
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `[Image: img_xxx]`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
|
||||
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. ``, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
|
||||
|
||||
## Scope requirement
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ lark-cli im +messages-mget --message-ids "om_aaa,om_bbb"
|
||||
|
||||
1. **Use JSON for full content:** table output truncates content. Use `--format json` when the full body matters.
|
||||
2. **Sender names are already enriched:** the command resolves sender names automatically, so no extra lookup is required.
|
||||
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `[Image: img_xxx]`. Use `+messages-resources-download` when you need the binary resource.
|
||||
3. **Images are rendered as placeholders:** image messages appear as placeholders such as ``. Use `+messages-resources-download` when you need the binary resource.
|
||||
4. **Batching is more efficient:** fetching multiple IDs in one request is better than calling the API repeatedly.
|
||||
|
||||
## References
|
||||
|
||||
@@ -152,7 +152,7 @@ lark-cli im +threads-messages-list --thread <thread_id>
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `[Image: img_xxx]`; resource binaries are **not** downloaded automatically.
|
||||
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as ``; resource binaries are **not** downloaded automatically.
|
||||
|
||||
Use `im +messages-resources-download` if you need to fetch the underlying image or file bytes from a specific message.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; by default resource binaries are **not** downloaded.
|
||||
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as ``; by default resource binaries are **not** downloaded.
|
||||
|
||||
Pass `--download-resources` to download every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` in one pass and attach a `resources` block to each reply (see [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in)). Otherwise download individual resources manually through `im +messages-resources-download` (see [lark-im-messages-resources-download](lark-im-messages-resources-download.md)).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user