diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index defaae01..2d8ff1eb 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg { // (not backed by from_meta service specs). Descriptions are now centralized in // service_descriptions.json. func getShortcutOnlyDomainNames() []string { - return []string{"base", "contact", "docs", "markdown", "apps", "note"} + return []string{"base", "contact", "docs", "markdown", "apps"} } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index d063c335..3409f297 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -9,7 +9,6 @@ import ( "errors" "io" "net/http" - "slices" "sort" "strings" "testing" @@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { } } -func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) { - if !slices.Contains(getShortcutOnlyDomainNames(), "note") { - t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read") - } -} - func TestCollectScopesForDomains(t *testing.T) { projects := registry.ListFromMetaProjects() if len(projects) == 0 { diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index b6fef7ca..1d7abd2b 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -47,10 +47,6 @@ "en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" }, "zh": { "title": "妙记", "description": "妙记信息获取、内容查询" } }, - "note": { - "en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" }, - "zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" } - }, "sheets": { "en": { "title": "Sheets", "description": "Spreadsheet operations" }, "zh": { "title": "电子表格", "description": "电子表格操作" } diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index c34fffc4..e6f43959 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -25,11 +25,9 @@ var migratedCommonHelperPaths = []string{ "shortcuts/doc/", "shortcuts/drive/", "shortcuts/event/", - "shortcuts/im/", "shortcuts/mail/", "shortcuts/markdown/", "shortcuts/minutes/", - "shortcuts/note/", "shortcuts/okr/", "shortcuts/sheets/", "shortcuts/slides/", diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index ffb87c55..57631ba8 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -26,11 +26,9 @@ var migratedEnvelopePaths = []string{ "shortcuts/doc/", "shortcuts/drive/", "shortcuts/event/", - "shortcuts/im/", "shortcuts/mail/", "shortcuts/markdown/", "shortcuts/minutes/", - "shortcuts/note/", "shortcuts/okr/", "shortcuts/sheets/", "shortcuts/slides/", @@ -38,6 +36,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/vc/", "shortcuts/whiteboard/", "shortcuts/wiki/", + "shortcuts/im/", } // legacyOutputImportPath is the import path of the package that declares the diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 30991872..67ef6408 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -953,7 +953,6 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes paths := []string{ "shortcuts/doc/docs_fetch_v2.go", "shortcuts/drive/drive_search.go", - "shortcuts/im/im_messages_send.go", "shortcuts/mail/mail_send.go", "shortcuts/markdown/markdown_fetch.go", "shortcuts/okr/okr_progress_create.go", @@ -989,18 +988,6 @@ common.` + helper + `() } } -func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) { - commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths)) - for _, path := range migratedCommonHelperPaths { - commonPaths[path] = struct{}{} - } - for _, path := range migratedEnvelopePaths { - if _, ok := commonPaths[path]; !ok { - t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path) - } - } -} - func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) { src := `package calendar diff --git a/shortcuts/note/note.go b/shortcuts/note/note.go deleted file mode 100644 index 037322ce..00000000 --- a/shortcuts/note/note.go +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Package note owns the Note domain: querying note detail and the unified -// transcript by a known note_id. The vc domain locates a -// note_id from meeting context and delegates note-detail parsing here, so the -// parsing logic lives in exactly one place. -package note - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -// NoNoteReadPermissionCode is returned when the caller lacks read permission -// for the requested note. -const NoNoteReadPermissionCode = 121005 - -// artifact_type enum from the note detail API. -const ( - artifactTypeMainDoc = 1 // main note document - artifactTypeVerbatim = 2 // verbatim transcript -) - -// note_display_type enum (i32) from the note detail API. Surfaced to callers as -// a stable string so Agents route on a name, not a magic number. -const ( - displayTypeNormal = 1 - displayTypeUnified = 2 -) - -// Detail is the parsed note detail shared by `note +detail` and `vc +notes`. -type Detail struct { - NoteID string - CreatorID string - CreateTime string - DisplayType string // unknown | normal | unified - NoteDocToken string - VerbatimDocToken string - SharedDocTokens []string -} - -// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note -// object. API errors are returned as typed errs.* values so callers can enrich -// user guidance without downgrading the envelope. -func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) { - data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil) - if err != nil { - return nil, err - } - noteObj, _ := data["note"].(map[string]any) - if noteObj == nil { - return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty") - } - noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts")) - return &Detail{ - NoteID: noteID, - CreatorID: common.GetString(noteObj, "creator_id"), - CreateTime: common.FormatTime(noteObj["create_time"]), - DisplayType: displayTypeString(displayTypeValue(noteObj)), - NoteDocToken: noteDoc, - VerbatimDocToken: verbatimDoc, - SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")), - }, nil -} - -// ToMap renders the detail as the field map consumed by `vc +notes`, keeping -// the historical key set (shared_doc_tokens omitted when empty) and adding the -// note_id / note_display_type fields. -func (d *Detail) ToMap() map[string]any { - m := map[string]any{ - "note_id": d.NoteID, - "note_display_type": d.DisplayType, - "creator_id": d.CreatorID, - "create_time": d.CreateTime, - "note_doc_token": d.NoteDocToken, - "verbatim_doc_token": d.VerbatimDocToken, - } - if len(d.SharedDocTokens) > 0 { - m["shared_doc_tokens"] = d.SharedDocTokens - } - return m -} - -// displayTypeValue reads the display-type field, tolerating either the -// documented note_display_type key or a bare display_type fallback. -func displayTypeValue(note map[string]any) any { - if v, ok := note["note_display_type"]; ok { - return v - } - return note["display_type"] -} - -func displayTypeString(v any) string { - switch parseLooseInt(v) { - case displayTypeNormal: - return "normal" - case displayTypeUnified: - return "unified" - default: - return "unknown" - } -} - -// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts. -func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) { - for _, a := range artifacts { - artifact, _ := a.(map[string]any) - if artifact == nil { - continue - } - docToken, _ := artifact["doc_token"].(string) - switch parseLooseInt(artifact["artifact_type"]) { - case artifactTypeMainDoc: - noteDoc = docToken - case artifactTypeVerbatim: - verbatimDoc = docToken - } - } - return -} - -// extractDocTokens collects doc_token values from a list of reference objects. -func extractDocTokens(refs []any) []string { - var tokens []string - for _, s := range refs { - source, _ := s.(map[string]any) - if source == nil { - continue - } - if docToken, _ := source["doc_token"].(string); docToken != "" { - tokens = append(tokens, docToken) - } - } - return tokens -} - -// parseLooseInt extracts an int from the varying JSON number representations -// DoAPIJSON may yield (json.Number, float64, or int). -func parseLooseInt(v any) int { - switch n := v.(type) { - case json.Number: - i, _ := n.Int64() - return int(i) - case float64: - // Reject fractional values: truncating 1.9 to 1 would silently coerce - // a malformed enum into a valid one. - if n != float64(int64(n)) { - return 0 - } - return int(n) - case int: - return n - default: - return 0 - } -} - -// parseLooseCursorID extracts a positive cursor as a string. String cursors are -// preferred because large JSON numbers lose precision when decoded into any. -func parseLooseCursorID(v any) (string, bool) { - switch n := v.(type) { - case string: - s := strings.TrimSpace(n) - if s == "" || s == "0" { - return "", false - } - return s, true - case json.Number: - i, err := n.Int64() - if err != nil || i <= 0 { - return "", false - } - return strconv.FormatInt(i, 10), true - case float64: - // encoding/json decodes numbers in map[string]any as float64. Accept - // only values that can round-trip safely as an integer cursor. - const maxSafeJSONInteger = 1<<53 - 1 - if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger { - return "", false - } - return strconv.FormatInt(int64(n), 10), true - case int64: - if n <= 0 { - return "", false - } - return strconv.FormatInt(n, 10), true - case int: - if n <= 0 { - return "", false - } - return strconv.Itoa(n), true - default: - return "", false - } -} diff --git a/shortcuts/note/note_detail.go b/shortcuts/note/note_detail.go deleted file mode 100644 index 21d3c14b..00000000 --- a/shortcuts/note/note_detail.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT -// -// note +detail — get note metadata and document tokens by a known note_id. - -package note - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -// NoteDetail queries note metadata, display type and document tokens by note_id. -var NoteDetail = common.Shortcut{ - Service: "note", - Command: "+detail", - Description: "Get note detail (display type, document tokens) by note_id", - Risk: "read", - Scopes: []string{"vc:note:read"}, - AuthTypes: []string{"user"}, - Flags: []common.Flag{ - {Name: "note-id", Desc: "note ID", Required: true}, - }, - Validate: func(_ context.Context, runtime *common.RuntimeContext) error { - noteID := strings.TrimSpace(runtime.Str("note-id")) - if noteID == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id") - } - if err := validate.ResourceName(noteID, "--note-id"); err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err) - } - return nil - }, - DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - noteID := strings.TrimSpace(runtime.Str("note-id")) - return common.NewDryRunAPI(). - GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID))) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - noteID := strings.TrimSpace(runtime.Str("note-id")) - detail, err := FetchDetail(ctx, runtime, noteID) - if err != nil { - return mapNoteError(err) - } - runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil) - return nil - }, -} - -// mapNoteError surfaces the no-permission case explicitly and passes through -// any other typed API error unchanged. -func mapNoteError(err error) error { - if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode { - message := strings.TrimSpace(problem.Message) - if message == "" { - message = "no read permission for this note" - } else if !strings.Contains(message, "no read permission for this note") { - message = fmt.Sprintf("no read permission for this note: %s", message) - } - var permErr *errs.PermissionError - if errors.As(err, &permErr) { - mapped := *permErr - mapped.Problem.Message = message - if mapped.Problem.Hint == "" { - mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry" - } - mapped.Cause = err - return &mapped - } - mappedProblem := *problem - mappedProblem.Category = errs.CategoryAuthorization - mappedProblem.Subtype = errs.SubtypePermissionDenied - mappedProblem.Message = message - if mappedProblem.Hint == "" { - mappedProblem.Hint = "Ask the note owner to grant read permission, then retry" - } - return &errs.PermissionError{Problem: mappedProblem, Cause: err} - } - return err -} diff --git a/shortcuts/note/note_test.go b/shortcuts/note/note_test.go deleted file mode 100644 index e79a629b..00000000 --- a/shortcuts/note/note_test.go +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package note - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/larksuite/cli/errs" -) - -// These tests were relocated from shortcuts/vc/vc_notes_test.go together with -// the note-detail parsing helpers they cover. - -func TestParseLooseInt(t *testing.T) { - tests := []struct { - input any - want int - }{ - {float64(1), 1}, - {float64(2), 2}, - {float64(1.9), 0}, - {json.Number("3"), 3}, - {"unknown", 0}, - {nil, 0}, - } - for _, tt := range tests { - got := parseLooseInt(tt.input) - if got != tt.want { - t.Errorf("parseLooseInt(%v) = %d, want %d", tt.input, got, tt.want) - } - } -} - -func TestParseLooseCursorID(t *testing.T) { - tests := []struct { - name string - in any - want string - ok bool - }{ - {name: "string", in: "7648924766078847940", want: "7648924766078847940", ok: true}, - {name: "trim string", in: " 123 ", want: "123", ok: true}, - {name: "empty string", in: "", ok: false}, - {name: "zero string", in: "0", ok: false}, - {name: "json number", in: json.Number("123"), want: "123", ok: true}, - {name: "float safe integer", in: float64(123), want: "123", ok: true}, - {name: "float unsafe integer", in: float64(1<<53 + 1), ok: false}, - {name: "float fractional", in: float64(1.5), ok: false}, - {name: "negative", in: -1, ok: false}, - {name: "nil", in: nil, ok: false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := parseLooseCursorID(tt.in) - if got != tt.want || ok != tt.ok { - t.Fatalf("parseLooseCursorID(%v) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok) - } - }) - } -} - -func TestExtractArtifactTokens(t *testing.T) { - artifacts := []any{ - map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)}, - map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)}, - map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)}, - nil, - } - noteDoc, verbatimDoc := extractArtifactTokens(artifacts) - if noteDoc != "main_doc" { - t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc") - } - if verbatimDoc != "verbatim_doc" { - t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc") - } -} - -func TestExtractArtifactTokens_Empty(t *testing.T) { - noteDoc, verbatimDoc := extractArtifactTokens(nil) - if noteDoc != "" || verbatimDoc != "" { - t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc) - } -} - -func TestExtractDocTokens(t *testing.T) { - refs := []any{ - map[string]any{"doc_token": "shared1"}, - map[string]any{"doc_token": "shared2"}, - map[string]any{"doc_token": ""}, - map[string]any{}, - nil, - } - tokens := extractDocTokens(refs) - if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" { - t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens) - } -} - -func TestExtractDocTokens_Empty(t *testing.T) { - tokens := extractDocTokens(nil) - if tokens != nil { - t.Errorf("expected nil for nil input, got %v", tokens) - } -} - -func TestDetailToMap(t *testing.T) { - detail := &Detail{ - NoteID: "note_1", - CreatorID: "creator_1", - CreateTime: "2026-06-09 12:00:00", - DisplayType: "unified", - NoteDocToken: "note_doc", - VerbatimDocToken: "verbatim_doc", - SharedDocTokens: []string{"shared_1", "shared_2"}, - } - - got := detail.ToMap() - want := map[string]any{ - "note_id": "note_1", - "creator_id": "creator_1", - "create_time": "2026-06-09 12:00:00", - "note_display_type": "unified", - "note_doc_token": "note_doc", - "verbatim_doc_token": "verbatim_doc", - "shared_doc_tokens": []string{"shared_1", "shared_2"}, - } - for key, wantValue := range want { - gotValue, ok := got[key] - if !ok { - t.Fatalf("ToMap missing key %q in %#v", key, got) - } - if !valuesEqual(gotValue, wantValue) { - t.Fatalf("ToMap[%q] = %#v, want %#v", key, gotValue, wantValue) - } - } -} - -func TestDetailToMap_OmitsEmptySharedDocTokens(t *testing.T) { - got := (&Detail{NoteID: "note_1"}).ToMap() - if _, ok := got["shared_doc_tokens"]; ok { - t.Fatalf("ToMap should omit empty shared_doc_tokens, got %#v", got) - } -} - -func TestMapNoteError_NoReadPermission(t *testing.T) { - err := &errs.PermissionError{ - Problem: errs.Problem{ - Category: errs.CategoryAuthorization, - Subtype: errs.SubtypePermissionDenied, - Code: NoNoteReadPermissionCode, - Message: "upstream permission denied", - LogID: "log_1", - }, - MissingScopes: []string{"vc:note:read"}, - Identity: "user", - } - - got := mapNoteError(err) - problem, ok := errs.ProblemOf(got) - if !ok { - t.Fatalf("mapNoteError returned %T, want typed problem", got) - } - if problem.Code != NoNoteReadPermissionCode { - t.Fatalf("mapped code = %d, want %d", problem.Code, NoNoteReadPermissionCode) - } - if !strings.Contains(problem.Message, "no read permission for this note") || !strings.Contains(problem.Message, "upstream permission denied") { - t.Fatalf("mapped message = %q, want note permission guidance with upstream message", problem.Message) - } - if !errors.Is(got, err) { - t.Fatal("mapped error should preserve the original typed error as cause") - } - originalProblem, _ := errs.ProblemOf(err) - if originalProblem.Message != "upstream permission denied" { - t.Fatalf("original message was mutated to %q", originalProblem.Message) - } - var gotPerm *errs.PermissionError - if !errors.As(got, &gotPerm) { - t.Fatalf("mapped error = %T, want PermissionError", got) - } - if gotPerm.LogID != "log_1" { - t.Fatalf("LogID = %q, want preserved log_1", gotPerm.LogID) - } - if len(gotPerm.MissingScopes) != 1 || gotPerm.MissingScopes[0] != "vc:note:read" { - t.Fatalf("MissingScopes = %#v, want preserved vc:note:read", gotPerm.MissingScopes) - } - if gotPerm.Identity != "user" { - t.Fatalf("Identity = %q, want preserved user", gotPerm.Identity) - } -} - -func TestMapNoteError_NormalizesNonPermissionTypedError(t *testing.T) { - err := &errs.APIError{ - Problem: errs.Problem{ - Category: errs.CategoryAPI, - Subtype: errs.SubtypeUnknown, - Code: NoNoteReadPermissionCode, - Message: "upstream api error", - LogID: "log_2", - }, - } - - got := mapNoteError(err) - var gotPerm *errs.PermissionError - if !errors.As(got, &gotPerm) { - t.Fatalf("mapped error = %T, want PermissionError", got) - } - if gotPerm.Category != errs.CategoryAuthorization || gotPerm.Subtype != errs.SubtypePermissionDenied { - t.Fatalf("mapped category/subtype = %q/%q, want authorization/permission_denied", gotPerm.Category, gotPerm.Subtype) - } - if !strings.Contains(gotPerm.Message, "no read permission for this note") || !strings.Contains(gotPerm.Message, "upstream api error") { - t.Fatalf("mapped message = %q, want note permission guidance with upstream message", gotPerm.Message) - } - if gotPerm.Hint == "" { - t.Fatal("mapped hint should not be empty") - } - if gotPerm.LogID != "log_2" { - t.Fatalf("LogID = %q, want preserved log_2", gotPerm.LogID) - } - if !errors.Is(got, err) { - t.Fatal("mapped error should preserve the original typed error as cause") - } -} - -func TestMapNoteError_Passthrough(t *testing.T) { - err := errors.New("boom") - if got := mapNoteError(err); got != err { - t.Fatalf("mapNoteError passthrough = %v, want original", got) - } -} - -func TestShortcuts(t *testing.T) { - shortcuts := Shortcuts() - if len(shortcuts) != 2 { - t.Fatalf("Shortcuts len = %d, want 2", len(shortcuts)) - } - if shortcuts[0].Command != "+detail" || shortcuts[1].Command != "+transcript" { - t.Fatalf("Shortcuts commands = %q, %q", shortcuts[0].Command, shortcuts[1].Command) - } -} - -func valuesEqual(a, b any) bool { - ab, _ := json.Marshal(a) - bb, _ := json.Marshal(b) - return string(ab) == string(bb) -} diff --git a/shortcuts/note/note_transcript.go b/shortcuts/note/note_transcript.go deleted file mode 100644 index d5fc8cea..00000000 --- a/shortcuts/note/note_transcript.go +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT -// -// note +transcript — fetch the unified note transcript by a -// known note_id. The API is paginated; the CLI walks all pages internally, -// concatenates the content and saves the whole transcript to a local file. - -package note - -import ( - "bytes" - "context" - "fmt" - "net/http" - "path/filepath" - "strconv" - "strings" - "time" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/i18n" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -const ( - transcriptFormatMarkdown = "markdown" - transcriptFormatPlainText = "plain_text" - - logPrefix = "[note +transcript]" - - // maxTranscriptPages bounds the pagination loop so a misbehaving has_more - // can never spin forever. transcriptPageSize reduces round trips; full - // transcript correctness still depends on has_more/cursor pagination. - maxTranscriptPages = 500 - transcriptPageSize = 200 - - // pageDelay throttles successive page requests to stay gentle on the - // downstream, matching the batch cadence used by `vc +notes`. - pageDelay = 100 * time.Millisecond - - // noteArtifactSubdir is the default top-level directory for note-scoped - // artifacts (parallel to the "minutes" layout used by minute artifacts). - noteArtifactSubdir = "notes" -) - -// NoteTranscript fetches the full unified transcript and saves it to a file. -var NoteTranscript = common.Shortcut{ - Service: "note", - Command: "+transcript", - Description: "Fetch the unified note transcript and save it to a file", - Risk: "read", - Scopes: []string{"vc:note:read"}, - AuthTypes: []string{"user"}, - Flags: []common.Flag{ - {Name: "note-id", Desc: "note ID", Required: true}, - {Name: "transcript-format", Desc: "transcript content format", Default: transcriptFormatMarkdown, Enum: []string{transcriptFormatMarkdown, transcriptFormatPlainText}}, - {Name: "locale", Desc: "transcript locale, e.g. zh_cn, en_us, ja_jp (default follows profile language or brand)"}, - {Name: "output", Desc: "output file path (default: ./notes/{note_id}/unified_transcript.{md,txt})"}, - {Name: "overwrite", Type: "bool", Desc: "overwrite an existing output file"}, - }, - Validate: func(_ context.Context, runtime *common.RuntimeContext) error { - noteID := strings.TrimSpace(runtime.Str("note-id")) - if noteID == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id") - } - if err := validate.ResourceName(noteID, "--note-id"); err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err) - } - if out := strings.TrimSpace(runtime.Str("output")); out != "" { - if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil { - return err - } - } - return nil - }, - DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - noteID := strings.TrimSpace(runtime.Str("note-id")) - transcriptFormat := runtime.Str("transcript-format") - locale := resolveTranscriptLocale(runtime) - return common.NewDryRunAPI(). - GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID))). - Desc("[1] Check note_display_type and verbatim_doc_token before transcript fetch"). - GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))). - Desc("[2] Fetch unified note transcript pages; subsequent pages add cursor_id internally"). - Params(map[string]interface{}{ - "format": transcriptFormat, - "page_size": transcriptPageSize, - "locale": locale, - }). - Set("transcript_format", transcriptFormat). - Set("locale", locale). - Set("note", "CLI first checks note_display_type via note detail, then paginates internally (cursor_id) and saves the full unified transcript to a file") - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - noteID := strings.TrimSpace(runtime.Str("note-id")) - transcriptFormat := runtime.Str("transcript-format") - locale := resolveTranscriptLocale(runtime) - - outPath := strings.TrimSpace(runtime.Str("output")) - if outPath == "" { - outPath = defaultTranscriptPath(noteID, transcriptFormat) - } - if !runtime.Bool("overwrite") { - if _, statErr := runtime.FileIO().Stat(outPath); statErr == nil { - precondition := errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s", outPath). - WithHint("Pass --overwrite to replace the existing file") - if strings.TrimSpace(runtime.Str("output")) != "" { - precondition = precondition.WithParam("--output") - } - return precondition - } - } - - if err := ensureUnifiedNote(ctx, runtime, noteID); err != nil { - return err - } - - content, err := fetchUnifiedTranscript(ctx, runtime, noteID, transcriptFormat, locale) - if err != nil { - return err - } - - saved, err := runtime.FileIO().Save(outPath, fileio.SaveOptions{}, bytes.NewReader(content)) - if err != nil { - return common.WrapSaveErrorTyped(err) - } - resolved, rerr := runtime.FileIO().ResolvePath(outPath) - if rerr != nil || resolved == "" { - resolved = outPath - } - - runtime.OutFormat(map[string]any{ - "note_id": noteID, - "transcript_format": transcriptFormat, - "transcript_file": resolved, - "size_bytes": saved.Size(), - }, nil, nil) - return nil - }, -} - -func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, noteID string) error { - detail, err := FetchDetail(ctx, runtime, noteID) - if err != nil { - return mapNoteError(err) - } - if detail.DisplayType != "unified" { - if detail.VerbatimDocToken != "" { - return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken). - WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken) - } - return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType). - WithHint("Use note +detail to inspect document tokens") - } - return nil -} - -// fetchUnifiedTranscript walks every page of the unified transcript and returns -// the concatenated content. Any page error fails the whole call: a partial -// transcript is misleading, so we prefer an explicit error over silent loss. -func fetchUnifiedTranscript(ctx context.Context, runtime *common.RuntimeContext, noteID, transcriptFormat, locale string) ([]byte, error) { - errOut := runtime.IO().ErrOut - apiPath := fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID)) - - var buf bytes.Buffer - var cursor string - seenCursors := map[string]bool{} - for page := 1; ; page++ { - if err := ctx.Err(); err != nil { - return nil, err - } - if page > maxTranscriptPages { - return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript exceeded %d pages; aborting to avoid an unbounded loop", maxTranscriptPages) - } - - query := larkcore.QueryParams{ - "format": []string{transcriptFormat}, - "locale": []string{locale}, - "page_size": []string{strconv.Itoa(transcriptPageSize)}, - } - if cursor != "" { - query["cursor_id"] = []string{cursor} - } - data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiPath, query, nil) - if err != nil { - return nil, mapNoteError(err) - } - - if transcript, _ := data["transcript"].(map[string]any); transcript != nil { - if chunk, _ := transcript[transcriptFormat].(string); chunk != "" { - buf.WriteString(chunk) - } - } - - hasMore, _ := data["has_more"].(bool) - if !hasMore { - break - } - next, ok := parseLooseCursorID(data["next_cursor_id"]) - if !ok || next == cursor || seenCursors[next] { - fmt.Fprintf(errOut, "%s has_more set but cursor did not advance at page %d\n", logPrefix, page) - return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript pagination cursor did not advance at page %d; aborting to avoid saving a partial transcript", page) - } - seenCursors[cursor] = true - cursor = next - timer := time.NewTimer(pageDelay) - select { - case <-ctx.Done(): - timer.Stop() - return nil, ctx.Err() - case <-timer.C: - } - } - - if buf.Len() == 0 { - return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript is empty for note %s in %s format; aborting to avoid saving an empty transcript", noteID, transcriptFormat) - } - return buf.Bytes(), nil -} - -// defaultTranscriptPath builds the default save path for a note transcript. -func defaultTranscriptPath(noteID, transcriptFormat string) string { - name := "unified_transcript.md" - if transcriptFormat == transcriptFormatPlainText { - name = "unified_transcript.txt" - } - return filepath.Join(noteArtifactSubdir, noteID, name) -} - -func resolveTranscriptLocale(runtime *common.RuntimeContext) string { - if explicit := strings.TrimSpace(runtime.Str("locale")); explicit != "" { - return explicit - } - if lang := runtime.Lang(); lang != "" { - return string(lang) - } - if runtime.Config.Brand == core.BrandLark { - return string(i18n.LangEnUS) - } - return string(i18n.LangZhCN) -} diff --git a/shortcuts/note/note_transcript_test.go b/shortcuts/note/note_transcript_test.go deleted file mode 100644 index 7c39d80b..00000000 --- a/shortcuts/note/note_transcript_test.go +++ /dev/null @@ -1,404 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package note - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/shortcuts/common" - "github.com/spf13/cobra" -) - -func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) { - factory, stdout, _, reg := noteShortcutTestFactory(t) - reg.Register(noteDetailStub("note_normal", displayTypeNormal)) - - err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_normal", "--output", "out.md", "--as", "user"}, factory, stdout) - if err == nil { - t.Fatal("expected non-unified note to fail") - } - if got := err.Error(); !strings.Contains(got, "not a unified note") || !strings.Contains(got, "note_display_type=normal") || !strings.Contains(got, "verbatim_doc_token=doc_verbatim") { - t.Fatalf("err = %q, want non-unified message", got) - } - problem, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T", err) - } - if problem.Subtype != errs.SubtypeFailedPrecondition { - t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype) - } - if !strings.Contains(problem.Hint, "docs +fetch --api-version v2 --doc doc_verbatim") { - t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } -} - -func TestNoteTranscriptFetchesUnifiedNote(t *testing.T) { - factory, stdout, _, reg := noteShortcutTestFactory(t) - dir := t.TempDir() - cmdutil.TestChdir(t, dir) - - reg.Register(noteDetailStub("note_unified", displayTypeUnified)) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/note_unified/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": false, - "transcript": map[string]interface{}{ - "markdown": "# transcript\n", - }, - }, - }, - }) - - err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_unified", "--as", "user"}, factory, stdout) - if err != nil { - t.Fatalf("err=%v", err) - } - content, err := os.ReadFile(filepath.Join(dir, "notes", "note_unified", "unified_transcript.md")) - if err != nil { - t.Fatalf("ReadFile transcript err=%v", err) - } - if string(content) != "# transcript\n" { - t.Fatalf("transcript = %q, want %q", string(content), "# transcript\n") - } - data := decodeNoteEnvelope(t, stdout) - if data["note_id"] != "note_unified" || data["size_bytes"] != float64(len(content)) { - t.Fatalf("unexpected output: %#v", data) - } -} - -func TestNoteTranscriptFormatFlagDoesNotShadowOutputFormat(t *testing.T) { - factory, stdout, _, reg := noteShortcutTestFactory(t) - dir := t.TempDir() - cmdutil.TestChdir(t, dir) - - reg.Register(noteDetailStub("note_plain", displayTypeUnified)) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/note_plain/unified_note_transcript?format=plain_text&locale=zh_cn&page_size=200", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": false, - "transcript": map[string]interface{}{ - "plain_text": "plain transcript\n", - }, - }, - }, - }) - - err := runNoteShortcut(t, NoteTranscript, []string{ - "+transcript", - "--note-id", "note_plain", - "--transcript-format", "plain_text", - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("err=%v", err) - } - content, err := os.ReadFile(filepath.Join(dir, "notes", "note_plain", "unified_transcript.txt")) - if err != nil { - t.Fatalf("ReadFile transcript err=%v", err) - } - if string(content) != "plain transcript\n" { - t.Fatalf("transcript = %q, want plain transcript", string(content)) - } - data := decodeNoteEnvelope(t, stdout) - if data["transcript_format"] != "plain_text" { - t.Fatalf("transcript_format = %#v, want plain_text; output=%s", data["transcript_format"], stdout.String()) - } - if _, ok := data["format"]; ok { - t.Fatalf("output should not expose ambiguous format field: %#v", data) - } -} - -func TestNoteTranscriptPassesLocaleThrough(t *testing.T) { - factory, stdout, _, reg := noteShortcutTestFactory(t) - dir := t.TempDir() - cmdutil.TestChdir(t, dir) - - reg.Register(noteDetailStub("note_locale", displayTypeUnified)) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/note_locale/unified_note_transcript?format=markdown&locale=en_us&page_size=200", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": false, - "transcript": map[string]interface{}{ - "markdown": "# en transcript\n", - }, - }, - }, - }) - - err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_locale", "--locale", "en_us", "--as", "user"}, factory, stdout) - if err != nil { - t.Fatalf("err=%v", err) - } - content, err := os.ReadFile(filepath.Join(dir, "notes", "note_locale", "unified_transcript.md")) - if err != nil { - t.Fatalf("ReadFile transcript err=%v", err) - } - if string(content) != "# en transcript\n" { - t.Fatalf("transcript = %q, want en transcript", string(content)) - } -} - -func TestNoteTranscriptDefaultsLocaleFromLarkBrand(t *testing.T) { - config := &core.CliConfig{ - AppID: "test-app-lark-locale", - AppSecret: "test-secret", - Brand: core.BrandLark, - UserOpenId: "ou_testuser", - } - factory, stdout, _, reg := noteShortcutTestFactoryWithConfig(t, config) - dir := t.TempDir() - cmdutil.TestChdir(t, dir) - - reg.Register(noteDetailStub("note_lark", displayTypeUnified)) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/note_lark/unified_note_transcript?format=markdown&locale=en_us&page_size=200", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": false, - "transcript": map[string]interface{}{ - "markdown": "# en transcript\n", - }, - }, - }, - }) - - err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_lark", "--as", "user"}, factory, stdout) - if err != nil { - t.Fatalf("err=%v", err) - } -} - -func TestNoteTranscriptRejectsExistingOutputBeforeFetch(t *testing.T) { - factory, stdout, _, _ := noteShortcutTestFactory(t) - dir := t.TempDir() - cmdutil.TestChdir(t, dir) - outPath := filepath.Join("notes", "note_exists", "unified_transcript.md") - if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { - t.Fatalf("MkdirAll err=%v", err) - } - if err := os.WriteFile(outPath, []byte("old"), 0644); err != nil { - t.Fatalf("WriteFile err=%v", err) - } - - err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_exists", "--as", "user"}, factory, stdout) - if err == nil { - t.Fatal("expected existing output to fail") - } - if got := err.Error(); !strings.Contains(got, "output file already exists") { - t.Fatalf("err = %q, want existing output error", got) - } - var validationErr *errs.ValidationError - if !errors.As(err, &validationErr) { - t.Fatalf("err = %T, want ValidationError", err) - } - if validationErr.Subtype != errs.SubtypeFailedPrecondition { - t.Fatalf("subtype = %v, want FailedPrecondition", validationErr.Subtype) - } - if !strings.Contains(validationErr.Hint, "--overwrite") { - t.Fatalf("hint = %q, want --overwrite guidance", validationErr.Hint) - } - // The CLI picked the default path itself, so no input param is at fault. - if validationErr.Param != "" { - t.Fatalf("param = %q, want empty for default output path", validationErr.Param) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } -} - -func TestNoteTranscriptRejectsEmptyTranscript(t *testing.T) { - factory, stdout, _, reg := noteShortcutTestFactory(t) - dir := t.TempDir() - cmdutil.TestChdir(t, dir) - - reg.Register(noteDetailStub("note_empty", displayTypeUnified)) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/note_empty/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": false, - "transcript": map[string]interface{}{ - "markdown": "", - }, - }, - }, - }) - - err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_empty", "--as", "user"}, factory, stdout) - if err == nil { - t.Fatal("expected empty transcript to fail") - } - if got := err.Error(); !strings.Contains(got, "transcript is empty") || !strings.Contains(got, "note_empty") { - t.Fatalf("err = %q, want empty transcript error", got) - } - problem, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T", err) - } - if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { - t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype) - } - if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_empty", "unified_transcript.md")); !os.IsNotExist(statErr) { - t.Fatalf("transcript file should not exist, statErr=%v", statErr) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } -} - -func TestNoteTranscriptRejectsCursorCycle(t *testing.T) { - factory, stdout, _, reg := noteShortcutTestFactory(t) - dir := t.TempDir() - cmdutil.TestChdir(t, dir) - - reg.Register(noteDetailStub("note_cycle", displayTypeUnified)) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/note_cycle/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": true, - "next_cursor_id": "A", - "transcript": map[string]interface{}{ - "markdown": "page1\n", - }, - }, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "cursor_id=A", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": true, - "next_cursor_id": "B", - "transcript": map[string]interface{}{ - "markdown": "page2\n", - }, - }, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "cursor_id=B", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "has_more": true, - "next_cursor_id": "A", - "transcript": map[string]interface{}{ - "markdown": "page3\n", - }, - }, - }, - }) - - err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_cycle", "--as", "user"}, factory, stdout) - if err == nil { - t.Fatal("expected cursor cycle to fail") - } - if got := err.Error(); !strings.Contains(got, "pagination cursor did not advance") { - t.Fatalf("err = %q, want cursor advance error", got) - } - problem, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T", err) - } - if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { - t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype) - } - if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_cycle", "unified_transcript.md")); !os.IsNotExist(statErr) { - t.Fatalf("transcript file should not exist, statErr=%v", statErr) - } -} - -func noteShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { - t.Helper() - config := &core.CliConfig{ - AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"), - AppSecret: "test-secret", - Brand: core.BrandFeishu, - UserOpenId: "ou_testuser", - } - return noteShortcutTestFactoryWithConfig(t, config) -} - -func noteShortcutTestFactoryWithConfig(t *testing.T, config *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { - t.Helper() - return cmdutil.TestFactory(t, config) -} - -func runNoteShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { - t.Helper() - parent := &cobra.Command{Use: "note"} - shortcut.Mount(parent, factory) - parent.SetArgs(args) - parent.SilenceErrors = true - parent.SilenceUsage = true - stdout.Reset() - if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok { - stderr.Reset() - } - return parent.ExecuteContext(context.Background()) -} - -func noteDetailStub(noteID string, displayType int) *httpmock.Stub { - return &httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/" + noteID, - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "note": map[string]interface{}{ - "note_display_type": displayType, - "artifacts": []interface{}{ - map[string]interface{}{"artifact_type": artifactTypeVerbatim, "doc_token": "doc_verbatim"}, - }, - }, - }, - }, - } -} - -func decodeNoteEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { - t.Helper() - var envelope map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { - t.Fatalf("decode stdout: %v\nstdout=%s", err, stdout.String()) - } - if data, _ := envelope["data"].(map[string]interface{}); data != nil { - return data - } - return envelope -} diff --git a/shortcuts/note/shortcuts.go b/shortcuts/note/shortcuts.go deleted file mode 100644 index 6d567e4c..00000000 --- a/shortcuts/note/shortcuts.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package note - -import "github.com/larksuite/cli/shortcuts/common" - -// Shortcuts returns all note-domain shortcuts. -func Shortcuts() []common.Shortcut { - return []common.Shortcut{ - NoteDetail, - NoteTranscript, - } -} diff --git a/shortcuts/register.go b/shortcuts/register.go index 89484278..22e3f839 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -29,7 +29,6 @@ import ( "github.com/larksuite/cli/shortcuts/mail" "github.com/larksuite/cli/shortcuts/markdown" "github.com/larksuite/cli/shortcuts/minutes" - "github.com/larksuite/cli/shortcuts/note" "github.com/larksuite/cli/shortcuts/sheets" sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward" "github.com/larksuite/cli/shortcuts/slides" @@ -80,7 +79,6 @@ func init() { allShortcuts = append(allShortcuts, minutes.Shortcuts()...) allShortcuts = append(allShortcuts, task.Shortcuts()...) allShortcuts = append(allShortcuts, vc.Shortcuts()...) - allShortcuts = append(allShortcuts, note.Shortcuts()...) allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...) allShortcuts = append(allShortcuts, wiki.Shortcuts()...) allShortcuts = append(allShortcuts, okr.Shortcuts()...) diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index bd4805c7..e433cbce 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -13,6 +13,7 @@ package vc import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -29,7 +30,6 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" - "github.com/larksuite/cli/shortcuts/note" ) // per-flag additional scope requirements for +notes (vc:note:read is checked by framework) @@ -51,6 +51,12 @@ var ( } ) +// artifact type enum from note detail API +const ( + artifactTypeMainDoc = 1 // main note document + artifactTypeVerbatim = 2 // verbatim transcript +) + const logPrefix = "[vc +notes]" const ( @@ -60,6 +66,9 @@ const ( recordingNotFoundCode = 121004 // 该会议没有妙记文件 recordingNoPermissionCode = 121005 // 非会议参与者无权查看 recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中 + + // note detail API specific error code. + noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限 ) func minutesReadError(err error, minuteToken string) error { @@ -212,7 +221,7 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont // 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 noteID, _ := noteResult["note_id"].(string); noteID != "" { + if _, ok := noteResult["note_doc_token"].(string); ok { for k, v := range noteResult { result[k] = v } @@ -360,13 +369,11 @@ func joinErrors(msgs ...string) string { // hasNotesPayload reports whether a result map carries any usable note or // minute payload, irrespective of partial failures surfaced via `error`. -// note_id counts: it is the routing key for `note +detail` / `note +transcript`, -// so a detail hit without doc tokens is still an actionable result. func hasNotesPayload(m map[string]any) bool { if m == nil { return false } - for _, k := range []string{"note_id", "note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} { + 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 } @@ -512,22 +519,84 @@ func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title str return transcriptPath } -// fetchNoteDetail retrieves note fields via note_id by delegating to the note -// domain (the canonical owner of note-detail parsing) and adapting the typed -// result into the historical map shape `vc +notes` merges into its output. The -// new note_id / note_display_type fields ride along via Detail.ToMap. -func fetchNoteDetail(ctx context.Context, runtime *common.RuntimeContext, noteID string) map[string]any { - detail, err := note.FetchDetail(ctx, runtime, noteID) - if err != nil { - if problem, ok := errs.ProblemOf(err); ok && problem.Code == note.NoNoteReadPermissionCode { - return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", problem.Code)} +// parseArtifactType extracts artifact_type as int from varying JSON number representations. +func parseArtifactType(v any) int { + switch n := v.(type) { + case json.Number: + i, _ := n.Int64() + return int(i) + case float64: + return int(n) + default: + return 0 + } +} + +// extractArtifactTokens picks main-doc and verbatim-doc tokens from the artifacts list. +func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) { + for _, a := range artifacts { + artifact, _ := a.(map[string]any) + if artifact == nil { + continue } - if problem, ok := errs.ProblemOf(err); ok && problem.Subtype == errs.SubtypeInvalidResponse && problem.Message == "note detail is empty" { - return map[string]any{"error": problem.Message} + docToken, _ := artifact["doc_token"].(string) + switch parseArtifactType(artifact["artifact_type"]) { + case artifactTypeMainDoc: + noteDoc = docToken + case artifactTypeVerbatim: + verbatimDoc = docToken + default: + // ignore unknown artifact types + } + } + return +} + +// extractDocTokens collects doc_token values from a list of reference objects. +func extractDocTokens(refs []any) []string { + var tokens []string + for _, s := range refs { + source, _ := s.(map[string]any) + if source == nil { + continue + } + if docToken, _ := source["doc_token"].(string); docToken != "" { + tokens = append(tokens, docToken) + } + } + return tokens +} + +// fetchNoteDetail retrieves note document tokens via note_id. +func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any { + data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil) + if err != nil { + if p, ok := errs.ProblemOf(err); ok && p.Code == noteNoPermissionCode { + return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", p.Code)} } return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)} } - return detail.ToMap() + + note, _ := data["note"].(map[string]any) + if note == nil { + return map[string]any{"error": "note detail is empty"} + } + + creatorID, _ := note["creator_id"].(string) + createTime := common.FormatTime(note["create_time"]) + noteDocToken, verbatimDocToken := extractArtifactTokens(common.GetSlice(note, "artifacts")) + sharedDocTokens := extractDocTokens(common.GetSlice(note, "references")) + + result := map[string]any{ + "creator_id": creatorID, + "create_time": createTime, + "note_doc_token": noteDocToken, + "verbatim_doc_token": verbatimDocToken, + } + if len(sharedDocTokens) > 0 { + result["shared_doc_tokens"] = sharedDocTokens + } + return result } // VCNotes queries meeting notes via meeting-ids, minute-tokens, or calendar-event-ids. @@ -706,12 +775,6 @@ var VCNotes = common.Shortcut{ id, _ = m["calendar_event_id"].(string) } row := map[string]interface{}{"id": id} - if v, _ := m["note_id"].(string); v != "" { - row["note_id"] = v - } - if v, _ := m["note_display_type"].(string); v != "" { - row["note_display_type"] = v - } if errMsg, _ := m["error"].(string); errMsg != "" { row["status"] = "FAIL" row["error"] = errMsg diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index 14f281a8..f8fc2c11 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -23,7 +23,6 @@ import ( "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" - "github.com/larksuite/cli/shortcuts/note" ) // --------------------------------------------------------------------------- @@ -120,21 +119,6 @@ func noteDetailStub(noteID string) *httpmock.Stub { } } -func noteDetailDisplayOnlyStub(noteID string, displayType int) *httpmock.Stub { - return &httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/" + noteID, - Body: map[string]interface{}{ - "code": 0, "msg": "ok", - "data": map[string]interface{}{ - "note": map[string]interface{}{ - "note_display_type": displayType, - }, - }, - }, - } -} - func artifactsStub(token, transcript string) *httpmock.Stub { data := map[string]interface{}{ "summary": "Test summary content", @@ -194,9 +178,68 @@ func TestSanitizeDirName(t *testing.T) { } } -// Note-detail parsing helpers (parseArtifactType/extractArtifactTokens/ -// extractDocTokens) moved to the note domain; their tests live in -// shortcuts/note/note_test.go. +func TestParseArtifactType(t *testing.T) { + tests := []struct { + input any + want int + }{ + {float64(1), 1}, + {float64(2), 2}, + {json.Number("3"), 3}, + {"unknown", 0}, + {nil, 0}, + } + for _, tt := range tests { + got := parseArtifactType(tt.input) + if got != tt.want { + t.Errorf("parseArtifactType(%v) = %d, want %d", tt.input, got, tt.want) + } + } +} + +func TestExtractArtifactTokens(t *testing.T) { + artifacts := []any{ + map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)}, + map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)}, + map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)}, + nil, + } + noteDoc, verbatimDoc := extractArtifactTokens(artifacts) + if noteDoc != "main_doc" { + t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc") + } + if verbatimDoc != "verbatim_doc" { + t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc") + } +} + +func TestExtractArtifactTokens_Empty(t *testing.T) { + noteDoc, verbatimDoc := extractArtifactTokens(nil) + if noteDoc != "" || verbatimDoc != "" { + t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc) + } +} + +func TestExtractDocTokens(t *testing.T) { + refs := []any{ + map[string]any{"doc_token": "shared1"}, + map[string]any{"doc_token": "shared2"}, + map[string]any{"doc_token": ""}, + map[string]any{}, + nil, + } + tokens := extractDocTokens(refs) + if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" { + t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens) + } +} + +func TestExtractDocTokens_Empty(t *testing.T) { + tokens := extractDocTokens(nil) + if tokens != nil { + t.Errorf("expected nil for nil input, got %v", tokens) + } +} // --------------------------------------------------------------------------- // Integration tests: +notes with mocked HTTP @@ -319,6 +362,25 @@ func TestNotes_BatchLimit(t *testing.T) { } } +func TestParseArtifactType_AllBranches(t *testing.T) { + // cover json.Number branch + if got := parseArtifactType(json.Number("1")); got != 1 { + t.Errorf("json.Number: got %d, want 1", got) + } + // cover float64 branch + if got := parseArtifactType(float64(2)); got != 2 { + t.Errorf("float64: got %d, want 2", got) + } + // cover default branch + if got := parseArtifactType("str"); got != 0 { + t.Errorf("default: got %d, want 0", got) + } + // cover nil + if got := parseArtifactType(nil); got != 0 { + t.Errorf("nil: got %d, want 0", got) + } +} + // --------------------------------------------------------------------------- // Unit tests for new calendar-to-notes functions // --------------------------------------------------------------------------- @@ -533,33 +595,6 @@ func TestNotes_CalendarPath_FallbackWhenMeetingChainFails(t *testing.T) { } } -func TestNotes_CalendarPath_KeepsNoteIDOnlyDetail(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) - - calID := "cal_test" - reg.Register(primaryCalendarStub(calID)) - reg.Register(calendarRelationStub(calID, "evt_note_only", []string{"m_note_only"}, nil)) - reg.Register(meetingGetStub("m_note_only", "note_only")) - reg.Register(noteDetailDisplayOnlyStub("note_only", 2)) - reg.Register(recordingErrStub("m_note_only", 121004, "not found")) - - err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt_note_only", "--as", "user"}, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - note := extractFirstNote(t, stdout) - if got := note["note_id"]; got != "note_only" { - t.Fatalf("note_id = %v, want note_only; note=%#v", got, note) - } - if got := note["note_display_type"]; got != "unified" { - t.Fatalf("note_display_type = %v, want unified; note=%#v", got, note) - } - if got := note["calendar_event_id"]; got != "evt_note_only" { - t.Fatalf("calendar_event_id = %v, want evt_note_only; note=%#v", got, note) - } -} - func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) warmTokenCache(t) @@ -613,26 +648,6 @@ func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) { } } -func TestNotes_TableOutputIncludesNoteRoutingFields(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) - - reg.Register(meetingGetStub("m_table", "note_table")) - reg.Register(noteDetailDisplayOnlyStub("note_table", 2)) - reg.Register(recordingErrStub("m_table", 121004, "not found")) - - err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_table", "--format", "table", "--as", "user"}, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - out := stdout.String() - if !strings.Contains(out, "note_table") { - t.Fatalf("table output missing note_id:\n%s", out) - } - if !strings.Contains(out, "unified") { - t.Fatalf("table output missing note_display_type:\n%s", out) - } -} - // --------------------------------------------------------------------------- // Transcript path layout tests (unified ./minutes/{token}/ default) // --------------------------------------------------------------------------- @@ -741,9 +756,7 @@ func TestHasNotesPayload(t *testing.T) { {"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": "", "note_id": ""}, false}, - {"only note_id", map[string]any{"note_id": "note1"}, true}, - {"note_id with display type", map[string]any{"note_id": "note1", "note_display_type": "unified", "note_doc_token": ""}, true}, + {"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}, @@ -1253,7 +1266,7 @@ func TestFetchNoteDetail_NoteNoPermission_ProblemOf(t *testing.T) { // meeting.get returns note_id, note detail returns 121005 reg.Register(meetingGetStub("m_noteperm2", "note_perm2")) - reg.Register(noteDetailErrStub("note_perm2", note.NoNoteReadPermissionCode, "no permission")) + reg.Register(noteDetailErrStub("note_perm2", noteNoPermissionCode, "no permission")) reg.Register(recordingOKStub("m_noteperm2", "https://meetings.feishu.cn/minutes/obcpermtest")) // note fails but minute_token succeeds → partial success (hasNotesPayload=true) @@ -1273,29 +1286,6 @@ func TestFetchNoteDetail_NoteNoPermission_ProblemOf(t *testing.T) { } } -func TestFetchNoteDetail_EmptyDetailKeepsLegacyError(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/vc/v1/notes/note_empty_detail", - Body: map[string]any{ - "code": 0, - "data": map[string]any{}, - }, - }) - - if err := botExec(t, "empty-note-detail", f, func(ctx context.Context, rctx *common.RuntimeContext) error { - got := fetchNoteDetail(ctx, rctx, "note_empty_detail") - if got["error"] != "note detail is empty" { - t.Fatalf("error = %#v, want legacy empty-detail text", got["error"]) - } - return nil - }); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - // TestNotes_AllFailed_OutPartialFailure pins that when every item in the batch // fails (successCount == 0), Execute returns *output.PartialFailureError with // ExitAPI code, and stdout still carries the ok:false envelope with notes data. diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 8fb1a214..3a170e94 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -56,7 +56,6 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen | `` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) | | `` | 同 `` | [`lark-sheets`](../lark-sheets/SKILL.md) | | `` | 同 `` | [`lark-base`](../lark-base/SKILL.md) | -| `` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id ` | | `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block | ## Shortcuts(推荐优先使用) diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index 7cd3a330..f4cbfb4d 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-minutes version: 1.0.0 -description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记;只有自然语言纪要标题时不要走本 skill" +description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc" metadata: requires: bins: ["lark-cli"] @@ -45,7 +45,6 @@ metadata: | "重命名妙记/改妙记标题" | 本 skill(`+update`) | | "替换说话人/把 A 的发言改成 B" | 本 skill(`+speaker-replace`) | | "这个妙记的逐字稿/总结/待办/章节" | [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) | -| "xx 纪要的逐字稿/原始记录/谁说了什么" 且没有 `minute_token` / 妙记 URL / 本地音视频文件 | 不走本 skill;路由到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md),必要时再到 [lark-note](../lark-note/SKILL.md) | | "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill(`+upload`),再 [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) | | 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`),再本 skill | @@ -167,7 +166,6 @@ Minutes (妙记) ← minute_token 标识 > - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [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` -> - 用户只给自然语言纪要标题并说"查 xx 纪要的逐字稿 / 原始记录 / 谁说了什么"时,不要因为出现"逐字稿"就走 `minutes +search` 或 `vc +notes --minute-tokens`;这不是妙记入口,应先搜索纪要文档并 fetch 正文。有 `vc-node-id` 再进入 Note 域,否则读取正文中明确给出的“文字记录/逐字稿” Docx 链接 > - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准 > - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果 > - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限 @@ -181,12 +179,6 @@ Minutes (妙记) ← minute_token 标识 > - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update` > - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace` > - 用户说"批量替换逐字稿关键词" → `minutes +word-replace` -> -> **Note 域边界(禁止规则)**:`minute_token` 是妙记文件标识,**不是** `note_id`。 -> - 不要把 `minute_token` 传给 `note +detail` 或 `note +transcript`。 -> - 不在本 skill 处理 Note 详情或 unified transcript。 -> - 已有 `minute_token` 且需要纪要产物索引(含 `note_id` / `note_display_type`)时,走 `vc +notes --minute-tokens`;拿到 `note_id` 后再切到 [lark-note](../lark-note/SKILL.md)。 -> - 只有自然语言纪要标题时,先搜索纪要文档并 fetch 正文;有 `vc-node-id` 才进入 Note 域,否则读取正文中明确给出的“文字记录/逐字稿” Docx 链接,不要从 Minutes 反查。 ## Shortcuts(推荐优先使用) @@ -226,7 +218,6 @@ lark-cli minutes [flags] ## 不在本 skill 范围 -- 已有 `minute_token` 的纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) -- 只有自然语言纪要标题的逐字稿查询 → 先搜索纪要文档并 fetch 正文;有 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md),否则读取正文中明确给出的“文字记录/逐字稿” Docx 链接 +- 纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) - 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md) - 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md) diff --git a/skills/lark-note/SKILL.md b/skills/lark-note/SKILL.md deleted file mode 100644 index ed1c0ad4..00000000 --- a/skills/lark-note/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: lark-note -version: 1.0.0 -description: "飞书会议纪要(Note)直查:已知 note_id 时查询纪要详情、展示类型(普通纪要 / unified 纪要)、关联文档 token,以及 unified 纪要的原始逐字记录(unified transcript)。用户已经持有 note_id 并想查纪要元信息、纪要类型、纪要/逐字稿文档 token 时使用本技能;unified 纪要的逐字稿不是独立文档,必须用 note +transcript 按 note_id 拉取。本技能只接受 note_id 入口。" -metadata: - requires: - bins: ["lark-cli"] - cliHelp: "lark-cli note --help" ---- - -# note (v1) - -**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。** - -Note 域负责**已知 `note_id`** 时的纪要直查。它不反查会议、日程、妙记或文档标题,也不读取 Docx 正文——那些分别属于 `lark-vc`、`lark-minutes`、`lark-doc`。 - -> **`note_id` 来源:** 如果入口是文档,先用 `docs +fetch --api-version v2 --doc ` 读取文档元信息;返回里的 `` 表示 VC 原始记录 block,其中 `vc-node-id` 属性值就是 Note 域使用的 `note_id`。这是显式属性映射,不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`。 -> -> **只有纪要标题时:** 用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `note_id`、`meeting_id`、`calendar_event_id`、`minute_token`、会议号或妙记 URL 时,先搜索纪要文档并 fetch 正文。只有正文里的 `` 可以进入本 skill;否则只读取正文中明确给出的“文字记录/逐字稿” Docx 链接,不要强行进入 Note 域。 - -## 核心概念 - -- **Note(会议纪要)**:会议结束后生成的纪要实体,通过 `note_id` 标识。 -- **展示类型(`note_display_type`)**:区分纪要形态,取值 `unknown` / `normal` / `unified`。 - - `normal`(普通纪要):纪要正文和逐字稿是两份独立的飞书文档,分别对应 `note_doc_token`、`verbatim_doc_token`。 - - `unified`:纪要正文、AI 产物、逐字记录合并呈现;**逐字稿不再是独立文档**,要用 `note +transcript` 按 `note_id` 拉取原始记录。 -- **文档 token**:`note_doc_token`(AI 智能纪要主文档)、`verbatim_doc_token`(普通纪要逐字稿文档)、`shared_doc_tokens`(会中共享文档)。拿到 token 后读正文交给 [lark-doc](../lark-doc/SKILL.md)。 - -## 触发规则 - -| 用户表达 | 命令 / 路由 | -|---------|------| -| 已知 `note_id`,查纪要详情 / 纪要类型 / 关联文档 token | `note +detail --note-id NOTE_ID` | -| 只有自然语言纪要标题,用户要逐字稿 / 原始记录 / 谁说了什么 | 不进本 skill;先路由到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md),拿到 `vc-node-id` 后再回来 | -| `docs +fetch --api-version v2` 返回了 ``,要进入 Note 域 | 把 `vc-node-id` 属性值作为 `NOTE_ID`:`note +detail --note-id ` | -| 已知 `note_id`,查 unified 原始记录 / 逐字稿 | `note +transcript --note-id NOTE_ID` | -| 已知 `note_id`,读纪要正文 | 先 `note +detail` 拿 `note_doc_token`,再调 `docs +fetch --api-version v2 --doc ` | - -## 路由规则(拿到 detail 后按 `note_display_type` 决策) - -| 条件 | Agent 后续动作 | -|------|---------------| -| 用户要纪要正文 / 总结 / 待办 / 章节 | `docs +fetch --api-version v2 --doc ` | -| `note_display_type=normal`,用户要逐字稿 / 谁说了什么 | `docs +fetch --api-version v2 --doc ` | -| `note_display_type=unknown`,且 `verbatim_doc_token` 非空,用户要逐字稿 / 谁说了什么 | `docs +fetch --api-version v2 --doc `;不要猜成 unified | -| `note_display_type=unknown`,且无可用逐字稿 token | 如果当前结果来自 `vc +notes`,可补一次 `note +detail --note-id ` 复核;如果 `note +detail` 后仍是 `unknown` 且没有逐字稿 token,停止重试并告知用户无法确定逐字稿入口 | -| `note_display_type=unified`,用户要逐字稿 / 原始记录 / 谁说了什么 | `note +transcript --note-id ` | - -> **判别键是 `note_display_type`,不是 `verbatim_doc_token` 是否为空。** unified 纪要的 `verbatim_doc_token` 也可能有值,但 unified 的逐字稿应统一走 `note +transcript`(输出更结构化)。 - -## 禁止规则 - -- 不处理 `meeting_id` —— 那是 [lark-vc](../lark-vc/SKILL.md) 的入口。 -- 不处理 `calendar_event_id` —— 那是 [lark-vc](../lark-vc/SKILL.md) 的入口。 -- 不处理 `minute_token` —— 那是 [lark-vc](../lark-vc/SKILL.md)(纪要产物索引)/ [lark-minutes](../lark-minutes/SKILL.md)(妙记基础信息与媒体)的入口。 -- 不处理自然语言纪要标题搜索 —— 先搜索纪要文档并 fetch 正文;只有 fetch 结果里的 `vc-node-id` 可以作为 `note_id`,普通纪要里的“文字记录/逐字稿” Docx 链接仍由 [lark-doc](../lark-doc/SKILL.md) 读取。 -- 不读取 Docx 正文 —— 拿到文档 token 后交给 [lark-doc](../lark-doc/SKILL.md)。 -- 不从纪要正文或 `doc_token` 反推 `note_id`;只有 `docs +fetch --api-version v2` 结果中 `` 的显式 `vc-node-id` 属性可以作为 `note_id`。 - -## Shortcuts(推荐优先使用) - -Shortcut 是对常用操作的高级封装(`lark-cli note + [flags]`)。 - -| Shortcut | 说明 | -|----------|------| -| [`+detail`](references/lark-note-detail.md) | Get note detail (display type, document tokens) by note_id | -| [`+transcript`](references/lark-note-transcript.md) | Fetch the unified note transcript and save it to a file | - -- 使用 `+detail` 命令时,必须阅读 [references/lark-note-detail.md](references/lark-note-detail.md)。 -- 使用 `+transcript` 命令时,必须阅读 [references/lark-note-transcript.md](references/lark-note-transcript.md)。 - -## 权限表 - -| 方法 | 所需 scope | -|------|-----------| -| `+detail` | `vc:note:read` | -| `+transcript` | `vc:note:read` | - -## 参考 - -- [lark-vc](../lark-vc/SKILL.md) — 从 meeting_id / calendar_event_id / minute_token 定位 note_id -- [lark-doc](../lark-doc/SKILL.md) — 读取纪要正文 / 普通逐字稿文档正文 -- [lark-minutes](../lark-minutes/SKILL.md) — 妙记基础信息与媒体下载 -- [lark-shared](../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-note/references/lark-note-detail.md b/skills/lark-note/references/lark-note-detail.md deleted file mode 100644 index d5855f17..00000000 --- a/skills/lark-note/references/lark-note-detail.md +++ /dev/null @@ -1,79 +0,0 @@ - -# note +detail - -> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -通过已知 `note_id` 查询纪要元信息、展示类型和关联文档 token。只读操作,仅支持 `user` 身份。 - -本 skill 对应 shortcut:`lark-cli note +detail`。 - -> **`note_id` 来源:** 从文档入口进入时,先执行 `docs +fetch --api-version v2 --doc `;返回里的 `` 表示 VC 原始记录 block,其中 `vc-node-id` 属性值就是这里的 `NOTE_ID`。如果没有该 block,但正文里有“文字记录/逐字稿”等明确 Docx 链接,那是普通纪要的独立逐字稿文档,直接用 `docs +fetch --api-version v2 --doc ` 读取,不要从 `doc_token` 反推 `note_id`。 - -## 命令 - -```bash -lark-cli note +detail --note-id NOTE_ID -lark-cli note +detail --note-id NOTE_ID --format json -lark-cli note +detail --note-id NOTE_ID --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--note-id ` | 是 | Note ID;如果来自 `docs +fetch --api-version v2`,取 `` 的 `vc-node-id` 属性值 | -| `--dry-run` | 否 | 预览 API 调用,不执行 | - -## 输出结果 - -返回 `note` 对象,包含: - -| 字段 | 说明 | -|------|------| -| `note_id` | 输入的 Note ID(显式回显) | -| `note_display_type` | `unknown` / `normal` / `unified`,区分普通纪要和 unified 纪要 | -| `note_doc_token` | AI 智能纪要主文档 token | -| `verbatim_doc_token` | 普通纪要逐字稿文档 token | -| `shared_doc_tokens` | 会中共享文档 token 列表(为空时省略) | -| `creator_id` | 创建者 ID | -| `create_time` | 创建时间(格式化) | - -输出示例: - -```json -{ - "note": { - "note_id": "note_xxxx", - "note_display_type": "unified", - "note_doc_token": "doxcnxxxx", - "verbatim_doc_token": "doxcnyyyy", - "shared_doc_tokens": ["doxcnzzzz"], - "creator_id": "ou_xxxx", - "create_time": "2026-06-04 10:00" - } -} -``` - -## 拿到结果后的路由 - -| 用户意图 | 后续动作 | -|---------|---------| -| 读纪要正文 / 总结 / 待办 / 章节 | `docs +fetch --api-version v2 --doc ` | -| `note_display_type=normal` + 要逐字稿 | `docs +fetch --api-version v2 --doc ` | -| `note_display_type=unified` + 要逐字稿 / 原始记录 | `note +transcript --note-id `(见 [lark-note-transcript.md](lark-note-transcript.md)) | - -> **判别键是 `note_display_type`。** 即使 unified 纪要也返回了非空 `verbatim_doc_token`,unified 的逐字稿仍应走 `note +transcript`(内容更结构化)。 - -## 常见错误与排查 - -| 错误现象 | 根本原因 | 解决方案 | -|---------|---------|---------| -| `--note-id is required` | 未传入 note_id | 补全 `--note-id` | -| `no read permission for this note` | 调用者无该纪要阅读权限 | 向纪要所有者申请权限 | -| `missing required scope(s)` | 缺少 `vc:note:read` | 按提示运行 `auth login --scope vc:note:read` | - -## 参考 - -- [lark-note](../SKILL.md) — Note 域总入口 -- [lark-note-transcript](lark-note-transcript.md) — unified 原始记录查询 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-note/references/lark-note-transcript.md b/skills/lark-note/references/lark-note-transcript.md deleted file mode 100644 index 973496a0..00000000 --- a/skills/lark-note/references/lark-note-transcript.md +++ /dev/null @@ -1,81 +0,0 @@ - -# note +transcript - -> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -通过已知 `note_id` 查询 **unified 纪要的原始逐字记录**,CLI 内部全量翻页后保存到本地文件。只读操作,仅支持 `user` 身份。 - -本 skill 对应 shortcut:`lark-cli note +transcript`。 - -> **何时用这个命令?** 当 `note +detail` 或 `vc +notes` 返回 `note_display_type=unified`,且用户想要逐字稿 / 原始记录 / 谁说了什么时。普通纪要(`normal`)的逐字稿是独立文档,应改用 `docs +fetch --api-version v2 --doc `。 -> -> **`note_id` 来源:** 如果当前只有纪要文档 token / URL,先 `docs +fetch --api-version v2 --doc `;返回中的 `` 表示 VC 原始记录 block,其中 `vc-node-id` 属性值就是 `--note-id`。如果没有该 block,但正文里有“文字记录/逐字稿”等明确 Docx 链接,那是普通纪要的独立逐字稿文档,应改用 `docs +fetch --api-version v2 --doc `,不要调用本命令。 - -## 命令 - -```bash -# 默认 markdown,保存到 ./notes/{note_id}/unified_transcript.md -lark-cli note +transcript --note-id NOTE_ID - -# 纯文本输出,保存到 ./notes/{note_id}/unified_transcript.txt -lark-cli note +transcript --note-id NOTE_ID --transcript-format plain_text - -# 指定输出文件 -lark-cli note +transcript --note-id NOTE_ID --output ./transcript.md --overwrite - -# 预览 API 调用 -lark-cli note +transcript --note-id NOTE_ID --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--note-id ` | 是 | Note ID;如果来自 `docs +fetch --api-version v2`,取 `` 的 `vc-node-id` 属性值 | -| `--transcript-format ` | 否 | 逐字稿内容格式:`markdown`(默认)/ `plain_text` | -| `--locale ` | 否 | 系统文案语言;默认跟随 profile language,未配置时 Feishu 为 `zh_cn`、Lark 为 `en_us`,也支持 `ja_jp` 等 | -| `--output ` | 否 | 输出文件路径;不传时默认落到 `./notes/{note_id}/unified_transcript.{md,txt}` | -| `--overwrite` | 否 | 覆盖已存在的输出文件 | -| `--dry-run` | 否 | 预览 API 调用,不执行 | - -## 输出结果 - -| 字段 | 说明 | -|------|------| -| `note_id` | 输入的 Note ID | -| `transcript_format` | 逐字稿内容格式:`markdown` / `plain_text` | -| `transcript_file` | 本地 transcript 文件路径 | -| `size_bytes` | 写入文件大小 | - -输出示例: - -```json -{ - "note_id": "note_xxxx", - "transcript_format": "markdown", - "transcript_file": "notes/note_xxxx/unified_transcript.md", - "size_bytes": 123456 -} -``` - -## 执行说明 - -- 该 API 分页返回,CLI 内部自动翻页(`cursor_id`)并把全部内容拼接保存,**不暴露分页参数**。 -- 任一页失败会整体报错,不保存半截 transcript。 -- 首期不支持 `structured` 输出格式。 -- 默认 `markdown`,作为 AI Friendly 输出;`plain_text` 为轻量纯文本。 - -## 常见错误与排查 - -| 错误现象 | 根本原因 | 解决方案 | -|---------|---------|---------| -| `--note-id is required` | 未传入 note_id | 补全 `--note-id` | -| `output file already exists` | 目标文件已存在 | 加 `--overwrite` 覆盖,或换 `--output` 路径 | -| `no read permission for this note` | 调用者无该纪要阅读权限 | 向纪要所有者申请权限 | -| `missing required scope(s)` | 缺少 `vc:note:read` | 按提示运行 `auth login --scope vc:note:read` | - -## 参考 - -- [lark-note](../SKILL.md) — Note 域总入口 -- [lark-note-detail](lark-note-detail.md) — 纪要详情与展示类型查询 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-vc/SKILL.md b/skills/lark-vc/SKILL.md index 378d2459..51bf0991 100644 --- a/skills/lark-vc/SKILL.md +++ b/skills/lark-vc/SKILL.md @@ -47,15 +47,14 @@ lark-cli vc +search --query "站会" --start-time ... | 查"昨天的会议""上周的会""已结束的会议" | 本 skill(`+search`,含即时会议) | | 查日历/日程或未来时间的会议 | [lark-calendar](../lark-calendar/SKILL.md) | | 查"今天有哪些会议" | `vc +search`(已结束)+ lark-calendar(未开始),合并展示 | -| 只按自然语言标题查"xx 纪要的逐字稿 / 原始记录 / 谁说了什么" | [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md),必要时再到 [lark-note](../lark-note/SKILL.md) | | Agent 真实入会/离会、会中实时事件 | [lark-vc-agent](../lark-vc-agent/SKILL.md) | | 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再回 `vc +notes --minute-tokens` | ## 核心概念 -- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。 -- **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识,包含纪要文档(总结、待办)和逐字稿文档。`note_display_type` 区分**普通纪要(`normal`)**和 **unified 纪要**;已知 `note_id` 的直查与 unified 原始记录请用 [lark-note](../lark-note/SKILL.md)。 -- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。 +- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索。 +- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(总结+待办)和逐字稿文档。 +- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,包含总结、待办、章节和文字记录,通过 minute_token 标识。 - **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。 - **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。 - **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。 @@ -64,19 +63,13 @@ lark-cli vc +search --query "站会" --start-time ... | 用户意图 | 必须读取的产物 | 禁止 | |---------|-------------|------| -| 提炼/总结/重新总结/整理会议内容/回顾会议 | 原始对话记录(按下方逐字稿路由取得)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 | +| 提炼/总结/重新总结/整理会议内容/回顾会议 | 逐字稿(`verbatim_doc_token`)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 | | 查看待办/章节 | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — | | 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — | | 直接看 AI 总结结果 | AI 纪要(`note_doc_token`) | — | -| 谁说了什么/完整发言记录 | 原始对话记录(按下方逐字稿路由取得) | — | +| 谁说了什么/完整发言记录 | 逐字稿(`verbatim_doc_token`) | — | -> **逐字稿路由(先看 `vc +notes` 返回的 `note_display_type`,不要只看 `verbatim_doc_token` 是否为空):** -> - `note_display_type=normal` → 逐字稿是独立文档:`docs +fetch --api-version v2 --doc ` -> - `note_display_type=unknown` 且 `verbatim_doc_token` 非空 → 先按独立文档处理:`docs +fetch --api-version v2 --doc `,不要猜成 unified -> - `note_display_type=unknown` 且无可用逐字稿 token → 先 `note +detail --note-id ` 复核展示类型 -> - `note_display_type=unified` → 逐字稿不是独立文档:`note +transcript --note-id `,切到 [lark-note](../lark-note/SKILL.md) -> -> **为什么"提炼/总结"必须从原始对话记录出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。 +> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。 ## 核心场景 @@ -84,7 +77,6 @@ lark-cli vc +search --query "站会" --start-time ... 1. 仅支持搜索已结束的会议,对于还未开始的未来会议,需要使用 lark-calendar 技能。 2. 仅支持使用关键词、时间段、参会人、组织者、会议室等筛选条件搜索会议记录,对于不支持的筛选条件,需要提示用户。 3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何会议记录。 -4. 如果用户表达是“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且 `xx` 更像纪要文档标题、没有会议线索(`meeting_id` / `calendar_event_id` / 会议号 / 参会人 / 时间范围),不要把 `xx` 当会议关键词走 `vc +search`;先搜索纪要文档并 fetch 正文。有 `` 时进入 [lark-note](../lark-note/SKILL.md);没有该 block 但有“文字记录/逐字稿” Docx 链接时,直接用 `docs +fetch --api-version v2` 读取该链接。 ### 2. 整理会议纪要 @@ -107,7 +99,7 @@ lark-cli docs +media-download --type whiteboard --token --out > **纪要相关文档 — 根据用户意图选择:** > - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办) > - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回) -> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由:`normal` 用 `verbatim_doc_token`(完整的逐句文字记录,含说话人和时间戳);`unified` 用 `note +transcript --note-id `([lark-note](../lark-note/SKILL.md)) +> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个 > - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有) > - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定 > - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens` @@ -141,19 +133,18 @@ lark-cli vc meeting get --params '{"meeting_id":"","with_participant | 用户意图 | 推荐命令 | 所在 skill | |---------|---------|--------| | 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill | -| 已结束会议的发言内容 | 先 `vc +notes` 取 `note_display_type`:`normal` 用 `verbatim_doc_token` + `docs +fetch --api-version v2`;`unified` 用 `note +transcript --note-id ` | 本 skill / [`lark-note`](../lark-note/SKILL.md) | +| 已结束会议的发言内容 | `vc +notes` 取 `verbatim_doc_token` 再 `docs +fetch --api-version v2` | 本 skill | | **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) | | **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) | ## 资源关系 -```text +``` Meeting (视频会议) -├── Note (会议纪要) ← note_id 标识,note_display_type: normal / unified +├── Note (会议纪要) │ ├── MainDoc (AI 智能纪要文档, note_doc_token) │ ├── MeetingNotes (用户绑定的会议纪要文档, meeting_notes) -│ ├── VerbatimDoc (逐字稿, verbatim_doc_token) ← normal 路径 -│ ├── UnifiedTranscript (unified 原始记录) ← unified 路径,note +transcript(lark-note) +│ ├── VerbatimDoc (逐字稿, verbatim_doc_token) │ └── SharedDoc (会中共享文档) └── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取 ├── Transcript (文字记录) @@ -163,14 +154,6 @@ Meeting (视频会议) └── Keywords (推荐关键词) ``` -> **妙记边界**:`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。 -> -> **Note 域边界**:`vc +notes` 是从**会议线索**(`meeting_id` / `calendar_event_id` / `minute_token`)定位纪要的入口,返回 `note_id` 和 `note_display_type`。 -> - 用户**已经持有 `note_id`** 想查纪要详情 / 类型 / unified 原始记录时,**不要走 `vc +notes`**,直接切到 [lark-note](../lark-note/SKILL.md)。 -> - 用户**已经持有 `doc_token`** 且目标是读正文时,**不要走 `vc +notes`**,直接切到 [lark-doc](../lark-doc/SKILL.md)。 -> - 用户**只有自然语言纪要标题**且要逐字稿 / 原始记录时,**不要先走 `vc +search` 或 `vc +notes`**;先搜索纪要文档并 fetch 正文。有 `` 时进入 [lark-note](../lark-note/SKILL.md);没有该 block 但有“文字记录/逐字稿” Docx 链接时,直接用 `docs +fetch --api-version v2` 读取该链接。 -> - `vc +notes` 返回 `note_display_type=unified` 且用户要逐字稿 / 原始记录时,用返回的 `note_id` 走 `note +transcript`([lark-note](../lark-note/SKILL.md))。 - ## API Resources ```bash @@ -197,6 +180,5 @@ lark-cli vc meeting get --params '{"meeting_id": "", "with_participa - 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md) - Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md) -- 只有纪要文档标题的逐字稿查询 → 先搜索纪要文档并 fetch 正文;有 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md),否则读取正文中明确给出的“文字记录/逐字稿” Docx 链接 - 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`) - 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md) diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md index 15c85be2..ccf83c99 100644 --- a/skills/lark-vc/references/lark-vc-notes.md +++ b/skills/lark-vc/references/lark-vc-notes.md @@ -77,34 +77,17 @@ 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_id` | **纪要 ID** — 用于继续进入 Note 域(`note +detail` / `note +transcript`) | -| `note_display_type` | **纪要展示类型**:`unknown` / `normal` / `unified`,区分普通纪要和 unified 纪要 | | `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 | | `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) | -| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳;unified 纪要的逐字稿请改用 `note +transcript` | +| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳 | | `shared_doc_tokens` | 会中共享文档 Token 列表 | | `creator_id` | 创建者 ID | | `create_time` | 创建时间(格式化) | -> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 见下方「按 `note_display_type` 路由逐字稿」。意图不明确时,展示所有文档链接让用户选择。 +> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示所有文档链接让用户选择。 > > 📌 不确定该返回哪个 token?参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。 -### 按 `note_display_type` 路由逐字稿 / 原始记录 - -逐字稿走哪条路由由 `note_display_type` 决定,**不要只看 `verbatim_doc_token` 是否为空**: - -| 字段 / 条件 | Agent 动作 | -|------------|-----------| -| 用户要纪要正文 / 总结 / 待办 / 章节 | `docs +fetch --api-version v2 --doc ` | -| `note_display_type=normal` + 用户要逐字稿 | `docs +fetch --api-version v2 --doc ` | -| `note_display_type=unknown` + `verbatim_doc_token` 非空 + 用户要逐字稿 | `docs +fetch --api-version v2 --doc `;不要猜成 unified | -| `note_display_type=unknown` + 无可用逐字稿 token | 先 `note +detail --note-id ` 复核,再按返回的展示类型路由 | -| `note_display_type=unified` + 用户要逐字稿 / 原始记录 | `note +transcript --note-id ` → 切到 [lark-note](../../lark-note/SKILL.md) | -| `minute_token` 存在 + 用户要音视频媒体 | `minutes +download --minute-tokens ` | - -> **`unified` 纪要的逐字稿不是独立文档**,必须用 `note +transcript` 按 `note_id` 拉取,输出更结构化。即使 unified 也返回了非空 `verbatim_doc_token`,仍以 `note_display_type` 为准。 - ### minute-tokens 路径的 AI 产物 通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物: diff --git a/skills/lark-vc/references/vc-domain-boundaries.md b/skills/lark-vc/references/vc-domain-boundaries.md index cf64dc88..5da65d49 100644 --- a/skills/lark-vc/references/vc-domain-boundaries.md +++ b/skills/lark-vc/references/vc-domain-boundaries.md @@ -27,7 +27,7 @@ | 产物 | Token 字段 | 本质 | 说明 | |------|-----------|------|------| | 智能纪要 | `note_doc_token` | 飞书文档 | AI 生成的会议总结与待办 | -| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳)— **仅 `note_display_type=normal` 时是可读的独立文档**;`unified` 纪要的逐字稿用 `note +transcript --note-id ` 拉取(见下方 [Note 域](#note-域)) | +| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳) | | 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 | 此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。 @@ -58,7 +58,7 @@ #### 逐字稿与文字记录的格式 -智能纪要的逐字稿(`normal` 纪要的 `verbatim_doc_token` 文档、`unified` 纪要的 `note +transcript` 输出)和妙记的文字记录(Transcript)都记录了用户原始对话内容,格式一致: +智能纪要的逐字稿(`verbatim_doc_token`)和妙记的文字记录(Transcript)都记录了用户原始对话内容,格式一致: ``` 发言人名称 相对时间戳 @@ -81,8 +81,6 @@ 根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。 -> **不要把纪要标题当会议线索:** 如果用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `meeting_id`、`calendar_event_id`、会议号、参会人或时间范围,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch --api-version v2`。若返回 ``,提取 `note_id` 后进入 Note 域判断 `normal` / `unified`;若没有该 block,但有“文字记录/逐字稿” Docx 链接,直接用 `docs +fetch --api-version v2` 读取该链接。 - ```bash lark-cli vc +search --start "" --end "" --format json ``` @@ -98,9 +96,8 @@ lark-cli vc +notes --meeting-ids ',' ``` 可获取会议的所有产物信息,包括: -- 纪要标识(`note_id`)与展示类型(`note_display_type`:`unknown` / `normal` / `unified`)— 决定逐字稿走哪条路由 - 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息 -- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录(仅 `normal` 纪要可直接读取该文档) +- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录 - 共享文档(`shared_doc_token`)— 会中投屏共享的文档 - 妙记 Token(`minute_token`)— 如存在录制产物则返回 @@ -114,78 +111,25 @@ lark-cli vc +notes --minute-tokens ',' 可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。 -#### Step 3: 按 `note_display_type` 拉取正文 / 逐字稿 +#### Step 3: Doc 域拉取文档内容 -智能纪要(`note_doc_token`)是飞书文档,使用 `docs +fetch --api-version v2` 读取正文内容;**逐字稿的读取方式由 `note_display_type` 决定**: +智能纪要和逐字稿都是飞书文档,需使用 `docs +fetch` 读取正文内容: ```bash -# 纪要正文(两种展示类型都适用) -lark-cli docs +fetch --api-version v2 --doc --doc-format markdown - -# note_display_type=normal:逐字稿是独立文档 -lark-cli docs +fetch --api-version v2 --doc --doc-format markdown - -# note_display_type=unified:逐字稿不是独立文档,按 note_id 拉取 -lark-cli note +transcript --note-id +lark-cli docs +fetch --api-version v2 --doc --doc-format markdown ``` -详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) 与 [lark-note](../../lark-note/SKILL.md) skill。 +详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) skill。 #### Step 4: 判断用户需要的产物内容 - 根据用户诉求(总结/待办/章节/完整发言记录等),选择合适的产物进行分析和信息提取 - 如果两种产物都不存在或没有权限,需如实告知用户 -## Note 域 - -- **lark-note skill** 负责**已知 `note_id`** 时的纪要直查:纪要详情、展示类型、关联文档 token,以及 unified 纪要的原始逐字记录。 -- **入口边界**:Note 域只接受 `note_id`。只有 `meeting_id` / `calendar_event_id` / `minute_token` 等会议线索时,先用 `lark-vc` 的 `+notes` 定位 `note_id`;只有自然语言纪要标题时,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch --api-version v2`。返回中的 `` 表示 VC 原始记录 block,其中 `vc-node-id` 属性值就是 `note_id`;拿到显式 `note_id` 后再进入 Note 域。若没有该 block,但有“文字记录/逐字稿” Docx 链接,说明是普通纪要的独立逐字稿文档,继续走 Doc 域读取。 -- **展示类型(`note_display_type`)决定逐字稿路由**: - - `normal`:逐字稿是独立文档(`verbatim_doc_token`),用 `docs +fetch --api-version v2` 读取。 - - `unknown` 且 `verbatim_doc_token` 非空:先按独立文档处理,用 `docs +fetch --api-version v2` 读取;不要猜成 unified。 - - `unknown` 且无可用逐字稿 token:先 `note +detail --note-id` 复核展示类型。 - - `unified`:逐字稿不是独立文档,用 `note +transcript --note-id` 拉取原始记录。 -- **判别键是 `note_display_type`,不是 `verbatim_doc_token` 是否为空**:unified 纪要也可能返回非空 `verbatim_doc_token`,但逐字稿仍以 `note +transcript` 为准(输出更结构化)。 - -### 资源关系 - -```text -Meeting -├── meeting_id -├── calendar_event_id → meeting relation -├── minute_token → Minutes -└── note_id → Note - -Note -├── note_display_type (unknown / normal / unified) -├── note_doc_token → Docs -├── verbatim_doc_token → Docs (normal 路径的逐字稿文档) -└── unified transcript → note +transcript (unified 原始记录) - -Docs -├── doc_token / Docx URL → docs +fetch --api-version v2 -└── from docs +fetch --api-version v2 → note_id → Note - -Minutes -├── minute_token → 基础信息 -└── media → minutes +download -``` - -### 边界判断表(按用户已有输入选第一入口) - -| 用户输入 | 第一入口 | 后续入口 | -|---------|---------|---------| -| `meeting_id` | `lark-vc` | `lark-note` / `lark-doc` | -| `calendar_event_id` | `lark-vc` | `lark-note` / `lark-doc` | -| `minute_token` | `lark-vc`(纪要产物索引)/ `lark-minutes`(妙记基础信息、媒体) | `lark-note` / `lark-doc` | -| `note_id` | `lark-note` | `lark-doc` | -| 自然语言纪要标题 + 逐字稿意图 | `lark-drive`(文档搜索) | `lark-doc`(正文读取);有 `vc-node-id` 时再进入 `lark-note`;不要先走 `lark-vc` / `lark-minutes` | -| `doc_token` / Docx URL | `lark-doc` | 如果 `docs +fetch --api-version v2` 返回 ``,用 `vc-node-id` 属性值作为 `note_id` 进入 `lark-note`;否则不反推 Note | - ## Doc 域 - **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。 -- **会议产物的文档本质**:智能纪要(`note_doc_token`)和 `normal` 纪要的逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch --api-version v2`)查询其内容和元信息;`unified` 纪要的逐字稿不是独立文档,用 `note +transcript` 拉取([lark-note](../../lark-note/SKILL.md))。 +- **会议产物的文档本质**:智能纪要(`note_doc_token`)、逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch`)查询其内容和元信息。 - **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`。 ## 三域关联总览 diff --git a/skills/lark-workflow-meeting-summary/SKILL.md b/skills/lark-workflow-meeting-summary/SKILL.md index d5c5a163..838a985c 100644 --- a/skills/lark-workflow-meeting-summary/SKILL.md +++ b/skills/lark-workflow-meeting-summary/SKILL.md @@ -74,11 +74,8 @@ lark-cli vc +notes --meeting-ids "id1,id2,...,idN" - 根据上一步搜集到的 `meeting-id` 查询会议纪要。 - 单次最多查询 50 个纪要信息,超过 50 个需分批调用。 - 部分会议返回 `no notes available`,在最终输出中标注"无纪要" -- 记录每个会议的 `note_id`(纪要 ID)、`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token)和 `verbatim_doc_token`(逐字稿文档 Token) +- 记录每个会议的 `note_doc_token`(纪要文档 Token)和 `verbatim_doc_token`(逐字稿文档 Token) -> **逐字稿路由按 `note_display_type` 决定**(详见 [vc-domain-boundaries.md](../lark-vc/references/vc-domain-boundaries.md) 的 Note 域): -> - `normal`:逐字稿是独立文档,链接/正文走 `verbatim_doc_token`。 -> - `unified`:逐字稿**不是独立文档**,没有可分享的逐字稿文档链接;需要逐字稿内容时用 `note +transcript --note-id `([lark-note](../lark-note/SKILL.md))拉取到本地,报告中标注"unified 纪要"即可。 2. 获取纪要文档和逐字稿文档链接 ```bash @@ -86,7 +83,6 @@ lark-cli vc +notes --meeting-ids "id1,id2,...,idN" lark-cli schema drive.metas.batch_query # 批量获取纪要文档与逐字稿链接: 一次最多查询 10 个文档 -# 仅对 note_doc_token 与 normal 纪要的 verbatim_doc_token 查询链接 lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", "doc_token": ""}], "with_url": true}' ``` @@ -94,7 +90,7 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", 根据时间跨度选择输出格式: -- **单日汇总**("今天"/"昨天"):用"今日会议概览"标题,逐会议列出会议时间、主题、纪要链接、逐字稿链接(`unified` 纪要无逐字稿链接,标注"unified 纪要,逐字稿需 `note +transcript` 拉取")。 +- **单日汇总**("今天"/"昨天"):用"今日会议概览"标题,逐会议列出会议时间、主题、纪要链接、逐字稿链接。 - **多日/周报**("这周"/"过去 7 天"等):用"会议纪要周报"标题,含概览统计、逐会议详情。 ### Step 5: 生成文档(可选,用户要求时) @@ -111,5 +107,4 @@ lark-cli docs +update --api-version v2 --doc "" --command append - - [lark-shared](../lark-shared/SKILL.md) — 认证、权限(必读) - [lark-vc](../lark-vc/SKILL.md) — `+search`、`+notes` 详细用法 -- [lark-note](../lark-note/SKILL.md) — `note +detail`、`note +transcript`(unified 纪要逐字稿) - [lark-doc](../lark-doc/SKILL.md) — `+fetch`、`+create`、`+update` 详细用法 \ No newline at end of file diff --git a/tests/cli_e2e/note/coverage.md b/tests/cli_e2e/note/coverage.md deleted file mode 100644 index 87c400e6..00000000 --- a/tests/cli_e2e/note/coverage.md +++ /dev/null @@ -1,21 +0,0 @@ -# Note CLI E2E Coverage - -## Metrics -- Denominator: 2 leaf commands -- Dry-run covered: 2 -- Dry-run coverage: 100.0% -- Live covered: 0 -- Live coverage: 0.0% - -Live E2E is intentionally not counted yet because both commands require meeting-generated note artifacts; stable create/use/cleanup fixtures are not available in this test suite. - -## Summary -- TestNoteDetailDryRun: dry-run coverage for `note +detail`; asserts the detail request method and `/open-apis/vc/v1/notes/{note_id}` URL without calling live APIs. -- TestNoteTranscriptDryRun: dry-run coverage for `note +transcript`; asserts the two-step request shape (`note detail` precheck, then `unified_note_transcript`), transcript query parameters, and that `--transcript-format` coexists with the global `--format` output flag. - -## Command Table - -| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | -| --- | --- | --- | --- | --- | --- | -| dry-run ✓ / live ✕ | note +detail | shortcut | note_dryrun_test.go::TestNoteDetailDryRun | `--note-id`; user identity | live note fixtures depend on meeting-generated artifacts | -| dry-run ✓ / live ✕ | note +transcript | shortcut | note_dryrun_test.go::TestNoteTranscriptDryRun | `--note-id`; `--transcript-format`; `--format json`; transcript API `format/page_size/locale` params | live unified-note fixtures depend on generated VC note artifacts | diff --git a/tests/cli_e2e/note/note_dryrun_test.go b/tests/cli_e2e/note/note_dryrun_test.go deleted file mode 100644 index 8d705556..00000000 --- a/tests/cli_e2e/note/note_dryrun_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package note - -import ( - "context" - "testing" - "time" - - clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -func TestNoteDetailDryRun(t *testing.T) { - setNoteDryRunEnv(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{ - "note", "+detail", - "--note-id", "note_dryrun", - "--dry-run", - }, - DefaultAs: "user", - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - out := result.Stdout - if got := gjson.Get(out, "api.0.method").String(); got != "GET" { - t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/vc/v1/notes/note_dryrun" { - t.Fatalf("url=%q, want note detail endpoint\nstdout:\n%s", got, out) - } -} - -func TestNoteTranscriptDryRun(t *testing.T) { - setNoteDryRunEnv(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{ - "note", "+transcript", - "--note-id", "note_dryrun", - "--transcript-format", "plain_text", - "--dry-run", - }, - DefaultAs: "user", - Format: "json", - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - out := result.Stdout - if got := gjson.Get(out, "api.#").Int(); got != 2 { - t.Fatalf("api count=%d, want 2\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.0.method").String(); got != "GET" { - t.Fatalf("detail method=%q, want GET\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/vc/v1/notes/note_dryrun" { - t.Fatalf("detail url=%q, want note detail endpoint\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.1.method").String(); got != "GET" { - t.Fatalf("transcript method=%q, want GET\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/vc/v1/notes/note_dryrun/unified_note_transcript" { - t.Fatalf("transcript url=%q, want unified transcript endpoint\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.1.params.format").String(); got != "plain_text" { - t.Fatalf("transcript API format=%q, want plain_text\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.1.params.page_size").Int(); got != 200 { - t.Fatalf("page_size=%d, want 200\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "api.1.params.locale").String(); got != "zh_cn" { - t.Fatalf("locale=%q, want zh_cn\nstdout:\n%s", got, out) - } - if got := gjson.Get(out, "transcript_format").String(); got != "plain_text" { - t.Fatalf("transcript_format=%q, want plain_text\nstdout:\n%s", got, out) - } -} - -func setNoteDryRunEnv(t *testing.T) { - t.Helper() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - t.Setenv("LARKSUITE_CLI_APP_ID", "note_dryrun_test") - t.Setenv("LARKSUITE_CLI_APP_SECRET", "note_dryrun_secret") - t.Setenv("LARKSUITE_CLI_BRAND", "feishu") -}