diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go index d2b5fa5eed..78944b2d47 100644 --- a/shortcuts/minutes/minutes_speaker_replace_test.go +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -33,27 +33,27 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) { }, { name: "missing from", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--to-user-id", "ou_b", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--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"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--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"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, wantErr: "invalid user ID format", }, { name: "invalid to prefix", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"}, wantErr: "invalid user ID format", }, { name: "from equals to", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, wantErr: "must be different", }, } @@ -87,12 +87,12 @@ func TestMinutesSpeakerReplace_ValidateTyped(t *testing.T) { }{ { name: "invalid from prefix", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, wantParam: "--from-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"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, wantParam: "--to-user-id", }, } diff --git a/shortcuts/minutes/minutes_summary.go b/shortcuts/minutes/minutes_summary.go new file mode 100644 index 0000000000..f8277eb258 --- /dev/null +++ b/shortcuts/minutes/minutes_summary.go @@ -0,0 +1,73 @@ +// 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" +) + +const minutesSummaryMarkdownTip = "Summary accepts any text; unsupported Markdown is saved but may display as literal raw text in Minutes. For best rendering, prefer plain text, line breaks, headings (#, ##, ###), bold (**text**), and lists (-, *, or 1.)." + +// MinutesSummary replaces the AI summary of a minute. +var MinutesSummary = common.Shortcut{ + Service: "minutes", + Command: "+summary", + Description: "Replace the AI summary of a minute", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + {Name: "summary", Desc: "replacement summary text (Markdown subset renders best in Minutes)", Required: true, Input: []string{common.File, common.Stdin}}, + }, + Tips: []string{ + minutesSummaryMarkdownTip, + "Use `lark-cli vc +notes --minute-tokens ` to read the current summary before replacing it.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := 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") + } + summary := strings.TrimSpace(runtime.Str("summary")) + if summary == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--summary is required").WithParam("--summary") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/summary", validate.EncodePathSegment(runtime.Str("minute-token")))). + Body(map[string]interface{}{"summary": ""}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := runtime.Str("minute-token") + summary := strings.TrimSpace(runtime.Str("summary")) + + path := fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/summary", validate.EncodePathSegment(minuteToken)) + body := map[string]interface{}{ + "summary": summary, + } + if _, err := runtime.CallAPITyped(http.MethodPut, path, nil, body); err != nil { + return err + } + + runtime.OutFormat(map[string]interface{}{ + "minute_token": minuteToken, + "updated": true, + }, nil, nil) + return nil + }, +} diff --git a/shortcuts/minutes/minutes_summary_todo_test.go b/shortcuts/minutes/minutes_summary_todo_test.go new file mode 100644 index 0000000000..0ebd3d297f --- /dev/null +++ b/shortcuts/minutes/minutes_summary_todo_test.go @@ -0,0 +1,415 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +const minutesSummaryTodoTestToken = "obcnexampleminute" + +func todoStub(token string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/minutes/v1/minutes/" + token + "/todo", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + } +} + +func firstTodoItem(t *testing.T, raw []byte) map[string]any { + t.Helper() + items := todoItems(t, raw) + if len(items) != 1 { + t.Fatalf("todo_items: want 1 item, got %d (%v)", len(items), items) + } + return items[0] +} + +func todoItems(t *testing.T, raw []byte) []map[string]any { + t.Helper() + if len(raw) == 0 { + t.Fatal("request body was not captured") + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("failed to parse captured body: %v", err) + } + rawItems, _ := body["todo_items"].([]any) + items := make([]map[string]any, 0, len(rawItems)) + for _, rawItem := range rawItems { + item, _ := rawItem.(map[string]any) + items = append(items, item) + } + return items +} + +func TestMinutesSummary_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesSummary, []string{ + "+summary", + "--minute-token", minutesSummaryTodoTestToken, + "--summary", "**Weekly sync**\n- follow up", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "PUT") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSummaryTodoTestToken+"/summary") { + t.Fatalf("dry-run output = %q", out) + } +} + +func TestMinutesTodo_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "- finish deck", + "--is-done", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSummaryTodoTestToken+"/todo") { + t.Fatalf("dry-run output = %q", out) + } + if !strings.Contains(out, "todo_items") { + t.Fatalf("dry-run output should contain todo_items, got %q", out) + } + if !strings.Contains(out, "operation") || !strings.Contains(out, "add") { + t.Fatalf("dry-run output should contain the operation, got %q", out) + } +} + +func TestMinutesTodo_RequiresIsDone(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "finish deck", + "--as", "user", + }, f, stderr) + if err == nil { + t.Fatal("expected validation error for missing --is-done") + } + if !strings.Contains(err.Error(), "is-done") { + t.Fatalf("error = %q, want message mentioning is-done", err.Error()) + } +} + +func TestMinutesTodo_RequiresOperation(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--todo", "finish deck", + "--is-done", + "--as", "user", + }, f, stderr) + if err == nil { + t.Fatal("expected validation error for missing --operation") + } + if !strings.Contains(err.Error(), "operation") { + t.Fatalf("error = %q, want message mentioning operation", err.Error()) + } +} + +func TestMinutesTodo_RejectsUnknownOperation(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--operation", "archive", + "--todo", "finish deck", + "--is-done", + "--as", "user", + }, f, stderr) + if err == nil { + t.Fatal("expected validation error for unknown --operation value") + } +} + +func TestMinutesTodo_Add_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "finish deck", "--is-done=false", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + item := firstTodoItem(t, stub.CapturedBody) + if item["operation"] != "add" { + t.Errorf("operation = %v, want add", item["operation"]) + } + if item["content"] != "finish deck" { + t.Errorf("content = %v, want finish deck", item["content"]) + } + if item["is_done"] != false { + t.Errorf("is_done = %v, want false", item["is_done"]) + } + if _, ok := item["todo_id"]; ok { + t.Errorf("add should not send todo_id, got %v", item["todo_id"]) + } + if !strings.Contains(stdout.String(), "add") { + t.Errorf("output should report add operation, got %q", stdout.String()) + } +} + +func TestMinutesTodo_Update_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "update", + "--todo-id", "99", "--todo", "updated deck", "--is-done", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + item := firstTodoItem(t, stub.CapturedBody) + if item["operation"] != "update" { + t.Errorf("operation = %v, want update", item["operation"]) + } + if item["todo_id"] != "99" { + t.Errorf("todo_id = %v, want 99", item["todo_id"]) + } + if item["content"] != "updated deck" { + t.Errorf("content = %v, want updated deck", item["content"]) + } + if item["is_done"] != true { + t.Errorf("is_done = %v, want true", item["is_done"]) + } +} + +func TestMinutesTodo_Delete_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "delete", + "--todo-id", "88", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + item := firstTodoItem(t, stub.CapturedBody) + if item["operation"] != "delete" { + t.Errorf("operation = %v, want delete", item["operation"]) + } + if item["todo_id"] != "88" { + t.Errorf("todo_id = %v, want 88", item["todo_id"]) + } + if _, ok := item["content"]; ok { + t.Errorf("delete should not send content, got %v", item["content"]) + } + if _, ok := item["is_done"]; ok { + t.Errorf("delete should not send is_done, got %v", item["is_done"]) + } + // the todo id must never be surfaced to the user in the command output + if strings.Contains(stdout.String(), "88") { + t.Errorf("output must not expose the todo id, got %q", stdout.String()) + } +} + +func TestMinutesTodo_DeleteRejectsIsDone(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "delete", + "--todo-id", "88", "--is-done", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --is-done is used to delete") + } +} + +func TestMinutesTodo_AddRejectsTodoID(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo-id", "88", "--todo", "finish deck", "--is-done", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --todo-id is used with operation add") + } +} + +func TestMinutesTodo_BatchAdd_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--todos", `[{"operation":"add","content":"晚上好1","is_done":true},{"operation":"add","content":"晚上好2","is_done":false}]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + items := todoItems(t, stub.CapturedBody) + if len(items) != 2 { + t.Fatalf("todo_items: want 2 items, got %d", len(items)) + } + if items[0]["content"] != "晚上好1" || items[0]["is_done"] != true { + t.Errorf("items[0] = %v", items[0]) + } + if items[1]["content"] != "晚上好2" || items[1]["is_done"] != false { + t.Errorf("items[1] = %v", items[1]) + } +} + +func TestMinutesTodo_BatchMixed_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--todos", `[{"operation":"add","content":"new item","is_done":false},{"operation":"update","todo_id":"99","content":"updated","is_done":true},{"operation":"delete","todo_id":"88"}]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + items := todoItems(t, stub.CapturedBody) + if len(items) != 3 { + t.Fatalf("todo_items: want 3 items, got %d", len(items)) + } + if items[0]["operation"] != "add" || items[1]["operation"] != "update" || items[2]["operation"] != "delete" { + t.Errorf("operations order = %v, %v, %v", items[0]["operation"], items[1]["operation"], items[2]["operation"]) + } + if items[2]["todo_id"] != "88" { + t.Errorf("delete todo_id = %v", items[2]["todo_id"]) + } + if !strings.Contains(stdout.String(), `"count"`) { + t.Errorf("output should include count, got %q", stdout.String()) + } +} + +func TestMinutesTodo_BatchRejectsSingleFlags(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--todos", `[{"operation":"add","content":"a","is_done":false}]`, + "--operation", "add", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --todos is mixed with --operation") + } +} + +func TestMinutesTodo_RequiresAnyInput(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --operation is not provided") + } +} + +func TestMinutesTodo_NoEditPermission(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPost, + URL: "/open-apis/minutes/v1/minutes/" + minutesSummaryTodoTestToken + "/todo", + Body: map[string]interface{}{ + "code": minutesTodoNoEditPermissionCode, + "msg": "permission deny", + }, + }) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "finish deck", "--is-done=false", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypePermissionDenied { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied) + } + if !strings.Contains(p.Message, "No edit permission") { + t.Errorf("message should be friendly, got: %s", p.Message) + } + 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) + } +} + +func TestMinutesSummaryAndTodo_HelpMetadata(t *testing.T) { + for _, tip := range MinutesSummary.Tips { + if strings.Contains(tip, "raw text") { + return + } + } + t.Fatal("MinutesSummary tips should mention unsupported markdown display behavior") +} diff --git a/shortcuts/minutes/minutes_todo.go b/shortcuts/minutes/minutes_todo.go new file mode 100644 index 0000000000..05888c333a --- /dev/null +++ b/shortcuts/minutes/minutes_todo.go @@ -0,0 +1,293 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const minutesTodoNoEditPermissionCode = 40005 + +// minuteTodoOp describes a resolved todo_items entry derived from flags or JSON. +type minuteTodoOp struct { + operation string // add | update | delete + item map[string]interface{} // the todo_items entry sent to the API +} + +// minuteTodoSpec is the JSON shape for --todos batch input. +type minuteTodoSpec struct { + Operation string `json:"operation"` + Content string `json:"content"` + IsDone *bool `json:"is_done"` + TodoID string `json:"todo_id"` +} + +// MinutesTodo adds, updates, or deletes todo item(s) on a minute. +var MinutesTodo = common.Shortcut{ + Service: "minutes", + Command: "+todo", + Description: "Add, update, or delete todo item(s) on a minute", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token (required)", Required: true}, + {Name: "operation", Desc: "operation for a single todo (required unless --todos)", Enum: []string{"add", "update", "delete"}}, + {Name: "todo", Desc: "todo plain-text content; required by single add/update", Input: []string{common.File, common.Stdin}}, + {Name: "is-done", Type: "bool", Desc: "completion flag; required by single add/update"}, + {Name: "todo-id", Desc: "id of an existing todo; required by single update/delete"}, + { + Name: "todos", + Desc: `batch todo_items JSON array; each item has operation add|update|delete (supports @file / @-)`, + Input: []string{common.File, common.Stdin}, + }, + }, + Tips: []string{ + "Single todo: `--operation add --todo \"...\" --is-done=false`.", + "Batch: `--todos '[{\"operation\":\"add\",\"content\":\"...\",\"is_done\":false}, ...]'` or `--todos @todos.json`.", + "Batch can mix add, update, and delete in one request; array order is preserved in the API body.", + "Update: `--operation update --todo-id --todo \"...\" --is-done`.", + "Delete: `--operation delete --todo-id `.", + "`content` is plain text only; markdown formatting is not supported.", + "Use `lark-cli vc +notes --minute-tokens ` to read current todos before writing.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := 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") + } + if _, err := resolveMinuteTodoOps(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + api := common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/todo", validate.EncodePathSegment(runtime.Str("minute-token")))) + ops, err := resolveMinuteTodoOps(runtime) + if err != nil { + return api.Body(map[string]interface{}{ + "todo_items": "", + }) + } + return api.Body(map[string]interface{}{ + "todo_items": todoItemsFromOps(ops), + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := runtime.Str("minute-token") + ops, err := resolveMinuteTodoOps(runtime) + if err != nil { + return err + } + + path := fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/todo", validate.EncodePathSegment(minuteToken)) + body := map[string]interface{}{ + "todo_items": todoItemsFromOps(ops), + } + if _, err := runtime.CallAPITyped(http.MethodPost, path, nil, body); err != nil { + return minutesTodoError(err, minuteToken) + } + + out := map[string]interface{}{ + "minute_token": minuteToken, + "count": len(ops), + "updated": true, + } + if len(ops) == 1 { + out["operation"] = ops[0].operation + } + runtime.OutFormat(out, nil, nil) + return nil + }, +} + +func todoItemsFromOps(ops []minuteTodoOp) []interface{} { + items := make([]interface{}, len(ops)) + for i, op := range ops { + items[i] = op.item + } + return items +} + +// resolveMinuteTodoOps builds todo_items from either --todos (batch) or single-item flags. +func resolveMinuteTodoOps(runtime *common.RuntimeContext) ([]minuteTodoOp, error) { + hasTodos := strings.TrimSpace(runtime.Str("todos")) != "" + hasSingle := runtime.Changed("operation") || runtime.Changed("todo") || + runtime.Changed("is-done") || runtime.Changed("todo-id") + + if hasTodos && hasSingle { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "use either --todos for batch or single-item flags (--operation, --todo, --is-done, --todo-id), not both").WithParam("--todos") + } + if hasTodos { + return resolveMinuteTodoBatch(runtime.Str("todos")) + } + op, err := resolveMinuteTodoSingle(runtime) + if err != nil { + return nil, err + } + return []minuteTodoOp{*op}, nil +} + +func resolveMinuteTodoBatch(raw string) ([]minuteTodoOp, error) { + specs, err := parseMinuteTodoSpecs(raw) + if err != nil { + return nil, err + } + if len(specs) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--todos must contain at least one todo item").WithParam("--todos") + } + ops := make([]minuteTodoOp, 0, len(specs)) + for i, spec := range specs { + item, err := buildMinuteTodoItem(spec) + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "todos[%d]: %s", i, err).WithParam("--todos").WithCause(err) + } + ops = append(ops, minuteTodoOp{ + operation: strings.TrimSpace(spec.Operation), + item: item, + }) + } + return ops, nil +} + +func parseMinuteTodoSpecs(raw string) ([]minuteTodoSpec, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--todos: value is empty").WithParam("--todos") + } + var specs []minuteTodoSpec + if err := json.Unmarshal([]byte(raw), &specs); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--todos: invalid JSON array: %s", err).WithParam("--todos").WithCause(err) + } + return specs, nil +} + +func resolveMinuteTodoSingle(runtime *common.RuntimeContext) (*minuteTodoOp, error) { + operation := strings.TrimSpace(runtime.Str("operation")) + todo := strings.TrimSpace(runtime.Str("todo")) + todoID := strings.TrimSpace(runtime.Str("todo-id")) + hasTodo := todo != "" + hasTodoID := todoID != "" + hasIsDone := runtime.Changed("is-done") + + if operation == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--operation is required for single-item mode (or use --todos for batch)").WithParam("--operation") + } + + spec := minuteTodoSpec{Operation: operation} + switch operation { + case "add": + if !hasTodo || !hasIsDone { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" requires --todo and --is-done").WithParam("--todo") + } + if hasTodoID { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" does not accept --todo-id (it creates a new todo)").WithParam("--todo-id") + } + done := runtime.Bool("is-done") + spec.Content = todo + spec.IsDone = &done + case "update": + if !hasTodoID || !hasTodo || !hasIsDone { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires --todo-id, --todo and --is-done").WithParam("--todo-id") + } + done := runtime.Bool("is-done") + spec.TodoID = todoID + spec.Content = todo + spec.IsDone = &done + case "delete": + if !hasTodoID { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" requires --todo-id").WithParam("--todo-id") + } + if hasTodo || hasIsDone { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" only accepts --todo-id (omit --todo and --is-done)").WithParam("--todo-id") + } + spec.TodoID = todoID + default: + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--operation is required, allowed: add, update, delete").WithParam("--operation") + } + + item, err := buildMinuteTodoItem(spec) + if err != nil { + return nil, err + } + return &minuteTodoOp{operation: operation, item: item}, nil +} + +func buildMinuteTodoItem(spec minuteTodoSpec) (map[string]interface{}, error) { + operation := strings.TrimSpace(spec.Operation) + if operation == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation is required") + } + if operation != "add" && operation != "update" && operation != "delete" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation %q is invalid, allowed: add, update, delete", operation) + } + + content := strings.TrimSpace(spec.Content) + todoID := strings.TrimSpace(spec.TodoID) + item := map[string]interface{}{"operation": operation} + + switch operation { + case "add": + if todoID != "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" does not accept todo_id") + } + if content == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" requires content") + } + if spec.IsDone == nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" requires is_done") + } + item["content"] = content + item["is_done"] = *spec.IsDone + case "update": + if todoID == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires todo_id") + } + if content == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires content") + } + if spec.IsDone == nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires is_done") + } + item["todo_id"] = todoID + item["content"] = content + item["is_done"] = *spec.IsDone + case "delete": + if todoID == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" requires todo_id") + } + if content != "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" must not include content") + } + if spec.IsDone != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" must not include is_done") + } + item["todo_id"] = todoID + } + return item, nil +} + +func minutesTodoError(err error, minuteToken string) error { + p, ok := errs.ProblemOf(err) + if !ok || p.Code != minutesTodoNoEditPermissionCode { + return err + } + 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" + return err +} diff --git a/shortcuts/minutes/minutes_update_test.go b/shortcuts/minutes/minutes_update_test.go index 8c7d5e00dc..2226493674 100644 --- a/shortcuts/minutes/minutes_update_test.go +++ b/shortcuts/minutes/minutes_update_test.go @@ -32,7 +32,7 @@ func TestMinutesUpdate_Validate(t *testing.T) { }, { name: "missing topic", - args: []string{"+update", "--minute-token", "obcn123456", "--as", "user"}, + args: []string{"+update", "--minute-token", minutesUpdateTestToken, "--as", "user"}, wantErr: "required flag(s) \"topic\" not set", }, } diff --git a/shortcuts/minutes/minutes_word_replace.go b/shortcuts/minutes/minutes_word_replace.go new file mode 100644 index 0000000000..fc079872d2 --- /dev/null +++ b/shortcuts/minutes/minutes_word_replace.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + minutesWordReplaceNoEditPermission = 40005 + minutesWordReplaceOthersEditing = 40110 + minutesWordReplaceInvalidParams = 40001 +) + +type transcriptWordReplace struct { + SourceWord string `json:"source_word"` + TargetWord string `json:"target_word"` +} + +// MinutesWordReplace batch-replaces words in a minute's transcript. +var MinutesWordReplace = common.Shortcut{ + Service: "minutes", + Command: "+word-replace", + Description: "Batch replace words in a minute's transcript", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + { + Name: "replace-words", + Desc: `JSON array of replacements, e.g. [{"source_word":"old","target_word":"new"}]`, + Required: true, + Input: []string{common.File, common.Stdin}, + }, + }, + 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") + } + if _, err := parseReplaceWords(runtime.Str("replace-words")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + replaceWords, _ := parseReplaceWords(runtime.Str("replace-words")) + return common.NewDryRunAPI(). + PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/word", validate.EncodePathSegment(minuteToken))). + Body(map[string]interface{}{ + "minute_token": minuteToken, + "replace_words": replaceWords, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + replaceWords, err := parseReplaceWords(runtime.Str("replace-words")) + if err != nil { + return err + } + + body := map[string]interface{}{ + "minute_token": minuteToken, + "replace_words": replaceWords, + } + + _, err = runtime.CallAPITyped(http.MethodPut, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/word", validate.EncodePathSegment(minuteToken)), + nil, body) + if err != nil { + return minutesWordReplaceError(err, minuteToken) + } + + outData := map[string]interface{}{ + "minute_token": minuteToken, + "replace_words": replaceWords, + } + + runtime.OutFormat(outData, nil, nil) + return nil + }, +} + +func parseReplaceWords(raw string) ([]map[string]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: value is required").WithParam("--replace-words") + } + + var items []transcriptWordReplace + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: must be a JSON array of {source_word,target_word} objects: %v", err).WithParam("--replace-words").WithCause(err) + } + if len(items) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: must include at least one replacement").WithParam("--replace-words") + } + + replaceWords := make([]map[string]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for i, item := range items { + sourceWord := strings.TrimSpace(item.SourceWord) + if sourceWord == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: item %d: source_word is required", i).WithParam("--replace-words") + } + if _, exists := seen[sourceWord]; exists { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: duplicate source_word %q", sourceWord).WithParam("--replace-words") + } + seen[sourceWord] = struct{}{} + replaceWords = append(replaceWords, map[string]string{ + "source_word": sourceWord, + "target_word": item.TargetWord, + }) + } + return replaceWords, nil +} + +func minutesWordReplaceError(err error, minuteToken string) error { + p, ok := errs.ProblemOf(err) + if !ok { + return err + } + + switch p.Code { + 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" + case minutesWordReplaceOthersEditing: + p.Subtype = errs.SubtypeConflict + p.Message = fmt.Sprintf("Minute %q transcript is being edited by someone else.", minuteToken) + p.Hint = "Wait until the other editor finishes, then retry" + case minutesWordReplaceInvalidParams: + if strings.Contains(strings.ToLower(p.Message), "not found in transcript") { + p.Subtype = errs.SubtypeNotFound + p.Message = fmt.Sprintf("None of the source words were found in minute %q transcript; nothing was replaced.", minuteToken) + p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use vc +notes to read it), then retry" + } + } + + return err +} diff --git a/shortcuts/minutes/minutes_word_replace_test.go b/shortcuts/minutes/minutes_word_replace_test.go new file mode 100644 index 0000000000..589ca2e8cf --- /dev/null +++ b/shortcuts/minutes/minutes_word_replace_test.go @@ -0,0 +1,283 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "encoding/json" + "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 minutesWordReplaceTestToken = "obcnexampleminute" + +func TestMinutesWordReplace_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{"+word-replace", "--replace-words", `[{"source_word":"a","target_word":"b"}]`, "--as", "user"}, + wantErr: "required flag(s) \"minute-token\" not set", + }, + { + name: "missing replace words", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--as", "user"}, + wantErr: "required flag(s) \"replace-words\" not set", + }, + { + name: "invalid json", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--replace-words", "not-json", "--as", "user"}, + wantErr: "JSON array", + }, + { + name: "empty source word", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--replace-words", `[{"source_word":"","target_word":"b"}]`, "--as", "user"}, + wantErr: "source_word is required", + }, + { + name: "duplicate source word", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--replace-words", `[{"source_word":"a","target_word":"b"},{"source_word":"a","target_word":"c"}]`, "--as", "user"}, + wantErr: "duplicate source_word", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesWordReplace.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 TestMinutesWordReplace_DryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--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/"+minutesWordReplaceTestToken+"/transcript/word") { + t.Errorf("expected word endpoint, got:\n%s", out) + } + if !strings.Contains(out, "foo") || !strings.Contains(out, "bar") { + t.Errorf("expected replace_words in body, got:\n%s", out) + } +} + +func TestMinutesWordReplace_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/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--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"` + ReplaceWords []struct { + SourceWord string `json:"source_word"` + TargetWord string `json:"target_word"` + } `json:"replace_words"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Data.MinuteToken != minutesWordReplaceTestToken { + t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesWordReplaceTestToken) + } + if len(envelope.Data.ReplaceWords) != 1 || envelope.Data.ReplaceWords[0].SourceWord != "foo" || envelope.Data.ReplaceWords[0].TargetWord != "bar" { + t.Errorf("data.replace_words = %#v, want foo->bar", envelope.Data.ReplaceWords) + } +} + +func TestMinutesWordReplace_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/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceNoEditPermission, + "msg": "permission deny", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypePermissionDenied { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied) + } +} + +func TestMinutesWordReplace_OthersAreEditing(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/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceOthersEditing, + "msg": "others are editing", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected others-are-editing error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeConflict { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeConflict) + } +} + +func TestMinutesWordReplace_WordsNotFound(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/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceInvalidParams, + "msg": "replace words not found in transcript", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected words-not-found error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeNotFound { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeNotFound) + } + if !strings.Contains(p.Message, minutesWordReplaceTestToken) { + t.Errorf("message should include minute token, got: %s", p.Message) + } + if !strings.Contains(p.Hint, "source_word") { + t.Errorf("hint should mention source_word, got: %s", p.Hint) + } +} + +// A generic 40001 without the transcript marker must NOT be rewritten as +// words_not_found; it should surface as the original invalid-params error. +func TestMinutesWordReplace_GenericInvalidParamsNotRewritten(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/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceInvalidParams, + "msg": "Invalid Params", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected invalid-params error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype == errs.SubtypeNotFound && strings.Contains(p.Message, "None of the source words were found") { + t.Fatalf("generic 40001 must not be rewritten as not_found, got subtype=%q message=%q", p.Subtype, p.Message) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index 75d0a70b9a..283eca7f88 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -12,6 +12,9 @@ func Shortcuts() []common.Shortcut { MinutesDownload, MinutesUpload, MinutesUpdate, + MinutesSummary, + MinutesTodo, MinutesSpeakerReplace, + MinutesWordReplace, } } diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index ac464ee297..f4cbfb4d86 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -82,13 +82,12 @@ metadata: > **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 -### 4. 获取妙记的逐字稿、总结、待办、章节 +### 4. 读取妙记的逐字稿、总结、待办、章节(只读) -1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。 -2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。 -3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。 -4. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",也支持;此时应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。 -5. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。 +1. 当用户要**查看 / 读取**"这个妙记的逐字稿""总结""待办""章节"时,使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 +2. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。 +3. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。 +4. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。 ```bash # 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节) @@ -96,6 +95,7 @@ lark-cli vc +notes --minute-tokens ``` > **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供 +> **读 vs 写**:`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)。 ### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿) @@ -110,6 +110,46 @@ lark-cli vc +notes --minute-tokens > > **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。 +### 6. 编辑妙记的 AI 待办与 AI 总结(写入) + +当用户要在**某条妙记内**操作 AI 待办或 AI 总结时使用本节。**不是**飞书任务(Task)清单里的待办。 + +**触发信号(任一命中即走本 skill,禁止走 lark-task)**: + +- "在(某条)妙记里新建 / 添加 / 修改 / 删除待办" +- "把妙记 A 的待办改成已完成 / 未完成" +- "妙记里的任务1 / 任务2"(上下文已明确是妙记) +- 已给出 `minute_token` 或妙记 URL,且要改待办 / 总结 + +**妙记 AI 待办 vs 飞书任务 Task**: + +| 用户意图 | 正确命令 | 错误命令 | +|---------|---------|---------| +| 妙记里加待办 | `minutes +todo --operation add` 或 `--todos '[...]'` | `task +create` / `task tasklists list` | +| 妙记里改待办 | `minutes +todo --operation update --todo-id ...` | `task +update` | +| 妙记里删待办 | `minutes +todo --operation delete --todo-id ...` | `task tasks delete` | +| 我的任务清单 | — | 走 [lark-task](../lark-task/SKILL.md) | + +**新建多条待办**:优先用 `--todos` 一次提交;单条则用多次 `--operation add`: + +```bash +# 批量:任务1 已完成 + 任务2 未完成 +lark-cli minutes +todo --minute-token --as user --todos '[ + {"operation":"add","content":"晚上好1","is_done":true}, + {"operation":"add","content":"晚上好2","is_done":false} +]' +``` + +**更新 / 删除前**:先用 `vc +notes --minute-tokens ` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。 + +**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。 + +**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `vc +notes --minute-tokens ` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。 + +**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。 + +> 使用 `+todo` 前必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);使用 `+summary` 前必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md)。 + ## 资源关系 ```text @@ -118,24 +158,51 @@ Minutes (妙记) ← minute_token 标识 └── MediaFile (音频/视频文件) → minutes +download ``` -> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件、上传音视频生成妙记**。 +> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载/上传音视频、编辑妙记 AI 待办与 AI 总结、重命名、逐字稿说话人/关键词替换**。 > > **路由规则**: > > - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search` > - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill > - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token` -> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要的是逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens` +> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要**读取**逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens` > - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准 > - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果 > - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限 > - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get` > - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download` -> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) +> - 用户要**读取**"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) +> - 用户要在**妙记内新建 / 修改 / 删除 AI 待办**(含「妙记里加待办」「任务1 已完成」等)→ [`minutes +todo`](references/lark-minutes-todo.md),**禁止**走 lark-task +> - 用户要**替换妙记 AI 总结全文** → [`minutes +summary`](references/lark-minutes-summary.md) > - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload` > - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens` > - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update` > - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace` +> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace` + +## Shortcuts(推荐优先使用) + +Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。 + +| Shortcut | 说明 | +| -------------------------------------------------- | --------------------------------------------------------------- | +| [`+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) | +| [`+summary`](references/lark-minutes-summary.md) | Replace the full AI summary text of a minute | +| [`+todo`](references/lark-minutes-todo.md) | Add, update, or delete **AI todo(s) inside a minute** (single or batch via `--todos`; not Feishu Task) | + +- 使用 `+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,不支持姓名)。 +- 使用 `+summary` 命令时,必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md),了解全文替换参数。 +- 使用 `+todo` 命令时,必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md),了解单条与 `--todos` 批量模式;**不要**用 lark-task。 + + ## API Resources diff --git a/skills/lark-minutes/references/lark-minutes-summary.md b/skills/lark-minutes/references/lark-minutes-summary.md new file mode 100644 index 0000000000..e7bc71344e --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-summary.md @@ -0,0 +1,122 @@ +# minutes +summary + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +替换妙记的 AI 总结内容。写操作,会覆盖当前总结。 + +本 skill 对应 shortcut:`lark-cli minutes +summary`(调用 `PUT /open-apis/minutes/v1/minutes/{minute_token}/summary`)。 + +## 典型触发表达 + +- "把这条妙记的总结改成……" +- "更新 / 替换妙记的 AI 总结" +- "修正总结内容后写回妙记" + +## 命令 + +```bash +# 直接传入总结内容(Markdown 子集) +lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary "**会议结论**\n- 方案 A 通过\n- 下周跟进排期" + +# 从文件读取总结内容 +lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @summary.md + +# 从 stdin 读取 +echo "**结论**" | lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @- + +# 预览 API 调用 +lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @summary.md --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记 Token | +| `--summary ` | 是 | 替换后的总结内容,支持 `@file` / `@-`(stdin) | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 核心约束 + +### 1. 先读后写 + +替换前建议先用 `lark-cli vc +notes --minute-tokens ` 读取当前总结,确认 `minute_token` 与待替换内容无误。 + +### 2. Markdown 展示说明 + +接口接受任意总结文本,**不会因 Markdown 格式校验失败而拒绝请求**。妙记客户端通常只能良好渲染以下 Markdown 子集;不支持的语法(如链接、代码块、四级标题等)会**按原始文本展示**(保留 Markdown 标记字符,不会渲染成对应样式)。Agent 写入时应优先使用可展示语法,避免用户在妙记里看到字面量的 `[链接](url)`、`` `code` `` 等: + +| 支持 | 写法 | 示例 | +|------|------|------| +| 纯文本 | 普通段落 | `本次会议讨论了 Q2 预算` | +| 换行 | `\n` 或空行 | 分段落书写 | +| 一级标题 | `# ` + 标题文字 | `# 会议结论` | +| 二级标题 | `## ` + 标题文字 | `## 行动项` | +| 三级标题 | `### ` + 标题文字 | `### 跟进事项` | +| 加粗 | `**文字**` | `**重点结论**` | +| 无序列表 | `- ` 或 `* ` | `- 跟进预算审批` | +| 有序列表 | `1. ` | `1. 确认需求` | + +> 标题语法建议:`#` 后保留空格,并优先使用 1~3 级(`#` / `##` / `###`)。四级及以上(`####`)无法渲染,会以原始文本形式展示。 + +**不建议使用**(会按原始文本展示):链接、图片、代码块、表格、引用块、斜体、删除线、四级及以上标题等。 + +合法示例: + +```markdown +# 会议结论 + +## 核心讨论 + +**方案 A 通过**,下周启动排期。 + +### 待跟进 +- 预算审批 +- 排期确认 + +1. 张三负责预算 +2. 李四负责排期 +``` + +### 3. 所需权限 + +| 身份 | 所需权限 | +|------|---------| +| user | `minutes:minutes:update` | + +## 输出结果 + +```json +{ + "minute_token": "obcnxxxxxxxxxxxxxxxxxxxx", + "updated": true +} +``` + +| 字段 | 说明 | +|------|------| +| `minute_token` | 妙记 Token | +| `updated` | 是否已成功更新 | + +## 如何获取 minute_token + +| 来源 | 获取方式 | +|------|---------| +| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` | +| 妙记搜索 | `lark-cli minutes +search --query "关键词"` | +| 会议产物查询 | `lark-cli vc +notes --minute-tokens ` | + +## 常见错误与排查 + +| 错误现象 | 错误码 | 根本原因 | 解决方案 | +|---------|--------|---------|---------| +| 总结展示为原始 Markdown 文本 | — | 总结含链接、四级标题等妙记端无法渲染的语法 | 改用标题(#~###)、加粗、列表等可展示格式;接口不会因此报错 | +| 参数无效 | — | `minute_token` 缺失或格式错误 | 检查 token 是否完整 | +| 权限不足 | — | 缺少 `minutes:minutes:update` | 运行 `auth login --scope "minutes:minutes:update"` | + +## 参考 + +- [lark-minutes](../SKILL.md) — 妙记全部命令 +- [minutes +todo](lark-minutes-todo.md) — 替换待办项 +- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-minutes/references/lark-minutes-todo.md b/skills/lark-minutes/references/lark-minutes-todo.md new file mode 100644 index 0000000000..7a50656b06 --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-todo.md @@ -0,0 +1,138 @@ +# minutes +todo + +> **路由**:本命令操作**妙记内的 AI 待办**,不是飞书任务(Task)。用户说「在妙记里新建待办」时**必须**用本命令,**禁止**走 `lark-cli task` / `tasklists list` / `task +create`。详见 [lark-minutes/SKILL.md](../SKILL.md) 第 6 节。 + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +对妙记中的待办做新增 / 更新 / 删除(单条或批量)。写操作。 + +本 skill 对应 shortcut:`lark-cli minutes +todo`(调用 `POST /open-apis/minutes/v1/minutes/{minute_token}/todo`)。 + +## 典型触发表达 + +- "给这条妙记加一条/多条待办" +- "把某条待办改成……" +- "标记某条待办为已完成 / 取消完成" +- "删除某条待办" + +## 命令 + +**单条模式**:`--operation` + 对应字段。 +**批量模式**:`--todos` JSON 数组(与单条 flags 互斥),一次请求可混合 `add` / `update` / `delete`。 + +```bash +# 单条:新增 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add --todo "跟进预算审批" --is-done=false --as user + +# 批量:一次新增两条 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --as user --todos '[ + {"operation":"add","content":"晚上好1","is_done":true}, + {"operation":"add","content":"晚上好2","is_done":false} +]' + +# 批量:混合增删改 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --as user --todos '[ + {"operation":"add","content":"新待办","is_done":false}, + {"operation":"update","todo_id":"1234567890","content":"已更新","is_done":true}, + {"operation":"delete","todo_id":"9876543210"} +]' + +# 从文件读取 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --as user --todos @todos.json + +# 单条:更新 / 删除 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation update --todo-id 1234567890 --todo "整理会议纪要" --is-done --as user +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation delete --todo-id 1234567890 --as user + +# 预览 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add --todo "新待办" --is-done --dry-run --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记 Token | +| `--operation ` | 单条模式 | `add` / `update` / `delete`;与 `--todos` 互斥 | +| `--todo ` | 单条 add/update | 待办纯文本 | +| `--is-done` | 单条 add/update | `--is-done` = true,`--is-done=false` = false | +| `--todo-id ` | 单条 update/delete | 已有待办 id | +| `--todos ` | 批量模式 | JSON 数组,支持 `@file` / `@-`;与单条 flags 互斥 | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 单条模式 + +| `--operation` | 必填参数 | 禁止参数 | +|---------------|----------|----------| +| `add` | `--todo` + `--is-done` | `--todo-id` | +| `update` | `--todo-id` + `--todo` + `--is-done` | — | +| `delete` | `--todo-id` | `--todo`、`--is-done` | + +## 批量模式:`--todos` + +每条元素字段与 API `todo_items[]` 一致: + +| JSON 字段 | add | update | delete | +|-----------|-----|--------|--------| +| `operation` | 必填 | 必填 | 必填 | +| `content` | 必填 | 必填 | 禁止 | +| `is_done` | 必填 | 必填 | 禁止 | +| `todo_id` | 禁止 | 必填 | 必填 | + +示例 `todos.json`: + +```json +[ + {"operation": "add", "content": "晚上好1", "is_done": true}, + {"operation": "add", "content": "晚上好2", "is_done": false} +] +``` + +数组顺序会原样写入请求体;端上展示顺序仍可能受完成状态分组影响。 + +## 核心约束 + +### 1. 先读后写,待办 id 如何获取 + +更新 / 删除前先用 `lark-cli vc +notes --minute-tokens ` 读取当前待办。返回的每条待办带 `todo_id` 字段。 + +> 待办 id 仅用于程序内部定位,不必展示给用户。 + +### 2. 待办内容为纯文本 + +`content` **不是 Markdown**,请直接传入待办描述文字。 + +### 3. 所需权限 + +| 身份 | 所需 scope | +|------|-----------| +| user | `minutes:minutes:update` | + +## 输出结果 + +```json +{ + "minute_token": "obcnxxxxxxxxxxxxxxxxxxxx", + "count": 2, + "updated": true +} +``` + +单条模式额外包含 `"operation": "add"`。 + +## 常见错误与排查 + +| 错误现象 | 解决方案 | +|---------|---------| +| 未指定操作 | 单条模式传 `--operation`,或批量传 `--todos` | +| `--todos` 与单条 flags 冲突 | 二选一 | +| `todos[i]` 校验失败 | 检查该条 `operation` 与字段组合 | +| `error.type` = `no_edit_permission` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` | +| 缺少 OAuth scope(`permission_violations` 含 `minutes:minutes:update`) | `lark-cli auth login --scope "minutes:minutes:update"` | + +## 参考 + +- [lark-minutes](../SKILL.md) +- [minutes +summary](lark-minutes-summary.md) +- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) +- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index 6d3d895113..bde2a93622 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -16,7 +16,11 @@ metadata: > **任务清单搜索技巧**:任务清单也遵循同样的判断逻辑。先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**清单查询关键字**(例如清单名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了清单查询关键字,则优先使用 `+tasklist-search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),并且使用搜索或原生列取清单都能达到目的时,应优先使用原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。 > **意图区分补充**:像“搜索飞书中今年以来我关注的任务”这类表达,虽然字面带有“搜索”,但如果没有真正的查询关键字,且本质是在限定“与我相关 + 时间范围”,则应优先走 `+get-related-tasks`;像“搜索飞书中由我创建的任务清单”这类表达,如果没有清单关键字,且本质是在限定“清单范围 + 创建者”,则应优先走原生 `tasklists.list` 后筛选,而不是直接走搜索型 shortcut。 > **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。 -> **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 +> **术语理解 — 待办 disambiguation(必读)**: +> - 用户提到「待办 / todo / 任务」时,**先判断归属**,不要默认走本 skill。 +> - **走 [lark-minutes](../lark-minutes/SKILL.md) 的 `minutes +todo`**(禁止本 skill):上下文含 **妙记 / 会议纪要 / minute_token / 妙记 URL**(`/minutes/`);或「在某某妙记里新建/修改待办」「妙记 AI 待办」「会议录制里的待办」。 +> - **走本 skill(lark-task)**:任务清单、分配给我、项目待办、截止日期/提醒、子任务、任务清单成员;或 applink 含 `client/todo/task?guid=`;或明确说「飞书任务」「任务中心」「我的任务清单」。 +> - **禁止**:用户要在妙记里加待办时,**不要**调用 `task tasklists list`、`task +create` 或任何 task 命令去「找清单再放任务」。 > **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。 > **创建/更新注意**: diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md index a819c87cd6..ccf83c99b0 100644 --- a/skills/lark-vc/references/lark-vc-notes.md +++ b/skills/lark-vc/references/lark-vc-notes.md @@ -95,7 +95,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | 字段 | 说明 | |------|------| | `artifacts.summary` | AI 总结(JSON 内联) | -| `artifacts.todos` | 待办事项(JSON 内联) | +| `artifacts.todos` | 待办事项(JSON 内联,**只读**);每条含 `content`、`is_done` 及 `todo_id`。`todo_id` 仅供 [`minutes +todo`](../../lark-minutes/references/lark-minutes-todo.md) 更新/删除待办时使用,不必展示给用户。**新建**妙记内待办请用 `minutes +todo`,不要用 lark-task | | `artifacts.chapters` | 章节纪要(JSON 内联) | | `artifacts.keywords` | 妙记推荐关键词(JSON 内联) | | `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` | diff --git a/tests/cli_e2e/minutes/minutes_word_replace_test.go b/tests/cli_e2e/minutes/minutes_word_replace_test.go new file mode 100644 index 0000000000..2ba739d500 --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_word_replace_test.go @@ -0,0 +1,39 @@ +// 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 TestMinutesWordReplace_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", "+word-replace", + "--minute-token", "obcnexampleminute", + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--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/word"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "foo"), "dry-run should contain source_word, got: %s", output) + assert.True(t, strings.Contains(output, "bar"), "dry-run should contain target_word, got: %s", output) +}