diff --git a/shortcuts/minutes/minutes_speaker_replace.go b/shortcuts/minutes/minutes_speaker_replace.go new file mode 100644 index 000000000..ccba0ea8e --- /dev/null +++ b/shortcuts/minutes/minutes_speaker_replace.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + minutesSpeakerReplaceSpeakerNotFoundCode = 2091001 + minutesSpeakerReplaceNoEditPermission = 2091005 +) + +// MinutesSpeakerReplace replaces a speaker in a minute's transcript. +var MinutesSpeakerReplace = common.Shortcut{ + Service: "minutes", + Command: "+speaker-replace", + Description: "Replace a speaker in a minute's transcript (rebind from one user to another)", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + {Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true}, + {Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + if minuteToken == "" { + return output.ErrValidation("--minute-token is required") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return output.ErrValidation("%s", err) + } + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + if fromUserID == "" { + return output.ErrValidation("--from-user-id is required") + } + if _, err := common.ValidateUserID(fromUserID); err != nil { + return output.ErrValidation("--from-user-id: %s", err) + } + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + if toUserID == "" { + return output.ErrValidation("--to-user-id is required") + } + if _, err := common.ValidateUserID(toUserID); err != nil { + return output.ErrValidation("--to-user-id: %s", err) + } + if fromUserID == toUserID { + return output.ErrValidation("--from-user-id and --to-user-id must be different") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + return common.NewDryRunAPI(). + PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))). + Body(map[string]interface{}{ + "minute_token": minuteToken, + "from_user_id": fromUserID, + "to_user_id": toUserID, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + + body := map[string]interface{}{ + "minute_token": minuteToken, + "from_user_id": fromUserID, + "to_user_id": toUserID, + } + + _, err := runtime.CallAPI(http.MethodPut, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), + nil, body) + if err != nil { + return minutesSpeakerReplaceError(err, minuteToken, fromUserID) + } + + outData := map[string]interface{}{ + "minute_token": minuteToken, + "from_user_id": fromUserID, + "to_user_id": toUserID, + } + + runtime.OutFormat(outData, nil, nil) + return nil + }, +} + +func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + return err + } + + switch exitErr.Detail.Code { + case minutesSpeakerReplaceNoEditPermission: + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "no_edit_permission", + Code: minutesSpeakerReplaceNoEditPermission, + Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken), + Hint: "Ask the minute owner for minute edit permission", + Detail: exitErr.Detail.Detail, + }, + Err: err, + } + case minutesSpeakerReplaceSpeakerNotFoundCode: + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "speaker_not_found", + Code: minutesSpeakerReplaceSpeakerNotFoundCode, + Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID), + Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.", + Detail: exitErr.Detail.Detail, + }, + Err: err, + } + } + + return err +} diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go new file mode 100644 index 000000000..899f15acd --- /dev/null +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -0,0 +1,247 @@ +// 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/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +const minutesSpeakerReplaceTestToken = "obcnexampleminute" + +func TestMinutesSpeakerReplace_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{"+speaker-replace", "--from-user-id", "ou_a", "--to-user-id", "ou_b", "--as", "user"}, + wantErr: "required flag(s) \"minute-token\" not set", + }, + { + name: "missing from", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--to-user-id", "ou_b", "--as", "user"}, + wantErr: "required flag(s) \"from-user-id\" not set", + }, + { + name: "missing to", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--as", "user"}, + wantErr: "required flag(s) \"to-user-id\" not set", + }, + { + name: "invalid from prefix", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, + wantErr: "--from-user-id", + }, + { + name: "invalid to prefix", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"}, + wantErr: "--to-user-id", + }, + { + name: "from equals to", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, + wantErr: "must be different", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesSpeakerReplace.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 TestMinutesSpeakerReplace_DryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-user-id", "ou_old_speaker", + "--to-user-id", "ou_new_speaker", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "PUT") { + t.Errorf("expected PUT method, got:\n%s", out) + } + if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSpeakerReplaceTestToken+"/transcript/speaker") { + t.Errorf("expected speaker endpoint, got:\n%s", out) + } + if !strings.Contains(out, "ou_old_speaker") { + t.Errorf("expected from_user_id in body, got:\n%s", out) + } + if !strings.Contains(out, "ou_new_speaker") { + t.Errorf("expected to_user_id in body, got:\n%s", out) + } +} + +func TestMinutesSpeakerReplace_Execute(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-user-id", "ou_old_speaker", + "--to-user-id", "ou_new_speaker", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope struct { + Data struct { + MinuteToken string `json:"minute_token"` + FromUserID string `json:"from_user_id"` + ToUserID string `json:"to_user_id"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Data.MinuteToken != minutesSpeakerReplaceTestToken { + t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesSpeakerReplaceTestToken) + } + if envelope.Data.FromUserID != "ou_old_speaker" { + t.Errorf("data.from_user_id = %q, want ou_old_speaker", envelope.Data.FromUserID) + } + if envelope.Data.ToUserID != "ou_new_speaker" { + t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID) + } +} + +func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", + Body: map[string]interface{}{ + "code": 2091001, + "msg": "speaker not exist", + }, + }) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-user-id", "ou_missing_speaker", + "--to-user-id", "ou_new_speaker", + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected speaker-not-found error, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Detail == nil { + t.Fatalf("expected structured error detail, got nil") + } + if exitErr.Detail.Type != "speaker_not_found" { + t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "Speaker not found") { + t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") { + t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") { + t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint) + } +} + +func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", + Body: map[string]interface{}{ + "code": 2091005, + "msg": "no edit permission", + }, + }) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-user-id", "ou_old_speaker", + "--to-user-id", "ou_new_speaker", + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Detail == nil { + t.Fatalf("expected structured error detail, got nil") + } + if exitErr.Detail.Type != "no_edit_permission" { + t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "No edit permission") { + t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) { + t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "edit permission") { + t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint) + } +} diff --git a/shortcuts/minutes/minutes_update.go b/shortcuts/minutes/minutes_update.go new file mode 100644 index 000000000..bdf30c680 --- /dev/null +++ b/shortcuts/minutes/minutes_update.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const minutesUpdateNoEditPermissionCode = 2091005 + +// MinutesUpdate updates the title (topic) of a minute. +var MinutesUpdate = common.Shortcut{ + Service: "minutes", + Command: "+update", + Description: "Update a minute's title", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + {Name: "topic", Desc: "new minute title", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + if minuteToken == "" { + return output.ErrValidation("--minute-token is required") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return output.ErrValidation("%s", err) + } + if strings.TrimSpace(runtime.Str("topic")) == "" { + return output.ErrValidation("--topic is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + return common.NewDryRunAPI(). + PATCH(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken))). + Body(map[string]interface{}{"topic": runtime.Str("topic")}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + topic := runtime.Str("topic") + + body := map[string]interface{}{ + "topic": topic, + } + + _, err := runtime.CallAPI(http.MethodPatch, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), + nil, body) + if err != nil { + return minutesUpdateError(err, minuteToken) + } + + outData := map[string]interface{}{ + "minute_token": minuteToken, + "topic": topic, + } + + runtime.OutFormat(outData, nil, nil) + return nil + }, +} + +func minutesUpdateError(err error, minuteToken string) error { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode { + return err + } + + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "no_edit_permission", + Code: minutesUpdateNoEditPermissionCode, + Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken), + Hint: "Ask the minute owner for minute edit permission", + Detail: exitErr.Detail.Detail, + }, + Err: err, + } +} diff --git a/shortcuts/minutes/minutes_update_test.go b/shortcuts/minutes/minutes_update_test.go new file mode 100644 index 000000000..c061eccf5 --- /dev/null +++ b/shortcuts/minutes/minutes_update_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +const minutesUpdateTestToken = "obcnexampleminute" + +func TestMinutesUpdate_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{"+update", "--topic", "new title", "--as", "user"}, + wantErr: "required flag(s) \"minute-token\" not set", + }, + { + name: "missing topic", + args: []string{"+update", "--minute-token", "obcn123456", "--as", "user"}, + wantErr: "required flag(s) \"topic\" not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesUpdate.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 TestMinutesUpdate_DryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesUpdate, []string{ + "+update", + "--minute-token", minutesUpdateTestToken, + "--topic", "周会纪要", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "PATCH") { + t.Errorf("expected PATCH method, got:\n%s", out) + } + if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesUpdateTestToken) { + t.Errorf("expected PATCH /open-apis/minutes/v1/minutes/, got:\n%s", out) + } + if !strings.Contains(out, "周会纪要") { + t.Errorf("expected topic in body, got:\n%s", out) + } +} + +func TestMinutesUpdate_Execute(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPatch, + URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesUpdate, []string{ + "+update", + "--minute-token", minutesUpdateTestToken, + "--topic", "新标题", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMinutesUpdate_NoEditPermission(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPatch, + URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken, + Body: map[string]interface{}{ + "code": 2091005, + "msg": "no edit permission", + }, + }) + + err := mountAndRun(t, MinutesUpdate, []string{ + "+update", + "--minute-token", minutesUpdateTestToken, + "--topic", "新标题", + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Detail == nil { + t.Fatalf("expected structured error detail, got nil") + } + if exitErr.Detail.Type != "no_edit_permission" { + t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "No edit permission") { + t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) { + t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "edit permission") { + t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index 8aef2b058..75d0a70b9 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -11,5 +11,7 @@ func Shortcuts() []common.Shortcut { MinutesSearch, MinutesDownload, MinutesUpload, + MinutesUpdate, + MinutesSpeakerReplace, } } diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index 2b2d8ea78..c84be81a5 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-minutes version: 1.0.0 -description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill,而不是尝试 `ffmpeg`、`whisper` 等本地转写命令。飞书妙记 URL 格式: http(s):///minutes/" +description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物;6.更新妙记标题(重命名妙记);7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s):///minutes/" metadata: requires: bins: ["lark-cli"] @@ -98,6 +98,8 @@ Minutes (妙记) ← minute_token 标识 > - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) > - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload` > - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens` +> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update` +> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace` ## Shortcuts(推荐优先使用) @@ -108,10 +110,14 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]` | [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range | | [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | | [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute | +| [`+update`](references/lark-minutes-update.md) | Update a minute's title | +| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) | - 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。 - 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。 - 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。 +- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。 +- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID,不支持姓名)。 @@ -135,5 +141,7 @@ lark-cli minutes [flags] # 调用 API | `+search` | `minutes:minutes.search:read` | | `minutes.get` | `minutes:minutes:readonly` | | `+download` | `minutes:minutes.media:export` | +| `+update` | `minutes:minutes:update` | +| `+speaker-replace` | `minutes:minutes:update` | diff --git a/skills/lark-minutes/references/lark-minutes-speaker-replace.md b/skills/lark-minutes/references/lark-minutes-speaker-replace.md new file mode 100644 index 000000000..12b82c63f --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-speaker-replace.md @@ -0,0 +1,50 @@ +# minutes +speaker-replace + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。 + +本 skill 对应 shortcut:`lark-cli minutes +speaker-replace`。 + +## 典型触发表达 + +- "把这条妙记里 A 的发言改成 B" +- "妙记说话人识别错了,帮我把张三的部分换成李四" +- "妙记说话人修改 / 替换 / 重新归属" +- "改一下妙记的说话人" + +## 命令示例 + +```bash +lark-cli minutes +speaker-replace \ + --minute-token obcnxxxxxxxxxxxxxxxxxxxx \ + --from-user-id ou_old_speaker_open_id \ + --to-user-id ou_new_speaker_open_id +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 | +| `--from-user-id ` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | +| `--to-user-id ` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | + +> **重要**:`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID,**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。 + +## 认证与权限 + +- 所需 scope:`minutes:minutes:update`。 + +## 输出结果 + +| 字段 | 说明 | +|------|------| +| `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致 | +| `from_user_id` | 被替换的原说话人 open_id,与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 | +| `to_user_id` | 替换后的新说话人 open_id,与输入的 `--to-user-id` 一致 | + +## 参考 + +- [lark-minutes](../SKILL.md) -- 妙记相关功能说明 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-minutes/references/lark-minutes-update.md b/skills/lark-minutes/references/lark-minutes-update.md new file mode 100644 index 000000000..780066093 --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-update.md @@ -0,0 +1,41 @@ +# minutes +update + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +修改飞书妙记的标题(topic)。 + +本 skill 对应 shortcut:`lark-cli minutes +update`。 + +## 典型触发表达 + +- "把这个妙记的标题改成 xxx" +- "重命名这条妙记" +- "修改妙记标题" + +## 命令示例 + +```bash +lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 | +| `--topic ` | 是 | 新的妙记标题 | + +## 认证与权限 +- 所需 scope:`minutes:minutes:update`。 + +## 输出结果 + +| 字段 | 说明 | +|------|------| +| `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致,可继续用于查询妙记信息、下载媒体或获取纪要产物 | +| `topic` | 修改后的妙记标题,与输入的 `--topic` 一致 | + +## 参考 + +- [lark-minutes](../SKILL.md) -- 妙记相关功能说明 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go new file mode 100644 index 000000000..70cf3cceb --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go @@ -0,0 +1,40 @@ +// 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 TestMinutesSpeakerReplace_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", "+speaker-replace", + "--minute-token", "obcnexampleminute", + "--from-user-id", "ou_old_speaker", + "--to-user-id", "ou_new_speaker", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "ou_old_speaker"), "dry-run should contain from_user_id, got: %s", output) + assert.True(t, strings.Contains(output, "ou_new_speaker"), "dry-run should contain to_user_id, got: %s", output) +} diff --git a/tests/cli_e2e/minutes/minutes_update_test.go b/tests/cli_e2e/minutes/minutes_update_test.go new file mode 100644 index 000000000..b452ff599 --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_update_test.go @@ -0,0 +1,38 @@ +// 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 TestMinutesUpdate_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", "+update", + "--minute-token", "obcnexampleminute", + "--topic", "新的妙记标题", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PATCH"), "dry-run should contain PATCH method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "新的妙记标题"), "dry-run should contain topic, got: %s", output) +}