From 6d7f8ba442ba0da0e2231b343a0ae9348cd4c4fb Mon Sep 17 00:00:00 2001 From: 91-enjoy Date: Tue, 2 Jun 2026 20:42:59 +0800 Subject: [PATCH] feat: im card message format (#1218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive card messages (msg_type: interactive) can contain @user elements in their card body. The json_attachment.at_users field stores resolved user info, but the user_id there is the sender-side platform user_id — not the reading app's canonical open_id. When the backend populates a mention_key on each at_users entry, it signals that the API-level mentions[] array carries a more authoritative open_id and display name for the reading context. This PR adds support for this two-level lookup: it threads the raw mentions[] array into the card converter, indexes it by mention_key for O(1) access, and renders the canonical open_id + display name whenever the link is resolvable. All existing fallback paths (no mention_key, nil mentions) are preserved without behavioral change. Change-Id: I00f846d76482adba315d07361c35909b71ca74c7 --- shortcuts/im/convert_lib/card.go | 74 ++++++++++++++++++--- shortcuts/im/convert_lib/card_test.go | 73 +++++++++++++++++++- shortcuts/im/convert_lib/content_convert.go | 5 ++ shortcuts/im/convert_lib/merge.go | 1 + shortcuts/im/convert_lib/merge_test.go | 26 ++++++++ 5 files changed, 166 insertions(+), 13 deletions(-) diff --git a/shortcuts/im/convert_lib/card.go b/shortcuts/im/convert_lib/card.go index 10c2c857..a418565b 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 bb011f98..49f63644 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 3dd60c7f..1ede9b79 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 5839b76d..aece5f73 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 460428d2..b9b5eff6 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) + } +}