Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions shortcuts/im/convert_lib/content_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,20 @@
}

// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
// update_time is only meaningful when the message was actually edited;
// the server echoes update_time == create_time for unedited messages, which
// would otherwise make every output look "updated" to downstream consumers.
if updated {
if v, ok := m["update_time"]; ok && v != nil {
if s, isStr := v.(string); isStr {
if strings.TrimSpace(s) != "" {
msg["update_time"] = common.FormatTime(s)
}
} else {
msg["update_time"] = common.FormatTime(v)

Check warning on line 168 in shortcuts/im/convert_lib/content_convert.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/content_convert.go#L167-L168

Added lines #L167 - L168 were not covered by tests
}
}
}
if v, ok := m["chat_id"]; ok {
msg["chat_id"] = v
}
Expand Down
55 changes: 55 additions & 0 deletions shortcuts/im/convert_lib/content_media_misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,61 @@ func TestFormatMessageItem(t *testing.T) {
}
}

func TestFormatMessageItem_UpdateTime_Present(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_edit",
"updated": true,
"create_time": "1710500000",
"update_time": "1710600000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"edited"}`},
}

got := FormatMessageItem(raw, nil)
want := common.FormatTime("1710600000")
if got["update_time"] != want {
t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want)
}
}

func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_no_edit",
"updated": false,
"create_time": "1710500000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}

got := FormatMessageItem(raw, nil)
if _, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"])
}
}

// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even
// for unedited messages, server returns update_time == create_time. We must
// NOT echo it through, otherwise every message looks "edited" to consumers.
// Gate the output on updated==true.
func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_unchanged",
"updated": false,
"create_time": "1710500000",
"update_time": "1710500000", // server echoes create_time
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}

got := FormatMessageItem(raw, nil)
if v, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v)
}
}

func TestResolveAppLinkDomain(t *testing.T) {
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)
Expand Down
207 changes: 207 additions & 0 deletions shortcuts/im/convert_lib/reactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package convertlib

import (
"fmt"
"net/http"

"github.com/larksuite/cli/shortcuts/common"
)

// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[]
// length for POST /im/v1/messages/reactions/batch_query (see
// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs).
const reactionsBatchQueryMaxQueries = 20

// EnrichReactions enriches messages with their reactions by calling the
// im.reactions.batch_query API. Messages are modified in place: each message
// that the server returns reactions for gets a "reactions" map attached.
//
// Failure modes (warning to stderr + skip; never aborts main message output):
// - batch_query call fails (network, 5xx, scope insufficient, rate limited):
// each message in the failed batch is marked with "reactions_error": true
// so callers can distinguish "fetch failed" from "no reactions exist".
// - batch_query returns a partial result: only messages the server failed on
// get "reactions_error": true; the successful ones get the reactions block.
//
// The "reactions_error" flag mirrors the "thread_replies_error" pattern in
// thread.go so downstream consumers handle both enrichment failures uniformly.
//
// Output shape (only on messages that the server actually returned data for):
//
// "reactions": {
// "counts": [{"reaction_type": "SMILE", "count": 3}],
// "details": [{"reaction_id": "...", "emoji_type": "SMILE",
// "operator": {...}, "action_time": "..."}]
// }
//
// The server caps queries[] at 20 per call, so messages are split into
// batches of size <= 20 before invoking the API.
func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) {
if len(messages) == 0 {
return
}

// Index messages by ID so we can merge reactions back later.
// A single message_id may appear more than once (e.g. mget --message-ids
// om_a,om_a); every occurrence must receive the reactions block, but the
// API should only be queried once per distinct id.
// Walks into msg["thread_replies"] recursively so replies attached by
// ExpandThreadReplies are enriched in the same batched call as their parent.
idIndex := make(map[string][]map[string]interface{}, len(messages))
var ids []string
collectMessageNodes(messages, idIndex, &ids)
if len(ids) == 0 {
return

Check warning on line 57 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L57

Added line #L57 was not covered by tests
}

for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries {
end := i + reactionsBatchQueryMaxQueries
if end > len(ids) {
end = len(ids)
}
fetchReactionsBatch(runtime, ids[i:end], idIndex)
}
}

