Skip to content

Commit 6d7f8ba

Browse files
authored
feat: im card message format (#1218)
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
1 parent b216363 commit 6d7f8ba

5 files changed

Lines changed: 166 additions & 13 deletions

File tree

shortcuts/im/convert_lib/card.go

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,12 @@ var cardChartTypeNames = map[string]string{
5050
type interactiveConverter struct{}
5151

5252
func (interactiveConverter) Convert(ctx *ConvertContext) string {
53-
return convertCard(ctx.RawContent)
53+
return convertCard(ctx.RawContent, ctx.Mentions)
5454
}
5555

5656
// convertCard converts a raw interactive/card message content JSON to human-readable string.
57-
func convertCard(raw string) string {
57+
// mentions is the raw mentions array from the API response; pass nil when not available.
58+
func convertCard(raw string, mentions []interface{}) string {
5859
var parsed cardObj
5960
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
6061
return "[interactive card]"
@@ -63,11 +64,19 @@ func convertCard(raw string) string {
6364
// raw_card_content format: outer JSON has "json_card" string field
6465
if jsonCard, ok := parsed["json_card"].(string); ok {
6566
c := &cardConverter{mode: cardModeConcise}
66-
if att, ok := parsed["json_attachment"].(string); ok && att != "" {
67-
var attObj cardObj
68-
if json.Unmarshal([]byte(att), &attObj) == nil {
69-
c.attachment = attObj
67+
switch att := parsed["json_attachment"].(type) {
68+
case string:
69+
if att != "" {
70+
var attObj cardObj
71+
if json.Unmarshal([]byte(att), &attObj) == nil {
72+
c.attachment = attObj
73+
}
7074
}
75+
case cardObj:
76+
c.attachment = att
77+
}
78+
if len(mentions) > 0 {
79+
c.mentionsByKey = buildMentionsByKey(mentions)
7180
}
7281
schema := 0
7382
if s, ok := parsed["card_schema"].(float64); ok {
@@ -84,6 +93,22 @@ func convertCard(raw string) string {
8493
return convertLegacyCard(parsed)
8594
}
8695

96+
// buildMentionsByKey indexes the mentions array by key for O(1) lookup in convertAt.
97+
func buildMentionsByKey(mentions []interface{}) map[string]map[string]interface{} {
98+
m := make(map[string]map[string]interface{}, len(mentions))
99+
for _, raw := range mentions {
100+
item, ok := raw.(map[string]interface{})
101+
if !ok {
102+
continue
103+
}
104+
key, _ := item["key"].(string)
105+
if key != "" {
106+
m[key] = item
107+
}
108+
}
109+
return m
110+
}
111+
87112
// ── Legacy converter ──────────────────────────────────────────────────────────
88113

89114
func convertLegacyCard(parsed cardObj) string {
@@ -158,8 +183,9 @@ func legacyExtractTexts(elements []interface{}, out *[]string) {
158183
// ── CardConverter ─────────────────────────────────────────────────────────────
159184

160185
type cardConverter struct {
161-
mode cardMode
162-
attachment cardObj
186+
mode cardMode
187+
attachment cardObj
188+
mentionsByKey map[string]map[string]interface{}
163189
}
164190

165191
func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
@@ -1403,26 +1429,52 @@ func (c *cardConverter) convertAt(prop cardObj) string {
14031429
}
14041430
userName := ""
14051431
actualUserID := ""
1432+
fromMentions := false
14061433
if c.attachment != nil {
14071434
if atUsers, ok := c.attachment["at_users"].(cardObj); ok {
14081435
if userInfo, ok := atUsers[userID].(cardObj); ok {
14091436
userName, _ = userInfo["content"].(string)
14101437
actualUserID, _ = userInfo["user_id"].(string)
1438+
// When the backend populates mention_key (raw_card_content path), use
1439+
// mentions[] for the canonical name and the reading-app open_id, which is
1440+
// more accurate than the origKey-stored user_id in at_users.
1441+
if mentionKey, _ := userInfo["mention_key"].(string); mentionKey != "" {
1442+
if mention, ok := c.mentionsByKey[mentionKey]; ok {
1443+
if name, _ := mention["name"].(string); name != "" {
1444+
userName = name
1445+
}
1446+
if id := extractMentionOpenId(mention["id"]); id != "" {
1447+
actualUserID = id
1448+
fromMentions = true
1449+
}
1450+
}
1451+
}
14111452
}
14121453
}
14131454
}
14141455
if userName != "" {
14151456
if c.mode == cardModeDetailed {
14161457
if actualUserID != "" {
1417-
return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID)
1458+
label := "user_id"
1459+
if fromMentions {
1460+
label = "open_id"
1461+
}
1462+
return fmt.Sprintf("@%s(%s:%s)", userName, label, actualUserID)
14181463
}
14191464
return fmt.Sprintf("@%s(open_id:%s)", userName, userID)
14201465
}
1421-
return "@" + userName
1466+
if fromMentions && actualUserID != "" {
1467+
return fmt.Sprintf("@%s(%s)", userName, actualUserID)
1468+
}
1469+
return fmt.Sprintf("@%s(%s)", userName, userID)
14221470
}
14231471
if c.mode == cardModeDetailed {
14241472
if actualUserID != "" {
1425-
return fmt.Sprintf("@user(user_id:%s)", actualUserID)
1473+
label := "user_id"
1474+
if fromMentions {
1475+
label = "open_id"
1476+
}
1477+
return fmt.Sprintf("@user(%s:%s)", label, actualUserID)
14261478
}
14271479
return fmt.Sprintf("@user(open_id:%s)", userID)
14281480
}

shortcuts/im/convert_lib/card_test.go

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ func newTestCardConverter(mode cardMode) *cardConverter {
2727

2828
func TestConvertCard(t *testing.T) {
2929
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\"}}}"}`
30-
got := convertCard(rawCard)
30+
got := convertCard(rawCard, nil)
3131
want := "<card title=\"Card Title\">\nhello\n[Open](https://example.com)\n</card>"
3232
if got != want {
3333
t.Fatalf("convertCard(json_card) = %q, want %q", got, want)
3434
}
3535

3636
legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}`
37-
gotLegacy := convertCard(legacy)
37+
gotLegacy := convertCard(legacy, nil)
3838
wantLegacy := "**Legacy Card**\nlegacy body"
3939
if gotLegacy != wantLegacy {
4040
t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy)
@@ -243,6 +243,75 @@ func TestCardConverterMethods(t *testing.T) {
243243
}
244244
}
245245

246+
func TestConvertAtWithMentions(t *testing.T) {
247+
mentions := []interface{}{
248+
map[string]interface{}{
249+
"key": "@_user_1",
250+
"id": "ou_6b64bef911a5a3ea763df8ffd9258f59",
251+
"name": "燕忠毅",
252+
},
253+
}
254+
attachment := cardObj{
255+
"at_users": cardObj{
256+
"cde8a6c8": cardObj{
257+
"user_id": "754700000001",
258+
"content": "燕忠毅",
259+
"mention_key": "@_user_1",
260+
},
261+
},
262+
}
263+
264+
// Concise mode: should show @Name(open_id) when mention resolves.
265+
concise := &cardConverter{
266+
mode: cardModeConcise,
267+
attachment: attachment,
268+
mentionsByKey: buildMentionsByKey(mentions),
269+
}
270+
if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(ou_6b64bef911a5a3ea763df8ffd9258f59)" {
271+
t.Fatalf("convertAt(concise with mentions) = %q", got)
272+
}
273+
274+
// Detailed mode: label should be open_id when resolved from mentions.
275+
detailed := &cardConverter{
276+
mode: cardModeDetailed,
277+
attachment: attachment,
278+
mentionsByKey: buildMentionsByKey(mentions),
279+
}
280+
if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" {
281+
t.Fatalf("convertAt(detailed with mentions) = %q", got)
282+
}
283+
284+
// No mention_key: falls back to at_users.user_id with user_id label (existing behavior).
285+
noMentionKey := &cardConverter{
286+
mode: cardModeDetailed,
287+
attachment: cardObj{
288+
"at_users": cardObj{
289+
"ou_at": cardObj{"content": "Bob", "user_id": "u_bob"},
290+
},
291+
},
292+
}
293+
if got := noMentionKey.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" {
294+
t.Fatalf("convertAt(fallback no mention_key) = %q", got)
295+
}
296+
297+
// mention_key present but mentionsByKey nil: still falls back gracefully.
298+
nilMentions := &cardConverter{
299+
mode: cardModeDetailed,
300+
attachment: cardObj{
301+
"at_users": cardObj{
302+
"cde8a6c8": cardObj{
303+
"user_id": "754700000001",
304+
"content": "燕忠毅",
305+
"mention_key": "@_user_1",
306+
},
307+
},
308+
},
309+
}
310+
if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(user_id:754700000001)" {
311+
t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got)
312+
}
313+
}
314+
246315
func TestCardConverterExtractTextHelpers(t *testing.T) {
247316
c := newTestCardConverter(cardModeDetailed)
248317

shortcuts/im/convert_lib/content_convert.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ type ContentConverter interface {
2525
type ConvertContext struct {
2626
RawContent string
2727
MentionMap map[string]string
28+
// Mentions is the raw mentions array from the API response.
29+
// Used by interactive card converter to resolve @user references via mention_key.
30+
Mentions []interface{}
2831
// MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API.
2932
// For other message types these can be zero values.
3033
MessageID string
@@ -93,6 +96,7 @@ func FormatEventMessage(msgType, rawContent, messageID string, mentions []interf
9396
content := ConvertBodyContent(msgType, &ConvertContext{
9497
RawContent: rawContent,
9598
MentionMap: BuildMentionKeyMap(mentions),
99+
Mentions: mentions,
96100
MessageID: messageID,
97101
})
98102

@@ -153,6 +157,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
153157
content = ConvertBodyContent(msgType, &ConvertContext{
154158
RawContent: rawContent,
155159
MentionMap: BuildMentionKeyMap(mentions),
160+
Mentions: mentions,
156161
MessageID: messageId,
157162
Runtime: runtime,
158163
SenderNames: nameCache,

shortcuts/im/convert_lib/merge.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ func FormatMergeForwardSubTree(parentID string, childrenMap map[string][]map[str
320320
content = ConvertBodyContent(msgType, &ConvertContext{
321321
RawContent: rawContent,
322322
MentionMap: BuildMentionKeyMap(mentions),
323+
Mentions: mentions,
323324
})
324325
}
325326

shortcuts/im/convert_lib/merge_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,29 @@ func TestMergeForwardConverterWithRuntime(t *testing.T) {
325325
t.Fatalf("mergeForwardConverter.Convert(runtime) = %s", got)
326326
}
327327
}
328+
329+
func TestFormatMergeForwardSubTreeInteractiveCardUsesMentions(t *testing.T) {
330+
cardContent := `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"at\",\"property\":{\"userID\":\"cde8a6c8\"}}]}}","json_attachment":"{\"at_users\":{\"cde8a6c8\":{\"user_id\":\"754700000001\",\"content\":\"Alice\",\"mention_key\":\"@_user_1\"}}}"}`
331+
items := []map[string]interface{}{
332+
{
333+
"message_id": "om_card",
334+
"msg_type": "interactive",
335+
"create_time": "1710500000000",
336+
"sender": map[string]interface{}{"name": "Sender"},
337+
"body": map[string]interface{}{"content": cardContent},
338+
"mentions": []interface{}{
339+
map[string]interface{}{
340+
"key": "@_user_1",
341+
"id": "ou_real_open_id",
342+
"name": "Alice",
343+
},
344+
},
345+
},
346+
}
347+
348+
children := BuildMergeForwardChildrenMap(items, "om_root")
349+
got := FormatMergeForwardSubTree("om_root", children)
350+
if !strings.Contains(got, "@Alice(ou_real_open_id)") {
351+
t.Fatalf("FormatMergeForwardSubTree(interactive card) = %s", got)
352+
}
353+
}

0 commit comments

Comments
 (0)