mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(note): clarify note ownership with dedicated detail and transcript flows (#1435)
* feat: split note domain * fix: address note transcript review comments * fix: stabilize empty note detail detection
This commit is contained in:
@@ -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"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -214,6 +215,12 @@ 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 {
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
"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": "电子表格操作" }
|
||||
|
||||
@@ -25,9 +25,11 @@ 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/",
|
||||
|
||||
@@ -26,9 +26,11 @@ 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/",
|
||||
@@ -36,7 +38,6 @@ var migratedEnvelopePaths = []string{
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -953,6 +953,7 @@ 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",
|
||||
@@ -988,6 +989,18 @@ 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
|
||||
|
||||
|
||||
209
shortcuts/note/note.go
Normal file
209
shortcuts/note/note.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"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
|
||||
|
||||
// ErrEmptyDetail identifies note detail responses that do not contain a note
|
||||
// object. Callers should use errors.Is instead of matching the display message.
|
||||
var ErrEmptyDetail = errors.New("note detail is empty")
|
||||
|
||||
// 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").WithCause(ErrEmptyDetail)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
86
shortcuts/note/note_detail.go
Normal file
86
shortcuts/note/note_detail.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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
|
||||
}
|
||||
280
shortcuts/note/note_test.go
Normal file
280
shortcuts/note/note_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// 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 TestNoteDetailEmptyDetailPreservesSentinelCause(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
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{},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteDetail, []string{"+detail", "--note-id", "note_empty_detail", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty detail to fail")
|
||||
}
|
||||
if !errors.Is(err, ErrEmptyDetail) {
|
||||
t.Fatalf("errors.Is(ErrEmptyDetail) = false for %T: %v", err, err)
|
||||
}
|
||||
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 stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
258
shortcuts/note/note_transcript.go
Normal file
258
shortcuts/note/note_transcript.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"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, transcriptContextError(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, transcriptContextError(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
|
||||
}
|
||||
|
||||
func transcriptContextError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "transcript fetch interrupted: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
438
shortcuts/note/note_transcript_test.go
Normal file
438
shortcuts/note/note_transcript_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// 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 TestTranscriptContextErrorPreservesCause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{
|
||||
name: "canceled",
|
||||
err: context.Canceled,
|
||||
subtype: errs.SubtypeNetworkTransport,
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
err: context.DeadlineExceeded,
|
||||
subtype: errs.SubtypeNetworkTimeout,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := transcriptContextError(tt.err)
|
||||
if !errors.Is(err, tt.err) {
|
||||
t.Fatalf("errors.Is(%v) = false", tt.err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryNetwork || problem.Subtype != tt.subtype {
|
||||
t.Fatalf("category/subtype = %v/%v, want Network/%v", problem.Category, problem.Subtype, tt.subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
14
shortcuts/note/shortcuts.go
Normal file
14
shortcuts/note/shortcuts.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ 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"
|
||||
@@ -79,6 +80,7 @@ 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()...)
|
||||
|
||||
@@ -13,7 +13,6 @@ package vc
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -30,6 +29,7 @@ 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,12 +51,6 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// artifact type enum from note detail API
|
||||
const (
|
||||
artifactTypeMainDoc = 1 // main note document
|
||||
artifactTypeVerbatim = 2 // verbatim transcript
|
||||
)
|
||||
|
||||
const logPrefix = "[vc +notes]"
|
||||
|
||||
const (
|
||||
@@ -66,9 +60,6 @@ const (
|
||||
recordingNotFoundCode = 121004 // 该会议没有妙记文件
|
||||
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
|
||||
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
|
||||
|
||||
// note detail API specific error code.
|
||||
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
|
||||
)
|
||||
|
||||
func minutesReadError(err error, minuteToken string) error {
|
||||
@@ -221,7 +212,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 _, ok := noteResult["note_doc_token"].(string); ok {
|
||||
if noteID, _ := noteResult["note_id"].(string); noteID != "" {
|
||||
for k, v := range noteResult {
|
||||
result[k] = v
|
||||
}
|
||||
@@ -369,11 +360,13 @@ 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_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
|
||||
for _, k := range []string{"note_id", "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
|
||||
}
|
||||
@@ -519,84 +512,22 @@ func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title str
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
// 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 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)}
|
||||
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)}
|
||||
}
|
||||
if errors.Is(err, note.ErrEmptyDetail) {
|
||||
return map[string]any{"error": note.ErrEmptyDetail.Error()}
|
||||
}
|
||||
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
|
||||
}
|
||||
|
||||
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
|
||||
return detail.ToMap()
|
||||
}
|
||||
|
||||
// VCNotes queries meeting notes via meeting-ids, minute-tokens, or calendar-event-ids.
|
||||
@@ -775,6 +706,12 @@ 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
|
||||
|
||||
@@ -23,6 +23,7 @@ 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"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,6 +120,21 @@ 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",
|
||||
@@ -178,68 +194,9 @@ func TestSanitizeDirName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Note-detail parsing helpers (parseArtifactType/extractArtifactTokens/
|
||||
// extractDocTokens) moved to the note domain; their tests live in
|
||||
// shortcuts/note/note_test.go.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: +notes with mocked HTTP
|
||||
@@ -362,25 +319,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -595,6 +533,33 @@ 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)
|
||||
@@ -648,6 +613,26 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -756,7 +741,9 @@ 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": ""}, 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},
|
||||
{"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},
|
||||
@@ -1266,7 +1253,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", noteNoPermissionCode, "no permission"))
|
||||
reg.Register(noteDetailErrStub("note_perm2", note.NoNoteReadPermissionCode, "no permission"))
|
||||
reg.Register(recordingOKStub("m_noteperm2", "https://meetings.feishu.cn/minutes/obcpermtest"))
|
||||
|
||||
// note fails but minute_token succeeds → partial success (hasNotesPayload=true)
|
||||
@@ -1286,6 +1273,29 @@ 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.
|
||||
|
||||
@@ -56,6 +56,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<vc-transcribe-tab vc-node-id="...">` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id <vc-node-id>` |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc"
|
||||
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -45,6 +45,7 @@ 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 |
|
||||
|
||||
@@ -179,6 +180,10 @@ Minutes (妙记) ← minute_token 标识
|
||||
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
|
||||
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
|
||||
> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace`
|
||||
>
|
||||
> **Note 域边界(禁止规则)**:`minute_token` 是妙记文件标识,**不是** `note_id`。
|
||||
> - 不要把 `minute_token` 传给 `note +detail` 或 `note +transcript`。
|
||||
> - 已有 `minute_token` 且要读取纪要产物时,先走 [lark-vc](../lark-vc/SKILL.md);只有自然语言纪要标题时不要从 Minutes 反查。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -218,6 +223,7 @@ lark-cli minutes <resource> <method> [flags]
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`)
|
||||
- 已有 `minute_token` 的纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`)
|
||||
- 只有自然语言纪要标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
|
||||
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
|
||||
57
skills/lark-note/SKILL.md
Normal file
57
skills/lark-note/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: lark-note
|
||||
version: 1.0.0
|
||||
description: "飞书会议纪要(Note)直查:已知 note_id 时查询纪要详情、展示类型、关联文档 token,并读取 unified 原始逐字记录。当用户已持有 note_id,或从文档显式 vc-node-id 获得 note_id 时使用。不负责会议/日程/妙记定位、文档标题搜索或 Docx 正文读取。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli note --help"
|
||||
---
|
||||
|
||||
# note (v1)
|
||||
|
||||
身份:仅使用 `--as user`。使用前阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
|
||||
Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-version v2` 返回的 `<vc-transcribe-tab vc-node-id="...">` 中的 `vc-node-id`。不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`。
|
||||
|
||||
## 命令路由
|
||||
|
||||
| 用户表达 / 上下文 | 路由 |
|
||||
|---------|------|
|
||||
| 已知 `note_id`,查纪要类型 / 文档 token | `note +detail --note-id NOTE_ID` |
|
||||
| `docs +fetch --api-version v2` 返回 `<vc-transcribe-tab vc-node-id="...">` | 取 `vc-node-id` 作为 `NOTE_ID`,先 `note +detail --note-id NOTE_ID` |
|
||||
| 已知 `note_id`,读纪要正文 | `note +detail` → `docs +fetch --api-version v2 --doc <note_doc_token>` |
|
||||
| 已知 `note_id`,查 unified 原始记录 / 逐字稿 | `note +transcript --note-id NOTE_ID` |
|
||||
| 只有自然语言纪要标题,用户要逐字稿 / 原始记录 / 谁说了什么 | 不进本 skill;先走文档搜索与 `docs +fetch`,拿到 `vc-node-id` 后再回来 |
|
||||
|
||||
## `note_display_type` 路由
|
||||
|
||||
| `note +detail` 结果 | 用户要逐字稿 / 原始记录时 |
|
||||
|------|---------------|
|
||||
| `normal` + `verbatim_doc_token` 非空 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| `unknown` + `verbatim_doc_token` 非空 | 先按独立文档处理;不要猜成 unified |
|
||||
| `unknown` + 无逐字稿 token | 停止重试并说明无法确定逐字稿入口 |
|
||||
| `unified` | `note +transcript --note-id <note_id>` |
|
||||
|
||||
判别键是 `note_display_type`,不是 `verbatim_doc_token` 是否为空:unified 纪要也可能返回非空 `verbatim_doc_token`。
|
||||
|
||||
## 关键字段
|
||||
|
||||
- `note_id`:Note 域唯一入口。
|
||||
- `note_display_type`:`unknown` / `normal` / `unified`。
|
||||
- `note_doc_token`:纪要正文文档,正文读取交给 [lark-doc](../lark-doc/SKILL.md)。
|
||||
- `verbatim_doc_token`:普通纪要逐字稿文档;unified 逐字稿不按这个 token 路由。
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
- 通过 `meeting_id` / `calendar_event_id` / `minute_token` 定位纪要 → [lark-vc](../lark-vc/SKILL.md)。
|
||||
- 自然语言纪要标题搜索 → [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md)。
|
||||
- Docx 正文读取 → [lark-doc](../lark-doc/SKILL.md)。
|
||||
- 妙记基础信息与媒体文件 → [lark-minutes](../lark-minutes/SKILL.md)。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 何时读 reference |
|
||||
|----------|------|
|
||||
| [`+detail`](references/lark-note-detail.md) | 需要解释输出字段或根据展示类型继续路由 |
|
||||
| [`+transcript`](references/lark-note-transcript.md) | 需要拉取 unified 原始记录或处理本地输出文件 |
|
||||
24
skills/lark-note/references/lark-note-detail.md
Normal file
24
skills/lark-note/references/lark-note-detail.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# note +detail
|
||||
|
||||
`note +detail` 只做一件事:按显式 `note_id` 返回纪要展示类型和相关文档 token。
|
||||
|
||||
```bash
|
||||
lark-cli note +detail --note-id NOTE_ID --format json
|
||||
```
|
||||
|
||||
## `note_id` 来源
|
||||
|
||||
- 可以来自用户直接给出的 `note_id`。
|
||||
- 如果入口是文档,先由 [lark-doc](../../lark-doc/SKILL.md) 读取 Docx;只有 `<vc-transcribe-tab vc-node-id="...">` 的 `vc-node-id` 可以作为 `note_id`。
|
||||
- 没有 `vc-node-id` 时,不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`。
|
||||
|
||||
## 输出后的路由
|
||||
|
||||
| detail 字段 | 后续动作 |
|
||||
|---------|---------|
|
||||
| `note_doc_token` | 读纪要正文 / 总结 / 待办 / 章节:`docs +fetch --api-version v2 --doc <note_doc_token>` |
|
||||
| `note_display_type=normal` + `verbatim_doc_token` | 读逐字稿:`docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| `note_display_type=unknown` + `verbatim_doc_token` | 先按普通独立逐字稿文档读取;不要猜成 unified |
|
||||
| `note_display_type=unified` | 读逐字稿 / 原始记录:转 [`note +transcript`](lark-note-transcript.md) |
|
||||
|
||||
判别键是 `note_display_type`。即使 unified 纪要返回了非空 `verbatim_doc_token`,逐字稿仍按 unified 路由。
|
||||
23
skills/lark-note/references/lark-note-transcript.md
Normal file
23
skills/lark-note/references/lark-note-transcript.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# note +transcript
|
||||
|
||||
只在 `note +detail` 或 `vc +notes` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`。
|
||||
|
||||
```bash
|
||||
lark-cli note +transcript --note-id NOTE_ID
|
||||
```
|
||||
|
||||
## 行为契约
|
||||
|
||||
- CLI 会先校验该 Note 是否为 `unified`;不是 unified 时不拉取 transcript。
|
||||
- CLI 内部自动翻页并拼接完整内容;任一页失败时整体报错,不保存半截 transcript。
|
||||
- 默认保存到 `./notes/{note_id}/unified_transcript.md`;`--transcript-format plain_text` 时保存为 `.txt`。
|
||||
- 目标文件已存在时会失败;用户明确要覆盖时才加 `--overwrite`。
|
||||
|
||||
## 何时不要用
|
||||
|
||||
| 场景 | 正确路由 |
|
||||
|------|---------|
|
||||
| 只有纪要文档标题 | 先文档搜索,再 `docs +fetch --api-version v2`;有 `vc-node-id` 才回 Note 域 |
|
||||
| 只有 Docx URL / `doc_token` | 先 `docs +fetch --api-version v2`;不要从 `doc_token` 反推 `note_id` |
|
||||
| `note_display_type=normal` | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| `note_display_type=unknown` 且 `verbatim_doc_token` 非空 | 先按独立逐字稿文档读取 |
|
||||
@@ -47,14 +47,15 @@ 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);仅在已拿到 `note_id` / `vc-node-id` 后再到 [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 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(总结+待办)和逐字稿文档。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,包含总结、待办、章节和文字记录,通过 minute_token 标识。
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识,包含纪要文档(总结、待办)和逐字稿文档。`note_display_type` 区分**普通纪要(`normal`)**和 **unified 纪要**;已知 `note_id` 的直查与 unified 原始记录请用 [lark-note](../lark-note/SKILL.md)。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
|
||||
- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。
|
||||
- **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。
|
||||
- **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。
|
||||
@@ -63,13 +64,15 @@ lark-cli vc +search --query "站会" --start-time ...
|
||||
|
||||
| 用户意图 | 必须读取的产物 | 禁止 |
|
||||
|---------|-------------|------|
|
||||
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 逐字稿(`verbatim_doc_token`)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
|
||||
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 原始对话记录(按下方逐字稿路由取得)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
|
||||
| 查看待办/章节 | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
|
||||
| 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — |
|
||||
| 直接看 AI 总结结果 | AI 纪要(`note_doc_token`) | — |
|
||||
| 谁说了什么/完整发言记录 | 逐字稿(`verbatim_doc_token`) | — |
|
||||
| 谁说了什么/完整发言记录 | 原始对话记录(按下方逐字稿路由取得) | — |
|
||||
|
||||
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
|
||||
> **逐字稿路由**:先看 `vc +notes` 返回的 `note_display_type`,不要只看 `verbatim_doc_token` 是否为空。具体路由以 [`+notes`](references/lark-vc-notes.md) 和 [lark-note](../lark-note/SKILL.md) 为准。
|
||||
>
|
||||
> **为什么"提炼/总结"必须从原始对话记录出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
|
||||
|
||||
## 核心场景
|
||||
|
||||
@@ -77,6 +80,7 @@ lark-cli vc +search --query "站会" --start-time ...
|
||||
1. 仅支持搜索已结束的会议,对于还未开始的未来会议,需要使用 lark-calendar 技能。
|
||||
2. 仅支持使用关键词、时间段、参会人、组织者、会议室等筛选条件搜索会议记录,对于不支持的筛选条件,需要提示用户。
|
||||
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何会议记录。
|
||||
4. 只有自然语言纪要标题、没有会议线索时,不要把标题当会议关键词;按上方意图路由切到文档搜索。
|
||||
|
||||
### 2. 整理会议纪要
|
||||
|
||||
@@ -99,7 +103,7 @@ lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --out
|
||||
> **纪要相关文档 — 根据用户意图选择:**
|
||||
> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办)
|
||||
> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回)
|
||||
> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个
|
||||
> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [`+notes`](references/lark-vc-notes.md)
|
||||
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
|
||||
> - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定
|
||||
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens`
|
||||
@@ -133,18 +137,19 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant
|
||||
| 用户意图 | 推荐命令 | 所在 skill |
|
||||
|---------|---------|--------|
|
||||
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
|
||||
| 已结束会议的发言内容 | `vc +notes` 取 `verbatim_doc_token` 再 `docs +fetch --api-version v2` | 本 skill |
|
||||
| 已结束会议的发言内容 | 先 `vc +notes`,再按 `note_display_type` 路由 | 本 skill / [`lark-note`](../lark-note/SKILL.md) |
|
||||
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `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 (会议纪要) ← note_id 标识,note_display_type: normal / unified
|
||||
│ ├── MainDoc (AI 智能纪要文档, note_doc_token)
|
||||
│ ├── MeetingNotes (用户绑定的会议纪要文档, meeting_notes)
|
||||
│ ├── VerbatimDoc (逐字稿, verbatim_doc_token)
|
||||
│ ├── VerbatimDoc (逐字稿, verbatim_doc_token) ← normal 路径
|
||||
│ ├── UnifiedTranscript (unified 原始记录) ← unified 路径,note +transcript(lark-note)
|
||||
│ └── SharedDoc (会中共享文档)
|
||||
└── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取
|
||||
├── Transcript (文字记录)
|
||||
@@ -154,6 +159,13 @@ 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` → [lark-note](../lark-note/SKILL.md)。
|
||||
> - 已有 `doc_token` 且目标是读正文 → [lark-doc](../lark-doc/SKILL.md)。
|
||||
> - 只有自然语言纪要标题 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -180,5 +192,6 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
|
||||
|
||||
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
- Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md)
|
||||
- 只有纪要文档标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
|
||||
- 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`)
|
||||
- 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)
|
||||
|
||||
@@ -77,17 +77,34 @@ 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 — 完整的逐句文字记录,含说话人和时间戳 |
|
||||
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳;unified 纪要的逐字稿请改用 `note +transcript` |
|
||||
| `shared_doc_tokens` | 会中共享文档 Token 列表 |
|
||||
| `creator_id` | 创建者 ID |
|
||||
| `create_time` | 创建时间(格式化) |
|
||||
|
||||
> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示所有文档链接让用户选择。
|
||||
> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 见下方「按 `note_display_type` 路由逐字稿」。意图不明确时,展示所有文档链接让用户选择。
|
||||
>
|
||||
> 📌 不确定该返回哪个 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_doc_token>` |
|
||||
| `note_display_type=normal` + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| `note_display_type=unknown` + `verbatim_doc_token` 非空 + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>`;不要猜成 unified |
|
||||
| `note_display_type=unknown` + 无可用逐字稿 token | 先 `note +detail --note-id <note_id>` 复核,再按返回的展示类型路由 |
|
||||
| `note_display_type=unified` + 用户要逐字稿 / 原始记录 | `note +transcript --note-id <note_id>` → 切到 [lark-note](../../lark-note/SKILL.md) |
|
||||
| `minute_token` 存在 + 用户要音视频媒体 | `minutes +download --minute-tokens <minute_token>` |
|
||||
|
||||
> **`unified` 纪要的逐字稿不是独立文档**,必须用 `note +transcript` 按 `note_id` 拉取,输出更结构化。即使 unified 也返回了非空 `verbatim_doc_token`,仍以 `note_display_type` 为准。
|
||||
|
||||
### minute-tokens 路径的 AI 产物
|
||||
|
||||
通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物:
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| 产物 | Token 字段 | 本质 | 说明 |
|
||||
|------|-----------|------|------|
|
||||
| 智能纪要 | `note_doc_token` | 飞书文档 | AI 生成的会议总结与待办 |
|
||||
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳) |
|
||||
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳)— **仅 `note_display_type=normal` 时是可读的独立文档**;`unified` 纪要的逐字稿用 `note +transcript --note-id <note_id>` 拉取(见下方 [Note 域](#note-域)) |
|
||||
| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 |
|
||||
|
||||
此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
#### 逐字稿与文字记录的格式
|
||||
|
||||
智能纪要的逐字稿(`verbatim_doc_token`)和妙记的文字记录(Transcript)都记录了用户原始对话内容,格式一致:
|
||||
智能纪要的逐字稿(`normal` 纪要的 `verbatim_doc_token` 文档、`unified` 纪要的 `note +transcript` 输出)和妙记的文字记录(Transcript)都记录了用户原始对话内容,格式一致:
|
||||
|
||||
```
|
||||
发言人名称 相对时间戳
|
||||
@@ -81,6 +81,8 @@
|
||||
|
||||
根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。
|
||||
|
||||
> **不要把纪要标题当会议线索:** 如果用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `meeting_id`、`calendar_event_id`、会议号、参会人或时间范围,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch --api-version v2`。若返回 `<vc-transcribe-tab vc-node-id="...">`,提取 `note_id` 后进入 Note 域判断 `normal` / `unified`;若没有该 block,但有“文字记录/逐字稿” Docx 链接,直接用 `docs +fetch --api-version v2` 读取该链接。
|
||||
|
||||
```bash
|
||||
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json
|
||||
```
|
||||
@@ -96,8 +98,9 @@ lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
|
||||
```
|
||||
|
||||
可获取会议的所有产物信息,包括:
|
||||
- 纪要标识(`note_id`)与展示类型(`note_display_type`:`unknown` / `normal` / `unified`)— 决定逐字稿走哪条路由
|
||||
- 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息
|
||||
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录
|
||||
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录(仅 `normal` 纪要可直接读取该文档)
|
||||
- 共享文档(`shared_doc_token`)— 会中投屏共享的文档
|
||||
- 妙记 Token(`minute_token`)— 如存在录制产物则返回
|
||||
|
||||
@@ -111,25 +114,39 @@ lark-cli vc +notes --minute-tokens '<minute_token1>,<minute_token2>'
|
||||
|
||||
可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
|
||||
|
||||
#### Step 3: Doc 域拉取文档内容
|
||||
#### Step 3: 按 `note_display_type` 拉取正文 / 逐字稿
|
||||
|
||||
智能纪要和逐字稿都是飞书文档,需使用 `docs +fetch` 读取正文内容:
|
||||
智能纪要(`note_doc_token`)是飞书文档,使用 `docs +fetch --api-version v2` 读取正文内容;**逐字稿的读取方式由 `note_display_type` 决定**:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
# 纪要正文(两种展示类型都适用)
|
||||
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
|
||||
|
||||
# note_display_type=normal:逐字稿是独立文档
|
||||
lark-cli docs +fetch --api-version v2 --doc <verbatim_doc_token> --doc-format markdown
|
||||
|
||||
# note_display_type=unified:逐字稿不是独立文档,按 note_id 拉取
|
||||
lark-cli note +transcript --note-id <note_id>
|
||||
```
|
||||
|
||||
详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) skill。
|
||||
详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) 与 [lark-note](../../lark-note/SKILL.md) skill。
|
||||
|
||||
#### Step 4: 判断用户需要的产物内容
|
||||
|
||||
- 根据用户诉求(总结/待办/章节/完整发言记录等),选择合适的产物进行分析和信息提取
|
||||
- 如果两种产物都不存在或没有权限,需如实告知用户
|
||||
|
||||
## Note 域
|
||||
|
||||
- VC 只负责从 `meeting_id` / `calendar_event_id` / `minute_token` 定位会议产物和 `note_id`。
|
||||
- 已知 `note_id` 后切到 [lark-note](../../lark-note/SKILL.md);逐字稿路由以 `lark-note` 的 `note_display_type` 规则为准。
|
||||
- 只有自然语言纪要标题时,先走文档搜索与 `docs +fetch --api-version v2`;只有 `<vc-transcribe-tab vc-node-id="...">` 的 `vc-node-id` 可以进入 Note 域。
|
||||
- `doc_token` / Docx URL 不是 `note_id`。没有 `vc-node-id` 时不要反推 Note,继续按 Doc 域读取正文或正文中明确给出的逐字稿文档。
|
||||
|
||||
## Doc 域
|
||||
|
||||
- **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。
|
||||
- **会议产物的文档本质**:智能纪要(`note_doc_token`)、逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch`)查询其内容和元信息。
|
||||
- **会议产物的文档本质**:智能纪要(`note_doc_token`)和 `normal` 纪要的逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch --api-version v2`)查询其内容和元信息;`unified` 纪要的逐字稿不是独立文档,用 `note +transcript` 拉取([lark-note](../../lark-note/SKILL.md))。
|
||||
- **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`。
|
||||
|
||||
## 三域关联总览
|
||||
|
||||
@@ -74,8 +74,11 @@ lark-cli vc +notes --meeting-ids "id1,id2,...,idN"
|
||||
- 根据上一步搜集到的 `meeting-id` 查询会议纪要。
|
||||
- 单次最多查询 50 个纪要信息,超过 50 个需分批调用。
|
||||
- 部分会议返回 `no notes available`,在最终输出中标注"无纪要"
|
||||
- 记录每个会议的 `note_doc_token`(纪要文档 Token)和 `verbatim_doc_token`(逐字稿文档 Token)
|
||||
- 记录每个会议的 `note_id`(纪要 ID)、`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`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 <note_id>`([lark-note](../lark-note/SKILL.md))拉取到本地,报告中标注"unified 纪要"即可。
|
||||
|
||||
2. 获取纪要文档和逐字稿文档链接
|
||||
```bash
|
||||
@@ -83,6 +86,7 @@ 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": "<doc_token>"}], "with_url": true}'
|
||||
```
|
||||
|
||||
@@ -90,7 +94,7 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
|
||||
|
||||
根据时间跨度选择输出格式:
|
||||
|
||||
- **单日汇总**("今天"/"昨天"):用"今日会议概览"标题,逐会议列出会议时间、主题、纪要链接、逐字稿链接。
|
||||
- **单日汇总**("今天"/"昨天"):用"今日会议概览"标题,逐会议列出会议时间、主题、纪要链接、逐字稿链接(`unified` 纪要无逐字稿链接,标注"unified 纪要,逐字稿需 `note +transcript` 拉取")。
|
||||
- **多日/周报**("这周"/"过去 7 天"等):用"会议纪要周报"标题,含概览统计、逐会议详情。
|
||||
|
||||
### Step 5: 生成文档(可选,用户要求时)
|
||||
@@ -107,4 +111,5 @@ lark-cli docs +update --api-version v2 --doc "<url_or_token>" --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` 详细用法
|
||||
21
tests/cli_e2e/note/coverage.md
Normal file
21
tests/cli_e2e/note/coverage.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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 |
|
||||
97
tests/cli_e2e/note/note_dryrun_test.go
Normal file
97
tests/cli_e2e/note/note_dryrun_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user