mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
Compare commits
3 Commits
backup/fea
...
v1.0.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bfb80951d | ||
|
|
639259fbfd | ||
|
|
0bdd7de807 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
|
||||
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
|
||||
- **platform**: Support multiple policy rules per plugin (#1182)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
|
||||
- **whiteboard**: Fix whiteboard skill (#1180)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Update login hint and split-flow docs (#1201)
|
||||
|
||||
## [v1.0.44] - 2026-05-29
|
||||
|
||||
### Features
|
||||
@@ -948,6 +964,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
||||
|
||||
@@ -279,7 +279,13 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
|
||||
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
|
||||
"**Display order:** Output the URL first, then place the QR code image below the URL." +
|
||||
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
|
||||
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
|
||||
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
|
||||
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
|
||||
@@ -1042,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
"After the user confirms authorization in a later step",
|
||||
"lark-cli auth login --device-code device-code",
|
||||
"come back and notify",
|
||||
"YOU must execute",
|
||||
"lark-cli auth login --device-code <device_code>",
|
||||
"Do NOT cache",
|
||||
"lark-cli auth login --no-wait --json",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.44",
|
||||
"version": "1.0.45",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@ func Shortcuts() []common.Shortcut {
|
||||
MinutesDownload,
|
||||
MinutesUpload,
|
||||
MinutesUpdate,
|
||||
MinutesSummary,
|
||||
MinutesTodo,
|
||||
MinutesSpeakerReplace,
|
||||
MinutesWordReplace,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import (
|
||||
var (
|
||||
scopesMeetingIDs = []string{
|
||||
"vc:meeting.meetingevent:read",
|
||||
"vc:note:read",
|
||||
"vc:record:readonly",
|
||||
}
|
||||
scopesMinuteTokens = []string{
|
||||
"minutes:minutes:readonly",
|
||||
@@ -48,6 +50,7 @@ var (
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar.event:read",
|
||||
"vc:meeting.meetingevent:read",
|
||||
"vc:record:readonly",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -59,6 +62,37 @@ const (
|
||||
|
||||
const logPrefix = "[vc +notes]"
|
||||
|
||||
const (
|
||||
minutesNoReadPermissionCode = 2091005
|
||||
|
||||
// recording API specific error codes (used to surface meeting minute_token state).
|
||||
recordingNotFoundCode = 121004 // 该会议没有妙记文件
|
||||
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
|
||||
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
|
||||
|
||||
// note detail API specific error code.
|
||||
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
|
||||
)
|
||||
|
||||
func minutesReadError(err error, minuteToken string) error {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesNoReadPermissionCode {
|
||||
return err
|
||||
}
|
||||
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "no_read_permission",
|
||||
Code: minutesNoReadPermissionCode,
|
||||
Message: fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken),
|
||||
Hint: "Ask the minute owner for minute file read permission",
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// validMinuteToken matches the server's minute-token format and blocks any
|
||||
// user-supplied token from reaching filesystem paths unsanitized.
|
||||
var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
@@ -196,7 +230,10 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
|
||||
for _, meetingID := range relInfo.MeetingIDs {
|
||||
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
|
||||
noteResult := fetchNoteByMeetingID(ctx, runtime, meetingID)
|
||||
if noteResult["error"] == nil {
|
||||
// success means note detail was retrieved, regardless of whether the
|
||||
// recording API (minute_token) call succeeded — minute_token failures
|
||||
// surface as part of the merged `error` string for downstream visibility.
|
||||
if _, ok := noteResult["note_doc_token"].(string); ok {
|
||||
for k, v := range noteResult {
|
||||
result[k] = v
|
||||
}
|
||||
@@ -246,7 +283,51 @@ func asStringSlice(v any) []string {
|
||||
return ss
|
||||
}
|
||||
|
||||
// fetchNoteByMeetingID queries notes via meeting_id.
|
||||
// fetchMeetingMinuteToken queries the recording API of a meeting and returns
|
||||
// the associated minute_token (parsed from the recording URL) and an
|
||||
// optional human-friendly error message. On success token is non-empty and
|
||||
// errMsg is empty; on failure token is empty and errMsg describes the cause:
|
||||
// - 121004: meeting has no minute file
|
||||
// - 121005: caller has no permission for the meeting recording
|
||||
// - 124002: recording / minute file is still being generated
|
||||
//
|
||||
// Other failures fall back to the raw API error description so Agents can
|
||||
// still parse the underlying cause.
|
||||
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
switch exitErr.Detail.Code {
|
||||
case recordingNotFoundCode:
|
||||
return "", "no minute file for this meeting"
|
||||
case recordingNoPermissionCode:
|
||||
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
|
||||
case recordingGeneratingCode:
|
||||
return "", "minute file is still being generated; please retry later"
|
||||
}
|
||||
}
|
||||
return "", fmt.Sprintf("failed to query recording: %v", err)
|
||||
}
|
||||
|
||||
recording, _ := data["recording"].(map[string]any)
|
||||
if recording == nil {
|
||||
return "", "no recording available for this meeting"
|
||||
}
|
||||
recordingURL, _ := recording["url"].(string)
|
||||
if t := extractMinuteToken(recordingURL); t != "" {
|
||||
return t, ""
|
||||
}
|
||||
return "", "no minute_token found in recording URL"
|
||||
}
|
||||
|
||||
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
|
||||
// the meeting's minute_token via the recording API. The two paths are queried
|
||||
// independently; their failures are merged into a single `error` field
|
||||
// (semicolon-separated) so Agents always see all causes at once. The
|
||||
// `minute_token` field is only populated on success.
|
||||
func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
|
||||
larkcore.QueryParams{"with_participants": []string{"false"}, "query_mode": []string{"0"}}, nil)
|
||||
@@ -259,16 +340,60 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
|
||||
return map[string]any{"meeting_id": meetingID, "error": "meeting not found"}
|
||||
}
|
||||
|
||||
noteID, _ := meeting["note_id"].(string)
|
||||
if noteID == "" {
|
||||
return map[string]any{"meeting_id": meetingID, "error": "no notes available for this meeting"}
|
||||
// Always attempt to query the meeting's minute_token via the recording API,
|
||||
// regardless of whether the meeting has a note_id, so callers always see
|
||||
// minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`).
|
||||
minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
|
||||
var result map[string]any
|
||||
var noteErr string
|
||||
if noteID, _ := meeting["note_id"].(string); noteID != "" {
|
||||
result = fetchNoteDetail(ctx, runtime, noteID)
|
||||
if msg, _ := result["error"].(string); msg != "" {
|
||||
noteErr = msg
|
||||
delete(result, "error")
|
||||
}
|
||||
} else {
|
||||
result = map[string]any{}
|
||||
noteErr = "no notes available for this meeting"
|
||||
}
|
||||
|
||||
result := fetchNoteDetail(ctx, runtime, noteID)
|
||||
result["meeting_id"] = meetingID
|
||||
if minuteToken != "" {
|
||||
result["minute_token"] = minuteToken
|
||||
}
|
||||
if combined := joinErrors(noteErr, minuteErr); combined != "" {
|
||||
result["error"] = combined
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// joinErrors merges multiple non-empty error messages with "; " so Agents can
|
||||
// see all causes at once when both note and minute paths fail.
|
||||
func joinErrors(msgs ...string) string {
|
||||
parts := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m != "" {
|
||||
parts = append(parts, m)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// hasNotesPayload reports whether a result map carries any usable note or
|
||||
// minute payload, irrespective of partial failures surfaced via `error`.
|
||||
func hasNotesPayload(m map[string]any) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range []string{"note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
|
||||
if v, ok := m[k]; ok && v != nil && v != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fetchNoteByMinuteToken queries notes via minute_token.
|
||||
// Fetches both note detail (doc tokens) and AI artifacts (summary/todos/chapters inline +
|
||||
// transcript to file) independently, merging into a single result map for Agent consumption.
|
||||
@@ -277,7 +402,13 @@ func fetchNoteByMinuteToken(ctx context.Context, runtime *common.RuntimeContext,
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
if err != nil {
|
||||
return map[string]any{"minute_token": minuteToken, "error": fmt.Sprintf("failed to query minutes: %v", err)}
|
||||
err = minutesReadError(err, minuteToken)
|
||||
result := map[string]any{"minute_token": minuteToken, "error": err.Error()}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Hint != "" {
|
||||
result["hint"] = exitErr.Detail.Hint
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
minute, _ := data["minute"].(map[string]any)
|
||||
@@ -472,6 +603,10 @@ func extractDocTokens(refs []any) []string {
|
||||
func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code == noteNoPermissionCode {
|
||||
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", exitErr.Detail.Code)}
|
||||
}
|
||||
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
|
||||
}
|
||||
|
||||
@@ -568,8 +703,9 @@ var VCNotes = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}").
|
||||
GET("/open-apis/vc/v1/notes/{note_id}").
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
||||
Set("meeting_ids", common.SplitCSV(ids)).
|
||||
Set("steps", "meeting.get → note_id → note detail API")
|
||||
Set("steps", "meeting.get → note_id → note detail API + recording API → minute_token")
|
||||
}
|
||||
if tokens := runtime.Str("minute-tokens"); tokens != "" {
|
||||
return common.NewDryRunAPI().
|
||||
@@ -586,8 +722,9 @@ var VCNotes = common.Shortcut{
|
||||
POST("/open-apis/calendar/v4/calendars/{calendar_id}/events/mget_instance_relation_info").
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}").
|
||||
GET("/open-apis/vc/v1/notes/{note_id}").
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
||||
Set("calendar_event_ids", common.SplitCSV(ids)).
|
||||
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note detail API")
|
||||
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note detail API + recording API → minute_token")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
@@ -641,11 +778,13 @@ var VCNotes = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// count results
|
||||
// count results: a result counts as "successful" when it carries any
|
||||
// note/minute payload, even if the merged `error` field surfaces a
|
||||
// partial failure (e.g. note ok but minute_token lookup failed).
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
m, _ := r.(map[string]any)
|
||||
if m["error"] == nil {
|
||||
if hasNotesPayload(m) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,3 +728,352 @@ func TestNotes_TranscriptExplicitOutputDir_PreservesLegacyLayout(t *testing.T) {
|
||||
t.Errorf("minutes/ should not be created when --output-dir is explicit")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests for joinErrors / hasNotesPayload (pure helpers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestJoinErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"all empty", []string{"", "", ""}, ""},
|
||||
{"single", []string{"only"}, "only"},
|
||||
{"two non-empty", []string{"a", "b"}, "a; b"},
|
||||
{"skip empties", []string{"", "a", "", "b", ""}, "a; b"},
|
||||
{"three", []string{"x", "y", "z"}, "x; y; z"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := joinErrors(tt.in...); got != tt.want {
|
||||
t.Errorf("joinErrors(%v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasNotesPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty", map[string]any{}, false},
|
||||
{"only meta", map[string]any{"meeting_id": "m1", "error": "fail"}, false},
|
||||
{"empty values", map[string]any{"note_doc_token": "", "minute_token": ""}, false},
|
||||
{"has note_doc_token", map[string]any{"note_doc_token": "doc1"}, true},
|
||||
{"has verbatim_doc_token", map[string]any{"verbatim_doc_token": "v1"}, true},
|
||||
{"has minute_token", map[string]any{"minute_token": "obc"}, true},
|
||||
{"has meeting_notes", map[string]any{"meeting_notes": []string{"d1"}}, true},
|
||||
{"has shared_doc_tokens", map[string]any{"shared_doc_tokens": []string{"s1"}}, true},
|
||||
{"has artifacts", map[string]any{"artifacts": map[string]any{"summary": "s"}}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasNotesPayload(tt.in); got != tt.want {
|
||||
t.Errorf("hasNotesPayload(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests for fetchMeetingMinuteToken — recording API → minute_token mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// recordingStub is a small helper for shaping `/v1/meetings/{id}/recording` responses.
|
||||
func recordingStub(meetingID string, body map[string]any) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/" + meetingID + "/recording",
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func recordingErrStub(meetingID string, code int, msg string) *httpmock.Stub {
|
||||
return recordingStub(meetingID, map[string]any{"code": code, "msg": msg})
|
||||
}
|
||||
|
||||
func recordingOKStub(meetingID, url string) *httpmock.Stub {
|
||||
return recordingStub(meetingID, map[string]any{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]any{
|
||||
"recording": map[string]any{"url": url},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetchMeetingMinuteToken_Success(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok"))
|
||||
|
||||
if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_ok")
|
||||
if token != "obctoken_ok" {
|
||||
t.Errorf("token = %q, want obctoken_ok", token)
|
||||
}
|
||||
if msg != "" {
|
||||
t.Errorf("errMsg = %q, want empty", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cases := []struct {
|
||||
name string
|
||||
meetingID string
|
||||
code int
|
||||
wantMsg string
|
||||
}{
|
||||
{"121004 not found", "m_121004", 121004, "no minute file for this meeting"},
|
||||
{"121005 no permission", "m_121005", 121005, "no permission to access this meeting's minute"},
|
||||
{"124002 generating", "m_124002", 124002, "minute file is still being generated"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(recordingErrStub(tt.meetingID, tt.code, "err"))
|
||||
|
||||
if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID)
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty on error", token)
|
||||
}
|
||||
if !strings.Contains(msg, tt.wantMsg) {
|
||||
t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(recordingErrStub("m_other", 99999, "weird"))
|
||||
|
||||
if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_other")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if !strings.Contains(msg, "failed to query recording") {
|
||||
t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(recordingStub("m_norec", map[string]any{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]any{},
|
||||
}))
|
||||
|
||||
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if !strings.Contains(msg, "no recording available") {
|
||||
t.Errorf("errMsg = %q, want contains 'no recording available'", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path"))
|
||||
|
||||
if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_notok")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if !strings.Contains(msg, "no minute_token found") {
|
||||
t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: fetchNoteByMeetingID — note + minute_token combined behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// extractFirstNote runs +notes via --meeting-ids and returns the single result map.
|
||||
func extractFirstNote(t *testing.T, stdout *bytes.Buffer) map[string]any {
|
||||
t.Helper()
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
notes, _ := data["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("expected 1 note, got %d (%v)", len(notes), notes)
|
||||
}
|
||||
note, _ := notes[0].(map[string]any)
|
||||
return note
|
||||
}
|
||||
|
||||
// assertNoteError verifies the result map's `error` field contains every
|
||||
// substring in wantSubstrs (order-independent). Pass an empty slice to assert
|
||||
// the field is absent. Centralized here so tests don't have to repeat the same
|
||||
// "for each substring, Contains + Errorf" pattern.
|
||||
func assertNoteError(t *testing.T, note map[string]any, wantSubstrs ...string) {
|
||||
t.Helper()
|
||||
errMsg, _ := note["error"].(string)
|
||||
if len(wantSubstrs) == 0 {
|
||||
if e, has := note["error"]; has {
|
||||
t.Errorf("error should be absent, got %v", e)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, sub := range wantSubstrs {
|
||||
if !strings.Contains(errMsg, sub) {
|
||||
t.Errorf("error %q missing substring %q", errMsg, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoteFieldAbsent fails the test if any of the named fields is present.
|
||||
func assertNoteFieldAbsent(t *testing.T, note map[string]any, fields ...string) {
|
||||
t.Helper()
|
||||
for _, f := range fields {
|
||||
if v, has := note[f]; has {
|
||||
t.Errorf("%s should be absent, got %v", f, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_MeetingPath_NoteAndMinuteBothOK(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_both", "note_both"))
|
||||
reg.Register(noteDetailStub("note_both"))
|
||||
reg.Register(recordingOKStub("m_both", "https://meetings.feishu.cn/minutes/obc_both"))
|
||||
|
||||
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_both", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
note := extractFirstNote(t, stdout)
|
||||
if got := note["note_doc_token"]; got != "doc_main" {
|
||||
t.Errorf("note_doc_token = %v, want doc_main", got)
|
||||
}
|
||||
if got := note["minute_token"]; got != "obc_both" {
|
||||
t.Errorf("minute_token = %v, want obc_both", got)
|
||||
}
|
||||
assertNoteError(t, note)
|
||||
}
|
||||
|
||||
func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_minfail", "note_minfail"))
|
||||
reg.Register(noteDetailStub("note_minfail"))
|
||||
reg.Register(recordingErrStub("m_minfail", 121005, "no permission"))
|
||||
|
||||
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_minfail", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
note := extractFirstNote(t, stdout)
|
||||
if got := note["note_doc_token"]; got != "doc_main" {
|
||||
t.Errorf("note_doc_token = %v, want doc_main", got)
|
||||
}
|
||||
assertNoteFieldAbsent(t, note, "minute_token")
|
||||
assertNoteError(t, note, "no permission to access this meeting's minute")
|
||||
}
|
||||
|
||||
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// note_id missing on the meeting object → no notes, but minute_token present
|
||||
reg.Register(meetingGetStub("m_nonote", ""))
|
||||
reg.Register(recordingOKStub("m_nonote", "https://meetings.feishu.cn/minutes/obc_nonote"))
|
||||
|
||||
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
note := extractFirstNote(t, stdout)
|
||||
if got := note["minute_token"]; got != "obc_nonote" {
|
||||
t.Errorf("minute_token = %v, want obc_nonote", got)
|
||||
}
|
||||
assertNoteError(t, note, "no notes available for this meeting")
|
||||
}
|
||||
|
||||
func TestNotes_MeetingPath_BothFail_ErrorJoinedWithSemicolon(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// no note_id → "no notes available..."; recording 121004 → "no minute file..."
|
||||
reg.Register(meetingGetStub("m_bothfail", ""))
|
||||
reg.Register(recordingErrStub("m_bothfail", 121004, "data not found"))
|
||||
|
||||
// Two-path failure with no payload should make the batch return ErrAPI.
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_bothfail", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch failure error, got nil")
|
||||
}
|
||||
|
||||
note := extractFirstNote(t, stdout)
|
||||
assertNoteFieldAbsent(t, note, "minute_token")
|
||||
assertNoteError(t, note,
|
||||
"no notes available for this meeting",
|
||||
"no minute file for this meeting",
|
||||
"; ", // causes joined with semicolon
|
||||
)
|
||||
}
|
||||
|
||||
// noteDetailErrStub returns a stub that emits an error response from
|
||||
// /open-apis/vc/v1/notes/{note_id}.
|
||||
func noteDetailErrStub(noteID string, code int, msg string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||||
Body: map[string]any{"code": code, "msg": msg},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// note 接口返回 121005 → 阅读权限不足;同时 recording 也返回 121005,
|
||||
// 用以验证两路错误都会被合并到顶层 error 字段(用 "; " 拼接)。
|
||||
reg.Register(meetingGetStub("m_noteperm", "note_noperm"))
|
||||
reg.Register(noteDetailErrStub("note_noperm", 121005, "no permission"))
|
||||
reg.Register(recordingErrStub("m_noteperm", 121005, "no permission"))
|
||||
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_noteperm", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch failure error, got nil")
|
||||
}
|
||||
|
||||
note := extractFirstNote(t, stdout)
|
||||
assertNoteFieldAbsent(t, note, "note_doc_token", "minute_token")
|
||||
assertNoteError(t, note,
|
||||
"[121005]",
|
||||
"no read permission for this meeting note",
|
||||
"; ", // note + minute causes joined with semicolon
|
||||
)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func meetingSearchDescription(item map[string]interface{}) string {
|
||||
var VCSearch = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+search",
|
||||
Description: "Search meeting records (requires at least one filter)",
|
||||
Description: "Search meeting records by keyword, time range, participant, organizer, or meeting room (requires at least one filter)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.search:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取/编辑妙记 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物;6.更新妙记标题(重命名妙记);7.替换妙记逐字稿中的说话人;8.在指定妙记中新增/更新/删除 AI 待办(minutes +todo,不是飞书任务 Task)。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物;6.更新妙记标题(重命名妙记);7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -12,6 +12,12 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
|
||||
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
|
||||
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
|
||||
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
|
||||
> 4. 了解会议总结、分析和信息提取的标准流程
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。
|
||||
@@ -46,19 +52,20 @@ metadata:
|
||||
|
||||
> **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
|
||||
### 4. 读取妙记的逐字稿、总结、待办、章节(只读)
|
||||
### 4. 获取妙记的逐字稿、总结、待办、章节
|
||||
|
||||
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 的明确触发信号。
|
||||
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 的明确触发信号。
|
||||
|
||||
```bash
|
||||
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
```
|
||||
|
||||
> **读 vs 写**:`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)。
|
||||
> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
|
||||
|
||||
### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
|
||||
@@ -73,42 +80,6 @@ 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
|
||||
@@ -117,27 +88,24 @@ Minutes (妙记) ← minute_token 标识
|
||||
└── MediaFile (音频/视频文件) → minutes +download
|
||||
```
|
||||
|
||||
> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载/上传音视频、编辑妙记 AI 待办与 AI 总结、重命名、逐字稿说话人/关键词替换**。
|
||||
> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件、上传音视频生成妙记**。
|
||||
>
|
||||
> **路由规则**:
|
||||
>
|
||||
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `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)
|
||||
> - 用户要在**妙记内新建 / 修改 / 删除 AI 待办**(含「妙记里加待办」「任务1 已完成」等)→ [`minutes +todo`](references/lark-minutes-todo.md),**禁止**走 lark-task
|
||||
> - 用户要**替换妙记 AI 总结全文** → [`minutes +summary`](references/lark-minutes-summary.md)
|
||||
> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
|
||||
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
|
||||
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
|
||||
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
|
||||
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
|
||||
> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace`
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -150,16 +118,12 @@ 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 管理,勿手动编辑 -->
|
||||
|
||||
@@ -176,6 +140,8 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
|
||||
|
||||
- `get` — 获取妙记信息
|
||||
|
||||
> **权限错误**:如果返回 `[2091005] permission deny`,表示用户没有对应妙记文件的阅读权限,需提示用户联系妙记 owner 申请权限。
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
@@ -185,7 +151,5 @@ 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 -->
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# 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. 确认需求` |
|
||||
|
||||
> 标题语法建议:`#` 后保留空格,并优先使用 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 <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) — 认证和全局参数
|
||||
@@ -1,137 +0,0 @@
|
||||
# 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)
|
||||
@@ -81,6 +81,29 @@ lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
|
||||
lark-cli auth login --device-code <device_code>
|
||||
```
|
||||
|
||||
**Split-Flow 完整步骤**:
|
||||
|
||||
**第一步:发起授权(当前轮)**
|
||||
|
||||
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`)
|
||||
2. 从 JSON 输出中提取 `verification_url` 和 `device_code`
|
||||
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`
|
||||
4. 将 URL 和二维码展示给用户(先 URL,后二维码)
|
||||
5. **结束本轮对话前,必须明确告知用户**:"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"
|
||||
|
||||
**第二步:完成授权(后续轮)**
|
||||
|
||||
1. 等待用户回复"已完成授权"
|
||||
2. **由你(AI agent)亲自执行**:`lark-cli auth login --device-code <device_code>`
|
||||
3. 此命令会轮询授权状态并完成登录
|
||||
4. 如果返回授权成功,流程结束
|
||||
|
||||
**关键规则**:
|
||||
|
||||
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行
|
||||
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
|
||||
- **禁止缓存 `verification_url` 或 `device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
|
||||
|
||||
## 更新检查
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
|
||||
@@ -16,11 +16,7 @@ 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` 作为对应的参数值。
|
||||
> **术语理解 — 待办 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 命令去「找清单再放任务」。
|
||||
> **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。
|
||||
> **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。
|
||||
|
||||
> **创建/更新注意**:
|
||||
|
||||
@@ -12,11 +12,17 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
|
||||
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
|
||||
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
|
||||
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
|
||||
> 4. 了解会议总结、分析和信息提取的标准流程
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting\_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办、章节)和逐字稿文档。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写和会议纪要,通过 minute\_token 标识。
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办)和逐字稿文档。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
|
||||
- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。
|
||||
- **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。
|
||||
- **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。
|
||||
@@ -29,8 +35,23 @@ metadata:
|
||||
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何会议记录。
|
||||
|
||||
### 2. 整理会议纪要
|
||||
1. 整理纪要文档时默认给出纪要文档和逐字稿链接即可,无需读取纪要文档或逐字稿内容。
|
||||
2. 用户明确需要获取纪要文档中的总结、待办、章节产物时,再读取文档获取具体内容。
|
||||
|
||||
> ⚠️ 在选择读取哪个产物前,请先确认你理解 AI 总结链路 vs 录制链路的区别。如不确定,先读 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md) 的「两条链路的独立性」章节。
|
||||
|
||||
**⚠️ 产物选择决策 — 根据用户意图严格区分:**
|
||||
|
||||
| 用户意图 | 必须读取的产物 | 禁止 |
|
||||
|---------|-------------|------|
|
||||
| **提炼/总结/重新总结/整理会议内容/回顾会议** | 逐字稿(`verbatim_doc_token`)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出|
|
||||
| **查看待办/章节** | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
|
||||
| **查看纪要链接/文档地址** | 仅返回文档链接,无需读取内容 | — |
|
||||
| **直接看 AI 总结结果** | AI 纪要(`note_doc_token`) | — |
|
||||
| **谁说了什么/完整发言记录** | 逐字稿(`verbatim_doc_token`) | — |
|
||||
|
||||
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。AI 纪要可作为补充参考,但不能作为唯一信息源。
|
||||
|
||||
1. 整理纪要文档时默认给出纪要文档、逐字稿、妙记链接即可,无需读取纪要文档或逐字稿内容。
|
||||
2. 用户明确需要获取总结、待办、章节产物时,再读取文档获取具体内容。
|
||||
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图(AI 生成的总结可视化),应同时下载展示给用户:
|
||||
```bash
|
||||
# 1. 读取纪要内容
|
||||
@@ -43,7 +64,7 @@ lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --out
|
||||
> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `vc +notes --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。
|
||||
|
||||
> **纪要相关文档 — 根据用户意图选择:**
|
||||
> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办 + 章节)
|
||||
> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办)
|
||||
> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回)
|
||||
> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个
|
||||
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
|
||||
@@ -119,7 +140,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) |
|
||||
| [`+notes`](references/lark-vc-notes.md) | Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids) |
|
||||
| [`+notes`](references/lark-vc-notes.md) | Query meeting notes and minutes (via meeting-ids, minute-tokens, or calendar-event-ids) |
|
||||
| [`+recording`](references/lark-vc-recording.md) | Query minute_token from meeting-ids or calendar-event-ids |
|
||||
|
||||
- 使用 `+search` 命令时,必须阅读 [references/lark-vc-search.md](references/lark-vc-search.md),了解搜索参数和返回值结构。
|
||||
@@ -158,9 +179,9 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read`、 `vc:record:readonly` |
|
||||
| `+notes --minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` |
|
||||
| `+notes --calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||
| `+notes --calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read`、 `vc:record:readonly` |
|
||||
| `+recording --meeting-ids` | `vc:record:readonly` |
|
||||
| `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` |
|
||||
| `+search` | `vc:meeting.search:read` |
|
||||
|
||||
@@ -63,9 +63,9 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
| 输入 | 所需权限 |
|
||||
|------|---------|
|
||||
| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||
| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read`、`vc:record:readonly` |
|
||||
| `--minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` |
|
||||
| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||
| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read`、`vc:record:readonly` |
|
||||
|
||||
## 输出结果
|
||||
|
||||
@@ -75,6 +75,8 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meeting_id` | 会议 ID(`--meeting-ids` / `--calendar-event-ids` 路径) |
|
||||
| `minute_token` | **会议对应的妙记 Token**(`--meeting-ids` / `--calendar-event-ids` 路径自动通过录制 API 反查并附加)|
|
||||
| `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 |
|
||||
| `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) |
|
||||
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳 |
|
||||
@@ -83,6 +85,8 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
| `create_time` | 创建时间(格式化) |
|
||||
|
||||
> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示所有文档链接让用户选择。
|
||||
>
|
||||
> 📌 不确定该返回哪个 token?参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。
|
||||
|
||||
### minute-tokens 路径的 AI 产物
|
||||
|
||||
@@ -91,7 +95,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `artifacts.summary` | AI 总结(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.todos` | 待办事项(JSON 内联) |
|
||||
| `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` |
|
||||
|
||||
154
skills/lark-vc/references/vc-domain-boundaries.md
Normal file
154
skills/lark-vc/references/vc-domain-boundaries.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Calendar/VC/Doc 跨领域关联关系、领域知识和职责边界说明
|
||||
|
||||
本文档说明飞书日历(Calendar)、视频会议(VC)、云文档(Doc)三个域之间的关联关系,帮助理解跨域数据流转和产物依赖。
|
||||
|
||||
## Calendar 域
|
||||
|
||||
- **lark-calendar skill** 负责日历与日程管理,包括创建、查询、修改、删除日程等操作。
|
||||
- **日程与会议的关系**:日程可以用于提前预约会议,确定会议时间、参与人、会议室、会议主题等信息。日程上可以关联飞书/Lark 视频会议。
|
||||
- **并非所有会议都通过日程发起**:即时会议不经过日程预约,直接创建。因此,仅查询日程数据无法覆盖所有会议,搜索历史会议应优先使用 `vc +search`。
|
||||
- **日程上的用户会议纪要**:用户可以在日程上绑定自己的会议纪要文档(MeetingNotes),用于手动记录会议相关信息。该文档与 AI 生成的智能纪要(`note_doc_token`)是不同的文档,相互独立。
|
||||
|
||||
> **路由规则**:查询过去已结束的会议 → `lark-vc`;查询未来日程/待开的会 → `lark-calendar`;查询"今天有哪些会议" → 两者结合(`vc +search` 查已结束 + `calendar` 查未开始)。
|
||||
|
||||
## VC 域
|
||||
|
||||
- **lark-vc skill** 负责视频会议管理,包括搜索历史会议、查询会议产物(智能纪要、逐字稿、妙记等)、查询参会人快照等操作。
|
||||
- **会议类型**:会议可以是日程会议(由日程发起,有对应的 `calendar_event_id`),也可以是即时会议等其他类型。
|
||||
|
||||
### 会议产物
|
||||
|
||||
会议产物取决于会中开启的功能,分为两条独立链路:
|
||||
|
||||
#### 链路一:开启「AI 总结」
|
||||
|
||||
会中开启「AI 总结」功能后,产生以下产物:
|
||||
|
||||
| 产物 | Token 字段 | 本质 | 说明 |
|
||||
|------|-----------|------|------|
|
||||
| 智能纪要 | `note_doc_token` | 飞书文档 | AI 生成的会议总结与待办 |
|
||||
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳) |
|
||||
| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 |
|
||||
|
||||
此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。
|
||||
|
||||
#### 链路二:开启「录制」
|
||||
|
||||
会中开启「录制」功能后,产生**妙记产物**(`minute_token`)。注意:妙记不一定是会中产生的,用户上传音视频文件或录音也会产生妙记。妙记本身包含以下子产物:
|
||||
|
||||
| 子产物 | 说明 |
|
||||
|--------|------|
|
||||
| Summary(总结) | 对整场会议的智能总结 |
|
||||
| Todo(待办) | 会议中识别出的待处理任务列表 |
|
||||
| Chapter(章节) | 按讨论话题划分的核心内容摘要 |
|
||||
| Transcript(文字记录) | 整场会议最原始的逐人发言记录 |
|
||||
|
||||
#### 两条链路的独立性
|
||||
|
||||
- 智能纪要(AI 总结链路)和妙记(录制链路)**相互独立、互不影响**。
|
||||
- 一场会议可能同时拥有两类产物,也可能只有其中一类,也可能都没有。
|
||||
- 当两者都存在时,Summary/Todo 内容可能重叠,应根据用户意图选择优先读取哪个。
|
||||
|
||||
> **产物选择决策**:
|
||||
> - **AI 产物 vs 原始记录**:智能总结、待办、章节都属于 AI 分析产物,可能只包含最终结论和关键信息。
|
||||
> - **用户要求"提炼/总结/重新总结/整理/回顾"会议内容时** → **内容总结必须从逐字稿/文字记录出发,基于原始对话独立分析**。禁止直接搬运 AI 纪要的总结作为最终输出——那只是对 AI 产物的重新排版,不是独立提炼。AI 纪要可作为补充参考,但不能作为内容总结的唯一信息源。
|
||||
> - **用户要求查看待办或章节时** → **应参考 AI 产物的待办和章节**,因为 AI 产物的待办更友好(包含提出人和负责人),章节按话题划分更结构化。
|
||||
> - **用户只想直接看 AI 总结结果** → 使用 AI 产物的总结。
|
||||
> - **链路优先级**:如果用户没有明确偏好,对于重复的内容(如智能总结、待办),**优先查询智能纪要(Note),不存在时再降级到妙记(Minutes)**。
|
||||
|
||||
#### 逐字稿与文字记录的格式
|
||||
|
||||
智能纪要的逐字稿(`verbatim_doc_token`)和妙记的文字记录(Transcript)都记录了用户原始对话内容,格式一致:
|
||||
|
||||
```
|
||||
发言人名称 相对时间戳
|
||||
<发言内容>
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
张三 00:00:00.195
|
||||
我们接下来讨论一下项目进度。
|
||||
```
|
||||
|
||||
- 第一行为发言人信息,包含用户名称和发言的相对时间(从会议开始计算的偏移量)。
|
||||
- 后续行为该发言人的发言内容,直到下一个发言人标记出现。
|
||||
|
||||
### 会议总结和分析流程
|
||||
|
||||
#### Step 1: 定位会议
|
||||
|
||||
根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。
|
||||
|
||||
```bash
|
||||
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json
|
||||
```
|
||||
|
||||
详细用法请阅读 [`lark-vc-search.md`](lark-vc-search.md)。
|
||||
|
||||
#### Step 2: 根据 meeting_id 查询产物
|
||||
|
||||
##### 获取会议产物
|
||||
|
||||
```bash
|
||||
lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
|
||||
```
|
||||
|
||||
可获取会议的所有产物信息,包括:
|
||||
- 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息
|
||||
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录
|
||||
- 共享文档(`shared_doc_token`)— 会中投屏共享的文档
|
||||
- 妙记 Token(`minute_token`)— 如存在录制产物则返回
|
||||
|
||||
详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
|
||||
|
||||
如果返回了 `minute_token`,可通过以下命令获取妙记的详细信息(总结、待办、章节、文字记录):
|
||||
|
||||
```bash
|
||||
lark-cli vc +notes --minute-tokens '<minute_token1>,<minute_token2>'
|
||||
```
|
||||
|
||||
可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
|
||||
|
||||
#### Step 3: Doc 域拉取文档内容
|
||||
|
||||
智能纪要和逐字稿都是飞书文档,需使用 `docs +fetch` 读取正文内容:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
```
|
||||
|
||||
详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) skill。
|
||||
|
||||
#### Step 4: 判断用户需要的产物内容
|
||||
|
||||
- 根据用户诉求(总结/待办/章节/完整发言记录等),选择合适的产物进行分析和信息提取
|
||||
- 如果两种产物都不存在或没有权限,需如实告知用户
|
||||
|
||||
## Doc 域
|
||||
|
||||
- **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。
|
||||
- **会议产物的文档本质**:智能纪要(`note_doc_token`)、逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch`)查询其内容和元信息。
|
||||
- **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`。
|
||||
|
||||
## 三域关联总览
|
||||
|
||||
```
|
||||
Calendar (日程) ──── 发起预约 ────► VC (会议)
|
||||
│
|
||||
┌──────────────────┤
|
||||
│ │
|
||||
AI 总结链路 录制链路
|
||||
│ │
|
||||
▼ ▼
|
||||
智能纪要 (Doc) 妙记 (Minutes)
|
||||
逐字稿 (Doc) ├── Summary
|
||||
共享文档 (Doc) ├── Todo
|
||||
用户纪要 (Doc) ├── Chapter
|
||||
└── Transcript
|
||||
```
|
||||
|
||||
- Calendar 提供会议预约入口,但并非所有会议都来自日程。
|
||||
- VC 是会议数据的中心,管理会议记录和产物关联。
|
||||
- Doc 是会议产物的载体,智能纪要和逐字稿都以飞书文档形式沉淀,需通过 Doc 域 API 读取。
|
||||
@@ -11,6 +11,12 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**。然后阅读 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md),了解会议纪要相关操作。
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
|
||||
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
|
||||
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
|
||||
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
|
||||
> 4. 了解会议总结、分析和信息提取的标准流程
|
||||
|
||||
## 适用场景
|
||||
|
||||
- "帮我整理这周的会议纪要" / "总结最近的会议" / "生成会议周报"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user