// collectMessageNodes walks messages (and any nested thread_replies) and
// records each map under its message_id. Distinct ids are appended to *ids in
// first-seen order so the API is queried at most once per id.
func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) {
for _, msg := range messages {
if id, _ := msg["message_id"].(string); id != "" {
if _, seen := idIndex[id]; !seen {
*ids = append(*ids, id)
}
idIndex[id] = append(idIndex[id], msg)
}
// thread_replies may arrive as a typed slice (set by ExpandThreadReplies)
// or as []interface{} (e.g. when produced via JSON round-trip).
switch nested := msg["thread_replies"].(type) {
case []map[string]interface{}:
collectMessageNodes(nested, idIndex, ids)
case []interface{}:
typed := make([]map[string]interface{}, 0, len(nested))
for _, raw := range nested {
if m, ok := raw.(map[string]interface{}); ok {
typed = append(typed, m)

Check warning on line 89 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L85-L89

Added lines #L85 - L89 were not covered by tests
}
}
collectMessageNodes(typed, idIndex, ids)

Check warning on line 92 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L92

Added line #L92 was not covered by tests
}
}
}

// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs
// and merges the results into idIndex. Failures are logged to stderr without
// aborting subsequent batches.
func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}) {
queries := make([]map[string]interface{}, 0, len(batchIDs))
for _, id := range batchIDs {
queries = append(queries, map[string]interface{}{"message_id": id})
}

data, err := runtime.DoAPIJSON(http.MethodPost,
"/open-apis/im/v1/messages/reactions/batch_query",
nil,
map[string]interface{}{"queries": queries},
)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
markReactionsError(batchIDs, idIndex)
return
}

countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"])
detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"])

// Attach the merged reactions block to every message that had any data.
// Each id may map to >1 message map (duplicate input), so iterate the slice.
for _, id := range batchIDs {
msgs := idIndex[id]
if len(msgs) == 0 {
continue

Check warning on line 125 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L125

Added line #L125 was not covered by tests
}
counts := countsByMsg[id]
details := detailsByMsg[id]
if len(counts) == 0 && len(details) == 0 {
continue
}
block := make(map[string]interface{}, 2)
if len(counts) > 0 {
block["counts"] = counts
}
if len(details) > 0 {
block["details"] = details
}
for _, msg := range msgs {
msg["reactions"] = block
}
}

// Surface per-message failures from the API response.
if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 {
var failedIDs []string
for _, raw := range fails {
item, _ := raw.(map[string]interface{})
if id, _ := item["message_id"].(string); id != "" {
failedIDs = append(failedIDs, id)
}
}
if len(failedIDs) > 0 {
fmt.Fprintf(runtime.IO().ErrOut,
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
len(failedIDs), failedIDs)
markReactionsError(failedIDs, idIndex)
}
}
}

// markReactionsError flags every message map indexed under the given ids with
// reactions_error=true, so downstream consumers can distinguish "fetch failed"
// from "no reactions exist" by reading stdout alone.
func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) {
for _, id := range ids {
for _, msg := range idIndex[id] {
msg["reactions_error"] = true
}
}
}

func groupReactionCounts(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue

Check warning on line 180 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L180

Added line #L180 was not covered by tests
}
entries, _ := row["reaction_count"].([]interface{})
if len(entries) == 0 {
continue

Check warning on line 184 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L184

Added line #L184 was not covered by tests
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}

func groupReactionDetails(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue

Check warning on line 198 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L198

Added line #L198 was not covered by tests
}
entries, _ := row["message_reaction_items"].([]interface{})
if len(entries) == 0 {
continue

Check warning on line 202 in shortcuts/im/convert_lib/reactions.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/reactions.go#L202

Added line #L202 was not covered by tests
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}
Loading
Loading