diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61dfcfce..dfafcb02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,7 @@ jobs: filename === '.github/actionlint.yaml' || filename.startsWith('scripts/') || filename.startsWith('verify/') || + filename.startsWith('internal/helpers/') || filename.startsWith('internal/generator/') || filename.startsWith('internal/cli/schema') || filename.startsWith('internal/interfacesnapshot/') || diff --git a/CHANGELOG.md b/CHANGELOG.md index ba478a89..5306b6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th targets to the initial card-creation request and prepending its returned `atTag` to the automatic streaming update. +### Fixed + +- **Machine-readable export and download receipts** — `dws doc export`, + `dws drive download`, and `dws drive download --version` now keep progress + logs on stderr under `--format json` and emit one JSON result on stdout after + a successful local write. The result includes the saved path and byte size; + document exports additionally report the node, requested format, job/task + ID, and final status. +### Changed + +- **Minutes `permission apply --policy` type** — `--policy` is now declared as + an `int` flag and its required check uses `Flags().Changed`, matching the + numeric-parameter convention. `--help` reports `int` instead of `string`; + accepted values (2/3/4) and gateway behavior are unchanged. +- **Minutes skill references** — document `permission apply` in both Minutes + skill references: list it in the command trees, describe its policy values and + how it differs from `permission add`, and add its intent routing. + ## [1.0.58-beta.2] - 2026-08-10 ### Added diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index c94b2c4c..2595ee69 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -256,6 +256,10 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch", "chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch", "chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.", + "chat.list_message_favorites --max-items": "Reviewed helper-only pagination control: --max-items caps aggregated CLI output for chat.list_message_favorites and is not sent to the remote adapter.", + "chat.list_message_favorites --page-all": "Reviewed helper-only pagination control: --page-all enables CLI-side looping for chat.list_message_favorites and is not sent to the remote adapter.", + "chat.list_message_favorites --page-delay": "Reviewed helper-only pagination control: --page-delay controls local delay between chat.list_message_favorites pages and is not sent to the remote adapter.", + "chat.list_message_favorites --page-limit": "Reviewed helper-only pagination control: --page-limit caps CLI-side page fetches for chat.list_message_favorites and is not sent to the remote adapter.", "chat.list_message_favorites --size": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --size is a CLI wrapper input and does not publish a direct interface property.", "chat.query_msg_read_status --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag", "chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag", diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 9a1c4e9f..bd9a013a 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -119,6 +119,275 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string return v } +func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { + if err := validateRequiredFlags(cmd, "start", "end"); err != nil { + return nil, err + } + cursor, _ := cmd.Flags().GetString("cursor") + return map[string]any{ + "startTime": mustGetFlag(cmd, "start"), + "endTime": mustGetFlag(cmd, "end"), + "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 + } + senderUserID := flagOrFallback(cmd, "sender-user-id", "sender") + senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id") + if senderUserID != "" && senderOpenDingTalkID != "" { + return nil, fmt.Errorf("--sender-user-id and --sender-open-dingtalk-id are mutually exclusive, specify exactly one") + } + if senderUserID == "" && senderOpenDingTalkID == "" { + return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required") + } + startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + if err != nil { + return nil, err + } + endRaw, _ := cmd.Flags().GetString("end") + if strings.TrimSpace(endRaw) == "" { + endRaw = time.Now().Format(time.RFC3339) + } + 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") + toolArgs := map[string]any{ + "startTime": startMs, + "endTime": endMs, + "limit": chatIntFlagOrFallback(cmd, "limit", "size"), + "cursor": cursor, + } + if senderUserID != "" { + toolArgs["senderUserId"] = senderUserID + } else { + toolArgs["senderOpenDingTalkId"] = senderOpenDingTalkID + } + return toolArgs, nil +} + +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")) + if err != nil { + return nil, err + } + endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) + if err != nil { + return nil, err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return nil, err + } + cursor, _ := cmd.Flags().GetString("cursor") + toolArgs := map[string]any{ + "startTime": startMs, + "endTime": endMs, + "limit": chatIntFlagOrFallback(cmd, "limit", "size"), + "cursor": cursor, + } + if groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat"); groupID != "" { + toolArgs["openConversationId"] = groupID + } + return toolArgs, nil +} + +func chatMessageListFocusedArgs(cmd *cobra.Command) (map[string]any, error) { + toolArgs := map[string]any{} + if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { + toolArgs["limit"] = v + } + if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 { + toolArgs["cursor"] = v + } + return toolArgs, nil +} + +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")) + if err != nil { + return nil, err + } + endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) + if err != nil { + return nil, err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return nil, err + } + cursor, _ := cmd.Flags().GetString("cursor") + toolArgs := map[string]any{ + "keyword": flagOrFallback(cmd, "query", "keyword"), + "startTime": startMs, + "endTime": endMs, + "limit": chatIntFlagOrFallback(cmd, "limit", "size"), + "cursor": cursor, + } + if groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat"); groupID != "" { + toolArgs["openConversationId"] = groupID + } + return toolArgs, nil +} + +func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { + toolArgs := map[string]any{} + if v := flagOrFallback(cmd, "query", "keyword"); v != "" { + toolArgs["keyword"] = v + } + if v := flagOrFallback(cmd, "users", "user", "userId"); v != "" { + appendChatIDArgs(toolArgs, parseCSVValues(v), "senderUserIds", "senderOpenDingTakIds") + } + if v := flagOrFallback(cmd, "sender-ids", "senders", "sender"); v != "" { + appendChatIDArgs(toolArgs, parseCSVValues(v), "senderUserIds", "senderOpenDingTakIds") + } + if v, _ := cmd.Flags().GetBool("at-me"); v { + toolArgs["atMe"] = true + } + if v, _ := cmd.Flags().GetString("at-ids"); v != "" { + appendChatIDArgs(toolArgs, parseCSVValues(v), "atUserIds", "atOpenDingTakIds") + } + appendChatConversationIDs(cmd, toolArgs) + if err := appendChatAdvancedFilters(cmd, toolArgs); err != nil { + return nil, err + } + return toolArgs, nil +} + +func appendChatConversationIDs(cmd *cobra.Command, toolArgs map[string]any) { + convIds := flagOrFallback(cmd, "conversation-ids", "groups", "group") + if convIds == "" { + return + } + var ids []string + for _, s := range strings.Split(convIds, ",") { + if t := strings.TrimSpace(s); t != "" { + ids = append(ids, t) + } + } + if len(ids) > 0 { + toolArgs["openConversationIds"] = ids + } +} + +func appendChatAdvancedFilters(cmd *cobra.Command, toolArgs map[string]any) error { + if v, _ := cmd.Flags().GetString("message-type"); v != "" { + toolArgs["messageType"] = v + } + if cmd.Flags().Changed("only-robot") { + toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot") + } else if cmd.Flags().Changed("only-robot-messages") { + toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot-messages") + } + if v := flagOrFallback(cmd, "conversation-type", "search-conv-type"); v != "" { + toolArgs["searchConvType"] = v + } + if v, _ := cmd.Flags().GetString("start"); v != "" { + ms, err := parseISOTimeToMillis("start", v) + if err != nil { + return err + } + toolArgs["startTime"] = ms + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + ms, err := parseISOTimeToMillis("end", v) + if err != nil { + return err + } + toolArgs["endTime"] = ms + } + if v, _ := cmd.Flags().GetString("cursor"); v != "" { + toolArgs["cursor"] = v + } + if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { + toolArgs["limit"] = v + } + return nil +} + +func chatMessageListFavoritesArgs(cmd *cobra.Command) (map[string]any, error) { + cursor, _ := cmd.Flags().GetInt64("cursor") + if cursor < 0 { + return nil, apperrors.NewValidation("--cursor must be greater than or equal to 0") + } + size, _ := cmd.Flags().GetInt("size") + if size < 1 || size > chatFavoritesMaxPageSize { + return nil, apperrors.NewValidation("--size must be between 1 and 30") + } + return map[string]any{"cursor": cursor, "size": strconv.Itoa(size)}, nil +} + +func pagedChatMessagesConfig(toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { + return PagedMCPCommandConfig{ + ServerID: "chat", + ToolName: toolName, + ItemPath: "result.messages", + CursorPath: "result.nextCursor", + HasMorePath: "result.hasMore", + CursorArg: "cursor", + CursorKind: PagedCursorString, + BuildArgs: build, + Fallback: func(args map[string]any) error { + return callMCPTool(toolName, args) + }, + } +} + +func pagedChatConversationMessagesConfig(toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { + cfg := pagedChatMessagesConfig(toolName, build) + cfg.ItemPath = "result.conversationMessagesList" + cfg.AggregationMode = PagedAggregationConversationMessages + return cfg +} + +func pagedChatConversationMessagesOnServerConfig(serverID, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { + cfg := pagedChatConversationMessagesConfig(toolName, build) + cfg.ServerID = serverID + cfg.Fallback = func(args map[string]any) error { + return callMCPToolOnServer(serverID, toolName, args) + } + return cfg +} + +func pagedChatMessagesOnServerConfig(serverID, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { + cfg := pagedChatMessagesConfig(toolName, build) + cfg.ServerID = serverID + cfg.Fallback = func(args map[string]any) error { + return callMCPToolOnServer(serverID, toolName, args) + } + return cfg +} + +func pagedChatMessagesInt64Config(toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { + cfg := pagedChatMessagesConfig(toolName, build) + cfg.CursorKind = PagedCursorInt64 + return cfg +} + +func pagedMCPParamDecls() []contract.ParamDecl { + return []contract.ParamDecl{ + {Name: "page-all", InterfaceType: "boolean"}, + {Name: "page-limit", InterfaceType: "integer"}, + {Name: "max-items", InterfaceType: "integer"}, + {Name: "page-delay", InterfaceType: "integer"}, + } +} + func runChatGroupSearch(cmd *cobra.Command, args []string) error { keyword := flagOrFallback(cmd, "query", "keyword", "name", "group") if len(args) == 1 { @@ -2725,22 +2994,13 @@ func newChatCommand() *cobra.Command { chatMessageListAllCmd := &cobra.Command{ Use: "list-all", Short: "拉取指定时间范围内当前用户的所有会话消息", - Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`, + 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 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`, 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 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`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return err - } - limit := chatIntFlagOrFallback(cmd, "limit", "size") - cursor, _ := cmd.Flags().GetString("cursor") - toolArgs := map[string]any{ - "startTime": mustGetFlag(cmd, "start"), - "endTime": mustGetFlag(cmd, "end"), - "limit": limit, - "cursor": cursor, - } - return callMCPTool("search_messages_by_time_range", toolArgs) + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( + "search_messages_by_time_range", chatMessageListAllArgs)) }, } DeclareLeafMetadata(chatMessageListAllCmd, LeafSpec{ @@ -2766,66 +3026,32 @@ func newChatCommand() *cobra.Command { AgentSummary: "按时间范围搜索跨会话消息并保留权益指引", UseWhen: []string{"需要汇总一段时间内所有可见会话消息时"}, AvoidWhen: []string{"已指定单个会话时优先使用 chat message list"}, - Examples: []string{"dws chat message list-all --start \"2026-07-01 00:00:00\" --end \"2026-07-02 00:00:00\" --limit 50"}, + Examples: []string{ + "dws chat message list-all --start \"2026-07-01 00:00:00\" --end \"2026-07-02 00:00:00\" --limit 50", + "dws chat message list-all --start \"2026-07-01 00:00:00\" --end \"2026-07-02 00:00:00\" --limit 100 --page-all --page-limit 20", + }, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) 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 继续翻页。`, + 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 控制页间等待毫秒数。`, 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 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 + 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 --page-all --page-limit 10 --page-delay 0 # 查询 userId: dws contact user search --query "姓名" # 查询 openDingTalkId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "start"); err != nil { - return err - } - senderUserID := flagOrFallback(cmd, "sender-user-id", "sender") - senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id") - if senderUserID != "" && senderOpenDingTalkID != "" { - return fmt.Errorf("--sender-user-id and --sender-open-dingtalk-id are mutually exclusive, specify exactly one") - } - if senderUserID == "" && senderOpenDingTalkID == "" { - return fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required") - } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) - if err != nil { - return err - } - endRaw, _ := cmd.Flags().GetString("end") - if strings.TrimSpace(endRaw) == "" { - endRaw = time.Now().Format(time.RFC3339) - } - endMs, err := parseISOTimeToMillis("end", endRaw) - if err != nil { - return err - } - if err := validateTimeRange(startMs, endMs); err != nil { - return err - } - limit := chatIntFlagOrFallback(cmd, "limit", "size") - cursor, _ := cmd.Flags().GetString("cursor") - toolArgs := map[string]any{ - "startTime": startMs, - "endTime": endMs, - "limit": limit, - "cursor": cursor, - } - if senderUserID != "" { - toolArgs["senderUserId"] = senderUserID - } else { - toolArgs["senderOpenDingTalkId"] = senderOpenDingTalkID - } - return callMCPTool("search_messages_by_sender", toolArgs) + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( + "search_messages_by_sender", chatMessageListBySenderArgs)) }, } DeclareLeafMetadata(chatMessageListBySenderCmd, LeafSpec{ @@ -2851,53 +3077,32 @@ func newChatCommand() *cobra.Command { AgentSummary: "按发送者和时间范围查询消息", UseWhen: []string{"需要查某人发送过的消息且不限定单聊时"}, AvoidWhen: []string{"明确查询与某人的单聊记录时使用 chat message list-direct"}, - Examples: []string{"dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50"}, + Examples: []string{ + "dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50", + "dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50 --page-all --page-limit 10", + }, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "sender-open-dingtalk-id", Property: "senderOpenDingTalkId"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) chatMessageListMentionsCmd := &cobra.Command{ Use: "list-mentions", Short: "拉取 @我 的消息", - Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, + 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 控制页间等待毫秒数。`, 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 dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor + dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --max-items 200 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return err - } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) - if err != nil { - return err - } - endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) - if err != nil { - return err - } - if err := validateTimeRange(startMs, endMs); err != nil { - return err - } - limit := chatIntFlagOrFallback(cmd, "limit", "size") - cursor, _ := cmd.Flags().GetString("cursor") - toolArgs := map[string]any{ - "startTime": startMs, - "endTime": endMs, - "limit": limit, - "cursor": cursor, - } - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") - if groupID != "" { - toolArgs["openConversationId"] = groupID - } - return callMCPTool("search_at_me_message", toolArgs) + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( + "search_at_me_message", chatMessageListMentionsArgs)) }, } DeclareLeafMetadata(chatMessageListMentionsCmd, LeafSpec{ @@ -2923,31 +3128,29 @@ func newChatCommand() *cobra.Command { AgentSummary: "查询指定时间范围内提及当前用户的消息", UseWhen: []string{"需要找出 @我的消息和待关注事项时"}, AvoidWhen: []string{"查询全部消息时使用 chat message list-all"}, - Examples: []string{"dws chat message list-mentions --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50"}, + Examples: []string{ + "dws chat message list-mentions --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50", + "dws chat message list-mentions --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50 --page-all --max-items 200", + }, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "group", Property: "openConversationId"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) chatMessageListFocusedCmd := &cobra.Command{ Use: "list-focused", Short: "拉取特别关注人的消息", - Long: `拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。`, + Long: `拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传数字分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用数字 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-focused --limit 50 - dws chat message list-focused --limit 20 --cursor `, + dws chat message list-focused --limit 20 --cursor + dws chat message list-focused --limit 50 --page-all --page-limit 10 --page-delay 0`, RunE: func(cmd *cobra.Command, args []string) error { - toolArgs := map[string]any{} - if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { - toolArgs["limit"] = v - } - if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 { - toolArgs["cursor"] = v - } - return callMCPTool("list_special_focus_messages", toolArgs) + return RunPagedMCPCommand(cmd, pagedChatMessagesInt64Config( + "list_special_focus_messages", chatMessageListFocusedArgs)) }, } DeclareLeafMetadata(chatMessageListFocusedCmd, LeafSpec{ @@ -2973,8 +3176,12 @@ func newChatCommand() *cobra.Command { AgentSummary: "列出当前用户特别关注的消息", UseWhen: []string{"需要查看特别关注或重点消息列表时"}, AvoidWhen: []string{"普通未读消息或提及消息使用对应专用命令"}, - Examples: []string{"dws chat message list-focused --limit 50"}, + Examples: []string{ + "dws chat message list-focused --limit 50", + "dws chat message list-focused --limit 50 --page-all --page-limit 10", + }, }, + Parameters: pagedMCPParamDecls(), }, }) @@ -3074,43 +3281,15 @@ func newChatCommand() *cobra.Command { chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", - Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, + Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --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 控制页间等待毫秒数。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor + dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil { - return err - } - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return err - } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) - if err != nil { - return err - } - endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) - if err != nil { - return err - } - if err := validateTimeRange(startMs, endMs); err != nil { - return err - } - limit := chatIntFlagOrFallback(cmd, "limit", "size") - cursor, _ := cmd.Flags().GetString("cursor") - toolArgs := map[string]any{ - "keyword": flagOrFallback(cmd, "query", "keyword"), - "startTime": startMs, - "endTime": endMs, - "limit": limit, - "cursor": cursor, - } - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") - if groupID != "" { - toolArgs["openConversationId"] = groupID - } - return callMCPTool("search_messages_by_keyword", toolArgs) + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( + "search_messages_by_keyword", chatMessageSearchArgs)) }, } DeclareLeafMetadata(chatMessageSearchCmd, LeafSpec{ @@ -3136,14 +3315,17 @@ func newChatCommand() *cobra.Command { AgentSummary: "按关键词和时间范围搜索消息", UseWhen: []string{"需要用关键词查找消息且过滤条件较简单时"}, AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"}, - Examples: []string{"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\""}, + Examples: []string{ + "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"", + "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300", + }, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "group", Property: "openConversationId"}, {Name: "query", Property: "keyword"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) @@ -3152,7 +3334,7 @@ func newChatCommand() *cobra.Command { chatMessageSearchAdvancedCmd := &cobra.Command{ Use: "search-advanced", Short: "多维度搜索消息", - Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。`, + Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --user --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --users , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" @@ -3160,105 +3342,13 @@ func newChatCommand() *cobra.Command { dws chat message search-advanced --at-me --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --at-ids , --conversation-ids , --limit 50 --cursor 0 dws chat message search-advanced --conversation-ids <单聊openConversationId> --query "合同" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --page-limit 20 --max-items 500 # 查询群 ID: dws chat search --query "群名" # 查询单聊会话 ID: dws chat conversation-info --user # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - toolArgs := map[string]any{} - - // The CLI primary is --query; the IM MCP field is still named "keyword". - if v := flagOrFallback(cmd, "query", "keyword"); v != "" { - toolArgs["keyword"] = v - } - - // --user/--userId are the preferred userId inputs for sender filtering. - if v := flagOrFallback(cmd, "users", "user", "userId"); v != "" { - appendChatIDArgs(toolArgs, parseCSVValues(v), "senderUserIds", "senderOpenDingTakIds") - } - - // sender-ids -> senderOpenDingTakIds / senderUserIds(注意:MCP 入参字段名缺少字母 l) - if v := flagOrFallback(cmd, "sender-ids", "senders", "sender"); v != "" { - ids := parseCSVValues(v) - if len(ids) > 0 { - appendChatIDArgs(toolArgs, ids, "senderUserIds", "senderOpenDingTakIds") - } - } - - // at-me - if v, _ := cmd.Flags().GetBool("at-me"); v { - toolArgs["atMe"] = true - } - - // at-ids -> atOpenDingTakIds / atUserIds(注意:MCP 入参字段名缺少字母 l) - if v, _ := cmd.Flags().GetString("at-ids"); v != "" { - ids := parseCSVValues(v) - if len(ids) > 0 { - appendChatIDArgs(toolArgs, ids, "atUserIds", "atOpenDingTakIds") - } - } - - // conversation-ids / groups / group -> openConversationIds - convIds := "" - if v, _ := cmd.Flags().GetString("conversation-ids"); v != "" { - convIds = v - } else if v, _ := cmd.Flags().GetString("groups"); v != "" { - convIds = v - } else if v, _ := cmd.Flags().GetString("group"); v != "" { - convIds = v - } - if convIds != "" { - var ids []string - for _, s := range strings.Split(convIds, ",") { - if t := strings.TrimSpace(s); t != "" { - ids = append(ids, t) - } - } - if len(ids) > 0 { - toolArgs["openConversationIds"] = ids - } - } - - if v, _ := cmd.Flags().GetString("message-type"); v != "" { - toolArgs["messageType"] = v - } - if cmd.Flags().Changed("only-robot") { - toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot") - } else if cmd.Flags().Changed("only-robot-messages") { - toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot-messages") - } - if v := flagOrFallback(cmd, "conversation-type", "search-conv-type"); v != "" { - toolArgs["searchConvType"] = v - } - - // start -> startTime (ISO-8601 to milliseconds) - if v, _ := cmd.Flags().GetString("start"); v != "" { - ms, err := parseISOTimeToMillis("start", v) - if err != nil { - return err - } - toolArgs["startTime"] = ms - } - - // end -> endTime (ISO-8601 to milliseconds) - if v, _ := cmd.Flags().GetString("end"); v != "" { - ms, err := parseISOTimeToMillis("end", v) - if err != nil { - return err - } - toolArgs["endTime"] = ms - } - - // cursor - if v, _ := cmd.Flags().GetString("cursor"); v != "" { - toolArgs["cursor"] = v - } - - // limit - if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { - toolArgs["limit"] = v - } - - return callMCPToolOnServer("im", "search_messages", toolArgs) + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesOnServerConfig( + "im", "search_messages", chatMessageSearchAdvancedArgs)) }, } DeclareLeafMetadata(chatMessageSearchAdvancedCmd, LeafSpec{ @@ -3288,9 +3378,12 @@ func newChatCommand() *cobra.Command { "只需拉取某会话时间线时使用 chat message list", "只需某人发给我的消息时使用 chat message list-by-sender", }, - Examples: []string{"dws chat message search-advanced --query \"周报\" --start \"2026-04-01T00:00:00+08:00\" --end \"2026-04-15T00:00:00+08:00\""}, + Examples: []string{ + "dws chat message search-advanced --query \"周报\" --start \"2026-04-01T00:00:00+08:00\" --end \"2026-04-15T00:00:00+08:00\"", + "dws chat message search-advanced --query \"周报\" --start \"2026-04-01T00:00:00+08:00\" --end \"2026-04-15T00:00:00+08:00\" --page-all --page-limit 20", + }, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "at-ids", Property: "atOpenDingTakIds"}, {Name: "conversation-ids", Property: "openConversationIds"}, {Name: "conversation-type", Property: "searchConvType"}, @@ -3300,7 +3393,7 @@ func newChatCommand() *cobra.Command { {Name: "query", Property: "keyword"}, {Name: "sender-ids", Property: "senderOpenDingTakIds"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) @@ -3856,6 +3949,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。` chatMessageListAllCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListAllCmd.Flags().MarkHidden("size") chatMessageListAllCmd.Flags().String("cursor", "0", "分页游标(首页传 \"0\",后续从响应中获取)") + AddPagedMCPFlags(chatMessageListAllCmd) // list-by-sender flags chatMessageListBySenderCmd.Flags().String("sender-user-id", "", "发送者 userId(与 --sender-open-dingtalk-id 二选一)") @@ -3871,6 +3965,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。` chatMessageListBySenderCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListBySenderCmd.Flags().MarkHidden("size") chatMessageListBySenderCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") + AddPagedMCPFlags(chatMessageListBySenderCmd) // list-mentions flags chatMessageListMentionsCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则查全部)") @@ -3882,10 +3977,12 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。` chatMessageListMentionsCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListMentionsCmd.Flags().MarkHidden("size") chatMessageListMentionsCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") + AddPagedMCPFlags(chatMessageListMentionsCmd) // list-focused flags chatMessageListFocusedCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListFocusedCmd.Flags().Int64("cursor", 0, "分页游标(首次不传或传 0,翻页传 nextCursor)") + AddPagedMCPFlags(chatMessageListFocusedCmd) // list-top-conversations flags chatMessageListTopConversationsCmd.Flags().Int("limit", 1000, "每页返回数量(默认 1000)") @@ -3908,6 +4005,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。` chatMessageSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageSearchCmd.Flags().MarkHidden("size") chatMessageSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") + AddPagedMCPFlags(chatMessageSearchCmd) // read-status flags (主 flag 为 --conversation-id,因为支持群聊和单聊) chatMessageReadStatusCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,群聊或单聊均可)") @@ -3979,6 +4077,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。` chatMessageSearchAdvancedCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)") chatMessageSearchAdvancedCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("size") + AddPagedMCPFlags(chatMessageSearchAdvancedCmd) // query-send-status flags chatMessageQuerySendStatusCmd.Flags().String("open-task-id", "", "消息发送任务 ID (必填)") @@ -7483,23 +7582,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Long: `查询当前用户收藏的消息列表,支持数字游标分页。 首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。 -返回 hasMore=true 时,将 nextCursor 作为下一次的 --cursor。`, +返回 hasMore=true 时,将数字 nextCursor 作为下一次的 --cursor。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.items。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-favorites dws chat message list-favorites --size 30 - dws chat message list-favorites --cursor 20 --size 20`, + dws chat message list-favorites --cursor 20 --size 20 + dws chat message list-favorites --size 20 --page-all --page-limit 10 --max-items 100 --page-delay 0`, RunE: func(cmd *cobra.Command, args []string) error { - cursor, _ := cmd.Flags().GetInt64("cursor") - if cursor < 0 { - return apperrors.NewValidation("--cursor must be greater than or equal to 0") - } - size, _ := cmd.Flags().GetInt("size") - if size < 1 || size > chatFavoritesMaxPageSize { - return apperrors.NewValidation("--size must be between 1 and 30") - } - return callMCPToolOnServer("im", "list_message_favorites", map[string]any{ - "cursor": cursor, - "size": strconv.Itoa(size), - }) + cfg := pagedChatMessagesOnServerConfig("im", "list_message_favorites", chatMessageListFavoritesArgs) + cfg.ItemPath = "result.items" + cfg.CursorKind = PagedCursorInt64 + return RunPagedMCPCommand(cmd, cfg) }, } DeclareLeafMetadata(chatMessageListFavoritesCmd, LeafSpec{ @@ -7525,15 +7617,19 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "分页查询当前用户收藏的消息列表。", UseWhen: []string{"需要查看当前用户已经收藏的消息,或使用 nextCursor 继续翻页时。"}, AvoidWhen: []string{"需要搜索普通聊天记录、置顶消息或修改收藏状态时不要使用。"}, - Examples: []string{"dws chat message list-favorites --cursor 0 --size 20"}, + Examples: []string{ + "dws chat message list-favorites --cursor 0 --size 20", + "dws chat message list-favorites --size 20 --page-all --page-limit 10", + }, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "size", InterfaceType: "string"}, - }, + }, pagedMCPParamDecls()...), }, }) chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)") chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-30)") + AddPagedMCPFlags(chatMessageListFavoritesCmd) // ── group list-my-groups: 拉取我创建/管理的群 ────────────── diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go new file mode 100644 index 00000000..3c1a426b --- /dev/null +++ b/internal/helpers/chat_message_pagination_test.go @@ -0,0 +1,267 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "io" + "reflect" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type chatMessagePaginationCaller struct { + steps []scriptedToolStep + calls []pagedCommandCall +} + +func (c *chatMessagePaginationCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { + copied := map[string]any{} + for k, v := range args { + copied[k] = v + } + c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied}) + if len(c.steps) == 0 { + return textToolResult(`{"result":{"messages":[],"items":[],"hasMore":false,"nextCursor":"0"}}`), nil + } + step := c.steps[len(c.calls)-1] + if step.err != nil { + return nil, step.err + } + return textToolResult(step.text), nil +} + +func (*chatMessagePaginationCaller) Format() string { return "json" } +func (*chatMessagePaginationCaller) DryRun() bool { return false } +func (*chatMessagePaginationCaller) Fields() string { return "" } +func (*chatMessagePaginationCaller) JQ() string { return "" } + +func executeChatMessagePaginationCommand(t *testing.T, caller *chatMessagePaginationCaller, args ...string) (map[string]any, error) { + t.Helper() + oldDeps := deps + oldSleep := helperSleep + t.Cleanup(func() { + deps = oldDeps + helperSleep = oldSleep + }) + InitDeps(caller) + out := &bytes.Buffer{} + deps.Out.w = out + deps.Out.errW = io.Discard + helperSleep = func(d time.Duration) {} + + root := newChatCommand() + root.SilenceErrors = true + root.SilenceUsage = true + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + err := root.ExecuteContext(context.Background()) + if out.Len() == 0 { + return nil, err + } + var parsed map[string]any + if unmarshalErr := json.Unmarshal(out.Bytes(), &parsed); unmarshalErr != nil { + t.Fatalf("stdout JSON = %q, err = %v", out.String(), unmarshalErr) + } + return parsed, err +} + +func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) { + tests := []struct { + name string + args []string + server string + tool string + want map[string]any + }{ + { + name: "list-all", + args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"}, + server: "", + tool: "search_messages_by_time_range", + want: map[string]any{"startTime": "2026-08-01 00:00:00", "endTime": "2026-08-02 00:00:00", "limit": 50, "cursor": "0"}, + }, + { + name: "list-by-sender", + args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"}, + server: "", + tool: "search_messages_by_sender", + want: map[string]any{"senderUserId": "u1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"}, + }, + { + name: "list-mentions", + args: []string{"message", "list-mentions", "--group", "cid1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"}, + server: "", + tool: "search_at_me_message", + want: map[string]any{"openConversationId": "cid1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"}, + }, + { + name: "list-focused", + args: []string{"message", "list-focused"}, + server: "", + tool: "list_special_focus_messages", + want: map[string]any{"limit": 50}, + }, + { + name: "search", + args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"}, + server: "", + tool: "search_messages_by_keyword", + want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"}, + }, + { + name: "search-advanced", + args: []string{"message", "search-advanced", "--query", "周报", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"}, + server: "im", + tool: "search_messages", + want: map[string]any{"keyword": "周报", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"}, + }, + { + name: "list-favorites", + args: []string{"message", "list-favorites"}, + server: "im", + tool: "list_message_favorites", + want: map[string]any{"cursor": int64(0), "size": "20"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + args := append([]string{}, tt.args...) + args = append(args, "--page-limit", "2", "--max-items", "1", "--page-delay", "0") + _, err := executeChatMessagePaginationCommand(t, caller, args...) + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one fallback call", caller.calls) + } + got := caller.calls[0] + if got.server != tt.server || got.tool != tt.tool || !argsEqual(got.args, tt.want) { + t.Fatalf("call = %#v, want server=%s tool=%s args=%#v", got, tt.server, tt.tool, tt.want) + } + }) + } +} + +func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) { + tests := []struct { + name string + args []string + server string + tool string + itemPath string + cursorOne any + cursorTwo any + pageOne string + pageTwo string + }{ + { + name: "list-all", args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"}, + server: "chat", tool: "search_messages_by_time_range", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`, + }, + { + name: "list-by-sender", args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"}, + server: "chat", tool: "search_messages_by_sender", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`, + }, + { + name: "list-mentions", args: []string{"message", "list-mentions", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"}, + server: "chat", tool: "search_at_me_message", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`, + }, + { + name: "list-focused", args: []string{"message", "list-focused"}, + server: "chat", tool: "list_special_focus_messages", itemPath: "messages", cursorOne: nil, cursorTwo: int64(2), + pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":2}}`, + pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":0}}`, + }, + { + name: "search", args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"}, + server: "chat", tool: "search_messages_by_keyword", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`, + }, + { + name: "search-advanced", args: []string{"message", "search-advanced", "--query", "周报"}, + server: "im", tool: "search_messages", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`, + }, + { + name: "list-favorites", args: []string{"message", "list-favorites"}, + server: "im", tool: "list_message_favorites", itemPath: "items", cursorOne: int64(0), cursorTwo: int64(20), + pageOne: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`, + pageTwo: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{steps: []scriptedToolStep{{text: tt.pageOne}, {text: tt.pageTwo}}} + args := append([]string{}, tt.args...) + args = append(args, "--page-all", "--page-delay", "0") + got, err := executeChatMessagePaginationCommand(t, caller, args...) + if err != nil { + t.Fatal(err) + } + items := got["result"].(map[string]any)[tt.itemPath].([]any) + if tt.itemPath == "conversationMessagesList" { + messages := items[0].(map[string]any)["messages"].([]any) + if len(items) != 1 || len(messages) != 2 { + t.Fatalf("conversation items = %#v", items) + } + } else if len(items) != 2 { + t.Fatalf("items = %#v", items) + } + if len(caller.calls) != 2 { + t.Fatalf("calls = %#v, want two pages", caller.calls) + } + if caller.calls[0].server != tt.server || caller.calls[0].tool != tt.tool { + t.Fatalf("first call = %#v", caller.calls[0]) + } + if !reflect.DeepEqual(caller.calls[0].args["cursor"], tt.cursorOne) { + t.Fatalf("first cursor = %#v, want %#v", caller.calls[0].args["cursor"], tt.cursorOne) + } + if !reflect.DeepEqual(caller.calls[1].args["cursor"], tt.cursorTwo) { + t.Fatalf("second cursor = %#v, want %#v", caller.calls[1].args["cursor"], tt.cursorTwo) + } + paging := got["paging"].(map[string]any) + if paging["pages"].(float64) != 2 || paging["total"].(float64) != 2 || paging["truncated"] != false { + t.Fatalf("paging = %#v", paging) + } + }) + } +} + +func argsEqual(got, want map[string]any) bool { + if len(got) != len(want) { + return false + } + for key, wantValue := range want { + gotValue, ok := got[key] + if !ok { + return false + } + switch w := wantValue.(type) { + case float64: + g, ok := gotValue.(int64) + if !ok || float64(g) != w { + return false + } + default: + if !reflect.DeepEqual(gotValue, wantValue) { + return false + } + } + } + return true +} diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index c1aa8b1e..1897aa18 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -3668,6 +3668,17 @@ CLI 内部自动完成全部流程: } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "doc_export", + "nodeId": node, + "exportFormat": format, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "导出文档(提交+轮询+下载)") deps.Out.PrintKeyValue("文档", node) deps.Out.PrintKeyValue("输出", outputPath) @@ -3678,7 +3689,7 @@ CLI 内部自动完成全部流程: ctx := context.Background() // ── Step 1: 提交导出任务 ── - deps.Out.PrintInfo("[1/3] 提交导出任务...") + printJSONSafeInfo("[1/3] 提交导出任务...") submitText, err := callMCPToolReturnText(ctx, "submit_export_job", submitArgs) if err != nil { return fmt.Errorf("提交导出任务失败: %w", err) @@ -3693,10 +3704,10 @@ CLI 内部自动完成全部流程: deps.Out.PrintRaw(submitText) return fmt.Errorf("提交导出任务成功但未返回 jobId") } - deps.Out.PrintInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID)) + printJSONSafeInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID)) // ── Step 2: 渐进式退避轮询 ── - deps.Out.PrintInfo("[2/3] 等待导出完成...") + printJSONSafeInfo("[2/3] 等待导出完成...") downloadURL, err := pollDocExportJob(ctx, jobID) if err != nil { return err @@ -3715,11 +3726,28 @@ CLI 内部自动完成全部流程: outputPath = filepath.Join(outputPath, filename) } - deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath)) if err := httpGetFile(ctx, downloadURL, nil, outputPath); err != nil { return fmt.Errorf("文件下载失败 (jobId=%s): %w", jobID, err) } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取导出产物信息失败 (jobId=%s): %w", jobID, err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": node, + "exportFormat": format, + "jobId": jobID, + "taskId": jobID, + "status": "SUCCESS", + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } + deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath)) return nil }, @@ -4657,7 +4685,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er for attempt := 1; attempt <= maxPolls; attempt++ { interval := pollInterval(attempt) - deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval)) + printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval)) select { case <-ctx.Done(): diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 83b7ba6d..752ab25d 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -619,10 +619,20 @@ func newDriveCommand() *cobra.Command { return err } dlOpts.logf = func(format string, a ...any) { - deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + printJSONSafeInfo(fmt.Sprintf(format, a...)) } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "drive_download", + "nodeId": fileID, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "下载钉盘文件") deps.Out.PrintKeyValue("文件ID", fileID) deps.Out.PrintKeyValue("输出", outputPath) @@ -632,7 +642,7 @@ func newDriveCommand() *cobra.Command { ctx := cmd.Context() // Step 1: 获取下载 URL 和签名请求头 - deps.Out.PrintInfo("[1/2] 获取下载链接...") + printJSONSafeInfo("[1/2] 获取下载链接...") text, err := callMCPToolReturnText(ctx, "download_file", argsMap) if err != nil { return err @@ -654,7 +664,7 @@ func newDriveCommand() *cobra.Command { } // Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试) - deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID dlOpts.version = parseDownloadFileVersion(text) @@ -684,6 +694,19 @@ func newDriveCommand() *cobra.Command { return err } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取下载产物信息失败: %w", err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": fileID, + "version": dlOpts.version, + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, @@ -772,10 +795,21 @@ func newDriveCommand() *cobra.Command { return err } dlOpts.logf = func(format string, a ...any) { - deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + printJSONSafeInfo(fmt.Sprintf(format, a...)) } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "drive_download_version", + "nodeId": fileID, + "version": versionNum, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "下载文件历史版本") deps.Out.PrintKeyValue("节点ID", fileID) deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum)) @@ -784,7 +818,7 @@ func newDriveCommand() *cobra.Command { } ctx := cmd.Context() - deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...") + printJSONSafeInfo("[1/2] 获取历史版本下载链接...") dlArgsMap := map[string]any{ "nodeId": fileID, "version": versionNum, @@ -804,7 +838,7 @@ func newDriveCommand() *cobra.Command { } outputPath = filepath.Join(outputPath, filename) } - deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID dlOpts.version = versionNum @@ -833,6 +867,19 @@ func newDriveCommand() *cobra.Command { } return err } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取下载产物信息失败: %w", err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": fileID, + "version": versionNum, + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, diff --git a/internal/helpers/json_output_contract_test.go b/internal/helpers/json_output_contract_test.go new file mode 100644 index 00000000..379befde --- /dev/null +++ b/internal/helpers/json_output_contract_test.go @@ -0,0 +1,198 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/spf13/cobra" +) + +func executeJSONOutputContractCommand(t *testing.T, caller *scriptedToolCaller, build func() *cobra.Command, args ...string) (string, string, error) { + t.Helper() + testseam.Protect(t, &deps) + testseam.Protect(t, &os.Args) + InitDeps(caller) + var stdout, stderr bytes.Buffer + deps.Out.w = &stdout + deps.Out.errW = &stderr + + root := build() + installExampleGlobalFlags(root) + os.Args = append([]string{"dws", root.Name()}, args...) + root.SilenceErrors = true + root.SilenceUsage = true + root.SetArgs(args) + if err := root.Execute(); err != nil { + return stdout.String(), stderr.String(), err + } + return stdout.String(), stderr.String(), nil +} + +func assertJSONOutputPayload(t *testing.T, stdout string) map[string]any { + t.Helper() + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout) + } + return payload +} + +func TestCrossPlatformCoverageJSONOutputContractForCompletedFileTransfers(t *testing.T) { + testseam.Swap(t, &httpGetFile, func(_ context.Context, _ string, _ map[string]string, destination string) error { + return os.WriteFile(destination, []byte("payload"), 0o600) + }) + + t.Run("drive latest download", func(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "latest.txt") + stdout, stderr, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}}, + newDriveCommand, + "download", "--node", "node-latest", "--output", outputPath) + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["nodeId"] != "node-latest" || payload["savedPath"] != outputPath || payload["sizeBytes"] != float64(7) || payload["version"] != float64(9) { + t.Fatalf("payload = %#v", payload) + } + if !strings.Contains(stderr, "下载完成") && !strings.Contains(stderr, "下载文件到") { + t.Fatalf("expected progress on stderr, got %q", stderr) + } + }) + + t.Run("drive historical download through compatibility flag", func(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "versioned.txt") + stdout, _, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}}, + newDriveCommand, + "download", "--node", "node-versioned", "--version", "4", "--output", outputPath) + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["nodeId"] != "node-versioned" || payload["version"] != float64(4) || payload["sizeBytes"] != float64(7) { + t.Fatalf("payload = %#v", payload) + } + }) + + t.Run("doc export", func(t *testing.T) { + testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + }) + outputPath := filepath.Join(t.TempDir(), "export.md") + stdout, stderr, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: `{"jobId":"export-job-1"}`}, + {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`}, + }}, + newDocCommand, + "export", "--node", "doc-node", "--export-format", "markdown", "--output", outputPath) + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["nodeId"] != "doc-node" || payload["exportFormat"] != "markdown" || payload["jobId"] != "export-job-1" || payload["taskId"] != "export-job-1" || payload["status"] != "SUCCESS" || payload["sizeBytes"] != float64(7) { + t.Fatalf("payload = %#v", payload) + } + if !strings.Contains(stderr, "提交导出任务") { + t.Fatalf("expected export progress on stderr, got %q", stderr) + } + }) +} + +func TestCrossPlatformCoverageJSONOutputContractDryRunIsMachineReadable(t *testing.T) { + stdout, _, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", dry: true}, + newDriveCommand, + "download", "--node", "node-dry-run", "--output", filepath.Join(t.TempDir(), "out.txt"), "--dry-run") + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "node-dry-run" { + t.Fatalf("payload = %#v", payload) + } + + stdout, _, err = executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", dry: true}, + newDocCommand, + "export", "--node", "doc-dry-run", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md"), "--dry-run") + if err != nil { + t.Fatal(err) + } + payload = assertJSONOutputPayload(t, stdout) + if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "doc-dry-run" || payload["operation"] != "doc_export" { + t.Fatalf("payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageJSONOutputContractReportsMissingLocalArtifact(t *testing.T) { + testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error { + return nil + }) + testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + }) + + tests := []struct { + name string + build func() *cobra.Command + args []string + steps []scriptedToolStep + }{ + { + name: "latest drive download", + build: newDriveCommand, + args: []string{"download", "--node", "node-latest", "--output", filepath.Join(t.TempDir(), "latest.txt")}, + steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}, + }, + { + name: "versioned drive download", + build: newDriveCommand, + args: []string{"download", "--node", "node-versioned", "--version", "4", "--output", filepath.Join(t.TempDir(), "versioned.txt")}, + steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}, + }, + { + name: "doc export", + build: newDocCommand, + args: []string{"export", "--node", "doc-node", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md")}, + steps: []scriptedToolStep{ + {text: `{"jobId":"export-job-1"}`}, + {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := executeJSONOutputContractCommand(t, &scriptedToolCaller{format: "json", steps: tt.steps}, tt.build, tt.args...) + if err == nil || !strings.Contains(err.Error(), "读取") { + t.Fatalf("expected missing local artifact error, got %v", err) + } + }) + } +} diff --git a/internal/helpers/minutes.go b/internal/helpers/minutes.go index ac52f2be..e3f11ce8 100644 --- a/internal/helpers/minutes.go +++ b/internal/helpers/minutes.go @@ -1346,7 +1346,7 @@ func newMinutesCommand() *cobra.Command { Short: "批量删除个人热词", Long: `批量删除听记个人热词。 支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`, - Example: ` dws minutes hot-word delete --words "钉钉" + Example: ` dws minutes hot-word delete --words "天气" dws minutes hot-word delete --words "OKR,钉钉,Copilot"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "words"); err != nil { @@ -1384,7 +1384,7 @@ func newMinutesCommand() *cobra.Command { "不确定现有热词时先用 hot-word list", }, Examples: []string{ - "dws minutes hot-word delete --words \"钉钉\"", + "dws minutes hot-word delete --words \"天气\"", "dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"", }, }, @@ -1883,11 +1883,12 @@ func newMinutesCommand() *cobra.Command { if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil { return err } - if err := validateRequiredFlags(cmd, "policy"); err != nil { - return err + // 手动校验必填参数(避免 validateRequiredFlags 对 Int 的误判) + if !cmd.Flags().Changed("policy") { + return fmt.Errorf("missing required flag --policy") } - policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64) + policyID, err := cmd.Flags().GetInt("policy") if err != nil || policyID < 2 || policyID > 4 { return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)") } @@ -1942,7 +1943,7 @@ func newMinutesCommand() *cobra.Command { _ = permissionApplyCmd.Flags().MarkHidden("task-uuid") permissionApplyCmd.Flags().String("uuid", "", "--id 的别名") _ = permissionApplyCmd.Flags().MarkHidden("uuid") - permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)") + permissionApplyCmd.Flags().Int("policy", 0, "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)") permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd) diff --git a/internal/helpers/output.go b/internal/helpers/output.go index 43e8f43e..7a714d82 100644 --- a/internal/helpers/output.go +++ b/internal/helpers/output.go @@ -151,6 +151,17 @@ func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n", func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) } func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) } +// printJSONSafeInfo keeps command progress out of stdout when the caller +// requested JSON. A successful JSON command must leave stdout parseable; its +// human-readable progress belongs on stderr instead. +func printJSONSafeInfo(msg string) { + if deps != nil && deps.Caller != nil && strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + deps.Out.PrintProgress("[INFO] " + msg) + return + } + deps.Out.PrintInfo(msg) +} + func (f *Formatter) PrintKeyValue(key, value string) { fmt.Fprintf(f.w, "%-16s%s\n", key+":", value) } diff --git a/internal/helpers/paged_command.go b/internal/helpers/paged_command.go new file mode 100644 index 00000000..72e3d619 --- /dev/null +++ b/internal/helpers/paged_command.go @@ -0,0 +1,521 @@ +package helpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const ( + defaultPagedCommandPageLimit = 50 + maxPagedCommandPageLimit = 500 + defaultPagedCommandDelayMS = 200 +) + +type PagedCursorKind int + +const ( + PagedCursorString PagedCursorKind = iota + PagedCursorInt64 +) + +type PagedAggregationMode int + +const ( + PagedAggregationArray PagedAggregationMode = iota + PagedAggregationConversationMessages +) + +type PagedMCPCommandConfig struct { + ServerID string + ToolName string + ItemPath string + CursorPath string + HasMorePath string + CursorArg string + CursorKind PagedCursorKind + AggregationMode PagedAggregationMode + BuildArgs func(*cobra.Command) (map[string]any, error) + Fallback func(map[string]any) error +} + +type pagedCommandOptions struct { + pageAll bool + pageLimit int + maxItems int + delayMS int +} + +func AddPagedMCPFlags(cmd *cobra.Command) { + cmd.Flags().Bool("page-all", false, "自动按 nextCursor 拉取所有分页;未设置时保持单页调用") + cmd.Flags().Int("page-limit", defaultPagedCommandPageLimit, "自动翻页最多请求页数(默认 50,范围 1-500;仅 --page-all 生效)") + cmd.Flags().Int("max-items", 0, "自动翻页最多返回条数(默认 0 表示不限制;仅 --page-all 生效)") + cmd.Flags().Int("page-delay", defaultPagedCommandDelayMS, "自动翻页每页之间等待毫秒数(默认 200;0 表示不等待;仅 --page-all 生效)") +} + +func RunPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig) error { + args, err := cfg.BuildArgs(cmd) + if err != nil { + return err + } + opts, err := readPagedCommandOptions(cmd) + if err != nil { + return err + } + if !opts.pageAll { + return cfg.Fallback(args) + } + if err := validatePagedConfig(cfg); err != nil { + return err + } + if deps.Caller.DryRun() { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "request": map[string]any{ + "server": cfg.ServerID, + "name": cfg.ToolName, + "args": args, + }, + "paging": map[string]any{ + "pageAll": true, + "pageLimit": opts.pageLimit, + "maxItems": opts.maxItems, + "pageDelay": opts.delayMS, + }, + }) + } + return runPagedMCPCommand(cmd, cfg, opts, args) +} + +func readPagedCommandOptions(cmd *cobra.Command) (pagedCommandOptions, error) { + pageAll, _ := cmd.Flags().GetBool("page-all") + opts := pagedCommandOptions{pageAll: pageAll} + if !pageAll { + return opts, nil + } + opts.pageLimit, _ = cmd.Flags().GetInt("page-limit") + if opts.pageLimit < 1 || opts.pageLimit > maxPagedCommandPageLimit { + return opts, fmt.Errorf("--page-limit must be between 1 and 500") + } + opts.maxItems, _ = cmd.Flags().GetInt("max-items") + if opts.maxItems < 0 { + return opts, fmt.Errorf("--max-items must be greater than or equal to 0") + } + opts.delayMS, _ = cmd.Flags().GetInt("page-delay") + if opts.delayMS < 0 { + return opts, fmt.Errorf("--page-delay must be greater than or equal to 0") + } + return opts, nil +} + +func validatePagedConfig(cfg PagedMCPCommandConfig) error { + switch { + case strings.TrimSpace(cfg.ServerID) == "": + return fmt.Errorf("paged command server is required") + case strings.TrimSpace(cfg.ToolName) == "": + return fmt.Errorf("paged command tool is required") + case strings.TrimSpace(cfg.ItemPath) == "": + return fmt.Errorf("paged command item path is required") + case strings.TrimSpace(cfg.CursorPath) == "": + return fmt.Errorf("paged command cursor path is required") + case strings.TrimSpace(cfg.HasMorePath) == "": + return fmt.Errorf("paged command hasMore path is required") + case strings.TrimSpace(cfg.CursorArg) == "": + return fmt.Errorf("paged command cursor arg is required") + case cfg.BuildArgs == nil || cfg.Fallback == nil: + return fmt.Errorf("paged command callbacks are required") + default: + return nil + } +} + +func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts pagedCommandOptions, args map[string]any) error { + var envelope map[string]any + ctx := cmd.Context() + items := newPagedCollection(cfg) + seenCursors := map[string]bool{} + currentCursor := cursorValueKey(args[cfg.CursorArg], cfg.CursorKind) + lastCursor := args[cfg.CursorArg] + hasMore := true + + for page := 1; page <= opts.pageLimit && hasMore; page++ { + pageCursor := args[cfg.CursorArg] + seenCursors[currentCursor] = true + text, err := callMCPToolReturnTextOnServer(ctx, cfg.ServerID, cfg.ToolName, args) + if err != nil { + return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) + } + parsed, pageItems, nextCursor, more, err := parsePagedCommandPage(text, cfg) + if err != nil { + return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) + } + if envelope == nil { + envelope = parsed + } + if err := items.Add(pageItems); err != nil { + return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) + } + hasMore = more + + if opts.maxItems > 0 && items.Total() > opts.maxItems { + items.Truncate(opts.maxItems) + return writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + Truncated: true, + HasMore: true, + LastCursor: pageCursor, + Pages: page, + Total: items.Total(), + TruncatedWithinPage: true, + }) + } + lastCursor = nextCursor + if opts.maxItems > 0 && items.Total() == opts.maxItems && hasMore { + return writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + Truncated: true, + HasMore: true, + LastCursor: lastCursor, + Pages: page, + Total: items.Total(), + }) + } + if !hasMore { + return writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + Truncated: false, + HasMore: false, + LastCursor: lastCursor, + Pages: page, + Total: items.Total(), + }) + } + nextKey := cursorValueKey(nextCursor, cfg.CursorKind) + if nextKey == "" || nextKey == currentCursor || seenCursors[nextKey] { + err := fmt.Errorf("pagination cursor did not advance: %s", nextKey) + return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err) + } + normalizedCursor, err := normalizeCursorArg(nextCursor, cfg.CursorKind) + if err != nil { + return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err) + } + currentCursor = nextKey + args[cfg.CursorArg] = normalizedCursor + if opts.delayMS > 0 { + if err := sleepPagedCommandDelay(ctx, time.Duration(opts.delayMS)*time.Millisecond); err != nil { + return handlePagedCommandError(cmd, envelope, cfg, items, page+1, currentCursor, err) + } + } + } + + return writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + Truncated: hasMore, + HasMore: hasMore, + LastCursor: lastCursor, + Pages: opts.pageLimit, + Total: items.Total(), + }) +} + +func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]any, []any, any, bool, error) { + var parsed map[string]any + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + return nil, nil, nil, false, fmt.Errorf("parse paged response JSON: %w", err) + } + rawHasMore, ok := getJSONPath(parsed, cfg.HasMorePath) + if !ok { + return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.HasMorePath) + } + hasMore, ok := rawHasMore.(bool) + if !ok { + return nil, nil, nil, false, fmt.Errorf("paged response %s must be boolean", cfg.HasMorePath) + } + rawItems, ok := getJSONPath(parsed, cfg.ItemPath) + if !ok && cfg.AggregationMode == PagedAggregationConversationMessages && !hasMore { + rawItems = []any{} + ok = true + } + if !ok { + return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.ItemPath) + } + items, ok := rawItems.([]any) + if !ok { + return nil, nil, nil, false, fmt.Errorf("paged response %s must be array", cfg.ItemPath) + } + nextCursor, ok := getJSONPath(parsed, cfg.CursorPath) + if hasMore && !ok { + return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.CursorPath) + } + return parsed, items, nextCursor, hasMore, nil +} + +type pagingMetadata struct { + Truncated bool + HasMore bool + LastCursor any + Pages int + Total int + TruncatedWithinPage bool + Partial bool + FailedPage int + FailedCursor string + PagesFetched int + ItemsFetched int + Error string +} + +func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, failedPage int, failedCursor string, err error) error { + if envelope == nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "pagination stopped at page %d: %v\n", failedPage, err) + if outputErr := writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + Truncated: true, + HasMore: true, + LastCursor: failedCursor, + Pages: failedPage - 1, + Total: items.Total(), + Partial: true, + FailedPage: failedPage, + FailedCursor: failedCursor, + PagesFetched: failedPage - 1, + ItemsFetched: items.Total(), + Error: err.Error(), + }); outputErr != nil { + return errors.Join(err, outputErr) + } + return err +} + +func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, meta pagingMetadata) error { + _ = setJSONPath(envelope, cfg.ItemPath, items.Values()) + _ = setJSONPath(envelope, cfg.HasMorePath, meta.HasMore) + _ = setJSONPath(envelope, cfg.CursorPath, meta.LastCursor) + paging := map[string]any{ + "truncated": meta.Truncated, + "hasMore": meta.HasMore, + "lastCursor": meta.LastCursor, + "pages": meta.Pages, + "total": meta.Total, + } + if meta.Partial { + paging["partial"] = true + paging["failedPage"] = meta.FailedPage + paging["failedCursor"] = meta.FailedCursor + paging["pagesFetched"] = meta.PagesFetched + paging["itemsFetched"] = meta.ItemsFetched + paging["error"] = meta.Error + } + if meta.TruncatedWithinPage { + paging["truncatedWithinPage"] = true + paging["resumeCursorReliable"] = false + } + envelope["paging"] = paging + return deps.Out.PrintJSON(envelope) +} + +func sleepPagedCommandDelay(ctx context.Context, delay time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-helperAfter(delay): + return nil + } +} + +type pagedCollection struct { + mode PagedAggregationMode + items []any + conversationIndex map[string]int + total int +} + +func newPagedCollection(cfg PagedMCPCommandConfig) *pagedCollection { + return &pagedCollection{ + mode: cfg.AggregationMode, + conversationIndex: map[string]int{}, + } +} + +func (c *pagedCollection) Add(items []any) error { + if c.mode != PagedAggregationConversationMessages { + c.items = append(c.items, items...) + c.total = len(c.items) + return nil + } + for _, item := range items { + if err := c.addConversation(item); err != nil { + return err + } + } + return nil +} + +func (c *pagedCollection) Values() []any { + if c.items == nil { + return []any{} + } + return c.items +} + +func (c *pagedCollection) Total() int { + return c.total +} + +func (c *pagedCollection) Truncate(maxItems int) bool { + if maxItems <= 0 || c.total <= maxItems { + return false + } + if c.mode != PagedAggregationConversationMessages { + c.items = c.items[:maxItems] + c.total = len(c.items) + return true + } + c.truncateConversationMessages(maxItems) + return true +} + +func (c *pagedCollection) addConversation(item any) error { + conversation, ok := item.(map[string]any) + if !ok { + return fmt.Errorf("paged response conversation item must be object") + } + key, _ := conversation["openConversationId"].(string) + if strings.TrimSpace(key) == "" { + return fmt.Errorf("paged response conversation item missing openConversationId") + } + messages, err := conversationMessages(conversation) + if err != nil { + return err + } + if idx, ok := c.conversationIndex[key]; ok { + existing := c.items[idx].(map[string]any) + existingMessages, err := conversationMessages(existing) + if err != nil { + return err + } + existing["messages"] = append(existingMessages, messages...) + c.total += len(messages) + return nil + } + c.conversationIndex[key] = len(c.items) + c.items = append(c.items, conversation) + c.total += len(messages) + return nil +} + +func (c *pagedCollection) truncateConversationMessages(maxItems int) { + remaining := maxItems + for i, item := range c.items { + conversation := item.(map[string]any) + messages, _ := conversationMessages(conversation) + if remaining >= len(messages) { + remaining -= len(messages) + continue + } + if remaining == 0 { + c.items = c.items[:i] + c.total = maxItems + return + } + conversation["messages"] = messages[:remaining] + c.items = c.items[:i+1] + c.total = maxItems + return + } +} + +func conversationMessages(conversation map[string]any) ([]any, error) { + raw, ok := conversation["messages"] + if !ok { + return []any{}, nil + } + messages, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("paged response conversation messages must be array") + } + return messages, nil +} + +func getJSONPath(root map[string]any, path string) (any, bool) { + var current any = root + for _, part := range strings.Split(path, ".") { + obj, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = obj[part] + if !ok { + return nil, false + } + } + return current, true +} + +func setJSONPath(root map[string]any, path string, value any) bool { + parts := strings.Split(path, ".") + current := root + for _, part := range parts[:len(parts)-1] { + next, ok := current[part].(map[string]any) + if !ok { + return false + } + current = next + } + current[parts[len(parts)-1]] = value + return true +} + +func cursorValueKey(value any, kind PagedCursorKind) string { + switch kind { + case PagedCursorInt64: + switch v := value.(type) { + case int64: + return strconv.FormatInt(v, 10) + case int: + return strconv.Itoa(v) + case float64: + return strconv.FormatInt(int64(v), 10) + case string: + return strings.TrimSpace(v) + default: + return "" + } + default: + if value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func normalizeCursorArg(value any, kind PagedCursorKind) (any, error) { + if kind != PagedCursorInt64 { + if value == nil { + return "", nil + } + return fmt.Sprint(value), nil + } + switch v := value.(type) { + case int64: + return v, nil + case int: + return int64(v), nil + case float64: + converted := int64(v) + if float64(converted) != v { + return nil, fmt.Errorf("paged response cursor must be an integer, got %v", v) + } + return converted, nil + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err == nil { + return parsed, nil + } + return nil, fmt.Errorf("paged response cursor must be a base-10 int64 string, got %q", v) + } + return nil, fmt.Errorf("paged response cursor must be int64-compatible, got %T", value) +} diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go new file mode 100644 index 00000000..eea5dbea --- /dev/null +++ b/internal/helpers/paged_command_test.go @@ -0,0 +1,973 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type pagedCommandCall struct { + server string + tool string + args map[string]any + ctx context.Context +} + +type pagedCommandCaller struct { + steps []scriptedToolStep + calls []pagedCommandCall + format string + dry bool +} + +func (c *pagedCommandCaller) CallTool(ctx context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { + copied := map[string]any{} + for k, v := range args { + copied[k] = v + } + c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied, ctx: ctx}) + if len(c.steps) == 0 { + return textToolResult(`{"result":{"messages":[],"hasMore":false}}`), nil + } + step := c.steps[len(c.calls)-1] + if step.err != nil { + return nil, step.err + } + return textToolResult(step.text), nil +} + +func (c *pagedCommandCaller) Format() string { return c.format } +func (c *pagedCommandCaller) DryRun() bool { return c.dry } +func (*pagedCommandCaller) Fields() string { return "" } +func (*pagedCommandCaller) JQ() string { return "" } + +func runPagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, args ...string) (map[string]any, string, error) { + t.Helper() + return runPagedCommandTestWithSleep(t, caller, cfg, func(time.Duration) {}, args...) +} + +func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), args ...string) (map[string]any, string, error) { + t.Helper() + out, stderr, err := executePagedCommandTest(t, caller, cfg, sleep, &bytes.Buffer{}, args...) + if strings.TrimSpace(out) == "" { + return nil, stderr, err + } + var parsed map[string]any + if unmarshalErr := json.Unmarshal([]byte(out), &parsed); unmarshalErr != nil { + t.Fatalf("stdout JSON = %q, err = %v", out, unmarshalErr) + } + return parsed, stderr, err +} + +func executePagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) { + t.Helper() + return executePagedCommandTestWithContext(t, context.Background(), caller, cfg, sleep, stdout, args...) +} + +func executePagedCommandTestWithContext(t *testing.T, ctx context.Context, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) { + t.Helper() + oldDeps := deps + oldSleep := helperSleep + oldAfter := helperAfter + t.Cleanup(func() { + deps = oldDeps + helperSleep = oldSleep + helperAfter = oldAfter + }) + InitDeps(caller) + out := stdout + errOut := &bytes.Buffer{} + deps.Out.w = out + deps.Out.errW = errOut + if sleep != nil { + helperSleep = sleep + helperAfter = func(d time.Duration) <-chan time.Time { + sleep(d) + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + } + } + + cmd := &cobra.Command{ + Use: "paged", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return RunPagedMCPCommand(cmd, cfg) + }, + } + cmd.SetContext(ctx) + cmd.Flags().String("cursor", "0", "") + AddPagedMCPFlags(cmd) + cmd.SetErr(errOut) + cmd.SetArgs(args) + err := cmd.Execute() + if buf, ok := out.(*bytes.Buffer); ok { + return buf.String(), errOut.String(), err + } + return "", errOut.String(), err +} + +func pagedCommandMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig { + if fallback == nil { + fallback = func(map[string]any) error { return nil } + } + return PagedMCPCommandConfig{ + ServerID: "chat", + ToolName: "search_messages_by_time_range", + ItemPath: "result.messages", + CursorPath: "result.nextCursor", + HasMorePath: "result.hasMore", + CursorArg: "cursor", + CursorKind: PagedCursorString, + BuildArgs: func(cmd *cobra.Command) (map[string]any, error) { + cursor, _ := cmd.Flags().GetString("cursor") + return map[string]any{"cursor": cursor, "limit": 2}, nil + }, + Fallback: fallback, + } +} + +func pagedCommandConversationMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig { + cfg := pagedCommandMessagesConfig(fallback) + cfg.ItemPath = "result.conversationMessagesList" + cfg.AggregationMode = PagedAggregationConversationMessages + return cfg +} + +func TestPagedMCPCommandDefaultUsesFallbackOnly(t *testing.T) { + caller := &pagedCommandCaller{} + fallbackCalls := 0 + cfg := pagedCommandMessagesConfig(func(args map[string]any) error { + fallbackCalls++ + if args["cursor"] != "0" { + t.Fatalf("fallback args = %#v", args) + } + return nil + }) + _, _, err := runPagedCommandTest(t, caller, cfg, "--page-limit", "2", "--max-items", "1", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + if fallbackCalls != 1 || len(caller.calls) != 0 { + t.Fatalf("fallback=%d remote=%d, want fallback only", fallbackCalls, len(caller.calls)) + } +} + +func TestPagedMCPCommandRejectsInvalidConfigWhenPageAll(t *testing.T) { + caller := &pagedCommandCaller{} + cfg := pagedCommandMessagesConfig(nil) + cfg.ServerID = " " + + got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all") + if err == nil || !strings.Contains(err.Error(), "server is required") { + t.Fatalf("result=%#v err=%v, want config error", got, err) + } + if got != nil { + t.Fatalf("result=%#v, want no stdout", got) + } + if len(caller.calls) != 0 { + t.Fatalf("calls=%#v, want no remote call", caller.calls) + } +} + +func TestPagedMCPCommandDryRunPrintsRequestAndSkipsRemote(t *testing.T) { + caller := &pagedCommandCaller{dry: true} + + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "3", "--max-items", "7", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("calls=%#v, want no remote call", caller.calls) + } + if got["dry_run"] != true { + t.Fatalf("dry_run=%#v, want true", got["dry_run"]) + } + request := got["request"].(map[string]any) + if request["server"] != "chat" || request["name"] != "search_messages_by_time_range" { + t.Fatalf("request=%#v", request) + } + args := request["args"].(map[string]any) + if args["cursor"] != "0" || args["limit"].(float64) != 2 { + t.Fatalf("args=%#v", args) + } + paging := got["paging"].(map[string]any) + if paging["pageAll"] != true || paging["pageLimit"].(float64) != 3 || paging["maxItems"].(float64) != 7 || paging["pageDelay"].(float64) != 0 { + t.Fatalf("paging=%#v", paging) + } +} + +func TestPagedMCPCommandValidateConfigRejectsMissingRequiredFields(t *testing.T) { + tests := []struct { + name string + edit func(*PagedMCPCommandConfig) + want string + }{ + { + name: "server", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.ServerID = "" + }, + want: "server is required", + }, + { + name: "tool", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.ToolName = "" + }, + want: "tool is required", + }, + { + name: "item path", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.ItemPath = "" + }, + want: "item path is required", + }, + { + name: "cursor path", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.CursorPath = "" + }, + want: "cursor path is required", + }, + { + name: "hasMore path", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.HasMorePath = "" + }, + want: "hasMore path is required", + }, + { + name: "cursor arg", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.CursorArg = "" + }, + want: "cursor arg is required", + }, + { + name: "build args callback", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.BuildArgs = nil + }, + want: "callbacks are required", + }, + { + name: "fallback callback", + edit: func(cfg *PagedMCPCommandConfig) { + cfg.Fallback = nil + }, + want: "callbacks are required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := pagedCommandMessagesConfig(nil) + tt.edit(&cfg) + err := validatePagedConfig(cfg) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v, want %q", err, tt.want) + } + }) + } +} + +func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, + {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":true,"nextCursor":"c3"}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "2", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + items := got["result"].(map[string]any)["messages"].([]any) + result := got["result"].(map[string]any) + paging := got["paging"].(map[string]any) + if len(items) != 2 || paging["truncated"] != true || paging["pages"].(float64) != 2 { + t.Fatalf("result = %#v", got) + } + if result["hasMore"] != true || result["nextCursor"] != "c3" { + t.Fatalf("result=%#v, want final page-limit cursor state", result) + } + if caller.calls[0].args["cursor"] != "0" || caller.calls[1].args["cursor"] != "c2" { + t.Fatalf("call args = %#v", caller.calls) + } +} + +func TestPagedMCPCommandStringCursorAggregatesAndSyncsCompletionFields(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, + {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`}, + }} + + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + result := got["result"].(map[string]any) + items := result["messages"].([]any) + if len(items) != 2 || result["hasMore"] != false || result["nextCursor"] != "" { + t.Fatalf("result=%#v, want complete aggregate with final cursor state", result) + } + paging := got["paging"].(map[string]any) + if paging["truncated"] != false || paging["hasMore"] != false || paging["lastCursor"] != "" { + t.Fatalf("paging=%#v, want complete pagination metadata", paging) + } +} + +func TestPagedMCPCommandConversationMessagesMergeSameConversation(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`}, + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"ignored","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) + if len(conversations) != 1 { + t.Fatalf("conversations=%#v, want one merged conversation", conversations) + } + conversation := conversations[0].(map[string]any) + messages := conversation["messages"].([]any) + if conversation["title"] != "群1" || len(messages) != 2 { + t.Fatalf("conversation=%#v, want preserved title and two messages", conversation) + } + paging := got["paging"].(map[string]any) + if paging["total"].(float64) != 2 { + t.Fatalf("paging=%#v, want total message count 2", paging) + } +} + +func TestPagedMCPCommandConversationMessagesPreserveFirstConversationOrder(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid2","messages":[{"id":"m2"}]},{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`}, + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid3","messages":[{"id":"m3"}]}],"hasMore":false,"nextCursor":""}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) + gotIDs := []string{ + conversations[0].(map[string]any)["openConversationId"].(string), + conversations[1].(map[string]any)["openConversationId"].(string), + conversations[2].(map[string]any)["openConversationId"].(string), + } + if strings.Join(gotIDs, ",") != "cid2,cid1,cid3" { + t.Fatalf("conversation order=%v", gotIDs) + } +} + +func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesMessages(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "3", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) + if len(conversations) != 2 { + t.Fatalf("conversations=%#v, want two conversations", conversations) + } + secondMessages := conversations[1].(map[string]any)["messages"].([]any) + paging := got["paging"].(map[string]any) + if len(secondMessages) != 1 || paging["total"].(float64) != 3 || paging["truncated"] != true { + t.Fatalf("result=%#v", got) + } +} + +func TestPagedMCPCommandConversationMessagesMissingListOnFinalPageIsEmpty(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"hasMore":false,"nextCursor":""}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) + if len(conversations) != 0 { + t.Fatalf("conversations=%#v, want empty", conversations) + } +} + +func TestPagedMCPCommandConversationMessagesLaterFailureOutputsPartial(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`}, + {err: errors.New("page failed")}, + }} + got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err == nil || !strings.Contains(stderr, "pagination stopped") { + t.Fatalf("err=%v stderr=%q", err, stderr) + } + conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) + paging := got["paging"].(map[string]any) + if len(conversations) != 1 || paging["partial"] != true || paging["itemsFetched"].(float64) != 1 { + t.Fatalf("result=%#v", got) + } +} + +func TestPagedMCPCommandConversationMessagesAddErrorsOutputPartial(t *testing.T) { + tests := []struct { + name string + response string + want string + }{ + { + name: "conversation item not object", + response: `{"result":{"conversationMessagesList":["bad"],"hasMore":false,"nextCursor":""}}`, + want: "conversation item must be object", + }, + { + name: "conversation missing openConversationId", + response: `{"result":{"conversationMessagesList":[{"messages":[]}],"hasMore":false,"nextCursor":""}}`, + want: "missing openConversationId", + }, + { + name: "conversation messages not array", + response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":"bad"}],"hasMore":false,"nextCursor":""}}`, + want: "conversation messages must be array", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}} + got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want) + } + if !strings.Contains(stderr, "pagination stopped at page 1") { + t.Fatalf("stderr=%q", stderr) + } + paging := got["paging"].(map[string]any) + if paging["partial"] != true || paging["itemsFetched"].(float64) != 0 { + t.Fatalf("paging=%#v", paging) + } + }) + } +} + +func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesAtConversationBoundary(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) + paging := got["paging"].(map[string]any) + if len(conversations) != 1 || paging["total"].(float64) != 2 || paging["truncated"] != true { + t.Fatalf("result=%#v", got) + } +} + +func TestPagedMCPCommandConversationMessagesAcceptsMissingMessages(t *testing.T) { + messages, err := conversationMessages(map[string]any{"openConversationId": "cid1"}) + if err != nil { + t.Fatal(err) + } + if len(messages) != 0 { + t.Fatalf("messages=%#v, want empty", messages) + } +} + +func TestPagedMCPCommandConversationMessagesRejectsCorruptExistingMessages(t *testing.T) { + collection := newPagedCollection(PagedMCPCommandConfig{AggregationMode: PagedAggregationConversationMessages}) + collection.items = []any{map[string]any{"openConversationId": "cid1", "messages": "bad"}} + collection.conversationIndex["cid1"] = 0 + + err := collection.Add([]any{map[string]any{"openConversationId": "cid1", "messages": []any{map[string]any{"id": "m2"}}}}) + if err == nil || !strings.Contains(err.Error(), "conversation messages must be array") { + t.Fatalf("err=%v, want corrupt existing messages error", err) + } +} + +func TestPagedMCPCommandResponseShapeErrors(t *testing.T) { + tests := []struct { + name string + response string + want string + }{ + { + name: "items not array", + response: `{"result":{"messages":"bad","hasMore":false}}`, + want: "result.messages must be array", + }, + { + name: "missing hasMore", + response: `{"result":{"messages":[]}}`, + want: "missing result.hasMore", + }, + { + name: "hasMore not bool", + response: `{"result":{"messages":[],"hasMore":"yes"}}`, + want: "result.hasMore must be boolean", + }, + { + name: "missing next cursor", + response: `{"result":{"messages":[{"id":"m1"}],"hasMore":true}}`, + want: "missing result.nextCursor", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}} + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want) + } + if got != nil { + t.Fatalf("result=%#v, want no partial stdout", got) + } + }) + } +} + +func TestPagedMCPCommandPageDelayControlsSleep(t *testing.T) { + tests := []struct { + name string + delay string + wantSleeps []time.Duration + }{ + { + name: "non zero delay sleeps between pages", + delay: "200", + wantSleeps: []time.Duration{200 * time.Millisecond}, + }, + { + name: "zero delay skips sleep", + delay: "0", + wantSleeps: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, + {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`}, + }} + var sleeps []time.Duration + got, _, err := runPagedCommandTestWithSleep(t, caller, pagedCommandMessagesConfig(nil), func(d time.Duration) { + sleeps = append(sleeps, d) + }, "--page-all", "--page-delay", tt.delay) + if err != nil { + t.Fatal(err) + } + items := got["result"].(map[string]any)["messages"].([]any) + if len(items) != 2 || len(caller.calls) != 2 { + t.Fatalf("items=%#v calls=%#v", items, caller.calls) + } + if len(sleeps) != len(tt.wantSleeps) { + t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps) + } + for i := range tt.wantSleeps { + if sleeps[i] != tt.wantSleeps[i] { + t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps) + } + } + }) + } +} + +func TestPagedMCPCommandMaxItemsTruncatesPrecisely(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + items := got["result"].(map[string]any)["messages"].([]any) + paging := got["paging"].(map[string]any) + if len(items) != 1 || paging["total"].(float64) != 1 || paging["truncated"] != true { + t.Fatalf("result = %#v", got) + } +} + +func TestPagedMCPCommandMaxItemsStopsWhenPageExactlyReachesLimit(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, + {err: errors.New("second page should not run")}, + }} + + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + items := got["result"].(map[string]any)["messages"].([]any) + result := got["result"].(map[string]any) + paging := got["paging"].(map[string]any) + if len(caller.calls) != 1 || len(items) != 2 { + t.Fatalf("calls=%#v items=%#v, want one full page", caller.calls, items) + } + if paging["truncated"] != true || paging["hasMore"] != true || paging["lastCursor"] != "c2" { + t.Fatalf("paging=%#v, want safe page-boundary cursor", paging) + } + if result["hasMore"] != true || result["nextCursor"] != "c2" { + t.Fatalf("result=%#v, want safe page-boundary cursor fields", result) + } + if _, ok := paging["truncatedWithinPage"]; ok { + t.Fatalf("paging=%#v, want no within-page truncation marker", paging) + } +} + +func TestPagedMCPCommandMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, + }} + + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + items := got["result"].(map[string]any)["messages"].([]any) + result := got["result"].(map[string]any) + paging := got["paging"].(map[string]any) + if len(items) != 1 || paging["lastCursor"] != "0" { + t.Fatalf("result=%#v, want current-page cursor after within-page truncation", got) + } + if result["hasMore"] != true || result["nextCursor"] != "0" { + t.Fatalf("result=%#v, want unreliable current-page cursor fields", result) + } + if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false { + t.Fatalf("paging=%#v, want unreliable resume marker", paging) + } +} + +func TestPagedCollectionTruncateReturnsFalseWhenLimitDoesNotTrim(t *testing.T) { + collection := newPagedCollection(PagedMCPCommandConfig{}) + if err := collection.Add([]any{map[string]any{"id": "m1"}}); err != nil { + t.Fatal(err) + } + + if collection.Truncate(0) { + t.Fatal("Truncate(0) should not trim") + } + if collection.Truncate(1) { + t.Fatal("Truncate(total) should not trim") + } + if collection.Total() != 1 || len(collection.Values()) != 1 { + t.Fatalf("collection=%#v, want unchanged single item", collection.Values()) + } +} + +func TestPagedMCPCommandConversationMessagesMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]}],"hasMore":true,"nextCursor":"c2"}}`}, + }} + + got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) + messages := conversations[0].(map[string]any)["messages"].([]any) + paging := got["paging"].(map[string]any) + if len(messages) != 1 || paging["lastCursor"] != "0" { + t.Fatalf("result=%#v, want truncated conversation with current-page cursor", got) + } + if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false { + t.Fatalf("paging=%#v, want unreliable resume marker", paging) + } +} + +func TestPagedMCPCommandPassesCommandContextToCaller(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`}, + }} + + out, _, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, &bytes.Buffer{}, "--page-all", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out) == "" || len(caller.calls) != 1 { + t.Fatalf("stdout=%q calls=%#v, want one successful call", out, caller.calls) + } + if caller.calls[0].ctx != ctx || caller.calls[0].ctx.Err() != context.Canceled { + t.Fatalf("call ctx=%#v err=%v, want canceled command context", caller.calls[0].ctx, caller.calls[0].ctx.Err()) + } +} + +func TestPagedMCPCommandPageDelayStopsWhenContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, + {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`}, + }} + var out bytes.Buffer + + stdout, stderr, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), nil, &out, "--page-all", "--page-delay", "10") + if strings.TrimSpace(stdout) == "" { + t.Fatal("stdout is empty, want partial pagination JSON") + } + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v, want context canceled", err) + } + if !strings.Contains(stderr, "pagination stopped at page 2") || len(caller.calls) != 1 { + t.Fatalf("stderr=%q calls=%#v, want cancellation before second call", stderr, caller.calls) + } + var got map[string]any + if unmarshalErr := json.Unmarshal([]byte(stdout), &got); unmarshalErr != nil { + t.Fatalf("stdout JSON = %q, err = %v", stdout, unmarshalErr) + } + paging := got["paging"].(map[string]any) + if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 { + t.Fatalf("paging=%#v, want partial cancellation metadata", paging) + } +} + +func TestPagedMCPCommandPropagatesAggregatedOutputErrors(t *testing.T) { + tests := []struct { + name string + steps []scriptedToolStep + args []string + wantStderr string + }{ + { + name: "normal end", + steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`}, + }, + args: []string{"--page-all", "--page-delay", "0"}, + }, + { + name: "max items truncation", + steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, + }, + args: []string{"--page-all", "--max-items", "1", "--page-delay", "0"}, + }, + { + name: "page limit truncation", + steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, + }, + args: []string{"--page-all", "--page-limit", "1", "--page-delay", "0"}, + }, + { + name: "partial result after later failure", + steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, + {err: errors.New("page failed")}, + }, + args: []string{"--page-all", "--page-delay", "0"}, + wantStderr: "pagination stopped at page 2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // TC-output-error: aggregate stdout write failures must fail the command. + caller := &pagedCommandCaller{steps: tt.steps} + _, stderr, err := executePagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, failingWriter{}, tt.args...) + if err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("err=%v, want propagated write failure", err) + } + if tt.wantStderr != "" && !strings.Contains(stderr, tt.wantStderr) { + t.Fatalf("stderr=%q, want %q", stderr, tt.wantStderr) + } + }) + } +} + +func TestPagedMCPCommandInt64CursorAndItemsPath(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`}, + {text: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`}, + }} + cfg := PagedMCPCommandConfig{ + ServerID: "im", + ToolName: "list_message_favorites", + ItemPath: "result.items", + CursorPath: "result.nextCursor", + HasMorePath: "result.hasMore", + CursorArg: "cursor", + CursorKind: PagedCursorInt64, + BuildArgs: func(cmd *cobra.Command) (map[string]any, error) { + return map[string]any{"cursor": int64(0), "size": "20"}, nil + }, + Fallback: func(map[string]any) error { return nil }, + } + got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + items := got["result"].(map[string]any)["items"].([]any) + if len(items) != 2 || caller.calls[1].args["cursor"] != int64(20) { + t.Fatalf("items=%#v calls=%#v", items, caller.calls) + } +} + +func TestPagedMCPCommandInt64CursorRejectsNonNumericNextCursor(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":"not-a-number"}}`}, + }} + cfg := PagedMCPCommandConfig{ + ServerID: "im", + ToolName: "list_message_favorites", + ItemPath: "result.items", + CursorPath: "result.nextCursor", + HasMorePath: "result.hasMore", + CursorArg: "cursor", + CursorKind: PagedCursorInt64, + BuildArgs: func(cmd *cobra.Command) (map[string]any, error) { + return map[string]any{"cursor": int64(0), "size": "20"}, nil + }, + Fallback: func(map[string]any) error { return nil }, + } + got, stderr, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0") + if err == nil || !strings.Contains(err.Error(), "base-10 int64 string") { + t.Fatalf("err=%v, want invalid int64 cursor error", err) + } + if !strings.Contains(stderr, "pagination stopped at page 2") { + t.Fatalf("stderr=%q", stderr) + } + paging := got["paging"].(map[string]any) + if paging["partial"] != true || paging["failedCursor"] != "not-a-number" || paging["pagesFetched"].(float64) != 1 { + t.Fatalf("paging = %#v", paging) + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%#v, want no second call with cursor 0", caller.calls) + } +} + +func TestPagedMCPCommandFirstPageFailureReturnsNoPartial(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}} + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all") + if err == nil || got != nil { + t.Fatalf("result=%#v err=%v, want first-page error without stdout", got, err) + } +} + +func TestPagedMCPCommandLaterFailureOutputsPartial(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, + {err: errors.New("page failed")}, + }} + got, stderr, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err == nil || !strings.Contains(stderr, "pagination stopped") { + t.Fatalf("err=%v stderr=%q", err, stderr) + } + paging := got["paging"].(map[string]any) + if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 { + t.Fatalf("paging = %#v", paging) + } +} + +func TestPagedMCPCommandCursorCycleOutputsPartial(t *testing.T) { + caller := &pagedCommandCaller{steps: []scriptedToolStep{ + {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"0"}}`}, + }} + got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") + if err == nil { + t.Fatal("cursor cycle should return error") + } + paging := got["paging"].(map[string]any) + if paging["partial"] != true || paging["pagesFetched"].(float64) != 1 { + t.Fatalf("paging = %#v", paging) + } +} + +func TestPagedMCPCommandSetJSONPathRejectsNonObjectIntermediate(t *testing.T) { + root := map[string]any{"result": "not-object"} + + if setJSONPath(root, "result.messages", []any{}) { + t.Fatal("setJSONPath should reject a non-object intermediate") + } + if root["result"] != "not-object" { + t.Fatalf("root=%#v, want original intermediate preserved", root) + } +} + +func TestPagedMCPCommandCursorValueKeyCoversBoundaryKinds(t *testing.T) { + if got := cursorValueKey(7, PagedCursorInt64); got != "7" { + t.Fatalf("int cursor key=%q, want 7", got) + } + if got := cursorValueKey(nil, PagedCursorString); got != "" { + t.Fatalf("nil string cursor key=%q, want empty", got) + } +} + +func TestPagedMCPCommandNormalizeCursorArgCoversBoundaryKinds(t *testing.T) { + tests := []struct { + name string + value any + kind PagedCursorKind + want any + wantErr string + }{ + { + name: "nil string cursor", + value: nil, + kind: PagedCursorString, + want: "", + }, + { + name: "int64 cursor", + value: int64(9), + kind: PagedCursorInt64, + want: int64(9), + }, + { + name: "int cursor", + value: 10, + kind: PagedCursorInt64, + want: int64(10), + }, + { + name: "numeric string cursor", + value: " 11 ", + kind: PagedCursorInt64, + want: int64(11), + }, + { + name: "fractional float cursor", + value: 1.5, + kind: PagedCursorInt64, + wantErr: "must be an integer", + }, + { + name: "unsupported cursor type", + value: []string{"bad"}, + kind: PagedCursorInt64, + wantErr: "int64-compatible", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeCursorArg(tt.value, tt.kind) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("value=%#v err=%v, want %q", tt.value, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("value=%#v got=%#v, want %#v", tt.value, got, tt.want) + } + }) + } +} diff --git a/internal/helpers/pr868_surfaces_coverage_test.go b/internal/helpers/pr868_surfaces_coverage_test.go index ed8bb74e..1a2f86a5 100644 --- a/internal/helpers/pr868_surfaces_coverage_test.go +++ b/internal/helpers/pr868_surfaces_coverage_test.go @@ -31,6 +31,17 @@ func executePR868Command(t *testing.T, root *cobra.Command, args ...string) erro } func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) { + t.Run("permission add requires explicit policy", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newMinutesCommand(), "permission", "add", "--ids", "task-1", "--member-uids", "user-1") + if err == nil || !strings.Contains(err.Error(), "--policy") { + t.Fatalf("permission add without --policy error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("permission add called MCP %d times before required policy validation", caller.calls) + } + }) t.Run("hot-word delete dry-run", func(t *testing.T) { installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"}) if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil { @@ -91,6 +102,21 @@ func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) { t.Fatal("expected missing policy") } }) + t.Run("permission apply policy flag is int", func(t *testing.T) { + // 数值参数声明为 int 类型 flag;必填校验走 cmd.Flags().Changed, + // 不能用 validateRequiredFlags(它把 int 零值当成未传)。 + cmd, _, err := newMinutesCommand().Find([]string{"permission", "apply"}) + if err != nil { + t.Fatalf("find permission apply: %v", err) + } + flag := cmd.Flags().Lookup("policy") + if flag == nil { + t.Fatal("flag --policy not found") + } + if flag.Value.Type() != "int" { + t.Fatalf("flag --policy type = %q, want %q", flag.Value.Type(), "int") + } + }) t.Run("audio-memo list default", func(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}} diff --git a/internal/helpers/product_examples_test.go b/internal/helpers/product_examples_test.go index 0b3daadf..dbfd500d 100644 --- a/internal/helpers/product_examples_test.go +++ b/internal/helpers/product_examples_test.go @@ -11,6 +11,7 @@ import ( "time" "unicode" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -157,7 +158,15 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te deps.Out.errW = io.Discard // Product examples execute real RunE paths; whiteboard insert retries must // not burn the suite timeout on real sleep (race CI uses a 12m package cap). + // Sheet export uses helperAfter for progressive polling, so keep that clock + // deterministic too; context deadlines alone still accumulate heavily under + // Windows coverage instrumentation. whiteboardSleep = func(time.Duration) {} + testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + }) httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error { if destPath == "" { diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 25daa8a0..aa3874de 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -836,6 +836,8 @@ Flags: #### 拉取指定时间范围内当前用户的所有会话消息 — 分页拉取当前登录用户在指定时间范围内的所有会话消息 --start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值作为下次 --cursor 继续翻页。若当前账号没有消息搜索权益,CLI 会透传服务端的友好提示与开通入口。 + +需要自动遍历全部分页时显式加 `--page-all`。`--page-limit` 控制最多请求页数(默认 50,范围 1-500),`--max-items` 控制最多输出条数(默认 0 不限制,精确截断并输出 `paging.truncated=true`),`--page-delay` 控制页间等待毫秒数(默认 200,0 表示不等待)。只传这些分页控制参数但不传 `--page-all` 时仍保持原单页调用。 ``` Usage: dws chat message list-all [flags] @@ -847,11 +849,16 @@ Flags: --end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填) --limit int 每页返回数量(默认 50) --cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取) + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) 注意: - 四个参数每次请求都会传递给服务端,cursor 首页传 "0" - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息 - 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页 + - 自动翻页:`--page-all` 会保留并合并 `result.conversationMessagesList`,同一会话跨页合并 messages,并在顶层输出 `paging` 元数据;shortcut 命令不属于本 typed fallback 小节 - 时间格式统一为 yyyy-MM-dd HH:mm:ss - 权限/权益错误不是空结果;应把返回的 friendly_hint 与 action_url 展示给用户,不要继续盲目翻页 ``` @@ -861,6 +868,8 @@ Flags: > 推荐优先使用 `chat message search-advanced --user/--users`(userId)或 `--sender-ids`(openDingTalkId),它还能叠加关键词/群/at 等过滤条件。本命令保留给需要旧 list-by-sender 返回结构的场景。 搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 + +自动翻页同样使用 `--page-all` 触发,复用发送者、时间范围和 limit 条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message list-by-sender [flags] @@ -876,6 +885,10 @@ Flags: --end string 结束时间,ISO-8601 格式 (必填) --limit int 每页返回数量(默认 50) --cursor string 分页游标(默认 "0",翻页传 nextCursor) + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) 注意: - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一: @@ -892,6 +905,8 @@ Flags: > 推荐使用 `chat message search-advanced --at-me`,它还能叠加关键词/群/发送者等过滤条件。本命令适用于仅需拉取 @我 消息的简单场景。 搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 + +自动翻页同样使用 `--page-all` 触发,复用时间范围和 group 过滤条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message list-mentions [flags] @@ -906,6 +921,10 @@ Flags: --end string 结束时间,ISO-8601 格式 (必填) --limit int 每页返回数量(默认 50) --cursor string 分页游标(默认 "0",翻页传 nextCursor) + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) 注意: - --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊 @@ -917,6 +936,8 @@ Flags: #### 拉取特别关注人的消息 拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。 + +自动翻页使用 `--page-all` 触发,按 int64 cursor 注入下一页游标并聚合 `result.messages`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message list-focused [flags] @@ -926,6 +947,10 @@ Example: Flags: --limit int 每页返回数量(默认 50) --cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor) + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) 注意: - 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor @@ -977,6 +1002,8 @@ Flags: > 推荐优先使用 `chat message search-advanced`,它是本命令的严格超集:query 可选(非必填)、支持多个会话(非单个)、还能叠加发送者/at 等维度过滤。 按关键词搜索消息内容。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 + +自动翻页同样使用 `--page-all` 触发,复用 query、时间范围和 group 条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message search [flags] @@ -991,6 +1018,10 @@ Flags: --end string 结束时间,ISO-8601 格式 (必填) --limit int 每页返回数量(默认 100) --cursor string 分页游标(默认 "0",翻页传 nextCursor) + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) 注意: - --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话 @@ -1004,6 +1035,8 @@ Flags: > 推荐:这是消息搜索的首选接口。它可以完全替代 `chat message search`(query 可选 vs 必填,支持多个会话 vs 单个),大部分替代 `chat message list-by-sender`(通过 --user/--users 按 userId 搜索发送者,或通过 --sender-ids 按 openDingTalkId 搜索)和 `chat message list-mentions`(通过 --at-me 搜索@我的消息)。仅在拉取「特别关注人」消息时需要退回 `list-focused`。 支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。 + +自动翻页使用 `--page-all` 触发,复用所有高级过滤参数,只替换每页 `cursor`,保留并合并 `result.conversationMessagesList`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message search-advanced [flags] @@ -1030,6 +1063,10 @@ Flags: --end string 结束时间,ISO-8601 格式(可选) --cursor string 分页游标(默认 "0") --limit int 每页返回数量(默认 100) + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) --conversation-ids 的别名: --groups 注意: @@ -1423,6 +1460,8 @@ Flags: ``` #### 查询收藏消息 — 分页查询当前用户收藏的消息 + +自动翻页使用 `--page-all` 触发,按 int64 cursor 注入下一页游标并聚合 `result.items`(不是 `result.messages`)。`--size` 仍保持 1-30 的 Open 服务范围;`--page-limit` 只控制最多请求页数。 ``` Usage: dws chat message list-favorites [flags] @@ -1433,10 +1472,15 @@ Example: Flags: --cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor --size int 一次拉取的收藏数量,默认 20,范围 1-30 + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) 注意: - 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20" - hasMore=true 时,将 nextCursor 作为下一次的 --cursor + - 自动翻页输出顶层 `paging` 元数据,达到 `--page-limit` 或 `--max-items` 时 `truncated=true` ``` ### bot (机器人管理) diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index dc1ce7f4..afeb727a 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -57,9 +57,10 @@ dws minutes │ ├── create --file-name "..." --file-size # 创建上传会话,获取上传地址 │ ├── complete --session-id # 确认上传完成 │ └── cancel --session-id # 取消上传会话 -└── permission # 权限管理(添加/移除听记成员权限) +└── permission # 权限管理(添加/移除听记成员权限、为自己申请权限) ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限 - └── remove --ids --member-uids # 移除成员权限 + ├── remove --ids --member-uids # 移除成员权限 + └── apply --id --policy <2-4> # 为当前用户申请听记权限 ``` **高频错误命令 vs 正确命令对照(真实 badcase 提炼):** @@ -308,7 +309,8 @@ dws minutes │ └── cancel --session-id ├── permission │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] -│ └── remove --ids --member-uids +│ ├── remove --ids --member-uids +│ └── apply --id --policy <2-4> └── tag # 听记标签/分组管理(标签在听记页面手动创建) ├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称) └── query --tag-id [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表 @@ -1211,6 +1213,38 @@ Flags: - 如果成员是听记的创建者(所有者),无法通过此命令移除其权限 - 建议在移除前先确认成员的当前权限,避免误操作 +### 为当前用户申请听记权限 +``` +Usage: + dws minutes permission apply [flags] +Example: + dws minutes permission apply --id --policy 4 + dws minutes permission apply --id --policy 2 +Flags: + --id string 听记 taskUuid (必填) + --policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填) +``` + +为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。 +适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。 + +**权限类型说明:** + +| --policy 值 | 含义 | 说明 | +|------------|------|------| +| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 | +| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 | +| 4 | 仅查看 | 仅可查看听记内容,不可下载 | + +**与 permission add 的区别:** +- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids` +- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1) + +**典型使用场景:** +- 打开同事分享的听记链接提示无权限,申请查看权限 +- 需要编辑某篇只读听记的纪要,申请可编辑权限 +- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限 + ### 查询我的听记标签/分组列表 ``` Usage: @@ -1595,6 +1629,7 @@ tagId 可通过 `dws minutes tag list` 获取。 用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add` 用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**) 用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove` +用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**) **自然语言示例:** - "把张三加到这个听记中" @@ -1611,6 +1646,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - "批量添加成员到这个听记" - "把张三加为参会人" - "移除张三对这个听记的权限" +- "我打不开这个听记,帮我申请一下权限" +- "帮我申请这个听记的查看权限" +- "申请这篇听记的编辑权限" - "取消小王对这个听记的访问" - "不让某人看这个听记了" @@ -1618,8 +1656,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - 用户提到"加/添加/共享/分享/让...看/让...访问"等增加权限语义 → `permission add` - 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**) - 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove` -- 如果用户未指定权限类型,默认使用 `--policy 4`(可查看/下载/编辑) -- 如果用户未提供 member-uids,需要先引导用户提供目标成员的钉钉 UID(可通过 `dws contact user search --query "姓名"` 查询) +- 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看)) +- `permission add` 的 `--policy` 是必填参数,没有默认值。用户未指定权限类型时,先确认要 0(管理员)/ 1(所有者)/ 2(可编辑)/ 3(可查看下载)/ 4(仅查看);即使确认选择 4,命令中仍必须显式传入 `--policy 4` +- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws contact user search --query "姓名"` 查询);`permission apply` 不适用这一条 **典型执行链路:** 1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 89099201..566f99a5 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -148,14 +148,16 @@ dws chat +chat-messages --group "项目群" --page-all --format json \ | 命令 | 用途 | 示例与要点 | |------|------|------------| | `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` | -| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start --end --limit 100 --cursor 0`;四个参数每次请求都传,翻页用 `nextCursor` | -| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊 | -| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部 | -| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor` | +| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start --end --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,保留并合并 `result.conversationMessagesList` | +| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all`,同一会话跨页合并 messages | +| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all`,同一会话跨页合并 messages | +| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor`;完整遍历加 `--page-all`,cursor 为 int64 | | `message list-unread-conversations` | 未读会话列表 | 可选 `--count` | | `message list-topic-replies` | 拉取话题回复 | `--group --topic-id ` | | `message list-by-ids` | 按消息 ID 批量查询 | `--msg-ids msgId1,msgId2`,最多 50 条 | +Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` 或 `--page-delay` 仍保持默认单页 fallback。`conversationMessagesList` 结构会保留并按 `openConversationId` 合并 messages。分页元数据输出到顶层 `paging`,包含 `truncated`、`hasMore`、`lastCursor`、`pages`、`total`;非第一页失败时会输出 partial 结果和 `failedPage` / `failedCursor` / `pagesFetched` / `itemsFetched`。 + `message list` 注意事项: - `--group`、`--user`、`--open-dingtalk-id` 互斥且必须指定一个。 @@ -194,6 +196,7 @@ dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+0 dws chat message search-advanced --user --start --end dws chat message search-advanced --at-me --start --end dws chat message search-advanced --conversation-ids , --query "合同" --limit 50 --cursor 0 +dws chat message search-advanced --query "周报" --start --end --page-all --max-items 200 dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件" dws chat message search-advanced --only-robot-messages --query "通知" ``` @@ -210,12 +213,14 @@ dws chat message search-advanced --only-robot-messages --query "通知" | `--only-robot-messages` | 只搜索机器人消息 | | `--start` / `--end` | ISO-8601 时间范围 | | `--cursor` / `--limit` | 分页,翻页用 `nextCursor` | +| `--page-all` / `--page-limit` / `--max-items` / `--page-delay` | 自动翻页;`--page-all` 是唯一触发开关 | 仅简单关键词搜索时可用: ```bash dws chat message search --query "changefree" --start --end --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start --end +dws chat message search --query "发布计划" --start --end --page-all --page-delay 0 ``` ### 消息状态与撤回 @@ -307,9 +312,10 @@ Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID | `message add-favorite` | 收藏消息 | `--open-message-id` `--open-conversation-id` | | `message remove-favorite` | 取消收藏消息 | `--open-message-id` `--open-conversation-id` | | `+flag-list` | 查询收藏消息列表的默认 Shortcut | 默认一页;要求全部时加 `--page-all`;`--page-size/--size` 范围为 1-30,可用 `--page-token` 或兼容的 `--cursor` 续页,检查 `complete` / `failures` | -| `message list-favorites` | 原始单页收藏结果 fallback | 可选 `--cursor` `--size` | +| `message list-favorites` | typed 收藏消息列表 | 默认一页;完整遍历加 `--page-all`,数字 cursor,聚合 `result.items`;可选 `--cursor` `--size` | `+flag-list` 查询钉钉 message favorite,底层使用数字 cursor;它与消息 Pin、消息 Top 和会话置顶属于不同对象层级。 +`message list-favorites --page-all` 仍遵守 `--size` 1-30;`--page-limit` 只控制最多请求页数,`--max-items` 可精确截断输出。 消息置顶 `set-top-msg` 与会话置顶 `chat set-top` 不同:前者置顶会话内消息,后者置顶整个会话。 ### 表情回应 diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index 7a8c9b19..538e940e 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -66,9 +66,10 @@ dws minutes │ ├── create --file-name "..." --file-size # 创建上传会话,获取上传地址 │ ├── complete --session-id # 确认上传完成 │ └── cancel --session-id # 取消上传会话 -├── permission # 权限管理(添加/移除听记成员权限) +├── permission # 权限管理(添加/移除听记成员权限、为自己申请权限) │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限 -│ └── remove --ids --member-uids # 移除成员权限 +│ ├── remove --ids --member-uids # 移除成员权限 +│ └── apply --id --policy <2-4> # 为当前用户申请听记权限 └── tag # 听记标签/分组管理(标签在听记页面手动创建) ├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称) └── query --tag-id [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表 @@ -417,7 +418,8 @@ dws minutes │ └── cancel --session-id ├── permission │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] -│ └── remove --ids --member-uids +│ ├── remove --ids --member-uids +│ └── apply --id --policy <2-4> └── tag ├── list └── query --tag-id [--limit] [--cursor] @@ -1324,6 +1326,38 @@ Flags: - 如果成员是听记的创建者(所有者),无法通过此命令移除其权限 - 建议在移除前先确认成员的当前权限,避免误操作 +### 为当前用户申请听记权限 +``` +Usage: + dws minutes permission apply [flags] +Example: + dws minutes permission apply --id --policy 4 + dws minutes permission apply --id --policy 2 +Flags: + --id string 听记 taskUuid (必填) + --policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填) +``` + +为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。 +适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。 + +**权限类型说明:** + +| --policy 值 | 含义 | 说明 | +|------------|------|------| +| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 | +| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 | +| 4 | 仅查看 | 仅可查看听记内容,不可下载 | + +**与 permission add 的区别:** +- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids` +- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1) + +**典型使用场景:** +- 打开同事分享的听记链接提示无权限,申请查看权限 +- 需要编辑某篇只读听记的纪要,申请可编辑权限 +- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限 + ### 查询我的听记标签/分组列表 ``` Usage: @@ -1708,6 +1742,7 @@ tagId 可通过 `dws minutes tag list` 获取。 用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add` 用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**) 用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove` +用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**) **自然语言示例:** - "把张三加到这个听记中" @@ -1724,6 +1759,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - "批量添加成员到这个听记" - "把张三加为参会人" - "移除张三对这个听记的权限" +- "我打不开这个听记,帮我申请一下权限" +- "帮我申请这个听记的查看权限" +- "申请这篇听记的编辑权限" - "取消小王对这个听记的访问" - "不让某人看这个听记了" @@ -1731,8 +1769,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - 用户提到"加/添加/共享/分享/让...看/让...访问"等增加权限语义 → `permission add` - 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**) - 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove` -- 如果用户未指定权限类型,默认使用 `--policy 4`(可查看/下载/编辑) -- 如果用户未提供 member-uids,需要先引导用户提供目标成员的钉钉 UID(可通过 `dws aisearch person --keyword "姓名" --dimension name` 查询) +- 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看)) +- `permission add` 的 `--policy` 是必填参数,没有默认值。用户未指定权限类型时,先确认要 0(管理员)/ 1(所有者)/ 2(可编辑)/ 3(可查看下载)/ 4(仅查看);即使确认选择 4,命令中仍必须显式传入 `--policy 4` +- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws aisearch person --keyword "姓名" --dimension name` 查询);`permission apply` 不适用这一条 **典型执行链路:** 1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index b69e2cd6..92b6ef62 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -578,6 +578,36 @@ if (!isInterfaceSensitive("internal/corecmd/corecmd.go")) { } } +func TestHighRiskClassificationShardsHelperChanges(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("ReadFile(ci.yml) error = %v", err) + } + workflow := string(data) + start := strings.Index(workflow, " const isDocsOnly =") + end := strings.Index(workflow, " const classifyFiles =") + if start < 0 || end <= start { + t.Fatal("Code Admission workflow missing high-risk classifier boundaries") + } + classifier := strings.TrimSpace(workflow[start:end]) + probe := classifier + ` +if (!isHighRisk("internal/helpers/minutes.go")) { + throw new Error("helper changes must use the sharded full suite"); +} +if (isHighRisk("internal/helpersx/minutes.go")) { + throw new Error("helper high-risk classification must respect the path boundary"); +} +` + cmd := exec.Command("node", "-e", probe) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("high-risk classifier rejected helper sharding contract: %v\n%s", err, output) + } +} + func TestChangelogPRFastPathWorkflowContract(t *testing.T) { root, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { @@ -644,6 +674,7 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { "filename === '.github/actionlint.yaml'", "filename.startsWith('scripts/')", "filename.startsWith('verify/')", + "filename.startsWith('internal/helpers/')", "filename === 'test/fixtures/cli-interface-baseline.txt'", "filename.startsWith('internal/interfacesnapshot/')", "filename.startsWith('internal/cobracmd/')", diff --git a/test/unit/skill_docs_policy_test.go b/test/unit/skill_docs_policy_test.go index 75e9b2c5..45877f26 100644 --- a/test/unit/skill_docs_policy_test.go +++ b/test/unit/skill_docs_policy_test.go @@ -540,6 +540,36 @@ func TestCrossPlatformCoverageDocSkillReferenceLoadingBudget(t *testing.T) { } } +func TestMinutesPermissionAddRequiresExplicitPolicy(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + paths := []string{ + filepath.Join(root, "skills", "mono", "references", "products", "minutes.md"), + filepath.Join(root, "skills", "multi", "dingtalk-minutes", "references", "minutes.md"), + } + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + text := string(content) + for _, required := range []string{ + "`permission add` 的 `--policy` 是必填参数,没有默认值", + "命令中仍必须显式传入 `--policy 4`", + } { + if !strings.Contains(text, required) { + t.Errorf("%s missing permission add policy contract %q", path, required) + } + } + if strings.Contains(text, "`permission add` 默认使用 `--policy 4`") { + t.Errorf("%s still documents a nonexistent permission add policy default", path) + } + } +} + func hasAny(s string, needles []string) bool { for _, needle := range needles { if strings.Contains(s, needle) {