feat(chat): unify typed and shortcut message contracts
This commit is contained in:
@@ -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 <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --direction older
|
||||
dws chat message list --group <openconversation_id> --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 <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"},
|
||||
Examples: []string{
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
|
||||
"dws chat message list --group <openConversationId> --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 <openconversation_id> --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 <nextCursor>
|
||||
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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 != "<nil>" {
|
||||
item["openConversationId"] = conversationID
|
||||
}
|
||||
if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" && conversationTitle != "<nil>" {
|
||||
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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
|
||||
Examples: []string{
|
||||
"dws chat +chat-messages --group <openConversationId> --direction older",
|
||||
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads",
|
||||
"dws chat +chat-messages --group <openConversationId> --direction older --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -150,6 +150,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
`dws chat +chat-messages --group <openconversation_id> --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 <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
|
||||
`dws chat +chat-messages --group <openconversation_id> --direction older --page-all --output ./exports/messages.json`,
|
||||
`dws chat +chat-messages --group <openconversation_id> --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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
|
||||
Examples: []string{
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
|
||||
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads",
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -138,6 +138,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
Tips: []string{
|
||||
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
|
||||
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
|
||||
`dws chat +search-msg --group <openConversationId> --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 != "<nil>" {
|
||||
item["openConversationId"] = conversationID
|
||||
}
|
||||
if _, exists := item["conversationTitle"]; !exists &&
|
||||
conversationTitle != "" && conversationTitle != "<nil>" {
|
||||
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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user