Compare commits

...

1 Commits

Author SHA1 Message Date
zhangjun.1
3669313759 feat: replace words for transcript 2026-06-03 21:24:55 +08:00
12 changed files with 1462 additions and 13 deletions

View File

@@ -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/internal/output"
"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 <token>` 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 output.ErrValidation("--minute-token is required")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
}
summary := strings.TrimSpace(runtime.Str("summary"))
if summary == "" {
return output.ErrValidation("--summary is required")
}
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": "<summary markdown>"})
},
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.CallAPI(http.MethodPut, path, nil, body); err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"minute_token": minuteToken,
"updated": true,
}, nil, nil)
return nil
},
}

View File

@@ -0,0 +1,371 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
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", "obcn123456789",
"--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/obcn123456789/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", "obcn123456789",
"--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/obcn123456789/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", "obcn123456789",
"--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", "obcn123456789",
"--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", "obcn123456789",
"--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("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--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("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--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("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--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", "obcn123456789",
"--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", "obcn123456789",
"--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("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--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("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--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", "obcn123456789",
"--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", "obcn123456789", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected validation error when --operation is not provided")
}
}
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")
}

View File

@@ -0,0 +1,280 @@
// 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/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// 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 <id> --todo \"...\" --is-done`.",
"Delete: `--operation delete --todo-id <id>`.",
"`content` is plain text only; markdown formatting is not supported.",
"Use `lark-cli vc +notes --minute-tokens <token>` to read current todos before writing.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := 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 _, 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": "<todo_items array>",
})
}
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.CallAPI(http.MethodPost, path, nil, body); err != nil {
return err
}
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, output.ErrValidation("use either --todos for batch or single-item flags (--operation, --todo, --is-done, --todo-id), not both")
}
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, output.ErrValidation("--todos: %s", err)
}
if len(specs) == 0 {
return nil, output.ErrValidation("--todos must contain at least one todo item")
}
ops := make([]minuteTodoOp, 0, len(specs))
for i, spec := range specs {
item, err := buildMinuteTodoItem(spec)
if err != nil {
return nil, output.ErrValidation("todos[%d]: %s", i, 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, fmt.Errorf("value is empty")
}
var specs []minuteTodoSpec
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
return nil, fmt.Errorf("invalid JSON array: %w", 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, output.ErrValidation("--operation is required for single-item mode (or use --todos for batch)")
}
spec := minuteTodoSpec{Operation: operation}
switch operation {
case "add":
if !hasTodo || !hasIsDone {
return nil, output.ErrValidation("operation \"add\" requires --todo and --is-done")
}
if hasTodoID {
return nil, output.ErrValidation("operation \"add\" does not accept --todo-id (it creates a new todo)")
}
done := runtime.Bool("is-done")
spec.Content = todo
spec.IsDone = &done
case "update":
if !hasTodoID || !hasTodo || !hasIsDone {
return nil, output.ErrValidation("operation \"update\" requires --todo-id, --todo and --is-done")
}
done := runtime.Bool("is-done")
spec.TodoID = todoID
spec.Content = todo
spec.IsDone = &done
case "delete":
if !hasTodoID {
return nil, output.ErrValidation("operation \"delete\" requires --todo-id")
}
if hasTodo || hasIsDone {
return nil, output.ErrValidation("operation \"delete\" only accepts --todo-id (omit --todo and --is-done)")
}
spec.TodoID = todoID
default:
return nil, output.ErrValidation("--operation is required, allowed: add, update, delete")
}
item, err := buildMinuteTodoItem(spec)
if err != nil {
return nil, output.ErrValidation("%s", 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, fmt.Errorf("operation is required")
}
if operation != "add" && operation != "update" && operation != "delete" {
return nil, fmt.Errorf("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, fmt.Errorf("operation \"add\" does not accept todo_id")
}
if content == "" {
return nil, fmt.Errorf("operation \"add\" requires content")
}
if spec.IsDone == nil {
return nil, fmt.Errorf("operation \"add\" requires is_done")
}
item["content"] = content
item["is_done"] = *spec.IsDone
case "update":
if todoID == "" {
return nil, fmt.Errorf("operation \"update\" requires todo_id")
}
if content == "" {
return nil, fmt.Errorf("operation \"update\" requires content")
}
if spec.IsDone == nil {
return nil, fmt.Errorf("operation \"update\" requires is_done")
}
item["todo_id"] = todoID
item["content"] = content
item["is_done"] = *spec.IsDone
case "delete":
if todoID == "" {
return nil, fmt.Errorf("operation \"delete\" requires todo_id")
}
if content != "" {
return nil, fmt.Errorf("operation \"delete\" must not include content")
}
if spec.IsDone != nil {
return nil, fmt.Errorf("operation \"delete\" must not include is_done")
}
item["todo_id"] = todoID
}
return item, nil
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
minutesWordReplaceNoEditPermission = 40005
minutesWordReplaceOthersEditing = 40110
)
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 output.ErrValidation("--minute-token is required")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := parseReplaceWords(runtime.Str("replace-words")); err != nil {
return output.ErrValidation("--replace-words: %s", 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 output.ErrValidation("--replace-words: %s", err)
}
body := map[string]interface{}{
"minute_token": minuteToken,
"replace_words": replaceWords,
}
_, err = runtime.CallAPI(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, errors.New("value is required")
}
var items []transcriptWordReplace
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, fmt.Errorf("must be a JSON array of {source_word,target_word} objects: %v", err)
}
if len(items) == 0 {
return nil, errors.New("must include at least one replacement")
}
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, fmt.Errorf("item %d: source_word is required", i)
}
if _, exists := seen[sourceWord]; exists {
return nil, fmt.Errorf("duplicate source_word %q", sourceWord)
}
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 {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
switch exitErr.Detail.Code {
case minutesWordReplaceNoEditPermission:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_edit_permission",
Code: minutesWordReplaceNoEditPermission,
Message: fmt.Sprintf("No edit permission for minute %q: cannot replace transcript words.", minuteToken),
Hint: "Ask the minute owner for minute edit permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
case minutesWordReplaceOthersEditing:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "others_are_editing",
Code: minutesWordReplaceOthersEditing,
Message: fmt.Sprintf("Minute %q transcript is being edited by someone else.", minuteToken),
Hint: "Wait until the other editor finishes, then retry",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
}
return err
}

