Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
- 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]].
- 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
- 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

**List replies in a thread**
Expand Down
22 changes: 22 additions & 0 deletions internal/affordance/im_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,28 @@
}
}

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

Check failure on line 173 in internal/affordance/im_source_test.go

View workflow job for this annotation

GitHub Actions / lint

directive `//nolint:forbidigo` is unused for linter "forbidigo" (nolintlint)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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 %s context: %v", tc.method, tc.context, tips)
}
}
}

func TestIMAffordancePreservesOutboundAndDeleteIntentBoundaries(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) })
Expand Down
12 changes: 4 additions & 8 deletions shortcuts/im/im_chat_messages_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()...),
Expand Down Expand Up @@ -175,14 +176,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: 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) {
Expand Down
13 changes: 4 additions & 9 deletions shortcuts/im/im_threads_messages_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()...),
Expand Down Expand Up @@ -147,15 +148,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: 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) {
Expand Down
233 changes: 233 additions & 0 deletions shortcuts/im/message_compact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package im

import (
"reflect"

"github.com/larksuite/cli/internal/output"
)

func messageListOutputData(
jsonShape string,
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
}

// 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. 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)
}
if runtimeFormat == "pretty" {
return legacy
}
format, known := output.ParseFormat(runtimeFormat)
if !known || format == output.FormatJSON {
return compactMessageListData(messages, chatID, threadID, hasMore, pageToken)
}
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.
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, 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))
}
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
}
}

func commonMessageString(messages []map[string]interface{}, key string) string {
common := ""
conflict := false
walkMessageTree(messages, func(message map[string]interface{}) {
if conflict {
return
}
value, ok := message[key].(string)
if !ok || value == "" {
common = ""
conflict = true
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
}
Loading
Loading