Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions affordance/im.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]].
Expand Down Expand Up @@ -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**
Expand Down
13 changes: 13 additions & 0 deletions internal/affordance/im_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
}

func TestIMAffordancePreservesOutboundAndDeleteIntentBoundaries(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) })
Expand Down
11 changes: 3 additions & 8 deletions shortcuts/im/im_chat_messages_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 3 additions & 9 deletions shortcuts/im/im_threads_messages_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
196 changes: 196 additions & 0 deletions shortcuts/im/message_compact.go
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
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)

Check warning on line 181 in shortcuts/im/message_compact.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/message_compact.go#L177-L181

Added lines #L177 - L181 were not covered by tests
}
}
return out

Check warning on line 184 in shortcuts/im/message_compact.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/message_compact.go#L184

Added line #L184 was not covered by tests
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
}
Loading
Loading