From 8c28d49a9ca511b0d2d9161327734ea40e407c20 Mon Sep 17 00:00:00 2001 From: "xiaoxiangyu.123" Date: Mon, 31 Aug 2026 22:48:39 +0800 Subject: [PATCH 1/4] feat(im)!: normalize message list JSON context Co-authored-by: TRAE CLI --- affordance/im.md | 6 + internal/affordance/im_source_test.go | 13 + shortcuts/im/im_chat_messages_list.go | 11 +- shortcuts/im/im_threads_messages_list.go | 12 +- shortcuts/im/message_compact.go | 196 +++++++++++++ shortcuts/im/message_compact_test.go | 270 ++++++++++++++++++ .../references/lark-im-chat-messages-list.md | 27 +- .../lark-im-threads-messages-list.md | 25 +- 8 files changed, 541 insertions(+), 19 deletions(-) create mode 100644 shortcuts/im/message_compact.go create mode 100644 shortcuts/im/message_compact_test.go diff --git a/affordance/im.md b/affordance/im.md index 8659f4ea7c..930077552d 100644 --- a/affordance/im.md +++ b/affordance/im.md @@ -55,6 +55,9 @@ lark-cli im +chat-members-list --chat-id oc_xxx ## +chat-messages-list Use this for message history when the conversation is already known. +### Tips +- JSON output stores chat_id once at the top level and sender metadata in participants; resolve each message's sender_id through that map. + ### Avoid when - Searching across conversations → use [[+messages-search]]. - Fetching full details for known message ids → use [[+messages-mget]]. @@ -227,6 +230,9 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Hello" ## +threads-messages-list Use this when a message or thread id is known and the replies inside that thread are needed. +### Tips +- JSON output stores thread_id once at the top level and sender metadata in participants; resolve each message's sender_id through that map. + ### Examples **List replies in a thread** diff --git a/internal/affordance/im_source_test.go b/internal/affordance/im_source_test.go index b335785688..a3713f5bea 100644 --- a/internal/affordance/im_source_test.go +++ b/internal/affordance/im_source_test.go @@ -166,6 +166,19 @@ func TestIMAffordanceDoesNotDuplicateRuntimeRecovery(t *testing.T) { } } +func TestIMMessageListAffordanceDocumentsNormalizedJSON(t *testing.T) { + prev := mdSource + t.Cleanup(func() { SetSource(prev) }) + SetSource(os.DirFS("../../affordance")) + + for _, method := range []string{"+chat-messages-list", "+threads-messages-list"} { + tips := parsedIMAffordance(t, method).Tips + if !containsItem(tips, "participants") || !containsItem(tips, "sender_id") { + t.Errorf("%s tips must explain normalized participant lookup: %v", method, tips) + } + } +} + func TestIMAffordancePreservesOutboundAndDeleteIntentBoundaries(t *testing.T) { prev := mdSource t.Cleanup(func() { SetSource(prev) }) diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 8e3114216f..f8235b57b0 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -175,14 +175,9 @@ var ImChatMessageList = common.Shortcut{ } pagination.Items = len(messages) - // Emit: pagination completion belongs to framework metadata; the - // business payload remains compatible for existing consumers. - outData := map[string]interface{}{ - "messages": messages, - "total": len(messages), - "has_more": hasMore, - "page_token": nextPageToken, - } + // Emit: JSON normalizes repeated chat/sender context; human and record + // formats keep the established per-message projection. + outData := messageListOutputData(runtime.Format, runtime.JqExpr, messages, chatId, "", hasMore, nextPageToken) runtime.OutFormat(outData, &output.Meta{ Pagination: pagination, }, func(w io.Writer) { diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 0c24ad2e43..815e890c85 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -147,15 +147,9 @@ var ImThreadsMessagesList = common.Shortcut{ } pagination.Items = len(messages) - // Emit: keep legacy data fields while publishing the authoritative run - // outcome through the shared output metadata contract. - outData := map[string]interface{}{ - "thread_id": threadId, - "messages": messages, - "total": len(messages), - "has_more": hasMore, - "page_token": nextPageToken, - } + // Emit: JSON normalizes repeated thread/sender context; human and record + // formats keep the established per-message projection. + outData := messageListOutputData(runtime.Format, runtime.JqExpr, messages, "", threadId, hasMore, nextPageToken) runtime.OutFormat(outData, &output.Meta{ Pagination: pagination, }, func(w io.Writer) { diff --git a/shortcuts/im/message_compact.go b/shortcuts/im/message_compact.go new file mode 100644 index 0000000000..713b3c2e3a --- /dev/null +++ b/shortcuts/im/message_compact.go @@ -0,0 +1,196 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "reflect" + + "github.com/larksuite/cli/internal/output" +) + +func messageListOutputData( + runtimeFormat string, + jqExpr string, + messages []map[string]interface{}, + chatID string, + threadID string, + hasMore bool, + pageToken string, +) map[string]interface{} { + legacy := map[string]interface{}{ + "messages": messages, + "total": len(messages), + "has_more": hasMore, + "page_token": pageToken, + } + if threadID != "" { + legacy["thread_id"] = threadID + } + + // JQ always filters the JSON envelope. Unknown formats also fall back to + // JSON in Emitter.Success, so both paths must see the normalized shape. + if jqExpr != "" { + return compactMessageListData(messages, chatID, threadID, hasMore, pageToken) + } + if runtimeFormat == "pretty" { + return legacy + } + format, known := output.ParseFormat(runtimeFormat) + if !known || format == output.FormatJSON { + return compactMessageListData(messages, chatID, threadID, hasMore, pageToken) + } + return legacy +} + +// compactMessageListData normalizes repeated conversation metadata for JSON +// output. It never mutates messages: the enriched message tree remains the +// source for human and record-oriented renderers. +func compactMessageListData( + messages []map[string]interface{}, + chatID string, + threadID string, + hasMore bool, + pageToken string, +) map[string]interface{} { + if chatID == "" { + chatID = commonMessageString(messages, "chat_id") + } + + participants, reusableSenders := compactParticipants(messages) + projected := make([]map[string]interface{}, 0, len(messages)) + for _, message := range messages { + projected = append(projected, compactMessage(message, chatID, threadID, reusableSenders)) + } + + out := map[string]interface{}{ + "messages": projected, + "total": len(projected), + "has_more": hasMore, + "page_token": pageToken, + } + if chatID != "" { + out["chat_id"] = chatID + } + if threadID != "" { + out["thread_id"] = threadID + } + if len(participants) > 0 { + out["participants"] = participants + } + return out +} + +// compactParticipants returns sender records that can be referenced without +// losing information. A sender id is reusable only when every occurrence has +// identical metadata; conflicting occurrences stay inline in compactMessage. +func compactParticipants(messages []map[string]interface{}) (map[string]map[string]interface{}, map[string]bool) { + originalByID := make(map[string]map[string]interface{}) + participants := make(map[string]map[string]interface{}) + reusable := make(map[string]bool) + + walkMessageTree(messages, func(message map[string]interface{}) { + sender, ok := message["sender"].(map[string]interface{}) + if !ok { + return + } + id, _ := sender["id"].(string) + if id == "" { + return + } + if existing, seen := originalByID[id]; seen { + if !reflect.DeepEqual(existing, sender) { + reusable[id] = false + } + return + } + originalByID[id] = cloneStringMap(sender) + participant := cloneStringMap(sender) + delete(participant, "id") + participants[id] = participant + reusable[id] = true + }) + + for id := range participants { + if !reusable[id] { + delete(participants, id) + } + } + return participants, reusable +} + +func compactMessage(message map[string]interface{}, chatID string, threadID string, reusableSenders map[string]bool) map[string]interface{} { + out := cloneStringMap(message) + if messageChatID, _ := out["chat_id"].(string); chatID != "" && messageChatID == chatID { + delete(out, "chat_id") + } + if messageThreadID, _ := out["thread_id"].(string); threadID != "" && messageThreadID == threadID { + delete(out, "thread_id") + } + if sender, ok := out["sender"].(map[string]interface{}); ok { + if id, _ := sender["id"].(string); id != "" && reusableSenders[id] { + delete(out, "sender") + out["sender_id"] = id + } + } + if replies := messageSlice(out["thread_replies"]); replies != nil { + projected := make([]map[string]interface{}, 0, len(replies)) + for _, reply := range replies { + projected = append(projected, compactMessage(reply, chatID, threadID, reusableSenders)) + } + out["thread_replies"] = projected + } + return out +} + +func commonMessageString(messages []map[string]interface{}, key string) string { + common := "" + conflict := false + walkMessageTree(messages, func(message map[string]interface{}) { + value, _ := message[key].(string) + if value == "" || conflict { + return + } + if common == "" { + common = value + return + } + if common != value { + common = "" + conflict = true + } + }) + return common +} + +func walkMessageTree(messages []map[string]interface{}, visit func(map[string]interface{})) { + for _, message := range messages { + visit(message) + walkMessageTree(messageSlice(message["thread_replies"]), visit) + } +} + +func messageSlice(value interface{}) []map[string]interface{} { + switch items := value.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if message, ok := item.(map[string]interface{}); ok { + out = append(out, message) + } + } + return out + default: + return nil + } +} + +func cloneStringMap(source map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(source)) + for key, value := range source { + out[key] = value + } + return out +} diff --git a/shortcuts/im/message_compact_test.go b/shortcuts/im/message_compact_test.go new file mode 100644 index 0000000000..a82d2de527 --- /dev/null +++ b/shortcuts/im/message_compact_test.go @@ -0,0 +1,270 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestCompactMessageListDataHoistsRepeatedContext(t *testing.T) { + sender := map[string]interface{}{ + "id": "ou_alice", + "id_type": "open_id", + "sender_type": "user", + "name": "Alice", + "sender_i18n_names": map[string]interface{}{"en_us": "Alice"}, + } + messages := []map[string]interface{}{ + { + "message_id": "om_root", "chat_id": "oc_chat", "sender": sender, + "content": "root", "reactions": map[string]interface{}{"counts": []interface{}{map[string]interface{}{"reaction_type": "OK", "count": 1}}}, + "thread_replies": []map[string]interface{}{{ + "message_id": "om_reply", "chat_id": "oc_chat", "sender": sender, "content": "reply", + }}, + }, + {"message_id": "om_second", "chat_id": "oc_chat", "sender": sender, "content": "second"}, + } + + got := compactMessageListData(messages, "oc_chat", "", true, "next") + if got["chat_id"] != "oc_chat" || got["has_more"] != true || got["page_token"] != "next" { + t.Fatalf("top-level context = %#v", got) + } + participants, ok := got["participants"].(map[string]map[string]interface{}) + wantParticipant := cloneStringMap(sender) + delete(wantParticipant, "id") + if !ok || len(participants) != 1 || !reflect.DeepEqual(participants["ou_alice"], wantParticipant) { + t.Fatalf("participants = %#v, want sender metadata keyed by id", got["participants"]) + } + projected := got["messages"].([]map[string]interface{}) + for index, message := range projected { + if message["sender_id"] != "ou_alice" { + t.Fatalf("message %d sender_id = %#v", index, message["sender_id"]) + } + if _, exists := message["sender"]; exists { + t.Fatalf("message %d retained repeated sender: %#v", index, message) + } + if _, exists := message["chat_id"]; exists { + t.Fatalf("message %d retained repeated chat_id: %#v", index, message) + } + } + reply := projected[0]["thread_replies"].([]map[string]interface{})[0] + if reply["sender_id"] != "ou_alice" || reply["content"] != "reply" { + t.Fatalf("projected reply = %#v", reply) + } + if _, ok := projected[0]["reactions"]; !ok { + t.Fatalf("reactions were lost: %#v", projected[0]) + } + + // Projection must not mutate the enriched source used by other formats. + if messages[0]["chat_id"] != "oc_chat" || messages[0]["sender"] == nil { + t.Fatalf("source message mutated: %#v", messages[0]) + } +} + +func TestCompactMessageListDataKeepsUnsafeSenderInline(t *testing.T) { + messages := []map[string]interface{}{ + {"message_id": "om_1", "sender": map[string]interface{}{"id": "ou_same", "name": "Old Name"}}, + {"message_id": "om_2", "sender": map[string]interface{}{"id": "ou_same", "name": "New Name"}}, + {"message_id": "om_system", "sender": map[string]interface{}{"sender_type": "system"}}, + } + + got := compactMessageListData(messages, "", "omt_thread", false, "") + if _, exists := got["participants"]; exists { + t.Fatalf("conflicting sender must not be hoisted: %#v", got["participants"]) + } + projected := got["messages"].([]map[string]interface{}) + for index, message := range projected { + if _, exists := message["sender"]; !exists { + t.Fatalf("message %d lost inline sender: %#v", index, message) + } + if _, exists := message["sender_id"]; exists { + t.Fatalf("message %d has unsafe sender reference: %#v", index, message) + } + } +} + +func TestCompactMessageListDataHoistsThreadID(t *testing.T) { + messages := []map[string]interface{}{ + {"message_id": "om_1", "thread_id": "omt_thread"}, + {"message_id": "om_2", "thread_id": "omt_thread"}, + } + got := compactMessageListData(messages, "", "omt_thread", false, "") + for index, message := range got["messages"].([]map[string]interface{}) { + if _, exists := message["thread_id"]; exists { + t.Fatalf("message %d retained repeated thread_id: %#v", index, message) + } + } +} + +func TestCompactMessageListDataDoesNotHoistMixedChatIDs(t *testing.T) { + messages := []map[string]interface{}{ + {"message_id": "om_1", "chat_id": "oc_a"}, + {"message_id": "om_2", "chat_id": "oc_b"}, + } + got := compactMessageListData(messages, "", "omt_thread", false, "") + if _, exists := got["chat_id"]; exists { + t.Fatalf("mixed chat_id was hoisted: %#v", got) + } + for index, message := range got["messages"].([]map[string]interface{}) { + if _, exists := message["chat_id"]; !exists { + t.Fatalf("message %d lost its chat_id: %#v", index, message) + } + } +} + +func TestMessageListOutputDataOnlyCompactsJSON(t *testing.T) { + messages := []map[string]interface{}{{ + "message_id": "om_1", "chat_id": "oc_chat", + "sender": map[string]interface{}{"id": "ou_alice", "name": "Alice"}, + }} + + for _, tc := range []struct { + name, format, jq string + wantCompact bool + }{ + {name: "default JSON", format: "json", wantCompact: true}, + {name: "case-insensitive JSON", format: "JSON", wantCompact: true}, + {name: "jq envelope", format: "table", jq: ".data", wantCompact: true}, + {name: "unknown falls back to JSON", format: "other", wantCompact: true}, + {name: "uppercase pretty falls back to JSON", format: "Pretty", wantCompact: true}, + {name: "pretty", format: "pretty"}, + {name: "table", format: "table"}, + {name: "csv", format: "csv"}, + {name: "ndjson", format: "ndjson"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := messageListOutputData(tc.format, tc.jq, messages, "oc_chat", "", false, "") + message := got["messages"].([]map[string]interface{})[0] + _, compact := message["sender_id"] + if compact != tc.wantCompact { + t.Fatalf("sender_id present = %t, want %t; output = %#v", compact, tc.wantCompact, got) + } + if !tc.wantCompact { + if message["chat_id"] != "oc_chat" || message["sender"] == nil { + t.Fatalf("legacy format lost inline context: %#v", got) + } + if _, exists := got["participants"]; exists { + t.Fatalf("legacy format gained participants: %#v", got) + } + } + }) + } +} + +func TestCompactMessageListDataReducesRepeatedJSON(t *testing.T) { + sender := map[string]interface{}{ + "id": "ou_alice", "id_type": "open_id", "sender_type": "user", + "name": "Alice Example", + "sender_i18n_names": map[string]interface{}{ + "en_us": "Alice Example", "zh_cn": "Alice Example", + }, + } + messages := make([]map[string]interface{}, 20) + for index := range messages { + messages[index] = map[string]interface{}{ + "message_id": "om_repeated", "chat_id": "oc_chat", "sender": sender, + "msg_type": "text", "content": "same-sized message body", + } + } + legacy, err := json.Marshal(map[string]interface{}{"messages": messages}) + if err != nil { + t.Fatal(err) + } + compact, err := json.Marshal(compactMessageListData(messages, "oc_chat", "", false, "")) + if err != nil { + t.Fatal(err) + } + if len(compact)*4 >= len(legacy)*3 { + t.Fatalf("compact JSON size = %d, legacy = %d; want at least 25%% reduction", len(compact), len(legacy)) + } +} + +func TestChatMessagesListEmitsCompactJSONContract(t *testing.T) { + var tc listPageAllCase + for _, candidate := range listPageAllCases() { + if candidate.name == "chat-messages-list" { + tc = candidate + break + } + } + runtime, calls := newListPageAllRuntime(t, tc, nil, func(req *http.Request, _ int) map[string]interface{} { + if got := req.URL.Query().Get("container_id"); got != "oc_test" { + t.Fatalf("container_id = %q, want oc_test", got) + } + return map[string]interface{}{ + "items": []interface{}{map[string]interface{}{ + "message_id": "om_1", "msg_type": "text", "chat_id": "oc_test", + "sender": map[string]interface{}{"id": "ou_alice", "sender_type": "user", "sender_name": "Alice"}, + "body": map[string]interface{}{"content": `{"text":"full content"}`}, "create_time": "0", + }}, + "has_more": false, "page_token": "final", + } + }) + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 1 { + t.Fatalf("API calls = %d, want 1", *calls) + } + data := listPageAllOutputData(t, runtime) + if data["chat_id"] != "oc_test" || data["page_token"] != "final" { + t.Fatalf("top-level data = %#v", data) + } + participants := data["participants"].(map[string]interface{}) + alice := participants["ou_alice"].(map[string]interface{}) + if alice["name"] != "Alice" || alice["sender_type"] != "user" { + t.Fatalf("participant = %#v", alice) + } + message := data["messages"].([]interface{})[0].(map[string]interface{}) + if message["sender_id"] != "ou_alice" || message["content"] != "full content" { + t.Fatalf("message = %#v", message) + } + if _, exists := message["sender"]; exists { + t.Fatalf("message retained sender: %#v", message) + } + if _, exists := message["chat_id"]; exists { + t.Fatalf("message retained chat_id: %#v", message) + } +} + +func TestThreadsMessagesListEmitsCompactJSONContract(t *testing.T) { + var tc listPageAllCase + for _, candidate := range listPageAllCases() { + if candidate.name == "threads-messages-list" { + tc = candidate + break + } + } + runtime, _ := newListPageAllRuntime(t, tc, nil, func(req *http.Request, _ int) map[string]interface{} { + if got := req.URL.Query().Get("container_id"); got != "omt_test" { + t.Fatalf("container_id = %q, want omt_test", got) + } + return map[string]interface{}{ + "items": []interface{}{map[string]interface{}{ + "message_id": "om_reply", "thread_id": "omt_test", "msg_type": "text", + "sender": map[string]interface{}{"id": "ou_bob", "sender_type": "user", "sender_name": "Bob"}, + "body": map[string]interface{}{"content": `{"text":"reply"}`}, "create_time": "0", + }}, + "has_more": true, "page_token": "next", + } + }) + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + data := listPageAllOutputData(t, runtime) + if data["thread_id"] != "omt_test" || data["has_more"] != true || data["page_token"] != "next" { + t.Fatalf("top-level data = %#v", data) + } + message := data["messages"].([]interface{})[0].(map[string]interface{}) + if message["sender_id"] != "ou_bob" || message["content"] != "reply" { + t.Fatalf("message = %#v", message) + } + if _, exists := message["thread_id"]; exists { + t.Fatalf("message retained repeated thread_id: %#v", message) + } +} diff --git a/skills/lark-im/references/lark-im-chat-messages-list.md b/skills/lark-im/references/lark-im-chat-messages-list.md index ba2319b8ba..0d0bfd47c3 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -90,6 +90,8 @@ lark-cli im +threads-messages-list --thread omt_xxx | Field | Description | |------|------| +| `chat_id` | Conversation ID shared by every returned message | +| `participants` | Sender metadata keyed by sender ID; messages refer to entries through `sender_id` | | `messages` | Message array | | `total` | Number of messages in the current page | | `has_more` | Whether additional pages are available | @@ -102,13 +104,36 @@ Each message contains: | `message_id` | Message ID | | `msg_type` | Message type: `text`, `image`, `file`, `interactive`, `post`, `audio`, `video`, `system`, etc. | | `create_time` | Creation time | -| `sender` | Sender information (includes `name` for user senders) | +| `sender_id` | Sender ID referencing `participants[sender_id]`; present when sender metadata can be normalized safely | +| `sender` | Inline sender fallback for system/anonymous senders or conflicting metadata | | `content` | Message content | | `deleted` | Whether the message has been recalled (always present, `true` = recalled) | | `updated` | Whether the message has been edited after sending | | `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions | | `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist | +The JSON response normalizes repeated conversation context. Instead of copying +the same `chat_id` and complete `sender` object into every message, it emits the +conversation `chat_id` once and stores sender metadata once in `participants`: + +```json +{ + "chat_id": "oc_xxx", + "participants": { + "ou_alice": {"name": "Alice", "sender_type": "user"} + }, + "messages": [ + {"message_id": "om_xxx", "sender_id": "ou_alice", "content": "Hello"} + ] +} +``` + +Migration: resolve a sender name with +`.data as $data | $data.messages[] as $message | ($message.sender.name // $data.participants[$message.sender_id].name)`. +A sender without a stable ID, or an ID whose metadata conflicts within the +response, remains inline as a `sender` object so normalization never drops +information. + ## Pagination (`has_more` / `page_token`) By default, `im +chat-messages-list` fetches one page. It returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue: diff --git a/skills/lark-im/references/lark-im-threads-messages-list.md b/skills/lark-im/references/lark-im-threads-messages-list.md index 8c9549508e..091cb12411 100644 --- a/skills/lark-im/references/lark-im-threads-messages-list.md +++ b/skills/lark-im/references/lark-im-threads-messages-list.md @@ -68,7 +68,30 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe Default is one page. With `--page-all`, `--page-token` sets the starting cursor; if `meta.pagination.complete=false`, resume from `meta.pagination.next_token` or raise `--page-limit`. -### 4. Recommended expansion strategy +### 4. Normalized JSON context + +JSON output emits `thread_id` once at the top level and stores repeated sender +metadata in `participants`, keyed by sender ID. Each message normally carries a +`sender_id` reference instead of a complete `sender` object. For example: + +```json +{ + "thread_id": "omt_xxx", + "participants": { + "ou_alice": {"name": "Alice", "sender_type": "user"} + }, + "messages": [ + {"message_id": "om_xxx", "sender_id": "ou_alice", "content": "Reply"} + ] +} +``` + +Migration: resolve a sender name with +`.data as $data | $data.messages[] as $message | ($message.sender.name // $data.participants[$message.sender_id].name)`. +Sender data remains inline when it cannot be referenced without losing +information. + +### 5. Recommended expansion strategy | Scenario | Recommended Parameters | |------|---------| From 6006361487f87caf10387c2374a732ac0668c3e7 Mon Sep 17 00:00:00 2001 From: "xiaoxiangyu.123" Date: Tue, 1 Sep 2026 11:01:55 +0800 Subject: [PATCH 2/4] fix(im): make normalized message JSON opt-in Co-authored-by: TRAE CLI --- affordance/im.md | 4 +- internal/affordance/im_source_test.go | 7 +- shortcuts/im/im_chat_messages_list.go | 7 +- shortcuts/im/im_threads_messages_list.go | 7 +- shortcuts/im/message_compact.go | 40 ++- shortcuts/im/message_compact_test.go | 310 ++++++++++++++---- .../references/lark-im-chat-messages-list.md | 23 +- .../lark-im-threads-messages-list.md | 16 +- .../im/im_list_page_all_dryrun_test.go | 4 +- tests/cli_e2e/im/im_page_all_live_test.go | 42 +++ 10 files changed, 364 insertions(+), 96 deletions(-) diff --git a/affordance/im.md b/affordance/im.md index 930077552d..ef200ca024 100644 --- a/affordance/im.md +++ b/affordance/im.md @@ -56,7 +56,7 @@ lark-cli im +chat-members-list --chat-id oc_xxx Use this for message history when the conversation is already known. ### Tips -- JSON output stores chat_id once at the top level and sender metadata in participants; resolve each message's sender_id through that map. +- Default JSON preserves inline chat_id and sender fields. Use --json-shape normalized when repeated context size matters; then resolve each sender_id through participants. ### Avoid when - Searching across conversations → use [[+messages-search]]. @@ -231,7 +231,7 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Hello" Use this when a message or thread id is known and the replies inside that thread are needed. ### Tips -- JSON output stores thread_id once at the top level and sender metadata in participants; resolve each message's sender_id through that map. +- Default JSON preserves inline thread_id and sender fields. Use --json-shape normalized when repeated context size matters; then resolve each sender_id through participants. ### Examples diff --git a/internal/affordance/im_source_test.go b/internal/affordance/im_source_test.go index a3713f5bea..f963c4c12d 100644 --- a/internal/affordance/im_source_test.go +++ b/internal/affordance/im_source_test.go @@ -166,15 +166,16 @@ func TestIMAffordanceDoesNotDuplicateRuntimeRecovery(t *testing.T) { } } -func TestIMMessageListAffordanceDocumentsNormalizedJSON(t *testing.T) { +func TestIMMessageListAffordanceDocumentsNormalizedJSONOptIn(t *testing.T) { prev := mdSource t.Cleanup(func() { SetSource(prev) }) SetSource(os.DirFS("../../affordance")) for _, method := range []string{"+chat-messages-list", "+threads-messages-list"} { tips := parsedIMAffordance(t, method).Tips - if !containsItem(tips, "participants") || !containsItem(tips, "sender_id") { - t.Errorf("%s tips must explain normalized participant lookup: %v", method, tips) + if !containsItem(tips, "Default JSON") || !containsItem(tips, "--json-shape normalized") || + !containsItem(tips, "participants") || !containsItem(tips, "sender_id") { + t.Errorf("%s tips must explain the normalized JSON opt-in and participant lookup: %v", method, tips) } } } diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index f8235b57b0..db17a1801b 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -41,6 +41,7 @@ var ImChatMessageList = common.Shortcut{ {Name: "order", Aliases: []string{"sort-order", "sort"}, Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "page-size", Aliases: []string{"limit"}, Default: fmt.Sprintf("%d", chatMessagesListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", chatMessagesListMaxPageSize)}, {Name: "page-token", Desc: "starting pagination cursor"}, + {Name: "json-shape", Default: messageListJSONShapeLegacy, Desc: "JSON data shape; non-JSON formats are unchanged", Enum: []string{messageListJSONShapeLegacy, messageListJSONShapeNormalized}}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, common.PageAllFlags()...), @@ -175,9 +176,9 @@ var ImChatMessageList = common.Shortcut{ } pagination.Items = len(messages) - // Emit: JSON normalizes repeated chat/sender context; human and record - // formats keep the established per-message projection. - outData := messageListOutputData(runtime.Format, runtime.JqExpr, messages, chatId, "", hasMore, nextPageToken) + // Emit: preserve the established envelope by default; normalized JSON is + // an explicit opt-in. Human and record formats retain legacy projection. + outData := messageListOutputData(runtime.Str("json-shape"), runtime.Format, runtime.JqExpr, messages, chatId, "", hasMore, nextPageToken) runtime.OutFormat(outData, &output.Meta{ Pagination: pagination, }, func(w io.Writer) { diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 815e890c85..2a1e3e11fe 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -38,6 +38,7 @@ var ImThreadsMessagesList = common.Shortcut{ {Name: "order", Aliases: []string{"sort"}, Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "page-size", Default: fmt.Sprintf("%d", threadsMessagesListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", threadsMessagesListMaxPageSize)}, {Name: "page-token", Desc: "starting pagination cursor"}, + {Name: "json-shape", Default: messageListJSONShapeLegacy, Desc: "JSON data shape; non-JSON formats are unchanged", Enum: []string{messageListJSONShapeLegacy, messageListJSONShapeNormalized}}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, common.PageAllFlags()...), @@ -147,9 +148,9 @@ var ImThreadsMessagesList = common.Shortcut{ } pagination.Items = len(messages) - // Emit: JSON normalizes repeated thread/sender context; human and record - // formats keep the established per-message projection. - outData := messageListOutputData(runtime.Format, runtime.JqExpr, messages, "", threadId, hasMore, nextPageToken) + // Emit: preserve the established envelope by default; normalized JSON is + // an explicit opt-in. Human and record formats retain legacy projection. + outData := messageListOutputData(runtime.Str("json-shape"), runtime.Format, runtime.JqExpr, messages, "", threadId, hasMore, nextPageToken) runtime.OutFormat(outData, &output.Meta{ Pagination: pagination, }, func(w io.Writer) { diff --git a/shortcuts/im/message_compact.go b/shortcuts/im/message_compact.go index 713b3c2e3a..8378177a9f 100644 --- a/shortcuts/im/message_compact.go +++ b/shortcuts/im/message_compact.go @@ -10,6 +10,7 @@ import ( ) func messageListOutputData( + jsonShape string, runtimeFormat string, jqExpr string, messages []map[string]interface{}, @@ -28,8 +29,15 @@ func messageListOutputData( legacy["thread_id"] = threadID } + // Preserve the established message envelope unless the caller explicitly + // opts into normalization. JQ filters whichever envelope the caller chose. + if jsonShape != messageListJSONShapeNormalized { + return legacy + } + // JQ always filters the JSON envelope. Unknown formats also fall back to - // JSON in Emitter.Success, so both paths must see the normalized shape. + // JSON in Emitter.Success. Record-oriented and human formats keep their + // established per-message shape even when the JSON-only option is present. if jqExpr != "" { return compactMessageListData(messages, chatID, threadID, hasMore, pageToken) } @@ -43,6 +51,11 @@ func messageListOutputData( return legacy } +const ( + messageListJSONShapeLegacy = "legacy" + messageListJSONShapeNormalized = "normalized" +) + // compactMessageListData normalizes repeated conversation metadata for JSON // output. It never mutates messages: the enriched message tree remains the // source for human and record-oriented renderers. @@ -133,14 +146,33 @@ func compactMessage(message map[string]interface{}, chatID string, threadID stri out["sender_id"] = id } } - if replies := messageSlice(out["thread_replies"]); replies != nil { + if replies, ok := compactThreadReplies(out["thread_replies"], chatID, threadID, reusableSenders); ok { + out["thread_replies"] = replies + } + return out +} + +func compactThreadReplies(value interface{}, chatID string, threadID string, reusableSenders map[string]bool) (interface{}, bool) { + switch replies := value.(type) { + case []map[string]interface{}: projected := make([]map[string]interface{}, 0, len(replies)) for _, reply := range replies { projected = append(projected, compactMessage(reply, chatID, threadID, reusableSenders)) } - out["thread_replies"] = projected + return projected, true + case []interface{}: + projected := make([]interface{}, 0, len(replies)) + for _, reply := range replies { + if message, ok := reply.(map[string]interface{}); ok { + projected = append(projected, compactMessage(message, chatID, threadID, reusableSenders)) + continue + } + projected = append(projected, reply) + } + return projected, true + default: + return nil, false } - return out } func commonMessageString(messages []map[string]interface{}, key string) string { diff --git a/shortcuts/im/message_compact_test.go b/shortcuts/im/message_compact_test.go index a82d2de527..f79c048a6e 100644 --- a/shortcuts/im/message_compact_test.go +++ b/shortcuts/im/message_compact_test.go @@ -4,11 +4,15 @@ package im import ( + "bytes" "context" "encoding/json" + "fmt" "net/http" "reflect" "testing" + + "github.com/larksuite/cli/shortcuts/common" ) func TestCompactMessageListDataHoistsRepeatedContext(t *testing.T) { @@ -60,10 +64,15 @@ func TestCompactMessageListDataHoistsRepeatedContext(t *testing.T) { t.Fatalf("reactions were lost: %#v", projected[0]) } - // Projection must not mutate the enriched source used by other formats. + // Projection must not mutate the enriched source used by other formats, + // including nested thread replies. if messages[0]["chat_id"] != "oc_chat" || messages[0]["sender"] == nil { t.Fatalf("source message mutated: %#v", messages[0]) } + sourceReply := messages[0]["thread_replies"].([]map[string]interface{})[0] + if sourceReply["chat_id"] != "oc_chat" || sourceReply["sender"] == nil { + t.Fatalf("source thread reply mutated: %#v", sourceReply) + } } func TestCompactMessageListDataKeepsUnsafeSenderInline(t *testing.T) { @@ -117,28 +126,104 @@ func TestCompactMessageListDataDoesNotHoistMixedChatIDs(t *testing.T) { } } -func TestMessageListOutputDataOnlyCompactsJSON(t *testing.T) { +func TestCompactMessageListDataPreservesMismatchedContextAndReplyValues(t *testing.T) { + messages := []map[string]interface{}{ + { + "message_id": "om_root", "chat_id": "oc_expected", "thread_id": "omt_expected", + "thread_replies": []interface{}{ + map[string]interface{}{"message_id": "om_reply", "chat_id": "oc_other", "thread_id": "omt_other"}, + "opaque-reply-value", + }, + }, + } + + got := compactMessageListData(messages, "oc_expected", "omt_expected", false, "") + root := got["messages"].([]map[string]interface{})[0] + if _, exists := root["chat_id"]; exists { + t.Fatalf("root retained matching chat_id: %#v", root) + } + if _, exists := root["thread_id"]; exists { + t.Fatalf("root retained matching thread_id: %#v", root) + } + replies := root["thread_replies"].([]interface{}) + reply := replies[0].(map[string]interface{}) + if reply["chat_id"] != "oc_other" || reply["thread_id"] != "omt_other" { + t.Fatalf("mismatched reply context was lost: %#v", reply) + } + if replies[1] != "opaque-reply-value" { + t.Fatalf("non-message reply value was lost: %#v", replies) + } +} + +func TestCompactMessageListDataEmptyMessages(t *testing.T) { + got := compactMessageListData(nil, "oc_chat", "", false, "") + if got["chat_id"] != "oc_chat" || got["total"] != 0 { + t.Fatalf("empty normalized output = %#v", got) + } + if messages := got["messages"].([]map[string]interface{}); len(messages) != 0 { + t.Fatalf("empty normalized messages = %#v", messages) + } + if _, exists := got["participants"]; exists { + t.Fatalf("empty output gained participants: %#v", got) + } +} + +func TestCompactMessageListDataDoesNotMutateInput(t *testing.T) { + messages := []map[string]interface{}{ + { + "message_id": "om_root", "chat_id": "oc_chat", + "sender": map[string]interface{}{ + "id": "ou_alice", "name": "Alice", + "sender_i18n_names": map[string]interface{}{"en_us": "Alice"}, + }, + "thread_replies": []interface{}{map[string]interface{}{ + "message_id": "om_reply", "chat_id": "oc_chat", + "sender": map[string]interface{}{"id": "ou_alice", "name": "Alice", "sender_i18n_names": map[string]interface{}{"en_us": "Alice"}}, + }}, + }, + } + before, err := json.Marshal(messages) + if err != nil { + t.Fatal(err) + } + + _ = compactMessageListData(messages, "oc_chat", "", false, "") + + after, err := json.Marshal(messages) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatalf("projection mutated input\nbefore: %s\nafter: %s", before, after) + } +} + +func TestMessageListOutputDataRequiresExplicitNormalizedJSON(t *testing.T) { messages := []map[string]interface{}{{ "message_id": "om_1", "chat_id": "oc_chat", "sender": map[string]interface{}{"id": "ou_alice", "name": "Alice"}, }} for _, tc := range []struct { - name, format, jq string - wantCompact bool + name, shape, format, jq string + wantCompact bool }{ - {name: "default JSON", format: "json", wantCompact: true}, - {name: "case-insensitive JSON", format: "JSON", wantCompact: true}, - {name: "jq envelope", format: "table", jq: ".data", wantCompact: true}, - {name: "unknown falls back to JSON", format: "other", wantCompact: true}, - {name: "uppercase pretty falls back to JSON", format: "Pretty", wantCompact: true}, - {name: "pretty", format: "pretty"}, - {name: "table", format: "table"}, - {name: "csv", format: "csv"}, - {name: "ndjson", format: "ndjson"}, + {name: "omitted shape keeps default JSON legacy", format: "json"}, + {name: "explicit legacy keeps JSON legacy", shape: "legacy", format: "json"}, + {name: "legacy jq filters legacy envelope", shape: "legacy", format: "table", jq: ".data"}, + {name: "legacy unknown format fallback stays legacy", shape: "legacy", format: "other"}, + {name: "normalized JSON", shape: "normalized", format: "json", wantCompact: true}, + {name: "case-insensitive normalized JSON", shape: "normalized", format: "JSON", wantCompact: true}, + {name: "normalized jq filters normalized envelope", shape: "normalized", format: "table", jq: ".data", wantCompact: true}, + {name: "normalized unknown format uses JSON fallback", shape: "normalized", format: "other", wantCompact: true}, + {name: "normalized uppercase pretty uses JSON fallback", shape: "normalized", format: "Pretty", wantCompact: true}, + {name: "normalized pretty keeps legacy projection", shape: "normalized", format: "pretty"}, + {name: "normalized table keeps legacy projection", shape: "normalized", format: "table"}, + {name: "normalized csv keeps legacy projection", shape: "normalized", format: "csv"}, + {name: "normalized ndjson keeps legacy projection", shape: "normalized", format: "ndjson"}, } { t.Run(tc.name, func(t *testing.T) { - got := messageListOutputData(tc.format, tc.jq, messages, "oc_chat", "", false, "") + got := messageListOutputData(tc.shape, tc.format, tc.jq, messages, "oc_chat", "", false, "") message := got["messages"].([]map[string]interface{})[0] _, compact := message["sender_id"] if compact != tc.wantCompact { @@ -156,6 +241,32 @@ func TestMessageListOutputDataOnlyCompactsJSON(t *testing.T) { } } +func TestMessageListJSONShapeFlagContract(t *testing.T) { + for _, shortcut := range []struct { + name string + flags []common.Flag + }{ + {name: "chat messages", flags: ImChatMessageList.Flags}, + {name: "thread messages", flags: ImThreadsMessagesList.Flags}, + } { + t.Run(shortcut.name, func(t *testing.T) { + var shape *common.Flag + for index := range shortcut.flags { + if shortcut.flags[index].Name == "json-shape" { + shape = &shortcut.flags[index] + break + } + } + if shape == nil { + t.Fatal("missing --json-shape flag") + } + if shape.Default != "legacy" || !reflect.DeepEqual(shape.Enum, []string{"legacy", "normalized"}) { + t.Fatalf("--json-shape contract = %#v", shape) + } + }) + } +} + func TestCompactMessageListDataReducesRepeatedJSON(t *testing.T) { sender := map[string]interface{}{ "id": "ou_alice", "id_type": "open_id", "sender_type": "user", @@ -184,7 +295,23 @@ func TestCompactMessageListDataReducesRepeatedJSON(t *testing.T) { } } -func TestChatMessagesListEmitsCompactJSONContract(t *testing.T) { +func TestChatMessagesListPreservesLegacyJSONByDefault(t *testing.T) { + testMessageListCommandJSONShape(t, "chat-messages-list", nil, false) +} + +func TestChatMessagesListEmitsNormalizedJSONWhenRequested(t *testing.T) { + testMessageListCommandJSONShape(t, "chat-messages-list", map[string]string{"json-shape": "normalized"}, true) +} + +func TestThreadsMessagesListPreservesLegacyJSONByDefault(t *testing.T) { + testMessageListCommandJSONShape(t, "threads-messages-list", nil, false) +} + +func TestThreadsMessagesListEmitsNormalizedJSONWhenRequested(t *testing.T) { + testMessageListCommandJSONShape(t, "threads-messages-list", map[string]string{"json-shape": "normalized"}, true) +} + +func TestChatMessagesListJQFiltersSelectedJSONShape(t *testing.T) { var tc listPageAllCase for _, candidate := range listPageAllCases() { if candidate.name == "chat-messages-list" { @@ -192,15 +319,80 @@ func TestChatMessagesListEmitsCompactJSONContract(t *testing.T) { break } } - runtime, calls := newListPageAllRuntime(t, tc, nil, func(req *http.Request, _ int) map[string]interface{} { - if got := req.URL.Query().Get("container_id"); got != "oc_test" { - t.Fatalf("container_id = %q, want oc_test", got) + for _, test := range []struct { + name string + flags map[string]string + normalized bool + }{ + {name: "legacy default"}, + {name: "normalized opt-in", flags: map[string]string{"json-shape": "normalized"}, normalized: true}, + } { + t.Run(test.name, func(t *testing.T) { + runtime, _ := newListPageAllRuntime(t, tc, test.flags, func(_ *http.Request, _ int) map[string]interface{} { + return map[string]interface{}{ + "items": []interface{}{map[string]interface{}{ + "message_id": "om_1", "chat_id": "oc_test", "msg_type": "text", + "sender": map[string]interface{}{"id": "ou_alice", "sender_type": "user", "sender_name": "Alice"}, + "body": map[string]interface{}{"content": `{"text":"hello"}`}, "create_time": "0", + }}, + "has_more": false, "page_token": "", + } + }) + runtime.Format = "table" + runtime.JqExpr = ".data.messages[0]" + + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + var message map[string]interface{} + if err := json.Unmarshal(runtime.IO().Out.(*bytes.Buffer).Bytes(), &message); err != nil { + t.Fatalf("jq output is not a JSON object: %v", err) + } + _, hasSenderID := message["sender_id"] + if hasSenderID != test.normalized { + t.Fatalf("sender_id present = %t, want %t: %#v", hasSenderID, test.normalized, message) + } + if test.normalized { + if _, exists := message["sender"]; exists { + t.Fatalf("normalized jq output retained sender: %#v", message) + } + } else if message["sender"] == nil || message["chat_id"] != "oc_test" { + t.Fatalf("legacy jq output lost inline context: %#v", message) + } + }) + } +} + +func testMessageListCommandJSONShape(t *testing.T, caseName string, flags map[string]string, normalized bool) { + t.Helper() + var tc listPageAllCase + for _, candidate := range listPageAllCases() { + if candidate.name == caseName { + tc = candidate + break + } + } + containerID := "oc_test" + contextKey := "chat_id" + senderID := "ou_alice" + senderName := "Alice" + content := "full content" + if caseName == "threads-messages-list" { + containerID = "omt_test" + contextKey = "thread_id" + senderID = "ou_bob" + senderName = "Bob" + content = "reply" + } + runtime, calls := newListPageAllRuntime(t, tc, flags, func(req *http.Request, _ int) map[string]interface{} { + if got := req.URL.Query().Get("container_id"); got != containerID { + t.Fatalf("container_id = %q, want %s", got, containerID) } return map[string]interface{}{ "items": []interface{}{map[string]interface{}{ - "message_id": "om_1", "msg_type": "text", "chat_id": "oc_test", - "sender": map[string]interface{}{"id": "ou_alice", "sender_type": "user", "sender_name": "Alice"}, - "body": map[string]interface{}{"content": `{"text":"full content"}`}, "create_time": "0", + "message_id": "om_1", "msg_type": "text", contextKey: containerID, + "sender": map[string]interface{}{"id": senderID, "sender_type": "user", "sender_name": senderName}, + "body": map[string]interface{}{"content": fmt.Sprintf(`{"text":%q}`, content)}, "create_time": "0", }}, "has_more": false, "page_token": "final", } @@ -212,59 +404,47 @@ func TestChatMessagesListEmitsCompactJSONContract(t *testing.T) { t.Fatalf("API calls = %d, want 1", *calls) } data := listPageAllOutputData(t, runtime) - if data["chat_id"] != "oc_test" || data["page_token"] != "final" { - t.Fatalf("top-level data = %#v", data) - } - participants := data["participants"].(map[string]interface{}) - alice := participants["ou_alice"].(map[string]interface{}) - if alice["name"] != "Alice" || alice["sender_type"] != "user" { - t.Fatalf("participant = %#v", alice) + if data["page_token"] != "final" { + t.Fatalf("page_token = %#v, want final", data["page_token"]) } message := data["messages"].([]interface{})[0].(map[string]interface{}) - if message["sender_id"] != "ou_alice" || message["content"] != "full content" { + if message["content"] != content { t.Fatalf("message = %#v", message) } - if _, exists := message["sender"]; exists { - t.Fatalf("message retained sender: %#v", message) - } - if _, exists := message["chat_id"]; exists { - t.Fatalf("message retained chat_id: %#v", message) - } -} - -func TestThreadsMessagesListEmitsCompactJSONContract(t *testing.T) { - var tc listPageAllCase - for _, candidate := range listPageAllCases() { - if candidate.name == "threads-messages-list" { - tc = candidate - break + if normalized { + if data[contextKey] != containerID { + t.Fatalf("top-level %s = %#v, want %s", contextKey, data[contextKey], containerID) } - } - runtime, _ := newListPageAllRuntime(t, tc, nil, func(req *http.Request, _ int) map[string]interface{} { - if got := req.URL.Query().Get("container_id"); got != "omt_test" { - t.Fatalf("container_id = %q, want omt_test", got) + participants := data["participants"].(map[string]interface{}) + participant := participants[senderID].(map[string]interface{}) + if participant["name"] != senderName || participant["sender_type"] != "user" { + t.Fatalf("participant = %#v", participant) } - return map[string]interface{}{ - "items": []interface{}{map[string]interface{}{ - "message_id": "om_reply", "thread_id": "omt_test", "msg_type": "text", - "sender": map[string]interface{}{"id": "ou_bob", "sender_type": "user", "sender_name": "Bob"}, - "body": map[string]interface{}{"content": `{"text":"reply"}`}, "create_time": "0", - }}, - "has_more": true, "page_token": "next", + if message["sender_id"] != senderID { + t.Fatalf("sender_id = %#v, want %s", message["sender_id"], senderID) } - }) - if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute() error = %v", err) + if _, exists := message["sender"]; exists { + t.Fatalf("normalized message retained sender: %#v", message) + } + if _, exists := message[contextKey]; exists { + t.Fatalf("normalized message retained %s: %#v", contextKey, message) + } + return } - data := listPageAllOutputData(t, runtime) - if data["thread_id"] != "omt_test" || data["has_more"] != true || data["page_token"] != "next" { - t.Fatalf("top-level data = %#v", data) + if caseName == "chat-messages-list" { + if _, exists := data[contextKey]; exists { + t.Fatalf("legacy chat output gained top-level %s: %#v", contextKey, data) + } + } else if data[contextKey] != containerID { + t.Fatalf("legacy thread output lost top-level %s: %#v", contextKey, data) } - message := data["messages"].([]interface{})[0].(map[string]interface{}) - if message["sender_id"] != "ou_bob" || message["content"] != "reply" { - t.Fatalf("message = %#v", message) + if message[contextKey] != containerID || message["sender"] == nil { + t.Fatalf("legacy message lost inline context: %#v", message) + } + if _, exists := message["sender_id"]; exists { + t.Fatalf("legacy message gained sender_id: %#v", message) } - if _, exists := message["thread_id"]; exists { - t.Fatalf("message retained repeated thread_id: %#v", message) + if _, exists := data["participants"]; exists { + t.Fatalf("legacy output gained participants: %#v", data) } } diff --git a/skills/lark-im/references/lark-im-chat-messages-list.md b/skills/lark-im/references/lark-im-chat-messages-list.md index 0d0bfd47c3..0801edaf67 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -34,6 +34,9 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --page-all # JSON output lark-cli im +chat-messages-list --chat-id oc_xxx --format json + +# JSON with repeated conversation and sender context normalized +lark-cli im +chat-messages-list --chat-id oc_xxx --json-shape normalized ``` ## Parameters @@ -47,6 +50,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json | `--order ` | No | Sort order: `asc` / `desc` (default `desc`) | | `--page-size ` | No | Page size (default 50, max 50) | | `--page-token ` | No | Starting cursor, normally returned by a previous response | +| `--json-shape ` | No | JSON data shape: `legacy` (default) preserves the established inline fields; `normalized` hoists repeated context | | `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` | | `--page-limit ` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) | | `--no-reactions` | No | Skip auto-fetching the `reactions` block | @@ -90,8 +94,6 @@ lark-cli im +threads-messages-list --thread omt_xxx | Field | Description | |------|------| -| `chat_id` | Conversation ID shared by every returned message | -| `participants` | Sender metadata keyed by sender ID; messages refer to entries through `sender_id` | | `messages` | Message array | | `total` | Number of messages in the current page | | `has_more` | Whether additional pages are available | @@ -104,17 +106,19 @@ Each message contains: | `message_id` | Message ID | | `msg_type` | Message type: `text`, `image`, `file`, `interactive`, `post`, `audio`, `video`, `system`, etc. | | `create_time` | Creation time | -| `sender_id` | Sender ID referencing `participants[sender_id]`; present when sender metadata can be normalized safely | -| `sender` | Inline sender fallback for system/anonymous senders or conflicting metadata | +| `chat_id` | Conversation ID | +| `sender` | Sender information (includes `name` for user senders) | | `content` | Message content | | `deleted` | Whether the message has been recalled (always present, `true` = recalled) | | `updated` | Whether the message has been edited after sending | | `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions | | `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist | -The JSON response normalizes repeated conversation context. Instead of copying -the same `chat_id` and complete `sender` object into every message, it emits the -conversation `chat_id` once and stores sender metadata once in `participants`: +### Optional normalized JSON context + +Default JSON preserves the established per-message `chat_id` and `sender` +fields. Pass `--json-shape normalized` to emit the conversation `chat_id` once +and store reusable sender metadata in top-level `participants`: ```json { @@ -128,11 +132,12 @@ conversation `chat_id` once and stores sender metadata once in `participants`: } ``` -Migration: resolve a sender name with +With normalized output, resolve a sender name with `.data as $data | $data.messages[] as $message | ($message.sender.name // $data.participants[$message.sender_id].name)`. A sender without a stable ID, or an ID whose metadata conflicts within the response, remains inline as a `sender` object so normalization never drops -information. +information. `--jq` filters the selected shape; `pretty`, `table`, `csv`, and +`ndjson` retain their established projections. ## Pagination (`has_more` / `page_token`) diff --git a/skills/lark-im/references/lark-im-threads-messages-list.md b/skills/lark-im/references/lark-im-threads-messages-list.md index 091cb12411..5e36ba88a2 100644 --- a/skills/lark-im/references/lark-im-threads-messages-list.md +++ b/skills/lark-im/references/lark-im-threads-messages-list.md @@ -31,6 +31,9 @@ lark-cli im +threads-messages-list --thread omt_xxx --format pretty lark-cli im +threads-messages-list --thread omt_xxx --format table lark-cli im +threads-messages-list --thread omt_xxx --format csv +# JSON with repeated thread and sender context normalized +lark-cli im +threads-messages-list --thread omt_xxx --json-shape normalized + # View as a bot lark-cli im +threads-messages-list --thread omt_xxx --as bot @@ -48,6 +51,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run | `--order ` | No | Sort order: `asc` (default) / `desc` | | `--page-size ` | No | Number of items per page (default 50, range 1-50) | | `--page-token ` | No | Starting cursor, normally returned by a previous response | +| `--json-shape ` | No | JSON data shape: `legacy` (default) preserves established inline fields; `normalized` hoists repeated context | | `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` | | `--page-limit ` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) | | `--format ` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` | @@ -70,9 +74,10 @@ Default is one page. With `--page-all`, `--page-token` sets the starting cursor; ### 4. Normalized JSON context -JSON output emits `thread_id` once at the top level and stores repeated sender -metadata in `participants`, keyed by sender ID. Each message normally carries a -`sender_id` reference instead of a complete `sender` object. For example: +Default JSON preserves the established per-message `thread_id` and `sender` +fields. Pass `--json-shape normalized` to store repeated sender metadata in +top-level `participants`, keyed by sender ID, and use `sender_id` references in +messages. The existing top-level `thread_id` remains the shared context: ```json { @@ -86,10 +91,11 @@ metadata in `participants`, keyed by sender ID. Each message normally carries a } ``` -Migration: resolve a sender name with +With normalized output, resolve a sender name with `.data as $data | $data.messages[] as $message | ($message.sender.name // $data.participants[$message.sender_id].name)`. Sender data remains inline when it cannot be referenced without losing -information. +information. `--jq` filters the selected shape; `pretty`, `table`, `csv`, and +`ndjson` retain their established projections. ### 5. Recommended expansion strategy diff --git a/tests/cli_e2e/im/im_list_page_all_dryrun_test.go b/tests/cli_e2e/im/im_list_page_all_dryrun_test.go index 20a2a478ba..acba3b303d 100644 --- a/tests/cli_e2e/im/im_list_page_all_dryrun_test.go +++ b/tests/cli_e2e/im/im_list_page_all_dryrun_test.go @@ -30,13 +30,13 @@ func TestIM_ListPageAllDryRun(t *testing.T) { }{ { name: "chat-messages-list", - args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun"}, + args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun", "--json-shape", "normalized"}, method: http.MethodGet, path: "/open-apis/im/v1/messages", }, { name: "threads-messages-list", - args: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun"}, + args: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun", "--json-shape", "normalized"}, method: http.MethodGet, path: "/open-apis/im/v1/messages", }, diff --git a/tests/cli_e2e/im/im_page_all_live_test.go b/tests/cli_e2e/im/im_page_all_live_test.go index 900ffdbd3b..83ef1ed0fb 100644 --- a/tests/cli_e2e/im/im_page_all_live_test.go +++ b/tests/cli_e2e/im/im_page_all_live_test.go @@ -101,11 +101,33 @@ func TestIM_PageAllLiveWorkflow(t *testing.T) { require.GreaterOrEqual(t, gjson.Get(result.Stdout, "meta.pagination.pages").Int(), int64(3)) require.Equal(t, gjson.Get(result.Stdout, "data.messages.#").Int(), gjson.Get(result.Stdout, "meta.pagination.items").Int()) + require.False(t, gjson.Get(result.Stdout, "data.participants").Exists(), + "default JSON must preserve the legacy envelope") + require.True(t, gjson.Get(result.Stdout, "data.messages.0.chat_id").Exists(), + "default JSON must preserve per-message chat_id") + require.True(t, gjson.Get(result.Stdout, "data.messages.0.sender").Exists(), + "default JSON must preserve per-message sender") for _, text := range texts { require.Contains(t, result.Stdout, text, "merged result must contain every sent message") } }) + t.Run("chat-messages-list normalizes JSON only when requested", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-messages-list", "--chat-id", chatID, + "--page-size", "3", "--json-shape", "normalized"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String()) + require.NotEmpty(t, gjson.Get(result.Stdout, "data.participants").Map()) + require.NotEmpty(t, gjson.Get(result.Stdout, "data.messages.0.sender_id").String()) + require.False(t, gjson.Get(result.Stdout, "data.messages.0.chat_id").Exists()) + require.False(t, gjson.Get(result.Stdout, "data.messages.0.sender").Exists()) + }) + t.Run("threads-messages-list walks a real thread", func(t *testing.T) { for i := 1; i <= 2; i++ { reply, err := clie2e.RunCmd(ctx, clie2e.Request{ @@ -143,6 +165,26 @@ func TestIM_PageAllLiveWorkflow(t *testing.T) { require.GreaterOrEqual(t, gjson.Get(result.Stdout, "meta.pagination.pages").Int(), int64(2)) require.Equal(t, gjson.Get(result.Stdout, "data.messages.#").Int(), gjson.Get(result.Stdout, "meta.pagination.items").Int()) + require.False(t, gjson.Get(result.Stdout, "data.participants").Exists(), + "default JSON must preserve the legacy envelope") + require.True(t, gjson.Get(result.Stdout, "data.messages.0.thread_id").Exists(), + "default JSON must preserve per-message thread_id") + require.True(t, gjson.Get(result.Stdout, "data.messages.0.sender").Exists(), + "default JSON must preserve per-message sender") + + normalized, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+threads-messages-list", "--thread", parentMessageID, + "--page-size", "2", "--json-shape", "normalized"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + normalized.AssertExitCode(t, 0) + normalized.AssertStdoutStatus(t, true) + require.NotEmpty(t, gjson.Get(normalized.Stdout, "data.thread_id").String()) + require.NotEmpty(t, gjson.Get(normalized.Stdout, "data.participants").Map()) + require.NotEmpty(t, gjson.Get(normalized.Stdout, "data.messages.0.sender_id").String()) + require.False(t, gjson.Get(normalized.Stdout, "data.messages.0.thread_id").Exists()) + require.False(t, gjson.Get(normalized.Stdout, "data.messages.0.sender").Exists()) }) t.Run("chat-list paginates across chats", func(t *testing.T) { From 69690e49ea651f595e897a7fbc6ee73cb0e4db21 Mon Sep 17 00:00:00 2001 From: "xiaoxiangyu.123" Date: Tue, 1 Sep 2026 11:11:15 +0800 Subject: [PATCH 3/4] fix(im): guard normalized context projection Co-authored-by: TRAE CLI --- internal/affordance/im_source_test.go | 16 ++++++++++++---- shortcuts/im/message_compact.go | 9 +++++++-- shortcuts/im/message_compact_test.go | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/internal/affordance/im_source_test.go b/internal/affordance/im_source_test.go index f963c4c12d..55ba212cae 100644 --- a/internal/affordance/im_source_test.go +++ b/internal/affordance/im_source_test.go @@ -169,13 +169,21 @@ func TestIMAffordanceDoesNotDuplicateRuntimeRecovery(t *testing.T) { func TestIMMessageListAffordanceDocumentsNormalizedJSONOptIn(t *testing.T) { prev := mdSource t.Cleanup(func() { SetSource(prev) }) - SetSource(os.DirFS("../../affordance")) + // SetSource requires fs.FS; this bounded repository-local fixture is test-only. + SetSource(os.DirFS("../../affordance")) //nolint:forbidigo - for _, method := range []string{"+chat-messages-list", "+threads-messages-list"} { - tips := parsedIMAffordance(t, method).Tips + for _, tc := range []struct { + method string + context string + }{ + {method: "+chat-messages-list", context: "chat_id"}, + {method: "+threads-messages-list", context: "thread_id"}, + } { + tips := parsedIMAffordance(t, tc.method).Tips if !containsItem(tips, "Default JSON") || !containsItem(tips, "--json-shape normalized") || + !containsItem(tips, tc.context) || !containsItem(tips, "participants") || !containsItem(tips, "sender_id") { - t.Errorf("%s tips must explain the normalized JSON opt-in and participant lookup: %v", method, tips) + t.Errorf("%s tips must explain the normalized JSON opt-in and %s context: %v", tc.method, tc.context, tips) } } } diff --git a/shortcuts/im/message_compact.go b/shortcuts/im/message_compact.go index 8378177a9f..b066646eb6 100644 --- a/shortcuts/im/message_compact.go +++ b/shortcuts/im/message_compact.go @@ -179,8 +179,13 @@ func commonMessageString(messages []map[string]interface{}, key string) string { common := "" conflict := false walkMessageTree(messages, func(message map[string]interface{}) { - value, _ := message[key].(string) - if value == "" || conflict { + if conflict { + return + } + value, ok := message[key].(string) + if !ok || value == "" { + common = "" + conflict = true return } if common == "" { diff --git a/shortcuts/im/message_compact_test.go b/shortcuts/im/message_compact_test.go index f79c048a6e..e212af1d9d 100644 --- a/shortcuts/im/message_compact_test.go +++ b/shortcuts/im/message_compact_test.go @@ -126,6 +126,27 @@ func TestCompactMessageListDataDoesNotHoistMixedChatIDs(t *testing.T) { } } +func TestCompactMessageListDataDoesNotHoistPartiallyPresentChatID(t *testing.T) { + for _, missingValue := range []interface{}{nil, 42} { + messages := []map[string]interface{}{ + {"message_id": "om_1", "chat_id": "oc_a"}, + {"message_id": "om_2", "chat_id": missingValue}, + } + if missingValue == nil { + delete(messages[1], "chat_id") + } + + got := compactMessageListData(messages, "", "omt_thread", false, "") + if _, exists := got["chat_id"]; exists { + t.Fatalf("partially present chat_id was hoisted for %#v: %#v", missingValue, got) + } + projected := got["messages"].([]map[string]interface{}) + if projected[0]["chat_id"] != "oc_a" { + t.Fatalf("known chat_id was lost for %#v: %#v", missingValue, projected) + } + } +} + func TestCompactMessageListDataPreservesMismatchedContextAndReplyValues(t *testing.T) { messages := []map[string]interface{}{ { From 82539220855e5a476b02050fd82c0321e6d757c8 Mon Sep 17 00:00:00 2001 From: "xiaoxiangyu.123" Date: Tue, 1 Sep 2026 11:20:47 +0800 Subject: [PATCH 4/4] test(im): remove unused lint waiver Co-authored-by: TRAE CLI --- internal/affordance/im_source_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/affordance/im_source_test.go b/internal/affordance/im_source_test.go index 55ba212cae..25e6990d37 100644 --- a/internal/affordance/im_source_test.go +++ b/internal/affordance/im_source_test.go @@ -169,8 +169,7 @@ func TestIMAffordanceDoesNotDuplicateRuntimeRecovery(t *testing.T) { func TestIMMessageListAffordanceDocumentsNormalizedJSONOptIn(t *testing.T) { prev := mdSource t.Cleanup(func() { SetSource(prev) }) - // SetSource requires fs.FS; this bounded repository-local fixture is test-only. - SetSource(os.DirFS("../../affordance")) //nolint:forbidigo + SetSource(os.DirFS("../../affordance")) for _, tc := range []struct { method string