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("
\n") + if elements, ok := elem["elements"].([]interface{}); ok { + sb.WriteString(c.convertElements(elements, 0)) + } + sb.WriteString("\n
") + 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: "
\nfill this\n
", + }, + { + 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) + } +}