diff --git a/CHANGELOG.md b/CHANGELOG.md index ba478a89..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 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..1a2f86a5 100644 --- a/internal/helpers/pr868_surfaces_coverage_test.go +++ b/internal/helpers/pr868_surfaces_coverage_test.go @@ -31,6 +31,17 @@ func executePR868Command(t *testing.T, root *cobra.Command, args ...string) erro } func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) { + t.Run("permission add requires explicit policy", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newMinutesCommand(), "permission", "add", "--ids", "task-1", "--member-uids", "user-1") + if err == nil || !strings.Contains(err.Error(), "--policy") { + t.Fatalf("permission add without --policy error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("permission add called MCP %d times before required policy validation", caller.calls) + } + }) t.Run("hot-word delete dry-run", func(t *testing.T) { installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"}) if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil { @@ -91,6 +102,21 @@ func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) { t.Fatal("expected missing policy") } }) + t.Run("permission apply policy flag is int", func(t *testing.T) { + // 数值参数声明为 int 类型 flag;必填校验走 cmd.Flags().Changed, + // 不能用 validateRequiredFlags(它把 int 零值当成未传)。 + cmd, _, err := newMinutesCommand().Find([]string{"permission", "apply"}) + if err != nil { + t.Fatalf("find permission apply: %v", err) + } + flag := cmd.Flags().Lookup("policy") + if flag == nil { + t.Fatal("flag --policy not found") + } + if flag.Value.Type() != "int" { + t.Fatalf("flag --policy type = %q, want %q", flag.Value.Type(), "int") + } + }) t.Run("audio-memo list default", func(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}} diff --git a/internal/helpers/product_examples_test.go b/internal/helpers/product_examples_test.go index 0b3daadf..dbfd500d 100644 --- a/internal/helpers/product_examples_test.go +++ b/internal/helpers/product_examples_test.go @@ -11,6 +11,7 @@ import ( "time" "unicode" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -157,7 +158,15 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te deps.Out.errW = io.Discard // Product examples execute real RunE paths; whiteboard insert retries must // not burn the suite timeout on real sleep (race CI uses a 12m package cap). + // Sheet export uses helperAfter for progressive polling, so keep that clock + // deterministic too; context deadlines alone still accumulate heavily under + // Windows coverage instrumentation. whiteboardSleep = func(time.Duration) {} + testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + }) httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error { if destPath == "" { diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index dc1ce7f4..afeb727a 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -57,9 +57,10 @@ dws minutes │ ├── create --file-name "..." --file-size # 创建上传会话,获取上传地址 │ ├── complete --session-id # 确认上传完成 │ └── cancel --session-id # 取消上传会话 -└── permission # 权限管理(添加/移除听记成员权限) +└── permission # 权限管理(添加/移除听记成员权限、为自己申请权限) ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限 - └── remove --ids --member-uids # 移除成员权限 + ├── remove --ids --member-uids # 移除成员权限 + └── apply --id --policy <2-4> # 为当前用户申请听记权限 ``` **高频错误命令 vs 正确命令对照(真实 badcase 提炼):** @@ -308,7 +309,8 @@ dws minutes │ └── cancel --session-id ├── permission │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] -│ └── remove --ids --member-uids +│ ├── remove --ids --member-uids +│ └── apply --id --policy <2-4> └── tag # 听记标签/分组管理(标签在听记页面手动创建) ├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称) └── query --tag-id [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表 @@ -1211,6 +1213,38 @@ Flags: - 如果成员是听记的创建者(所有者),无法通过此命令移除其权限 - 建议在移除前先确认成员的当前权限,避免误操作 +### 为当前用户申请听记权限 +``` +Usage: + dws minutes permission apply [flags] +Example: + dws minutes permission apply --id --policy 4 + dws minutes permission apply --id --policy 2 +Flags: + --id string 听记 taskUuid (必填) + --policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填) +``` + +为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。 +适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。 + +**权限类型说明:** + +| --policy 值 | 含义 | 说明 | +|------------|------|------| +| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 | +| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 | +| 4 | 仅查看 | 仅可查看听记内容,不可下载 | + +**与 permission add 的区别:** +- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids` +- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1) + +**典型使用场景:** +- 打开同事分享的听记链接提示无权限,申请查看权限 +- 需要编辑某篇只读听记的纪要,申请可编辑权限 +- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限 + ### 查询我的听记标签/分组列表 ``` Usage: @@ -1595,6 +1629,7 @@ tagId 可通过 `dws minutes tag list` 获取。 用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add` 用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**) 用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove` +用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**) **自然语言示例:** - "把张三加到这个听记中" @@ -1611,6 +1646,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - "批量添加成员到这个听记" - "把张三加为参会人" - "移除张三对这个听记的权限" +- "我打不开这个听记,帮我申请一下权限" +- "帮我申请这个听记的查看权限" +- "申请这篇听记的编辑权限" - "取消小王对这个听记的访问" - "不让某人看这个听记了" @@ -1618,8 +1656,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - 用户提到"加/添加/共享/分享/让...看/让...访问"等增加权限语义 → `permission add` - 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**) - 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove` -- 如果用户未指定权限类型,默认使用 `--policy 4`(可查看/下载/编辑) -- 如果用户未提供 member-uids,需要先引导用户提供目标成员的钉钉 UID(可通过 `dws contact user search --query "姓名"` 查询) +- 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看)) +- `permission add` 的 `--policy` 是必填参数,没有默认值。用户未指定权限类型时,先确认要 0(管理员)/ 1(所有者)/ 2(可编辑)/ 3(可查看下载)/ 4(仅查看);即使确认选择 4,命令中仍必须显式传入 `--policy 4` +- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws contact user search --query "姓名"` 查询);`permission apply` 不适用这一条 **典型执行链路:** 1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index 7a8c9b19..538e940e 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -66,9 +66,10 @@ dws minutes │ ├── create --file-name "..." --file-size # 创建上传会话,获取上传地址 │ ├── complete --session-id # 确认上传完成 │ └── cancel --session-id # 取消上传会话 -├── permission # 权限管理(添加/移除听记成员权限) +├── permission # 权限管理(添加/移除听记成员权限、为自己申请权限) │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限 -│ └── remove --ids --member-uids # 移除成员权限 +│ ├── remove --ids --member-uids # 移除成员权限 +│ └── apply --id --policy <2-4> # 为当前用户申请听记权限 └── tag # 听记标签/分组管理(标签在听记页面手动创建) ├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称) └── query --tag-id [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表 @@ -417,7 +418,8 @@ dws minutes │ └── cancel --session-id ├── permission │ ├── add --ids --member-uids --policy <0-4> [--cover] [--sub-resources "..."] -│ └── remove --ids --member-uids +│ ├── remove --ids --member-uids +│ └── apply --id --policy <2-4> └── tag ├── list └── query --tag-id [--limit] [--cursor] @@ -1324,6 +1326,38 @@ Flags: - 如果成员是听记的创建者(所有者),无法通过此命令移除其权限 - 建议在移除前先确认成员的当前权限,避免误操作 +### 为当前用户申请听记权限 +``` +Usage: + dws minutes permission apply [flags] +Example: + dws minutes permission apply --id --policy 4 + dws minutes permission apply --id --policy 2 +Flags: + --id string 听记 taskUuid (必填) + --policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填) +``` + +为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。 +适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。 + +**权限类型说明:** + +| --policy 值 | 含义 | 说明 | +|------------|------|------| +| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 | +| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 | +| 4 | 仅查看 | 仅可查看听记内容,不可下载 | + +**与 permission add 的区别:** +- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids` +- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1) + +**典型使用场景:** +- 打开同事分享的听记链接提示无权限,申请查看权限 +- 需要编辑某篇只读听记的纪要,申请可编辑权限 +- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限 + ### 查询我的听记标签/分组列表 ``` Usage: @@ -1708,6 +1742,7 @@ tagId 可通过 `dws minutes tag list` 获取。 用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add` 用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**) 用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove` +用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**) **自然语言示例:** - "把张三加到这个听记中" @@ -1724,6 +1759,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - "批量添加成员到这个听记" - "把张三加为参会人" - "移除张三对这个听记的权限" +- "我打不开这个听记,帮我申请一下权限" +- "帮我申请这个听记的查看权限" +- "申请这篇听记的编辑权限" - "取消小王对这个听记的访问" - "不让某人看这个听记了" @@ -1731,8 +1769,9 @@ tagId 可通过 `dws minutes tag list` 获取。 - 用户提到"加/添加/共享/分享/让...看/让...访问"等增加权限语义 → `permission add` - 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**) - 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove` -- 如果用户未指定权限类型,默认使用 `--policy 4`(可查看/下载/编辑) -- 如果用户未提供 member-uids,需要先引导用户提供目标成员的钉钉 UID(可通过 `dws aisearch person --keyword "姓名" --dimension name` 查询) +- 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看)) +- `permission add` 的 `--policy` 是必填参数,没有默认值。用户未指定权限类型时,先确认要 0(管理员)/ 1(所有者)/ 2(可编辑)/ 3(可查看下载)/ 4(仅查看);即使确认选择 4,命令中仍必须显式传入 `--policy 4` +- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws aisearch person --keyword "姓名" --dimension name` 查询);`permission apply` 不适用这一条 **典型执行链路:** 1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 9f5812a6..29225ec5 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -675,6 +675,7 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { "filename === '.github/actionlint.yaml'", "filename.startsWith('scripts/')", "filename.startsWith('verify/')", + "filename.startsWith('internal/helpers/')", "filename === 'test/fixtures/cli-interface-baseline.txt'", "filename.startsWith('internal/interfacesnapshot/')", "filename.startsWith('internal/cobracmd/')", diff --git a/test/unit/skill_docs_policy_test.go b/test/unit/skill_docs_policy_test.go index 75e9b2c5..45877f26 100644 --- a/test/unit/skill_docs_policy_test.go +++ b/test/unit/skill_docs_policy_test.go @@ -540,6 +540,36 @@ func TestCrossPlatformCoverageDocSkillReferenceLoadingBudget(t *testing.T) { } } +func TestMinutesPermissionAddRequiresExplicitPolicy(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + paths := []string{ + filepath.Join(root, "skills", "mono", "references", "products", "minutes.md"), + filepath.Join(root, "skills", "multi", "dingtalk-minutes", "references", "minutes.md"), + } + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + text := string(content) + for _, required := range []string{ + "`permission add` 的 `--policy` 是必填参数,没有默认值", + "命令中仍必须显式传入 `--policy 4`", + } { + if !strings.Contains(text, required) { + t.Errorf("%s missing permission add policy contract %q", path, required) + } + } + if strings.Contains(text, "`permission add` 默认使用 `--policy 4`") { + t.Errorf("%s still documents a nonexistent permission add policy default", path) + } + } +} + func hasAny(s string, needles []string) bool { for _, needle := range needles { if strings.Contains(s, needle) {