From f79a6fc707bf64398734f2e468b9d471f0428bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=AE=E7=9F=A5=E5=A4=8F?= Date: Fri, 7 Aug 2026 16:25:38 +0800 Subject: [PATCH 01/21] fix(minutes): type permission apply --policy as int --- CHANGELOG.md | 7 ++ internal/helpers/minutes.go | 13 ++-- .../helpers/pr868_surfaces_coverage_test.go | 15 ++++ skills/mono/references/products/minutes.md | 73 ++++++++++++++++++- .../dingtalk-minutes/references/minutes.md | 73 ++++++++++++++++++- 5 files changed, 169 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 277cf601..6497665b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Changed - **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged. +- **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 `hot-word delete` and + `permission apply`, list both in the command trees, add their intent + routing, and use a neutral hot-word example in the delete samples. ### Fixed diff --git a/internal/helpers/minutes.go b/internal/helpers/minutes.go index ac52f2be..e3f11ce8 100644 --- a/internal/helpers/minutes.go +++ b/internal/helpers/minutes.go @@ -1346,7 +1346,7 @@ func newMinutesCommand() *cobra.Command { Short: "批量删除个人热词", Long: `批量删除听记个人热词。 支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`, - Example: ` dws minutes hot-word delete --words "钉钉" + Example: ` dws minutes hot-word delete --words "天气" dws minutes hot-word delete --words "OKR,钉钉,Copilot"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "words"); err != nil { @@ -1384,7 +1384,7 @@ func newMinutesCommand() *cobra.Command { "不确定现有热词时先用 hot-word list", }, Examples: []string{ - "dws minutes hot-word delete --words \"钉钉\"", + "dws minutes hot-word delete --words \"天气\"", "dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"", }, }, @@ -1883,11 +1883,12 @@ func newMinutesCommand() *cobra.Command { if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil { return err } - if err := validateRequiredFlags(cmd, "policy"); err != nil { - return err + // 手动校验必填参数(避免 validateRequiredFlags 对 Int 的误判) + if !cmd.Flags().Changed("policy") { + return fmt.Errorf("missing required flag --policy") } - policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64) + policyID, err := cmd.Flags().GetInt("policy") if err != nil || policyID < 2 || policyID > 4 { return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)") } @@ -1942,7 +1943,7 @@ func newMinutesCommand() *cobra.Command { _ = permissionApplyCmd.Flags().MarkHidden("task-uuid") permissionApplyCmd.Flags().String("uuid", "", "--id 的别名") _ = permissionApplyCmd.Flags().MarkHidden("uuid") - permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)") + permissionApplyCmd.Flags().Int("policy", 0, "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)") permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd) diff --git a/internal/helpers/pr868_surfaces_coverage_test.go b/internal/helpers/pr868_surfaces_coverage_test.go index ed8bb74e..829a95c7 100644 --- a/internal/helpers/pr868_surfaces_coverage_test.go +++ b/internal/helpers/pr868_surfaces_coverage_test.go @@ -91,6 +91,21 @@ func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) { t.Fatal("expected missing policy") } }) + t.Run("permission apply policy flag is int", func(t *testing.T) { + // 数值参数声明为 int 类型 flag;必填校验走 cmd.Flags().Changed, + // 不能用 validateRequiredFlags(它把 int 零值当成未传)。 + cmd, _, err := newMinutesCommand().Find([]string{"permission", "apply"}) + if err != nil { + t.Fatalf("find permission apply: %v", err) + } + flag := cmd.Flags().Lookup("policy") + if flag == nil { + t.Fatal("flag --policy not found") + } + if flag.Value.Type() != "int" { + t.Fatalf("flag --policy type = %q, want %q", flag.Value.Type(), "int") + } + }) t.Run("audio-memo list default", func(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}} diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index dc1ce7f4..05ddd46d 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -51,15 +51,17 @@ dws minutes │ └── status --id # 查询脑图生成状态 ├── hot-word # 个人热词管理 │ ├── add --words "..." # 添加热词(提升语音识别准确率) +│ ├── delete --words "..." # 批量删除个人热词 │ └── list # 查询我的热词列表 ├── replace-text --id --search "..." --replace "..." # 替换转写文本中的错误文字 ├── upload # 音频上传(上传本地音频文件生成听记) │ ├── create --file-name "..." --file-size # 创建上传会话,获取上传地址 │ ├── complete --session-id # 确认上传完成 │ └── cancel --session-id # 取消上传会话 -└── permission # 权限管理(添加/移除听记成员权限) +└── permission # 权限管理(添加/移除听记成员权限、为自己申请权限) ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限 - └── remove --ids --member-uids # 移除成员权限 + ├── remove --ids --member-uids # 移除成员权限 + └── apply --id --policy <2-4> # 为当前用户申请听记权限 ``` **高频错误命令 vs 正确命令对照(真实 badcase 提炼):** @@ -300,6 +302,7 @@ dws minutes │ └── status --id ├── hot-word │ ├── add --words "..." +│ ├── delete --words "..." │ └── list ├── replace-text --id --search "..." --replace "..." ├── upload @@ -308,7 +311,8 @@ dws minutes │ └── cancel --session-id ├── permission │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] -│ └── remove --ids --member-uids +│ ├── remove --ids --member-uids +│ └── apply --id --policy <2-4> └── tag # 听记标签/分组管理(标签在听记页面手动创建) ├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称) └── query --tag-id [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表 @@ -1047,6 +1051,19 @@ Flags: 添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。支持一次添加多个热词(逗号分隔),每个热词长度不超过 10 个汉字或 5 个英文单词。 +### 批量删除个人热词 +``` +Usage: + dws minutes hot-word delete [flags] +Example: + dws minutes hot-word delete --words "天气" + dws minutes hot-word delete --words "OKR,钉钉,Copilot" +Flags: + --words string 要删除的热词,多个用逗号分隔 (必填) +``` + +批量删除听记个人热词。支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化,适用于清理误加、过时或不再需要的热词。 + ### 查询我的热词列表 ``` Usage: @@ -1211,6 +1228,38 @@ Flags: - 如果成员是听记的创建者(所有者),无法通过此命令移除其权限 - 建议在移除前先确认成员的当前权限,避免误操作 +### 为当前用户申请听记权限 +``` +Usage: + dws minutes permission apply [flags] +Example: + dws minutes permission apply --id --policy 4 + dws minutes permission apply --id --policy 2 +Flags: + --id string 听记 taskUuid (必填) + --policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填) +``` + +为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。 +适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。 + +**权限类型说明:** + +| --policy 值 | 含义 | 说明 | +|------------|------|------| +| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 | +| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 | +| 4 | 仅查看 | 仅可查看听记内容,不可下载 | + +**与 permission add 的区别:** +- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids` +- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1) + +**典型使用场景:** +- 打开同事分享的听记链接提示无权限,申请查看权限 +- 需要编辑某篇只读听记的纪要,申请可编辑权限 +- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限 + ### 查询我的听记标签/分组列表 ``` Usage: @@ -1313,6 +1362,20 @@ tagId 可通过 `dws minutes tag list` 获取。 - "把'AI听记'加进热词库" - "这个专有名词总转写错,帮我加到热词" +### 删除热词 +用户说"删热词/删除热词/移除热词/去掉某个热词/这个热词不要了" → `hot-word delete` + +**自然语言示例:** +- "把'天气'从热词里删掉" +- "删除热词'魔镜'" +- "移除我加错的那个热词" +- "帮我把'飞书,悟空'这几个热词删了" + +**路由规则:** +- 用户想去掉/移除已有热词 → `hot-word delete --words "..."`(多个用逗号分隔) +- 不确定当前有哪些热词时,可先 `hot-word list` 确认再删 +- 用户身份由系统自动识别,无需传 uid + ### 查找替换 用户说"把所有的A替换成B/查找替换/批量替换文字" → `replace-text`,**执行成功后必须主动引导用户:"要不要把『新文字』加到热词里,避免后续新听记再识别错?" 用户确认后再调用 `hot-word add`** @@ -1595,6 +1658,7 @@ tagId 可通过 `dws minutes tag list` 获取。 用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add` 用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**) 用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove` +用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**) **自然语言示例:** - "把张三加到这个听记中" @@ -1611,6 +1675,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - "批量添加成员到这个听记" - "把张三加为参会人" - "移除张三对这个听记的权限" +- "我打不开这个听记,帮我申请一下权限" +- "帮我申请这个听记的查看权限" +- "申请这篇听记的编辑权限" - "取消小王对这个听记的访问" - "不让某人看这个听记了" diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index 7a8c9b19..25b75ce7 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -60,15 +60,17 @@ dws minutes │ └── status --id # 查询脑图生成状态 ├── hot-word # 个人热词管理 │ ├── add --words "..." # 添加热词(提升语音识别准确率) +│ ├── delete --words "..." # 批量删除个人热词 │ └── list # 查询我的热词列表 ├── replace-text --id --search "..." --replace "..." # 替换转写文本中的错误文字 ├── upload # 音频上传(上传本地音频文件生成听记) │ ├── create --file-name "..." --file-size # 创建上传会话,获取上传地址 │ ├── complete --session-id # 确认上传完成 │ └── cancel --session-id # 取消上传会话 -├── permission # 权限管理(添加/移除听记成员权限) +├── permission # 权限管理(添加/移除听记成员权限、为自己申请权限) │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限 -│ └── remove --ids --member-uids # 移除成员权限 +│ ├── remove --ids --member-uids # 移除成员权限 +│ └── apply --id --policy <2-4> # 为当前用户申请听记权限 └── tag # 听记标签/分组管理(标签在听记页面手动创建) ├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称) └── query --tag-id [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表 @@ -409,6 +411,7 @@ dws minutes │ └── status --id ├── hot-word │ ├── add --words "..." +│ ├── delete --words "..." │ └── list ├── replace-text --id --search "..." --replace "..." ├── upload @@ -417,7 +420,8 @@ dws minutes │ └── cancel --session-id ├── permission │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] -│ └── remove --ids --member-uids +│ ├── remove --ids --member-uids +│ └── apply --id --policy <2-4> └── tag ├── list └── query --tag-id [--limit] [--cursor] @@ -1160,6 +1164,19 @@ Flags: 添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。支持一次添加多个热词(逗号分隔),每个热词长度不超过 10 个汉字或 5 个英文单词。 +### 批量删除个人热词 +``` +Usage: + dws minutes hot-word delete [flags] +Example: + dws minutes hot-word delete --words "天气" + dws minutes hot-word delete --words "OKR,钉钉,Copilot" +Flags: + --words string 要删除的热词,多个用逗号分隔 (必填) +``` + +批量删除听记个人热词。支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化,适用于清理误加、过时或不再需要的热词。 + ### 查询我的热词列表 ``` Usage: @@ -1324,6 +1341,38 @@ Flags: - 如果成员是听记的创建者(所有者),无法通过此命令移除其权限 - 建议在移除前先确认成员的当前权限,避免误操作 +### 为当前用户申请听记权限 +``` +Usage: + dws minutes permission apply [flags] +Example: + dws minutes permission apply --id --policy 4 + dws minutes permission apply --id --policy 2 +Flags: + --id string 听记 taskUuid (必填) + --policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填) +``` + +为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。 +适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。 + +**权限类型说明:** + +| --policy 值 | 含义 | 说明 | +|------------|------|------| +| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 | +| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 | +| 4 | 仅查看 | 仅可查看听记内容,不可下载 | + +**与 permission add 的区别:** +- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids` +- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1) + +**典型使用场景:** +- 打开同事分享的听记链接提示无权限,申请查看权限 +- 需要编辑某篇只读听记的纪要,申请可编辑权限 +- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限 + ### 查询我的听记标签/分组列表 ``` Usage: @@ -1426,6 +1475,20 @@ tagId 可通过 `dws minutes tag list` 获取。 - "把'AI听记'加进热词库" - "这个专有名词总转写错,帮我加到热词" +### 删除热词 +用户说"删热词/删除热词/移除热词/去掉某个热词/这个热词不要了" → `hot-word delete` + +**自然语言示例:** +- "把'天气'从热词里删掉" +- "删除热词'魔镜'" +- "移除我加错的那个热词" +- "帮我把'飞书,悟空'这几个热词删了" + +**路由规则:** +- 用户想去掉/移除已有热词 → `hot-word delete --words "..."`(多个用逗号分隔) +- 不确定当前有哪些热词时,可先 `hot-word list` 确认再删 +- 用户身份由系统自动识别,无需传 uid + ### 查找替换 用户说"把所有的A替换成B/查找替换/批量替换文字" → `replace-text`,**执行成功后必须主动引导用户:"要不要把『新文字』加到热词里,避免后续新听记再识别错?" 用户确认后再调用 `hot-word add`** @@ -1708,6 +1771,7 @@ tagId 可通过 `dws minutes tag list` 获取。 用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add` 用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**) 用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove` +用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**) **自然语言示例:** - "把张三加到这个听记中" @@ -1724,6 +1788,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - "批量添加成员到这个听记" - "把张三加为参会人" - "移除张三对这个听记的权限" +- "我打不开这个听记,帮我申请一下权限" +- "帮我申请这个听记的查看权限" +- "申请这篇听记的编辑权限" - "取消小王对这个听记的访问" - "不让某人看这个听记了" From 06b0a9eef361e0191aabd54c2d83c1ee773bd45e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=AE=E7=9F=A5=E5=A4=8F?= Date: Fri, 7 Aug 2026 17:56:32 +0800 Subject: [PATCH 02/21] docs(minutes): drop hot-word delete intent routing --- CHANGELOG.md | 6 ++++-- skills/mono/references/products/minutes.md | 14 -------------- .../multi/dingtalk-minutes/references/minutes.md | 14 -------------- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6497665b..ab3f9e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th numeric-parameter convention. `--help` reports `int` instead of `string`; accepted values (2/3/4) and gateway behavior are unchanged. - **Minutes skill references** — document `hot-word delete` and - `permission apply`, list both in the command trees, add their intent - routing, and use a neutral hot-word example in the delete samples. + `permission apply`, list both in the command trees, add intent routing for + `permission apply`, and use a neutral hot-word example in the delete samples. + `hot-word delete` intentionally gets no intent routing while its leaf still + declares `confirmation: not_required`. ### Fixed diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index 05ddd46d..36667c70 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -1362,20 +1362,6 @@ tagId 可通过 `dws minutes tag list` 获取。 - "把'AI听记'加进热词库" - "这个专有名词总转写错,帮我加到热词" -### 删除热词 -用户说"删热词/删除热词/移除热词/去掉某个热词/这个热词不要了" → `hot-word delete` - -**自然语言示例:** -- "把'天气'从热词里删掉" -- "删除热词'魔镜'" -- "移除我加错的那个热词" -- "帮我把'飞书,悟空'这几个热词删了" - -**路由规则:** -- 用户想去掉/移除已有热词 → `hot-word delete --words "..."`(多个用逗号分隔) -- 不确定当前有哪些热词时,可先 `hot-word list` 确认再删 -- 用户身份由系统自动识别,无需传 uid - ### 查找替换 用户说"把所有的A替换成B/查找替换/批量替换文字" → `replace-text`,**执行成功后必须主动引导用户:"要不要把『新文字』加到热词里,避免后续新听记再识别错?" 用户确认后再调用 `hot-word add`** diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index 25b75ce7..2a8e6db6 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -1475,20 +1475,6 @@ tagId 可通过 `dws minutes tag list` 获取。 - "把'AI听记'加进热词库" - "这个专有名词总转写错,帮我加到热词" -### 删除热词 -用户说"删热词/删除热词/移除热词/去掉某个热词/这个热词不要了" → `hot-word delete` - -**自然语言示例:** -- "把'天气'从热词里删掉" -- "删除热词'魔镜'" -- "移除我加错的那个热词" -- "帮我把'飞书,悟空'这几个热词删了" - -**路由规则:** -- 用户想去掉/移除已有热词 → `hot-word delete --words "..."`(多个用逗号分隔) -- 不确定当前有哪些热词时,可先 `hot-word list` 确认再删 -- 用户身份由系统自动识别,无需传 uid - ### 查找替换 用户说"把所有的A替换成B/查找替换/批量替换文字" → `replace-text`,**执行成功后必须主动引导用户:"要不要把『新文字』加到热词里,避免后续新听记再识别错?" 用户确认后再调用 `hot-word add`** From e5a60386c607ebcd128b14d7e6c3d78651a670d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Sun, 9 Aug 2026 17:18:53 +0800 Subject: [PATCH 03/21] feat(chat): add typed IM message pagination --- CHANGELOG.md | 8 + internal/helpers/chat.go | 526 ++++++++++-------- .../helpers/chat_message_pagination_test.go | 262 +++++++++ internal/helpers/paged_command.go | 362 ++++++++++++ internal/helpers/paged_command_test.go | 223 ++++++++ skills/mono/references/products/chat.md | 44 ++ .../references/chat/chat-message.md | 16 +- 7 files changed, 1199 insertions(+), 242 deletions(-) create mode 100644 internal/helpers/chat_message_pagination_test.go create mode 100644 internal/helpers/paged_command.go create mode 100644 internal/helpers/paged_command_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6df671..04b52758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Added + +- **Typed IM message pagination** — `dws chat message list-all`, + `list-by-sender`, `list-mentions`, `list-focused`, `list-favorites`, + `search`, and `search-advanced` now support explicit `--page-all` + traversal with `--page-limit`, `--max-items`, and `--page-delay` controls. + Default single-page behavior is unchanged when `--page-all` is omitted. + ## [1.0.58-beta.1] - 2026-08-07 ### Added diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 0003918e..07cb13fe 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -119,6 +119,259 @@ 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 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 { @@ -2724,18 +2977,8 @@ func newChatCommand() *cobra.Command { 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"`, 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, pagedChatMessagesConfig( + "search_messages_by_time_range", chatMessageListAllArgs)) }, } DeclareLeafMetadata(chatMessageListAllCmd, LeafSpec{ @@ -2763,10 +3006,10 @@ func newChatCommand() *cobra.Command { 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"}, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) @@ -2781,46 +3024,8 @@ func newChatCommand() *cobra.Command { # 查询 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, pagedChatMessagesConfig( + "search_messages_by_sender", chatMessageListBySenderArgs)) }, } DeclareLeafMetadata(chatMessageListBySenderCmd, LeafSpec{ @@ -2848,11 +3053,11 @@ func newChatCommand() *cobra.Command { AvoidWhen: []string{"明确查询与某人的单聊记录时使用 chat message list-direct"}, Examples: []string{"dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50"}, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "sender-open-dingtalk-id", Property: "senderOpenDingTalkId"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) @@ -2866,33 +3071,8 @@ func newChatCommand() *cobra.Command { dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor # 查询群 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, pagedChatMessagesConfig( + "search_at_me_message", chatMessageListMentionsArgs)) }, } DeclareLeafMetadata(chatMessageListMentionsCmd, LeafSpec{ @@ -2920,11 +3100,11 @@ func newChatCommand() *cobra.Command { 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"}, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "group", Property: "openConversationId"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) @@ -2935,14 +3115,8 @@ func newChatCommand() *cobra.Command { Example: ` dws chat message list-focused --limit 50 dws chat message list-focused --limit 20 --cursor `, 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{ @@ -2970,6 +3144,7 @@ func newChatCommand() *cobra.Command { AvoidWhen: []string{"普通未读消息或提及消息使用对应专用命令"}, Examples: []string{"dws chat message list-focused --limit 50"}, }, + Parameters: pagedMCPParamDecls(), }, }) @@ -3075,37 +3250,8 @@ func newChatCommand() *cobra.Command { dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor # 查询群 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, pagedChatMessagesConfig( + "search_messages_by_keyword", chatMessageSearchArgs)) }, } DeclareLeafMetadata(chatMessageSearchCmd, LeafSpec{ @@ -3133,12 +3279,12 @@ func newChatCommand() *cobra.Command { 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\""}, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "group", Property: "openConversationId"}, {Name: "query", Property: "keyword"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) @@ -3159,101 +3305,8 @@ func newChatCommand() *cobra.Command { # 查询单聊会话 ID: dws chat conversation-info --user # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - toolArgs := map[string]any{} - - // The CLI primary is --query; the IM MCP field is still named "keyword". - if v := flagOrFallback(cmd, "query", "keyword"); v != "" { - toolArgs["keyword"] = v - } - - // --user/--userId are the preferred userId inputs for sender filtering. - if v := flagOrFallback(cmd, "users", "user", "userId"); v != "" { - appendChatIDArgs(toolArgs, parseCSVValues(v), "senderUserIds", "senderOpenDingTakIds") - } - - // sender-ids -> senderOpenDingTakIds / senderUserIds(注意:MCP 入参字段名缺少字母 l) - if v := flagOrFallback(cmd, "sender-ids", "senders", "sender"); v != "" { - ids := parseCSVValues(v) - if len(ids) > 0 { - appendChatIDArgs(toolArgs, ids, "senderUserIds", "senderOpenDingTakIds") - } - } - - // at-me - if v, _ := cmd.Flags().GetBool("at-me"); v { - toolArgs["atMe"] = true - } - - // at-ids -> atOpenDingTakIds / atUserIds(注意:MCP 入参字段名缺少字母 l) - if v, _ := cmd.Flags().GetString("at-ids"); v != "" { - ids := parseCSVValues(v) - if len(ids) > 0 { - appendChatIDArgs(toolArgs, ids, "atUserIds", "atOpenDingTakIds") - } - } - - // conversation-ids / groups / group -> openConversationIds - convIds := "" - if v, _ := cmd.Flags().GetString("conversation-ids"); v != "" { - convIds = v - } else if v, _ := cmd.Flags().GetString("groups"); v != "" { - convIds = v - } else if v, _ := cmd.Flags().GetString("group"); v != "" { - convIds = v - } - if convIds != "" { - var ids []string - for _, s := range strings.Split(convIds, ",") { - if t := strings.TrimSpace(s); t != "" { - ids = append(ids, t) - } - } - if len(ids) > 0 { - toolArgs["openConversationIds"] = ids - } - } - - if v, _ := cmd.Flags().GetString("message-type"); v != "" { - toolArgs["messageType"] = v - } - if cmd.Flags().Changed("only-robot") { - toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot") - } else if cmd.Flags().Changed("only-robot-messages") { - toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot-messages") - } - if v := flagOrFallback(cmd, "conversation-type", "search-conv-type"); v != "" { - toolArgs["searchConvType"] = v - } - - // start -> startTime (ISO-8601 to milliseconds) - if v, _ := cmd.Flags().GetString("start"); v != "" { - ms, err := parseISOTimeToMillis("start", v) - if err != nil { - return err - } - toolArgs["startTime"] = ms - } - - // end -> endTime (ISO-8601 to milliseconds) - if v, _ := cmd.Flags().GetString("end"); v != "" { - ms, err := parseISOTimeToMillis("end", v) - if err != nil { - return err - } - toolArgs["endTime"] = ms - } - - // cursor - if v, _ := cmd.Flags().GetString("cursor"); v != "" { - toolArgs["cursor"] = v - } - - // limit - if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { - toolArgs["limit"] = v - } - - return callMCPToolOnServer("im", "search_messages", toolArgs) + return RunPagedMCPCommand(cmd, pagedChatMessagesOnServerConfig( + "im", "search_messages", chatMessageSearchAdvancedArgs)) }, } DeclareLeafMetadata(chatMessageSearchAdvancedCmd, LeafSpec{ @@ -3285,7 +3338,7 @@ func newChatCommand() *cobra.Command { }, Examples: []string{"dws chat message search-advanced --query \"周报\" --start \"2026-04-01T00:00:00+08:00\" --end \"2026-04-15T00:00:00+08:00\""}, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "at-ids", Property: "atOpenDingTakIds"}, {Name: "conversation-ids", Property: "openConversationIds"}, {Name: "conversation-type", Property: "searchConvType"}, @@ -3295,7 +3348,7 @@ func newChatCommand() *cobra.Command { {Name: "query", Property: "keyword"}, {Name: "sender-ids", Property: "senderOpenDingTakIds"}, {Name: "start", Property: "startTime"}, - }, + }, pagedMCPParamDecls()...), }, }) @@ -3841,6 +3894,7 @@ func newChatCommand() *cobra.Command { 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 二选一)") @@ -3856,6 +3910,7 @@ func newChatCommand() *cobra.Command { 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(可选,不传则查全部)") @@ -3867,10 +3922,12 @@ func newChatCommand() *cobra.Command { 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)") @@ -3893,6 +3950,7 @@ func newChatCommand() *cobra.Command { 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 (必填,群聊或单聊均可)") @@ -3964,6 +4022,7 @@ func newChatCommand() *cobra.Command { 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 (必填)") @@ -7473,18 +7532,10 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 dws chat message list-favorites --size 30 dws chat message list-favorites --cursor 20 --size 20`, 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{ @@ -7512,13 +7563,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AvoidWhen: []string{"需要搜索普通聊天记录、置顶消息或修改收藏状态时不要使用。"}, Examples: []string{"dws chat message list-favorites --cursor 0 --size 20"}, }, - Parameters: []contract.ParamDecl{ + Parameters: append([]contract.ParamDecl{ {Name: "size", InterfaceType: "string"}, - }, + }, pagedMCPParamDecls()...), }, }) chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)") chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-30)") + AddPagedMCPFlags(chatMessageListFavoritesCmd) // ── group list-my-groups: 拉取我创建/管理的群 ────────────── diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go new file mode 100644 index 00000000..d47ed585 --- /dev/null +++ b/internal/helpers/chat_message_pagination_test.go @@ -0,0 +1,262 @@ +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: "messages", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"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: "messages", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"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: "messages", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"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: "messages", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`, + }, + { + name: "search-advanced", args: []string{"message", "search-advanced", "--query", "周报"}, + server: "im", tool: "search_messages", itemPath: "messages", cursorOne: "0", cursorTwo: "c2", + pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, + pageTwo: `{"result":{"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 len(items) != 2 { + t.Fatalf("items = %#v", items) + } + if len(caller.calls) != 2 { + t.Fatalf("calls = %#v, want two pages", caller.calls) + } + if caller.calls[0].server != tt.server || caller.calls[0].tool != tt.tool { + t.Fatalf("first call = %#v", caller.calls[0]) + } + if !reflect.DeepEqual(caller.calls[0].args["cursor"], tt.cursorOne) { + t.Fatalf("first cursor = %#v, want %#v", caller.calls[0].args["cursor"], tt.cursorOne) + } + if !reflect.DeepEqual(caller.calls[1].args["cursor"], tt.cursorTwo) { + t.Fatalf("second cursor = %#v, want %#v", caller.calls[1].args["cursor"], tt.cursorTwo) + } + paging := got["paging"].(map[string]any) + if paging["pages"].(float64) != 2 || paging["total"].(float64) != 2 || paging["truncated"] != false { + t.Fatalf("paging = %#v", paging) + } + }) + } +} + +func argsEqual(got, want map[string]any) bool { + if len(got) != len(want) { + return false + } + for key, wantValue := range want { + gotValue, ok := got[key] + if !ok { + return false + } + switch w := wantValue.(type) { + case float64: + g, ok := gotValue.(int64) + if !ok || float64(g) != w { + return false + } + default: + if !reflect.DeepEqual(gotValue, wantValue) { + return false + } + } + } + return true +} diff --git a/internal/helpers/paged_command.go b/internal/helpers/paged_command.go new file mode 100644 index 00000000..8f5e7a94 --- /dev/null +++ b/internal/helpers/paged_command.go @@ -0,0 +1,362 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const ( + defaultPagedCommandPageLimit = 50 + maxPagedCommandPageLimit = 500 + defaultPagedCommandDelayMS = 200 +) + +type PagedCursorKind int + +const ( + PagedCursorString PagedCursorKind = iota + PagedCursorInt64 +) + +type PagedMCPCommandConfig struct { + ServerID string + ToolName string + ItemPath string + CursorPath string + HasMorePath string + CursorArg string + CursorKind PagedCursorKind + 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 + var allItems []any + 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++ { + seenCursors[currentCursor] = true + text, err := callMCPToolReturnTextOnServer(context.Background(), cfg.ServerID, cfg.ToolName, args) + if err != nil { + return handlePagedCommandError(cmd, envelope, cfg, allItems, page, currentCursor, err) + } + parsed, items, nextCursor, more, err := parsePagedCommandPage(text, cfg) + if err != nil { + return handlePagedCommandError(cmd, envelope, cfg, allItems, page, currentCursor, err) + } + if envelope == nil { + envelope = parsed + } + allItems = append(allItems, items...) + lastCursor = nextCursor + hasMore = more + + truncatedByItems := truncatePagedItems(&allItems, opts.maxItems) + if truncatedByItems { + writePagedCommandResult(envelope, cfg, allItems, pagingMetadata{ + Truncated: true, + HasMore: true, + LastCursor: lastCursor, + Pages: page, + Total: len(allItems), + }) + return nil + } + if !hasMore { + writePagedCommandResult(envelope, cfg, allItems, pagingMetadata{ + Truncated: false, + HasMore: false, + LastCursor: lastCursor, + Pages: page, + Total: len(allItems), + }) + return nil + } + 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, allItems, page+1, nextKey, err) + } + currentCursor = nextKey + args[cfg.CursorArg] = normalizeCursorArg(nextCursor, cfg.CursorKind) + if opts.delayMS > 0 { + helperSleep(time.Duration(opts.delayMS) * time.Millisecond) + } + } + + writePagedCommandResult(envelope, cfg, allItems, pagingMetadata{ + Truncated: hasMore, + HasMore: hasMore, + LastCursor: lastCursor, + Pages: opts.pageLimit, + Total: len(allItems), + }) + return nil +} + +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) + } + rawItems, ok := getJSONPath(parsed, cfg.ItemPath) + 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) + } + 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) + } + 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 + Partial bool + FailedPage int + FailedCursor string + PagesFetched int + ItemsFetched int + Error string +} + +func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg PagedMCPCommandConfig, items []any, 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) + writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + Truncated: true, + HasMore: true, + LastCursor: failedCursor, + Pages: failedPage - 1, + Total: len(items), + Partial: true, + FailedPage: failedPage, + FailedCursor: failedCursor, + PagesFetched: failedPage - 1, + ItemsFetched: len(items), + Error: err.Error(), + }) + return err +} + +func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items []any, meta pagingMetadata) { + _ = setJSONPath(envelope, cfg.ItemPath, items) + 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 + } + envelope["paging"] = paging + _ = deps.Out.PrintJSON(envelope) +} + +func truncatePagedItems(items *[]any, maxItems int) bool { + if maxItems <= 0 || len(*items) <= maxItems { + return false + } + *items = (*items)[:maxItems] + return true +} + +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 { + if kind != PagedCursorInt64 { + if value == nil { + return "" + } + return fmt.Sprint(value) + } + switch v := value.(type) { + case int64: + return v + case int: + return int64(v) + case float64: + return int64(v) + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err == nil { + return parsed + } + } + return int64(0) +} diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go new file mode 100644 index 00000000..d717fe29 --- /dev/null +++ b/internal/helpers/paged_command_test.go @@ -0,0 +1,223 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "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 +} + +type pagedCommandCaller struct { + steps []scriptedToolStep + calls []pagedCommandCall + format string + dry bool +} + +func (c *pagedCommandCaller) 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":[],"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() + oldDeps := deps + oldSleep := helperSleep + t.Cleanup(func() { + deps = oldDeps + helperSleep = oldSleep + }) + InitDeps(caller) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + deps.Out.w = out + deps.Out.errW = errOut + helperSleep = func(d time.Duration) {} + + cmd := &cobra.Command{ + Use: "paged", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return RunPagedMCPCommand(cmd, cfg) + }, + } + cmd.Flags().String("cursor", "0", "") + AddPagedMCPFlags(cmd) + cmd.SetErr(errOut) + cmd.SetArgs(args) + err := cmd.Execute() + if strings.TrimSpace(out.String()) == "" { + return nil, errOut.String(), 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, 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 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 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) + paging := got["paging"].(map[string]any) + if len(items) != 2 || paging["truncated"] != true || paging["pages"].(float64) != 2 { + t.Fatalf("result = %#v", got) + } + if caller.calls[0].args["cursor"] != "0" || caller.calls[1].args["cursor"] != "c2" { + t.Fatalf("call args = %#v", caller.calls) + } +} + +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 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 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) + } +} diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index f3421650..e48a6c98 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -819,6 +819,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] @@ -830,11 +832,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.messages`,并在顶层输出 `paging` 元数据;shortcut 命令不属于本 typed fallback 小节 - 时间格式统一为 yyyy-MM-dd HH:mm:ss - 权限/权益错误不是空结果;应把返回的 friendly_hint 与 action_url 展示给用户,不要继续盲目翻页 ``` @@ -844,6 +851,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`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message list-by-sender [flags] @@ -859,6 +868,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 二者互斥,必须且只能指定其一: @@ -875,6 +888,8 @@ Flags: > 推荐使用 `chat message search-advanced --at-me`,它还能叠加关键词/群/发送者等过滤条件。本命令适用于仅需拉取 @我 消息的简单场景。 搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 + +自动翻页同样使用 `--page-all` 触发,复用时间范围和 group 过滤条件,只替换每页 `cursor`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message list-mentions [flags] @@ -889,6 +904,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 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊 @@ -900,6 +919,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] @@ -909,6 +930,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 @@ -960,6 +985,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`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message search [flags] @@ -974,6 +1001,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 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话 @@ -987,6 +1018,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.messages`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message search-advanced [flags] @@ -1013,6 +1046,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 注意: @@ -1406,6 +1443,8 @@ Flags: ``` #### 查询收藏消息 — 分页查询当前用户收藏的消息 + +自动翻页使用 `--page-all` 触发,按 int64 cursor 注入下一页游标并聚合 `result.items`(不是 `result.messages`)。`--size` 仍保持 1-30 的 Open 服务范围;`--page-limit` 只控制最多请求页数。 ``` Usage: dws chat message list-favorites [flags] @@ -1416,10 +1455,15 @@ Example: Flags: --cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor --size int 一次拉取的收藏数量,默认 20,范围 1-30 + --page-all 自动按 nextCursor 拉取所有分页 + --page-limit int 自动翻页最多请求页数(默认 50,范围 1-500) + --max-items int 自动翻页最多返回条数(默认 0 表示不限制) + --page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待) 注意: - 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20" - hasMore=true 时,将 nextCursor 作为下一次的 --cursor + - 自动翻页输出顶层 `paging` 元数据,达到 `--page-limit` 或 `--max-items` 时 `truncated=true` ``` ### bot (机器人管理) diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index f04f923d..6a0d007d 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -148,14 +148,16 @@ dws chat +chat-messages --group "项目群" --page-all --format json \ | 命令 | 用途 | 示例与要点 | |------|------|------------| | `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` | -| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start --end --limit 100 --cursor 0`;四个参数每次请求都传,翻页用 `nextCursor` | -| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊 | -| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部 | -| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor` | +| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start --end --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,聚合 `result.messages` | +| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all` | +| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all` | +| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor`;完整遍历加 `--page-all`,cursor 为 int64 | | `message list-unread-conversations` | 未读会话列表 | 可选 `--count` | | `message list-topic-replies` | 拉取话题回复 | `--group --topic-id ` | | `message list-by-ids` | 按消息 ID 批量查询 | `--msg-ids msgId1,msgId2`,最多 50 条 | +Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` 或 `--page-delay` 仍保持默认单页 fallback。分页元数据输出到顶层 `paging`,包含 `truncated`、`hasMore`、`lastCursor`、`pages`、`total`;非第一页失败时会输出 partial 结果和 `failedPage` / `failedCursor` / `pagesFetched` / `itemsFetched`。 + `message list` 注意事项: - `--group`、`--user`、`--open-dingtalk-id` 互斥且必须指定一个。 @@ -194,6 +196,7 @@ dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+0 dws chat message search-advanced --user --start --end dws chat message search-advanced --at-me --start --end dws chat message search-advanced --conversation-ids , --query "合同" --limit 50 --cursor 0 +dws chat message search-advanced --query "周报" --start --end --page-all --max-items 200 dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件" dws chat message search-advanced --only-robot-messages --query "通知" ``` @@ -210,12 +213,14 @@ dws chat message search-advanced --only-robot-messages --query "通知" | `--only-robot-messages` | 只搜索机器人消息 | | `--start` / `--end` | ISO-8601 时间范围 | | `--cursor` / `--limit` | 分页,翻页用 `nextCursor` | +| `--page-all` / `--page-limit` / `--max-items` / `--page-delay` | 自动翻页;`--page-all` 是唯一触发开关 | 仅简单关键词搜索时可用: ```bash dws chat message search --query "changefree" --start --end --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start --end +dws chat message search --query "发布计划" --start --end --page-all --page-delay 0 ``` ### 消息状态与撤回 @@ -289,9 +294,10 @@ dws chat +messages-update-card --biz-id --content "最终内容" --flow- | `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` 不同:前者置顶会话内消息,后者置顶整个会话。 ### 表情回应 From 8156528c05980d5a038467b872dc46f523c489bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Sun, 9 Aug 2026 20:20:20 +0800 Subject: [PATCH 04/21] fix(chat): harden IM pagination cursor mapping --- .../cli/schema_parameter_mapping_ledger.go | 4 +++ internal/helpers/paged_command.go | 27 ++++++++++----- internal/helpers/paged_command_test.go | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index 9e4380b7..37471cc8 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -256,6 +256,10 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch", "chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch", "chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.", + "chat.list_message_favorites --max-items": "Reviewed helper-only pagination control: --max-items caps aggregated CLI output for chat.list_message_favorites and is not sent to the remote adapter.", + "chat.list_message_favorites --page-all": "Reviewed helper-only pagination control: --page-all enables CLI-side looping for chat.list_message_favorites and is not sent to the remote adapter.", + "chat.list_message_favorites --page-delay": "Reviewed helper-only pagination control: --page-delay controls local delay between chat.list_message_favorites pages and is not sent to the remote adapter.", + "chat.list_message_favorites --page-limit": "Reviewed helper-only pagination control: --page-limit caps CLI-side page fetches for chat.list_message_favorites and is not sent to the remote adapter.", "chat.list_message_favorites --size": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --size is a CLI wrapper input and does not publish a direct interface property.", "chat.query_msg_read_status --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag", "chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag", diff --git a/internal/helpers/paged_command.go b/internal/helpers/paged_command.go index 8f5e7a94..8591bb0b 100644 --- a/internal/helpers/paged_command.go +++ b/internal/helpers/paged_command.go @@ -177,8 +177,12 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page err := fmt.Errorf("pagination cursor did not advance: %s", nextKey) return handlePagedCommandError(cmd, envelope, cfg, allItems, page+1, nextKey, err) } + normalizedCursor, err := normalizeCursorArg(nextCursor, cfg.CursorKind) + if err != nil { + return handlePagedCommandError(cmd, envelope, cfg, allItems, page+1, nextKey, err) + } currentCursor = nextKey - args[cfg.CursorArg] = normalizeCursorArg(nextCursor, cfg.CursorKind) + args[cfg.CursorArg] = normalizedCursor if opts.delayMS > 0 { helperSleep(time.Duration(opts.delayMS) * time.Millisecond) } @@ -338,25 +342,30 @@ func cursorValueKey(value any, kind PagedCursorKind) string { } } -func normalizeCursorArg(value any, kind PagedCursorKind) any { +func normalizeCursorArg(value any, kind PagedCursorKind) (any, error) { if kind != PagedCursorInt64 { if value == nil { - return "" + return "", nil } - return fmt.Sprint(value) + return fmt.Sprint(value), nil } switch v := value.(type) { case int64: - return v + return v, nil case int: - return int64(v) + return int64(v), nil case float64: - return int64(v) + 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 + return parsed, nil } + return nil, fmt.Errorf("paged response cursor must be a base-10 int64 string, got %q", v) } - return int64(0) + return nil, fmt.Errorf("paged response cursor must be int64-compatible, got %T", value) } diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index d717fe29..a179e437 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -185,6 +185,39 @@ func TestPagedMCPCommandInt64CursorAndItemsPath(t *testing.T) { } } +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") From 6774d423b74b0822b3d6f2f19e9f6321450d1982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=AE=E7=9F=A5=E5=A4=8F?= Date: Sun, 9 Aug 2026 23:43:51 +0800 Subject: [PATCH 05/21] docs(minutes): drop hot-word delete references from skill docs --- CHANGELOG.md | 8 +++----- skills/mono/references/products/minutes.md | 15 --------------- .../multi/dingtalk-minutes/references/minutes.md | 15 --------------- 3 files changed, 3 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3f9e5d..bfe3217c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,9 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th 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 `hot-word delete` and - `permission apply`, list both in the command trees, add intent routing for - `permission apply`, and use a neutral hot-word example in the delete samples. - `hot-word delete` intentionally gets no intent routing while its leaf still - declares `confirmation: not_required`. +- **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. ### Fixed diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index 36667c70..f6bfac66 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -51,7 +51,6 @@ dws minutes │ └── status --id # 查询脑图生成状态 ├── hot-word # 个人热词管理 │ ├── add --words "..." # 添加热词(提升语音识别准确率) -│ ├── delete --words "..." # 批量删除个人热词 │ └── list # 查询我的热词列表 ├── replace-text --id --search "..." --replace "..." # 替换转写文本中的错误文字 ├── upload # 音频上传(上传本地音频文件生成听记) @@ -302,7 +301,6 @@ dws minutes │ └── status --id ├── hot-word │ ├── add --words "..." -│ ├── delete --words "..." │ └── list ├── replace-text --id --search "..." --replace "..." ├── upload @@ -1051,19 +1049,6 @@ Flags: 添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。支持一次添加多个热词(逗号分隔),每个热词长度不超过 10 个汉字或 5 个英文单词。 -### 批量删除个人热词 -``` -Usage: - dws minutes hot-word delete [flags] -Example: - dws minutes hot-word delete --words "天气" - dws minutes hot-word delete --words "OKR,钉钉,Copilot" -Flags: - --words string 要删除的热词,多个用逗号分隔 (必填) -``` - -批量删除听记个人热词。支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化,适用于清理误加、过时或不再需要的热词。 - ### 查询我的热词列表 ``` Usage: diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index 2a8e6db6..df980463 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -60,7 +60,6 @@ dws minutes │ └── status --id # 查询脑图生成状态 ├── hot-word # 个人热词管理 │ ├── add --words "..." # 添加热词(提升语音识别准确率) -│ ├── delete --words "..." # 批量删除个人热词 │ └── list # 查询我的热词列表 ├── replace-text --id --search "..." --replace "..." # 替换转写文本中的错误文字 ├── upload # 音频上传(上传本地音频文件生成听记) @@ -411,7 +410,6 @@ dws minutes │ └── status --id ├── hot-word │ ├── add --words "..." -│ ├── delete --words "..." │ └── list ├── replace-text --id --search "..." --replace "..." ├── upload @@ -1164,19 +1162,6 @@ Flags: 添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。支持一次添加多个热词(逗号分隔),每个热词长度不超过 10 个汉字或 5 个英文单词。 -### 批量删除个人热词 -``` -Usage: - dws minutes hot-word delete [flags] -Example: - dws minutes hot-word delete --words "天气" - dws minutes hot-word delete --words "OKR,钉钉,Copilot" -Flags: - --words string 要删除的热词,多个用逗号分隔 (必填) -``` - -批量删除听记个人热词。支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化,适用于清理误加、过时或不再需要的热词。 - ### 查询我的热词列表 ``` Usage: From cde050f14618c1aeb338a0c8f20c048dd0607631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Mon, 10 Aug 2026 10:10:16 +0800 Subject: [PATCH 06/21] test(chat): cover paged command delay sleep --- internal/helpers/paged_command_test.go | 54 +++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index a179e437..e7459df3 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -48,6 +48,11 @@ 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() oldDeps := deps oldSleep := helperSleep @@ -60,7 +65,7 @@ func runPagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPC errOut := &bytes.Buffer{} deps.Out.w = out deps.Out.errW = errOut - helperSleep = func(d time.Duration) {} + helperSleep = sleep cmd := &cobra.Command{ Use: "paged", @@ -142,6 +147,53 @@ func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) { } } +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"}}`}, From b150911da934789b84350398c7415e57d75554ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=AE=E7=9F=A5=E5=A4=8F?= Date: Mon, 10 Aug 2026 11:09:08 +0800 Subject: [PATCH 07/21] docs(minutes): scope permission member-uids rule and add apply routing --- skills/mono/references/products/minutes.md | 5 +++-- skills/multi/dingtalk-minutes/references/minutes.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index f6bfac66..3aac478e 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -1656,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 4`(可查看/下载/编辑) +- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws contact user search --query "姓名"` 查询);`permission apply` 不适用这一条 **典型执行链路:** 1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index df980463..a3c0bbe5 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -1769,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 4`(可查看/下载/编辑) +- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws aisearch person --keyword "姓名" --dimension name` 查询);`permission apply` 不适用这一条 **典型执行链路:** 1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID From 08ee5dc5731b2640aab2d826fa6d339b0ff4649d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A6=82=E6=A4=BD?= Date: Mon, 10 Aug 2026 17:21:54 +0800 Subject: [PATCH 08/21] fix: emit JSON receipts for exports and downloads --- CHANGELOG.md | 9 ++ internal/helpers/doc.go | 38 ++++- internal/helpers/drive.go | 59 +++++++- internal/helpers/json_output_contract_test.go | 137 ++++++++++++++++++ internal/helpers/output.go | 11 ++ 5 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 internal/helpers/json_output_contract_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0525e085..aa19d4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### 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. + ## [1.0.58-beta.2] - 2026-08-10 ### Added diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 01dac46c..15374281 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -3620,6 +3620,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) @@ -3630,7 +3641,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) @@ -3645,10 +3656,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 @@ -3667,11 +3678,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 }, @@ -4609,7 +4637,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er for attempt := 1; attempt <= maxPolls; attempt++ { interval := pollInterval(attempt) - deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval)) + printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval)) select { case <-ctx.Done(): diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 83b7ba6d..752ab25d 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -619,10 +619,20 @@ func newDriveCommand() *cobra.Command { return err } dlOpts.logf = func(format string, a ...any) { - deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + printJSONSafeInfo(fmt.Sprintf(format, a...)) } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "drive_download", + "nodeId": fileID, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "下载钉盘文件") deps.Out.PrintKeyValue("文件ID", fileID) deps.Out.PrintKeyValue("输出", outputPath) @@ -632,7 +642,7 @@ func newDriveCommand() *cobra.Command { ctx := cmd.Context() // Step 1: 获取下载 URL 和签名请求头 - deps.Out.PrintInfo("[1/2] 获取下载链接...") + printJSONSafeInfo("[1/2] 获取下载链接...") text, err := callMCPToolReturnText(ctx, "download_file", argsMap) if err != nil { return err @@ -654,7 +664,7 @@ func newDriveCommand() *cobra.Command { } // Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试) - deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID dlOpts.version = parseDownloadFileVersion(text) @@ -684,6 +694,19 @@ func newDriveCommand() *cobra.Command { return err } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取下载产物信息失败: %w", err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": fileID, + "version": dlOpts.version, + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, @@ -772,10 +795,21 @@ func newDriveCommand() *cobra.Command { return err } dlOpts.logf = func(format string, a ...any) { - deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + printJSONSafeInfo(fmt.Sprintf(format, a...)) } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "drive_download_version", + "nodeId": fileID, + "version": versionNum, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "下载文件历史版本") deps.Out.PrintKeyValue("节点ID", fileID) deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum)) @@ -784,7 +818,7 @@ func newDriveCommand() *cobra.Command { } ctx := cmd.Context() - deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...") + printJSONSafeInfo("[1/2] 获取历史版本下载链接...") dlArgsMap := map[string]any{ "nodeId": fileID, "version": versionNum, @@ -804,7 +838,7 @@ func newDriveCommand() *cobra.Command { } outputPath = filepath.Join(outputPath, filename) } - deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID dlOpts.version = versionNum @@ -833,6 +867,19 @@ func newDriveCommand() *cobra.Command { } return err } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取下载产物信息失败: %w", err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": fileID, + "version": versionNum, + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, diff --git a/internal/helpers/json_output_contract_test.go b/internal/helpers/json_output_contract_test.go new file mode 100644 index 00000000..80fbad68 --- /dev/null +++ b/internal/helpers/json_output_contract_test.go @@ -0,0 +1,137 @@ +// 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 TestJSONOutputContractForCompletedFileTransfers(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 TestJSONOutputContractDryRunIsMachineReadable(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) + } +} diff --git a/internal/helpers/output.go b/internal/helpers/output.go index 43e8f43e..7a714d82 100644 --- a/internal/helpers/output.go +++ b/internal/helpers/output.go @@ -151,6 +151,17 @@ func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n", func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) } func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) } +// printJSONSafeInfo keeps command progress out of stdout when the caller +// requested JSON. A successful JSON command must leave stdout parseable; its +// human-readable progress belongs on stderr instead. +func printJSONSafeInfo(msg string) { + if deps != nil && deps.Caller != nil && strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + deps.Out.PrintProgress("[INFO] " + msg) + return + } + deps.Out.PrintInfo(msg) +} + func (f *Formatter) PrintKeyValue(key, value string) { fmt.Fprintf(f.w, "%-16s%s\n", key+":", value) } From 5a160cefd84330f87da7ded0f29d771c11b9274a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Mon, 10 Aug 2026 17:59:38 +0800 Subject: [PATCH 09/21] docs(chat): expose typed message pagination help --- internal/helpers/chat.go | 62 +++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 07cb13fe..ab82dd7e 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2973,9 +2973,10 @@ 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.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 { return RunPagedMCPCommand(cmd, pagedChatMessagesConfig( "search_messages_by_time_range", chatMessageListAllArgs)) @@ -3004,7 +3005,10 @@ 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: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, @@ -3016,11 +3020,12 @@ func newChatCommand() *cobra.Command { chatMessageListBySenderCmd := &cobra.Command{ Use: "list-by-sender", Short: "拉取指定发送者的消息(包含单聊和群聊)", - Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, + Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --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-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0 dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor + dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --page-limit 10 --page-delay 0 # 查询 userId: dws contact user search --query "姓名" # 查询 openDingTalkId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { @@ -3051,7 +3056,10 @@ func newChatCommand() *cobra.Command { AgentSummary: "按发送者和时间范围查询消息", UseWhen: []string{"需要查某人发送过的消息且不限定单聊时"}, AvoidWhen: []string{"明确查询与某人的单聊记录时使用 chat message list-direct"}, - Examples: []string{"dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50"}, + Examples: []string{ + "dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50", + "dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50 --page-all --page-limit 10", + }, }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, @@ -3064,11 +3072,12 @@ func newChatCommand() *cobra.Command { 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.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0 dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor + dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --max-items 200 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatMessagesConfig( @@ -3098,7 +3107,10 @@ 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: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, @@ -3111,9 +3123,10 @@ func newChatCommand() *cobra.Command { chatMessageListFocusedCmd := &cobra.Command{ Use: "list-focused", Short: "拉取特别关注人的消息", - Long: `拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。`, + Long: `拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传数字分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用数字 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-focused --limit 50 - dws chat message list-focused --limit 20 --cursor `, + dws chat message list-focused --limit 20 --cursor + dws chat message list-focused --limit 50 --page-all --page-limit 10 --page-delay 0`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatMessagesInt64Config( "list_special_focus_messages", chatMessageListFocusedArgs)) @@ -3142,7 +3155,10 @@ 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(), }, @@ -3244,10 +3260,11 @@ func newChatCommand() *cobra.Command { chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", - Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, + Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --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 search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor + dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatMessagesConfig( @@ -3277,7 +3294,10 @@ func newChatCommand() *cobra.Command { AgentSummary: "按关键词和时间范围搜索消息", UseWhen: []string{"需要用关键词查找消息且过滤条件较简单时"}, AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"}, - Examples: []string{"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\""}, + Examples: []string{ + "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"", + "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300", + }, }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, @@ -3293,7 +3313,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.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --user --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --users , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" @@ -3301,6 +3321,7 @@ func newChatCommand() *cobra.Command { dws chat message search-advanced --at-me --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --at-ids , --conversation-ids , --limit 50 --cursor 0 dws chat message search-advanced --conversation-ids <单聊openConversationId> --query "合同" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --page-limit 20 --max-items 500 # 查询群 ID: dws chat search --query "群名" # 查询单聊会话 ID: dws chat conversation-info --user # 查询人员: dws contact user search --keyword "姓名" --format json`, @@ -3336,7 +3357,10 @@ 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: append([]contract.ParamDecl{ {Name: "at-ids", Property: "atOpenDingTakIds"}, @@ -7527,10 +7551,11 @@ 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 { cfg := pagedChatMessagesOnServerConfig("im", "list_message_favorites", chatMessageListFavoritesArgs) cfg.ItemPath = "result.items" @@ -7561,7 +7586,10 @@ 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: append([]contract.ParamDecl{ {Name: "size", InterfaceType: "string"}, From a9751fa74d34eee9b7902ed745c089224cfe889f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Mon, 10 Aug 2026 18:23:41 +0800 Subject: [PATCH 10/21] docs(changelog): drop typed pagination entry from branch --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b52758..cf6df671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] -### Added - -- **Typed IM message pagination** — `dws chat message list-all`, - `list-by-sender`, `list-mentions`, `list-focused`, `list-favorites`, - `search`, and `search-advanced` now support explicit `--page-all` - traversal with `--page-limit`, `--max-items`, and `--page-delay` controls. - Default single-page behavior is unchanged when `--page-all` is omitted. - ## [1.0.58-beta.1] - 2026-08-07 ### Added From b8418b6a5f09a62a7acf17945f9de85e152e042d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Mon, 10 Aug 2026 18:59:24 +0800 Subject: [PATCH 11/21] test(chat): cover paged command edge cases --- internal/helpers/paged_command_test.go | 247 +++++++++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index e7459df3..3cc182d4 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -128,6 +128,126 @@ func TestPagedMCPCommandDefaultUsesFallbackOnly(t *testing.T) { } } +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"}}`}, @@ -147,6 +267,48 @@ func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) { } } +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 @@ -306,3 +468,88 @@ func TestPagedMCPCommandCursorCycleOutputsPartial(t *testing.T) { 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) + } + }) + } +} From 7ffb48c9ae0b9f5cb5dbb3a8d78483053153750b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A6=82=E6=A4=BD?= Date: Mon, 10 Aug 2026 19:57:37 +0800 Subject: [PATCH 12/21] test: cover JSON export and download receipts --- internal/helpers/json_output_contract_test.go | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/internal/helpers/json_output_contract_test.go b/internal/helpers/json_output_contract_test.go index 80fbad68..379befde 100644 --- a/internal/helpers/json_output_contract_test.go +++ b/internal/helpers/json_output_contract_test.go @@ -57,7 +57,7 @@ func assertJSONOutputPayload(t *testing.T, stdout string) map[string]any { return payload } -func TestJSONOutputContractForCompletedFileTransfers(t *testing.T) { +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) }) @@ -122,7 +122,7 @@ func TestJSONOutputContractForCompletedFileTransfers(t *testing.T) { }) } -func TestJSONOutputContractDryRunIsMachineReadable(t *testing.T) { +func TestCrossPlatformCoverageJSONOutputContractDryRunIsMachineReadable(t *testing.T) { stdout, _, err := executeJSONOutputContractCommand(t, &scriptedToolCaller{format: "json", dry: true}, newDriveCommand, @@ -134,4 +134,65 @@ func TestJSONOutputContractDryRunIsMachineReadable(t *testing.T) { 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) + } + }) + } } From 4d274c9da3fec2d8d0e0cf3b22973ea4ec6a06aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Mon, 10 Aug 2026 20:34:43 +0800 Subject: [PATCH 13/21] fix(chat): merge conversation message pagination --- internal/helpers/chat.go | 36 +++- .../helpers/chat_message_pagination_test.go | 37 ++-- internal/helpers/paged_command.go | 198 ++++++++++++++---- internal/helpers/paged_command_test.go | 100 +++++++++ skills/mono/references/products/chat.md | 10 +- .../references/chat/chat-message.md | 8 +- 6 files changed, 315 insertions(+), 74 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 89c66100..b65d34e1 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -348,6 +348,22 @@ func pagedChatMessagesConfig(toolName string, build func(*cobra.Command) (map[st } } +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 @@ -2978,12 +2994,12 @@ func newChatCommand() *cobra.Command { chatMessageListAllCmd := &cobra.Command{ Use: "list-all", Short: "拉取指定时间范围内当前用户的所有会话消息", - Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,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 100 --page-all --page-limit 20 --max-items 500 --page-delay 0`, RunE: func(cmd *cobra.Command, args []string) error { - return RunPagedMCPCommand(cmd, pagedChatMessagesConfig( + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( "search_messages_by_time_range", chatMessageListAllArgs)) }, } @@ -3025,7 +3041,7 @@ func newChatCommand() *cobra.Command { chatMessageListBySenderCmd := &cobra.Command{ Use: "list-by-sender", Short: "拉取指定发送者的消息(包含单聊和群聊)", - Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, + Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0 @@ -3034,7 +3050,7 @@ func newChatCommand() *cobra.Command { # 查询 userId: dws contact user search --query "姓名" # 查询 openDingTalkId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { - return RunPagedMCPCommand(cmd, pagedChatMessagesConfig( + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( "search_messages_by_sender", chatMessageListBySenderArgs)) }, } @@ -3077,7 +3093,7 @@ func newChatCommand() *cobra.Command { chatMessageListMentionsCmd := &cobra.Command{ Use: "list-mentions", Short: "拉取 @我 的消息", - Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, + Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0 dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 @@ -3085,7 +3101,7 @@ func newChatCommand() *cobra.Command { dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --max-items 200 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - return RunPagedMCPCommand(cmd, pagedChatMessagesConfig( + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( "search_at_me_message", chatMessageListMentionsArgs)) }, } @@ -3265,14 +3281,14 @@ 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 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, + Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - return RunPagedMCPCommand(cmd, pagedChatMessagesConfig( + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( "search_messages_by_keyword", chatMessageSearchArgs)) }, } @@ -3318,7 +3334,7 @@ func newChatCommand() *cobra.Command { chatMessageSearchAdvancedCmd := &cobra.Command{ Use: "search-advanced", Short: "多维度搜索消息", - Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, + Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --user --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --users , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" @@ -3331,7 +3347,7 @@ func newChatCommand() *cobra.Command { # 查询单聊会话 ID: dws chat conversation-info --user # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - return RunPagedMCPCommand(cmd, pagedChatMessagesOnServerConfig( + return RunPagedMCPCommand(cmd, pagedChatConversationMessagesOnServerConfig( "im", "search_messages", chatMessageSearchAdvancedArgs)) }, } diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index d47ed585..3c1a426b 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -162,21 +162,21 @@ func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) { }{ { 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: "messages", cursorOne: "0", cursorTwo: "c2", - pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, - pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`, + 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: "messages", cursorOne: "0", cursorTwo: "c2", - pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, - pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`, + 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: "messages", cursorOne: "0", cursorTwo: "c2", - pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, - pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`, + 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"}, @@ -186,15 +186,15 @@ func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) { }, { 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: "messages", cursorOne: "0", cursorTwo: "c2", - pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, - pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`, + 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: "messages", cursorOne: "0", cursorTwo: "c2", - pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`, - pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`, + 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"}, @@ -214,7 +214,12 @@ func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) { t.Fatal(err) } items := got["result"].(map[string]any)[tt.itemPath].([]any) - if len(items) != 2 { + 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 { diff --git a/internal/helpers/paged_command.go b/internal/helpers/paged_command.go index 8591bb0b..3e15abdd 100644 --- a/internal/helpers/paged_command.go +++ b/internal/helpers/paged_command.go @@ -24,16 +24,24 @@ const ( 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 - BuildArgs func(*cobra.Command) (map[string]any, error) - Fallback func(map[string]any) error + 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 { @@ -128,7 +136,7 @@ func validatePagedConfig(cfg PagedMCPCommandConfig) error { func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts pagedCommandOptions, args map[string]any) error { var envelope map[string]any - var allItems []any + items := newPagedCollection(cfg) seenCursors := map[string]bool{} currentCursor := cursorValueKey(args[cfg.CursorArg], cfg.CursorKind) lastCursor := args[cfg.CursorArg] @@ -138,48 +146,50 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page seenCursors[currentCursor] = true text, err := callMCPToolReturnTextOnServer(context.Background(), cfg.ServerID, cfg.ToolName, args) if err != nil { - return handlePagedCommandError(cmd, envelope, cfg, allItems, page, currentCursor, err) + return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) } - parsed, items, nextCursor, more, err := parsePagedCommandPage(text, cfg) + parsed, pageItems, nextCursor, more, err := parsePagedCommandPage(text, cfg) if err != nil { - return handlePagedCommandError(cmd, envelope, cfg, allItems, page, currentCursor, err) + return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) } if envelope == nil { envelope = parsed } - allItems = append(allItems, items...) + if err := items.Add(pageItems); err != nil { + return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) + } lastCursor = nextCursor hasMore = more - truncatedByItems := truncatePagedItems(&allItems, opts.maxItems) + truncatedByItems := items.Truncate(opts.maxItems) if truncatedByItems { - writePagedCommandResult(envelope, cfg, allItems, pagingMetadata{ + writePagedCommandResult(envelope, cfg, items, pagingMetadata{ Truncated: true, HasMore: true, LastCursor: lastCursor, Pages: page, - Total: len(allItems), + Total: items.Total(), }) return nil } if !hasMore { - writePagedCommandResult(envelope, cfg, allItems, pagingMetadata{ + writePagedCommandResult(envelope, cfg, items, pagingMetadata{ Truncated: false, HasMore: false, LastCursor: lastCursor, Pages: page, - Total: len(allItems), + Total: items.Total(), }) return nil } 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, allItems, page+1, nextKey, err) + return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err) } normalizedCursor, err := normalizeCursorArg(nextCursor, cfg.CursorKind) if err != nil { - return handlePagedCommandError(cmd, envelope, cfg, allItems, page+1, nextKey, err) + return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err) } currentCursor = nextKey args[cfg.CursorArg] = normalizedCursor @@ -188,12 +198,12 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page } } - writePagedCommandResult(envelope, cfg, allItems, pagingMetadata{ + writePagedCommandResult(envelope, cfg, items, pagingMetadata{ Truncated: hasMore, HasMore: hasMore, LastCursor: lastCursor, Pages: opts.pageLimit, - Total: len(allItems), + Total: items.Total(), }) return nil } @@ -203,14 +213,6 @@ func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]a if err := json.Unmarshal([]byte(text), &parsed); err != nil { return nil, nil, nil, false, fmt.Errorf("parse paged response JSON: %w", err) } - rawItems, ok := getJSONPath(parsed, cfg.ItemPath) - 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) - } rawHasMore, ok := getJSONPath(parsed, cfg.HasMorePath) if !ok { return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.HasMorePath) @@ -219,6 +221,18 @@ func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]a 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) @@ -240,7 +254,7 @@ type pagingMetadata struct { Error string } -func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg PagedMCPCommandConfig, items []any, failedPage int, failedCursor string, err error) error { +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 } @@ -250,19 +264,19 @@ func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg Pa HasMore: true, LastCursor: failedCursor, Pages: failedPage - 1, - Total: len(items), + Total: items.Total(), Partial: true, FailedPage: failedPage, FailedCursor: failedCursor, PagesFetched: failedPage - 1, - ItemsFetched: len(items), + ItemsFetched: items.Total(), Error: err.Error(), }) return err } -func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items []any, meta pagingMetadata) { - _ = setJSONPath(envelope, cfg.ItemPath, items) +func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, meta pagingMetadata) { + _ = setJSONPath(envelope, cfg.ItemPath, items.Values()) paging := map[string]any{ "truncated": meta.Truncated, "hasMore": meta.HasMore, @@ -282,14 +296,120 @@ func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, _ = deps.Out.PrintJSON(envelope) } -func truncatePagedItems(items *[]any, maxItems int) bool { - if maxItems <= 0 || len(*items) <= maxItems { +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 } - *items = (*items)[:maxItems] + 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, ".") { diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index 3cc182d4..3eb9825b 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -109,6 +109,13 @@ func pagedCommandMessagesConfig(fallback func(map[string]any) error) PagedMCPCom } } +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 @@ -267,6 +274,99 @@ func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) { } } +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 TestPagedMCPCommandResponseShapeErrors(t *testing.T) { tests := []struct { name string diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 9cccac94..aa3874de 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -858,7 +858,7 @@ Flags: - 四个参数每次请求都会传递给服务端,cursor 首页传 "0" - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息 - 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页 - - 自动翻页:`--page-all` 会聚合 `result.messages`,并在顶层输出 `paging` 元数据;shortcut 命令不属于本 typed fallback 小节 + - 自动翻页:`--page-all` 会保留并合并 `result.conversationMessagesList`,同一会话跨页合并 messages,并在顶层输出 `paging` 元数据;shortcut 命令不属于本 typed fallback 小节 - 时间格式统一为 yyyy-MM-dd HH:mm:ss - 权限/权益错误不是空结果;应把返回的 friendly_hint 与 action_url 展示给用户,不要继续盲目翻页 ``` @@ -869,7 +869,7 @@ Flags: 搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 -自动翻页同样使用 `--page-all` 触发,复用发送者、时间范围和 limit 条件,只替换每页 `cursor`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 +自动翻页同样使用 `--page-all` 触发,复用发送者、时间范围和 limit 条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message list-by-sender [flags] @@ -906,7 +906,7 @@ Flags: 搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 -自动翻页同样使用 `--page-all` 触发,复用时间范围和 group 过滤条件,只替换每页 `cursor`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 +自动翻页同样使用 `--page-all` 触发,复用时间范围和 group 过滤条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message list-mentions [flags] @@ -1003,7 +1003,7 @@ Flags: 按关键词搜索消息内容。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 -自动翻页同样使用 `--page-all` 触发,复用 query、时间范围和 group 条件,只替换每页 `cursor`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 +自动翻页同样使用 `--page-all` 触发,复用 query、时间范围和 group 条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message search [flags] @@ -1036,7 +1036,7 @@ Flags: 支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。 -自动翻页使用 `--page-all` 触发,复用所有高级过滤参数,只替换每页 `cursor`,聚合 `result.messages`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 +自动翻页使用 `--page-all` 触发,复用所有高级过滤参数,只替换每页 `cursor`,保留并合并 `result.conversationMessagesList`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。 ``` Usage: dws chat message search-advanced [flags] diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 9ebd0ceb..566f99a5 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -148,15 +148,15 @@ dws chat +chat-messages --group "项目群" --page-all --format json \ | 命令 | 用途 | 示例与要点 | |------|------|------------| | `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` | -| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start --end --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,聚合 `result.messages` | -| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all` | -| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all` | +| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start --end --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,保留并合并 `result.conversationMessagesList` | +| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all`,同一会话跨页合并 messages | +| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all`,同一会话跨页合并 messages | | `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor`;完整遍历加 `--page-all`,cursor 为 int64 | | `message list-unread-conversations` | 未读会话列表 | 可选 `--count` | | `message list-topic-replies` | 拉取话题回复 | `--group --topic-id ` | | `message list-by-ids` | 按消息 ID 批量查询 | `--msg-ids msgId1,msgId2`,最多 50 条 | -Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` 或 `--page-delay` 仍保持默认单页 fallback。分页元数据输出到顶层 `paging`,包含 `truncated`、`hasMore`、`lastCursor`、`pages`、`total`;非第一页失败时会输出 partial 结果和 `failedPage` / `failedCursor` / `pagesFetched` / `itemsFetched`。 +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` 注意事项: From 44c5ef13b41acdcad66aef8ad8b247286b108755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Mon, 10 Aug 2026 22:24:27 +0800 Subject: [PATCH 14/21] test(chat): cover conversation pagination edges --- internal/helpers/paged_command_test.go | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index 3eb9825b..ed336d0b 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -367,6 +367,83 @@ func TestPagedMCPCommandConversationMessagesLaterFailureOutputsPartial(t *testin } } +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 From b55f2437807af943ac2c9207f6c94817259c1298 Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 10 Aug 2026 23:25:52 +0800 Subject: [PATCH 15/21] ci(test): shard helper changes in full suite --- .github/workflows/ci.yml | 1 + test/scripts/changelog_pr_gate_test.go | 31 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97e71d3f..46218c94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,7 @@ jobs: filename === '.github/actionlint.yaml' || filename.startsWith('scripts/') || filename.startsWith('verify/') || + filename.startsWith('internal/helpers/') || filename.startsWith('internal/generator/') || filename.startsWith('internal/cli/schema') || filename.startsWith('internal/interfacesnapshot/') || diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 18d1d8d3..fd2e9377 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -439,6 +439,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 { @@ -505,6 +535,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/')", From 19f9285f8c9be6b6543c0b944cdfa761f94d05a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Mon, 10 Aug 2026 23:50:55 +0800 Subject: [PATCH 16/21] fix(helpers): propagate paged output errors --- internal/helpers/paged_command.go | 20 +++---- internal/helpers/paged_command_test.go | 80 +++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/internal/helpers/paged_command.go b/internal/helpers/paged_command.go index 3e15abdd..c440c88e 100644 --- a/internal/helpers/paged_command.go +++ b/internal/helpers/paged_command.go @@ -3,6 +3,7 @@ package helpers import ( "context" "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -163,24 +164,22 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page truncatedByItems := items.Truncate(opts.maxItems) if truncatedByItems { - writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + return writePagedCommandResult(envelope, cfg, items, pagingMetadata{ Truncated: true, HasMore: true, LastCursor: lastCursor, Pages: page, Total: items.Total(), }) - return nil } if !hasMore { - writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + return writePagedCommandResult(envelope, cfg, items, pagingMetadata{ Truncated: false, HasMore: false, LastCursor: lastCursor, Pages: page, Total: items.Total(), }) - return nil } nextKey := cursorValueKey(nextCursor, cfg.CursorKind) if nextKey == "" || nextKey == currentCursor || seenCursors[nextKey] { @@ -198,14 +197,13 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page } } - writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + return writePagedCommandResult(envelope, cfg, items, pagingMetadata{ Truncated: hasMore, HasMore: hasMore, LastCursor: lastCursor, Pages: opts.pageLimit, Total: items.Total(), }) - return nil } func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]any, []any, any, bool, error) { @@ -259,7 +257,7 @@ func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg Pa return err } fmt.Fprintf(cmd.ErrOrStderr(), "pagination stopped at page %d: %v\n", failedPage, err) - writePagedCommandResult(envelope, cfg, items, pagingMetadata{ + if outputErr := writePagedCommandResult(envelope, cfg, items, pagingMetadata{ Truncated: true, HasMore: true, LastCursor: failedCursor, @@ -271,11 +269,13 @@ func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg Pa 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) { +func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, meta pagingMetadata) error { _ = setJSONPath(envelope, cfg.ItemPath, items.Values()) paging := map[string]any{ "truncated": meta.Truncated, @@ -293,7 +293,7 @@ func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, paging["error"] = meta.Error } envelope["paging"] = paging - _ = deps.Out.PrintJSON(envelope) + return deps.Out.PrintJSON(envelope) } type pagedCollection struct { diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index ed336d0b..e5ee9bf2 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "strings" "testing" "time" @@ -53,6 +54,19 @@ func runPagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPC } 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() oldDeps := deps oldSleep := helperSleep @@ -61,7 +75,7 @@ func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg helperSleep = oldSleep }) InitDeps(caller) - out := &bytes.Buffer{} + out := stdout errOut := &bytes.Buffer{} deps.Out.w = out deps.Out.errW = errOut @@ -79,14 +93,10 @@ func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg cmd.SetErr(errOut) cmd.SetArgs(args) err := cmd.Execute() - if strings.TrimSpace(out.String()) == "" { - return nil, errOut.String(), err + if buf, ok := out.(*bytes.Buffer); ok { + return buf.String(), errOut.String(), 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, errOut.String(), err + return "", errOut.String(), err } func pagedCommandMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig { @@ -548,6 +558,60 @@ func TestPagedMCPCommandMaxItemsTruncatesPrecisely(t *testing.T) { } } +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}}`}, From 783e1eeef9606f9279c77e4ac3d1605736d43fda Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 11 Aug 2026 00:13:48 +0800 Subject: [PATCH 17/21] fix(ci): stabilize minutes coverage contracts --- .../helpers/pr868_surfaces_coverage_test.go | 11 +++++++ internal/helpers/product_examples_test.go | 9 ++++++ skills/mono/references/products/minutes.md | 2 +- .../dingtalk-minutes/references/minutes.md | 2 +- test/unit/skill_docs_policy_test.go | 30 +++++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal/helpers/pr868_surfaces_coverage_test.go b/internal/helpers/pr868_surfaces_coverage_test.go index 829a95c7..1a2f86a5 100644 --- a/internal/helpers/pr868_surfaces_coverage_test.go +++ b/internal/helpers/pr868_surfaces_coverage_test.go @@ -31,6 +31,17 @@ func executePR868Command(t *testing.T, root *cobra.Command, args ...string) erro } func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) { + t.Run("permission add requires explicit policy", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newMinutesCommand(), "permission", "add", "--ids", "task-1", "--member-uids", "user-1") + if err == nil || !strings.Contains(err.Error(), "--policy") { + t.Fatalf("permission add without --policy error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("permission add called MCP %d times before required policy validation", caller.calls) + } + }) t.Run("hot-word delete dry-run", func(t *testing.T) { installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"}) if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil { diff --git a/internal/helpers/product_examples_test.go b/internal/helpers/product_examples_test.go index 0b3daadf..dbfd500d 100644 --- a/internal/helpers/product_examples_test.go +++ b/internal/helpers/product_examples_test.go @@ -11,6 +11,7 @@ import ( "time" "unicode" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -157,7 +158,15 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te deps.Out.errW = io.Discard // Product examples execute real RunE paths; whiteboard insert retries must // not burn the suite timeout on real sleep (race CI uses a 12m package cap). + // Sheet export uses helperAfter for progressive polling, so keep that clock + // deterministic too; context deadlines alone still accumulate heavily under + // Windows coverage instrumentation. whiteboardSleep = func(time.Duration) {} + testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + }) httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error { if destPath == "" { diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index 3aac478e..afeb727a 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -1657,7 +1657,7 @@ tagId 可通过 `dws minutes tag list` 获取。 - 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**) - 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove` - 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看)) -- 如果用户未指定权限类型,`permission add` 默认使用 `--policy 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` 不适用这一条 **典型执行链路:** diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index a3c0bbe5..538e940e 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -1770,7 +1770,7 @@ tagId 可通过 `dws minutes tag list` 获取。 - 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**) - 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove` - 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看)) -- 如果用户未指定权限类型,`permission add` 默认使用 `--policy 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` 不适用这一条 **典型执行链路:** diff --git a/test/unit/skill_docs_policy_test.go b/test/unit/skill_docs_policy_test.go index faefe8ca..ed0429bd 100644 --- a/test/unit/skill_docs_policy_test.go +++ b/test/unit/skill_docs_policy_test.go @@ -394,6 +394,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) { From 6eb3efa0650dee64f26d6e093d54b12d7a236d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Tue, 11 Aug 2026 08:41:18 +0800 Subject: [PATCH 18/21] fix(helpers): stop paged commands at max items --- internal/helpers/paged_command.go | 60 ++++++++--- internal/helpers/paged_command_test.go | 133 ++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 19 deletions(-) diff --git a/internal/helpers/paged_command.go b/internal/helpers/paged_command.go index c440c88e..2a37d860 100644 --- a/internal/helpers/paged_command.go +++ b/internal/helpers/paged_command.go @@ -137,6 +137,7 @@ func validatePagedConfig(cfg PagedMCPCommandConfig) error { 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) @@ -144,8 +145,9 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page hasMore := true for page := 1; page <= opts.pageLimit && hasMore; page++ { + pageCursor := args[cfg.CursorArg] seenCursors[currentCursor] = true - text, err := callMCPToolReturnTextOnServer(context.Background(), cfg.ServerID, cfg.ToolName, args) + text, err := callMCPToolReturnTextOnServer(ctx, cfg.ServerID, cfg.ToolName, args) if err != nil { return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) } @@ -159,11 +161,21 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page if err := items.Add(pageItems); err != nil { return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err) } - lastCursor = nextCursor hasMore = more - truncatedByItems := items.Truncate(opts.maxItems) - if truncatedByItems { + 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, @@ -193,7 +205,9 @@ func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts page currentCursor = nextKey args[cfg.CursorArg] = normalizedCursor if opts.delayMS > 0 { - helperSleep(time.Duration(opts.delayMS) * time.Millisecond) + if err := sleepPagedCommandDelay(ctx, time.Duration(opts.delayMS)*time.Millisecond); err != nil { + return handlePagedCommandError(cmd, envelope, cfg, items, page+1, currentCursor, err) + } } } @@ -239,17 +253,18 @@ func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]a } type pagingMetadata struct { - Truncated bool - HasMore bool - LastCursor any - Pages int - Total int - Partial bool - FailedPage int - FailedCursor string - PagesFetched int - ItemsFetched int - Error string + 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 { @@ -292,10 +307,23 @@ func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, 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 diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index e5ee9bf2..9c3a913a 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -18,6 +18,7 @@ type pagedCommandCall struct { server string tool string args map[string]any + ctx context.Context } type pagedCommandCaller struct { @@ -27,12 +28,12 @@ type pagedCommandCaller struct { dry bool } -func (c *pagedCommandCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { +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}) + 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 } @@ -67,19 +68,34 @@ func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg } 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 - helperSleep = sleep + 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", @@ -88,6 +104,7 @@ func executePagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg Paged return RunPagedMCPCommand(cmd, cfg) }, } + cmd.SetContext(ctx) cmd.Flags().String("cursor", "0", "") AddPagedMCPFlags(cmd) cmd.SetErr(errOut) @@ -558,6 +575,116 @@ func TestPagedMCPCommandMaxItemsTruncatesPrecisely(t *testing.T) { } } +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) + 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 _, 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) + 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 paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false { + t.Fatalf("paging=%#v, want unreliable resume marker", paging) + } +} + +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 From e94c7063ed8b6b7c2c16125c3b029594e3e4e1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Tue, 11 Aug 2026 09:59:10 +0800 Subject: [PATCH 19/21] fix(helpers): sync paged aggregate cursors --- internal/helpers/paged_command.go | 2 ++ internal/helpers/paged_command_test.go | 50 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/internal/helpers/paged_command.go b/internal/helpers/paged_command.go index 2a37d860..72e3d619 100644 --- a/internal/helpers/paged_command.go +++ b/internal/helpers/paged_command.go @@ -292,6 +292,8 @@ func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg Pa 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, diff --git a/internal/helpers/paged_command_test.go b/internal/helpers/paged_command_test.go index 9c3a913a..eea5dbea 100644 --- a/internal/helpers/paged_command_test.go +++ b/internal/helpers/paged_command_test.go @@ -292,15 +292,40 @@ func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) { 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"}}`}, @@ -586,6 +611,7 @@ func TestPagedMCPCommandMaxItemsStopsWhenPageExactlyReachesLimit(t *testing.T) { 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) @@ -593,6 +619,9 @@ func TestPagedMCPCommandMaxItemsStopsWhenPageExactlyReachesLimit(t *testing.T) { 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) } @@ -608,15 +637,36 @@ func TestPagedMCPCommandMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) { 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"}}`}, From d3e444cb563c5bf1ed6703cb267f586a847c3ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=AE=E7=9F=A5=E5=A4=8F?= Date: Tue, 11 Aug 2026 10:43:51 +0800 Subject: [PATCH 20/21] docs(changelog): move the Minutes policy notes into Unreleased The two Minutes notes (permission apply --policy int typing and the skill reference updates) landed in the released 1.0.58-beta.2 section after the branch merged main. That rewrites published release notes and would drop both notes from the next release generated out of Unreleased. Move them verbatim into a Changed subsection under Unreleased; the beta.2 section is byte-identical to main again. --- CHANGELOG.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 848d7eb5..1b6e73cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ 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. +### 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 @@ -40,13 +50,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。 - **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。 - **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`/` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。 -- **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.1] - 2026-08-07 From 10fe258e4b72a88f0fbf7abb169f60da4afa2681 Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 11 Aug 2026 10:54:42 +0800 Subject: [PATCH 21/21] ci: shard helper changes through full suite --- .github/workflows/ci.yml | 1 + test/scripts/changelog_pr_gate_test.go | 30 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61dfcfce..dfafcb02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,7 @@ jobs: filename === '.github/actionlint.yaml' || filename.startsWith('scripts/') || filename.startsWith('verify/') || + filename.startsWith('internal/helpers/') || filename.startsWith('internal/generator/') || filename.startsWith('internal/cli/schema') || filename.startsWith('internal/interfacesnapshot/') || diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index b69e2cd6..0739c46f 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -578,6 +578,36 @@ if (!isInterfaceSensitive("internal/corecmd/corecmd.go")) { } } +func TestHighRiskClassificationShardsHelperChanges(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("ReadFile(ci.yml) error = %v", err) + } + workflow := string(data) + start := strings.Index(workflow, " const isDocsOnly =") + end := strings.Index(workflow, " const classifyFiles =") + if start < 0 || end <= start { + t.Fatal("Code Admission workflow missing high-risk classifier boundaries") + } + classifier := strings.TrimSpace(workflow[start:end]) + probe := classifier + ` +if (!isHighRisk("internal/helpers/minutes.go")) { + throw new Error("helper changes must use the sharded full suite"); +} +if (isHighRisk("internal/helpersx/minutes.go")) { + throw new Error("helper high-risk classification must respect the path boundary"); +} +` + cmd := exec.Command("node", "-e", probe) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("high-risk classifier rejected helper sharding contract: %v\n%s", err, output) + } +} + func TestChangelogPRFastPathWorkflowContract(t *testing.T) { root, err := filepath.Abs(filepath.Join("..", "..")) if err != nil {