Merge pull request #973 from xlb1130/feat/85378080-chat-message-time-defaults

feat(chat): default message query time ranges
This commit is contained in:
github-actions[bot]
2026-08-14 00:01:32 +08:00
committed by GitHub
8 changed files with 707 additions and 59 deletions
@@ -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.
+111 -54
View File
@@ -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 <openconversation_id> --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 <openconversation_id>
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
@@ -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 <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
"dws chat message list --group <openConversationId> --limit 50",
"dws chat message list --group <openConversationId> --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 <userId> --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 <openDingTalkId> --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 <userId> --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 <openconversation_id> --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 <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>
@@ -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 <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --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 <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --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")
@@ -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"},
@@ -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)
}
}
})
}
}
@@ -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
@@ -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,
@@ -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": {
+4 -4
View File
@@ -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