diff --git a/shortcuts/minutes/minutes_apply_permission.go b/shortcuts/minutes/minutes_apply_permission.go new file mode 100644 index 000000000..c68b452b8 --- /dev/null +++ b/shortcuts/minutes/minutes_apply_permission.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// MinutesApplyPermission applies for view or edit permission on a minute. +var MinutesApplyPermission = common.Shortcut{ + Service: "minutes", + Command: "+apply-permission", + Description: "Apply for view or edit permission on a minute", + Risk: "write", + Scopes: []string{"minutes:permission:apply"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + {Name: "perm", Desc: "permission to apply for", Required: true, Enum: []string{"view", "edit"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + if minuteToken == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") + } + perm := strings.TrimSpace(runtime.Str("perm")) + if perm == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm is required").WithParam("--perm") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + return common.NewDryRunAPI(). + POST(minutesApplyPermissionPath(minuteToken)). + Body(minutesApplyPermissionBody(runtime)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + perm := strings.TrimSpace(runtime.Str("perm")) + + _, err := runtime.CallAPITyped(http.MethodPost, minutesApplyPermissionPath(minuteToken), nil, map[string]interface{}{"perm": perm}) + if err != nil { + return err + } + + runtime.OutFormat(map[string]interface{}{ + "minute_token": minuteToken, + "perm": perm, + }, nil, nil) + return nil + }, +} + +func minutesApplyPermissionPath(minuteToken string) string { + return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/permissions/apply", validate.EncodePathSegment(minuteToken)) +} + +func minutesApplyPermissionBody(runtime *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "perm": strings.TrimSpace(runtime.Str("perm")), + } +} diff --git a/shortcuts/minutes/minutes_apply_permission_test.go b/shortcuts/minutes/minutes_apply_permission_test.go new file mode 100644 index 000000000..bfd241eb3 --- /dev/null +++ b/shortcuts/minutes/minutes_apply_permission_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/spf13/cobra" +) + +const minutesApplyPermissionTestToken = "obcnexampleminute" + +func TestMinutesApplyPermission_Validate(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing minute token", + args: []string{"+apply-permission", "--perm", "view", "--as", "user"}, + wantErr: "required flag(s) \"minute-token\" not set", + }, + { + name: "missing perm", + args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--as", "user"}, + wantErr: "required flag(s) \"perm\" not set", + }, + { + name: "invalid perm", + args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"}, + wantErr: "allowed: view, edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesApplyPermission.Mount(parent, f) + parent.SetArgs(tt.args) + parent.SilenceErrors = true + parent.SilenceUsage = true + err := parent.Execute() + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error()) + } + }) + } +} + +func TestMinutesApplyPermission_ValidateTypedMinuteToken(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + parent := &cobra.Command{Use: "minutes"} + MinutesApplyPermission.Mount(parent, f) + parent.SetArgs([]string{"+apply-permission", "--minute-token", "..", "--perm", "view", "--as", "user"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + err := parent.Execute() + if err == nil { + t.Fatalf("expected error, got nil") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("want *errs.ValidationError, got %T", err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype=%q", ve.Subtype) + } + if ve.Param != "--minute-token" { + t.Errorf("param=%q", ve.Param) + } +} + +func TestMinutesApplyPermission_ValidateTypedPerm(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + parent := &cobra.Command{Use: "minutes"} + MinutesApplyPermission.Mount(parent, f) + parent.SetArgs([]string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + err := parent.Execute() + if err == nil { + t.Fatalf("expected error, got nil") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("want *errs.ValidationError, got %T", err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype=%q", ve.Subtype) + } + if ve.Param != "--perm" { + t.Errorf("param=%q", ve.Param) + } +} + +func TestMinutesApplyPermission_DryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesApplyPermission, []string{ + "+apply-permission", + "--minute-token", minutesApplyPermissionTestToken, + "--perm", "view", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "POST") { + t.Errorf("expected POST method, got:\n%s", out) + } + if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesApplyPermissionTestToken+"/permissions/apply") { + t.Errorf("expected apply-permission endpoint, got:\n%s", out) + } + if !strings.Contains(out, `"perm": "view"`) && !strings.Contains(out, `"perm":"view"`) { + t.Errorf("expected perm body, got:\n%s", out) + } +} + +func TestMinutesApplyPermission_Execute(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + stub := &httpmock.Stub{ + Method: http.MethodPost, + URL: "/open-apis/minutes/v1/minutes/" + minutesApplyPermissionTestToken + "/permissions/apply", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRun(t, MinutesApplyPermission, []string{ + "+apply-permission", + "--minute-token", minutesApplyPermissionTestToken, + "--perm", "edit", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var requestBody struct { + Perm string `json:"perm"` + } + if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + if requestBody.Perm != "edit" { + t.Errorf("request perm = %q, want edit", requestBody.Perm) + } + + var envelope struct { + Data struct { + MinuteToken string `json:"minute_token"` + Perm string `json:"perm"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Data.MinuteToken != minutesApplyPermissionTestToken { + t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesApplyPermissionTestToken) + } + if envelope.Data.Perm != "edit" { + t.Errorf("data.perm = %q, want edit", envelope.Data.Perm) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index c70c37c73..705a5862a 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut { MinutesDownload, MinutesUpload, MinutesUpdate, + MinutesApplyPermission, MinutesSummary, MinutesTodo, MinutesSpeakerReplace, diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index c04ecd837..5129de67b 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -31,6 +31,7 @@ metadata: | [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 | | [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 | | [`+update`](references/lark-minutes-update.md) | 更新妙记标题 | +| `+apply-permission` | 申请妙记查看或编辑权限 | | [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(须先 `lark-cli api GET .../speakerlist` 取 `speaker_id`) | | `+word-replace` | 批量替换逐字稿关键词(详见 `lark-cli minutes +word-replace --help`) | | [`+summary`](references/lark-minutes-summary.md) | 替换妙记 AI 总结全文 | @@ -52,6 +53,7 @@ metadata: | 在妙记里增加 / 更改 / 删除 AI 待办 | `+todo`(**禁止走 lark-task**) | | 替换妙记的AI 总结 | `+summary` | | 重命名妙记/改妙记标题 | `+update` | +| 申请妙记权限(查看/编辑) | `+apply-permission --perm view\|edit` | | 替换说话人/把 A 的发言改成 B/重新归属发言人/把外部(非飞书)说话人改成飞书用户" | 先 `lark-cli api GET .../transcript/speakerlist` 取 `speaker_id`,再 [`minutes +speaker-replace`](references/lark-minutes-speaker-replace.md);`--from-speaker-id` 只传 id,不传展示名 | | 批量替换逐字稿关键词 | `+word-replace` | | 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`)获取 `minute_token`,再本 skill | @@ -75,8 +77,19 @@ metadata: 2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`。 3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。 +### 3. 申请妙记权限 -### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿) +只有当用户明确要求"申请查看权限"、"申请编辑权限"、"帮我申请这条妙记权限"时,才调用: + +```bash +lark-cli minutes +apply-permission --minute-token --perm view|edit +``` + +这是向妙记所有者发起权限申请,不代表立即获得权限。 + +**安全约束**:遇到无权限错误时,不要自动调用 `+apply-permission`;先把无权限事实告知用户,只有用户明确要求申请权限时才发起申请。 + +### 4. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿) 1. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。 2. **处理流程**: diff --git a/tests/cli_e2e/minutes/minutes_apply_permission_test.go b/tests/cli_e2e/minutes/minutes_apply_permission_test.go new file mode 100644 index 000000000..cb74523fa --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_apply_permission_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMinutesApplyPermission_DryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+apply-permission", + "--minute-token", "obcnexampleminute", + "--perm", "view", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/permissions/apply"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output) +} + +func TestMinutesApplyPermission_InvalidPerm(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+apply-permission", + "--minute-token", "obcnexampleminute", + "--perm", "full_access", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + assert.True(t, strings.Contains(result.Stderr, "--perm"), "stderr should name --perm, got: %s", result.Stderr) + assert.True(t, strings.Contains(result.Stderr, "view") && strings.Contains(result.Stderr, "edit"), "stderr should list allowed values, got: %s", result.Stderr) +}