Files
larksuite-cli/shortcuts/im/convert_lib/text.go
sammi-bytedance 501bf539af feat(im): complete audio/post rendering and add opt-in --download-resources (#1245)
Block 1 — field completion: audio renders <audio key="..." duration="Xs"/>
(falls back to [Voice: Xs]/[Voice]); post renders emotion -> :emoji_type:,
applies text.style (bold/italic/underline/lineThrough), passes through md;
sticker unchanged.

Block 2 — opt-in --download-resources (default off) on +chat-messages-list,
+messages-mget, +threads-messages-list: extract downloadable resource refs
during formatting (image/file/audio/video/media + post-embedded; sticker
excluded; merge_forward sub-items carry the top-level container message_id,
since the resources endpoint rejects sub-item ids with "234003 File not in
msg" and can only fetch a forwarded resource through the container; thread
replies get their own block), then download each distinct (message_id,
file_key) once into ./lark-im-resources/ with bounded concurrency (3), filling
back local_path/size_bytes; single-resource failures are isolated (error:true +
stderr warning). Path safety reuses normalizeDownloadOutputPath +
ResolveSavePath.

Batch download keys each file on disk by its unique file_key basename and only
appends an extension (from the Content-Disposition filename or MIME type) —
it does NOT substitute the server's Content-Disposition filename. Otherwise two
resources whose servers return the same filename (e.g. download.bin) would
resolve to the same ./lark-im-resources/ path and clobber each other
concurrently. The friendly "adopt the server filename" behavior is kept only
for an explicit +messages-resources-download with no --output.

Resource ref extraction guards against self-referential / cyclic merge_forward
prefetch maps (a real API sub-item list can include the container's own id or a
back-pointing merge_forward) via a visited set, so extraction terminates instead
of overflowing the stack. The container message_id is threaded through nested
merge_forwards as the download owner.

Also: document the feature (including the im:message:readonly scope requirement)
in skills/lark-im — SKILL.md is generated from skill-template/domains/im.md
(edit the source), plus the hand-written message-enrichment + 3 command
references.

Change-Id: I3a71d7d1b193130f551aaa2ec180ac1500d59ac4
Meego: https://meego.larkoffice.com/5e96d7bff4e7c525510f9156/story/detail/7331555925
2026-06-10 20:07:49 +08:00

199 lines
4.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"fmt"
"sort"
"strings"
)
type textConverter struct{}
func (textConverter) Convert(ctx *ConvertContext) string {
parsed, err := ParseJSONObject(ctx.RawContent)
if err != nil {
return invalidJSONPlaceholder("text")
}
text, _ := parsed["text"].(string)
if text == "" {
return ctx.RawContent
}
return ResolveMentionKeys(text, ctx.MentionMap)
}
type postConverter struct{}
func (postConverter) Convert(ctx *ConvertContext) string {
parsed, err := ParseJSONObject(ctx.RawContent)
if err != nil || parsed == nil {
return invalidJSONPlaceholder("rich text")
}
body := unwrapPostLocale(parsed)
if body == nil {
return "[Rich text message]"
}
var parts []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())
}
}
result := strings.TrimSpace(strings.Join(parts, "\n"))
if result == "" {
return "[Rich text message]"
}
return ResolveMentionKeys(result, ctx.MentionMap)
}
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
if _, ok := parsed["content"]; ok {
return parsed
}
if _, ok := parsed["title"]; ok {
return parsed
}
for _, locale := range []string{"zh_cn", "en_us", "ja_jp"} {
if v, ok := parsed[locale]; ok {
if m, ok := v.(map[string]interface{}); ok {
return m
}
}
}
keys := make([]string, 0, len(parsed))
for key := range parsed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
v := parsed[key]
if m, ok := v.(map[string]interface{}); ok {
return m
}
}
return nil
}
// renderPostElem renders a single post (rich-text) element to its inline text
// form: text/a/at carry their content through applyPostStyle for text.style
// Markdown emphasis, emotion becomes :emoji_type:, md is passed through raw,
// and unknown tags fall back to the element's text.
func renderPostElem(el map[string]interface{}) string {
tag, _ := el["tag"].(string)
switch tag {
case "text":
text, _ := el["text"].(string)
return applyPostStyle(text, el["style"])
case "a":
text, _ := el["text"].(string)
href, _ := el["href"].(string)
var rendered string
switch {
case href != "" && text != "":
rendered = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), href)
case href != "":
rendered = href
default:
rendered = text
}
return applyPostStyle(rendered, el["style"])
case "at":
userId, _ := el["user_id"].(string)
var rendered string
switch {
case userId == "@_all" || userId == "all":
rendered = "@all"
default:
if name, _ := el["user_name"].(string); name != "" {
rendered = "@" + name
} else {
rendered = "@" + userId
}
}
return applyPostStyle(rendered, el["style"])
case "emotion":
// Deliberately not routed through applyPostStyle: an emoji shortcode is
// an atomic token, not prose, so bold/italic/strike emphasis around
// ":emoji:" would be meaningless (and emotion elements don't carry style).
emoji, _ := el["emoji_type"].(string)
if emoji == "" {
return ""
}
return ":" + emoji + ":"
case "md":
text, _ := el["text"].(string)
return text
case "img":
key, _ := el["image_key"].(string)
if key != "" {
return fmt.Sprintf("[Image: %s]", key)
}
return "[Image]"
case "media":
key, _ := el["file_key"].(string)
if key != "" {
return fmt.Sprintf("[Media: %s]", key)
}
return "[Media]"
case "code_block":
lang, _ := el["language"].(string)
code, _ := el["text"].(string)
if lang != "" {
return fmt.Sprintf("\n```%s\n%s\n```\n", lang, code)
}
return fmt.Sprintf("\n```\n%s\n```\n", code)
case "hr":
return "\n---\n"
default:
text, _ := el["text"].(string)
return text
}
}
// applyPostStyle wraps text with Markdown emphasis per the post element's
// style array (bold/italic/underline/lineThrough). Styles compose from inner
// to outer in a fixed order so output is deterministic; empty text or no
// styles pass through unchanged.
func applyPostStyle(text string, raw interface{}) string {
if text == "" {
return text
}
styles, _ := raw.([]interface{})
if len(styles) == 0 {
return text
}
has := func(name string) bool {
for _, s := range styles {
if v, _ := s.(string); v == name {
return true
}
}
return false
}
if has("bold") {
text = "**" + text + "**"
}
if has("italic") {
text = "*" + text + "*"
}
if has("underline") {
text = "<u>" + text + "</u>"
}
if has("lineThrough") {
text = "~~" + text + "~~"
}
return text
}