diff --git a/.changes/973-chat-message-time-defaults.md b/.changes/973-chat-message-time-defaults.md new file mode 100644 index 00000000..09da6854 --- /dev/null +++ b/.changes/973-chat-message-time-defaults.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows. diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 19fabba2..f36dd104 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -468,22 +468,50 @@ func validateNativeSearchConversationScope(conversationIDs []string) error { } func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { + startRaw, endRaw, err := defaultChatMessageListAllTimeRange(cmd, 24*time.Hour) + if err != nil { + return nil, err + } + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + return nil, err + } + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + return nil, err + } + if err := validateTimeRange(startMs, endMs); err != nil { return nil, err } cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ - "startTime": mustGetFlag(cmd, "start"), - "endTime": mustGetFlag(cmd, "end"), + "startTime": startRaw, + "endTime": endRaw, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, }, nil } -func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { - if err := validateRequiredFlags(cmd, "start"); err != nil { - return nil, err +func defaultChatMessageListAllTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) { + startRaw := mustGetFlag(cmd, "start") + endRaw := mustGetFlag(cmd, "end") + anchor := time.Now() + if endRaw == "" { + endRaw = formatChatMessageListAllTime(anchor.UnixMilli()) + } else if startRaw == "" { + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + return "", "", err + } + anchor = time.UnixMilli(endMs) } + if startRaw == "" { + startRaw = formatChatMessageListAllTime(anchor.Add(-lookback).UnixMilli()) + } + return startRaw, endRaw, nil +} + +func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { senderUserID := flagOrFallback(cmd, "sender-user-id", "sender") senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id") if senderUserID != "" && senderOpenDingTalkID != "" { @@ -492,13 +520,13 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { if senderUserID == "" && senderOpenDingTalkID == "" { return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required") } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err } - endRaw, _ := cmd.Flags().GetString("end") - if strings.TrimSpace(endRaw) == "" { - endRaw = time.Now().Format(time.RFC3339) + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + return nil, err } endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { @@ -523,14 +551,15 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { } func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) { - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return nil, err - } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err } - endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + return nil, err + } + endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } @@ -565,14 +594,15 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil { return nil, err } - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return nil, err - } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err } - endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + return nil, err + } + endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } @@ -593,6 +623,33 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { return toolArgs, nil } +func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) { + startRaw := mustGetFlag(cmd, "start") + endRaw := mustGetFlag(cmd, "end") + anchor := time.Now() + if endRaw == "" { + endRaw = anchor.Format(time.RFC3339) + } else if startRaw == "" { + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + return "", "", err + } + anchor = time.UnixMilli(endMs) + } + if startRaw == "" { + startRaw = anchor.Add(-lookback).Format(time.RFC3339) + } + return startRaw, endRaw, nil +} + +func formatChatMessageListAllTime(ms int64) string { + return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05") +} + +func defaultChatMessageListTime() string { + return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05") +} + func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { toolArgs := map[string]any{} if v := flagOrFallback(cmd, "query", "keyword"); v != "" { @@ -2606,8 +2663,9 @@ func newChatCommand() *cobra.Command { chatMessageListCmd := &cobra.Command{ Use: "list", Short: "拉取会话消息内容", - 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" + Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --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 + 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 @@ -2615,9 +2673,6 @@ func newChatCommand() *cobra.Command { # 查询群 ID: dws chat search --query "群名" # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "time"); err != nil { - return err - } groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") @@ -2641,11 +2696,16 @@ func newChatCommand() *cobra.Command { openDingTalkID = userID userID = "" } - forward, err := resolveMessageForward(cmd, true) + timeVal := mustGetFlag(cmd, "time") + defaultForward := true + if timeVal == "" { + timeVal = defaultChatMessageListTime() + defaultForward = false + } + forward, err := resolveMessageForward(cmd, defaultForward) if err != nil { return err } - timeVal := mustGetFlag(cmd, "time") if groupID != "" { toolArgs := map[string]any{ "openconversation_id": groupID, @@ -2696,7 +2756,7 @@ func newChatCommand() *cobra.Command { UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"}, AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"}, Examples: []string{ - "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50", + "dws chat message list --group --limit 50", "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'", }, }, @@ -2731,7 +2791,7 @@ func newChatCommand() *cobra.Command { timeVal, _ := cmd.Flags().GetString("time") defaultForward := true if strings.TrimSpace(timeVal) == "" { - timeVal = time.Now().Format("2006-01-02 15:04:05") + timeVal = defaultChatMessageListTime() defaultForward = false } forward, err := resolveMessageForward(cmd, defaultForward) @@ -3489,7 +3549,7 @@ func newChatCommand() *cobra.Command { chatMessageListAllCmd := &cobra.Command{ Use: "list-all", Short: "拉取指定时间范围内当前用户的所有会话消息", - Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`, + Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 可选,不传时默认最近 1 天到当前时间,避免跨全部会话范围过大;--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`, Example: ` dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token" dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 100 --page-all --page-limit 20 --max-items 500 --page-delay 0`, @@ -3536,7 +3596,7 @@ func newChatCommand() *cobra.Command { chatMessageListBySenderCmd := &cobra.Command{ Use: "list-by-sender", Short: "拉取指定发送者的消息(包含单聊和群聊)", - Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。--start 和 --end 可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0 @@ -3588,7 +3648,7 @@ func newChatCommand() *cobra.Command { chatMessageListMentionsCmd := &cobra.Command{ Use: "list-mentions", Short: "拉取 @我 的消息", - Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `搜索时间范围内 @我 的消息,可选指定群聊。--start 和 --end 可选,不传时默认最近 7 天到当前时间。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0 dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 @@ -3776,7 +3836,7 @@ func newChatCommand() *cobra.Command { chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", - Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, 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 @@ -4334,9 +4394,9 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageListCmd.Flags().String("group", "", "群聊 openconversation_id(群聊时必填)") chatMessageListCmd.Flags().String("user", "", "单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)") chatMessageListCmd.Flags().String("open-dingtalk-id", "", "单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于无法获取 userId 的场景)") - chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)") - chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(推荐)") - chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older") + chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)") + chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(未传 --time 时默认 older)") + chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older(未传 --time 时默认 false)") _ = chatMessageListCmd.Flags().MarkHidden("forward") chatMessageListCmd.Flags().Int("limit", 0, "返回数量,不传则不限制") chatMessageListCmd.Flags().Int("size", 0, "--limit 的旧版别名") @@ -4454,10 +4514,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageListTopicRepliesCmd.Flags().String("forward", "false", "true 等价 --direction newer,false 等价 --direction older(默认 false)") _ = chatMessageListTopicRepliesCmd.Flags().MarkHidden("forward") - chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)") - _ = chatMessageListAllCmd.MarkFlagRequired("start") - chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)") - _ = chatMessageListAllCmd.MarkFlagRequired("end") + chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间前 1 天)") + chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)") chatMessageListAllCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListAllCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListAllCmd.Flags().MarkHidden("size") @@ -4471,9 +4529,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageListBySenderCmd.Flags().String("sender-open-dingtalk-id", "", "发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)") chatMessageListBySenderCmd.Flags().String("user", "", "") _ = chatMessageListBySenderCmd.Flags().MarkHidden("user") - chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)") - _ = chatMessageListBySenderCmd.MarkFlagRequired("start") - chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)") + chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") + chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageListBySenderCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListBySenderCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListBySenderCmd.Flags().MarkHidden("size") @@ -4482,10 +4539,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 // list-mentions flags chatMessageListMentionsCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则查全部)") - chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)") - _ = chatMessageListMentionsCmd.MarkFlagRequired("start") - chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)") - _ = chatMessageListMentionsCmd.MarkFlagRequired("end") + chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") + chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageListMentionsCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListMentionsCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListMentionsCmd.Flags().MarkHidden("size") @@ -4510,10 +4565,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageSearchCmd.Flags().String("keyword", "", "--query 的别名") _ = chatMessageSearchCmd.Flags().MarkHidden("keyword") chatMessageSearchCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则搜索所有会话)") - chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)") - _ = chatMessageSearchCmd.MarkFlagRequired("start") - chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)") - _ = chatMessageSearchCmd.MarkFlagRequired("end") + chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") + chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageSearchCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)") chatMessageSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageSearchCmd.Flags().MarkHidden("size") @@ -5923,6 +5976,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Short: "下载消息中的资源(图片/视频/语音等)到本地", Long: `下载聊天消息中的图片、视频、语音等资源到本地文件。 +本命令只支持聊天消息 mediaId 下载,不支持钉盘 fileId。 +如需用 fileId 下载,请使用钉盘/drive 下载命令。 + 流程: 1. 根据 resourceType + resource-id + message-id + open-conversation-id 向服务端获取下载 URL 2. HTTP GET 下载文件到本地 @@ -5933,6 +5989,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/ dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./photo.jpg # resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId + # 不支持钉盘 fileId;fileId 下载请使用钉盘/drive 下载命令 # message-id: 从 dws chat message list 返回的 openMessageId # open-conversation-id: 从 dws chat search 获取 openConversationId`, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -6062,16 +6119,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Selection: contract.SelectionSpec{ AgentSummary: "下载消息中的媒体资源到本地", UseWhen: []string{"已知消息、会话和资源 ID,需要保存媒体文件时"}, - AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令"}, + AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令", "资源是钉盘 fileId 时使用钉盘/drive 下载命令"}, Examples: []string{"dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ."}, }, }, }) // download-media flags - chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId (必填)") + chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId(必填;仅支持聊天消息 mediaId,不支持钉盘 fileId)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("type") - chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填)") + chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值(必填;不是钉盘 fileId)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("resource-id") chatMessageDownloadMediaCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id") diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 6b12c044..a5f0bacd 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -160,6 +160,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) os.Args = []string{"dws", "chat"} t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) caller := &productExampleCaller{} + if got := formatChatMessageListAllTime(0); got == "" { + t.Fatal("formatChatMessageListAllTime returned empty string") + } commands := [][]string{ {"chmod", "chat.read", "--ttl="}, @@ -168,9 +171,17 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"}, {"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"}, {"message", "list-direct", "--user=D-user", "--limit=1"}, + {"message", "list-all"}, + {"message", "list-all", "--end=2026-01-02T00:00:00Z"}, + {"message", "list-all", "--end=bad"}, + {"message", "list-all", "--start=not-a-time", "--end=2026-01-02T00:00:00Z"}, + {"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"}, + {"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"}, + {"message", "list-all", "--start=2027-01-01 00:00:00"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"}, + {"message", "list-by-sender", "--sender-user-id=u1", "--end=2026-01-02T00:00:00Z"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z"}, {"message", "list-by-sender", "--sender-open-dingtalk-id=D1", "--start=2026-01-01T00:00:00Z"}, {"message", "list-mentions", "--start=bad", "--end=2026-01-02T00:00:00Z"}, diff --git a/internal/helpers/chat_message_help_test.go b/internal/helpers/chat_message_help_test.go index fcf1548c..9e528d3c 100644 --- a/internal/helpers/chat_message_help_test.go +++ b/internal/helpers/chat_message_help_test.go @@ -90,3 +90,96 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing }) } } + +func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *testing.T) { + tests := []struct { + name string + args []string + contains []string + absent []string + }{ + { + name: "list", + args: []string{"message", "list", "--help"}, + contains: []string{ + "--time 可选,不传时默认上海时间当前时间并向旧消息拉取", + "默认上海时间当前时间", + "未传 --time 时默认 older", + }, + absent: []string{"开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)"}, + }, + { + name: "search", + args: []string{"message", "search", "--help"}, + contains: []string{ + "可选,不传时默认最近 7 天到当前时间", + "默认当前时间前 7 天", + "默认当前时间", + }, + absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"}, + }, + { + name: "list-by-sender", + args: []string{"message", "list-by-sender", "--help"}, + contains: []string{ + "--start 和 --end 可选,不传时默认最近 7 天到当前时间", + "默认当前时间前 7 天", + "默认当前时间", + }, + absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"}, + }, + { + name: "list-mentions", + args: []string{"message", "list-mentions", "--help"}, + contains: []string{ + "--start 和 --end 可选,不传时默认最近 7 天到当前时间", + "默认当前时间前 7 天", + "默认当前时间", + }, + absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"}, + }, + { + name: "list-all", + args: []string{"message", "list-all", "--help"}, + contains: []string{ + "--start 和 --end 可选,不传时默认最近 1 天到当前时间", + "默认当前时间前 1 天", + "默认当前时间", + }, + absent: []string{"起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)"}, + }, + { + name: "download-media", + args: []string{"message", "download-media", "--help"}, + contains: []string{ + "只支持聊天消息 mediaId 下载,不支持钉盘 fileId", + "fileId 下载请使用钉盘/drive 下载命令", + "仅支持聊天消息 mediaId,不支持钉盘 fileId", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newChatCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs(test.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("dws chat %s: %v\n%s", strings.Join(test.args, " "), err, output.String()) + } + help := output.String() + for _, want := range test.contains { + if !strings.Contains(help, want) { + t.Errorf("chat message %s help missing %q:\n%s", test.name, want, help) + } + } + for _, unwanted := range test.absent { + if strings.Contains(help, unwanted) { + t.Errorf("chat message %s help still contains %q:\n%s", test.name, unwanted, help) + } + } + }) + } +} diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 21bac2c7..e947f489 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "reflect" + "strings" "testing" "time" @@ -148,6 +149,356 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) { } } +func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) { + tests := []struct { + name string + args []string + wantTool string + wantLookback time.Duration + }{ + { + name: "list-all defaults to one day", + args: []string{"message", "list-all"}, + wantTool: "search_messages_by_time_range", + wantLookback: 24 * time.Hour, + }, + { + name: "list-by-sender defaults to seven days", + args: []string{"message", "list-by-sender", "--sender-user-id", "u1"}, + wantTool: "search_messages_by_sender", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "list-mentions defaults to seven days", + args: []string{"message", "list-mentions"}, + wantTool: "search_at_me_message", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "search defaults to seven days", + args: []string{"message", "search", "--query", "发布"}, + wantTool: "search_messages_by_keyword", + wantLookback: 7 * 24 * time.Hour, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + before := time.Now() + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + if got.tool != tt.wantTool { + t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) + } + startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool) + endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool) + wantEndMin := before.Truncate(time.Second).UnixMilli() + wantEndMax := after.Add(time.Second).UnixMilli() + if endMs < wantEndMin || endMs > wantEndMax { + t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax) + } + wantStartMin := before.Add(-tt.wantLookback).Truncate(time.Second).UnixMilli() + wantStartMax := after.Add(-tt.wantLookback).Add(time.Second).UnixMilli() + if startMs < wantStartMin || startMs > wantStartMax { + t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax) + } + if tt.wantTool == "search_messages_by_time_range" { + assertChatMessageListAllTimeFormat(t, got.args["startTime"]) + assertChatMessageListAllTimeFormat(t, got.args["endTime"]) + } + }) + } +} + +func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { + endRaw := "2026-01-01T00:00:00+08:00" + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + args []string + wantTool string + wantLookback time.Duration + }{ + { + name: "list-all defaults start one day before explicit end", + args: []string{"message", "list-all", "--end", endRaw}, + wantTool: "search_messages_by_time_range", + wantLookback: 24 * time.Hour, + }, + { + name: "list-by-sender defaults start seven days before explicit end", + args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--end", endRaw}, + wantTool: "search_messages_by_sender", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "list-mentions defaults start seven days before explicit end", + args: []string{"message", "list-mentions", "--end", endRaw}, + wantTool: "search_at_me_message", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "search defaults start seven days before explicit end", + args: []string{"message", "search", "--query", "发布", "--end", endRaw}, + wantTool: "search_messages_by_keyword", + wantLookback: 7 * 24 * time.Hour, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + if got.tool != tt.wantTool { + t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) + } + startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool) + gotEndMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool) + if gotEndMs != endMs { + t.Fatalf("endTime = %d, want %d", gotEndMs, endMs) + } + wantStartMs := time.UnixMilli(endMs).Add(-tt.wantLookback).UnixMilli() + if startMs != wantStartMs { + t.Fatalf("startTime = %d, want %d", startMs, wantStartMs) + } + if tt.wantTool == "search_messages_by_time_range" { + assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs)) + assertStringArg(t, got.args["endTime"], endRaw) + } + }) + } +} + +func TestChatMessageListAllDefaultStartFromTimezoneLessEndUsesShanghai(t *testing.T) { + previousLocal := time.Local + t.Cleanup(func() { time.Local = previousLocal }) + + for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} { + t.Run(loc.String(), func(t *testing.T) { + time.Local = loc + caller := &chatMessagePaginationCaller{} + _, err := executeChatMessagePaginationCommand(t, caller, "message", "list-all", "--end", "2026-03-01 00:00:00") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + assertStringArg(t, got.args["endTime"], "2026-03-01 00:00:00") + assertStringArg(t, got.args["startTime"], "2026-02-28 00:00:00") + + startMs, err := parseISOTimeToMillis("start", got.args["startTime"].(string)) + if err != nil { + t.Fatal(err) + } + endMs, err := parseISOTimeToMillis("end", got.args["endTime"].(string)) + if err != nil { + t.Fatal(err) + } + if gotWindow := time.Duration(endMs-startMs) * time.Millisecond; gotWindow != 24*time.Hour { + t.Fatalf("window = %v, want 24h", gotWindow) + } + }) + } +} + +func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing.T) { + startRaw := "2026-01-01T00:00:00+08:00" + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + args []string + wantTool string + }{ + { + name: "list-all defaults end to now", + args: []string{"message", "list-all", "--start", startRaw}, + wantTool: "search_messages_by_time_range", + }, + { + name: "list-by-sender defaults end to now", + args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", startRaw}, + wantTool: "search_messages_by_sender", + }, + { + name: "list-mentions defaults end to now", + args: []string{"message", "list-mentions", "--start", startRaw}, + wantTool: "search_at_me_message", + }, + { + name: "search defaults end to now", + args: []string{"message", "search", "--query", "发布", "--start", startRaw}, + wantTool: "search_messages_by_keyword", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + before := time.Now() + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + if got.tool != tt.wantTool { + t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) + } + gotStartMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool) + if gotStartMs != startMs { + t.Fatalf("startTime = %d, want %d", gotStartMs, startMs) + } + endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool) + wantEndMin := before.Truncate(time.Second).UnixMilli() + wantEndMax := after.Add(time.Second).UnixMilli() + if endMs < wantEndMin || endMs > wantEndMax { + t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax) + } + if tt.wantTool == "search_messages_by_time_range" { + assertStringArg(t, got.args["startTime"], startRaw) + assertChatMessageListAllTimeFormat(t, got.args["endTime"]) + } + }) + } +} + +func TestChatMessageListAllRejectsInvalidTimeBounds(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "invalid start", + args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"}, + wantErr: "cannot parse time for --start", + }, + { + name: "end before start", + args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"}, + wantErr: "--end must be after --start", + }, + { + name: "default end before future start", + args: []string{"message", "list-all", "--start", "2027-01-01 00:00:00"}, + wantErr: "--end must be after --start", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want contains %q", err.Error(), tt.wantErr) + } + if len(caller.calls) != 0 { + t.Fatalf("calls = %#v, want no MCP call", caller.calls) + } + }) + } +} + +func numericArgAsInt64(t *testing.T, value any) int64 { + t.Helper() + switch v := value.(type) { + case int64: + return v + case int: + return int64(v) + case float64: + return int64(v) + case string: + parsed, err := parseISOTimeToMillis("time", v) + if err != nil { + t.Fatalf("parse time arg %q: %v", v, err) + } + return parsed + default: + t.Fatalf("unsupported numeric arg type %T (%#v)", value, value) + return 0 + } +} + +func chatMessageTimeArgAsMillis(t *testing.T, value any, tool string) int64 { + t.Helper() + if tool == "search_messages_by_time_range" { + return parseChatMessageListAllTimeArg(t, value).UnixMilli() + } + return numericArgAsInt64(t, value) +} + +func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time { + t.Helper() + raw, ok := value.(string) + if !ok { + t.Fatalf("time arg = %#v, want time string", value) + } + if strings.Contains(raw, "T") { + parsedMs, err := parseISOTimeToMillis("time", raw) + if err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } + return time.UnixMilli(parsedMs) + } + parsedMs, err := parseISOTimeToMillis("time", raw) + if err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } + return time.UnixMilli(parsedMs) +} + +func assertChatMessageListAllTimeFormat(t *testing.T, value any) { + t.Helper() + raw, ok := value.(string) + if !ok { + t.Fatalf("time arg = %#v, want time string", value) + } + if strings.Contains(raw, "T") { + t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw) + } + if _, err := parseISOTimeToMillis("time", raw); err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } +} + +func assertStringArg(t *testing.T, value any, want string) { + t.Helper() + got, ok := value.(string) + if !ok || got != want { + t.Fatalf("arg = %#v, want %q", value, want) + } +} + func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) { tests := []struct { name string diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 340a3bd6..bdf1da88 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -724,6 +724,99 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing. } } +func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) { + previousLocal := time.Local + t.Cleanup(func() { time.Local = previousLocal }) + + tests := []struct { + name string + args []string + wantTool string + wantTarget map[string]any + }{ + { + name: "group", + args: []string{"message", "list", "--group", "cid-1", "--limit", "50"}, + wantTool: "list_conversation_message_v2", + wantTarget: map[string]any{"openconversation_id": "cid-1"}, + }, + { + name: "user", + args: []string{"message", "list", "--user", "user-1", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"userId": "user-1"}, + }, + { + name: "user open DingTalk ID fallback", + args: []string{"message", "list", "--user", "D-user", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"openDingTalkId": "D-user"}, + }, + { + name: "open DingTalk ID", + args: []string{"message", "list", "--open-dingtalk-id", "D-open", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"openDingTalkId": "D-open"}, + }, + { + name: "direct user", + args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"userId": "user-1"}, + }, + { + name: "direct open DingTalk ID", + args: []string{"message", "list-direct", "--open-dingtalk-id", "D-direct", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"openDingTalkId": "D-direct"}, + }, + } + + for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} { + t.Run(loc.String(), func(t *testing.T) { + time.Local = loc + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatChangedContractCaller{} + before := time.Now() + err := executeChatChangedContract(t, caller, tt.args...) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one MCP call", caller.calls) + } + if caller.calls[0].toolName != tt.wantTool { + t.Fatalf("tool = %q, want %q", caller.calls[0].toolName, tt.wantTool) + } + for key, want := range tt.wantTarget { + if got := caller.calls[0].args[key]; got != want { + t.Fatalf("arg %s = %#v, want %#v", key, got, want) + } + } + raw, ok := caller.calls[0].args["time"].(string) + if !ok || raw == "" { + t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"]) + } + gotMs, err := parseISOTimeToMillis("time", raw) + if err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } + wantMin := before.Add(-time.Second).UnixMilli() + wantMax := after.Add(time.Second).UnixMilli() + if gotMs < wantMin || gotMs > wantMax { + t.Fatalf("time arg = %q (%d), want between %d and %d", raw, gotMs, wantMin, wantMax) + } + if caller.calls[0].args["forward"] != false { + t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"]) + } + }) + } + }) + } +} + func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) { caller := &chatChangedContractCaller{} err := executeChatChangedContract(t, caller, diff --git a/internal/helpers/im_read_result_contract_test.go b/internal/helpers/im_read_result_contract_test.go index 3cff91cc..9494069d 100644 --- a/internal/helpers/im_read_result_contract_test.go +++ b/internal/helpers/im_read_result_contract_test.go @@ -21,7 +21,9 @@ import ( "io" "os" "reflect" + "strings" "testing" + "time" "github.com/spf13/cobra" @@ -39,11 +41,16 @@ type imReadResultCaller struct { results map[string]*edition.ToolResult errors map[string]error calls []imReadResultCall + args map[string]any dryRun bool } -func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) { +func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { c.calls = append(c.calls, imReadResultCall{productID: productID, toolName: toolName}) + c.args = map[string]any{} + for k, v := range args { + c.args[k] = v + } if err := c.errors[toolName]; err != nil { return nil, err } @@ -203,6 +210,37 @@ func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLeg } } +func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *testing.T) { + payload := `{"result":{"messages":[],"hasMore":false}}` + caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}} + loc := shanghaiLocation() + + before := time.Now().In(loc) + _, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, + "message", "list", "--group", "cid-1", "--limit", "50") + after := time.Now().In(loc) + if err != nil { + t.Fatalf("chat message list returned error: %v", err) + } + 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) + } + if got := caller.args["forward"]; got != false { + t.Fatalf("forward = %#v, want false when --time is omitted", got) + } + timeRaw, ok := caller.args["time"].(string) + if !ok || strings.TrimSpace(timeRaw) == "" { + t.Fatalf("time arg = %#v, want generated current time", caller.args["time"]) + } + gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, loc) + if err != nil { + t.Fatalf("parse generated time %q: %v", timeRaw, err) + } + if gotTime.Before(before.Add(-time.Second)) || gotTime.After(after.Add(time.Second)) { + t.Fatalf("time = %s, want between %s and %s", gotTime, before, after) + } +} + func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) { payload := `{ "result": { diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt index fab13963..db802415 100644 --- a/test/fixtures/cli-interface-baseline.txt +++ b/test/fixtures/cli-interface-baseline.txt @@ -2692,7 +2692,7 @@ [chat.message.list-all] runnable: true hidden: false - flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.list-by-ids] runnable: true @@ -2702,7 +2702,7 @@ [chat.message.list-by-sender] runnable: true hidden: false - flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --sender:string|required=false|hidden=true|no-opt=""|scope=local, --sender-open-dingtalk-id:string|required=false|hidden=false|no-opt=""|scope=local, --sender-user-id:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --user:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --sender:string|required=false|hidden=true|no-opt=""|scope=local, --sender-open-dingtalk-id:string|required=false|hidden=false|no-opt=""|scope=local, --sender-user-id:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --user:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.list-emotion-replies] runnable: true @@ -2722,7 +2722,7 @@ [chat.message.list-mentions] runnable: true hidden: false - flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.list-pin-msg] runnable: true @@ -2782,7 +2782,7 @@ [chat.message.search] runnable: true hidden: false - flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=true|no-opt=""|scope=local, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --query:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=true|no-opt=""|scope=local, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --query:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.search-advanced] runnable: true