feat: complete card message format (#1198)

The card message converter (shortcuts/im/convert_lib/card.go) previously
rendered a subset of card fields and had several mode-gated behaviors that
caused information to be silently dropped in concise mode. This PR audits
every element handler and brings the output up to full fidelity:
missing header fields are rendered, collapsible panels always expand, rich
element metadata (images, audio, video, overflow URLs, person names) is no
longer hidden behind cardModeDetailed, and several format bugs are fixed.

Change-Id: I422474ab6b7505e48ab5697793900df035be6e29
This commit is contained in:
91-enjoy
2026-06-03 19:17:12 +08:00
committed by GitHub
parent 7e7f716a82
commit 2f35ce3724
2 changed files with 1314 additions and 140 deletions

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
@@ -196,8 +197,12 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
header, _ := card["header"].(cardObj)
title := ""
subtitle := ""
headerTags := ""
if header != nil {
title = c.extractHeaderTitle(header)
subtitle = c.extractHeaderSubtitle(header)
headerTags = c.extractHeaderTags(header)
}
bodyContent := ""
@@ -206,13 +211,19 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
}
var sb strings.Builder
if title != "" {
sb.WriteString("<card title=\"")
sb.WriteString(cardEscapeAttr(title))
sb.WriteString("\">\n")
if title != "" && subtitle != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\" subtitle=\"%s\">\n", cardEscapeAttr(title), cardEscapeAttr(subtitle)))
} else if title != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\">\n", cardEscapeAttr(title)))
} else if subtitle != "" {
sb.WriteString(fmt.Sprintf("<card subtitle=\"%s\">\n", cardEscapeAttr(subtitle)))
} else {
sb.WriteString("<card>\n")
}
if headerTags != "" {
sb.WriteString(headerTags)
sb.WriteString("\n")
}
if bodyContent != "" {
sb.WriteString(bodyContent)
sb.WriteString("\n")
@@ -233,6 +244,49 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string {
return ""
}
// extractHeaderSubtitle returns the subtitle text of a card header, supporting both
// the property-wrapped and flat element formats.
func (c *cardConverter) extractHeaderSubtitle(header cardObj) string {
if prop, ok := header["property"].(cardObj); ok {
if subtitleElem, ok := prop["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
}
if subtitleElem, ok := header["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
return ""
}
// extractHeaderTags returns a space-joined string of header tag labels from textTagList,
// supporting both property-wrapped and flat header formats.
func (c *cardConverter) extractHeaderTags(header cardObj) string {
var prop cardObj
if p, ok := header["property"].(cardObj); ok {
prop = p
} else {
prop = header
}
tagList, ok := prop["textTagList"].([]interface{})
if !ok || len(tagList) == 0 {
return ""
}
var tags []string
for _, tag := range tagList {
tm, ok := tag.(cardObj)
if !ok {
continue
}
if text := c.convertElement(tm, 0); text != "" {
tags = append(tags, text)
}
}
if len(tags) == 0 {
return ""
}
return strings.Join(tags, " ")
}
func (c *cardConverter) convertBody(body cardObj) string {
var elements []interface{}
@@ -479,8 +533,11 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string {
if textElem, ok := prop["text"].(cardObj); ok {
if text := c.convertElement(textElem, 0); text != "" {
if textSize, _ := textElem["text_size"].(string); textSize == "notation" {
text = "📝 " + text
textProp := c.extractProperty(textElem)
if textStyle, ok := textProp["textStyle"].(cardObj); ok {
if size, _ := textStyle["size"].(string); size == "notation" {
text = "📝 " + text
}
}
results = append(results, text)
}
@@ -558,7 +615,14 @@ func (c *cardConverter) convertEmoji(prop cardObj) string {
}
func (c *cardConverter) convertLocalDatetime(prop cardObj) string {
if ms, ok := prop["milliseconds"].(string); ok && ms != "" {
var ms string
switch v := prop["milliseconds"].(type) {
case string:
ms = v
case float64:
ms = strconv.FormatInt(int64(v), 10)
}
if ms != "" {
if formatted := cardFormatMillisToISO8601(ms); formatted != "" {
return formatted
}
@@ -789,22 +853,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string {
}
}
shouldExpand := expanded || c.mode == cardModeDetailed
if shouldExpand {
var sb strings.Builder
sb.WriteString("▼ " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
}
indicator := "▶"
if expanded {
indicator = "▼"
}
var sb strings.Builder
sb.WriteString(indicator + " " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
}
}
sb.WriteString("▲")
return sb.String()
}
return "▶ " + title
sb.WriteString("▲")
return sb.String()
}
func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string {
@@ -852,27 +916,7 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
}
disabled, _ := prop["disabled"].(bool)
if disabled && c.mode == cardModeConcise {
return fmt.Sprintf("[%s ✗]", buttonText)
}
if actions, ok := prop["actions"].([]interface{}); ok {
for _, action := range actions {
am, ok := action.(cardObj)
if !ok {
continue
}
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
}
}
}
}
}
if disabled && c.mode == cardModeDetailed {
if disabled {
result := fmt.Sprintf("[%s ✗]", buttonText)
if tips, ok := prop["disabledTips"].(cardObj); ok {
if tipsText := c.extractTextContent(tips); tipsText != "" {
@@ -882,7 +926,42 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
return result
}
return fmt.Sprintf("[%s]", buttonText)
result := fmt.Sprintf("[%s]", buttonText)
if actions, ok := prop["actions"].([]interface{}); ok {
for _, action := range actions {
am, ok := action.(cardObj)
if !ok {
continue
}
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
break
}
}
}
}
}
if confirmObj, ok := prop["confirm"].(cardObj); ok {
var parts []string
if titleElem, ok := confirmObj["title"]; ok {
if t := c.extractTextContent(titleElem); t != "" {
parts = append(parts, t)
}
}
if textElem, ok := confirmObj["text"]; ok {
if t := c.extractTextContent(textElem); t != "" {
parts = append(parts, t)
}
}
if len(parts) > 0 {
result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": "))
}
}
return result
}
func (c *cardConverter) convertActions(prop cardObj) string {
@@ -914,11 +993,33 @@ func (c *cardConverter) convertOverflow(prop cardObj) string {
if !ok {
continue
}
text := ""
if textElem, ok := om["text"].(cardObj); ok {
if text := c.extractTextContent(textElem); text != "" {
optTexts = append(optTexts, text)
text = c.extractTextContent(textElem)
}
if text == "" {
continue
}
urlStr := ""
if actions, ok := om["actions"].([]interface{}); ok {
for _, a := range actions {
am, ok := a.(cardObj)
if !ok {
continue
}
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
urlStr, _ = ad["url"].(string)
}
}
}
}
if urlStr != "" {
text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr)
} else if value, _ := om["value"].(string); value != "" {
text += "(" + value + ")"
}
optTexts = append(optTexts, text)
}
return "⋮ " + strings.Join(optTexts, ", ")
}
@@ -958,17 +1059,20 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
if !ok {
continue
}
value, _ := om["value"].(string)
optText := ""
if textElem, ok := om["text"].(cardObj); ok {
optText = c.extractTextContent(textElem)
}
if optText == "" {
optText, _ = om["value"].(string)
optText = c.lookupOptionUserName(value)
}
if optText == "" {
optText = value
}
if optText == "" {
continue
}
value, _ := om["value"].(string)
if selectedValues[value] {
optText = "✓" + optText
hasSelected = true
@@ -989,17 +1093,15 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
}
result := "{" + strings.Join(optionTexts, " / ") + "}"
if c.mode == cardModeDetailed {
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
}
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if c.mode == cardModeDetailed && strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
}
return result
}
@@ -1025,6 +1127,17 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string {
}
value, _ := om["value"].(string)
text := fmt.Sprintf("🖼️ Image %d", i+1)
if value != "" {
text += "(" + value + ")"
}
if imageID, ok := om["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
text += "(img_key:" + originKey + ")"
} else if imgToken != "" {
text += "(img_token:" + imgToken + ")"
}
}
if selectedValues[value] {
text = "✓" + text
}
@@ -1127,13 +1240,14 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string {
}
result := "🖼️ " + alt
if c.mode == cardModeDetailed {
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
if token := c.getImageToken(imageID); token != "" {
result += "(img_token:" + token + ")"
} else {
result += "(img_key:" + imageID + ")"
}
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
result += "(img_key:" + originKey + ")"
} else if imgToken != "" {
result += "(img_token:" + imgToken + ")"
} else {
result += "(img_key:" + imageID + ")"
}
}
return result
@@ -1145,20 +1259,25 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string {
return ""
}
result := fmt.Sprintf("🖼️ %d image(s)", len(imgList))
if c.mode == cardModeDetailed {
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
keys = append(keys, originKey)
} else if imgToken != "" {
keys = append(keys, imgToken)
} else {
keys = append(keys, imageID)
}
}
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
}
}
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
}
return result
}
@@ -1176,7 +1295,11 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string {
if ct, ok := chartSpec["type"].(string); ok && ct != "" {
chartType = ct
if typeName, ok := cardChartTypeNames[ct]; ok {
title += typeName
if title != "Chart" {
title += " (" + typeName + ")"
} else {
title = typeName
}
}
}
}
@@ -1194,12 +1317,25 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
if !ok {
return ""
}
dataObj, ok := chartSpec["data"].(cardObj)
if !ok {
return ""
// VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]).
// Older/object format: data is a map with a "values" key directly.
var values []interface{}
switch d := chartSpec["data"].(type) {
case cardObj:
if v, ok := d["values"].([]interface{}); ok {
values = v
}
case []interface{}:
for _, series := range d {
if sm, ok := series.(cardObj); ok {
if v, ok := sm["values"].([]interface{}); ok {
values = append(values, v...)
}
}
}
}
values, ok := dataObj["values"].([]interface{})
if !ok || len(values) == 0 {
if len(values) == 0 {
return ""
}
@@ -1244,28 +1380,24 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
func (c *cardConverter) convertAudio(prop cardObj, _ string) string {
result := "🎵 Audio"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
return result
}
func (c *cardConverter) convertVideo(prop cardObj, _ string) string {
result := "🎬 Video"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
return result
}
@@ -1323,9 +1455,14 @@ func (c *cardConverter) convertTable(prop cardObj) string {
func (c *cardConverter) extractTableCellValue(data interface{}) string {
switch v := data.(type) {
case string:
// Lark API serialises array-type cell data as a Go-format string like
// "[map[text:VIP] map[text:Premium]]". Detect and extract text values.
if texts := goMapArrayTexts(v); len(texts) > 0 {
return strings.Join(texts, ", ")
}
return v
case float64:
return strconv.FormatFloat(v, 'f', 2, 64)
return strconv.FormatFloat(v, 'f', -1, 64)
case []interface{}:
var texts []string
for _, item := range v {
@@ -1346,6 +1483,47 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string {
}
}
// goMapNextKey matches the start of the next key in a Go fmt map literal (space + identifier + colon).
var goMapNextKey = regexp.MustCompile(` [a-zA-Z_][a-zA-Z0-9_]*:`)
// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string,
// e.g. "[map[text:VIP] map[text:Premium]]" → ["VIP", "Premium"].
// Values may contain spaces; they are delimited by the next map key or by "]".
// Returns nil if the string doesn't look like this format.
func goMapArrayTexts(s string) []string {
if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") {
return nil
}
const key = "text:"
var texts []string
rest := s
for {
idx := strings.Index(rest, key)
if idx < 0 {
break
}
after := rest[idx+len(key):]
bracketEnd := strings.Index(after, "]")
nextKey := goMapNextKey.FindStringIndex(after)
var end int
if nextKey != nil && (bracketEnd < 0 || nextKey[0] < bracketEnd) {
end = nextKey[0]
} else if bracketEnd >= 0 {
end = bracketEnd
} else {
if after != "" {
texts = append(texts, after)
}
break
}
if val := after[:end]; val != "" {
texts = append(texts, val)
}
rest = after[end:]
}
return texts
}
func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
if userID == "" {
@@ -1359,14 +1537,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
}
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
}
return "@" + personName
return personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("@user(open_id:%s)", userID)
return fmt.Sprintf("user(open_id:%s)", userID)
}
return "@" + userID
return userID
}
// convertPersonV1 handles the v1 card schema person element.
@@ -1382,14 +1560,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string {
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
}
return "@" + personName
return personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("@user(open_id:%s)", userID)
return fmt.Sprintf("user(open_id:%s)", userID)
}
return "@" + userID
return userID
}
func (c *cardConverter) convertPersonList(prop cardObj) string {
@@ -1404,10 +1582,21 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
continue
}
personID, _ := pm["id"].(string)
if c.mode == cardModeDetailed && personID != "" {
names = append(names, fmt.Sprintf("@user(id:%s)", personID))
personName := c.lookupPersonName(personID)
if personName != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID))
} else {
names = append(names, personName)
}
} else if personID != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("user(id:%s)", personID))
} else {
names = append(names, personID)
}
} else {
names = append(names, "@user")
names = append(names, "user")
}
}
return strings.Join(names, ", ")
@@ -1415,8 +1604,15 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
func (c *cardConverter) convertAvatar(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("👤 %s(open_id:%s)", personName, userID)
}
return "👤 " + personName
}
result := "👤"
if c.mode == cardModeDetailed && userID != "" {
if userID != "" {
result += "(id:" + userID + ")"
}
return result
@@ -1497,20 +1693,37 @@ func (c *cardConverter) lookupPersonName(userID string) string {
return ""
}
func (c *cardConverter) getImageToken(imageID string) string {
// lookupOptionUserName resolves a user display name from the attachment's option_users map,
// used for person-selector option labels.
func (c *cardConverter) lookupOptionUserName(userID string) string {
if c.attachment == nil {
return ""
}
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
if token, ok := imageInfo["token"].(string); ok {
return token
if optUsers, ok := c.attachment["option_users"].(cardObj); ok {
if userInfo, ok := optUsers[userID].(cardObj); ok {
if content, ok := userInfo["content"].(string); ok {
return content
}
}
}
return ""
}
// getImageKeyAndToken returns the origin_key and token for an image ID from the attachment map.
// origin_key takes priority over token as the display-ready image reference.
func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) {
if c.attachment == nil {
return "", ""
}
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
originKey, _ = imageInfo["origin_key"].(string)
token, _ = imageInfo["token"].(string)
}
}
return originKey, token
}
type cardTextStyle struct {
bold bool
italic bool

File diff suppressed because it is too large Load Diff