diff --git a/shortcuts/minutes/minutes_apply_permission.go b/shortcuts/minutes/minutes_apply_permission.go new file mode 100644 index 000000000..18703e6a9 --- /dev/null +++ b/shortcuts/minutes/minutes_apply_permission.go @@ -0,0 +1,74 @@ +// 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"}, + 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/minutes_detail.go b/shortcuts/minutes/minutes_detail.go index 17afade0c..4d8cb3686 100644 --- a/shortcuts/minutes/minutes_detail.go +++ b/shortcuts/minutes/minutes_detail.go @@ -70,7 +70,7 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu if isMinutesDetailProcessingError(err) { markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated") } else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode { - result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken) + result.Error = fmt.Sprintf("No read permission for minute %s. Ask the user before running: minutes +apply-permission --minute-token %s --perm view", minuteToken, minuteToken) } else { result.Error = fmt.Sprintf("failed to query minute: %v", err) } diff --git a/shortcuts/minutes/minutes_speaker_replace.go b/shortcuts/minutes/minutes_speaker_replace.go index a92676f10..d69a14835 100644 --- a/shortcuts/minutes/minutes_speaker_replace.go +++ b/shortcuts/minutes/minutes_speaker_replace.go @@ -135,7 +135,7 @@ func minutesSpeakerReplaceError(err error, minuteToken, sourceSpeaker string) er switch p.Code { case minutesSpeakerReplaceNoEditPermission: p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken) - p.Hint = "Ask the minute owner for minute edit permission" + p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken) case minutesSpeakerReplaceSpeakerNotFoundCode: p.Subtype = errs.SubtypeNotFound p.Message = fmt.Sprintf("Speaker not found in minute %q: source speaker %q does not match an existing speaker in the transcript.", minuteToken, sourceSpeaker) diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go index 193331f55..fd6740975 100644 --- a/shortcuts/minutes/minutes_speaker_replace_test.go +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -361,7 +361,7 @@ func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) { if !strings.Contains(p.Message, minutesSpeakerReplaceTestToken) { t.Errorf("message should include minute token, got: %s", p.Message) } - if !strings.Contains(p.Hint, "edit permission") { - t.Errorf("hint should mention edit permission, got: %s", p.Hint) + if !strings.Contains(p.Hint, "+apply-permission") { + t.Errorf("hint should mention apply-permission, got: %s", p.Hint) } } diff --git a/shortcuts/minutes/minutes_summary_todo_test.go b/shortcuts/minutes/minutes_summary_todo_test.go index 0ebd3d297..ca41db977 100644 --- a/shortcuts/minutes/minutes_summary_todo_test.go +++ b/shortcuts/minutes/minutes_summary_todo_test.go @@ -400,8 +400,8 @@ func TestMinutesTodo_NoEditPermission(t *testing.T) { if !strings.Contains(p.Message, minutesSummaryTodoTestToken) { t.Errorf("message should include minute token, got: %s", p.Message) } - if !strings.Contains(p.Hint, "edit permission") { - t.Errorf("hint should mention edit permission, got: %s", p.Hint) + if !strings.Contains(p.Hint, "+apply-permission") { + t.Errorf("hint should mention apply-permission, got: %s", p.Hint) } } diff --git a/shortcuts/minutes/minutes_todo.go b/shortcuts/minutes/minutes_todo.go index 70730b439..93c84ea07 100644 --- a/shortcuts/minutes/minutes_todo.go +++ b/shortcuts/minutes/minutes_todo.go @@ -288,6 +288,6 @@ func minutesTodoError(err error, minuteToken string) error { } p.Subtype = errs.SubtypePermissionDenied p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update todos.", minuteToken) - p.Hint = "Ask the minute owner for minute edit permission" + p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken) return err } diff --git a/shortcuts/minutes/minutes_update.go b/shortcuts/minutes/minutes_update.go index b7fa2bf66..6b9f93783 100644 --- a/shortcuts/minutes/minutes_update.go +++ b/shortcuts/minutes/minutes_update.go @@ -79,6 +79,6 @@ func minutesUpdateError(err error, minuteToken string) error { return err } p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken) - p.Hint = "Ask the minute owner for minute edit permission" + p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken) return err } diff --git a/shortcuts/minutes/minutes_update_test.go b/shortcuts/minutes/minutes_update_test.go index 222649367..7356aaedf 100644 --- a/shortcuts/minutes/minutes_update_test.go +++ b/shortcuts/minutes/minutes_update_test.go @@ -171,7 +171,7 @@ func TestMinutesUpdate_NoEditPermission(t *testing.T) { if !strings.Contains(p.Message, minutesUpdateTestToken) { t.Errorf("message should include minute token, got: %s", p.Message) } - if !strings.Contains(p.Hint, "edit permission") { - t.Errorf("hint should mention edit permission, got: %s", p.Hint) + if !strings.Contains(p.Hint, "+apply-permission") { + t.Errorf("hint should mention apply-permission, got: %s", p.Hint) } } diff --git a/shortcuts/minutes/minutes_word_replace.go b/shortcuts/minutes/minutes_word_replace.go index 2cae38eb9..db7f2a9b4 100644 --- a/shortcuts/minutes/minutes_word_replace.go +++ b/shortcuts/minutes/minutes_word_replace.go @@ -139,7 +139,7 @@ func minutesWordReplaceError(err error, minuteToken string) error { case minutesWordReplaceNoEditPermission: p.Subtype = errs.SubtypePermissionDenied p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace transcript words.", minuteToken) - p.Hint = "Ask the minute owner for minute edit permission" + p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken) case minutesWordReplaceOthersEditing: p.Subtype = errs.SubtypeConflict p.Message = fmt.Sprintf("Minute %q transcript is being edited by someone else.", minuteToken) 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/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 555acc70a..0d1b03259 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -68,7 +68,7 @@ func minutesReadError(err error, minuteToken string) error { return err } p.Message = fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken) - p.Hint = "Ask the minute owner for minute file read permission" + p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm view", minuteToken) return err } diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index 992c5f2d9..d87ae13ad 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -1255,7 +1255,7 @@ func TestMinutesReadError_ProblemOf_EnrichesMessage(t *testing.T) { t.Errorf("error message not enriched: %q", errMsg) } hint, _ := note["hint"].(string) - if !strings.Contains(hint, "minute file read permission") { + if !strings.Contains(hint, "+apply-permission") { t.Errorf("hint not surfaced: %q", hint) } } diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index c04ecd837..7a6f6b208 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,21 @@ metadata: 2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`。 3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。 +### 3. 申请妙记权限 -### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿) +遇到妙记没有查看或编辑权限时,引导用户申请对应权限;只有用户明确要申请时,才调用 `minutes +apply-permission`。 + +只有当用户明确要求"申请查看权限"、"申请编辑权限"、"帮我申请这条妙记权限"时,才调用: + +```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) +}