diff --git a/affordance/im.md b/affordance/im.md index c0f5f3cb8c..777815edc2 100644 --- a/affordance/im.md +++ b/affordance/im.md @@ -59,11 +59,14 @@ Use this for message history when the conversation is already known. - Searching across conversations → use [[+messages-search]]. - Fetching full details for known message ids → use [[+messages-mget]]. +### Tips +- Use `--format pretty` to read returned messages and expanded replies. Continue with `--page-token` or `--page-all` for more outer messages; use `+threads-messages-list --page-all` for a complete thread. Use JSON for machine processing or transport fields. + ### Examples -**List messages in a group chat** +**Read a group conversation with full bodies and thread replies** ```bash -lark-cli im +chat-messages-list --chat-id oc_xxx +lark-cli im +chat-messages-list --chat-id oc_xxx --format pretty ``` ### Skills diff --git a/internal/affordance/im_source_test.go b/internal/affordance/im_source_test.go index 0787a9fb37..94df0bffa9 100644 --- a/internal/affordance/im_source_test.go +++ b/internal/affordance/im_source_test.go @@ -26,7 +26,7 @@ var imAffordanceExamples = []imAffordanceExample{ {method: "+chat-create", command: `lark-cli im +chat-create --name "My Group"`, source: "lark-im/references/lark-im-chat-create.md"}, {method: "+chat-list", command: "lark-cli im +chat-list", source: "lark-im/references/lark-im-chat-list.md"}, {method: "+chat-members-list", command: "lark-cli im +chat-members-list --chat-id oc_xxx", source: "lark-im/references/lark-im-chat-members-list.md"}, - {method: "+chat-messages-list", command: "lark-cli im +chat-messages-list --chat-id oc_xxx", source: "lark-im/references/lark-im-chat-messages-list.md"}, + {method: "+chat-messages-list", command: "lark-cli im +chat-messages-list --chat-id oc_xxx --format pretty", source: "lark-im/references/lark-im-chat-messages-list.md"}, {method: "+chat-search", command: `lark-cli im +chat-search --query "project"`, source: "lark-im/references/lark-im-chat-search.md"}, {method: "+chat-update", command: `lark-cli im +chat-update --chat-id oc_xxx --name "New Group Name"`, source: "lark-im/references/lark-im-chat-update.md"}, {method: "+messages-mget", command: "lark-cli im +messages-mget --message-ids om_xxx", source: "lark-im/references/lark-im-messages-mget.md"}, diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 8e3114216f..d8eab598d5 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -183,31 +183,18 @@ var ImChatMessageList = common.Shortcut{ "has_more": hasMore, "page_token": nextPageToken, } - runtime.OutFormat(outData, &output.Meta{ + emitMeta := &output.Meta{ Pagination: pagination, - }, func(w io.Writer) { - if len(messages) == 0 { - fmt.Fprintln(w, "No messages in this time range.") - return - } - var rows []map[string]interface{} - for _, msg := range messages { - row := map[string]interface{}{ - "time": msg["create_time"], - "type": msg["msg_type"], - } - if sender, ok := msg["sender"].(map[string]interface{}); ok { - if disp := senderDisplay(sender); disp != "" { - row["sender"] = disp - } - } - if content, _ := msg["content"].(string); content != "" { - row["content"] = convertlib.TruncateContent(content, 40) - } - rows = append(rows, row) - } - output.PrintTable(w, rows) - fmt.Fprintf(w, "\n%d message(s)\ntip: use --format json to view full message content\n", len(messages)) + } + // The conversation renderer owns its footer because it reports both + // outer messages and visible thread replies. Suppress the framework's + // second pagination summary only for an actual pretty render; JSON/jq, + // table, CSV and NDJSON keep their established metadata contracts. + if runtime.Format == "pretty" && runtime.JqExpr == "" { + emitMeta = nil + } + runtime.OutFormat(outData, emitMeta, func(w io.Writer) { + renderChatMessagesPretty(w, messages, hasMore, nextPageToken) }) return nil }, diff --git a/shortcuts/im/im_chat_messages_pretty.go b/shortcuts/im/im_chat_messages_pretty.go new file mode 100644 index 0000000000..2d4f32596c --- /dev/null +++ b/shortcuts/im/im_chat_messages_pretty.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "fmt" + "io" + "math" + "strconv" + "strings" +) + +const chatMessagePrettyDivider = "────────────────────────" + +// renderChatMessagesPretty renders the already-projected message data as a +// readable conversation transcript. It deliberately consumes only the public +// shortcut projection: OpenAPI transport fields stay available in JSON but do +// not leak into the human view. +func renderChatMessagesPretty(w io.Writer, messages []map[string]interface{}, hasMore bool, pageToken string) { + if len(messages) == 0 { + fmt.Fprintln(w, "No messages in this time range.") + fmt.Fprintln(w) + writeChatMessagesPrettyFooter(w, 0, 0, hasMore, pageToken) + return + } + + visibleMessages := make([]map[string]interface{}, 0, len(messages)) + seenMessageIDs := make(map[string]struct{}, len(messages)) + for _, msg := range messages { + id, _ := msg["message_id"].(string) + if id != "" { + if _, duplicate := seenMessageIDs[id]; duplicate { + continue + } + seenMessageIDs[id] = struct{}{} + } + visibleMessages = append(visibleMessages, msg) + } + + threadReplyCount := 0 + for i, msg := range visibleMessages { + if i > 0 { + fmt.Fprintln(w) + } + renderChatMessagePretty(w, msg) + threadReplyCount += renderChatMessageRepliesPretty(w, msg) + fmt.Fprintln(w, chatMessagePrettyDivider) + } + + fmt.Fprintln(w) + writeChatMessagesPrettyFooter(w, len(visibleMessages), threadReplyCount, hasMore, pageToken) +} + +func renderChatMessagePretty(w io.Writer, msg map[string]interface{}) { + createTime := prettyMessageTime(msg) + sender := prettyMessageSender(msg) + fmt.Fprintf(w, "%s · %s\n", createTime, sender) + writePrettyQuotedContent(w, prettyMessageContent(msg), "") + + metadata := prettyMessageMetadata(msg) + if len(metadata) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, strings.Join(metadata, " · ")) + } + if reactions := prettyReactionSummary(msg); reactions != "" { + fmt.Fprintf(w, "表情:%s\n", reactions) + } +} + +func renderChatMessageRepliesPretty(w io.Writer, root map[string]interface{}) int { + rootID, _ := root["message_id"].(string) + threadID, _ := root["thread_id"].(string) + rawReplies, repliesPresent := root["thread_replies"] + replies := prettyMessageMaps(rawReplies) + + seen := make(map[string]struct{}, len(replies)+1) + if rootID != "" { + seen[rootID] = struct{}{} + } + visible := make([]map[string]interface{}, 0, len(replies)) + for _, reply := range replies { + id, _ := reply["message_id"].(string) + if id != "" { + if _, duplicate := seen[id]; duplicate { + continue + } + seen[id] = struct{}{} + } + visible = append(visible, reply) + } + + for _, reply := range visible { + fmt.Fprintln(w) + fmt.Fprintf(w, " ↳ %s · %s\n", prettyReplyTime(prettyMessageTime(root), prettyMessageTime(reply)), prettyMessageSender(reply)) + writePrettyQuotedContent(w, prettyMessageContent(reply), " ") + if reactions := prettyReactionSummary(reply); reactions != "" { + fmt.Fprintf(w, " 表情:%s\n", reactions) + } + } + + threadRepliesError, _ := root["thread_replies_error"].(bool) + threadHasMore, _ := root["thread_has_more"].(bool) + if threadRepliesError { + fmt.Fprintln(w) + fmt.Fprintf(w, " ↳ 话题回复获取失败 · thread: %s\n", prettyID(threadID)) + } else if threadID != "" && !repliesPresent { + // A successful thread fetch includes at least its root message. An + // absent field therefore normally means the cross-thread expansion + // budget was exhausted; keep the limitation visible in pretty output. + fmt.Fprintln(w) + fmt.Fprintf(w, " ↳ 话题回复未展开 · thread: %s\n", threadID) + } + if threadHasMore { + fmt.Fprintln(w) + fmt.Fprintf(w, " ↳ 还有更多话题回复 · thread: %s\n", prettyID(threadID)) + } + + return len(visible) +} + +func prettyMessageMetadata(msg map[string]interface{}) []string { + parts := make([]string, 0, 3) + if id, _ := msg["message_id"].(string); id != "" { + parts = append(parts, "message: "+id) + } + if threadID, _ := msg["thread_id"].(string); threadID != "" { + parts = append(parts, "thread: "+threadID) + } + if replyTo, _ := msg["reply_to"].(string); replyTo != "" { + parts = append(parts, "reply_to: "+replyTo) + } + return parts +} + +func prettyMessageSender(msg map[string]interface{}) string { + if sender, ok := msg["sender"].(map[string]interface{}); ok { + if display := senderDisplay(sender); display != "" { + return display + } + if senderType, _ := sender["sender_type"].(string); senderType != "" { + return senderType + } + } + if msgType, _ := msg["msg_type"].(string); msgType == "system" { + return "系统" + } + return "未知发送者" +} + +func prettyMessageTime(msg map[string]interface{}) string { + if createTime, _ := msg["create_time"].(string); createTime != "" { + return createTime + } + return "未知时间" +} + +func prettyReplyTime(rootTime, replyTime string) string { + rootDate, rootClock, rootOK := strings.Cut(rootTime, " ") + replyDate, replyClock, replyOK := strings.Cut(replyTime, " ") + if rootOK && replyOK && rootDate == replyDate && rootClock != "" && replyClock != "" { + return replyClock + } + return replyTime +} + +func prettyMessageContent(msg map[string]interface{}) string { + if deleted, _ := msg["deleted"].(bool); deleted { + return "已撤回" + } + if content, _ := msg["content"].(string); content != "" { + return content + } + if msgType, _ := msg["msg_type"].(string); msgType != "" { + if msgType == "text" || msgType == "post" { + return "[空消息]" + } + return "[" + msgType + "]" + } + return "[unknown message]" +} + +func writePrettyQuotedContent(w io.Writer, content, indent string) { + fmt.Fprintf(w, "%s> %s\n", indent, escapePrettyContent(content)) +} + +func escapePrettyContent(content string) string { + return strings.NewReplacer( + "\\", "\\\\", + "\r", "\\r", + "\n", "\\n", + "\t", "\\t", + ).Replace(content) +} + +func prettyReactionSummary(msg map[string]interface{}) string { + reactions, ok := msg["reactions"].(map[string]interface{}) + if !ok { + return "" + } + + var summaries []string + for _, count := range prettyMessageMaps(reactions["counts"]) { + reactionType, _ := count["reaction_type"].(string) + formattedCount, ok := prettyPositiveCount(count["count"]) + if reactionType == "" || !ok { + continue + } + summaries = append(summaries, reactionType+"×"+formattedCount) + } + return strings.Join(summaries, "、") +} + +func prettyPositiveCount(raw interface{}) (string, bool) { + value, err := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(raw)), 64) + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 { + return "", false + } + if math.Trunc(value) == value { + return strconv.FormatInt(int64(value), 10), true + } + return strconv.FormatFloat(value, 'f', -1, 64), true +} + +func prettyMessageMaps(raw interface{}) []map[string]interface{} { + switch values := raw.(type) { + case []map[string]interface{}: + return values + case []interface{}: + result := make([]map[string]interface{}, 0, len(values)) + for _, value := range values { + if item, ok := value.(map[string]interface{}); ok { + result = append(result, item) + } + } + return result + default: + return nil + } +} + +func writeChatMessagesPrettyFooter(w io.Writer, messages, replies int, hasMore bool, pageToken string) { + if pageToken == "" { + pageToken = "-" + } + fmt.Fprintf(w, "%d messages · %d thread replies · has_more: %t · page_token: %s\n", messages, replies, hasMore, pageToken) +} + +func prettyID(id string) string { + if id == "" { + return "-" + } + return id +} diff --git a/shortcuts/im/im_chat_messages_pretty_test.go b/shortcuts/im/im_chat_messages_pretty_test.go new file mode 100644 index 0000000000..6fa3f0d3a2 --- /dev/null +++ b/shortcuts/im/im_chat_messages_pretty_test.go @@ -0,0 +1,246 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "net/http" + "strings" + "testing" +) + +func TestRenderChatMessagesPrettyConversation(t *testing.T) { + root := map[string]interface{}{ + "message_id": "om_root", + "msg_type": "post", + "create_time": "2026-08-11 21:37", + "sender": map[string]interface{}{"name": "豆包"}, + "content": "第一行\n\n```go\nfmt.Println(\"hello\")\n```", + "thread_id": "omt_topic", + "reactions": map[string]interface{}{ + "counts": []interface{}{ + map[string]interface{}{"reaction_type": "THUMBSUP", "count": float64(2)}, + map[string]interface{}{"reaction_type": "DONE", "count": 1}, + map[string]interface{}{"reaction_type": "EMPTY", "count": 0}, + }, + }, + "thread_has_more": true, + } + reply := map[string]interface{}{ + "message_id": "om_reply", + "msg_type": "text", + "create_time": "2026-08-11 21:38", + "sender": map[string]interface{}{"name": "boe 的"}, + "content": "Completed\n第二行", + "reactions": map[string]interface{}{ + "counts": []map[string]interface{}{ + {"reaction_type": "SMILE", "count": 1}, + }, + }, + } + crossDayRecalled := map[string]interface{}{ + "message_id": "om_recalled", + "msg_type": "text", + "create_time": "2026-08-12 00:03", + "sender": map[string]interface{}{"id": "ou_user"}, + "content": "This message was recalled", + "deleted": true, + } + root["thread_replies"] = []interface{}{root, reply, reply, crossDayRecalled} + + ordinaryReply := map[string]interface{}{ + "message_id": "om_plain_reply", + "msg_type": "text", + "create_time": "2026-08-11 22:00", + "sender": map[string]interface{}{"sender_type": "anonymous"}, + "content": "普通回复", + "reply_to": "om_parent", + } + + var out bytes.Buffer + renderChatMessagesPretty(&out, []map[string]interface{}{root, ordinaryReply}, true, "next-token") + + want := `2026-08-11 21:37 · 豆包 +> 第一行\n\n` + "```go" + `\nfmt.Println("hello")\n` + "```" + ` + +message: om_root · thread: omt_topic +表情:THUMBSUP×2、DONE×1 + + ↳ 21:38 · boe 的 + > Completed\n第二行 + 表情:SMILE×1 + + ↳ 2026-08-12 00:03 · ou_user + > 已撤回 + + ↳ 还有更多话题回复 · thread: omt_topic +──────────────────────── + +2026-08-11 22:00 · anonymous +> 普通回复 + +message: om_plain_reply · reply_to: om_parent +──────────────────────── + +2 messages · 2 thread replies · has_more: true · page_token: next-token +` + if out.String() != want { + t.Fatalf("pretty transcript mismatch\n--- got ---\n%s--- want ---\n%s", out.String(), want) + } +} + +func TestRenderChatMessagesPrettyThreadStatesAndFallbacks(t *testing.T) { + messages := []map[string]interface{}{ + { + "message_id": "om_failed", + "msg_type": "interactive", + "create_time": "2026-08-11 20:00", + "thread_id": "omt_failed", + "thread_replies_error": true, + }, + { + "message_id": "om_omitted", + "msg_type": "system", + "create_time": "2026-08-11 20:01", + "thread_id": "omt_omitted", + }, + { + "message_id": "om_empty_text", + "msg_type": "text", + "create_time": "2026-08-11 20:02", + }, + } + + var out bytes.Buffer + renderChatMessagesPretty(&out, messages, false, "") + got := out.String() + for _, want := range []string{ + "2026-08-11 20:00 · 未知发送者\n> [interactive]", + "话题回复获取失败 · thread: omt_failed", + "2026-08-11 20:01 · 系统\n> [system]", + "话题回复未展开 · thread: omt_omitted", + "2026-08-11 20:02 · 未知发送者\n> [空消息]", + "3 messages · 0 thread replies · has_more: false · page_token: -", + } { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "> [text]") || strings.Contains(got, "> [post]") { + t.Fatalf("ordinary message type leaked into pretty output:\n%s", got) + } +} + +func TestRenderChatMessagesPrettyEmpty(t *testing.T) { + var out bytes.Buffer + renderChatMessagesPretty(&out, nil, false, "") + want := "No messages in this time range.\n\n0 messages · 0 thread replies · has_more: false · page_token: -\n" + if out.String() != want { + t.Fatalf("empty pretty output = %q, want %q", out.String(), want) + } +} + +func TestRenderChatMessagesPrettyDeduplicatesRootMessageIDs(t *testing.T) { + root := map[string]interface{}{ + "message_id": "om_root", + "create_time": "2026-08-11 21:37", + "content": "root", + } + messages := []map[string]interface{}{ + root, + root, + {"create_time": "2026-08-11 21:38", "content": "without id one"}, + {"create_time": "2026-08-11 21:39", "content": "without id two"}, + } + + var out bytes.Buffer + renderChatMessagesPretty(&out, messages, false, "") + got := out.String() + if count := strings.Count(got, "message: om_root"); count != 1 { + t.Fatalf("root message rendered %d times, want 1:\n%s", count, got) + } + for _, want := range []string{"without id one", "without id two", "3 messages · 0 thread replies"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } +} + +func TestEscapePrettyContent(t *testing.T) { + input := "line 1\r\nline 2\tC:\\tmp" + want := `line 1\r\nline 2\tC:\\tmp` + if got := escapePrettyContent(input); got != want { + t.Fatalf("escapePrettyContent() = %q, want %q", got, want) + } +} + +func TestImChatMessageListExecuteUsesConversationPrettyRenderer(t *testing.T) { + transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != imMessagesListPath { + t.Fatalf("unexpected request: %s", req.URL.String()) + } + if req.URL.Query().Get("container_id_type") == "thread" { + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "message_id": "om_root", "thread_id": "omt_topic", "msg_type": "text", "create_time": "root-time", + "sender": map[string]interface{}{"id": "cli_root", "sender_type": "app", "sender_name": "豆包"}, + "body": map[string]interface{}{"content": `{"text":"a long root message that must not be truncated after forty characters"}`}, + }, + map[string]interface{}{ + "message_id": "om_reply", "thread_id": "omt_topic", "msg_type": "post", "create_time": "reply-time", + "sender": map[string]interface{}{"id": "cli_reply", "sender_type": "app", "sender_name": "boe 的"}, + "body": map[string]interface{}{"content": `{"title":"","content_v2":[[{"tag":"md","text":"reply line 1\nreply line 2"}]]}`}, + }, + }, + "has_more": false, + }, + }), nil + } + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "message_id": "om_root", "thread_id": "omt_topic", "msg_type": "text", "create_time": "root-time", + "sender": map[string]interface{}{"id": "cli_root", "sender_type": "app", "sender_name": "豆包"}, + "body": map[string]interface{}{"content": `{"text":"a long root message that must not be truncated after forty characters"}`}, + }, + }, + "has_more": true, + "page_token": "next-token", + }, + }), nil + }) + + runtime := newBotShortcutRuntime(t, transport) + runtime.Cmd = newListPageAllCommand(t, ImChatMessageList, map[string]string{ + "chat-id": "oc_test", + "page-size": "1", + "no-reactions": "true", + }) + runtime.Format = "pretty" + + if err := ImChatMessageList.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + out, _ := runtime.IO().Out.(*bytes.Buffer) + got := out.String() + for _, want := range []string{ + "a long root message that must not be truncated after forty characters", + " ↳ reply-time · boe 的", + " > reply line 1\\nreply line 2", + "1 messages · 1 thread replies · has_more: true · page_token: next-token", + } { + if !strings.Contains(got, want) { + t.Fatalf("pretty Execute output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "message: om_reply") || strings.Contains(got, "tip: use --format json") || strings.Contains(got, "Pagination:") { + t.Fatalf("legacy table/footer leaked into conversation pretty output:\n%s", got) + } +} 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..94243b38fb 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -32,6 +32,9 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx" # Fetch multiple pages automatically, up to 10 pages by default lark-cli im +chat-messages-list --chat-id oc_xxx --page-all +# Read the current result as a complete conversation transcript +lark-cli im +chat-messages-list --chat-id oc_xxx --format pretty + # JSON output lark-cli im +chat-messages-list --chat-id oc_xxx --format json ``` @@ -149,7 +152,7 @@ lark-cli api GET /open-apis/im/v1/messages \ 2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls. 3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`. 4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler. -5. **For full content:** table output truncates content. Use `--format json` when you need the complete message body. +5. **Choose output by task:** use `--format pretty` to read returned messages and expanded replies. Continue with `--page-token` or `--page-all` for more outer messages; use `+threads-messages-list --page-all` for a complete thread. Use `--format json` for programmatic processing. 6. **For sender info:** the command already resolves sender names, so you do not need a separate lookup. 7. **Application/bot identity + named group history:** If the user says "使用应用身份/以 bot 身份" and asks to list or read historical messages for a named group, use bot identity for both steps: ```bash