Merge remote-tracking branch 'origin/main' into feat/eval-dispatch
This commit is contained in:
@@ -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/') ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+350
-254
@@ -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 <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
|
||||
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
|
||||
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0
|
||||
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor <nextCursor>
|
||||
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --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 <userId> --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 <userId> --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 <userId> --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 <openconversation_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-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor <nextCursor>
|
||||
dws chat message list-mentions --group <openconversation_id> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --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 <nextCursor>`,
|
||||
dws chat message list-focused --limit 20 --cursor <nextCursor>
|
||||
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 <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
|
||||
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
|
||||
dws chat message search --query "发布计划" --start "2026-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 <userId> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
|
||||
dws chat message search-advanced --users <userId1>,<userId2> --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 <openDingTalkId1>,<openDingTalkId2> --conversation-ids <openConversationId1>,<openConversationId2> --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 <userId>
|
||||
# 查询人员: 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: 拉取我创建/管理的群 ──────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+33
-5
@@ -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():
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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":[]}`}}}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 (机器人管理)
|
||||
|
||||
@@ -57,9 +57,10 @@ dws minutes
|
||||
│ ├── create --file-name "..." --file-size <n> # 创建上传会话,获取上传地址
|
||||
│ ├── complete --session-id <sid> # 确认上传完成
|
||||
│ └── cancel --session-id <sid> # 取消上传会话
|
||||
└── permission <subcommand> # 权限管理(添加/移除听记成员权限)
|
||||
└── permission <subcommand> # 权限管理(添加/移除听记成员权限、为自己申请权限)
|
||||
├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限
|
||||
└── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
└── apply --id <uuid> --policy <2-4> # 为当前用户申请听记权限
|
||||
```
|
||||
|
||||
**高频错误命令 vs 正确命令对照(真实 badcase 提炼):**
|
||||
@@ -308,7 +309,8 @@ dws minutes
|
||||
│ └── cancel --session-id <sid>
|
||||
├── permission <subcommand>
|
||||
│ ├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."]
|
||||
│ └── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ ├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ └── apply --id <uuid> --policy <2-4>
|
||||
└── tag <subcommand> # 听记标签/分组管理(标签在听记页面手动创建)
|
||||
├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称)
|
||||
└── query --tag-id <tagId> [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表
|
||||
@@ -1211,6 +1213,38 @@ Flags:
|
||||
- 如果成员是听记的创建者(所有者),无法通过此命令移除其权限
|
||||
- 建议在移除前先确认成员的当前权限,避免误操作
|
||||
|
||||
### 为当前用户申请听记权限
|
||||
```
|
||||
Usage:
|
||||
dws minutes permission apply [flags]
|
||||
Example:
|
||||
dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --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
|
||||
|
||||
@@ -148,14 +148,16 @@ dws chat +chat-messages --group "项目群" --page-all --format json \
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group <cid> --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` |
|
||||
| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start <ISO> --end <ISO> --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 <ISO> --end <ISO> --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 <openConversationId> --topic-id <openConvThreadId>` |
|
||||
| `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 <userId> --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --at-me --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --conversation-ids <cid1>,<cid2> --query "合同" --limit 50 --cursor 0
|
||||
dws chat message search-advanced --query "周报" --start <ISO> --end <ISO> --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 <ISO> --end <ISO> --limit 50 --cursor 0
|
||||
dws chat message search --query "codereview" --group <openConversationId> --start <ISO> --end <ISO>
|
||||
dws chat message search --query "发布计划" --start <ISO> --end <ISO> --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` 不同:前者置顶会话内消息,后者置顶整个会话。
|
||||
|
||||
### 表情回应
|
||||
|
||||
@@ -66,9 +66,10 @@ dws minutes
|
||||
│ ├── create --file-name "..." --file-size <n> # 创建上传会话,获取上传地址
|
||||
│ ├── complete --session-id <sid> # 确认上传完成
|
||||
│ └── cancel --session-id <sid> # 取消上传会话
|
||||
├── permission <subcommand> # 权限管理(添加/移除听记成员权限)
|
||||
├── permission <subcommand> # 权限管理(添加/移除听记成员权限、为自己申请权限)
|
||||
│ ├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限
|
||||
│ └── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
│ ├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
│ └── apply --id <uuid> --policy <2-4> # 为当前用户申请听记权限
|
||||
└── tag <subcommand> # 听记标签/分组管理(标签在听记页面手动创建)
|
||||
├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称)
|
||||
└── query --tag-id <tagId> [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表
|
||||
@@ -417,7 +418,8 @@ dws minutes
|
||||
│ └── cancel --session-id <sid>
|
||||
├── permission <subcommand>
|
||||
│ ├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."]
|
||||
│ └── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ ├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ └── apply --id <uuid> --policy <2-4>
|
||||
└── tag <subcommand>
|
||||
├── list
|
||||
└── query --tag-id <tagId> [--limit] [--cursor]
|
||||
@@ -1324,6 +1326,38 @@ Flags:
|
||||
- 如果成员是听记的创建者(所有者),无法通过此命令移除其权限
|
||||
- 建议在移除前先确认成员的当前权限,避免误操作
|
||||
|
||||
### 为当前用户申请听记权限
|
||||
```
|
||||
Usage:
|
||||
dws minutes permission apply [flags]
|
||||
Example:
|
||||
dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --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
|
||||
|
||||
@@ -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/')",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user