mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 := "<card title=\"Card Title\">\nhello\n[Open](https://example.com)\n</card>"
|
||||
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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -320,6 +320,7 @@ func FormatMergeForwardSubTree(parentID string, childrenMap map[string][]map[str
|
||||
content = ConvertBodyContent(msgType, &ConvertContext{
|
||||
RawContent: rawContent,
|
||||
MentionMap: BuildMentionKeyMap(mentions),
|
||||
Mentions: mentions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user