diff --git a/events/im/message_receive.go b/events/im/message_receive.go
index e19fc78f9..b3c3e90be 100644
--- a/events/im/message_receive.go
+++ b/events/im/message_receive.go
@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
- Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
+ Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
- content := msg.Content
- if msg.MessageType != "interactive" {
+ var content string
+ if msg.MessageType == "interactive" {
+ content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
+ } else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
diff --git a/shortcuts/im/convert_lib/card_userdsl.go b/shortcuts/im/convert_lib/card_userdsl.go
new file mode 100644
index 000000000..62a016807
--- /dev/null
+++ b/shortcuts/im/convert_lib/card_userdsl.go
@@ -0,0 +1,1093 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package convertlib
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var atMentionRe = regexp.MustCompile(`]*)>(?:[^<]*)?`)
+
+// Each attr regex captures the value in group 1 (quoted) or group 2 (unquoted).
+var atMentionKeyAttrRe = regexp.MustCompile(`\b(?:mention_key|mentions_key)=(?:"([^"]*)"|([^\s">/]+))`)
+var atIDAttrRe = regexp.MustCompile(`\bid=(?:"([^"]+)"|([^\s">/]+))`)
+var atIDsAttrRe = regexp.MustCompile(`\bids=(?:"([^"]+)"|([^\s">/]+))`)
+
+// attrVal returns the captured attribute value from a quoted-or-unquoted regex match.
+func attrVal(m []string) string {
+ if len(m) >= 2 && m[1] != "" {
+ return m[1]
+ }
+ if len(m) >= 3 {
+ return m[2]
+ }
+ return ""
+}
+
+// buildMentionAtMap builds a mention key → "@name(ou_xxx)" lookup from the message mentions array.
+func buildMentionAtMap(mentions []interface{}) map[string]string {
+ if len(mentions) == 0 {
+ return nil
+ }
+ m := make(map[string]string, len(mentions))
+ for _, raw := range mentions {
+ item, _ := raw.(map[string]interface{})
+ key, _ := item["key"].(string)
+ name, _ := item["name"].(string)
+ openID := extractMentionOpenId(item["id"])
+ if key == "" {
+ continue
+ }
+ if name != "" && openID != "" {
+ m[key] = fmt.Sprintf("@%s(%s)", name, openID)
+ } else if name != "" {
+ m[key] = "@" + name
+ } else if openID != "" {
+ m[key] = "@" + openID
+ }
+ }
+ return m
+}
+
+// resolveAtMentions replaces tags in markdown content with resolved mention strings.
+//
+// Single mention: → "@name(ou_x)" or "@ou_x" fallback.
+// Multi mention: → each pair resolved and
+//
+// concatenated: "@name1(id1)@id2@name3(id3)".
+func resolveAtMentions(content string, mentionAt map[string]string) string {
+ if len(mentionAt) == 0 {
+ return content
+ }
+ return atMentionRe.ReplaceAllStringFunc(content, func(match string) string {
+ attrs := atMentionRe.FindStringSubmatch(match)
+ if len(attrs) < 2 {
+ return match
+ }
+ attrStr := attrs[1]
+
+ // Multi-mention: ids="id1,id2,id3" or ids=id1,id2,id3 with mentions_key
+ if idsMatch := atIDsAttrRe.FindStringSubmatch(attrStr); attrVal(idsMatch) != "" {
+ ids := strings.Split(attrVal(idsMatch), ",")
+ var keys []string
+ if mk := atMentionKeyAttrRe.FindStringSubmatch(attrStr); attrVal(mk) != "" {
+ keys = strings.Split(attrVal(mk), ",")
+ }
+ var sb strings.Builder
+ for i, id := range ids {
+ key := ""
+ if i < len(keys) {
+ key = keys[i]
+ }
+ if key != "" {
+ if resolved, ok := mentionAt[key]; ok {
+ sb.WriteString(resolved)
+ continue
+ }
+ }
+ if id != "" {
+ sb.WriteString("@" + id)
+ }
+ }
+ return sb.String()
+ }
+
+ // Single mention
+ key := attrVal(atMentionKeyAttrRe.FindStringSubmatch(attrStr))
+ if key != "" {
+ if resolved, ok := mentionAt[key]; ok {
+ return resolved
+ }
+ }
+ if id := attrVal(atIDAttrRe.FindStringSubmatch(attrStr)); id != "" {
+ return "@" + id
+ }
+ return match
+ })
+}
+
+// ConvertInteractiveEventContent extracts user_dsl from an interactive event message.content
+// and converts it to human-readable text with the same format as cardConverter.
+// mentions is the message-level mentions array used to resolve @at references in markdown content.
+func ConvertInteractiveEventContent(rawContent string, mentions []interface{}) string {
+ var content cardObj
+ if err := json.Unmarshal([]byte(rawContent), &content); err != nil {
+ return "[interactive card]"
+ }
+ userDslStr, ok := content["user_dsl"].(string)
+ if !ok || userDslStr == "" {
+ return "[interactive card]"
+ }
+ return convertUserDslCard(userDslStr, buildMentionAtMap(mentions))
+}
+
+func convertUserDslCard(raw string, mentionAt map[string]string) string {
+ var parsed cardObj
+ if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
+ return "[interactive card]"
+ }
+ c := &userDslConverter{mentionAt: mentionAt}
+ result := c.convert(parsed)
+ if result == "" {
+ return "[interactive card]"
+ }
+ return result
+}
+
+type userDslConverter struct {
+ mentionAt map[string]string
+}
+
+func (c *userDslConverter) convert(parsed cardObj) string {
+ var header cardObj
+ var elements []interface{}
+
+ if _, hasSchema := parsed["schema"]; hasSchema {
+ // user-2.ts format: header at root, body.elements
+ header, _ = parsed["header"].(cardObj)
+ if body, ok := parsed["body"].(cardObj); ok {
+ elements, _ = body["elements"].([]interface{})
+ }
+ } else {
+ // user-1.ts format: i18n_header.zh_cn or direct header, elements at root
+ if i18nHeader, ok := parsed["i18n_header"].(cardObj); ok {
+ header, _ = i18nHeader["zh_cn"].(cardObj)
+ } else if h, ok := parsed["header"].(cardObj); ok {
+ header = h
+ }
+ elements, _ = parsed["elements"].([]interface{})
+ }
+
+ title := c.extractHeaderTitle(header)
+ subtitle := c.extractHeaderSubtitle(header)
+
+ var sb strings.Builder
+ if title != "" && subtitle != "" {
+ sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(title), cardEscapeAttr(subtitle)))
+ } else if title != "" {
+ sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(title)))
+ } else if subtitle != "" {
+ sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(subtitle)))
+ } else {
+ sb.WriteString("\n")
+ }
+
+ if len(elements) > 0 {
+ body := c.convertElements(elements, 0)
+ if body != "" {
+ sb.WriteString(body)
+ sb.WriteString("\n")
+ }
+ }
+ sb.WriteString("")
+ result := sb.String()
+ if result == "\n" {
+ return "[interactive card]"
+ }
+ return result
+}
+
+func (c *userDslConverter) extractHeaderTitle(header cardObj) string {
+ if header == nil {
+ return ""
+ }
+ if titleElem, ok := header["title"].(cardObj); ok {
+ return c.extractTextContent(titleElem)
+ }
+ return ""
+}
+
+func (c *userDslConverter) extractHeaderSubtitle(header cardObj) string {
+ if header == nil {
+ return ""
+ }
+ if subtitleElem, ok := header["subtitle"].(cardObj); ok {
+ return c.extractTextContent(subtitleElem)
+ }
+ return ""
+}
+
+func (c *userDslConverter) extractTextContent(elem cardObj) string {
+ if elem == nil {
+ return ""
+ }
+ var content string
+ if s, ok := elem["content"].(string); ok {
+ content = s
+ } else if i18n, ok := elem["i18n"].(cardObj); ok {
+ for _, lang := range []string{"zh_cn", "en_us", "ja_jp"} {
+ if t, ok := i18n[lang].(string); ok && t != "" {
+ content = t
+ break
+ }
+ }
+ }
+ if tag, _ := elem["tag"].(string); tag == "lark_md" {
+ return resolveAtMentions(content, c.mentionAt)
+ }
+ return content
+}
+
+func (c *userDslConverter) convertElements(elements []interface{}, depth int) string {
+ var results []string
+ for _, el := range elements {
+ elem, ok := el.(cardObj)
+ if !ok {
+ continue
+ }
+ if result := c.convertElement(elem, depth); result != "" {
+ results = append(results, result)
+ }
+ }
+ return strings.Join(results, "\n")
+}
+
+func (c *userDslConverter) convertElement(elem cardObj, depth int) string {
+ tag, _ := elem["tag"].(string)
+ switch tag {
+ case "plain_text", "lark_md":
+ return c.extractTextContent(elem)
+ case "markdown":
+ content, _ := elem["content"].(string)
+ return resolveAtMentions(content, c.mentionAt)
+ case "div":
+ return c.convertDiv(elem)
+ case "note":
+ return c.convertNote(elem)
+ case "hr":
+ return "---"
+ case "br":
+ return "\n"
+ case "img", "image":
+ return c.convertImage(elem)
+ case "img_combination":
+ return c.convertImgCombination(elem)
+ case "column_set":
+ return c.convertColumnSet(elem, depth)
+ case "column":
+ return c.convertColumn(elem, depth)
+ case "button":
+ return c.convertButton(elem)
+ case "actions", "action":
+ return c.convertActions(elem)
+ case "overflow":
+ return c.convertOverflow(elem)
+ case "select_static", "select_person":
+ return c.convertSelect(elem, false)
+ case "multi_select_static", "multi_select_person":
+ return c.convertSelect(elem, true)
+ case "input":
+ return c.convertInput(elem)
+ case "date_picker", "picker_date":
+ return c.convertDatePicker(elem, "date")
+ case "picker_time":
+ return c.convertDatePicker(elem, "time")
+ case "picker_datetime":
+ return c.convertDatePicker(elem, "datetime")
+ case "checker":
+ return c.convertChecker(elem)
+ case "table":
+ return c.convertTable(elem)
+ case "chart":
+ return c.convertChart(elem)
+ case "form":
+ return c.convertForm(elem)
+ case "collapsible_panel":
+ return c.convertCollapsiblePanel(elem)
+ case "interactive_container":
+ return c.convertInteractiveContainer(elem)
+ case "person":
+ return c.convertPerson(elem)
+ case "person_list":
+ return c.convertPersonList(elem)
+ case "text_tag":
+ return c.convertTextTag(elem)
+ case "avatar":
+ return c.convertAvatar(elem)
+ case "select_img":
+ return c.convertSelectImg(elem)
+ case "repeat":
+ return c.convertRepeat(elem)
+ case "audio":
+ return c.convertAudio(elem)
+ case "video":
+ return c.convertVideo(elem)
+ case "custom_icon", "standard_icon":
+ return ""
+ case "fallback_text":
+ if textElem, ok := elem["text"].(cardObj); ok {
+ return c.extractTextContent(textElem)
+ }
+ return ""
+ default:
+ if content, ok := elem["content"].(string); ok && content != "" {
+ return content
+ }
+ if textElem, ok := elem["text"].(cardObj); ok {
+ return c.extractTextContent(textElem)
+ }
+ if elems, ok := elem["elements"].([]interface{}); ok && len(elems) > 0 {
+ return c.convertElements(elems, depth)
+ }
+ return ""
+ }
+}
+
+func (c *userDslConverter) convertDiv(elem cardObj) string {
+ var results []string
+ if textElem, ok := elem["text"].(cardObj); ok {
+ if text := c.convertElement(textElem, 0); text != "" {
+ if size, _ := textElem["text_size"].(string); size == "notation" {
+ text = "📝 " + text
+ }
+ results = append(results, text)
+ }
+ }
+ if fields, ok := elem["fields"].([]interface{}); ok {
+ var fieldTexts []string
+ for _, field := range fields {
+ fm, ok := field.(cardObj)
+ if !ok {
+ continue
+ }
+ if te, ok := fm["text"].(cardObj); ok {
+ if ft := c.extractTextContent(te); ft != "" {
+ fieldTexts = append(fieldTexts, ft)
+ }
+ }
+ }
+ if len(fieldTexts) > 0 {
+ results = append(results, strings.Join(fieldTexts, "\n"))
+ }
+ }
+ if extraElem, ok := elem["extra"].(cardObj); ok {
+ if extra := c.convertElement(extraElem, 0); extra != "" {
+ results = append(results, extra)
+ }
+ }
+ return strings.Join(results, "\n")
+}
+
+func (c *userDslConverter) convertNote(elem cardObj) string {
+ elements, _ := elem["elements"].([]interface{})
+ if len(elements) == 0 {
+ return ""
+ }
+ var texts []string
+ for _, el := range elements {
+ e, ok := el.(cardObj)
+ if !ok {
+ continue
+ }
+ if text := c.convertElement(e, 0); text != "" {
+ texts = append(texts, text)
+ }
+ }
+ if len(texts) == 0 {
+ return ""
+ }
+ return "📝 " + strings.Join(texts, " ")
+}
+
+func (c *userDslConverter) convertImage(elem cardObj) string {
+ alt := "Image"
+ if altElem, ok := elem["alt"].(cardObj); ok {
+ if altText := c.extractTextContent(altElem); altText != "" {
+ alt = altText
+ }
+ }
+ if titleElem, ok := elem["title"].(cardObj); ok {
+ if titleText := c.extractTextContent(titleElem); titleText != "" {
+ alt = titleText
+ }
+ }
+ result := "🖼️ " + alt
+ if imgKey, ok := elem["img_key"].(string); ok && imgKey != "" {
+ result += "(img_key:" + imgKey + ")"
+ }
+ return result
+}
+
+func (c *userDslConverter) convertImgCombination(elem cardObj) string {
+ imgList, _ := elem["img_list"].([]interface{})
+ if len(imgList) == 0 {
+ return ""
+ }
+ result := fmt.Sprintf("🖼️ %d image(s)", len(imgList))
+ var keys []string
+ for _, img := range imgList {
+ im, ok := img.(cardObj)
+ if !ok {
+ continue
+ }
+ if imgKey, ok := im["img_key"].(string); ok && imgKey != "" {
+ keys = append(keys, imgKey)
+ }
+ }
+ if len(keys) > 0 {
+ result += "(keys:" + strings.Join(keys, ",") + ")"
+ }
+ return result
+}
+
+func (c *userDslConverter) convertColumnSet(elem cardObj, depth int) string {
+ columns, _ := elem["columns"].([]interface{})
+ if len(columns) == 0 {
+ return ""
+ }
+ var results []string
+ for _, col := range columns {
+ colElem, ok := col.(cardObj)
+ if !ok {
+ continue
+ }
+ if result := c.convertElement(colElem, depth+1); result != "" {
+ results = append(results, result)
+ }
+ }
+ sep := "\n\n"
+ if allColumnsAreButtons(results) {
+ sep = " "
+ }
+ return strings.Join(results, sep)
+}
+
+func (c *userDslConverter) convertColumn(elem cardObj, depth int) string {
+ elements, _ := elem["elements"].([]interface{})
+ if len(elements) == 0 {
+ return ""
+ }
+ return c.convertElements(elements, depth)
+}
+
+func (c *userDslConverter) extractButtonURL(elem cardObj) string {
+ if urlStr, ok := elem["url"].(string); ok && urlStr != "" {
+ return urlStr
+ }
+ if multiURL, ok := elem["multi_url"].(cardObj); ok {
+ if urlStr, ok := multiURL["url"].(string); ok && urlStr != "" {
+ return urlStr
+ }
+ }
+ if behaviors, ok := elem["behaviors"].([]interface{}); ok {
+ for _, b := range behaviors {
+ bm, ok := b.(cardObj)
+ if !ok {
+ continue
+ }
+ if bm["type"] == "open_url" {
+ if urlStr, ok := bm["default_url"].(string); ok && urlStr != "" {
+ return urlStr
+ }
+ }
+ }
+ }
+ return ""
+}
+
+func (c *userDslConverter) convertButton(elem cardObj) string {
+ buttonText := ""
+ if textElem, ok := elem["text"].(cardObj); ok {
+ buttonText = c.extractTextContent(textElem)
+ }
+ if buttonText == "" {
+ buttonText = "Button"
+ }
+ disabled, _ := elem["disabled"].(bool)
+ if disabled {
+ result := fmt.Sprintf("[%s ✗]", buttonText)
+ if tipsElem, ok := elem["disabled_tips"].(cardObj); ok {
+ if tipsText := c.extractTextContent(tipsElem); tipsText != "" {
+ result += fmt.Sprintf("(tips:\"%s\")", tipsText)
+ }
+ }
+ return result
+ }
+ result := fmt.Sprintf("[%s]", buttonText)
+ if urlStr := c.extractButtonURL(elem); urlStr != "" {
+ result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
+ }
+ if confirmObj, ok := elem["confirm"].(cardObj); ok {
+ var parts []string
+ if titleElem, ok := confirmObj["title"].(cardObj); ok {
+ if t := c.extractTextContent(titleElem); t != "" {
+ parts = append(parts, t)
+ }
+ }
+ if textElem, ok := confirmObj["text"].(cardObj); 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 *userDslConverter) convertActions(elem cardObj) string {
+ actions, _ := elem["actions"].([]interface{})
+ if len(actions) == 0 {
+ return ""
+ }
+ var results []string
+ for _, action := range actions {
+ ae, ok := action.(cardObj)
+ if !ok {
+ continue
+ }
+ if result := c.convertElement(ae, 0); result != "" {
+ results = append(results, result)
+ }
+ }
+ return strings.Join(results, " ")
+}
+
+func (c *userDslConverter) convertOverflow(elem cardObj) string {
+ options, _ := elem["options"].([]interface{})
+ if len(options) == 0 {
+ return ""
+ }
+ var optTexts []string
+ for _, opt := range options {
+ om, ok := opt.(cardObj)
+ if !ok {
+ continue
+ }
+ if textElem, ok := om["text"].(cardObj); ok {
+ if text := c.extractTextContent(textElem); text != "" {
+ urlStr := ""
+ if u, ok := om["url"].(string); ok && u != "" {
+ urlStr = u
+ } else if multiURL, ok := om["multi_url"].(cardObj); ok {
+ urlStr, _ = multiURL["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, ", ")
+}
+
+func (c *userDslConverter) convertSelect(elem cardObj, isMulti bool) string {
+ options, _ := elem["options"].([]interface{})
+ tag, _ := elem["tag"].(string)
+ isPerson := tag == "select_person" || tag == "multi_select_person"
+
+ selectedValues := map[string]bool{}
+ var selectedOrder []string // preserve order for synthetic person entries
+ if isMulti {
+ if vals, ok := elem["selected_values"].([]interface{}); ok {
+ for _, v := range vals {
+ if s, ok := v.(string); ok {
+ selectedValues[s] = true
+ selectedOrder = append(selectedOrder, s)
+ }
+ }
+ }
+ } else {
+ if init, ok := elem["initial_option"].(string); ok {
+ selectedValues[init] = true
+ selectedOrder = append(selectedOrder, init)
+ }
+ if idx, ok := elem["initial_index"].(float64); ok {
+ i := int(idx)
+ if i >= 0 && i < len(options) {
+ if opt, ok := options[i].(cardObj); ok {
+ if val, ok := opt["value"].(string); ok {
+ selectedValues[val] = true
+ }
+ }
+ }
+ }
+ }
+
+ var optionTexts []string
+ hasSelected := false
+ for _, opt := range options {
+ om, ok := opt.(cardObj)
+ if !ok {
+ continue
+ }
+ value, _ := om["value"].(string)
+ optText := ""
+ if textElem, ok := om["text"].(cardObj); ok {
+ optText = c.extractTextContent(textElem)
+ }
+ if optText == "" {
+ optText = value
+ }
+ if optText == "" {
+ continue
+ }
+ if selectedValues[value] {
+ optText = "✓" + optText
+ hasSelected = true
+ }
+ optionTexts = append(optionTexts, optText)
+ }
+
+ // Person selectors have no static options in the DSL; synthesize from selected IDs.
+ if isPerson && len(options) == 0 && len(selectedOrder) > 0 {
+ for _, id := range selectedOrder {
+ optionTexts = append(optionTexts, "✓"+id)
+ }
+ hasSelected = true
+ }
+
+ if len(optionTexts) == 0 {
+ placeholder := "Please select"
+ if phElem, ok := elem["placeholder"].(cardObj); ok {
+ if ph := c.extractTextContent(phElem); ph != "" {
+ placeholder = ph
+ }
+ }
+ optionTexts = append(optionTexts, placeholder+" ▼")
+ } else if !hasSelected {
+ optionTexts[len(optionTexts)-1] += " ▼"
+ }
+ result := "{" + strings.Join(optionTexts, " / ") + "}"
+ if isMulti {
+ result += "(multi)"
+ }
+ return result
+}
+
+func (c *userDslConverter) convertInput(elem cardObj) string {
+ label := ""
+ if labelElem, ok := elem["label"].(cardObj); ok {
+ label = c.extractTextContent(labelElem)
+ }
+ defaultValue, _ := elem["default_value"].(string)
+ placeholder := ""
+ if phElem, ok := elem["placeholder"].(cardObj); ok {
+ placeholder = c.extractTextContent(phElem)
+ }
+ var result string
+ switch {
+ case defaultValue != "":
+ result = defaultValue + "___"
+ case placeholder != "":
+ result = placeholder + "_____"
+ default:
+ result = "_____"
+ }
+ if label != "" {
+ result = label + ": " + result
+ }
+ if inputType, _ := elem["input_type"].(string); inputType == "multiline_text" {
+ result = strings.ReplaceAll(result, "_____", "...")
+ }
+ return result
+}
+
+func (c *userDslConverter) convertDatePicker(elem cardObj, pickerType string) string {
+ var emoji, value string
+ switch pickerType {
+ case "date":
+ emoji = "📅"
+ value, _ = elem["initial_date"].(string)
+ case "time":
+ emoji = "🕐"
+ value, _ = elem["initial_time"].(string)
+ case "datetime":
+ emoji = "📅"
+ value, _ = elem["initial_datetime"].(string)
+ default:
+ emoji = "📅"
+ }
+ if value != "" {
+ value = cardNormalizeTimeFormat(value)
+ }
+ if value == "" {
+ placeholder := "Select"
+ if phElem, ok := elem["placeholder"].(cardObj); ok {
+ if ph := c.extractTextContent(phElem); ph != "" {
+ placeholder = ph
+ }
+ }
+ value = placeholder
+ }
+ return emoji + " " + value
+}
+
+func (c *userDslConverter) convertChecker(elem cardObj) string {
+ checked, _ := elem["checked"].(bool)
+ checkMark := "[ ]"
+ if checked {
+ checkMark = "[x]"
+ }
+ text := ""
+ if textElem, ok := elem["text"].(cardObj); ok {
+ text = c.extractTextContent(textElem)
+ }
+ return checkMark + " " + text
+}
+
+func (c *userDslConverter) convertTable(elem cardObj) string {
+ columns, _ := elem["columns"].([]interface{})
+ if len(columns) == 0 {
+ return ""
+ }
+ rows, _ := elem["rows"].([]interface{})
+
+ var colNames, colKeys []string
+ for _, col := range columns {
+ cm, ok := col.(cardObj)
+ if !ok {
+ continue
+ }
+ displayName, _ := cm["display_name"].(string)
+ name, _ := cm["name"].(string)
+ if displayName == "" {
+ displayName = name
+ }
+ colNames = append(colNames, displayName)
+ colKeys = append(colKeys, name)
+ }
+
+ var lines []string
+ lines = append(lines, "| "+strings.Join(colNames, " | ")+" |")
+ separator := "|"
+ for range colNames {
+ separator += "------|"
+ }
+ lines = append(lines, separator)
+
+ for _, row := range rows {
+ rm, ok := row.(cardObj)
+ if !ok {
+ continue
+ }
+ var cells []string
+ for _, key := range colKeys {
+ cells = append(cells, c.extractUserDslTableCellValue(rm[key]))
+ }
+ lines = append(lines, "| "+strings.Join(cells, " | ")+" |")
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (c *userDslConverter) extractUserDslTableCellValue(v interface{}) string {
+ if v == nil {
+ return ""
+ }
+ switch val := v.(type) {
+ case string:
+ return val
+ case float64:
+ return strconv.FormatFloat(val, 'f', -1, 64)
+ case []interface{}:
+ var texts []string
+ for _, item := range val {
+ im, ok := item.(cardObj)
+ if !ok {
+ continue
+ }
+ if text, ok := im["text"].(string); ok && text != "" {
+ texts = append(texts, "「"+text+"」")
+ }
+ }
+ return strings.Join(texts, " ")
+ default:
+ if m, ok := v.(cardObj); ok {
+ if content, ok := m["content"].(string); ok {
+ return content
+ }
+ }
+ return fmt.Sprintf("%v", v)
+ }
+}
+
+func (c *userDslConverter) convertChart(elem cardObj) string {
+ chartSpec, ok := elem["chart_spec"].(cardObj)
+ if !ok {
+ return "📊 Chart"
+ }
+ title := "Chart"
+ chartType := ""
+ if titleObj, ok := chartSpec["title"].(cardObj); ok {
+ if text, ok := titleObj["text"].(string); ok && text != "" {
+ title = text
+ }
+ }
+ if ct, ok := chartSpec["type"].(string); ok && ct != "" {
+ chartType = ct
+ if typeName, ok := cardChartTypeNames[ct]; ok {
+ if title != "Chart" {
+ title += " (" + typeName + ")"
+ } else {
+ title = typeName
+ }
+ }
+ }
+ summary := c.extractChartSummary(chartSpec, chartType)
+ result := "📊 " + title
+ if summary != "" {
+ result += "\nSummary: " + summary
+ }
+ return result
+}
+
+func (c *userDslConverter) extractChartSummary(chartSpec cardObj, chartType string) string {
+ 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...)
+ }
+ }
+ }
+ }
+ if len(values) == 0 {
+ return ""
+ }
+ switch chartType {
+ case "line", "bar", "area":
+ xField, _ := chartSpec["xField"].(string)
+ if xField == "" {
+ if arr, ok := chartSpec["xField"].([]interface{}); ok && len(arr) > 0 {
+ xField, _ = arr[0].(string)
+ }
+ }
+ yField, _ := chartSpec["yField"].(string)
+ if xField == "" || yField == "" {
+ return fmt.Sprintf("%d data point(s)", len(values))
+ }
+ var parts []string
+ for _, v := range values {
+ vm, ok := v.(cardObj)
+ if !ok {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%v:%v", vm[xField], vm[yField]))
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, ", ")
+ }
+ case "pie":
+ catField, _ := chartSpec["categoryField"].(string)
+ valField, _ := chartSpec["valueField"].(string)
+ if catField == "" || valField == "" {
+ return fmt.Sprintf("%d data point(s)", len(values))
+ }
+ var parts []string
+ for _, v := range values {
+ vm, ok := v.(cardObj)
+ if !ok {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%v:%v", vm[catField], vm[valField]))
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, ", ")
+ }
+ }
+ return fmt.Sprintf("%d data point(s)", len(values))
+}
+
+func (c *userDslConverter) convertForm(elem cardObj) string {
+ var sb strings.Builder
+ sb.WriteString("")
+ return sb.String()
+}
+
+func (c *userDslConverter) convertCollapsiblePanel(elem cardObj) string {
+ expanded, _ := elem["expanded"].(bool)
+ title := "Details"
+ if header, ok := elem["header"].(cardObj); ok {
+ if titleRaw, ok := header["title"]; ok {
+ if titleElem, ok := titleRaw.(cardObj); ok {
+ if t := c.convertElement(titleElem, 0); t != "" {
+ title = t
+ }
+ }
+ }
+ }
+ indicator := "▶"
+ if expanded {
+ indicator = "▼"
+ }
+ var sb strings.Builder
+ sb.WriteString(indicator + " " + title + "\n")
+ if elements, ok := elem["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()
+}
+
+func (c *userDslConverter) convertInteractiveContainer(elem cardObj) string {
+ urlStr := ""
+ if behaviors, ok := elem["behaviors"].([]interface{}); ok {
+ for _, b := range behaviors {
+ bm, ok := b.(cardObj)
+ if !ok {
+ continue
+ }
+ if bm["type"] == "open_url" {
+ if u, ok := bm["default_url"].(string); ok && u != "" {
+ urlStr = u
+ break
+ }
+ }
+ }
+ }
+ if urlStr == "" {
+ if action, ok := elem["action"].(cardObj); ok {
+ if u, ok := action["url"].(string); ok && u != "" {
+ urlStr = u
+ } else if multiURL, ok := action["multi_url"].(cardObj); ok {
+ urlStr, _ = multiURL["url"].(string)
+ }
+ }
+ }
+
+ var sb strings.Builder
+ sb.WriteString("\n")
+ if elements, ok := elem["elements"].([]interface{}); ok {
+ sb.WriteString(c.convertElements(elements, 0))
+ }
+ sb.WriteString("\n")
+ return sb.String()
+}
+
+func (c *userDslConverter) convertPerson(elem cardObj) string {
+ userID, _ := elem["user_id"].(string)
+ if userID == "" {
+ return ""
+ }
+ return userID
+}
+
+func (c *userDslConverter) convertPersonList(elem cardObj) string {
+ persons, _ := elem["persons"].([]interface{})
+ if len(persons) == 0 {
+ return ""
+ }
+ var ids []string
+ for _, p := range persons {
+ pm, ok := p.(cardObj)
+ if !ok {
+ continue
+ }
+ if id, ok := pm["id"].(string); ok && id != "" {
+ ids = append(ids, id)
+ }
+ }
+ return strings.Join(ids, ", ")
+}
+
+func (c *userDslConverter) convertTextTag(elem cardObj) string {
+ textElem, ok := elem["text"].(cardObj)
+ if !ok {
+ return ""
+ }
+ text := c.extractTextContent(textElem)
+ if text == "" {
+ return ""
+ }
+ return "「" + text + "」"
+}
+
+func (c *userDslConverter) convertAvatar(elem cardObj) string {
+ userID, _ := elem["user_id"].(string)
+ result := "👤"
+ if userID != "" {
+ result += "(id:" + userID + ")"
+ }
+ return result
+}
+
+func (c *userDslConverter) convertSelectImg(elem cardObj) string {
+ options, _ := elem["options"].([]interface{})
+ if len(options) == 0 {
+ return ""
+ }
+ selectedValues := map[string]bool{}
+ if vals, ok := elem["selected_values"].([]interface{}); ok {
+ for _, v := range vals {
+ if s, ok := v.(string); ok {
+ selectedValues[s] = true
+ }
+ }
+ }
+ var optTexts []string
+ for i, opt := range options {
+ om, ok := opt.(cardObj)
+ if !ok {
+ continue
+ }
+ value, _ := om["value"].(string)
+ text := fmt.Sprintf("🖼️ Image %d", i+1)
+ if value != "" {
+ text += "(" + value + ")"
+ }
+ if imgKey, ok := om["img_key"].(string); ok && imgKey != "" {
+ text += "(img_key:" + imgKey + ")"
+ }
+ if selectedValues[value] {
+ text = "✓" + text
+ }
+ optTexts = append(optTexts, text)
+ }
+ return "{" + strings.Join(optTexts, " / ") + "}"
+}
+
+func (c *userDslConverter) convertRepeat(elem cardObj) string {
+ if elements, ok := elem["elements"].([]interface{}); ok {
+ return c.convertElements(elements, 0)
+ }
+ return ""
+}
+
+func (c *userDslConverter) convertAudio(elem cardObj) string {
+ result := "🎵 Audio"
+ fileKey, _ := elem["file_key"].(string)
+ if fileKey == "" {
+ fileKey, _ = elem["audio_id"].(string)
+ }
+ if fileKey != "" {
+ result += "(key:" + fileKey + ")"
+ }
+ return result
+}
+
+func (c *userDslConverter) convertVideo(elem cardObj) string {
+ result := "🎬 Video"
+ fileKey, _ := elem["file_key"].(string)
+ if fileKey != "" {
+ result += "(key:" + fileKey + ")"
+ }
+ return result
+}
diff --git a/shortcuts/im/convert_lib/card_userdsl_test.go b/shortcuts/im/convert_lib/card_userdsl_test.go
new file mode 100644
index 000000000..6f73ea3f8
--- /dev/null
+++ b/shortcuts/im/convert_lib/card_userdsl_test.go
@@ -0,0 +1,993 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package convertlib
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestConvertInteractiveEventContent(t *testing.T) {
+ // invalid JSON → fallback
+ if got := ConvertInteractiveEventContent("not-json", nil); got != "[interactive card]" {
+ t.Fatalf("invalid JSON = %q, want [interactive card]", got)
+ }
+ // missing user_dsl → fallback
+ if got := ConvertInteractiveEventContent(`{"other":"field"}`, nil); got != "[interactive card]" {
+ t.Fatalf("missing user_dsl = %q, want [interactive card]", got)
+ }
+ // empty user_dsl → fallback
+ if got := ConvertInteractiveEventContent(`{"user_dsl":""}`, nil); got != "[interactive card]" {
+ t.Fatalf("empty user_dsl = %q, want [interactive card]", got)
+ }
+ // user_dsl that is not a string (wrong type) → fallback
+ if got := ConvertInteractiveEventContent(`{"user_dsl":123}`, nil); got != "[interactive card]" {
+ t.Fatalf("non-string user_dsl = %q, want [interactive card]", got)
+ }
+ // valid user-2 card → output
+ userDsl := `{"schema":"2.0","header":{"title":{"tag":"plain_text","content":"Hello"}},"body":{"elements":[{"tag":"markdown","content":"world"}]}}`
+ rawContent := `{"user_dsl":"` + strings.ReplaceAll(userDsl, `"`, `\"`) + `"}`
+ got := ConvertInteractiveEventContent(rawContent, nil)
+ if !strings.HasPrefix(got, ``) {
+ t.Fatalf("valid card = %q, want prefix ", got)
+ }
+ if !strings.Contains(got, "world") {
+ t.Fatalf("valid card = %q, want to contain 'world'", got)
+ }
+}
+
+func makeMentionCard(mdContent string) string {
+ obj := map[string]interface{}{
+ "schema": "2.0",
+ "header": map[string]interface{}{
+ "title": map[string]interface{}{"tag": "plain_text", "content": "T"},
+ },
+ "body": map[string]interface{}{
+ "elements": []interface{}{
+ map[string]interface{}{"tag": "markdown", "content": mdContent},
+ },
+ },
+ }
+ dslBytes, _ := json.Marshal(obj)
+ raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
+ return string(raw)
+}
+
+func TestConvertInteractiveEventContentMentions(t *testing.T) {
+ mentions := []interface{}{
+ map[string]interface{}{
+ "key": "@_user_1",
+ "name": "test-user",
+ "id": map[string]interface{}{"open_id": "fake-uid-001"},
+ },
+ }
+
+ // quoted attrs: mention_key="key"
+ got := ConvertInteractiveEventContent(makeMentionCard(`hi n done`), mentions)
+ if !strings.Contains(got, "@test-user(fake-uid-001)") {
+ t.Fatalf("quoted mention_key not resolved, got: %s", got)
+ }
+
+ // unquoted attrs (real Lark format):
+ got = ConvertInteractiveEventContent(makeMentionCard(`hi done`), mentions)
+ if !strings.Contains(got, "@test-user(fake-uid-001)") {
+ t.Fatalf("unquoted mention_key not resolved, got: %s", got)
+ }
+
+ // mentions_key variant (unquoted)
+ got = ConvertInteractiveEventContent(makeMentionCard(`hi done`), mentions)
+ if !strings.Contains(got, "@test-user(fake-uid-001)") {
+ t.Fatalf("unquoted mentions_key not resolved, got: %s", got)
+ }
+
+ // degradation 1: no mention_key/mentions_key attr → fall back to @id (unquoted)
+ got = ConvertInteractiveEventContent(makeMentionCard(`hi done`), mentions)
+ if !strings.Contains(got, "@fake-uid-001") {
+ t.Fatalf("no mention_key unquoted: expected @id fallback, got: %s", got)
+ }
+
+ // degradation 2: mention_key not found in mentions → fall back to @id
+ got = ConvertInteractiveEventContent(makeMentionCard(`hi done`), mentions)
+ if !strings.Contains(got, "@fake-uid-001") {
+ t.Fatalf("key not in mentions: expected @id fallback, got: %s", got)
+ }
+
+ // multi-mention: ids=id1,id2,id3 mentions_key=k1,,k3
+ // k1 hits → @name(id1), k2 empty → @id2 fallback, k3 not found → @id3 fallback
+ got = ConvertInteractiveEventContent(
+ makeMentionCard(``),
+ mentions,
+ )
+ want := "@test-user(fake-uid-001)@fake-uid-002@fake-uid-003"
+ if !strings.Contains(got, want) {
+ t.Fatalf("multi-mention unquoted: want %q in output, got: %s", want, got)
+ }
+}
+
+func TestUserDslConverterSchema(t *testing.T) {
+ c := &userDslConverter{}
+
+ // user-2.ts: schema field present, header at root, body.elements
+ schema2 := cardObj{
+ "schema": "2.0",
+ "header": cardObj{
+ "title": cardObj{"tag": "plain_text", "content": "Schema2 Title"},
+ "subtitle": cardObj{"tag": "plain_text", "content": "Sub"},
+ },
+ "body": cardObj{
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "body text"},
+ },
+ },
+ }
+ got := c.convert(schema2)
+ if got != "\nbody text\n" {
+ t.Fatalf("schema2 = %q", got)
+ }
+
+ // user-1.ts: no schema field, i18n_header.zh_cn, elements at root
+ schema1 := cardObj{
+ "i18n_header": cardObj{
+ "zh_cn": cardObj{
+ "title": cardObj{"tag": "plain_text", "content": "Schema1 Title"},
+ },
+ },
+ "elements": []interface{}{
+ cardObj{"tag": "hr"},
+ },
+ }
+ got = c.convert(schema1)
+ if got != "\n---\n" {
+ t.Fatalf("schema1 = %q", got)
+ }
+
+ // user-1.ts: no schema, direct header (real Lark event format)
+ schema1Direct := cardObj{
+ "header": cardObj{
+ "title": cardObj{"tag": "plain_text", "content": "Direct Header Title"},
+ "subtitle": cardObj{"tag": "plain_text", "content": "Direct Sub"},
+ },
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "direct body"},
+ },
+ }
+ got = c.convert(schema1Direct)
+ if got != "\ndirect body\n" {
+ t.Fatalf("schema1 direct header = %q", got)
+ }
+
+ // no header, no elements → fallback
+ got = c.convert(cardObj{})
+ if got != "[interactive card]" {
+ t.Fatalf("empty card = %q, want [interactive card]", got)
+ }
+
+ // card with title only → valid (not "[interactive card]")
+ titleOnly := cardObj{
+ "schema": "2.0",
+ "header": cardObj{"title": cardObj{"tag": "plain_text", "content": "TitleOnly"}},
+ "body": cardObj{"elements": []interface{}{}},
+ }
+ got = c.convert(titleOnly)
+ if !strings.Contains(got, "TitleOnly") {
+ t.Fatalf("title-only card = %q, want to contain TitleOnly", got)
+ }
+}
+
+func TestUserDslConverterDispatch(t *testing.T) {
+ c := &userDslConverter{}
+
+ tests := []struct {
+ name string
+ elem cardObj
+ want string
+ contains string
+ }{
+ {
+ name: "plain_text",
+ elem: cardObj{"tag": "plain_text", "content": "hello"},
+ want: "hello",
+ },
+ {
+ name: "markdown",
+ elem: cardObj{"tag": "markdown", "content": "**bold**"},
+ want: "**bold**",
+ },
+ {
+ name: "hr",
+ elem: cardObj{"tag": "hr"},
+ want: "---",
+ },
+ {
+ name: "br",
+ elem: cardObj{"tag": "br"},
+ want: "\n",
+ },
+ {
+ name: "img with img_key",
+ elem: cardObj{
+ "tag": "img",
+ "img_key": "img_v3_abc",
+ "alt": cardObj{"tag": "plain_text", "content": "Banner"},
+ },
+ want: "🖼️ Banner(img_key:img_v3_abc)",
+ },
+ {
+ name: "img_combination",
+ elem: cardObj{
+ "tag": "img_combination",
+ "img_list": []interface{}{
+ cardObj{"img_key": "k1"},
+ cardObj{"img_key": "k2"},
+ },
+ },
+ want: "🖼️ 2 image(s)(keys:k1,k2)",
+ },
+ {
+ name: "button with behaviors default_url",
+ elem: cardObj{
+ "tag": "button",
+ "text": cardObj{"tag": "plain_text", "content": "Open"},
+ "behaviors": []interface{}{
+ cardObj{"type": "open_url", "default_url": "https://example.com"},
+ },
+ },
+ want: "[Open](https://example.com)",
+ },
+ {
+ name: "button disabled",
+ elem: cardObj{
+ "tag": "button",
+ "text": cardObj{"tag": "plain_text", "content": "Nope"},
+ "disabled": true,
+ },
+ want: "[Nope ✗]",
+ },
+ {
+ name: "button no url",
+ elem: cardObj{
+ "tag": "button",
+ "text": cardObj{"tag": "plain_text", "content": "Submit"},
+ },
+ want: "[Submit]",
+ },
+ {
+ name: "action wrapper (user-1 style)",
+ elem: cardObj{
+ "tag": "action",
+ "actions": []interface{}{
+ cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "A"}},
+ cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "B"}},
+ },
+ },
+ want: "[A] [B]",
+ },
+ {
+ name: "overflow",
+ elem: cardObj{
+ "tag": "overflow",
+ "options": []interface{}{
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Edit"}},
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Delete"}},
+ },
+ },
+ want: "⋮ Edit, Delete",
+ },
+ {
+ name: "select_static no selection",
+ elem: cardObj{
+ "tag": "select_static",
+ "options": []interface{}{
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
+ },
+ },
+ want: "{Option1 / Option2 ▼}",
+ },
+ {
+ name: "select_static with initial_option",
+ elem: cardObj{
+ "tag": "select_static",
+ "initial_option": "2",
+ "options": []interface{}{
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
+ },
+ },
+ want: "{Option1 / ✓Option2}",
+ },
+ {
+ name: "multi_select_static with selected_values",
+ elem: cardObj{
+ "tag": "multi_select_static",
+ "selected_values": []interface{}{"A"},
+ "options": []interface{}{
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "OptA"}, "value": "A"},
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "OptB"}, "value": "B"},
+ },
+ },
+ want: "{✓OptA / OptB}(multi)",
+ },
+ {
+ name: "select_person no options no selection shows placeholder",
+ elem: cardObj{
+ "tag": "select_person",
+ "placeholder": cardObj{"tag": "plain_text", "content": "请选择"},
+ },
+ want: "{请选择 ▼}",
+ },
+ {
+ name: "select_person with initial_option synthesizes from ID",
+ elem: cardObj{
+ "tag": "select_person",
+ "initial_option": "fake-open-id-001",
+ },
+ want: "{✓fake-open-id-001}",
+ },
+ {
+ name: "multi_select_person with selected_values shows IDs and multi",
+ elem: cardObj{
+ "tag": "multi_select_person",
+ "selected_values": []interface{}{"fake-open-id-001", "fake-open-id-002"},
+ },
+ want: "{✓fake-open-id-001 / ✓fake-open-id-002}(multi)",
+ },
+ {
+ name: "multi_select_person no selection shows placeholder",
+ elem: cardObj{
+ "tag": "multi_select_person",
+ "placeholder": cardObj{"tag": "plain_text", "content": "添加人员"},
+ },
+ want: "{添加人员 ▼}(multi)",
+ },
+ {
+ name: "input with default_value",
+ elem: cardObj{
+ "tag": "input",
+ "label": cardObj{"tag": "plain_text", "content": "Reason"},
+ "default_value": "prefilled",
+ },
+ want: "Reason: prefilled___",
+ },
+ {
+ name: "input with placeholder",
+ elem: cardObj{
+ "tag": "input",
+ "placeholder": cardObj{"tag": "plain_text", "content": "Type here"},
+ },
+ want: "Type here_____",
+ },
+ {
+ name: "date_picker with initial_date",
+ elem: cardObj{
+ "tag": "date_picker",
+ "initial_date": "2026-01-01",
+ },
+ want: "📅 2026-01-01",
+ },
+ {
+ name: "date_picker placeholder",
+ elem: cardObj{
+ "tag": "date_picker",
+ "placeholder": cardObj{"tag": "plain_text", "content": "Pick date"},
+ },
+ want: "📅 Pick date",
+ },
+ {
+ name: "picker_time with initial_time",
+ elem: cardObj{
+ "tag": "picker_time",
+ "initial_time": "14:30",
+ },
+ want: "🕐 14:30",
+ },
+ {
+ name: "checker unchecked",
+ elem: cardObj{
+ "tag": "checker",
+ "text": cardObj{"tag": "plain_text", "content": "Task A"},
+ },
+ want: "[ ] Task A",
+ },
+ {
+ name: "checker checked",
+ elem: cardObj{
+ "tag": "checker",
+ "checked": true,
+ "text": cardObj{"tag": "plain_text", "content": "Task B"},
+ },
+ want: "[x] Task B",
+ },
+ {
+ name: "chart with chart_spec",
+ elem: cardObj{
+ "tag": "chart",
+ "chart_spec": cardObj{
+ "title": cardObj{"text": "Sales"},
+ "type": "bar",
+ "xField": "month",
+ "yField": "value",
+ "data": cardObj{"values": []interface{}{
+ cardObj{"month": "Jan", "value": float64(10)},
+ cardObj{"month": "Feb", "value": float64(20)},
+ }},
+ },
+ },
+ want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
+ },
+ {
+ name: "chart with compound xField array",
+ elem: cardObj{
+ "tag": "chart",
+ "chart_spec": cardObj{
+ "title": cardObj{"text": "Sales"},
+ "type": "bar",
+ "xField": []interface{}{"month", "category"},
+ "yField": "value",
+ "data": cardObj{"values": []interface{}{
+ cardObj{"month": "Jan", "category": "A", "value": float64(10)},
+ cardObj{"month": "Feb", "category": "B", "value": float64(20)},
+ }},
+ },
+ },
+ want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
+ },
+ {
+ name: "chart no custom title uses type name",
+ elem: cardObj{
+ "tag": "chart",
+ "chart_spec": cardObj{
+ "type": "pie",
+ "categoryField": "label",
+ "valueField": "val",
+ "data": cardObj{"values": []interface{}{
+ cardObj{"label": "A", "val": float64(1)},
+ }},
+ },
+ },
+ want: "📊 Pie chart\nSummary: A:1",
+ },
+ {
+ name: "chart vchart array data format",
+ elem: cardObj{
+ "tag": "chart",
+ "chart_spec": cardObj{
+ "type": "bar",
+ "xField": "x",
+ "yField": "y",
+ "data": []interface{}{
+ cardObj{"id": "s1", "values": []interface{}{
+ cardObj{"x": "Jan", "y": float64(5)},
+ }},
+ cardObj{"id": "s2", "values": []interface{}{
+ cardObj{"x": "Feb", "y": float64(8)},
+ }},
+ },
+ },
+ },
+ want: "📊 Bar chart\nSummary: Jan:5, Feb:8",
+ },
+ {
+ name: "text_tag",
+ elem: cardObj{
+ "tag": "text_tag",
+ "text": cardObj{"tag": "plain_text", "content": "新功能"},
+ },
+ want: "「新功能」",
+ },
+ {
+ name: "avatar with user_id",
+ elem: cardObj{"tag": "avatar", "user_id": "fake-open-id-001"},
+ want: "👤(id:fake-open-id-001)",
+ },
+ {
+ name: "avatar no user_id",
+ elem: cardObj{"tag": "avatar"},
+ want: "👤",
+ },
+ {
+ name: "select_img no selection",
+ elem: cardObj{
+ "tag": "select_img",
+ "options": []interface{}{
+ cardObj{"value": "v1", "img_key": "img_k1"},
+ cardObj{"value": "v2", "img_key": "img_k2"},
+ },
+ },
+ want: "{🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
+ },
+ {
+ name: "select_img with selected",
+ elem: cardObj{
+ "tag": "select_img",
+ "selected_values": []interface{}{"v1"},
+ "options": []interface{}{
+ cardObj{"value": "v1", "img_key": "img_k1"},
+ cardObj{"value": "v2", "img_key": "img_k2"},
+ },
+ },
+ want: "{✓🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
+ },
+ {
+ name: "repeat delegates to elements",
+ elem: cardObj{
+ "tag": "repeat",
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "item A"},
+ cardObj{"tag": "markdown", "content": "item B"},
+ },
+ },
+ want: "item A\nitem B",
+ },
+ {
+ name: "audio with file_key",
+ elem: cardObj{"tag": "audio", "file_key": "file_abc123"},
+ want: "🎵 Audio(key:file_abc123)",
+ },
+ {
+ name: "audio fallback audio_id",
+ elem: cardObj{"tag": "audio", "audio_id": "audio_xyz"},
+ want: "🎵 Audio(key:audio_xyz)",
+ },
+ {
+ name: "video with file_key",
+ elem: cardObj{"tag": "video", "file_key": "video_abc"},
+ want: "🎬 Video(key:video_abc)",
+ },
+ {
+ name: "custom_icon returns empty",
+ elem: cardObj{"tag": "custom_icon", "img_key": "some_key"},
+ want: "",
+ },
+ {
+ name: "standard_icon returns empty",
+ elem: cardObj{"tag": "standard_icon", "token": "alarm_outlined"},
+ want: "",
+ },
+ {
+ name: "button disabled with disabled_tips",
+ elem: cardObj{
+ "tag": "button",
+ "text": cardObj{"tag": "plain_text", "content": "Submit"},
+ "disabled": true,
+ "disabled_tips": cardObj{"tag": "plain_text", "content": "Not allowed"},
+ },
+ want: "[Submit ✗](tips:\"Not allowed\")",
+ },
+ {
+ name: "button with confirm",
+ elem: cardObj{
+ "tag": "button",
+ "text": cardObj{"tag": "plain_text", "content": "Delete"},
+ "confirm": cardObj{
+ "title": cardObj{"tag": "plain_text", "content": "确认"},
+ "text": cardObj{"tag": "plain_text", "content": "不可撤销"},
+ },
+ },
+ want: "[Delete](confirm:\"确认: 不可撤销\")",
+ },
+ {
+ name: "overflow with url",
+ elem: cardObj{
+ "tag": "overflow",
+ "options": []interface{}{
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Open"}, "url": "https://example.com"},
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Copy"}, "value": "copy"},
+ },
+ },
+ want: "⋮ [Open](https://example.com), Copy(copy)",
+ },
+ {
+ name: "select_static with initial_index",
+ elem: cardObj{
+ "tag": "select_static",
+ "initial_index": float64(1),
+ "options": []interface{}{
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "First"}, "value": "a"},
+ cardObj{"text": cardObj{"tag": "plain_text", "content": "Second"}, "value": "b"},
+ },
+ },
+ want: "{First / ✓Second}",
+ },
+ {
+ name: "div text with notation size",
+ elem: cardObj{
+ "tag": "div",
+ "text": cardObj{
+ "tag": "plain_text",
+ "content": "小字注释",
+ "text_size": "notation",
+ },
+ },
+ want: "📝 小字注释",
+ },
+ {
+ name: "form",
+ elem: cardObj{
+ "tag": "form",
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "fill this"},
+ },
+ },
+ want: "",
+ },
+ {
+ name: "collapsible_panel collapsed",
+ elem: cardObj{
+ "tag": "collapsible_panel",
+ "expanded": false,
+ "header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "inner"},
+ },
+ },
+ want: "▶ Details\n inner\n▲",
+ },
+ {
+ name: "collapsible_panel expanded",
+ elem: cardObj{
+ "tag": "collapsible_panel",
+ "expanded": true,
+ "header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "inner"},
+ },
+ },
+ want: "▼ Details\n inner\n▲",
+ },
+ {
+ name: "interactive_container with behaviors",
+ elem: cardObj{
+ "tag": "interactive_container",
+ "behaviors": []interface{}{
+ cardObj{"type": "open_url", "default_url": "https://example.com"},
+ },
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "Click here"},
+ },
+ },
+ want: "\nClick here\n",
+ },
+ {
+ name: "interactive_container no url",
+ elem: cardObj{
+ "tag": "interactive_container",
+ "elements": []interface{}{
+ cardObj{"tag": "markdown", "content": "No link"},
+ },
+ },
+ want: "\nNo link\n",
+ },
+ {
+ name: "column_set with buttons → space-joined",
+ elem: cardObj{
+ "tag": "column_set",
+ "columns": []interface{}{
+ cardObj{"tag": "column", "elements": []interface{}{
+ cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "X"}},
+ }},
+ cardObj{"tag": "column", "elements": []interface{}{
+ cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "Y"}},
+ }},
+ },
+ },
+ want: "[X] [Y]",
+ },
+ {
+ name: "person",
+ elem: cardObj{"tag": "person", "user_id": "fake-open-id-002"},
+ want: "fake-open-id-002",
+ },
+ {
+ name: "unknown tag fallback to content",
+ elem: cardObj{"tag": "mystery", "content": "mystery content"},
+ want: "mystery content",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := c.convertElement(tt.elem, 0)
+ if tt.contains != "" {
+ if !strings.Contains(got, tt.contains) {
+ t.Fatalf("convertElement(%s) = %q, want to contain %q", tt.name, got, tt.contains)
+ }
+ return
+ }
+ if got != tt.want {
+ t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestUserDslExtractButtonURL(t *testing.T) {
+ c := &userDslConverter{}
+
+ // direct url field wins first
+ got := c.extractButtonURL(cardObj{
+ "url": "https://example.com/direct",
+ "multi_url": cardObj{"url": "https://example.com/multi"},
+ "behaviors": []interface{}{
+ cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
+ },
+ })
+ if got != "https://example.com/direct" {
+ t.Fatalf("direct url = %q, want https://example.com/direct", got)
+ }
+
+ // multi_url.url when no direct url
+ got = c.extractButtonURL(cardObj{
+ "multi_url": cardObj{"url": "https://example.com/multi"},
+ "behaviors": []interface{}{
+ cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
+ },
+ })
+ if got != "https://example.com/multi" {
+ t.Fatalf("multi_url = %q, want https://example.com/multi", got)
+ }
+
+ // behaviors default_url as last resort
+ got = c.extractButtonURL(cardObj{
+ "behaviors": []interface{}{
+ cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
+ },
+ })
+ if got != "https://example.com/behavior" {
+ t.Fatalf("behaviors = %q, want https://example.com/behavior", got)
+ }
+
+ // non-open_url behavior is ignored
+ got = c.extractButtonURL(cardObj{
+ "behaviors": []interface{}{
+ cardObj{"type": "callback", "default_url": "https://example.com/callback"},
+ },
+ })
+ if got != "" {
+ t.Fatalf("non-open_url = %q, want empty", got)
+ }
+
+ // no url anywhere → empty
+ got = c.extractButtonURL(cardObj{"text": cardObj{"content": "No URL"}})
+ if got != "" {
+ t.Fatalf("no url = %q, want empty", got)
+ }
+}
+
+func TestUserDslExtractTableCellValue(t *testing.T) {
+ c := &userDslConverter{}
+
+ // nil
+ if got := c.extractUserDslTableCellValue(nil); got != "" {
+ t.Fatalf("nil = %q, want empty", got)
+ }
+ // string
+ if got := c.extractUserDslTableCellValue("hello"); got != "hello" {
+ t.Fatalf("string = %q, want 'hello'", got)
+ }
+ // float64 integer
+ if got := c.extractUserDslTableCellValue(float64(42)); got != "42" {
+ t.Fatalf("int float = %q, want '42'", got)
+ }
+ // float64 decimal
+ if got := c.extractUserDslTableCellValue(float64(3.14)); got != "3.14" {
+ t.Fatalf("float = %q, want '3.14'", got)
+ }
+ // []interface{} with text tags → 「text」 format
+ got := c.extractUserDslTableCellValue([]interface{}{
+ cardObj{"text": "S2", "color": "blue"},
+ cardObj{"text": "M1", "color": "red"},
+ })
+ if got != "「S2」 「M1」" {
+ t.Fatalf("tag array = %q, want '「S2」 「M1」'", got)
+ }
+ // cardObj with content field
+ got = c.extractUserDslTableCellValue(cardObj{"content": "cell content"})
+ if got != "cell content" {
+ t.Fatalf("cardObj with content = %q, want 'cell content'", got)
+ }
+}
+
+func TestUserDslConvertTable(t *testing.T) {
+ c := &userDslConverter{}
+
+ got := c.convertTable(cardObj{
+ "columns": []interface{}{
+ cardObj{"display_name": "客户名称", "name": "customer_name"},
+ cardObj{"display_name": "规模", "name": "scale"},
+ cardObj{"display_name": "金额", "name": "arr"},
+ },
+ "rows": []interface{}{
+ cardObj{
+ "customer_name": "飞书科技",
+ "scale": []interface{}{cardObj{"text": "S2", "color": "blue"}},
+ "arr": float64(16800),
+ },
+ },
+ })
+ want := "| 客户名称 | 规模 | 金额 |\n|------|------|------|\n| 飞书科技 | 「S2」 | 16800 |"
+ if got != want {
+ t.Fatalf("convertTable() = %q, want %q", got, want)
+ }
+
+ // no columns → empty
+ if got := c.convertTable(cardObj{}); got != "" {
+ t.Fatalf("no columns = %q, want empty", got)
+ }
+}
+
+func TestLarkMdMentionResolution(t *testing.T) {
+ mentions := []interface{}{
+ map[string]interface{}{
+ "key": "@_user_1",
+ "name": "test-user",
+ "id": map[string]interface{}{"open_id": "fake-uid-001"},
+ },
+ }
+
+ // lark_md in div.text — the real Lark event format (C01 case)
+ card := map[string]interface{}{
+ "elements": []interface{}{
+ map[string]interface{}{
+ "tag": "div",
+ "text": map[string]interface{}{
+ "tag": "lark_md",
+ "content": "Hello check this.",
+ },
+ },
+ },
+ }
+ dslBytes, _ := json.Marshal(card)
+ raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
+ got := ConvertInteractiveEventContent(string(raw), mentions)
+ if strings.Contains(got, " tag not resolved, got: %s", got)
+ }
+ if !strings.Contains(got, "@fake-uid-001") {
+ t.Fatalf("div.text lark_md: @id not in output, got: %s", got)
+ }
+
+ // lark_md in note.elements (C02 case)
+ card = map[string]interface{}{
+ "elements": []interface{}{
+ map[string]interface{}{
+ "tag": "note",
+ "elements": []interface{}{
+ map[string]interface{}{
+ "tag": "lark_md",
+ "content": "Note: check.",
+ },
+ },
+ },
+ },
+ }
+ dslBytes, _ = json.Marshal(card)
+ raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
+ got = ConvertInteractiveEventContent(string(raw), mentions)
+ if strings.Contains(got, " tag not resolved, got: %s", got)
+ }
+ if !strings.Contains(got, "@fake-uid-001") {
+ t.Fatalf("note lark_md: @id not in output, got: %s", got)
+ }
+
+ // mention_key resolution via mentions map
+ card = map[string]interface{}{
+ "elements": []interface{}{
+ map[string]interface{}{
+ "tag": "div",
+ "text": map[string]interface{}{
+ "tag": "lark_md",
+ "content": `Hi n done.`,
+ },
+ },
+ },
+ }
+ dslBytes, _ = json.Marshal(card)
+ raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
+ got = ConvertInteractiveEventContent(string(raw), mentions)
+ if !strings.Contains(got, "@test-user(fake-uid-001)") {
+ t.Fatalf("div.text lark_md mention_key: want @test-user(fake-uid-001), got: %s", got)
+ }
+}
+
+func TestConvertUserDslCardEndToEnd(t *testing.T) {
+ // user-2.ts format — matches structure of docs/user-dsl/user-example-2.json
+ schema2JSON := `{
+ "schema": "2.0",
+ "header": {
+ "title": {"tag": "plain_text", "content": "飞书卡片组件展示"},
+ "template": "blue"
+ },
+ "body": {
+ "elements": [
+ {"tag": "markdown", "content": "### 基础文本"},
+ {"tag": "hr"},
+ {
+ "tag": "img",
+ "img_key": "img_v3_02122_abc",
+ "alt": {"tag": "plain_text", "content": "示例图片"}
+ },
+ {
+ "tag": "button",
+ "text": {"tag": "plain_text", "content": "主要按钮"},
+ "behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
+ },
+ {
+ "tag": "table",
+ "columns": [
+ {"display_name": "名称", "name": "name"},
+ {"display_name": "数值", "name": "value"}
+ ],
+ "rows": [
+ {"name": "项目A", "value": 100},
+ {"name": "项目B", "value": 200}
+ ]
+ }
+ ]
+ }
+ }`
+
+ got := convertUserDslCard(schema2JSON, nil)
+
+ if !strings.HasPrefix(got, ``) {
+ t.Fatalf("e2e schema2: missing card title prefix, got: %s", got)
+ }
+ if !strings.Contains(got, "### 基础文本") {
+ t.Fatal("e2e schema2: missing markdown content")
+ }
+ if !strings.Contains(got, "---") {
+ t.Fatal("e2e schema2: missing hr")
+ }
+ if !strings.Contains(got, "🖼️ 示例图片(img_key:img_v3_02122_abc)") {
+ t.Fatalf("e2e schema2: missing image, got: %s", got)
+ }
+ if !strings.Contains(got, "[主要按钮](https://example.com)") {
+ t.Fatalf("e2e schema2: missing button, got: %s", got)
+ }
+ if !strings.Contains(got, "| 名称 | 数值 |") {
+ t.Fatal("e2e schema2: missing table header")
+ }
+ if !strings.Contains(got, "| 项目A | 100 |") {
+ t.Fatalf("e2e schema2: missing table row, got: %s", got)
+ }
+ if !strings.HasSuffix(got, "") {
+ t.Fatalf("e2e schema2: missing suffix, got: %s", got)
+ }
+
+ // user-1.ts format
+ schema1JSON := `{
+ "i18n_header": {
+ "zh_cn": {
+ "title": {"tag": "plain_text", "content": "Schema1 卡片"},
+ "template": "blue"
+ }
+ },
+ "elements": [
+ {"tag": "markdown", "content": "Hello **World**"},
+ {
+ "tag": "action",
+ "actions": [
+ {
+ "tag": "button",
+ "text": {"tag": "plain_text", "content": "跳转"},
+ "behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
+ }
+ ]
+ }
+ ]
+ }`
+
+ got = convertUserDslCard(schema1JSON, nil)
+ if !strings.HasPrefix(got, ``) {
+ t.Fatalf("e2e schema1: missing card title, got: %s", got)
+ }
+ if !strings.Contains(got, "Hello **World**") {
+ t.Fatal("e2e schema1: missing markdown")
+ }
+ if !strings.Contains(got, "[跳转](https://example.com)") {
+ t.Fatalf("e2e schema1: missing button, got: %s", got)
+ }
+}