From 27afa806ca59489ca9b4677cd4ed13de62dec191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Fri, 7 Aug 2026 16:35:41 +0800 Subject: [PATCH] feat(chat): unify typed and shortcut message contracts --- internal/helpers/chat.go | 73 +++++- internal/helpers/chat_message_search_test.go | 1 + .../helpers/im_read_result_contract_test.go | 221 +++++++++++++++++- internal/shortcut/chatmsg/chatmsg.go | 112 ++++++++- internal/shortcut/chatmsg/chatmsg_test.go | 44 ++++ .../chatmsg/coverage_completion_test.go | 3 + .../shortcut/smart/at_me_search_msg_test.go | 12 +- internal/shortcut/smart/chat_messages.go | 29 +-- internal/shortcut/smart/gap_fill_test.go | 2 +- internal/shortcut/smart/search_msg.go | 139 +---------- .../smart/search_msg_execution_test.go | 15 ++ 11 files changed, 479 insertions(+), 172 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index c922c596..cea31ba2 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -18,6 +18,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" @@ -25,6 +26,56 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" ) +func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error { + if deps.Caller.DryRun() { + return callMCPToolOnServer("chat", toolName, args) + } + text, err := callMCPToolReturnTextOnServer(cmd.Context(), "chat", toolName, args) + if err != nil { + return err + } + data := map[string]any{} + if strings.TrimSpace(text) != "" { + if err := unmarshalJSONUseNumber(text, &data); err != nil { + deps.Out.PrintRaw(text) + return nil + } + } + + items := chatmsg.ListMessageItems(data) + if search { + items = chatmsg.SearchMessageItems(data) + } + messages := make([]map[string]any, 0, len(items)) + for _, item := range items { + projected := make(map[string]any, len(item)+8) + for key, value := range item { + projected[key] = value + } + for key, value := range chatmsg.ProjectMessageV1(item, true) { + projected[key] = value + } + if value, exists := item["openMessageId"]; exists { + projected["openMessageId"] = value + } else if value, exists := projected["messageId"]; exists { + projected["openMessageId"] = value + } + if value, exists := item["content"]; exists { + projected["content"] = value + } else if value, exists := projected["text"]; exists { + projected["content"] = value + } + messages = append(messages, projected) + } + + payload := make(map[string]any, len(data)+1) + for key, value := range data { + payload[key] = value + } + payload["messages"] = messages + return writeCommandPayload(cmd, payload) +} + func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error) { forwardStr, _ := cmd.Flags().GetString("forward") forward := forwardStr != "false" @@ -1851,11 +1902,12 @@ func newChatCommand() *cobra.Command { chatMessageListCmd := &cobra.Command{ Use: "list", Short: "拉取会话消息内容", - Long: `拉取指定群聊或单聊的会话消息内容。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, + Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, Example: ` dws chat message list --group --time "2025-03-01 00:00:00" dws chat message list --user --time "2025-03-01 00:00:00" --limit 50 dws chat message list --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50 dws chat message list --group --time "2025-03-01 00:00:00" --direction older + dws chat message list --group --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}' # 查询群 ID: dws chat search --query "群名" # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { @@ -1899,7 +1951,7 @@ func newChatCommand() *cobra.Command { if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { toolArgs["limit"] = v } - return callMCPTool("list_conversation_message_v2", toolArgs) + return callProjectedChatMessages(cmd, "list_conversation_message_v2", toolArgs, false) } toolArgs := map[string]any{ "time": timeVal, @@ -1913,7 +1965,7 @@ func newChatCommand() *cobra.Command { if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { toolArgs["limit"] = v } - return callMCPTool("list_individual_chat_message", toolArgs) + return callProjectedChatMessages(cmd, "list_individual_chat_message", toolArgs, false) }, } DeclareLeafMetadata(chatMessageListCmd, LeafSpec{ @@ -1939,7 +1991,10 @@ func newChatCommand() *cobra.Command { AgentSummary: "分页读取指定会话消息及其引用上下文", UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"}, AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"}, - Examples: []string{"dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50"}, + Examples: []string{ + "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50", + "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'", + }, }, Parameters: []contract.ParamDecl{ {Name: "direction", Property: "forward"}, @@ -3074,10 +3129,11 @@ func newChatCommand() *cobra.Command { chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", - Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, + Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor + dws chat message search --query "发布计划" --start "2026-07-01T00:00:00+08:00" --end "2026-07-10T00:00:00+08:00" --jq '.messages[] | {messageId, text}' # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil { @@ -3110,7 +3166,7 @@ func newChatCommand() *cobra.Command { if groupID != "" { toolArgs["openConversationId"] = groupID } - return callMCPTool("search_messages_by_keyword", toolArgs) + return callProjectedChatMessages(cmd, "search_messages_by_keyword", toolArgs, true) }, } DeclareLeafMetadata(chatMessageSearchCmd, LeafSpec{ @@ -3136,7 +3192,10 @@ func newChatCommand() *cobra.Command { AgentSummary: "按关键词和时间范围搜索消息", UseWhen: []string{"需要用关键词查找消息且过滤条件较简单时"}, AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"}, - Examples: []string{"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\""}, + Examples: []string{ + "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"", + "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --jq '.messages[] | {messageId, text}'", + }, }, Parameters: []contract.ParamDecl{ {Name: "end", Property: "endTime"}, diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 3d559b51..0057552d 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -160,6 +160,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller, InitDeps(caller) deps.Out.w = io.Discard cmd := newChatCommand() + installExampleGlobalFlags(cmd) cmd.SilenceErrors = true cmd.SilenceUsage = true cmd.SetArgs(args) diff --git a/internal/helpers/im_read_result_contract_test.go b/internal/helpers/im_read_result_contract_test.go index d643c89b..1b584a2f 100644 --- a/internal/helpers/im_read_result_contract_test.go +++ b/internal/helpers/im_read_result_contract_test.go @@ -35,6 +35,7 @@ type imReadResultCall struct { type imReadResultCaller struct { responses map[string]string calls []imReadResultCall + dryRun bool } func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) { @@ -43,7 +44,7 @@ func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName str } func (*imReadResultCaller) Format() string { return "json" } -func (*imReadResultCaller) DryRun() bool { return false } +func (c *imReadResultCaller) DryRun() bool { return c.dryRun } func (*imReadResultCaller) Fields() string { return "" } func (*imReadResultCaller) JQ() string { return "" } @@ -63,6 +64,8 @@ func executeIMReadCommand(t *testing.T, caller *imReadResultCaller, processArgs os.Args = processArgs root := build() + installExampleGlobalFlags(root) + root.SetOut(&stdout) root.SilenceErrors = true root.SilenceUsage = true root.SetArgs(args) @@ -85,13 +88,14 @@ func requireSameJSON(t *testing.T, got, want string) { } } -func TestChatMessageListPreservesQuotedRichMessageContext(t *testing.T) { +func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLegacy(t *testing.T) { payload := `{ "result": { "messages": [ - {"msgType":"reply","quotedMessage":{"msgType":"merged_forward","content":{"items":[{"text":"原消息"}]}}}, - {"msgType":"reply","quotedMessage":{"msgType":"image","content":{"mediaId":"media-1"}}} - ] + {"openMessageId":"msg-1","content":{"text":"回复正文"},"createTime":101,"msgType":"reply","quotedMessage":{"msgType":"merged_forward","content":{"items":[{"text":"原消息"}]}}}, + {"openMessageId":"msg-2","content":{"text":"图片回复"},"createTime":102,"msgType":"reply","quotedMessage":{"msgType":"image","content":{"mediaId":"media-1"}}} + ], + "hasMore": false } }` caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}} @@ -104,7 +108,212 @@ func TestChatMessageListPreservesQuotedRichMessageContext(t *testing.T) { if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) { t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls) } - requireSameJSON(t, got, payload) + var result map[string]any + if err := json.Unmarshal([]byte(got), &result); err != nil { + t.Fatalf("decode command output: %v\noutput: %s", err, got) + } + if _, exists := result["contractVersion"]; exists { + t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result) + } + messages, ok := result["messages"].([]any) + if !ok || len(messages) != 2 { + t.Fatalf("messages = %#v", result["messages"]) + } + first, ok := messages[0].(map[string]any) + if !ok || first["messageId"] != "msg-1" || first["openMessageId"] != "msg-1" || first["text"] != "回复正文" { + t.Fatalf("first projected message = %#v", messages[0]) + } + if _, ok := first["content"].(map[string]any); !ok { + t.Fatalf("legacy content not preserved: %#v", first) + } + quoted, ok := first["quotedMessage"].(map[string]any) + if !ok || quoted["messageType"] != "merged_forward" { + t.Fatalf("merged-forward quote = %#v", first["quotedMessage"]) + } + second, ok := messages[1].(map[string]any) + if !ok { + t.Fatalf("second projected message = %#v", messages[1]) + } + quoted, ok = second["quotedMessage"].(map[string]any) + if !ok || quoted["messageType"] != "image" { + t.Fatalf("image quote = %#v", second["quotedMessage"]) + } + resources, ok := quoted["resourceRefs"].([]any) + if !ok || len(resources) != 1 { + t.Fatalf("image quote resources = %#v", quoted["resourceRefs"]) + } + legacy, ok := result["result"].(map[string]any) + if !ok { + t.Fatalf("legacy result envelope missing: %#v", result["result"]) + } + legacyMessages, ok := legacy["messages"].([]any) + if !ok || len(legacyMessages) != 2 { + t.Fatalf("legacy result.messages = %#v", legacy["messages"]) + } +} + +func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) { + payload := `{ + "result": { + "conversationMessagesList": [{ + "openConversationId": "cid-1", + "title": "项目群", + "messages": [{"openMessageId":"msg-search-1","messageId":"legacy-conflict","content":{"richText":"发布计划"},"createTime":201}] + }], + "nextCursor": "cursor-2" + } + }` + caller := &imReadResultCaller{responses: map[string]string{"search_messages_by_keyword": payload}} + + got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, + "message", "search", "--query", "发布计划", + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00") + if err != nil { + t.Fatalf("chat message search returned error: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "search_messages_by_keyword"}) { + t.Fatalf("calls = %#v, want chat/search_messages_by_keyword", caller.calls) + } + var result map[string]any + if err := json.Unmarshal([]byte(got), &result); err != nil { + t.Fatalf("decode command output: %v\noutput: %s", err, got) + } + if _, exists := result["contractVersion"]; exists { + t.Fatalf("typed search added an out-of-scope contract envelope: %#v", result) + } + messages, ok := result["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v", result["messages"]) + } + message, ok := messages[0].(map[string]any) + if !ok || message["messageId"] != "msg-search-1" || message["openMessageId"] != "msg-search-1" || + message["conversationId"] != "cid-1" || message["text"] != "发布计划" { + t.Fatalf("projected search message = %#v", messages[0]) + } + if _, ok := message["content"].(map[string]any); !ok { + t.Fatalf("legacy content not preserved: %#v", message) + } + legacy, ok := result["result"].(map[string]any) + if !ok { + t.Fatalf("legacy result envelope missing: %#v", result["result"]) + } + legacyGroups, ok := legacy["conversationMessagesList"].([]any) + if !ok || len(legacyGroups) != 1 { + t.Fatalf("legacy result.conversationMessagesList = %#v", legacy["conversationMessagesList"]) + } + if legacy["nextCursor"] != "cursor-2" { + t.Fatalf("legacy result.nextCursor = %#v", legacy["nextCursor"]) + } +} + +func TestCrossPlatformCoverageChatMessageListPreservesNonJSONResponse(t *testing.T) { + const payload = "upstream temporarily returned plain text" + caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}} + + got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, + "message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00") + if err != nil { + t.Fatalf("chat message list returned error: %v", err) + } + if got != payload+"\n" { + t.Fatalf("command output = %q, want raw payload", got) + } +} + +func TestCrossPlatformCoverageChatMessageListPreservesTopLevelMessageFields(t *testing.T) { + payload := `{ + "messages": [{ + "openMessageId": "msg-top-1", + "content": {"text": "顶层正文"}, + "msgType": "text", + "senderName": "张三", + "extensionField": {"source": "legacy"} + }, { + "messageId": "msg-top-2", + "text": "稳定正文" + }], + "count": 99, + "partial": true, + "failedCount": 1, + "failures": [{"stage":"legacy","error":"legacy failure"}], + "hasMore": false + }` + caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}} + + got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, + "message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00") + if err != nil { + t.Fatalf("chat message list returned error: %v", err) + } + var result map[string]any + if err := json.Unmarshal([]byte(got), &result); err != nil { + t.Fatalf("decode command output: %v\noutput: %s", err, got) + } + messages, ok := result["messages"].([]any) + if !ok || len(messages) != 2 { + t.Fatalf("messages = %#v", result["messages"]) + } + message, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("message = %#v", messages[0]) + } + if message["messageId"] != "msg-top-1" || message["text"] != "顶层正文" { + t.Fatalf("stable fields = %#v", message) + } + if message["msgType"] != "text" || message["senderName"] != "张三" { + t.Fatalf("legacy message fields = %#v", message) + } + if extension, ok := message["extensionField"].(map[string]any); !ok || extension["source"] != "legacy" { + t.Fatalf("extensionField = %#v", message["extensionField"]) + } + stableOnly, ok := messages[1].(map[string]any) + if !ok || stableOnly["openMessageId"] != "msg-top-2" || stableOnly["content"] != "稳定正文" { + t.Fatalf("legacy aliases from stable fields = %#v", messages[1]) + } + if result["count"] != float64(99) || result["partial"] != true || result["failedCount"] != float64(1) { + t.Fatalf("legacy top-level envelope fields = %#v", result) + } + if _, exists := result["contractVersion"]; exists { + t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result) + } + failures, ok := result["failures"].([]any) + if !ok || len(failures) != 1 || failures[0].(map[string]any)["stage"] != "legacy" { + t.Fatalf("legacy failures = %#v", result["failures"]) + } +} + +func TestCrossPlatformCoverageChatMessageListDryRunKeepsPreviewPath(t *testing.T) { + caller := &imReadResultCaller{dryRun: true} + + got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, + "message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00") + if err != nil { + t.Fatalf("chat message list dry-run returned error: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run calls = %#v, want none", caller.calls) + } + var preview map[string]any + if err := json.Unmarshal([]byte(got), &preview); err != nil { + t.Fatalf("decode dry-run output: %v\noutput: %s", err, got) + } + if preview["dry_run"] != true || preview["executed"] != false || preview["tool"] != "list_conversation_message_v2" { + t.Fatalf("dry-run preview = %#v", preview) + } +} + +func TestCrossPlatformCoverageChatMessageListRawPreservesLargeIntegers(t *testing.T) { + const payload = `{"messages":[{"openMessageId":"msg-1","content":{"text":"正文"},"sequence":9007199254740993}],"hasMore":false}` + caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}} + + got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, + "message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00", "--format", "raw") + if err != nil { + t.Fatalf("chat message list returned error: %v", err) + } + if !bytes.Contains([]byte(got), []byte(`"sequence":9007199254740993`)) { + t.Fatalf("raw output changed large integer: %s", got) + } } func TestDingMessageListPreservesContent(t *testing.T) { diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go index ed186909..17ddd13d 100644 --- a/internal/shortcut/chatmsg/chatmsg.go +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -124,6 +124,111 @@ func NewMessageListPayload(messages []map[string]any) map[string]any { } } +// ListMessageItems returns message rows from the common list response envelopes. +func ListMessageItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + scopes := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if inner, ok := data[wrapper].(map[string]any); ok { + scopes = append(scopes, inner) + } + } + for _, scope := range scopes { + for _, key := range []string{"messages", "list", "items", "records", "data", "result"} { + if rows, ok := scope[key].([]any); ok { + items := messageMaps(rows) + if len(items) > 0 { + return items + } + } + } + } + return nil +} + +// SearchMessageItems flattens grouped search results into stable message rows. +func SearchMessageItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + for _, root := range []map[string]any{data, childMap(data, "result")} { + if root == nil { + continue + } + if groups, ok := root["conversationMessagesList"].([]any); ok { + return flattenSearchMessageGroups(groups) + } + } + for _, key := range []string{"list", "messages", "messageList", "items", "data", "records", "result"} { + if rows, ok := data[key].([]any); ok { + return messageMaps(rows) + } + if inner, ok := data[key].(map[string]any); ok { + for _, nestedKey := range []string{"list", "messages", "messageList", "items", "data", "records"} { + if rows, ok := inner[nestedKey].([]any); ok { + return messageMaps(rows) + } + } + } + } + return nil +} + +func childMap(data map[string]any, key string) map[string]any { + value, _ := data[key].(map[string]any) + return value +} + +func flattenSearchMessageGroups(groups []any) []map[string]any { + items := make([]map[string]any, 0) + for _, rawGroup := range groups { + group, ok := rawGroup.(map[string]any) + if !ok { + continue + } + messages, ok := group["messages"].([]any) + if !ok { + continue + } + conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"])) + conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"])) + singleChat, hasSingleChat := group["singleChat"] + for _, rawMessage := range messages { + message, ok := rawMessage.(map[string]any) + if !ok { + continue + } + item := make(map[string]any, len(message)+3) + for key, value := range message { + item[key] = value + } + if _, exists := item["openConversationId"]; !exists && conversationID != "" && conversationID != "" { + item["openConversationId"] = conversationID + } + if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" && conversationTitle != "" { + item["conversationTitle"] = conversationTitle + } + if _, exists := item["singleChat"]; !exists && hasSingleChat { + item["singleChat"] = singleChat + } + items = append(items, item) + } + } + return items +} + +func messageMaps(rows []any) []map[string]any { + items := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + if item, ok := row.(map[string]any); ok { + items = append(items, item) + } + } + return items +} + // StableMessageID returns the normalized message identity used for // cross-page deduplication. An empty value means the lower response did not // publish a stable identity; callers must keep that row rather than guessing. @@ -184,7 +289,7 @@ func senderDisplayName(m map[string]any) string { // Text reads a message's textual content (tolerating common text keys and one // level of nesting) and runs it through CleanText. func Text(m map[string]any) any { - for _, key := range []string{"text", "content", "msgContent", "message", "body", "plainText"} { + for _, key := range []string{"text", "content", "msgContent", "message", "msg", "body", "plainText"} { v, ok := m[key] if !ok || v == nil { continue @@ -195,7 +300,7 @@ func Text(m map[string]any) any { return CleanText(t) } case map[string]any: - for _, inner := range []string{"text", "content", "value"} { + for _, inner := range []string{"text", "content", "richText", "title", "value"} { if s, ok := t[inner].(string); ok && s != "" { return CleanText(s) } @@ -920,6 +1025,9 @@ func paginationValuePresent(value any) bool { switch typed := value.(type) { case nil: return false + case json.Number: + number, err := typed.Float64() + return err != nil || number != 0 case string: return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != "0" case int: diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go index c9f7f244..3e75929f 100644 --- a/internal/shortcut/chatmsg/chatmsg_test.go +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -595,3 +595,47 @@ func TestCrossPlatformCoverageForwarded(t *testing.T) { t.Errorf("nested forwarded = %#v", fwd[1]["forwarded"]) } } + +func TestCrossPlatformCoverageListMessageItemsUnwrapsCommonEnvelope(t *testing.T) { + if ListMessageItems(nil) != nil { + t.Fatal("nil list envelope returned messages") + } + items := ListMessageItems(map[string]any{ + "result": map[string]any{ + "messages": []any{ + map[string]any{"openMessageId": "msg-1"}, + "invalid", + }, + }, + }) + if len(items) != 1 || items[0]["openMessageId"] != "msg-1" { + t.Fatalf("items = %#v", items) + } +} + +func TestCrossPlatformCoverageSearchMessageItemsFlattensConversationGroups(t *testing.T) { + if SearchMessageItems(nil) != nil { + t.Fatal("nil search envelope returned messages") + } + items := SearchMessageItems(map[string]any{ + "result": map[string]any{ + "conversationMessagesList": []any{ + map[string]any{ + "openConversationId": "cid-1", + "title": "项目群", + "singleChat": false, + "messages": []any{ + map[string]any{"openMessageId": "msg-1"}, + }, + }, + }, + }, + }) + if len(items) != 1 || items[0]["openConversationId"] != "cid-1" || + items[0]["conversationTitle"] != "项目群" || items[0]["singleChat"] != false { + t.Fatalf("items = %#v", items) + } + if SearchMessageItems(map[string]any{"result": "invalid"}) != nil { + t.Fatal("non-map result was accepted") + } +} diff --git a/internal/shortcut/chatmsg/coverage_completion_test.go b/internal/shortcut/chatmsg/coverage_completion_test.go index ef8c8742..2c78ca43 100644 --- a/internal/shortcut/chatmsg/coverage_completion_test.go +++ b/internal/shortcut/chatmsg/coverage_completion_test.go @@ -114,6 +114,9 @@ func TestCrossPlatformCoveragePaginationVariants(t *testing.T) { want bool }{ {nil, false}, + {json.Number("0"), false}, + {json.Number("0.0"), false}, + {json.Number("9007199254740993"), true}, {" ", false}, {"0", false}, {"cursor", true}, diff --git a/internal/shortcut/smart/at_me_search_msg_test.go b/internal/shortcut/smart/at_me_search_msg_test.go index 81d403c3..fb27fc03 100644 --- a/internal/shortcut/smart/at_me_search_msg_test.go +++ b/internal/shortcut/smart/at_me_search_msg_test.go @@ -99,6 +99,16 @@ func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) { t.Fatalf("searchMsgProject = %#v", row) } + // Canonical ID precedence and rich-text extraction must match typed search. + row = searchMsgProject(map[string]any{ + "openMessageId": "open-id", + "messageId": "legacy-conflict", + "content": map[string]any{"richText": "富文本消息"}, + }) + if row["messageId"] != "open-id" || row["text"] != "富文本消息" { + t.Fatalf("searchMsgProject canonical fields = %#v", row) + } + // encrypted → marker; id-only sender; forwarded "null" sender nulled. row = searchMsgProject(map[string]any{ "senderId": "DAAA", @@ -115,7 +125,7 @@ func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) { t.Errorf("searchMsgProject encrypted text = %v, want marker", row["text"]) } fwd, ok := row["forwarded"].([]map[string]any) - if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil { + if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil || fwd[0]["time"] != "t" { t.Errorf("searchMsgProject forwarded = %#v", row["forwarded"]) } diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 70cc7eb5..7a1e9fe1 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -91,7 +91,7 @@ var ChatMessages = shortcut.Shortcut{ AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"}, Examples: []string{ "dws chat +chat-messages --group --direction older", - "dws chat +chat-messages --open-dingtalk-id --download-resources --output-dir ./downloads", + "dws chat +chat-messages --group --direction older --jq '.messages[] | {messageId, text}'", }, }, }, @@ -150,6 +150,7 @@ var ChatMessages = shortcut.Shortcut{ `dws chat +chat-messages --group --start "2025-03-01T00:00:00+08:00" --end "2025-03-02T00:00:00+08:00" --order asc --page-all`, `dws chat +chat-messages --user --time "2025-03-01 00:00:00" --page-all --page-limit 50`, `dws chat +chat-messages --group --direction older --page-all --output ./exports/messages.json`, + `dws chat +chat-messages --group --direction older --jq '.messages[] | {messageId, text}'`, }, Validate: validateChatMessages, Execute: executeChatMessages, @@ -743,31 +744,7 @@ func chatMessagesNextCursorBoundary(value any) (string, string, error) { // tolerating the common container keys and one level of nesting under a // "result"/"data" wrapper. func chatMessageItems(data map[string]any) []map[string]any { - if data == nil { - return nil - } - scopes := []map[string]any{data} - for _, wrap := range []string{"result", "data"} { - if inner, ok := data[wrap].(map[string]any); ok { - scopes = append(scopes, inner) - } - } - for _, scope := range scopes { - for _, key := range []string{"messages", "list", "items", "records", "data", "result"} { - if raw, ok := scope[key].([]any); ok { - out := make([]map[string]any, 0, len(raw)) - for _, e := range raw { - if m, ok := e.(map[string]any); ok { - out = append(out, m) - } - } - if len(out) > 0 { - return out - } - } - } - } - return nil + return chatmsg.ListMessageItems(data) } // projectChatMessage reshapes one raw message into the clean diff --git a/internal/shortcut/smart/gap_fill_test.go b/internal/shortcut/smart/gap_fill_test.go index 0cf8e338..685f0c37 100644 --- a/internal/shortcut/smart/gap_fill_test.go +++ b/internal/shortcut/smart/gap_fill_test.go @@ -233,7 +233,7 @@ func TestCrossPlatformCoverageSearchMsgFlattensRealGroupedResponse(t *testing.T) if _, mutated := original["openConversationId"]; mutated { t.Fatalf("source message mutated: %#v", original) } - if searchMsgChildMap(map[string]any{"result": "invalid"}, "result") != nil { + if searchMsgItems(map[string]any{"result": "invalid"}) != nil { t.Fatal("non-map child was accepted") } } diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index a584a6a9..fa592df3 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -67,7 +67,7 @@ var SearchMsg = shortcut.Shortcut{ AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"}, Examples: []string{ "dws chat +search-msg --query \"周报\" --senders --days 3 --page-all", - "dws chat +search-msg --group --message-type file --download-resources --output-dir ./downloads", + "dws chat +search-msg --query \"周报\" --senders --days 3 --page-all --jq '.messages[] | {messageId, text}'", }, }, }, @@ -138,6 +138,7 @@ var SearchMsg = shortcut.Shortcut{ Tips: []string{ `dws chat +search-msg --group --query "changefree"`, `dws chat +search-msg --senders --at-me --days 3 --page-all`, + `dws chat +search-msg --group --query "changefree" --jq '.messages[] | {messageId, text}'`, }, Validate: validateSearchMsgWithResources, Execute: func(rt *shortcut.RuntimeContext) error { @@ -505,106 +506,21 @@ func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any // response, probing common container keys at the top level and nested under // "result". Returns nil when no list is found. func searchMsgItems(data map[string]any) []map[string]any { - if data == nil { - return nil - } - for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} { - if root == nil { - continue - } - if groups, ok := root["conversationMessagesList"].([]any); ok { - return searchMsgFlattenGroups(groups) - } - } - keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return searchMsgToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} { - if arr, ok := inner[k2].([]any); ok { - return searchMsgToMaps(arr) - } - } - } - } - return nil + return chatmsg.SearchMessageItems(data) } -func searchMsgChildMap(data map[string]any, key string) map[string]any { - if value, ok := data[key].(map[string]any); ok { - return value - } - return nil -} - -func searchMsgFlattenGroups(groups []any) []map[string]any { - out := make([]map[string]any, 0) - for _, rawGroup := range groups { - group, ok := rawGroup.(map[string]any) - if !ok { - continue - } - messages, ok := group["messages"].([]any) - if !ok { - continue - } - conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"])) - conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"])) - singleChat, hasSingleChat := group["singleChat"] - for _, rawMessage := range messages { - message, ok := rawMessage.(map[string]any) - if !ok { - continue - } - item := make(map[string]any, len(message)+3) - for key, value := range message { - item[key] = value - } - if _, exists := item["openConversationId"]; !exists && - conversationID != "" && conversationID != "" { - item["openConversationId"] = conversationID - } - if _, exists := item["conversationTitle"]; !exists && - conversationTitle != "" && conversationTitle != "" { - item["conversationTitle"] = conversationTitle - } - if _, exists := item["singleChat"]; !exists && hasSingleChat { - item["singleChat"] = singleChat - } - out = append(out, item) - } - } - return out -} - -func searchMsgToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// searchMsgProject reshapes one matched message into {sender, time, text, -// messageId}, running text through the shared chatmsg cleaning (card/auto-reply -// JSON → readable, ciphertext → marker) and recursively expanding any forwarded -// chat record under "forwarded". +// searchMsgProject adds search-only sender/time aliases and forwarded-message +// expansion to the shared message projection. func searchMsgProject(m map[string]any) map[string]any { return searchMsgProjectWithReactions(m, true) } func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[string]any { row := chatmsg.ProjectMessageV1(m, includeReactions) - // Preserve the established search aliases while also publishing the V1 - // canonical fields used by other message readers. + // Keep search-only aliases while messageId/text stay owned by the shared + // projection used by typed message search. row["sender"] = searchMsgSender(m) row["time"] = searchMsgTime(m) - row["text"] = searchMsgCleanText(m) - row["messageId"] = searchMsgMessageID(m) if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any { return searchMsgProjectWithReactions(item, includeReactions) }); len(forwarded) > 0 { @@ -613,14 +529,6 @@ func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[ return row } -// searchMsgCleanText runs searchMsgText's extraction through chatmsg.CleanText. -func searchMsgCleanText(m map[string]any) any { - if s, ok := searchMsgText(m).(string); ok { - return chatmsg.CleanText(s) - } - return searchMsgText(m) -} - // searchMsgSender reads a message's sender display name/id, tolerating the // common sender keys the gateway may use (including a nested sender object). The // literal string "null" (carried by forwarded sub-messages) and the empty string @@ -639,8 +547,8 @@ func searchMsgSender(m map[string]any) any { } for _, key := range []string{"sender", "from", "senderUser"} { if nested, ok := m[key].(map[string]any); ok { - for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} { - if v := norm(nested[k2]); v != "" { + for _, nestedKey := range []string{"name", "nick", "userName", "staffName", "displayName"} { + if v := norm(nested[nestedKey]); v != "" { return v } } @@ -668,39 +576,12 @@ func searchMsgTime(m map[string]any) any { return nil } -// searchMsgText reads a message's textual content, tolerating flat text keys and -// a nested content/text object. -func searchMsgText(m map[string]any) any { - for _, key := range []string{"text", "content", "msgContent", "message", "body"} { - if v := searchMsgString(m[key]); v != "" { - return v - } - } - for _, key := range []string{"content", "text", "msg"} { - if nested, ok := m[key].(map[string]any); ok { - for _, k2 := range []string{"text", "content", "richText", "title"} { - if v := searchMsgString(nested[k2]); v != "" { - return v - } - } - } - } - return nil -} - // searchMsgMessageID reads a message's identifier, tolerating the common id keys // the gateway may use. func searchMsgMessageID(m map[string]any) any { - for _, key := range []string{"messageId", "message_id", "msgId", "msg_id", "openMessageId", "id"} { - if v := searchMsgString(m[key]); v != "" { - return v - } - } - return nil + return chatmsg.MessageID(m) } -// searchMsgString coerces a scalar JSON value to a trimmed string, returning "" -// for nil / non-scalar / empty values. func searchMsgString(v any) string { switch typed := v.(type) { case string: diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index 92a6c7d0..b2cf21e3 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -25,6 +25,7 @@ type searchMsgExecutionCaller struct { omitMgetItem bool firstResponse string mgetResponse string + numericZeroEnd bool } func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { @@ -37,6 +38,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str if f.omitPagination { return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}]}}`), nil } + if f.numericZeroEnd { + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":false,"nextCursor":0}}`), nil + } if args["cursor"] == "c2" { if f.failSecondPage { return nil, errors.New("second page unavailable") @@ -192,6 +196,17 @@ func TestCrossPlatformCoverageSearchMsgMissingPaginationCannotClaimComplete(t *t } } +func TestCrossPlatformCoverageSearchMsgNumericZeroCursorIsComplete(t *testing.T) { + caller := &searchMsgExecutionCaller{numericZeroEnd: true} + payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich") + if payload["complete"] != true || payload["hasMore"] != false || payload["paginationKnown"] != true { + t.Fatalf("numeric zero cursor pagination = %#v", payload) + } + if payload["nextCursor"] != "" { + t.Fatalf("numeric zero cursor exposed as next page: %#v", payload["nextCursor"]) + } +} + func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) { caller := &searchMsgExecutionCaller{omitMgetItem: true} payload := executeSearchMsg(t, caller, "--query", "周报", "--page-all")