View File

@@ -0,0 +1,210 @@
// 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 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", "obcn123456", "--as", "user"},
wantErr: "required flag(s) \"replace-words\" not set",
},
{
name: "invalid json",
args: []string{"+word-replace", "--minute-token", "obcn123456", "--replace-words", "not-json", "--as", "user"},
wantErr: "JSON array",
},
{
name: "empty source word",
args: []string{"+word-replace", "--minute-token", "obcn123456", "--replace-words", `[{"source_word":"","target_word":"b"}]`, "--as", "user"},
wantErr: "source_word is required",
},
{
name: "duplicate source word",
args: []string{"+word-replace", "--minute-token", "obcn123456", "--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")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "no_edit_permission" {
t.Fatalf("expected no_edit_permission detail, got %#v", exitErr.Detail)
}
}
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")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "others_are_editing" {
t.Fatalf("expected others_are_editing detail, got %#v", exitErr.Detail)
}
}

View File

@@ -12,6 +12,9 @@ func Shortcuts() []common.Shortcut {
MinutesDownload,
MinutesUpload,
MinutesUpdate,
MinutesSummary,
MinutesTodo,
MinutesSpeakerReplace,
MinutesWordReplace,
}
}

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物6.更新妙记标题重命名妙记7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取/编辑妙记 AI 产物总结、待办、章节5.上传音视频生成妙记也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物6.更新妙记标题重命名妙记7.替换妙记逐字稿中的说话人8.在指定妙记中新增/更新/删除 AI 待办minutes +todo不是飞书任务 Task。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
metadata:
requires:
bins: ["lark-cli"]
@@ -46,20 +46,19 @@ 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 获取纪要产物(逐字稿、总结、待办、章节)
lark-cli vc +notes --minute-tokens <minute_token>
```
> **跨 skill 路由**逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
> **读 vs 写**`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)
### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
@@ -74,6 +73,42 @@ lark-cli vc +notes --minute-tokens <minute_token>
>
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `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 <token> --as user --todos '[
{"operation":"add","content":"晚上好1","is_done":true},
{"operation":"add","content":"晚上好2","is_done":false}
]'
```
**更新 / 删除前**:先用 `vc +notes --minute-tokens <token>` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
**替换 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
@@ -82,24 +117,27 @@ 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推荐优先使用
@@ -112,12 +150,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`
| [`+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。
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
@@ -143,5 +185,7 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
| `+download` | `minutes:minutes.media:export` |
| `+update` | `minutes:minutes:update` |
| `+speaker-replace` | `minutes:minutes:update` |
| `+summary` | `minutes:minutes:update` |
| `+todo` | `minutes:minutes:update` |
<!-- AUTO-GENERATED-END -->

