diff --git a/shortcuts/im/convert_lib/card.go b/shortcuts/im/convert_lib/card.go index 10c2c8577..a418565b5 100644 --- a/shortcuts/im/convert_lib/card.go +++ b/shortcuts/im/convert_lib/card.go @@ -50,11 +50,12 @@ var cardChartTypeNames = map[string]string{ type interactiveConverter struct{} func (interactiveConverter) Convert(ctx *ConvertContext) string { - return convertCard(ctx.RawContent) + return convertCard(ctx.RawContent, ctx.Mentions) } // convertCard converts a raw interactive/card message content JSON to human-readable string. -func convertCard(raw string) string { +// mentions is the raw mentions array from the API response; pass nil when not available. +func convertCard(raw string, mentions []interface{}) string { var parsed cardObj if err := json.Unmarshal([]byte(raw), &parsed); err != nil { return "[interactive card]" @@ -63,11 +64,19 @@ func convertCard(raw string) string { // raw_card_content format: outer JSON has "json_card" string field if jsonCard, ok := parsed["json_card"].(string); ok { c := &cardConverter{mode: cardModeConcise} - if att, ok := parsed["json_attachment"].(string); ok && att != "" { - var attObj cardObj - if json.Unmarshal([]byte(att), &attObj) == nil { - c.attachment = attObj + switch att := parsed["json_attachment"].(type) { + case string: + if att != "" { + var attObj cardObj + if json.Unmarshal([]byte(att), &attObj) == nil { + c.attachment = attObj + } } + case cardObj: + c.attachment = att + } + if len(mentions) > 0 { + c.mentionsByKey = buildMentionsByKey(mentions) } schema := 0 if s, ok := parsed["card_schema"].(float64); ok { @@ -84,6 +93,22 @@ func convertCard(raw string) string { return convertLegacyCard(parsed) } +// buildMentionsByKey indexes the mentions array by key for O(1) lookup in convertAt. +func buildMentionsByKey(mentions []interface{}) map[string]map[string]interface{} { + m := make(map[string]map[string]interface{}, len(mentions)) + for _, raw := range mentions { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + key, _ := item["key"].(string) + if key != "" { + m[key] = item + } + } + return m +} + // ── Legacy converter ────────────────────────────────────────────────────────── func convertLegacyCard(parsed cardObj) string { @@ -158,8 +183,9 @@ func legacyExtractTexts(elements []interface{}, out *[]string) { // ── CardConverter ───────────────────────────────────────────────────────────── type cardConverter struct { - mode cardMode - attachment cardObj + mode cardMode + attachment cardObj + mentionsByKey map[string]map[string]interface{} } func (c *cardConverter) convert(jsonCard string, hintSchema int) string { @@ -1403,26 +1429,52 @@ func (c *cardConverter) convertAt(prop cardObj) string { } userName := "" actualUserID := "" + fromMentions := false if c.attachment != nil { if atUsers, ok := c.attachment["at_users"].(cardObj); ok { if userInfo, ok := atUsers[userID].(cardObj); ok { userName, _ = userInfo["content"].(string) actualUserID, _ = userInfo["user_id"].(string) + // When the backend populates mention_key (raw_card_content path), use + // mentions[] for the canonical name and the reading-app open_id, which is + // more accurate than the origKey-stored user_id in at_users. + if mentionKey, _ := userInfo["mention_key"].(string); mentionKey != "" { + if mention, ok := c.mentionsByKey[mentionKey]; ok { + if name, _ := mention["name"].(string); name != "" { + userName = name + } + if id := extractMentionOpenId(mention["id"]); id != "" { + actualUserID = id + fromMentions = true + } + } + } } } } if userName != "" { if c.mode == cardModeDetailed { if actualUserID != "" { - return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID) + label := "user_id" + if fromMentions { + label = "open_id" + } + return fmt.Sprintf("@%s(%s:%s)", userName, label, actualUserID) } return fmt.Sprintf("@%s(open_id:%s)", userName, userID) } - return "@" + userName + if fromMentions && actualUserID != "" { + return fmt.Sprintf("@%s(%s)", userName, actualUserID) + } + return fmt.Sprintf("@%s(%s)", userName, userID) } if c.mode == cardModeDetailed { if actualUserID != "" { - return fmt.Sprintf("@user(user_id:%s)", actualUserID) + label := "user_id" + if fromMentions { + label = "open_id" + } + return fmt.Sprintf("@user(%s:%s)", label, actualUserID) } return fmt.Sprintf("@user(open_id:%s)", userID) } diff --git a/shortcuts/im/convert_lib/card_test.go b/shortcuts/im/convert_lib/card_test.go index bb011f987..49f63644b 100644 --- a/shortcuts/im/convert_lib/card_test.go +++ b/shortcuts/im/convert_lib/card_test.go @@ -27,14 +27,14 @@ func newTestCardConverter(mode cardMode) *cardConverter { func TestConvertCard(t *testing.T) { rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}` - got := convertCard(rawCard) + got := convertCard(rawCard, nil) want := "\nhello\n[Open](https://example.com)\n" if got != want { t.Fatalf("convertCard(json_card) = %q, want %q", got, want) } legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}` - gotLegacy := convertCard(legacy) + gotLegacy := convertCard(legacy, nil) wantLegacy := "**Legacy Card**\nlegacy body" if gotLegacy != wantLegacy { t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy) @@ -243,6 +243,75 @@ func TestCardConverterMethods(t *testing.T) { } } +func TestConvertAtWithMentions(t *testing.T) { + mentions := []interface{}{ + map[string]interface{}{ + "key": "@_user_1", + "id": "ou_6b64bef911a5a3ea763df8ffd9258f59", + "name": "燕忠毅", + }, + } + attachment := cardObj{ + "at_users": cardObj{ + "cde8a6c8": cardObj{ + "user_id": "754700000001", + "content": "燕忠毅", + "mention_key": "@_user_1", + }, + }, + } + + // Concise mode: should show @Name(open_id) when mention resolves. + concise := &cardConverter{ + mode: cardModeConcise, + attachment: attachment, + mentionsByKey: buildMentionsByKey(mentions), + } + if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(ou_6b64bef911a5a3ea763df8ffd9258f59)" { + t.Fatalf("convertAt(concise with mentions) = %q", got) + } + + // Detailed mode: label should be open_id when resolved from mentions. + detailed := &cardConverter{ + mode: cardModeDetailed, + attachment: attachment, + mentionsByKey: buildMentionsByKey(mentions), + } + if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" { + t.Fatalf("convertAt(detailed with mentions) = %q", got) + } + + // No mention_key: falls back to at_users.user_id with user_id label (existing behavior). + noMentionKey := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "at_users": cardObj{ + "ou_at": cardObj{"content": "Bob", "user_id": "u_bob"}, + }, + }, + } + if got := noMentionKey.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" { + t.Fatalf("convertAt(fallback no mention_key) = %q", got) + } + + // mention_key present but mentionsByKey nil: still falls back gracefully. + nilMentions := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "at_users": cardObj{ + "cde8a6c8": cardObj{ + "user_id": "754700000001", + "content": "燕忠毅", + "mention_key": "@_user_1", + }, + }, + }, + } + if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(user_id:754700000001)" { + t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got) + } +} + func TestCardConverterExtractTextHelpers(t *testing.T) { c := newTestCardConverter(cardModeDetailed) diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 3dd60c7fb..1ede9b79e 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -25,6 +25,9 @@ type ContentConverter interface { type ConvertContext struct { RawContent string MentionMap map[string]string + // Mentions is the raw mentions array from the API response. + // Used by interactive card converter to resolve @user references via mention_key. + Mentions []interface{} // MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API. // For other message types these can be zero values. MessageID string @@ -93,6 +96,7 @@ func FormatEventMessage(msgType, rawContent, messageID string, mentions []interf content := ConvertBodyContent(msgType, &ConvertContext{ RawContent: rawContent, MentionMap: BuildMentionKeyMap(mentions), + Mentions: mentions, MessageID: messageID, }) @@ -153,6 +157,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, content = ConvertBodyContent(msgType, &ConvertContext{ RawContent: rawContent, MentionMap: BuildMentionKeyMap(mentions), + Mentions: mentions, MessageID: messageId, Runtime: runtime, SenderNames: nameCache, diff --git a/shortcuts/im/convert_lib/merge.go b/shortcuts/im/convert_lib/merge.go index 5839b76d5..aece5f73b 100644 --- a/shortcuts/im/convert_lib/merge.go +++ b/shortcuts/im/convert_lib/merge.go @@ -320,6 +320,7 @@ func FormatMergeForwardSubTree(parentID string, childrenMap map[string][]map[str content = ConvertBodyContent(msgType, &ConvertContext{ RawContent: rawContent, MentionMap: BuildMentionKeyMap(mentions), + Mentions: mentions, }) } diff --git a/shortcuts/im/convert_lib/merge_test.go b/shortcuts/im/convert_lib/merge_test.go index 460428d24..b9b5eff66 100644 --- a/shortcuts/im/convert_lib/merge_test.go +++ b/shortcuts/im/convert_lib/merge_test.go @@ -325,3 +325,29 @@ func TestMergeForwardConverterWithRuntime(t *testing.T) { t.Fatalf("mergeForwardConverter.Convert(runtime) = %s", got) } } + +func TestFormatMergeForwardSubTreeInteractiveCardUsesMentions(t *testing.T) { + cardContent := `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"at\",\"property\":{\"userID\":\"cde8a6c8\"}}]}}","json_attachment":"{\"at_users\":{\"cde8a6c8\":{\"user_id\":\"754700000001\",\"content\":\"Alice\",\"mention_key\":\"@_user_1\"}}}"}` + items := []map[string]interface{}{ + { + "message_id": "om_card", + "msg_type": "interactive", + "create_time": "1710500000000", + "sender": map[string]interface{}{"name": "Sender"}, + "body": map[string]interface{}{"content": cardContent}, + "mentions": []interface{}{ + map[string]interface{}{ + "key": "@_user_1", + "id": "ou_real_open_id", + "name": "Alice", + }, + }, + }, + } + + children := BuildMergeForwardChildrenMap(items, "om_root") + got := FormatMergeForwardSubTree("om_root", children) + if !strings.Contains(got, "@Alice(ou_real_open_id)") { + t.Fatalf("FormatMergeForwardSubTree(interactive card) = %s", got) + } +}