View File

@@ -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>` | 是 | 妙记 Token |
| `--summary <text>` | 是 | 替换后的总结内容,支持 `@file` / `@-`stdin |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 先读后写
替换前建议先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前总结,确认 `minute_token` 与待替换内容无误。
### 2. Markdown 展示说明
接口接受任意总结文本,**不会因 Markdown 格式校验失败而拒绝请求**。妙记客户端通常只能良好渲染以下 Markdown 子集;不支持的语法(如链接、代码块、四级标题等)会**按原始文本展示**(保留 Markdown 标记字符不会渲染成对应样式。Agent 写入时应优先使用可展示语法,避免用户在妙记里看到字面量的 `[链接](url)`、`` `code` `` 等:
| 支持 | 写法 | 示例 |
|------|------|------|
| 纯文本 | 普通段落 | `本次会议讨论了 Q2 预算` |
| 换行 | `\n` 或空行 | 分段落书写 |
| 一级标题 | `# ` + 标题文字 | `# 会议结论` |
| 二级标题 | `## ` + 标题文字 | `## 行动项` |
| 三级标题 | `### ` + 标题文字 | `### 跟进事项` |
| 加粗 | `**文字**` | `**重点结论**` |
| 无序列表 | `- ` 或 `* ` | `- 跟进预算审批` |
| 有序列表 | `1. ` | `1. 确认需求` |
> 标题语法建议:`#` 后保留空格,并优先使用 13 级(`#` / `##` / `###`)。四级及以上(`####`)无法渲染,会以原始文本形式展示。
**不建议使用**(会按原始文本展示):链接、图片、代码块、表格、引用块、斜体、删除线、四级及以上标题等。
合法示例:
```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 <token>` |
## 常见错误与排查
| 错误现象 | 错误码 | 根本原因 | 解决方案 |
|---------|--------|---------|---------|
| 总结展示为原始 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) — 认证和全局参数

View File

@@ -0,0 +1,137 @@
# 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>` | 是 | 妙记 Token |
| `--operation <op>` | 单条模式 | `add` / `update` / `delete`;与 `--todos` 互斥 |
| `--todo <text>` | 单条 add/update | 待办纯文本 |
| `--is-done` | 单条 add/update | `--is-done` = true`--is-done=false` = false |
| `--todo-id <id>` | 单条 update/delete | 已有待办 id |
| `--todos <json>` | 批量模式 | 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 <token>` 读取当前待办。返回的每条待办带 `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` 与字段组合 |
| 权限不足 | `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)

View File

@@ -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 待办」「会议录制里的待办」。
> - **走本 skilllark-task**:任务清单、分配给我、项目待办、截止日期/提醒、子任务、任务清单成员;或 applink 含 `client/todo/task?guid=`;或明确说「飞书任务」「任务中心」「我的任务清单」。
> - **禁止**:用户要在妙记里加待办时,**不要**调用 `task tasklists list`、`task +create` 或任何 task 命令去「找清单再放任务」。
> **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。
> **创建/更新注意**

View File

@@ -91,7 +91,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` |

View File

@@ -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)
}