From addd32f974b3a91ddcc534c429e2e3b954a23081 Mon Sep 17 00:00:00 2001 From: "chenjunlin.666" Date: Thu, 6 Aug 2026 10:53:30 +0800 Subject: [PATCH] feat(fetch): unify content reads across Lark resources --- extension/fileio/types.go | 6 + internal/contentartifact/temp.go | 93 ++++ internal/contentartifact/temp_test.go | 209 ++++++++ internal/output/emitter.go | 28 +- internal/output/emitter_contract_test.go | 99 ++++ internal/vfs/localfileio/localfileio.go | 4 + internal/vfs/localfileio/localfileio_test.go | 7 + .../common/contentread/anchored_markdown.go | 439 +++++++++++++++++ .../contentread/anchored_markdown_table.go | 77 +++ .../contentread/anchored_markdown_test.go | 383 +++++++++++++++ shortcuts/common/contentread/fetch.go | 41 ++ shortcuts/common/contentread/fetch_test.go | 210 ++++++++ shortcuts/common/contentread/images.go | 49 ++ shortcuts/common/contentread/markdown.go | 61 +++ shortcuts/common/contentread/pagination.go | 32 ++ .../common/contentread/postprocess_test.go | 209 ++++++++ shortcuts/common/contentread/tables.go | 83 ++++ shortcuts/common/contentread/types.go | 41 ++ shortcuts/common/fetch_content_delivery.go | 126 +++++ .../common/fetch_content_delivery_test.go | 350 ++++++++++++++ shortcuts/common/resource_url.go | 60 +++ shortcuts/common/runner.go | 24 + shortcuts/common/wiki_node.go | 59 +++ shortcuts/doc/docs_fetch_markdown.go | 171 +++++++ shortcuts/doc/docs_fetch_spill_test.go | 195 ++++++++ shortcuts/doc/docs_fetch_v2.go | 198 +++++++- shortcuts/doc/docs_fetch_v2_test.go | 78 ++- shortcuts/doc/docs_fetch_v2_wiki_test.go | 448 ++++++++++++++++++ shortcuts/doc/docs_skill_doc_test.go | 56 +++ shortcuts/drive/drive_fetch.go | 193 ++++++++ shortcuts/drive/drive_fetch_dispatch.go | 250 ++++++++++ shortcuts/drive/drive_fetch_envelope.go | 100 ++++ shortcuts/drive/drive_fetch_input.go | 170 +++++++ shortcuts/drive/drive_fetch_spill_test.go | 223 +++++++++ shortcuts/drive/drive_fetch_test.go | 257 ++++++++++ shortcuts/drive/drive_fetch_validate.go | 115 +++++ shortcuts/drive/drive_skill_doc_test.go | 54 +++ shortcuts/drive/shortcuts.go | 1 + shortcuts/drive/shortcuts_test.go | 1 + shortcuts/minutes/minutes_fetch.go | 258 ++++++++++ shortcuts/minutes/minutes_fetch_test.go | 166 +++++++ skills/lark-base/SKILL.md | 2 + skills/lark-doc/SKILL.md | 2 +- skills/lark-doc/references/lark-doc-fetch.md | 13 + skills/lark-drive/SKILL.md | 21 +- .../lark-drive/references/lark-drive-fetch.md | 88 ++++ skills/lark-minutes/SKILL.md | 3 +- skills/lark-sheets/SKILL.md | 4 +- skills/lark-slides/SKILL.md | 5 +- tests/cli_e2e/docs/coverage.md | 4 +- tests/cli_e2e/docs/docs_create_fetch_test.go | 47 +- tests/cli_e2e/docs/docs_fetch_dryrun_test.go | 56 +++ tests/cli_e2e/drive/coverage.md | 8 +- .../cli_e2e/drive/drive_fetch_dryrun_test.go | 341 +++++++++++++ .../drive/drive_fetch_workflow_test.go | 77 +++ 55 files changed, 6244 insertions(+), 51 deletions(-) create mode 100644 internal/contentartifact/temp.go create mode 100644 internal/contentartifact/temp_test.go create mode 100644 shortcuts/common/contentread/anchored_markdown.go create mode 100644 shortcuts/common/contentread/anchored_markdown_table.go create mode 100644 shortcuts/common/contentread/anchored_markdown_test.go create mode 100644 shortcuts/common/contentread/fetch.go create mode 100644 shortcuts/common/contentread/fetch_test.go create mode 100644 shortcuts/common/contentread/images.go create mode 100644 shortcuts/common/contentread/markdown.go create mode 100644 shortcuts/common/contentread/pagination.go create mode 100644 shortcuts/common/contentread/postprocess_test.go create mode 100644 shortcuts/common/contentread/tables.go create mode 100644 shortcuts/common/contentread/types.go create mode 100644 shortcuts/common/fetch_content_delivery.go create mode 100644 shortcuts/common/fetch_content_delivery_test.go create mode 100644 shortcuts/common/wiki_node.go create mode 100644 shortcuts/doc/docs_fetch_markdown.go create mode 100644 shortcuts/doc/docs_fetch_spill_test.go create mode 100644 shortcuts/doc/docs_fetch_v2_wiki_test.go create mode 100644 shortcuts/doc/docs_skill_doc_test.go create mode 100644 shortcuts/drive/drive_fetch.go create mode 100644 shortcuts/drive/drive_fetch_dispatch.go create mode 100644 shortcuts/drive/drive_fetch_envelope.go create mode 100644 shortcuts/drive/drive_fetch_input.go create mode 100644 shortcuts/drive/drive_fetch_spill_test.go create mode 100644 shortcuts/drive/drive_fetch_test.go create mode 100644 shortcuts/drive/drive_fetch_validate.go create mode 100644 shortcuts/drive/drive_skill_doc_test.go create mode 100644 shortcuts/minutes/minutes_fetch.go create mode 100644 shortcuts/minutes/minutes_fetch_test.go create mode 100644 skills/lark-drive/references/lark-drive-fetch.md create mode 100644 tests/cli_e2e/drive/drive_fetch_dryrun_test.go create mode 100644 tests/cli_e2e/drive/drive_fetch_workflow_test.go diff --git a/extension/fileio/types.go b/extension/fileio/types.go index 386bda198e..e9746a689d 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -42,6 +42,12 @@ type FileIO interface { Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error) } +// LocalTemporaryFileSupport is an optional capability for runtimes that allow +// commands to create files in the local temporary directory. +type LocalTemporaryFileSupport interface { + SupportsLocalTemporaryFiles() bool +} + // FileInfo is a minimal subset of os.FileInfo covering actual CLI usage. // os.FileInfo satisfies this interface. type FileInfo interface { diff --git a/internal/contentartifact/temp.go b/internal/contentartifact/temp.go new file mode 100644 index 0000000000..ce78a72257 --- /dev/null +++ b/internal/contentartifact/temp.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package contentartifact saves oversized command output to local files. +package contentartifact + +import ( + "io" + "io/fs" + "path/filepath" + + "github.com/larksuite/cli/internal/vfs" +) + +const tempMarkdownPattern = "lark-cli-fetch-*.md" + +type tempFile interface { + Name() string + Chmod(mode fs.FileMode) error + Write(p []byte) (int, error) + Sync() error + Close() error +} + +// WriteTempMarkdown writes content exactly as provided to a private file in the +// platform temporary directory. The caller owns the returned file and may +// remove it when it is no longer needed. +func WriteTempMarkdown(content []byte) (absolutePath string, size int64, err error) { + return writeTempMarkdown( + content, + func(dir, pattern string) (tempFile, error) { + return vfs.CreateTemp(dir, pattern) + }, + vfs.Getwd, + vfs.Remove, + ) +} + +func writeTempMarkdown( + content []byte, + createTemp func(dir, pattern string) (tempFile, error), + getwd func() (string, error), + remove func(string) error, +) (string, int64, error) { + tmp, err := createTemp("", tempMarkdownPattern) + if err != nil { + return "", 0, err + } + tmpName := tmp.Name() + + closed := false + succeeded := false + defer func() { + if succeeded { + return + } + if !closed { + _ = tmp.Close() + } + _ = remove(tmpName) + }() + + if err := tmp.Chmod(0o600); err != nil { + return "", 0, err + } + written, err := tmp.Write(content) + if err != nil { + return "", 0, err + } + if written != len(content) { + return "", 0, io.ErrShortWrite + } + if err := tmp.Sync(); err != nil { + return "", 0, err + } + if err := tmp.Close(); err != nil { + return "", 0, err + } + closed = true + + absolutePath := tmpName + if !filepath.IsAbs(absolutePath) { + cwd, err := getwd() + if err != nil { + return "", 0, err + } + absolutePath = filepath.Join(cwd, absolutePath) + } + absolutePath = filepath.Clean(absolutePath) + + succeeded = true + return absolutePath, int64(written), nil +} diff --git a/internal/contentartifact/temp_test.go b/internal/contentartifact/temp_test.go new file mode 100644 index 0000000000..ba572d33ea --- /dev/null +++ b/internal/contentartifact/temp_test.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentartifact + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +func TestWriteTempMarkdownWritesExactPrivateFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("TMPDIR", tempDir) + if runtime.GOOS == "windows" { + t.Setenv("TEMP", tempDir) + t.Setenv("TMP", tempDir) + } + + content := []byte("# Heading\n\nbody without a trailing newline") + path, size, err := WriteTempMarkdown(content) + if err != nil { + t.Fatalf("WriteTempMarkdown() error = %v", err) + } + t.Cleanup(func() { _ = vfs.Remove(path) }) + + if !filepath.IsAbs(path) { + t.Errorf("path = %q, want an absolute path", path) + } + if dir := filepath.Clean(filepath.Dir(path)); dir != filepath.Clean(tempDir) { + t.Errorf("directory = %q, want %q", dir, tempDir) + } + if name := filepath.Base(path); !strings.HasPrefix(name, "lark-cli-fetch-") || filepath.Ext(name) != ".md" { + t.Errorf("filename = %q, want lark-cli-fetch-*.md", name) + } + if size != int64(len(content)) { + t.Errorf("size = %d, want %d", size, len(content)) + } + + got, err := vfs.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + if string(got) != string(content) { + t.Errorf("content = %q, want exact bytes %q", got, content) + } + + if runtime.GOOS != "windows" { + info, err := vfs.Stat(path) + if err != nil { + t.Fatalf("Stat(%q) error = %v", path, err) + } + if permission := info.Mode().Perm(); permission != 0o600 { + t.Errorf("permission = %04o, want 0600", permission) + } + } +} + +func TestWriteTempMarkdownRemovesPartialFileAfterFailure(t *testing.T) { + testErr := errors.New("injected failure") + tests := []struct { + name string + configure func(*fakeTempFile) + wantCloses int + }{ + { + name: "chmod", + configure: func(file *fakeTempFile) { + file.chmodErr = testErr + }, + wantCloses: 1, + }, + { + name: "write", + configure: func(file *fakeTempFile) { + file.writeErr = testErr + }, + wantCloses: 1, + }, + { + name: "short write", + configure: func(file *fakeTempFile) { + file.shortWrite = true + }, + wantCloses: 1, + }, + { + name: "sync", + configure: func(file *fakeTempFile) { + file.syncErr = testErr + }, + wantCloses: 1, + }, + { + name: "close", + configure: func(file *fakeTempFile) { + file.closeErr = testErr + }, + wantCloses: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := &fakeTempFile{name: "partial.md"} + tt.configure(file) + var removed []string + + _, _, err := writeTempMarkdown( + []byte("content"), + func(_, _ string) (tempFile, error) { return file, nil }, + func() (string, error) { return "/tmp", nil }, + func(path string) error { + removed = append(removed, path) + return nil + }, + ) + if err == nil { + t.Fatal("writeTempMarkdown() error = nil, want failure") + } + if len(removed) != 1 || removed[0] != file.name { + t.Errorf("removed = %v, want [%q]", removed, file.name) + } + if file.closeCalls != tt.wantCloses { + t.Errorf("close calls = %d, want %d", file.closeCalls, tt.wantCloses) + } + }) + } +} + +func TestWriteTempMarkdownCreateFailureHasNoCleanup(t *testing.T) { + testErr := errors.New("create failed") + removeCalls := 0 + + _, _, err := writeTempMarkdown( + []byte("content"), + func(_, _ string) (tempFile, error) { return nil, testErr }, + func() (string, error) { return "/tmp", nil }, + func(string) error { + removeCalls++ + return nil + }, + ) + if !errors.Is(err, testErr) { + t.Fatalf("error = %v, want %v", err, testErr) + } + if removeCalls != 0 { + t.Errorf("remove calls = %d, want 0", removeCalls) + } +} + +func TestWriteTempMarkdownResolvesRelativePath(t *testing.T) { + file := &fakeTempFile{name: filepath.Join("tmp", "artifact.md")} + path, size, err := writeTempMarkdown( + []byte("abc"), + func(_, _ string) (tempFile, error) { return file, nil }, + func() (string, error) { return filepath.FromSlash("/workspace"), nil }, + func(string) error { return nil }, + ) + if err != nil { + t.Fatalf("writeTempMarkdown() error = %v", err) + } + if want := filepath.Join(filepath.FromSlash("/workspace"), file.name); path != want { + t.Errorf("path = %q, want %q", path, want) + } + if size != 3 { + t.Errorf("size = %d, want 3", size) + } +} + +type fakeTempFile struct { + name string + chmodErr error + writeErr error + shortWrite bool + syncErr error + closeErr error + closeCalls int +} + +func (f *fakeTempFile) Name() string { return f.name } + +func (f *fakeTempFile) Chmod(fs.FileMode) error { return f.chmodErr } + +func (f *fakeTempFile) Write(content []byte) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + if f.shortWrite { + return len(content) - 1, nil + } + return len(content), nil +} + +func (f *fakeTempFile) Sync() error { return f.syncErr } + +func (f *fakeTempFile) Close() error { + f.closeCalls++ + return f.closeErr +} + +var _ tempFile = (*os.File)(nil) +var _ tempFile = (*fakeTempFile)(nil) diff --git a/internal/output/emitter.go b/internal/output/emitter.go index fcba4e49af..66c68f5002 100644 --- a/internal/output/emitter.go +++ b/internal/output/emitter.go @@ -51,6 +51,11 @@ type EmitOptions struct { DryRun bool Pretty PrettyRenderer JQSafetyWarning bool + // SafetyResult supplies a content-safety result computed from the original + // response before a caller transforms it for output (for example, replacing + // a large inline body with an artifact reference). When set, Success reuses + // this result instead of scanning the transformed value. + SafetyResult *ScanResult } // StreamOptions describes one streamed page's wire representation. Streaming @@ -118,7 +123,7 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error { case FormatJSON: return e.emitEnvelope(data, true, opts) case FormatTable, FormatCSV, FormatNDJSON: - return e.emitFormatted(data, format, opts.Meta) + return e.emitFormatted(data, format, opts) default: return errs.NewInternalError(errs.SubtypeUnknown, "unsupported output format %q", format) @@ -189,7 +194,7 @@ func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error { } func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error { - scanResult := ScanForSafety(e.commandPath, data, e.errOut) + scanResult := e.scanForSafety(data, opts) if scanResult.Blocked { return scanResult.BlockErr } @@ -244,7 +249,7 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro } func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error { - scanResult := ScanForSafety(e.commandPath, data, e.errOut) + scanResult := e.scanForSafety(data, opts) if scanResult.Blocked { return scanResult.BlockErr } @@ -271,8 +276,8 @@ func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error { // emitFormatted handles only non-envelope formats. JSON, jq, and unknown-format // fallback are resolved by Success before reaching this function, so there is // exactly one JSON success contract: the standard Envelope. -func (e *Emitter) emitFormatted(data interface{}, format Format, meta *Meta) error { - scanResult := ScanForSafety(e.commandPath, data, e.errOut) +func (e *Emitter) emitFormatted(data interface{}, format Format, opts EmitOptions) error { + scanResult := e.scanForSafety(data, opts) if scanResult.Blocked { return scanResult.BlockErr } @@ -288,7 +293,7 @@ func (e *Emitter) emitFormatted(data interface{}, format Format, meta *Meta) err if err := WriteFormatted(w, data, format); err != nil { return err } - return writePaginationSummary(w, meta) + return writePaginationSummary(w, opts.Meta) }) case FormatCSV, FormatNDJSON: if err := e.emit(func(w io.Writer) error { @@ -296,16 +301,23 @@ func (e *Emitter) emitFormatted(data interface{}, format Format, meta *Meta) err }); err != nil { return err } - if meta == nil || meta.Pagination == nil { + if opts.Meta == nil || opts.Meta.Pagination == nil { return nil } - return writePaginationDiagnostic(e.errOut, *meta.Pagination) + return writePaginationDiagnostic(e.errOut, *opts.Meta.Pagination) default: return errs.NewInternalError(errs.SubtypeUnknown, "non-envelope emitter received unsupported format %q", format) } } +func (e *Emitter) scanForSafety(data interface{}, opts EmitOptions) ScanResult { + if opts.SafetyResult != nil { + return *opts.SafetyResult + } + return ScanForSafety(e.commandPath, data, e.errOut) +} + func writePaginationSummary(w io.Writer, meta *Meta) error { if meta == nil || meta.Pagination == nil { return nil diff --git a/internal/output/emitter_contract_test.go b/internal/output/emitter_contract_test.go index e50e4dd43f..12a26861b0 100644 --- a/internal/output/emitter_contract_test.go +++ b/internal/output/emitter_contract_test.go @@ -11,6 +11,7 @@ import ( "io" "reflect" "strings" + "sync/atomic" "testing" "github.com/larksuite/cli/errs" @@ -28,6 +29,7 @@ func (w contractFailingWriter) Write([]byte) (int, error) { type contractSafetyProvider struct { alert *extcs.Alert + calls atomic.Int32 } func (p *contractSafetyProvider) Name() string { @@ -35,6 +37,7 @@ func (p *contractSafetyProvider) Name() string { } func (p *contractSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) { + p.calls.Add(1) return p.alert, nil } @@ -184,6 +187,102 @@ func TestEmitterPaginationMetadataByFormat(t *testing.T) { } } +func TestEmitterSuccessReusesSafetyResultFromOriginalData(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn") + provider := &contractSafetyProvider{alert: &extcs.Alert{ + Provider: "emitter-contract", + MatchedRules: []string{"fixture-rule"}, + }} + extcs.Register(provider) + t.Cleanup(func() { extcs.Register(nil) }) + + original := map[string]interface{}{"content": "original large response"} + scanResult := output.ScanForSafety("lark-cli fixture +emit", original, io.Discard) + if got := provider.calls.Load(); got != 1 { + t.Fatalf("scan calls after pre-scan = %d, want 1", got) + } + + compact := map[string]interface{}{"content_file": map[string]interface{}{"path": "/tmp/result.md"}} + stdout := &bytes.Buffer{} + emitter := output.NewEmitter(output.EmitterConfig{ + Out: stdout, + ErrOut: io.Discard, + CommandPath: "lark-cli fixture +emit", + }) + if err := emitter.Success(compact, output.EmitOptions{ + Format: "json", + SafetyResult: &scanResult, + }); err != nil { + t.Fatalf("Emitter.Success() error = %v", err) + } + if got := provider.calls.Load(); got != 1 { + t.Fatalf("scan calls after emission = %d, want 1", got) + } + + var envelope struct { + Data map[string]interface{} `json:"data"` + ContentSafetyAlert *extcs.Alert `json:"_content_safety_alert"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if envelope.ContentSafetyAlert == nil || envelope.ContentSafetyAlert.Provider != "emitter-contract" { + t.Fatalf("content safety alert = %#v, want pre-scanned alert", envelope.ContentSafetyAlert) + } + if _, ok := envelope.Data["content_file"]; !ok { + t.Fatalf("data = %#v, want compact content_file output", envelope.Data) + } + if _, ok := envelope.Data["content"]; ok { + t.Fatalf("data = %#v, original content should not be emitted", envelope.Data) + } +} + +func TestEmitterSuccessDoesNotRescanAnyFormatWithSafetyResult(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn") + provider := &contractSafetyProvider{alert: &extcs.Alert{ + Provider: "emitter-contract", + MatchedRules: []string{"unexpected-scan"}, + }} + extcs.Register(provider) + t.Cleanup(func() { extcs.Register(nil) }) + + scanResult := output.ScanResult{} + tests := []struct { + name string + opts output.EmitOptions + }{ + {name: "json", opts: output.EmitOptions{Format: "json"}}, + {name: "jq", opts: output.EmitOptions{JQ: ".data.id"}}, + {name: "pretty", opts: output.EmitOptions{ + Format: "pretty", + Pretty: func(w io.Writer, _ bool) error { + _, err := io.WriteString(w, "fixture\n") + return err + }, + }}, + {name: "table", opts: output.EmitOptions{Format: "table"}}, + {name: "csv", opts: output.EmitOptions{Format: "csv"}}, + {name: "ndjson", opts: output.EmitOptions{Format: "ndjson"}}, + {name: "unknown format fallback", opts: output.EmitOptions{Format: "yaml"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.opts.SafetyResult = &scanResult + emitter := output.NewEmitter(output.EmitterConfig{ + Out: &bytes.Buffer{}, + ErrOut: io.Discard, + CommandPath: "lark-cli fixture +emit", + }) + if err := emitter.Success(map[string]interface{}{"id": "1"}, tt.opts); err != nil { + t.Fatalf("Emitter.Success() error = %v", err) + } + }) + } + if got := provider.calls.Load(); got != 0 { + t.Fatalf("content safety provider calls = %d, want 0", got) + } +} + func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") stdout := &bytes.Buffer{} diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index 9fd60bc1ca..2c71d9bcab 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -30,6 +30,10 @@ func init() { // and atomic writes are handled internally. type LocalFileIO struct{} +// SupportsLocalTemporaryFiles reports that this implementation and the process +// share the same local filesystem. +func (*LocalFileIO) SupportsLocalTemporaryFiles() bool { return true } + // Open opens a local file for reading after validating the path. func (l *LocalFileIO) Open(name string) (fileio.File, error) { safePath, err := SafeInputPath(name) diff --git a/internal/vfs/localfileio/localfileio_test.go b/internal/vfs/localfileio/localfileio_test.go index 9581165ac1..0be903e4a2 100644 --- a/internal/vfs/localfileio/localfileio_test.go +++ b/internal/vfs/localfileio/localfileio_test.go @@ -47,6 +47,13 @@ func TestProvider_ResolveFileIO(t *testing.T) { } } +func TestLocalFileIOSupportsLocalTemporaryFiles(t *testing.T) { + var support fileio.LocalTemporaryFileSupport = &LocalFileIO{} + if !support.SupportsLocalTemporaryFiles() { + t.Fatal("SupportsLocalTemporaryFiles() = false, want true") + } +} + // ── Open ── func TestLocalFileIO_Open_ValidFile(t *testing.T) { diff --git a/shortcuts/common/contentread/anchored_markdown.go b/shortcuts/common/contentread/anchored_markdown.go new file mode 100644 index 0000000000..06ce7e76fc --- /dev/null +++ b/shortcuts/common/contentread/anchored_markdown.go @@ -0,0 +1,439 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "context" + "encoding/xml" + "errors" + "io" + "regexp" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// FetchAnchoredMarkdown reads a Doc or Docx URL as Markdown with block anchors. +func FetchAnchoredMarkdown(ctx context.Context, runtime *common.RuntimeContext, rawURL string, opts FetchOptions) (*FetchResult, error) { + req := NewRequest(rawURL) + req.WithBlockID = true + ApplyPagination(&req, opts.Full, opts.PageToken, opts.PageSize) + resp, err := FetchDocInfo(ctx, runtime, req) + if err != nil { + return nil, err + } + if resp == nil { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "document read returned an empty response") + } + if strings.TrimSpace(resp.FullContent) == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "document read returned no anchored content") + } + md, rerr := RenderAnchoredMarkdown(resp, opts.MaxRows) + if rerr != nil { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "could not render anchored Markdown: %v", rerr).WithCause(rerr) + } + if strings.TrimSpace(md) == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "document read rendered no Markdown content") + } + return &FetchResult{ + Content: md, + Title: resp.Title, + UpdateTime: resp.UpdateTime, + HasMore: resp.HasMore, + NextPageToken: resp.NextPageToken, + }, nil +} + +// RenderAnchoredMarkdown converts anchored XML to readable Markdown and limits +// materialized tables to maxRows. +func RenderAnchoredMarkdown(resp *Response, maxRows int) (string, error) { + if resp == nil || strings.TrimSpace(resp.FullContent) == "" { + return "", nil + } + return renderAnchoredMarkdown(resp.FullContent, resp.ImageMetaMap, maxRows) +} + +var anchoredMarkdownBlankRunRe = regexp.MustCompile(`\n{3,}`) + +func renderAnchoredMarkdown(xmlContent string, metas map[string]*ImageMeta, maxRows int) (string, error) { + r := &anchoredMarkdownRenderer{metas: metas} + // Content-read returns an XML fragment with occasional HTML constructs and + // unescaped text, so normalize it and decode under a synthetic root. + dec := xml.NewDecoder(strings.NewReader("" + escapeBareLessThan(stripInvalidXMLChars(xmlContent)) + "")) + dec.Strict = false + dec.AutoClose = xml.HTMLAutoClose + dec.Entity = xml.HTMLEntity + + if err := r.renderChildren(dec, ""); err != nil { + return "", err + } + md := anchoredMarkdownBlankRunRe.ReplaceAllString(r.out.String(), "\n\n") + md = TruncateGFMTables(md, maxRows, "") + return strings.TrimSpace(md) + "\n", nil +} + +type anchoredMarkdownRenderer struct { + metas map[string]*ImageMeta + out strings.Builder +} + +func (r *anchoredMarkdownRenderer) renderChildren(dec *xml.Decoder, parentName string) error { + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + if err := r.renderBlock(dec, t); err != nil { + return err + } + case xml.EndElement: + if parentName != "" && t.Name.Local == parentName { + return nil + } + } + } +} + +func (r *anchoredMarkdownRenderer) renderBlock(dec *xml.Decoder, start xml.StartElement) error { + name := start.Name.Local + switch { + case isHeadingTag(name): + level := int(name[1] - '0') + txt, err := r.readText(dec, name) + if err != nil { + return err + } + if txt = normalizeInline(txt); txt != "" { + r.out.WriteString(strings.Repeat("#", level) + " " + txt + idSuffix(realID(start)) + "\n\n") + } + return nil + case name == "p": + txt, err := r.readText(dec, name) + if err != nil { + return err + } + if txt = normalizeInline(txt); txt != "" { + r.out.WriteString(txt + "\n\n") + } + return nil + case name == "ul" || name == "ol": + return r.renderList(dec, start) + case name == "pre": + return r.renderCode(dec, start) + case name == "img": + r.out.WriteString(r.renderImg(start) + "\n\n") + return dec.Skip() + case name == "sheet" || name == "bitable" || name == "synced" || name == "component": + return r.renderEmbedTable(dec, start) + case name == "whiteboard" || name == "board": + r.out.WriteString("> " + resTokenLink("画板", attrOf(start, "token")) + idSuffix(realID(start)) + "\n\n") + return dec.Skip() + default: + // Unknown wrapper (including the synthetic contentroot): descend into children. + return r.renderChildren(dec, name) + } +} + +func (r *anchoredMarkdownRenderer) renderList(dec *xml.Decoder, start xml.StartElement) error { + ordered := start.Name.Local == "ol" + n := 0 + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "li" { + txt, err := r.readText(dec, "li") + if err != nil { + return err + } + if txt = normalizeInline(txt); txt != "" { + n++ + marker := "- " + if ordered { + marker = strconv.Itoa(n) + ". " + } + r.out.WriteString(marker + txt + "\n") + } + } else { + // Content-read may interleave embedded blocks between list items. + r.out.WriteString("\n") + if err := r.renderBlock(dec, t); err != nil { + return err + } + } + case xml.EndElement: + if t.Name.Local == start.Name.Local { + r.out.WriteString("\n") + return nil + } + } + } +} + +func (r *anchoredMarkdownRenderer) renderCode(dec *xml.Decoder, start xml.StartElement) error { + lang := attrOf(start, "lang") + code, err := r.readText(dec, "pre") + if err != nil { + return err + } + r.out.WriteString("```" + lang + "\n" + strings.Trim(code, "\n") + "\n```\n\n") + return nil +} + +func (r *anchoredMarkdownRenderer) renderImg(start xml.StartElement) string { + token := attrOf(start, "token") + base := RenderOneImage(token, r.metas[token]) + return base + idSuffix(realID(start)) +} + +// renderEmbedTable handles table-shaped embeds, text-shaped embeds, and +// placeholders when the service did not materialize either form. +func (r *anchoredMarkdownRenderer) renderEmbedTable(dec *xml.Decoder, start xml.StartElement) error { + id := realID(start) + token := attrOf(start, "token") + rows, rawText, err := r.collectRows(dec, start.Name.Local) + if err != nil { + return err + } + if len(rows) > 0 { + r.out.WriteString("**" + resTokenLink("表", token) + "**" + idSuffix(id) + "\n\n") + r.out.WriteString(rowsToGFM(rows)) + r.out.WriteString("\n") + return nil + } + if md := strings.TrimSpace(rawText); md != "" { + r.out.WriteString("**" + resTokenLink(embedLabel(start.Name.Local), token) + "**" + idSuffix(id) + "\n\n") + r.out.WriteString(md) + r.out.WriteString("\n") + return nil + } + r.out.WriteString("**" + resTokenLink(embedLabel(start.Name.Local), token) + "**" + idSuffix(id) + "\n") + r.out.WriteString("> 内容可能未展开" + embedSkillHint(start.Name.Local) + "\n\n") + return nil +} + +func embedLabel(tag string) string { + switch tag { + case "sheet": + return "电子表格" + case "bitable": + return "多维表格" + case "synced": + return "同步块" + case "component": + return "引用内容" + default: + return "嵌入内容" + } +} + +func embedSkillHint(tag string) string { + switch tag { + case "sheet": + return ",用 sheets +cells-get 取" + case "bitable": + return ",用 base +record-list 取" + default: + return "" + } +} + +// collectRows supports both nested HTML tables and text-only embeds. +func (r *anchoredMarkdownRenderer) collectRows(dec *xml.Decoder, name string) (rows [][]string, rawText string, err error) { + var text strings.Builder + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return rows, text.String(), nil + } + if err != nil { + return rows, text.String(), err + } + switch t := tok.(type) { + case xml.CharData: + text.Write(t) + case xml.StartElement: + if t.Name.Local == "tr" { + cells, err := r.collectCells(dec) + if err != nil { + return rows, text.String(), err + } + rows = append(rows, cells) + } + case xml.EndElement: + if t.Name.Local == name { + return rows, text.String(), nil + } + } + } +} + +func (r *anchoredMarkdownRenderer) collectCells(dec *xml.Decoder) ([]string, error) { + var cells []string + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return cells, nil + } + if err != nil { + return cells, err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "td" || t.Name.Local == "th" { + txt, err := r.readText(dec, t.Name.Local) + if err != nil { + return cells, err + } + cells = append(cells, gfmCell(r.renderCellImages(txt))) + } + case xml.EndElement: + if t.Name.Local == "tr" { + return cells, nil + } + } + } +} + +func (r *anchoredMarkdownRenderer) readText(dec *xml.Decoder, name string) (string, error) { + var b strings.Builder + depth := 1 + for { + tok, err := dec.Token() + if err != nil { + return b.String(), err + } + switch t := tok.(type) { + case xml.CharData: + b.Write(t) + case xml.StartElement: + depth++ + case xml.EndElement: + depth-- + if depth == 0 { + return b.String(), nil + } + } + } +} + +func isHeadingTag(name string) bool { + return len(name) == 2 && name[0] == 'h' && name[1] >= '1' && name[1] <= '6' +} + +func realID(start xml.StartElement) string { + id := attrOf(start, "id") + if id == "" || isAllDigits(id) { + return "" + } + return id +} + +func idSuffix(id string) string { + if id == "" { + return "" + } + return " {#" + id + "}" +} + +// resTokenLink keeps resource tokens distinct from block anchors. +func resTokenLink(label, token string) string { + if token == "" { + return label + } + return "[" + label + "](token=" + token + ")" +} + +func attrOf(start xml.StartElement, name string) string { + for _, a := range start.Attr { + if a.Name.Local == name { + return a.Value + } + } + return "" +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +func isInvalidXMLChar(r rune) bool { + if r == '\t' || r == '\n' || r == '\r' { + return false + } + return r < 0x20 || r == 0xFFFE || r == 0xFFFF +} + +// stripInvalidXMLChars removes controls rejected by encoding/xml. +func stripInvalidXMLChars(s string) string { + if strings.IndexFunc(s, isInvalidXMLChar) < 0 { + return s + } + return strings.Map(func(r rune) rune { + if isInvalidXMLChar(r) { + return -1 + } + return r + }, s) +} + +// escapeBareLessThan preserves unescaped '<' text without masking real tags. +func escapeBareLessThan(s string) string { + if !strings.Contains(s, "<") { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] != '<' { + b.WriteByte(s[i]) + continue + } + if i+1 < len(s) && plausibleTagStart(s[i+1]) { + b.WriteByte('<') + continue + } + b.WriteString("<") + } + return b.String() +} + +func plausibleTagStart(c byte) bool { + switch { + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z': + return true + case c == '_' || c == ':' || c == '/' || c == '!' || c == '?': + return true + } + return false +} + +func normalizeInline(s string) string { + return strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) +} diff --git a/shortcuts/common/contentread/anchored_markdown_table.go b/shortcuts/common/contentread/anchored_markdown_table.go new file mode 100644 index 0000000000..a58b35ab64 --- /dev/null +++ b/shortcuts/common/contentread/anchored_markdown_table.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "regexp" + "strings" +) + +// Table-cell images arrive as escaped markers. XML decoding turns +// them into plain text, so extract the marker and token with regular expressions. +var ( + qaCellImageRe = regexp.MustCompile(`(?s)(.*?)`) + cellImageTokenRe = regexp.MustCompile(`image_token="([^"]+)"`) +) + +// renderCellImages rewrites each … marker in a cell to a +// markdown image reference (via the shared RenderOneImage) joined on ImageMetaMap. +// A marker without an image_token is dropped. +func (r *anchoredMarkdownRenderer) renderCellImages(s string) string { + if !strings.Contains(s, "") { + return s + } + return qaCellImageRe.ReplaceAllStringFunc(s, func(marker string) string { + body := qaCellImageRe.FindStringSubmatch(marker)[1] + tok := cellImageTokenRe.FindStringSubmatch(body) + if len(tok) < 2 { + return "" + } + return RenderOneImage(tok[1], r.metas[tok[1]]) + }) +} + +func gfmCell(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "|", "\\|") + return strings.TrimSpace(s) +} + +// rowsToGFM uses the first row as the header and pads rows to equal width. +func rowsToGFM(rows [][]string) string { + if len(rows) == 0 { + return "" + } + cols := 0 + for _, row := range rows { + if len(row) > cols { + cols = len(row) + } + } + if cols == 0 { + return "" + } + var b strings.Builder + writeRow := func(cells []string) { + b.WriteString("|") + for c := 0; c < cols; c++ { + v := "" + if c < len(cells) { + v = cells[c] + } + b.WriteString(" " + v + " |") + } + b.WriteString("\n") + } + writeRow(rows[0]) + b.WriteString("|") + for c := 0; c < cols; c++ { + b.WriteString(" --- |") + } + b.WriteString("\n") + for _, row := range rows[1:] { + writeRow(row) + } + return b.String() +} diff --git a/shortcuts/common/contentread/anchored_markdown_test.go b/shortcuts/common/contentread/anchored_markdown_test.go new file mode 100644 index 0000000000..563dda95a8 --- /dev/null +++ b/shortcuts/common/contentread/anchored_markdown_test.go @@ -0,0 +1,383 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "strings" + "testing" +) + +func mustRenderAnchoredMarkdown(t *testing.T, xmlContent string, metas map[string]*ImageMeta, maxRows int) string { + t.Helper() + md, err := renderAnchoredMarkdown(xmlContent, metas, maxRows) + if err != nil { + t.Fatalf("renderAnchoredMarkdown error: %v", err) + } + return md +} + +func TestRenderAnchoredMarkdown_HeadingsAnchorParagraphsDont(t *testing.T) { + t.Parallel() + xml := `

文档树结构

` + + `

涉及的节点类别包括:

` + + `

二级标题

` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + for _, want := range []string{ + "# 文档树结构 {#blk_root}", + "## 二级标题 {#blk_h2}", + "涉及的节点类别包括:", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "{#blk_para}") { + t.Errorf("paragraph must not get an anchor, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_StripsIllegalXMLControlChars(t *testing.T) { + t.Parallel() + xml := "

A\x0cB

x\x08y

" + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "# AB {#blk}") { + t.Errorf("heading should render with control char stripped, got:\n%s", got) + } + if !strings.Contains(got, "xy") { + t.Errorf("paragraph should render with control char stripped, got:\n%s", got) + } + if strings.ContainsAny(got, "\x0c\x08") { + t.Errorf("output still carries a control char:\n%q", got) + } +} + +func TestStripInvalidXMLChars_FastPathReturnsCleanInputUnchanged(t *testing.T) { + t.Parallel() + clean := "

tab\there\nand newline

" + if got := stripInvalidXMLChars(clean); got != clean { + t.Errorf("clean input must be returned unchanged, got:\n%q", got) + } + if got := stripInvalidXMLChars("a\x0c\x08b"); got != "ab" { + t.Errorf("control chars must be stripped, got: %q", got) + } +} + +func TestRenderAnchoredMarkdown_HeadingWithoutIDStaysPlain(t *testing.T) { + t.Parallel() + got := mustRenderAnchoredMarkdown(t, `

无 id 标题

`, nil, 0) + if !strings.Contains(got, "### 无 id 标题") { + t.Errorf("want plain heading, got:\n%s", got) + } + if strings.Contains(got, "{#") { + t.Errorf("no anchor expected, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_List(t *testing.T) { + t.Parallel() + ul := `
  • 根节点
  • 表格
` + got := mustRenderAnchoredMarkdown(t, ul, nil, 0) + if !strings.Contains(got, "- 根节点") || !strings.Contains(got, "- 表格") { + t.Errorf("unordered list wrong:\n%s", got) + } + if strings.Contains(got, "{#a}") { + t.Errorf("list item must not get anchor, got:\n%s", got) + } + + ol := `
` + gotO := mustRenderAnchoredMarkdown(t, ol, nil, 0) + if !strings.Contains(gotO, "1. 一") || !strings.Contains(gotO, "2. 二") { + t.Errorf("ordered list wrong:\n%s", gotO) + } +} + +func TestRenderAnchoredMarkdown_Code(t *testing.T) { + t.Parallel() + xml := `
fmt.Println("hi")
` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + if !strings.Contains(got, "```go") || !strings.Contains(got, `fmt.Println("hi")`) { + t.Errorf("code fence wrong:\n%s", got) + } + if strings.Contains(got, "{#c}") { + t.Errorf("code must not get anchor, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_ImageAnchoredAndJoined(t *testing.T) { + t.Parallel() + metas := map[string]*ImageMeta{ + "imgtok": {Caption: "架构图"}, + } + xml := `` + + got := mustRenderAnchoredMarkdown(t, xml, metas, 0) + if !strings.Contains(got, "![架构图](imgtok) {#blk_img}") { + t.Errorf("image wrong:\n%s", got) + } + + missing := mustRenderAnchoredMarkdown(t, ``, metas, 0) + if !strings.Contains(missing, "![image](nope) {#x}") { + t.Errorf("image missing-meta wrong:\n%s", missing) + } +} + +func TestRenderAnchoredMarkdown_NativeSheetToGFM(t *testing.T) { + t.Parallel() + xml := `` + + `` + + `` + + `` + + `
姓名分数
张三90
李四85
` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "**表** {#blk_sheet}") { + t.Errorf("sheet heading/anchor wrong:\n%s", got) + } + for _, want := range []string{ + "| 姓名 | 分数 |", + "| --- | --- |", + "| 张三 | 90 |", + "| 李四 | 85 |", + } { + if !strings.Contains(got, want) { + t.Errorf("missing GFM row %q in:\n%s", want, got) + } + } +} + +func TestRenderAnchoredMarkdown_SheetNestedInListNotDropped(t *testing.T) { + t.Parallel() + xml := `
    ` + + `
  1. 第一步
  2. ` + + `` + + `` + + `` + + `
    方式
    文档-文档引用
    ` + + `
  3. 第二步
  4. ` + + `
` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "1. 第一步") || !strings.Contains(got, "第二步") { + t.Errorf("list items wrong:\n%s", got) + } + if !strings.Contains(got, "**表** {#blk_sheet}") { + t.Errorf("nested sheet anchor missing (table dropped?):\n%s", got) + } + for _, want := range []string{ + "| 边 | 方式 |", + "| --- | --- |", + "| 文档-文档 | 引用 |", + } { + if !strings.Contains(got, want) { + t.Errorf("missing GFM row %q in:\n%s", want, got) + } + } +} + +func TestRenderAnchoredMarkdown_SheetTruncation(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteString(``) + for i := 0; i < 5; i++ { + b.WriteString(``) + } + b.WriteString(`
n
r
`) + got := mustRenderAnchoredMarkdown(t, b.String(), nil, 2) + + if strings.Count(got, "| r |") != 2 { + t.Errorf("want 2 kept data rows, got:\n%s", got) + } + if !strings.Contains(got, "还有 3 行") { + t.Errorf("want truncation hint for 3 dropped rows, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_EmbeddedBitablePlaceholder(t *testing.T) { + t.Parallel() + xml := `` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "**[多维表格](token=bbl_secret)** {#blk_bt}") { + t.Errorf("bitable placeholder wrong:\n%s", got) + } + if !strings.Contains(got, "内容可能未展开,用 base +record-list 取") { + t.Errorf("want 'may not be expanded, fetch via base' hint, got:\n%s", got) + } + if strings.Contains(got, "docY") { + t.Errorf("source-doc-id must not leak, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_EmbeddedBitableExpanded(t *testing.T) { + t.Parallel() + xml := `
业务poc
知识问答@崔
` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "**[表](token=bbl_secret)** {#blk_bt}") { + t.Errorf("bitable header line wrong:\n%s", got) + } + if !strings.Contains(got, "| 业务 | poc |") { + t.Errorf("want GFM header with client-side spacing, got:\n%s", got) + } + if !strings.Contains(got, "| 知识问答 | @崔 |") { + t.Errorf("want GFM data row, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_EmbeddedBitableTruncation(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + for i := 0; i < 5; i++ { + b.WriteString(``) + } + b.WriteString(`
n
r
`) + got := mustRenderAnchoredMarkdown(t, b.String(), nil, 2) + + if strings.Count(got, "| r |") != 2 { + t.Errorf("want 2 kept data rows, got:\n%s", got) + } + if !strings.Contains(got, "**[表](token=bbl)** {#blk_bt}") { + t.Errorf("bitable anchor/token link must survive truncation, got:\n%s", got) + } + if !strings.Contains(got, "还有 3 行") { + t.Errorf("want truncation hint for 3 dropped rows, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_EmbeddedSyncedMarkdown(t *testing.T) { + t.Parallel() + xml := "同步块第一行\n第二行<a>" + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "**同步块** {#blk_syn}") { + t.Errorf("synced header line wrong:\n%s", got) + } + if !strings.Contains(got, "同步块第一行") || !strings.Contains(got, "第二行") { + t.Errorf("want inlined + un-escaped markdown text, got:\n%s", got) + } + if strings.Contains(got, "base 技能") { + t.Errorf("expanded synced must not be a placeholder, got:\n%s", got) + } + if strings.Contains(got, "docY") { + t.Errorf("source-doc-id must not leak, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_EmbeddedComponentMarkdown(t *testing.T) { + t.Parallel() + xml := "任务:完成方案设计\n状态:未完成" + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "**引用内容** {#blk_task}") { + t.Errorf("component header line wrong:\n%s", got) + } + if !strings.Contains(got, "任务:完成方案设计") || !strings.Contains(got, "状态:未完成") { + t.Errorf("want inlined markdown text, got:\n%s", got) + } + if strings.Contains(got, "base 技能") { + t.Errorf("expanded component must not be a placeholder, got:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_WhiteboardPlaceholder(t *testing.T) { + t.Parallel() + got := mustRenderAnchoredMarkdown(t, ``, nil, 0) + if !strings.Contains(got, "> [画板](token=board_tok) {#wb}") { + t.Errorf("whiteboard placeholder wrong:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_UnescapesEntities(t *testing.T) { + t.Parallel() + got := mustRenderAnchoredMarkdown(t, `

a & b < c > d " e ' f

`, nil, 0) + if !strings.Contains(got, `a & b < c > d " e ' f`) { + t.Errorf("entities not un-escaped:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_ToleratesBareLessThan(t *testing.T) { + t.Parallel() + xml := `

cmd < ok

` + + `

heredoc: <<'EOF'

` + + `

range a < b

` + + `

after

` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + for _, want := range []string{ + "heredoc: <<'EOF'", + "range a < b", + "cmd < ok", + "## after {#blk_h2}", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestRenderAnchoredMarkdown_CellPipeEscaped(t *testing.T) { + t.Parallel() + xml := `
a|bc
` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + if !strings.Contains(got, `a\|b`) { + t.Errorf("pipe in cell not escaped:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_CellImageMarker(t *testing.T) { + t.Parallel() + metas := map[string]*ImageMeta{ + "K1": {Caption: "图1"}, + } + xml := `` + + `` + + `` + + `` + + `
col
<qa:image>anchor="b" image_token="K1" w="0" h="0"</qa>
<qa:image>image_token="K2"</qa>
` + got := mustRenderAnchoredMarkdown(t, xml, metas, 0) + + if !strings.Contains(got, "![图1](K1)") { + t.Errorf("cell image with meta should join url:\n%s", got) + } + if !strings.Contains(got, "![image](K2)") { + t.Errorf("cell image without meta should fall back to token:\n%s", got) + } + if strings.Contains(got, "") || strings.Contains(got, "") || strings.Contains(got, "image_token=") { + t.Errorf("raw qa:image marker must not leak:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_EmptyInput(t *testing.T) { + t.Parallel() + got := mustRenderAnchoredMarkdown(t, ``, nil, 0) + if strings.TrimSpace(got) != "" { + t.Errorf("empty input should render empty, got: %q", got) + } +} + +func TestRenderAnchoredMarkdown_HTMLishTableTolerated(t *testing.T) { + t.Parallel() + xml := `
line1
line2
a b
` + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + if !strings.Contains(got, "**表** {#s}") { + t.Errorf("html-ish table should still render, got:\n%s", got) + } + if !strings.Contains(got, "line1") || !strings.Contains(got, "line2") { + t.Errorf("cell text lost:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_NilOrEmptyResp(t *testing.T) { + t.Parallel() + if got, err := RenderAnchoredMarkdown(nil, 0); err != nil || got != "" { + t.Errorf("nil resp: got (%q, %v), want (\"\", nil)", got, err) + } + if got, err := RenderAnchoredMarkdown(&Response{}, 0); err != nil || got != "" { + t.Errorf("empty resp: got (%q, %v), want (\"\", nil)", got, err) + } +} diff --git a/shortcuts/common/contentread/fetch.go b/shortcuts/common/contentread/fetch.go new file mode 100644 index 0000000000..435122fd69 --- /dev/null +++ b/shortcuts/common/contentread/fetch.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "context" + "encoding/json" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// Path is the content-read route (materialized Markdown, or for +// docx the XML-with-block-id payload). Exported so drive/docs dry-run can report +// the POST target without re-typing the literal. +const Path = "/open-apis/search/v2/knowledge_qa/fetch_doc_info" + +// FetchDocInfo posts the fetch request and decodes the response. Transport +// failures and non-zero API codes come back already typed from CallAPITyped. +// +// It does NOT call EnsureScopes: the doc-read scope is declared on each entry +// point's Shortcut.Scopes and enforced by the pre-flight. Conditional scopes +// (Wiki unwrap, Minutes note documents) are ensured by the dispatch path that +// needs them. +func FetchDocInfo(ctx context.Context, runtime *common.RuntimeContext, req Request) (*Response, error) { + _ = ctx + data, err := runtime.CallAPITyped("POST", Path, nil, req) + if err != nil { + return nil, err + } + raw, err := json.Marshal(data) + if err != nil { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "marshal fetch data: %s", err).WithCause(err) + } + var resp Response + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "decode fetch response: %s", err).WithCause(err) + } + return &resp, nil +} diff --git a/shortcuts/common/contentread/fetch_test.go b/shortcuts/common/contentread/fetch_test.go new file mode 100644 index 0000000000..2da9676590 --- /dev/null +++ b/shortcuts/common/contentread/fetch_test.go @@ -0,0 +1,210 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "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" +) + +func newFetchTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+fetch"}, cfg, f, core.AsUser) + return rt, reg +} + +func TestFetchDocInfoDecodesContract(t *testing.T) { + rt, reg := newFetchTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "title": "Doc", + "full_content": "# hi", + "url": "https://x", + "update_time": float64(123), + "qa_image_meta_map": map[string]interface{}{ + "t1": map[string]interface{}{ + "image_key": "img_key_1", + "caption": "图", + }, + }, + }}, + }) + + resp, err := FetchDocInfo(context.Background(), rt, Request{URL: "https://doc"}) + if err != nil { + t.Fatalf("FetchDocInfo: %v", err) + } + if resp.Title != "Doc" || resp.FullContent != "# hi" || resp.UpdateTime != 123 { + t.Errorf("decode mismatch: %+v", resp) + } + if resp.URL != "https://x" { + t.Errorf("url decode mismatch: %q", resp.URL) + } + m := resp.ImageMetaMap["t1"] + if m == nil || m.ImageKey != "img_key_1" || m.Caption != "图" { + t.Errorf("image meta decode mismatch: %+v", m) + } +} + +func TestFetchBlockIDRoundTrip(t *testing.T) { + rt, reg := newFetchTestRuntime(t) + const xml = `

hi

` + stub := &httpmock.Stub{ + Method: "POST", + URL: Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "title": "Doc", + "full_content": xml, + }}, + } + reg.Register(stub) + + req := NewRequest("https://doc") + req.WithBlockID = true + resp, err := FetchDocInfo(context.Background(), rt, req) + if err != nil { + t.Fatalf("FetchDocInfo: %v", err) + } + if !strings.Contains(string(stub.CapturedBody), `"with_block_id":true`) { + t.Errorf("request body missing with_block_id: %s", stub.CapturedBody) + } + if resp.FullContent != xml { + t.Errorf("FullContent decode mismatch: %q", resp.FullContent) + } +} + +func TestFetchPaginationRoundTrip(t *testing.T) { + rt, reg := newFetchTestRuntime(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "title": "Doc", + "full_content": "# page 1", + "has_more": true, + "next_page_token": "tok-2", + }}, + } + reg.Register(stub) + + req := NewRequest("https://doc") + req.EnablePagination = true + req.PageToken = "tok-1" + req.PageSize = 4000 + resp, err := FetchDocInfo(context.Background(), rt, req) + if err != nil { + t.Fatalf("FetchDocInfo: %v", err) + } + body := string(stub.CapturedBody) + for _, want := range []string{`"enable_pagination":true`, `"page_token":"tok-1"`, `"page_size":4000`} { + if !strings.Contains(body, want) { + t.Errorf("request body missing %s: %s", want, body) + } + } + if !resp.HasMore || resp.NextPageToken != "tok-2" { + t.Errorf("pagination decode mismatch: HasMore=%v NextPageToken=%q", resp.HasMore, resp.NextPageToken) + } +} + +func TestFetchAnchoredMarkdownClassifiesEmptyContent(t *testing.T) { + rt, reg := newFetchTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{}}, + }) + + _, err := FetchAnchoredMarkdown(context.Background(), rt, "https://www.feishu.cn/doc/doccnLegacy", FetchOptions{}) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, want internal/invalid_response", err, err) + } +} + +func TestNewRequestOmitsPagination(t *testing.T) { + t.Parallel() + req := NewRequest("https://doc") + if req.EnablePagination || req.PageToken != "" || req.PageSize != 0 { + t.Fatalf("NewRequest should leave pagination unset, got %+v", req) + } + raw, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, bad := range []string{"with_block_id", "enable_pagination", "page_token", "page_size"} { + if strings.Contains(string(raw), bad) { + t.Errorf("wire shape leaked %s: %s", bad, raw) + } + } +} + +func TestFetchNonZeroCodePropagates(t *testing.T) { + rt, reg := newFetchTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: Path, + Body: map[string]interface{}{"code": float64(1061044), "msg": "doc not found", "log_id": "lz"}, + }) + + _, err := FetchDocInfo(context.Background(), rt, Request{URL: "https://doc"}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error for non-zero code, got %T: %v", err, err) + } + if p.Code != 1061044 { + t.Errorf("code = %d, want 1061044", p.Code) + } + if p.LogID != "lz" { + t.Errorf("LogID = %q, want lz", p.LogID) + } +} + +func TestFetchHTTPErrorPropagates(t *testing.T) { + rt, reg := newFetchTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: Path, + Status: 500, + RawBody: []byte(`{"error":"boom"}`), + }) + + if _, err := FetchDocInfo(context.Background(), rt, Request{URL: "x"}); err == nil { + t.Fatal("expected error on HTTP 500") + } +} + +func TestFetchDocInfoRejectsMalformedDataAsInvalidResponse(t *testing.T) { + rt, reg := newFetchTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "update_time": "not-an-integer", + }}, + }) + + _, err := FetchDocInfo(context.Background(), rt, Request{URL: "https://doc"}) + p, ok := errs.ProblemOf(err) + if !ok || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, want invalid_response", err, err) + } + if errors.Unwrap(err) == nil { + t.Fatal("decode error must preserve its cause") + } +} diff --git a/shortcuts/common/contentread/images.go b/shortcuts/common/contentread/images.go new file mode 100644 index 0000000000..802e5834b3 --- /dev/null +++ b/shortcuts/common/contentread/images.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "fmt" + "regexp" + "strings" +) + +// qaImageTagRe matches the materialized image tag the server emits: +// (optional space before the self-close). The +// capture group is the ImageMetaMap key. +var qaImageTagRe = regexp.MustCompile(``) + +// RenderImages rewrites every tag in md to a markdown image, +// looking each token up in metas (keyed by image_token). Tags whose token is +// absent from metas degrade to a caption-only / token placeholder. +func RenderImages(md string, metas map[string]*ImageMeta) string { + if !strings.Contains(md, " 0 { + req.PageSize = int32(pageSize) + } +} + +// IsPageContinuation reports whether the caller supplied a page cursor. +// Continuations cannot fall back to an API that would restart at page one. +func IsPageContinuation(pageToken string) bool { + return strings.TrimSpace(pageToken) != "" +} + +// PaginationCursorHint reports a missing cursor without discarding readable data. +func PaginationCursorHint(hasMore bool, nextPageToken string) string { + if hasMore && strings.TrimSpace(nextPageToken) == "" { + return "expected next_page_token when has_more is true, but got empty; retry the read from the start" + } + return "" +} diff --git a/shortcuts/common/contentread/postprocess_test.go b/shortcuts/common/contentread/postprocess_test.go new file mode 100644 index 0000000000..4173497ca9 --- /dev/null +++ b/shortcuts/common/contentread/postprocess_test.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "strings" + "testing" +) + +func TestRenderImages(t *testing.T) { + t.Parallel() + metas := map[string]*ImageMeta{ + "t1": {ImageKey: "img_key_1", Caption: "架构图"}, + "t2": {}, // no caption + } + md := ` and and ` + got := RenderImages(md, metas) + for _, want := range []string{ + "![架构图](img_key_1)", // caption + image_key url + "![image](t2)", // no caption → "image" + "![image](missing)", // meta absent → token placeholder + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestRenderImages_NoTagsUnchanged(t *testing.T) { + t.Parallel() + md := "# title\n\nno images here | a | b" + if got := RenderImages(md, nil); got != md { + t.Errorf("expected unchanged, got: %s", got) + } +} + +func TestTruncateGFMTables_Basic(t *testing.T) { + t.Parallel() + md := strings.Join([]string{ + "| a | b |", + "| --- | --- |", + "| 1 | 2 |", + "| 3 | 4 |", + "| 5 | 6 |", + }, "\n") + got := TruncateGFMTables(md, 2, "") + if !strings.Contains(got, "| 1 | 2 |") || !strings.Contains(got, "| 3 | 4 |") { + t.Errorf("kept rows missing: %s", got) + } + if strings.Contains(got, "| 5 | 6 |") { + t.Errorf("row beyond limit should be dropped: %s", got) + } + if !strings.Contains(got, "还有 1 行") { + t.Errorf("missing truncation hint: %s", got) + } +} + +func TestTruncateGFMTables_NoLimitAndUnderLimit(t *testing.T) { + t.Parallel() + md := "| a |\n| --- |\n| 1 |\n| 2 |" + if got := TruncateGFMTables(md, 0, ""); got != md { + t.Errorf("maxRows=0 must be no-op, got: %s", got) + } + if got := TruncateGFMTables(md, 5, ""); got != md || strings.Contains(got, "还有") { + t.Errorf("under-limit must be unchanged without hint, got: %s", got) + } +} + +func TestTruncateGFMTables_SkipsCodeFence(t *testing.T) { + t.Parallel() + md := strings.Join([]string{ + "```", + "| a | b |", + "| --- | --- |", + "| 1 | 2 |", + "| 3 | 4 |", + "| 5 | 6 |", + "```", + }, "\n") + got := TruncateGFMTables(md, 1, "") + if got != md { + t.Errorf("table inside code fence must not be truncated:\n%s", got) + } + if strings.Contains(got, "还有") { + t.Errorf("no hint expected inside fence: %s", got) + } +} + +func TestTruncateGFMTables_ProsePipeNotTable(t *testing.T) { + t.Parallel() + md := "this | has a pipe\nbut no delimiter row\nand | another | pipe" + if got := TruncateGFMTables(md, 1, ""); got != md { + t.Errorf("prose with pipes but no delimiter must be untouched, got: %s", got) + } +} + +func TestTruncateGFMTables_MultipleTablesIndependent(t *testing.T) { + t.Parallel() + md := strings.Join([]string{ + "| a |", "| --- |", "| 1 |", "| 2 |", "| 3 |", + "", + "text between", + "", + "| x |", "| --- |", "| 9 |", "| 8 |", "| 7 |", + }, "\n") + got := TruncateGFMTables(md, 1, "") + if n := strings.Count(got, "还有 2 行"); n != 2 { + t.Errorf("expected 2 independent truncation hints, got %d:\n%s", n, got) + } +} + +func TestTruncateHintFor(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "sheet": "> 还有 %d 行(用 sheets +cells-get 取全量)", + "bitable": "> 还有 %d 行(用 base +record-list 取全量)", + "slides": "> 还有 %d 行", + "file": "> 还有 %d 行", + "": "> 还有 %d 行", + "unknown": "> 还有 %d 行", + } + for ft, want := range cases { + if got := TruncateHintFor(ft); got != want { + t.Errorf("TruncateHintFor(%q) = %q, want %q", ft, got, want) + } + } +} + +// TestTruncateGFMTables_HintByType asserts the truncation notice points the +// reader at the right native command: sheet → sheets +cells-get, bitable → +// base +record-list, and slides/file/doc (no "fetch full" equivalent) get the +// plain notice with no command pointer. +func TestTruncateGFMTables_HintByType(t *testing.T) { + t.Parallel() + md := strings.Join([]string{ + "| a | b |", "| --- | --- |", "| 1 | 2 |", "| 3 | 4 |", "| 5 | 6 |", + }, "\n") + if got := TruncateGFMTables(md, 2, TruncateHintFor("sheet")); !strings.Contains(got, "sheets +cells-get") || strings.Contains(got, "base +record-list") { + t.Errorf("sheet hint should point at sheets +cells-get only: %s", got) + } + if got := TruncateGFMTables(md, 2, TruncateHintFor("bitable")); !strings.Contains(got, "base +record-list") || strings.Contains(got, "sheets +cells-get") { + t.Errorf("base hint should point at base +record-list only: %s", got) + } + for _, ft := range []string{"slides", "file", ""} { + got := TruncateGFMTables(md, 2, TruncateHintFor(ft)) + if strings.Contains(got, "+cells-get") || strings.Contains(got, "+record-list") { + t.Errorf("%q hint must not point at a command: %s", ft, got) + } + if !strings.Contains(got, "还有 1 行") { + t.Errorf("%q hint should still carry the plain row-count notice: %s", ft, got) + } + } +} + +// TestApplyPagination asserts the pagination wiring from flag values. +func TestApplyPagination(t *testing.T) { + t.Parallel() + t.Run("full opts out", func(t *testing.T) { + req := NewRequest("u") + ApplyPagination(&req, true, "tok", 5) + if req.EnablePagination || req.PageToken != "" || req.PageSize != 0 { + t.Errorf("full must leave pagination off, got %+v", req) + } + }) + t.Run("default on", func(t *testing.T) { + req := NewRequest("u") + ApplyPagination(&req, false, "tok", 0) + if !req.EnablePagination || req.PageToken != "tok" { + t.Errorf("pagination should be on with token, got %+v", req) + } + if req.PageSize != 0 { + t.Errorf("pageSize<=0 must be omitted, got %d", req.PageSize) + } + }) + t.Run("page size hint", func(t *testing.T) { + req := NewRequest("u") + ApplyPagination(&req, false, "", 4000) + if !req.EnablePagination || req.PageSize != 4000 { + t.Errorf("page size hint should forward, got %+v", req) + } + }) +} + +func TestIsPageContinuation(t *testing.T) { + t.Parallel() + if !IsPageContinuation("tok") { + t.Error("non-empty token should be a continuation") + } + if IsPageContinuation("") { + t.Error("empty token should not be a continuation") + } + if IsPageContinuation(" ") { + t.Error("whitespace-only token should not be a continuation") + } +} + +func TestPaginationCursorHint(t *testing.T) { + t.Parallel() + if got := PaginationCursorHint(true, ""); !strings.Contains(got, "expected next_page_token") { + t.Fatalf("hint = %q", got) + } + if got := PaginationCursorHint(true, "next"); got != "" { + t.Fatalf("hint with cursor = %q, want empty", got) + } + if got := PaginationCursorHint(false, ""); got != "" { + t.Fatalf("hint without more pages = %q, want empty", got) + } +} diff --git a/shortcuts/common/contentread/tables.go b/shortcuts/common/contentread/tables.go new file mode 100644 index 0000000000..782d61a0bf --- /dev/null +++ b/shortcuts/common/contentread/tables.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contentread + +import ( + "fmt" + "regexp" + "strings" +) + +const truncateHintFmt = "> 还有 %d 行" + +// TruncateHintFor returns an entity-specific table truncation notice. +func TruncateHintFor(fetchType string) string { + switch fetchType { + case "sheet": + return "> 还有 %d 行(用 sheets +cells-get 取全量)" + case "bitable": + return "> 还有 %d 行(用 base +record-list 取全量)" + default: + return truncateHintFmt + } +} + +var gfmDelimiterRe = regexp.MustCompile(`^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$`) + +// TruncateGFMTables limits each GFM table independently and skips code fences. +// Non-positive maxRows disables truncation; an empty hintFmt uses the default. +func TruncateGFMTables(md string, maxRows int, hintFmt string) string { + if maxRows <= 0 { + return md + } + if strings.TrimSpace(hintFmt) == "" { + hintFmt = truncateHintFmt + } + lines := strings.Split(md, "\n") + out := make([]string, 0, len(lines)) + inFence := false + for i := 0; i < len(lines); { + line := lines[i] + if isFenceLine(line) { + inFence = !inFence + out = append(out, line) + i++ + continue + } + if !inFence && i+1 < len(lines) && strings.Contains(line, "|") && gfmDelimiterRe.MatchString(lines[i+1]) { + out = append(out, lines[i], lines[i+1]) + j := i + 2 + kept, dropped := 0, 0 + for j < len(lines) && isTableRow(lines[j]) { + if kept < maxRows { + out = append(out, lines[j]) + kept++ + } else { + dropped++ + } + j++ + } + if dropped > 0 { + out = append(out, "", fmt.Sprintf(hintFmt, dropped)) + } + i = j + continue + } + out = append(out, line) + i++ + } + return strings.Join(out, "\n") +} + +func isFenceLine(line string) bool { + t := strings.TrimSpace(line) + return strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") +} + +func isTableRow(line string) bool { + if isFenceLine(line) { + return false + } + return strings.TrimSpace(line) != "" && strings.Contains(line, "|") +} diff --git a/shortcuts/common/contentread/types.go b/shortcuts/common/contentread/types.go new file mode 100644 index 0000000000..2854d57876 --- /dev/null +++ b/shortcuts/common/contentread/types.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package contentread implements the shared content-read client and Markdown +// rendering used by docs and drive +fetch. +package contentread + +// Request is the subset of the content-read request used by the CLI. +// WithBlockID selects anchored XML; pagination fields are omitted when unused. +type Request struct { + URL string `json:"url"` + WithBlockID bool `json:"with_block_id,omitempty"` + EnablePagination bool `json:"enable_pagination,omitempty"` + PageToken string `json:"page_token,omitempty"` + PageSize int32 `json:"page_size,omitempty"` +} + +// NewRequest builds a request from the verbatim resource URL so selectors such +// as ?sheet= and ?table= reach the service. +func NewRequest(rawURL string) Request { + return Request{URL: rawURL} +} + +// Response is the subset of the content-read response consumed by the CLI. +// FullContent contains anchored XML when WithBlockID was requested and Markdown +// otherwise; HasMore and NextPageToken describe the current page. +type Response struct { + Title string `json:"title"` + FullContent string `json:"full_content"` + URL string `json:"url"` + UpdateTime int64 `json:"update_time"` + ImageMetaMap map[string]*ImageMeta `json:"qa_image_meta_map"` + NextPageToken string `json:"next_page_token"` + HasMore bool `json:"has_more"` +} + +// ImageMeta contains the image fields currently returned by content-read. +type ImageMeta struct { + ImageKey string `json:"image_key"` + Caption string `json:"caption"` +} diff --git a/shortcuts/common/fetch_content_delivery.go b/shortcuts/common/fetch_content_delivery.go new file mode 100644 index 0000000000..15857bcbdc --- /dev/null +++ b/shortcuts/common/fetch_content_delivery.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "crypto/sha256" + "fmt" + "io" + "unicode/utf8" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/contentartifact" + "github.com/larksuite/cli/internal/output" +) + +const ( + // FetchContentSpillThreshold is the local temporary-file threshold for + // --full responses. It measures the final UTF-8 body, not the JSON envelope. + FetchContentSpillThreshold = 24 * 1024 + fetchContentPreviewLimit = 512 +) + +// FetchContentFile describes content saved outside stdout. Path is absolute so +// callers can find a temporary file after changing directories. +type FetchContentFile struct { + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + Encoding string `json:"encoding"` + Temporary bool `json:"temporary"` + Hint string `json:"hint"` +} + +// FetchContentDelivery holds either the inline body or metadata for a saved +// copy. Small and paginated reads keep the existing inline output. +type FetchContentDelivery struct { + Content string + InlineHint string + File *FetchContentFile + Preview string +} + +// Inline reports whether the content should remain in its legacy JSON field. +func (d FetchContentDelivery) Inline() bool { return d.File == nil } + +// PrepareFetchContentDelivery scans the complete response before saving the +// fetched body, then returns either inline content or saved-file metadata. +func PrepareFetchContentDelivery(runtime *RuntimeContext, safetyData any, content, contentJQPath string) (FetchContentDelivery, output.ScanResult, error) { + scan := runtime.ScanOutputForSafety(safetyData) + if scan.Blocked { + return FetchContentDelivery{}, scan, scan.BlockErr + } + + delivery := FetchContentDelivery{Content: content} + autoSpill := runtime.Bool("full") && + runtime.JqExpr == "" && len([]byte(content)) > FetchContentSpillThreshold + if !autoSpill { + return delivery, scan, nil + } + fallbackHint := fmt.Sprintf( + "Content remains inline because temporary-file delivery failed and may be truncated. If incomplete, rerun locally with --full --jq '%s' and redirect stdout to a new file; use --page-token only when shell redirection is unavailable.", + contentJQPath, + ) + support, ok := runtime.FileIO().(fileio.LocalTemporaryFileSupport) + if !ok || !support.SupportsLocalTemporaryFiles() { + delivery.InlineHint = fallbackHint + return delivery, scan, nil + } + + body := []byte(content) + path, size, err := contentartifact.WriteTempMarkdown(body) + if err != nil { + delivery.InlineHint = fallbackHint + return delivery, scan, nil //nolint:nilerr // Temporary-file delivery is optional; preserve the inline content and recovery hint. + } + + sum := sha256.Sum256(body) + delivery = FetchContentDelivery{ + File: &FetchContentFile{ + Path: path, + SizeBytes: size, + SHA256: fmt.Sprintf("%x", sum), + Encoding: "utf-8", + Temporary: true, + Hint: fmt.Sprintf( + "Oversized content was saved to temporary file: %s. Consider reading or searching this file locally for follow-up questions before fetching the resource again.", + path, + ), + }, + Preview: fetchContentPreview(body), + } + return delivery, scan, nil +} + +func fetchContentPreview(body []byte) string { + if len(body) <= fetchContentPreviewLimit { + return string(body) + } + // Reserve space for the ellipsis while keeping the complete preview at or + // below the byte limit and never splitting a UTF-8 code point. + end := fetchContentPreviewLimit - len("…") + for end > 0 && !utf8.Valid(body[:end]) { + end-- + } + return string(body[:end]) + "…" +} + +// WriteFetchContentPretty prints the body inline or identifies its saved file. +func WriteFetchContentPretty(w io.Writer, delivery FetchContentDelivery) { + if delivery.Inline() { + if delivery.InlineHint != "" { + fmt.Fprintf(w, "Hint: %s\n\n", delivery.InlineHint) + } + fmt.Fprintln(w, delivery.Content) + return + } + fmt.Fprintf(w, "Content saved to: %s\n", delivery.File.Path) + fmt.Fprintf(w, "Size: %d bytes\n", delivery.File.SizeBytes) + fmt.Fprintf(w, "SHA-256: %s\n", delivery.File.SHA256) + fmt.Fprintf(w, "Temporary: %t\n", delivery.File.Temporary) + fmt.Fprintf(w, "Hint: %s\n", delivery.File.Hint) + if delivery.Preview != "" { + fmt.Fprintf(w, "\nPreview:\n%s\n", delivery.Preview) + } +} diff --git a/shortcuts/common/fetch_content_delivery_test.go b/shortcuts/common/fetch_content_delivery_test.go new file mode 100644 index 0000000000..1d017bf3da --- /dev/null +++ b/shortcuts/common/fetch_content_delivery_test.go @@ -0,0 +1,350 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "unicode/utf8" + + "github.com/spf13/cobra" + + extcs "github.com/larksuite/cli/extension/contentsafety" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +type fetchDeliveryTestOptions struct { + full bool + jq string +} + +const testFetchContentJQPath = ".data.content" + +func newFetchDeliveryTestRuntime(t *testing.T, opts fetchDeliveryTestOptions) (*RuntimeContext, *cmdutil.Factory) { + t.Helper() + + root := &cobra.Command{Use: "lark-cli"} + service := &cobra.Command{Use: "drive"} + cmd := &cobra.Command{Use: "+fetch"} + root.AddCommand(service) + service.AddCommand(cmd) + cmd.Flags().Bool("full", false, "") + if err := cmd.Flags().Set("full", boolString(opts.full)); err != nil { + t.Fatalf("set --full: %v", err) + } + + cfg := &core.CliConfig{Brand: core.BrandFeishu} + factory, _, _, _ := cmdutil.TestFactory(t, cfg) + rctx := TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + rctx.JqExpr = opts.jq + return rctx, factory +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} + +func setFetchDeliveryTempDir(t *testing.T) string { + t.Helper() + tempDir := t.TempDir() + t.Setenv("TMPDIR", tempDir) + if runtime.GOOS == "windows" { + t.Setenv("TEMP", tempDir) + t.Setenv("TMP", tempDir) + } + return tempDir +} + +func assertFetchDeliveryFile(t *testing.T, delivery FetchContentDelivery, content string) { + t.Helper() + if delivery.Inline() { + t.Fatal("delivery.Inline() = true, want materialized content") + } + if delivery.Content != "" { + t.Fatalf("delivery.Content has %d bytes, want omitted content", len(delivery.Content)) + } + if delivery.File == nil { + t.Fatal("delivery.File = nil") + } + if !filepath.IsAbs(delivery.File.Path) { + t.Errorf("content file path = %q, want absolute path", delivery.File.Path) + } + if delivery.File.SizeBytes != int64(len([]byte(content))) { + t.Errorf("size_bytes = %d, want %d", delivery.File.SizeBytes, len([]byte(content))) + } + wantHash := sha256.Sum256([]byte(content)) + if delivery.File.SHA256 != hex.EncodeToString(wantHash[:]) { + t.Errorf("sha256 = %q, want %q", delivery.File.SHA256, hex.EncodeToString(wantHash[:])) + } + if delivery.File.Encoding != "utf-8" { + t.Errorf("encoding = %q, want utf-8", delivery.File.Encoding) + } + if !delivery.File.Temporary { + t.Error("temporary = false, want true") + } + if !strings.Contains(delivery.File.Hint, "Oversized content was saved to temporary file:") || + !strings.Contains(delivery.File.Hint, "Consider reading or searching this file locally") { + t.Errorf("hint = %q, want save reason and local-read recommendation", delivery.File.Hint) + } + + got, err := os.ReadFile(delivery.File.Path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", delivery.File.Path, err) + } + if string(got) != content { + t.Fatalf("saved content differs: got %d bytes, want %d", len(got), len(content)) + } + info, err := os.Stat(delivery.File.Path) + if err != nil { + t.Fatalf("Stat(%q): %v", delivery.File.Path, err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Errorf("file mode = %04o, want 0600", info.Mode().Perm()) + } +} + +func TestPrepareFetchContentDeliverySpillBoundary(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + tempDir := setFetchDeliveryTempDir(t) + + t.Run("exactly 24 KiB remains inline", func(t *testing.T) { + content := strings.Repeat("a", FetchContentSpillThreshold) + rctx, _ := newFetchDeliveryTestRuntime(t, fetchDeliveryTestOptions{full: true}) + + delivery, scan, err := PrepareFetchContentDelivery(rctx, map[string]any{"content": content}, content, testFetchContentJQPath) + if err != nil { + t.Fatalf("PrepareFetchContentDelivery() error = %v", err) + } + if scan.Blocked { + t.Fatal("content safety scan unexpectedly blocked") + } + if !delivery.Inline() || delivery.Content != content { + t.Fatalf("delivery = %#v, want exact inline content at threshold", delivery) + } + }) + + t.Run("24 KiB plus one byte spills", func(t *testing.T) { + content := strings.Repeat("b", FetchContentSpillThreshold+1) + rctx, _ := newFetchDeliveryTestRuntime(t, fetchDeliveryTestOptions{full: true}) + + delivery, scan, err := PrepareFetchContentDelivery(rctx, map[string]any{"content": content}, content, testFetchContentJQPath) + if err != nil { + t.Fatalf("PrepareFetchContentDelivery() error = %v", err) + } + if scan.Blocked { + t.Fatal("content safety scan unexpectedly blocked") + } + t.Cleanup(func() { _ = os.Remove(delivery.File.Path) }) + assertFetchDeliveryFile(t, delivery, content) + if filepath.Clean(filepath.Dir(delivery.File.Path)) != filepath.Clean(tempDir) { + t.Errorf("temporary directory = %q, want %q", filepath.Dir(delivery.File.Path), tempDir) + } + if name := filepath.Base(delivery.File.Path); !strings.HasPrefix(name, "lark-cli-fetch-") || filepath.Ext(name) != ".md" { + t.Errorf("temporary filename = %q, want lark-cli-fetch-*.md", name) + } + if len([]byte(delivery.Preview)) > fetchContentPreviewLimit { + t.Errorf("preview = %d bytes, want at most %d", len([]byte(delivery.Preview)), fetchContentPreviewLimit) + } + if !utf8.ValidString(delivery.Preview) { + t.Fatal("preview is not valid UTF-8") + } + }) +} + +func TestPrepareFetchContentDeliveryUnicodePreview(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + setFetchDeliveryTempDir(t) + content := strings.Repeat("界", FetchContentSpillThreshold/len("界")+2) + rctx, _ := newFetchDeliveryTestRuntime(t, fetchDeliveryTestOptions{full: true}) + + delivery, _, err := PrepareFetchContentDelivery(rctx, map[string]any{"content": content}, content, testFetchContentJQPath) + if err != nil { + t.Fatalf("PrepareFetchContentDelivery() error = %v", err) + } + t.Cleanup(func() { _ = os.Remove(delivery.File.Path) }) + assertFetchDeliveryFile(t, delivery, content) + if !utf8.ValidString(delivery.Preview) { + t.Fatal("preview is not valid UTF-8") + } + if got := len([]byte(delivery.Preview)); got > fetchContentPreviewLimit { + t.Errorf("preview = %d bytes, want at most %d", got, fetchContentPreviewLimit) + } + if !strings.HasSuffix(delivery.Preview, "…") { + t.Errorf("preview suffix = %q, want ellipsis", delivery.Preview[len(delivery.Preview)-3:]) + } +} + +func TestPrepareFetchContentDeliveryAutomaticSpillBypasses(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + tempDir := setFetchDeliveryTempDir(t) + content := strings.Repeat("x", FetchContentSpillThreshold+1) + tests := []struct { + name string + opts fetchDeliveryTestOptions + }{ + {name: "non-full", opts: fetchDeliveryTestOptions{}}, + {name: "jq", opts: fetchDeliveryTestOptions{full: true, jq: ".data.content"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _ := newFetchDeliveryTestRuntime(t, tt.opts) + delivery, _, err := PrepareFetchContentDelivery(rctx, map[string]any{"content": content}, content, testFetchContentJQPath) + if err != nil { + t.Fatalf("PrepareFetchContentDelivery() error = %v", err) + } + if !delivery.Inline() || delivery.Content != content { + t.Fatalf("delivery = %#v, want exact inline content", delivery) + } + }) + } + entries, err := os.ReadDir(tempDir) + if err != nil { + t.Fatalf("ReadDir(%q): %v", tempDir, err) + } + if len(entries) != 0 { + t.Fatalf("automatic-spill bypasses created files: %v", entries) + } +} + +func TestPrepareFetchContentDeliveryWithoutLocalTempSupportStaysInline(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + tempDir := setFetchDeliveryTempDir(t) + content := strings.Repeat("x", FetchContentSpillThreshold+1) + rctx, factory := newFetchDeliveryTestRuntime(t, fetchDeliveryTestOptions{full: true}) + factory.FileIOProvider = fetchDeliveryFileIOProvider{fileIO: fetchDeliveryUnsupportedFileIO{}} + + delivery, scan, err := PrepareFetchContentDelivery(rctx, map[string]any{"content": content}, content, testFetchContentJQPath) + if err != nil { + t.Fatalf("PrepareFetchContentDelivery() error = %v", err) + } + if scan.Blocked { + t.Fatal("content safety scan unexpectedly blocked") + } + if !delivery.Inline() || delivery.Content != content { + t.Fatalf("delivery = %#v, want complete inline content", delivery) + } + if want := fetchDeliveryFallbackHint(testFetchContentJQPath); delivery.InlineHint != want { + t.Errorf("inline hint = %q, want %q", delivery.InlineHint, want) + } + entries, readErr := os.ReadDir(tempDir) + if readErr != nil { + t.Fatalf("ReadDir(%q): %v", tempDir, readErr) + } + if len(entries) != 0 { + t.Fatalf("unsupported runtime created temporary files: %v", entries) + } +} + +func fetchDeliveryFallbackHint(contentJQPath string) string { + return "Content remains inline because temporary-file delivery failed and may be truncated. " + + "If incomplete, rerun locally with --full --jq '" + contentJQPath + + "' and redirect stdout to a new file; use --page-token only when shell redirection is unavailable." +} + +func TestWriteFetchContentPrettyPrintsInlineFallbackHintFirst(t *testing.T) { + delivery := FetchContentDelivery{Content: "body", InlineHint: "retry another way"} + var out strings.Builder + WriteFetchContentPretty(&out, delivery) + if got, want := out.String(), "Hint: retry another way\n\nbody\n"; got != want { + t.Fatalf("pretty output = %q, want %q", got, want) + } +} + +func TestPrepareFetchContentDeliverySafetyBlockCreatesNoFile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block") + tempDir := setFetchDeliveryTempDir(t) + const tailMarker = "fetch-safety-tail-marker" + provider := &fetchDeliverySafetyProvider{marker: tailMarker} + extcs.Register(provider) + t.Cleanup(func() { extcs.Register(nil) }) + content := strings.Repeat("blocked", FetchContentSpillThreshold) + tailMarker + rctx, _ := newFetchDeliveryTestRuntime(t, fetchDeliveryTestOptions{full: true}) + + delivery, scan, err := PrepareFetchContentDelivery(rctx, map[string]any{"content": content}, content, testFetchContentJQPath) + if err == nil || !scan.Blocked { + t.Fatalf("PrepareFetchContentDelivery() = (%#v, blocked=%t, %v), want content-safety block", delivery, scan.Blocked, err) + } + entries, readErr := os.ReadDir(tempDir) + if readErr != nil { + t.Fatalf("ReadDir(%q): %v", tempDir, readErr) + } + if len(entries) != 0 { + t.Fatalf("content-safety block created files: %v", entries) + } +} + +type fetchDeliverySafetyProvider struct{ marker string } + +func (p *fetchDeliverySafetyProvider) Name() string { return "fetch-delivery-test" } + +func (p *fetchDeliverySafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) { + raw, err := json.Marshal(req.Data) + if err != nil || !strings.Contains(string(raw), p.marker) { + return nil, err + } + return &extcs.Alert{ + Provider: p.Name(), + MatchedRules: []string{"blocked-content"}, + }, nil +} + +type fetchDeliveryUnsupportedFileIO struct{} + +func (fetchDeliveryUnsupportedFileIO) Open(string) (fileio.File, error) { return nil, fs.ErrNotExist } +func (fetchDeliveryUnsupportedFileIO) Stat(string) (fileio.FileInfo, error) { + return nil, fs.ErrNotExist +} +func (fetchDeliveryUnsupportedFileIO) ResolvePath(path string) (string, error) { return path, nil } +func (fetchDeliveryUnsupportedFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + return nil, fs.ErrPermission +} + +type fetchDeliveryFileIOProvider struct{ fileIO fileio.FileIO } + +func (p fetchDeliveryFileIOProvider) Name() string { return "fetch-delivery-test" } +func (p fetchDeliveryFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO { + return p.fileIO +} + +func TestPrepareFetchContentDeliveryTemporaryFileFailureStaysInline(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + notDirectory := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(notDirectory, []byte("file"), 0o600); err != nil { + t.Fatalf("WriteFile(%q): %v", notDirectory, err) + } + t.Setenv("TMPDIR", notDirectory) + t.Setenv("TEMP", notDirectory) + t.Setenv("TMP", notDirectory) + + content := strings.Repeat("x", FetchContentSpillThreshold+1) + rctx, _ := newFetchDeliveryTestRuntime(t, fetchDeliveryTestOptions{full: true}) + delivery, scan, err := PrepareFetchContentDelivery(rctx, map[string]any{"content": content}, content, testFetchContentJQPath) + if err != nil { + t.Fatalf("PrepareFetchContentDelivery() error = %v", err) + } + if scan.Blocked { + t.Fatal("content safety scan unexpectedly blocked") + } + if !delivery.Inline() || delivery.Content != content { + t.Fatalf("delivery = %#v, want complete inline content", delivery) + } + if want := fetchDeliveryFallbackHint(testFetchContentJQPath); delivery.InlineHint != want { + t.Errorf("inline hint = %q, want %q", delivery.InlineHint, want) + } +} diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 29ec31c10e..c5d69ac585 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -135,3 +135,63 @@ func ParseResourceURL(rawURL string) (ResourceRef, bool) { return ResourceRef{}, false } + +// ResourceURLOrBuild preserves URL inputs and builds a brand URL for bare +// tokens. Unknown kinds remain unchanged so the backend can reject them. +func ResourceURLOrBuild(brand core.LarkBrand, kind, input string) string { + input = strings.TrimSpace(input) + if input == "" || strings.Contains(input, "://") { + return input + } + if built := BuildResourceURL(brand, kind, input); built != "" { + return built + } + return input +} + +// FetchURLResolution includes the Wiki probe state behind a resolved fetch URL. +// Callers that need the underlying type can reuse WikiNode; WikiProbeAttempted +// also distinguishes "not probed" from a failed probe so they do not retry it. +type FetchURLResolution struct { + URL string + WikiNode *WikiNode + WikiProbeAttempted bool +} + +// ResolveFetchURLDetailed resolves a URL or bare token for the fetch API and +// includes the bare-token Wiki probe result. Real URLs do not need a probe. A +// bare token is probed exactly once; both successful and failed attempts are +// recorded for downstream reuse. +func ResolveFetchURLDetailed(runtime *RuntimeContext, declaredKind, input string) FetchURLResolution { + input = strings.TrimSpace(input) + if input == "" || strings.Contains(input, "://") { + return FetchURLResolution{URL: input} + } + if node, err := ResolveWikiNode(runtime, input); err == nil { + if u := wikiNodeURL(runtime.Config.Brand, node); u != "" { + return FetchURLResolution{URL: u, WikiNode: node, WikiProbeAttempted: true} + } + return FetchURLResolution{ + URL: ResourceURLOrBuild(runtime.Config.Brand, declaredKind, input), + WikiNode: node, + WikiProbeAttempted: true, + } + } + return FetchURLResolution{ + URL: ResourceURLOrBuild(runtime.Config.Brand, declaredKind, input), + WikiProbeAttempted: true, + } +} + +// wikiNodeURL builds the /wiki/ URL a resolved wiki node reads as: +// node_token, except for a shortcut node (node_type=shortcut) where it follows +// origin_node_token to the real node so the fetch reads the original doc, not the +// shortcut. Returns "" when node_token is absent (BuildResourceURL returns "" for +// an empty token). +func wikiNodeURL(brand core.LarkBrand, node *WikiNode) string { + nodeToken := node.NodeToken + if strings.EqualFold(node.NodeType, "shortcut") && node.OriginNodeToken != "" { + nodeToken = node.OriginNodeToken + } + return BuildResourceURL(brand, "wiki", nodeToken) +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e12224f0cd..7cd99364a2 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -688,6 +688,15 @@ func (ctx *RuntimeContext) ValidatePath(path string) error { // ── Output helpers ── +// ScanOutputForSafety scans the original response before a caller performs an +// output-only transformation that would hide part of it from the emitter. A +// caller that observes Blocked must stop before creating files or performing +// any other output side effect, and return BlockErr. +func (ctx *RuntimeContext) ScanOutputForSafety(data any) output.ScanResult { + streams := ctx.IO() + return output.ScanForSafety(ctx.Cmd.CommandPath(), data, streams.ErrOut) +} + func (ctx *RuntimeContext) newEmitter() *output.Emitter { streams := ctx.IO() return output.NewEmitter(output.EmitterConfig{ @@ -792,6 +801,21 @@ func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, pre })) } +// OutFormatRawWithSafety is like OutFormatRaw but reuses a content-safety +// result computed from the original response. This keeps alerts and blocks +// associated with that response while avoiding a second scan of transformed +// output data. +func (ctx *RuntimeContext) OutFormatRawWithSafety(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), scanResult output.ScanResult) { + ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{ + Format: ctx.Format, + Raw: true, + JQ: ctx.JqExpr, + Meta: meta, + Pretty: wrapLegacyPrettyRenderer(prettyFn), + SafetyResult: &scanResult, + })) +} + // ── Scope pre-check ── // checkScopePrereqs performs a fast local check: does the token diff --git a/shortcuts/common/wiki_node.go b/shortcuts/common/wiki_node.go new file mode 100644 index 0000000000..5d3819f1c1 --- /dev/null +++ b/shortcuts/common/wiki_node.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "strings" + + "github.com/larksuite/cli/errs" +) + +// WikiNode is the structured result of unwrapping a wiki node token via wiki +// get_node. ObjType/ObjToken identify the underlying resource — the thing drive +// +fetch / +inspect actually reads; NodeToken/SpaceID/NodeType carry the wiki +// provenance recorded in resource.source. OriginNodeToken is set for shortcut +// nodes (node_type=shortcut) and points at the origin node. +type WikiNode struct { + ObjType string + ObjToken string + NodeToken string + SpaceID string + NodeType string + OriginNodeToken string +} + +// ResolveWikiNode unwraps a wiki node token into the underlying resource via +// GET /open-apis/wiki/v2/spaces/get_node (obj_type=wiki, the API default: treat +// the token as a node_token). It returns the node's obj_type/obj_token (what to +// actually fetch) plus the wiki provenance (node_token/space_id/node_type) for +// source metadata. drive +inspect and drive +fetch share this helper so wiki +// unwrapping lives in one place rather than inline copies. +// +// obj_type=wiki is the explicit default the API uses when a token is a +// node_token; passing it makes the intent unambiguous. Returns a typed +// InvalidResponse error when get_node succeeds but the node lacks +// obj_type/obj_token (incomplete data); transport/permission errors come back as +// the underlying CallAPITyped error for the caller to annotate. +func ResolveWikiNode(runtime *RuntimeContext, wikiToken string) (*WikiNode, error) { + data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": wikiToken, "obj_type": "wiki"}, nil) + if err != nil { + return nil, err + } + node := GetMap(data, "node") + objType := strings.TrimSpace(GetString(node, "obj_type")) + objToken := strings.TrimSpace(GetString(node, "obj_token")) + if objType == "" || objToken == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken) + } + return &WikiNode{ + ObjType: objType, + ObjToken: objToken, + NodeToken: strings.TrimSpace(GetString(node, "node_token")), + SpaceID: strings.TrimSpace(GetString(node, "space_id")), + NodeType: strings.TrimSpace(GetString(node, "node_type")), + OriginNodeToken: strings.TrimSpace(GetString(node, "origin_node_token")), + }, nil +} diff --git a/shortcuts/doc/docs_fetch_markdown.go b/shortcuts/doc/docs_fetch_markdown.go new file mode 100644 index 0000000000..aee1809988 --- /dev/null +++ b/shortcuts/doc/docs_fetch_markdown.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "io" + "maps" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" +) + +// FetchDocumentMarkdown reads a document as plain Markdown through the document +// fetch API. It is the fallback when the paginated anchored-Markdown path is +// unavailable on the first page. +func FetchDocumentMarkdown(runtime *common.RuntimeContext, docToken string) (content string, err error) { + apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", docToken) + body := map[string]interface{}{"format": "markdown"} + injectDocsScene(runtime, body) + data, err := runtime.CallAPITyped("POST", apiPath, nil, body) + if err != nil { + return "", err + } + if doc, ok := data["document"].(map[string]interface{}); ok { + content, _ = doc["content"].(string) + } + return content, nil +} + +// resolvedFetchURL is the URL forwarded to the paginated read on the Execute path: +// a bare token is resolved via a wiki probe, a real URL is forwarded verbatim. +func resolvedFetchURL(runtime *common.RuntimeContext) common.FetchURLResolution { + return common.ResolveFetchURLDetailed(runtime, "docx", strings.TrimSpace(runtime.Str("doc"))) +} + +// typedFetchURL is the dry-run counterpart — a typed /docx/ URL for a bare +// token, with no wiki probe (dry-run makes no API calls). +func typedFetchURL(runtime *common.RuntimeContext) string { + return common.ResourceURLOrBuild(runtime.Config.Brand, "docx", strings.TrimSpace(runtime.Str("doc"))) +} + +// pageContinuationFailed preserves a typed upstream error and adds the +// continuation-specific recovery step. Untyped render failures are malformed +// response errors, not server errors. +func pageContinuationFailed(cause error) error { + const hint = "the cursor may have expired because the document changed; re-run without --page-token to read from the start" + if problem, ok := errs.ProblemOf(cause); ok { + if problem.Hint == "" { + problem.Hint = hint + } else if !strings.Contains(problem.Hint, hint) { + problem.Hint += "; " + hint + } + return cause + } + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "could not decode the continuation page: %v", cause). + WithHint(hint). + WithCause(cause) +} + +// handlePaginatedReadFailure falls back on the first page but surfaces a +// continuation error because the document API cannot honor a cursor. +func handlePaginatedReadFailure(runtime *common.RuntimeContext, continuation bool, cause error) (bool, error) { + if continuation { + return true, pageContinuationFailed(cause) + } + fmt.Fprintf(runtime.IO().ErrOut, + "[fetch] paginated Markdown read unavailable (%v); falling back to the document API\n", cause) + return false, nil +} + +// emitPaginatedMarkdown emits Markdown and pagination metadata. Oversized +// --full reads may replace inline content with a local file descriptor. +func emitPaginatedMarkdown(runtime *common.RuntimeContext, content, title string, updateTime int64, hasMore bool, nextPageToken string) error { + nextPageToken = strings.TrimSpace(nextPageToken) + data := map[string]interface{}{ + "document": map[string]interface{}{ + "content": content, + "title": title, + "update_time": updateTime, + }, + } + if hasMore { + data["has_more"] = true + data["next_page_token"] = nextPageToken + } + cursorHint := contentread.PaginationCursorHint(hasMore, nextPageToken) + if cursorHint != "" { + appendDocWarning(data, cursorHint) + } + if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" { + fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) + } + delivery, scan, err := common.PrepareFetchContentDelivery(runtime, data, content, docsFetchContentJQPath) + if err != nil { + return err + } + emitted := cloneFetchDocumentData(data) + applyFetchContentDelivery(emitted, delivery) + runtime.OutFormatRawWithSafety(emitted, nil, func(w io.Writer) { + common.WriteFetchContentPretty(w, delivery) + }, scan) + if cursorHint != "" { + fmt.Fprintf(runtime.IO().ErrOut, "[fetch] warning: %s\n", cursorHint) + } else if hasMore { + fmt.Fprintf(runtime.IO().ErrOut, + "[fetch] more content available — re-run with --page-token %s to continue "+ + "(cursor is tied to this doc version; if the doc changed, re-fetch from the start)\n", + nextPageToken) + } + return nil +} + +func cloneFetchDocumentData(data map[string]interface{}) map[string]interface{} { + emitted := maps.Clone(data) + document := maps.Clone(data["document"].(map[string]interface{})) + emitted["document"] = document + return emitted +} + +func applyFetchContentDelivery(data map[string]interface{}, delivery common.FetchContentDelivery) { + document := data["document"].(map[string]interface{}) + if delivery.Inline() { + if delivery.InlineHint != "" { + data["content_delivery_hint"] = delivery.InlineHint + document["content_inline"] = true + } + return + } + delete(document, "content") + document["content_inline"] = false + document["content_file"] = delivery.File + document["content_preview"] = delivery.Preview +} + +// runAnchoredMarkdownFetch handles whole-document Markdown with pagination and +// block anchors. It returns the raw failure to executeFetchV2 so a Wiki input +// can be diagnosed before the document API fallback is emitted. +func runAnchoredMarkdownFetch(ctx context.Context, runtime *common.RuntimeContext, fetchURL string) (handled bool, err error) { + opts := contentread.FetchOptions{ + MaxRows: runtime.Int("embed-max-rows"), + Full: runtime.Bool("full"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + PageSize: runtime.Int("page-size"), + } + result, ferr := contentread.FetchAnchoredMarkdown(ctx, runtime, fetchURL, opts) + if ferr != nil { + return false, ferr + } + if err := emitPaginatedMarkdown(runtime, result.Content, result.Title, result.UpdateTime, result.HasMore, result.NextPageToken); err != nil { + return true, err + } + return true, nil +} + +// dryRunAnchoredMarkdownFetch describes the paginated anchored-Markdown call. +func dryRunAnchoredMarkdownFetch(runtime *common.RuntimeContext) *common.DryRunAPI { + body := contentread.NewRequest(typedFetchURL(runtime)) + body.WithBlockID = true + contentread.ApplyPagination(&body, runtime.Bool("full"), runtime.Str("page-token"), runtime.Int("page-size")) + return common.NewDryRunAPI(). + POST(contentread.Path). + Desc("fetch document as paginated Markdown with block anchors"). + Body(body). + Set("embed_max_rows", runtime.Int("embed-max-rows")) +} diff --git a/shortcuts/doc/docs_fetch_spill_test.go b/shortcuts/doc/docs_fetch_spill_test.go new file mode 100644 index 0000000000..eb85c88bda --- /dev/null +++ b/shortcuts/doc/docs_fetch_spill_test.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" +) + +func TestDocsFetchFullAnchoredMarkdownOversizeSpills(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + t.Setenv("TMPDIR", t.TempDir()) + text := strings.Repeat("anchored content ", 2399) + "anchored content" + wantContent := text + "\n" + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-anchored-spill")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "title": "Large Doc", + "full_content": "

" + text + "

", + }, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/docx/doxcnAnchoredSpill", + "--doc-format", "markdown", + "--full", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("docs +fetch anchored Markdown error = %v", err) + } + _, document := decodeDocsSpillEnvelope(t, stdout.Bytes()) + assertDocsSpillFile(t, document, wantContent) +} + +func TestDocsFetchFullDocumentAPIFallbackReturnsInlineContent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + const wantContent = "document API fallback content\n" + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-fallback")) + primaryStub := &httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + } + reg.Register(primaryStub) + fallbackStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnNativeFallback/fetch", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcnNativeFallback", + "content": wantContent, + }, + }, + }, + } + reg.Register(fallbackStub) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/docx/doxcnNativeFallback", + "--doc-format", "markdown", + "--full", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("docs +fetch fallback error = %v", err) + } + _, document := decodeDocsSpillEnvelope(t, stdout.Bytes()) + if got := document["content"]; got != wantContent { + t.Fatalf("content = %#v, want %q", got, wantContent) + } + if _, ok := document["content_file"]; ok { + t.Fatalf("small fallback content unexpectedly spilled: %#v", document) + } + if len(primaryStub.CapturedBodies) != 1 || len(fallbackStub.CapturedBodies) != 1 { + t.Fatalf("calls: primary=%d fallback=%d, want one each", len(primaryStub.CapturedBodies), len(fallbackStub.CapturedBodies)) + } +} + +func TestApplyFetchContentDeliveryDoesNotMutateScannedData(t *testing.T) { + originalDocument := map[string]interface{}{"content": "body", "title": "title"} + original := map[string]interface{}{"document": originalDocument} + emitted := cloneFetchDocumentData(original) + applyFetchContentDelivery(emitted, common.FetchContentDelivery{ + File: &common.FetchContentFile{Path: "/tmp/body.md"}, + Preview: "preview", + }) + + if originalDocument["content"] != "body" { + t.Fatalf("original document was mutated: %#v", originalDocument) + } + if _, ok := emitted["document"].(map[string]interface{})["content"]; ok { + t.Fatalf("emitted document retained inline content: %#v", emitted) + } +} + +func TestApplyFetchContentDeliveryAddsInlineFallbackHint(t *testing.T) { + data := map[string]interface{}{"document": map[string]interface{}{"content": "body"}} + applyFetchContentDelivery(data, common.FetchContentDelivery{ + Content: "body", + InlineHint: "retry without --full", + }) + + document := data["document"].(map[string]interface{}) + if data["content_delivery_hint"] != "retry without --full" || document["content_inline"] != true { + t.Fatalf("data = %#v, want inline fallback metadata", data) + } + if document["content"] != "body" { + t.Fatalf("inline fallback changed content: %#v", document) + } + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal inline fallback: %v", err) + } + if strings.Index(string(raw), `"content_delivery_hint"`) > strings.Index(string(raw), `"content":"body"`) { + t.Fatalf("inline hint must precede the oversized body: %s", raw) + } +} + +func decodeDocsSpillEnvelope(t *testing.T, raw []byte) (map[string]interface{}, map[string]interface{}) { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, raw) + } + data, ok := envelope["data"].(map[string]interface{}) + if !ok { + t.Fatalf("missing data object: %#v", envelope) + } + document, ok := data["document"].(map[string]interface{}) + if !ok { + t.Fatalf("missing document object: %#v", data) + } + return data, document +} + +func assertDocsSpillFile(t *testing.T, document map[string]interface{}, wantContent string) { + t.Helper() + if _, ok := document["content"]; ok { + t.Fatal("spilled document retained inline content") + } + if inline, ok := document["content_inline"].(bool); !ok || inline { + t.Fatalf("content_inline = %#v, want false", document["content_inline"]) + } + file, ok := document["content_file"].(map[string]interface{}) + if !ok { + t.Fatalf("content_file = %#v", document["content_file"]) + } + path, _ := file["path"].(string) + t.Cleanup(func() { _ = os.Remove(path) }) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read spill file: %v", err) + } + if string(got) != wantContent { + t.Fatalf("spill file content mismatch: got %d bytes, want %d", len(got), len(wantContent)) + } + if file["temporary"] != true || int(file["size_bytes"].(float64)) != len(wantContent) { + t.Fatalf("content_file = %#v", file) + } + if hint, _ := file["hint"].(string); !strings.Contains(hint, "Oversized content was saved to temporary file:") || + !strings.Contains(hint, "Consider reading or searching this file locally") { + t.Fatalf("content_file.hint = %q", hint) + } + if preview, _ := document["content_preview"].(string); preview == "" || len(preview) > 512 { + t.Fatalf("content_preview length = %d, want 1..512", len(preview)) + } +} diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index f8b43812e6..c6b10eaeec 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -7,14 +7,19 @@ import ( "context" "fmt" "io" + "math" "strconv" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" ) -const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}` +const ( + docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}` + docsFetchContentJQPath = ".data.document.content" +) // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { @@ -30,6 +35,11 @@ func v2FetchFlags() []common.Flag { {Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"}, {Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"}, {Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"}, + // Whole-document Markdown pagination with block anchors. + {Name: "full", Type: "bool", Default: "false", Desc: "markdown whole-doc only: return the whole document in one response (disable auto-pagination)"}, + {Name: "page-token", Desc: "markdown whole-doc only: continue a paginated read from a prior next_page_token"}, + {Name: "page-size", Type: "int", Default: "0", Desc: "markdown whole-doc only: per-page token budget hint (0 = server default)"}, + {Name: "embed-max-rows", Type: "int", Default: "50", Desc: "markdown only: cap each rendered table to N data rows (0 = no limit)"}, } } @@ -43,13 +53,56 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error { if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { return err } + if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 0, 0, math.MaxInt32); err != nil { + return err + } if err := validateReadModeFlags(runtime); err != nil { return err } + return validatePaginatedReadFlags(runtime) +} + +// useAnchoredMarkdownRead reports whether the whole-document Markdown read uses the +// paginated anchored-Markdown path instead of the document API. Only Markdown + +// scope=full qualifies; XML, partial scopes, and im-markdown use the document API. +func useAnchoredMarkdownRead(runtime *common.RuntimeContext) bool { + if runtime.Str("doc-format") != "markdown" || effectiveFetchReadMode(runtime) != "full" { + return false + } + // The paginated Markdown API has no field for a historical revision or a cite + // language, so it would silently return the latest revision / default + // language. Route to the document API (which honors both) when + // either is explicitly requested, instead of silently dropping the user's intent. + if runtime.Int("revision-id") > 0 || runtime.Changed("lang") { + return false + } + return true +} + +// validatePaginatedReadFlags checks the paginated-read flags (--full/--page-token/ +// --page-size) apply only to the markdown whole-doc path. +func validatePaginatedReadFlags(runtime *common.RuntimeContext) error { + if runtime.Bool("full") && (strings.TrimSpace(runtime.Str("page-token")) != "" || runtime.Int("page-size") > 0) { + return common.ValidationErrorf("--full cannot be combined with --page-token/--page-size").WithParam("--full") + } + usePaginatedRead := useAnchoredMarkdownRead(runtime) + pagination := runtime.Bool("full") || strings.TrimSpace(runtime.Str("page-token")) != "" || runtime.Int("page-size") > 0 + if pagination && !usePaginatedRead { + // Markdown + full would otherwise enable the paginated read; if it is off here, + // a historical revision or an explicit --lang forced the document API path, so + // the pagination-only flags conflict with those (not with format/scope). + if runtime.Str("doc-format") == "markdown" && effectiveFetchReadMode(runtime) == "full" { + return common.ValidationErrorf("--full/--page-token/--page-size are not supported together with a historical --revision-id (or an explicit --lang), which use the document API path").WithParam("--full") + } + return common.ValidationErrorf("--full/--page-token/--page-size only apply to --doc-format markdown with --scope full").WithParam("--full") + } return nil } func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if useAnchoredMarkdownRead(runtime) { + return dryRunAnchoredMarkdownFetch(runtime) + } // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) body := buildFetchBody(runtime) @@ -61,14 +114,39 @@ func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.Dr Set("document_id", ref.Token) } -func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { +func executeFetchV2(ctx context.Context, runtime *common.RuntimeContext) error { ref, _ := parseDocumentRef(runtime.Str("doc")) + var resolution common.FetchURLResolution + if useAnchoredMarkdownRead(runtime) { + resolution = resolvedFetchURL(runtime) + } + diagnoseWikiType := newWikiFetchTypeGuard(runtime, ref, resolution.WikiProbeAttempted, resolution.WikiNode) + + // Whole-document Markdown reads try the paginated Markdown endpoint first. + // A first-page failure falls back to the document-fetch API, preserving the + // behavior available before the paginated path was introduced. + if useAnchoredMarkdownRead(runtime) { + handled, fetchErr := runAnchoredMarkdownFetch(ctx, runtime, resolution.URL) + if handled { + return fetchErr + } + if redirectErr := diagnoseWikiType(fetchErr); redirectErr != nil { + return redirectErr + } + continuation := contentread.IsPageContinuation(strings.TrimSpace(runtime.Str("page-token"))) + if handled, err := handlePaginatedReadFailure(runtime, continuation, fetchErr); handled || err != nil { + return err + } + } apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token) body := buildFetchBody(runtime) data, err := doDocAPI(runtime, "POST", apiPath, body) if err != nil { + if redirectErr := diagnoseWikiType(err); redirectErr != nil { + return redirectErr + } return err } if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil { @@ -81,14 +159,118 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { applyFetchIMMarkdown(data, runtime.Str("doc")) } - runtime.OutFormatRaw(data, nil, func(w io.Writer) { - if doc, ok := data["document"].(map[string]interface{}); ok { - if content, ok := doc["content"].(string); ok { - fmt.Fprintln(w, content) + document, ok := data["document"].(map[string]interface{}) + if !ok { + runtime.OutFormatRaw(data, nil, nil) + return nil + } + content, ok := document["content"].(string) + if !ok { + runtime.OutFormatRaw(data, nil, nil) + return nil + } + delivery, scan, err := common.PrepareFetchContentDelivery(runtime, data, content, docsFetchContentJQPath) + if err != nil { + return err + } + emitted := cloneFetchDocumentData(data) + applyFetchContentDelivery(emitted, delivery) + runtime.OutFormatRawWithSafety(emitted, nil, func(w io.Writer) { + common.WriteFetchContentPretty(w, delivery) + }, scan) + return nil +} + +// newWikiFetchTypeGuard lazily resolves a Wiki input only after the Doc read +// has failed. Successful Doc/Docx Wiki reads therefore stay on the fast path +// without an extra get_node call. The probe result is cached so a primary read +// followed by a fallback failure never probes the same node twice. +func newWikiFetchTypeGuard(runtime *common.RuntimeContext, ref documentRef, checked bool, resolvedNode *common.WikiNode) func(error) error { + actualType := "" + actualToken := "" + if resolvedNode != nil { + actualType = strings.TrimSpace(resolvedNode.ObjType) + actualToken = strings.TrimSpace(resolvedNode.ObjToken) + } + + return func(cause error) error { + input := strings.TrimSpace(runtime.Str("doc")) + // Bare tokens are intentionally parsed as docx because their type is + // ambiguous without I/O. After a failed Doc read, treat them as possible + // Wiki node tokens and probe once; a normal docx token simply fails that + // best-effort probe and keeps its original error. + wikiCandidate := ref.Kind == "wiki" || (ref.Kind == "docx" && !strings.Contains(input, "://")) + if !wikiCandidate || !shouldDiagnoseWikiFetchType(cause) { + return nil + } + if !checked { + checked = true + node, err := common.ResolveWikiNode(runtime, ref.Token) + if err != nil { + // Type enrichment is best effort. Permission, transport, and malformed + // get_node responses must not replace the original fetch failure. + return nil //nolint:nilerr // Retain the original fetch failure when the optional Wiki probe fails. } + actualType = strings.TrimSpace(node.ObjType) + actualToken = strings.TrimSpace(node.ObjToken) } - }) - return nil + + switch strings.ToLower(actualType) { + case "", "doc", "docx": + return nil + } + + redirectErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, + "Wiki input resolves to %q, but docs +fetch only supports doc/docx content", actualType). + WithParam("--doc"). + WithHint("%s; do not retry `docs +fetch` for this Wiki resource", + wikiFetchFallbackHint(input, ref, actualType, actualToken)) + if cause != nil { + redirectErr.WithCause(cause) + } + return redirectErr + } +} + +// wikiFetchFallbackHint routes only types drive +fetch actually supports there. +// Mindnote has its own content API; unknown future types get an inspect command +// instead of a remediation that is guaranteed to fail. +func wikiFetchFallbackHint(input string, ref documentRef, actualType, actualToken string) string { + switch strings.ToLower(actualType) { + case "sheet", "sheets", "base", "bitable", "slides", "file", "minutes": + if ref.Kind == "wiki" && strings.Contains(input, "://") { + return fmt.Sprintf("run once: `lark-cli drive +fetch --url %s`", shellQuoteFetchURL(input)) + } + return fmt.Sprintf("run once: `lark-cli drive +fetch --token %s --type wiki`", shellQuoteFetchURL(ref.Token)) + case "mindnote": + return fmt.Sprintf("run: `lark-cli mindnotes nodes list --mindnote-id %s`", shellQuoteFetchURL(actualToken)) + default: + if ref.Kind == "wiki" && strings.Contains(input, "://") { + return fmt.Sprintf("inspect the resource with `lark-cli drive +inspect --url %s` and use its entity-specific reader", shellQuoteFetchURL(input)) + } + return fmt.Sprintf("inspect the resource with `lark-cli drive +inspect --url %s --type wiki` and use its entity-specific reader", shellQuoteFetchURL(ref.Token)) + } +} + +// shouldDiagnoseWikiFetchType limits the extra Wiki probe to failures that can +// plausibly mean "this is not a document": an untyped failure, a malformed +// content response, or an upstream API error. Authentication, permission, +// network, and safety failures keep their original recovery guidance. +func shouldDiagnoseWikiFetchType(cause error) bool { + if cause == nil { + return false + } + if !errs.IsTyped(cause) || errs.IsAPI(cause) { + return true + } + problem, ok := errs.ProblemOf(cause) + return ok && problem.Subtype == errs.SubtypeInvalidResponse +} + +// shellQuoteFetchURL returns a POSIX-shell-safe single argument. The hint is +// intentionally executable and preserves Wiki query/fragment selectors. +func shellQuoteFetchURL(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 2b3d9fa2c3..e5c9c8d1c0 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" "github.com/spf13/cobra" ) @@ -686,7 +687,9 @@ func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) { func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) { t.Parallel() - for _, format := range []string{"markdown", "im-markdown"} { + // Markdown whole-document reads use the anchored Markdown path; cover the + // document API's detail downgrade through im-markdown. + for _, format := range []string{"im-markdown"} { for _, detail := range []string{"with-ids", "full"} { t.Run(format+"/"+detail, func(t *testing.T) { t.Parallel() @@ -721,26 +724,22 @@ func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) { func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning")) + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning")) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch", + URL: contentread.Path, Body: map[string]interface{}{ "code": 0, "msg": "ok", "data": map[string]interface{}{ - "document": map[string]interface{}{ - "document_id": "doxcnFetchWarning", - "revision_id": float64(1), - "content": "# hello", - }, + "full_content": "

hello

", }, }, }) err := mountAndRunDocs(t, DocsFetch, []string{ "+fetch", - "--doc", "doxcnFetchWarning", + "--doc", "https://example.feishu.cn/docx/doxcnFetchWarning", "--doc-format", "markdown", "--detail", "with-ids", "--as", "bot", @@ -756,11 +755,14 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) { data, _ := envelope["data"].(map[string]interface{}) warnings, _ := data["warnings"].([]interface{}) if len(warnings) != 1 { - t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"]) + t.Fatalf("warnings = %#v, want one detail warning", data["warnings"]) } if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") { t.Fatalf("unexpected warning: %q", got) } + if strings.Contains(stderr.String(), "expected next_page_token") { + t.Fatalf("unexpected pagination warning: %q", stderr.String()) + } } func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) { @@ -769,23 +771,19 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) { f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-pretty-warning")) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/docs_ai/v1/documents/doxcnFetchPrettyWarning/fetch", + URL: contentread.Path, Body: map[string]interface{}{ "code": 0, "msg": "ok", "data": map[string]interface{}{ - "document": map[string]interface{}{ - "document_id": "doxcnFetchPrettyWarning", - "revision_id": float64(1), - "content": "# hello", - }, + "full_content": "

hello

", }, }, }) err := mountAndRunDocs(t, DocsFetch, []string{ "+fetch", - "--doc", "doxcnFetchPrettyWarning", + "--doc", "https://example.feishu.cn/docx/doxcnFetchPrettyWarning", "--doc-format", "markdown", "--detail", "full", "--format", "pretty", @@ -795,7 +793,7 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if got := stdout.String(); got != "# hello\n" { + if got := stdout.String(); got != "# hello\n\n" { t.Fatalf("stdout = %q, want markdown content only", got) } if got := stderr.String(); !strings.Contains(got, "warning: --detail full is only supported with --doc-format xml") || @@ -1021,6 +1019,9 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "") cmd.Flags().String("offset", "", "") cmd.Flags().String("limit", "", "") + cmd.Flags().Bool("full", false, "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().Int("page-size", 0, "") if apiVersion != "" { if err := cmd.Flags().Set("api-version", apiVersion); err != nil { t.Fatalf("set api-version: %v", err) @@ -1056,3 +1057,44 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd.Flags().String("src-block-ids", "", "") return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil) } + +func TestAnchoredMarkdownRevisionAndLangUseDocumentAPI(t *testing.T) { + t.Parallel() + cases := []struct { + name string + flags map[string]string + useAnchoredMarkdown bool + }{ + {"markdown full uses anchored Markdown", map[string]string{"doc-format": "markdown"}, true}, // scope defaults to full + {"historical revision uses document API", map[string]string{"doc-format": "markdown", "revision-id": "42"}, false}, + {"explicit lang uses document API", map[string]string{"doc-format": "markdown", "lang": "ja-JP"}, false}, + {"xml uses document API", map[string]string{"doc-format": "xml"}, false}, + {"partial scope uses document API", map[string]string{"doc-format": "markdown", "scope": "outline"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rt := newFetchShortcutTestRuntime(t, "", tc.flags) + got := useAnchoredMarkdownRead(rt) + if got != tc.useAnchoredMarkdown { + t.Fatalf("useAnchoredMarkdownRead()=%v, want %v (flags=%v)", got, tc.useAnchoredMarkdown, tc.flags) + } + }) + } +} + +func TestValidatePaginatedReadFlagsRevisionConflict(t *testing.T) { + t.Parallel() + rt := newFetchShortcutTestRuntime(t, "", map[string]string{ + "doc-format": "markdown", + "revision-id": "42", + "full": "true", + }) + err := validatePaginatedReadFlags(rt) + if err == nil { + t.Fatal("expected conflict error for --full + historical --revision-id") + } + if !strings.Contains(err.Error(), "revision-id") { + t.Fatalf("error should blame --revision-id, got: %v", err) + } +} diff --git a/shortcuts/doc/docs_fetch_v2_wiki_test.go b/shortcuts/doc/docs_fetch_v2_wiki_test.go new file mode 100644 index 0000000000..9cb1664814 --- /dev/null +++ b/shortcuts/doc/docs_fetch_v2_wiki_test.go @@ -0,0 +1,448 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common/contentread" +) + +func TestPageContinuationFailedPreservesTypedError(t *testing.T) { + transportCause := errors.New("transport cause") + upstream := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope"). + WithMissingScopes("docx:document:readonly"). + WithLogID("log-1"). + WithHint("grant document access"). + WithCause(transportCause) + cause := fmt.Errorf("fetch: %w", upstream) + + got := pageContinuationFailed(cause) + problem, ok := errs.ProblemOf(got) + if !ok || problem.Subtype != errs.SubtypeMissingScope || problem.LogID != "log-1" { + t.Fatalf("problem = %#v, want original permission metadata", problem) + } + var permissionErr *errs.PermissionError + if !errors.As(got, &permissionErr) || len(permissionErr.MissingScopes) != 1 || + permissionErr.MissingScopes[0] != "docx:document:readonly" || !errors.Is(got, transportCause) { + t.Fatalf("error lost missing scopes or cause: %#v", got) + } + if !strings.Contains(problem.Hint, "grant document access") || !strings.Contains(problem.Hint, "without --page-token") { + t.Fatalf("error lost cause or recovery hint: %#v", got) + } +} + +func TestDocsFetchWikiNativeFailureRedirectsToDrive(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const wikiURL = "https://example.feishu.cn/wiki/wikcnSheet?sheet=shtDetail#section" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-wiki-native-redirect")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/wikcnSheet/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "document fetch failed", + }, + }) + reg.Register(wikiNodeStub("sheet", "shtBacking")) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", wikiURL, + "--scope", "keyword", + "--keyword", "owner", + "--as", "bot", + }, f, stdout) + assertWikiFetchDriveRedirect(t, err, "sheet", wikiURL) + + var apiErr *errs.APIError + if !errors.As(errors.Unwrap(err), &apiErr) { + t.Fatalf("wrapped cause = %T, want original *errs.APIError", errors.Unwrap(err)) + } +} + +func TestDocsFetchBareWikiTokenFailureRedirectsToDrive(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const wikiToken = "wikcnBareSheet" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-bare-wiki-redirect")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/" + wikiToken + "/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "document fetch failed", + }, + }) + reg.Register(wikiNodeStub("sheet", "shtBacking")) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", wikiToken, + "--as", "bot", + }, f, stdout) + assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--doc") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + want := "lark-cli drive +fetch --token '" + wikiToken + "' --type wiki" + if !strings.Contains(validationErr.Hint, want) { + t.Fatalf("hint %q missing executable bare-Wiki fallback %q", validationErr.Hint, want) + } +} + +func TestDocsFetchMindnoteWikiUsesMindnoteFallback(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const wikiURL = "https://example.feishu.cn/wiki/wikcnMindnote" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-mindnote-wiki")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/wikcnMindnote/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "document fetch failed", + }, + }) + reg.Register(wikiNodeStub("mindnote", "mndBacking")) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", wikiURL, + "--as", "bot", + }, f, stdout) + assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--doc") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if !strings.Contains(validationErr.Hint, "lark-cli mindnotes nodes list --mindnote-id 'mndBacking'") { + t.Fatalf("hint %q missing Mindnote reader", validationErr.Hint) + } + if strings.Contains(validationErr.Hint, "drive +fetch") { + t.Fatalf("hint %q must not route unsupported Mindnote content to drive +fetch", validationErr.Hint) + } +} + +func TestDocsFetchWikiAnchoredReadFailureRedirectsBeforeFallback(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const wikiURL = "https://example.feishu.cn/wiki/wikcnBase?table=tblDetail&view=vewMain" + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-wiki-anchored-redirect")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + }) + reg.Register(wikiNodeStub("bitable", "basBacking")) + fallbackStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/wikcnBase/fetch", + Optional: true, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{"content": "must not be read"}, + }, + }, + } + reg.Register(fallbackStub) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", wikiURL, + "--doc-format", "markdown", + "--page-token", "page-2", + "--as", "bot", + }, f, stdout) + assertWikiFetchDriveRedirect(t, err, "bitable", wikiURL) + + if got := len(fallbackStub.CapturedBodies); got != 0 { + t.Fatalf("document API fallback calls = %d, want 0 after non-Doc Wiki diagnosis", got) + } + if strings.Contains(stderr.String(), "falling back to the document API") { + t.Fatalf("stderr contains misleading fallback: %q", stderr.String()) + } +} + +func TestDocsFetchWikiAnchoredReadSuccessDoesNotResolveNode(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-wiki-anchored-fast-path")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "title": "Doc Wiki", + "full_content": "

Hello

Body

", + }, + }, + }) + wikiStub := wikiNodeStub("docx", "doxcnBacking") + wikiStub.Optional = true + reg.Register(wikiStub) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/wiki/wikcnDocx", + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(wikiStub.CapturedBodies); got != 0 { + t.Fatalf("wiki get_node calls = %d, want 0 on successful Docx Wiki fast path", got) + } +} + +func TestDocsFetchWikiTypeProbeIsCachedAcrossFallback(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-wiki-probe-cache")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + }) + wikiStub := wikiNodeStub("docx", "doxcnBacking") + wikiStub.Reusable = true + reg.Register(wikiStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/wikcnDocx/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "document API fallback failed", + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/wiki/wikcnDocx", + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("fetch succeeded, want original native API error") + } + if !errs.IsAPI(err) { + t.Fatalf("error type = %T, want original API error: %v", err, err) + } + if got := len(wikiStub.CapturedBodies); got != 1 { + t.Fatalf("wiki get_node calls = %d, want exactly 1 across primary and fallback failures", got) + } + if !strings.Contains(stderr.String(), "falling back to the document API") { + t.Fatalf("stderr missing document API fallback: %q", stderr.String()) + } +} + +func TestDocsFetchBareWikiTokenReusesResolutionForRedirect(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const wikiToken = "wikcnBareBase" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-bare-wiki-cache")) + wikiStub := wikiNodeStub("bitable", "basBacking") + wikiStub.Reusable = true + reg.Register(wikiStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", wikiToken, + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--doc") + if got := len(wikiStub.CapturedBodies); got != 1 { + t.Fatalf("wiki get_node calls = %d, want exactly 1 across URL resolution and type redirect", got) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, + "lark-cli drive +fetch --token '"+wikiToken+"' --type wiki") { + t.Fatalf("error = %#v, want executable drive +fetch redirect", err) + } +} + +func TestDocsFetchBareTokenDoesNotRepeatFailedWikiProbe(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const token = "doxcnBareToken" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-bare-token-failed-wiki-probe")) + wikiStub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Reusable: true, + Status: 404, + } + reg.Register(wikiStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/" + token + "/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "original native failure", + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", token, + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + if err == nil || !errs.IsAPI(err) { + t.Fatalf("error = %#v, want original native API failure", err) + } + if got := len(wikiStub.CapturedBodies); got != 1 { + t.Fatalf("wiki get_node calls = %d, want exactly 1 after failed URL resolution", got) + } +} + +func TestDocsFetchWikiProbeFailurePreservesOriginalError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-wiki-probe-failure")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/wikcnUnknown/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "original fetch failure", + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Status: 403, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/wiki/wikcnUnknown", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("fetch succeeded, want original API error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error type = %T, want original *errs.APIError: %v", err, err) + } + if apiErr.Code != 999999 || apiErr.Message != "original fetch failure" { + t.Fatalf("API error = code %d message %q, want original fetch failure", apiErr.Code, apiErr.Message) + } +} + +func TestShouldDiagnoseWikiFetchTypePreservesTypedInfrastructureErrors(t *testing.T) { + t.Parallel() + + if !shouldDiagnoseWikiFetchType(errors.New("read returned no block content")) { + t.Fatal("untyped content failure should trigger Wiki type diagnosis") + } + if !shouldDiagnoseWikiFetchType(errs.NewAPIError(errs.SubtypeUnknown, "document endpoint rejected the resource")) { + t.Fatal("API failure should trigger Wiki type diagnosis") + } + if !shouldDiagnoseWikiFetchType(errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid response")) { + t.Fatal("invalid content response should trigger Wiki type diagnosis") + } + for name, err := range map[string]error{ + "network": errs.NewNetworkError(errs.SubtypeNetworkTransport, "network failed"), + "permission": errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied"), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + if shouldDiagnoseWikiFetchType(err) { + t.Fatalf("%s error should keep its original classification", name) + } + }) + } +} + +func TestShellQuoteFetchURL(t *testing.T) { + t.Parallel() + + const input = "https://example.feishu.cn/wiki/wikcnX?query=a'b&literal=$HOME" + const want = `'https://example.feishu.cn/wiki/wikcnX?query=a'"'"'b&literal=$HOME'` + if got := shellQuoteFetchURL(input); got != want { + t.Fatalf("shellQuoteFetchURL() = %q, want %q", got, want) + } +} + +func wikiNodeStub(objType, objToken string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": objType, + "obj_token": objToken, + "node_token": "wikNode", + }, + }, + }, + } +} + +func assertWikiFetchDriveRedirect(t *testing.T, err error, objType, wikiURL string) { + t.Helper() + + assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--doc") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if !strings.Contains(validationErr.Message, objType) { + t.Fatalf("message %q does not include actual Wiki type %q", validationErr.Message, objType) + } + for _, want := range []string{ + "lark-cli drive +fetch --url", + shellQuoteFetchURL(wikiURL), + "do not retry `docs +fetch`", + } { + if !strings.Contains(validationErr.Hint, want) { + t.Fatalf("hint %q missing %q", validationErr.Hint, want) + } + } +} diff --git a/shortcuts/doc/docs_skill_doc_test.go b/shortcuts/doc/docs_skill_doc_test.go new file mode 100644 index 0000000000..f210ec1a6c --- /dev/null +++ b/shortcuts/doc/docs_skill_doc_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// docsFrameworkFlags are injected by the common command runner into every +// command (not declared in each command's own Flags list), so they are valid in +// skill examples even though they never appear in v2FetchFlags. +var docsFrameworkFlags = map[string]bool{ + "as": true, "json": true, "dry-run": true, "format": true, + "yes": true, "print-schema": true, "flag-name": true, +} + +func TestSkillDocFetchExampleFlagsAreRegistered(t *testing.T) { + t.Parallel() + data, err := os.ReadFile(filepath.Join("..", "..", "skills", "lark-doc", "references", "lark-doc-fetch.md")) + if err != nil { + t.Skipf("skill doc not found: %v", err) + } + registered := map[string]bool{} + for _, f := range DocsFetch.Flags { + registered[f.Name] = true + } + // Join backslash-continued lines so a multi-line command example is scanned + // as a single command. + joined := strings.ReplaceAll(string(data), "\\\n", " ") + flagRe := regexp.MustCompile(`--[a-z][a-z0-9-]*`) + var bad []string + seen := map[string]bool{} + for _, line := range strings.Split(joined, "\n") { + if !strings.Contains(line, "docs +fetch") { + continue + } + for _, m := range flagRe.FindAllString(line, -1) { + name := strings.TrimPrefix(m, "--") + if registered[name] || docsFrameworkFlags[name] || seen[name] { + continue + } + seen[name] = true + bad = append(bad, name) + } + } + if len(bad) > 0 { + t.Errorf("lark-doc-fetch.md uses flags not registered on docs +fetch: %s\n"+ + "delete a removed flag from the skill doc, or if it is a new framework "+ + "flag add it to docsFrameworkFlags", strings.Join(bad, ", ")) + } +} diff --git a/shortcuts/drive/drive_fetch.go b/shortcuts/drive/drive_fetch.go new file mode 100644 index 0000000000..d20d9a2aac --- /dev/null +++ b/shortcuts/drive/drive_fetch.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" +) + +// DriveFetch reads supported Lark resources as Markdown with a unified output +// envelope. It detects the URL type, unwraps Wiki links, and dispatches to the +// document, content-read, or Minutes API as appropriate. +var DriveFetch = common.Shortcut{ + Service: "drive", + Command: "+fetch", + Description: "Fetch any Lark doc/sheet/base/slides/file/minutes as a readable markdown snapshot (auto-detects type; unwraps wiki)", + Risk: "read", + Scopes: []string{}, + // Each resource type checks scopes at dispatch time. Conditional scopes expose + // complete auth metadata without preflighting unrelated resource types. + ConditionalUserScopes: []string{ + "docx:document:readonly", + "wiki:node:retrieve", + "minutes:minutes.basic:read", + "minutes:minutes.artifacts:read", + "vc:note:read", + }, + ConditionalBotScopes: []string{ + "docx:document:readonly", + "wiki:node:retrieve", + }, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "Lark/Feishu resource URL (docx, doc, sheet, base, wiki, slides, file, minutes)"}, + {Name: "token", Desc: "bare resource token (requires --type)"}, + {Name: "type", Enum: []string{"doc", "docx", "sheet", "sheets", "base", "bitable", "slides", "file", "minutes", "wiki"}, Desc: "resource type (required with --token; auto-detected for --url)"}, + {Name: "embed-max-rows", Type: "int", Default: "50", Desc: "cap each rendered table to N data rows (0 = no limit)"}, + {Name: "full", Type: "bool", Default: "false", Desc: "return the whole resource content in one response (disable auto-pagination; not for minutes)"}, + {Name: "page-token", Desc: "continue a paginated read from a prior next_page_token (not for minutes)"}, + {Name: "page-size", Type: "int", Default: "0", Desc: "per-page token budget hint (0 = server default; not for minutes)"}, + {Name: "include", Desc: "minutes only: comma-separated extras to append: transcript, note-doc"}, + }, + Tips: []string{ + "Unified read entry: pass any Lark doc/sheet/base/slides/file/minutes URL (or --token --type) and get a readable markdown snapshot.", + "For doc deep-read with --scope/--detail use `docs +fetch`; for structured sheet/base data use `sheets +cells-get` / `base +record-list`.", + "Wiki links are unwrapped to the underlying resource and read directly; the originating wiki node is recorded in resource.source.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFetch(ctx, runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return PlanFetchDryRun(ctx, runtime) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return RunFetch(ctx, runtime) + }, +} + +// RunFetch resolves input, unwraps Wiki, dispatches by type, and emits output. +func RunFetch(ctx context.Context, runtime *common.RuntimeContext) error { + in, err := resolveDriveFetchInput(runtime) + if err != nil { + return err + } + brand := runtime.Config.Brand + + // Unwrap wiki → underlying obj_type/obj_token (and record wiki provenance). + fetchType := in.inputType + fetchToken := in.token + var wikiSrc *fetchSource + if in.inputType == "wiki" { + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(fetchToken)) + node, werr := common.ResolveWikiNode(runtime, fetchToken) + if werr != nil { + // get_node failed (e.g. the user identity lacks wiki:node:retrieve + // scope, or the node is not found). The fetch service reads the wiki + // URL directly server-side (it unwraps the node itself) and paginates + // by wiki URL + page_token — independent of get_node's obj_type — so + // fall back to direct fetch and honor any --page-token/--full the + // caller passed (fetchWikiDirect surfaces has_more/next_page_token). + fmt.Fprintf(runtime.IO().ErrOut, + "[fetch] wiki get_node failed (%v); falling back to direct fetch of the wiki URL\n", werr) + out, ferr := fetchWikiDirect(ctx, runtime, in) + if ferr != nil { + return ferr + } + res := fetchResource{ + Type: "wiki", + Title: out.title, + Token: in.token, + URL: in.rawURL, + Selector: in.selector, + UpdateTime: out.updateTime, + Source: &fetchSource{Type: "wiki", InputURL: in.rawURL}, + } + return emitDriveFetch(runtime, out, res) + } + objType, ok := normalizeFetchType(node.ObjType) + if !ok { + return common.ValidationErrorf("wiki node resolved to %q, which is not a fetchable resource type", node.ObjType).WithParam("--url") + } + fetchType = objType + fetchToken = node.ObjToken + wikiSrc = &fetchSource{Type: "wiki", InputURL: in.rawURL, NodeToken: node.NodeToken, SpaceID: node.SpaceID} + if err := validateFetchTypeFlags(runtime, fetchType); err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Wiki unwrapped to %s: %s\n", fetchType, common.MaskToken(fetchToken)) + } + + out, err := dispatchDriveFetch(ctx, runtime, in, fetchType, fetchToken, wikiSrc != nil) + if err != nil { + return err + } + + res := fetchResource{ + Type: fetchType, + Title: out.title, + Token: fetchToken, + Selector: in.selector, + UpdateTime: out.updateTime, + CreateTime: out.createTime, + NoteID: out.noteID, + NoteDocToken: out.noteDocToken, + VerbatimDocToken: out.verbatimDocToken, + Source: wikiSrc, + } + res.URL = fetchResourceURL(brand, in, fetchType, fetchToken, wikiSrc != nil) + return emitDriveFetch(runtime, out, res) +} + +func emitDriveFetch(runtime *common.RuntimeContext, out *driveFetchOutput, res fetchResource) error { + warnings := append([]string(nil), out.warnings...) + cursorHint := contentread.PaginationCursorHint(out.hasMore, out.nextToken) + if cursorHint != "" { + warnings = append(warnings, cursorHint) + } + env := newFetchEnvelope(out.content, res). + withPagination(out.hasMore, out.nextToken). + withWarnings(warnings...) + delivery, scan, err := common.PrepareFetchContentDelivery(runtime, env, out.content, ".data.content") + if err != nil { + return err + } + emitted := env.withContentDelivery(delivery) + if cursorHint != "" && runtime.Format != "pretty" { + fmt.Fprintf(runtime.IO().ErrOut, "[fetch] warning: %s\n", cursorHint) + } + runtime.OutFormatRawWithSafety(emitted, nil, func(w io.Writer) { + writeDriveFetchPretty(w, delivery, emitted.Resource, emitted.Warnings) + }, scan) + return nil +} + +func writeDriveFetchPretty(w io.Writer, delivery common.FetchContentDelivery, resource fetchResource, warnings []string) { + common.WriteFetchContentPretty(w, delivery) + if resource.NoteID != "" || resource.NoteDocToken != "" || resource.VerbatimDocToken != "" { + fmt.Fprintln(w, "\nRelated note:") + if resource.NoteID != "" { + fmt.Fprintf(w, " note_id: %s\n", resource.NoteID) + } + if resource.NoteDocToken != "" { + fmt.Fprintf(w, " note_doc_token: %s\n", resource.NoteDocToken) + } + if resource.VerbatimDocToken != "" { + fmt.Fprintf(w, " verbatim_doc_token: %s\n", resource.VerbatimDocToken) + } + } + if len(warnings) > 0 { + fmt.Fprintln(w, "\nWarnings:") + for _, warning := range warnings { + fmt.Fprintf(w, "- %s\n", warning) + } + } +} + +type driveFetchOutput struct { + content string + title string + updateTime int64 + createTime string // minutes only + noteID string // --include note-doc only + noteDocToken string // --include note-doc only + verbatimDocToken string // --include note-doc only + hasMore bool + nextToken string + warnings []string +} diff --git a/shortcuts/drive/drive_fetch_dispatch.go b/shortcuts/drive/drive_fetch_dispatch.go new file mode 100644 index 0000000000..1f92c5cb2d --- /dev/null +++ b/shortcuts/drive/drive_fetch_dispatch.go @@ -0,0 +1,250 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" + "github.com/larksuite/cli/shortcuts/doc" + "github.com/larksuite/cli/shortcuts/minutes" +) + +// dispatchDriveFetch routes a resolved resource type to its reader. +func dispatchDriveFetch(ctx context.Context, runtime *common.RuntimeContext, in driveFetchInput, fetchType, fetchToken string, isWiki bool) (*driveFetchOutput, error) { + forwardURL := fetchResourceURL(runtime.Config.Brand, in, fetchType, fetchToken, isWiki) + maxRows := runtime.Int("embed-max-rows") + + switch fetchType { + case "doc", "docx": + if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil { + return nil, err + } + opts := contentread.FetchOptions{ + MaxRows: maxRows, + Full: runtime.Bool("full"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + PageSize: runtime.Int("page-size"), + } + result, ferr := contentread.FetchAnchoredMarkdown(ctx, runtime, forwardURL, opts) + if ferr != nil { + // A --page-token continuation must not fall back because the document + // API cannot honor a cursor. + if continuationErr := pageContinuationError(runtime, ferr); continuationErr != nil { + return nil, continuationErr + } + content, nerr := doc.FetchDocumentMarkdown(runtime, fetchToken) + if nerr != nil { + return nil, withFetchErrorContext(nerr, + "doc fetch unavailable", + "the paginated Markdown read and document API fallback both failed; check read access for this document") + } + return &driveFetchOutput{ + content: content, + }, nil + } + return &driveFetchOutput{ + content: result.Content, + title: result.Title, + updateTime: result.UpdateTime, + hasMore: result.HasMore, + nextToken: result.NextPageToken, + }, nil + + case "sheet", "bitable", "slides", "file": + // The fetch OpenAPI authorizes every entity type under docx:document:readonly + // (the content-read service's permission model), so the non-document paths + // ensure the same scope. + if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil { + return nil, err + } + opts := contentread.FetchOptions{ + MaxRows: maxRows, + Full: runtime.Bool("full"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + PageSize: runtime.Int("page-size"), + } + res, ferr := contentread.FetchMarkdown(ctx, runtime, forwardURL, fetchType, opts) + if ferr != nil { + if continuationErr := pageContinuationError(runtime, ferr); continuationErr != nil { + return nil, continuationErr + } + return nil, driveFetchUnavailable(fetchType, ferr) + } + return &driveFetchOutput{ + content: res.Content, + title: res.Title, + updateTime: res.UpdateTime, + hasMore: res.HasMore, + nextToken: res.NextPageToken, + }, nil + + case "minutes": + include, _ := minutes.ParseIncludes(runtime.Str("include")) // validated + if err := ensureMinutesScopes(runtime); err != nil { + return nil, err + } + result, merr := minutes.FetchMinutesMarkdown(ctx, runtime, fetchToken, include) + if merr != nil { + return nil, withFetchErrorContext(merr, + "minutes fetch unavailable", + "check the minutes:minutes.basic:read and minutes:minutes.artifacts:read scopes, or use `vc +notes` for the meeting-centric path") + } + return &driveFetchOutput{ + content: result.Content, + title: result.Title, + createTime: result.CreateTime, + noteID: result.NoteID, + noteDocToken: result.NoteDocToken, + verbatimDocToken: result.VerbatimDocToken, + warnings: result.Warnings, + }, nil + } + + return nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported fetch type %q", fetchType) +} + +// ensureMinutesScopes checks only the core metadata and artifact scopes. +// note-doc is optional and performs its own degradable vc:note:read check. +func ensureMinutesScopes(runtime *common.RuntimeContext) error { + return runtime.EnsureScopes([]string{ + "minutes:minutes.basic:read", + "minutes:minutes.artifacts:read", + }) +} + +// fetchResourceURL preserves input URLs and rebuilds URLs for tokens or +// Wiki-unwrapped resources, retaining table and view selectors. +func fetchResourceURL(brand core.LarkBrand, in driveFetchInput, fetchType, fetchToken string, isWiki bool) string { + if isWiki { + return appendQuery(common.BuildResourceURL(brand, fetchType, fetchToken), in.query) + } + if in.isBareToken { + return common.BuildResourceURL(brand, in.inputType, in.token) + } + return in.rawURL +} + +func appendQuery(base, query string) string { + query = strings.TrimSpace(query) + if query == "" { + return base + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + query +} + +// withFetchErrorContext preserves typed metadata and cause while adding path +// context and recovery guidance. Unexpected untyped failures become server +// errors with the original error retained as their cause. +func withFetchErrorContext(err error, label, hint string) error { + if problem, ok := errs.ProblemOf(err); ok && problem != nil { + problem.Message = fmt.Sprintf("%s: %s", label, problem.Message) + if problem.Hint == "" { + problem.Hint = hint + } else if !strings.Contains(problem.Hint, hint) { + problem.Hint += "; " + hint + } + return err + } + return errs.NewAPIError(errs.SubtypeServerError, "%s: %v", label, err). + WithHint(hint). + WithCause(err) +} + +func pageContinuationError(runtime *common.RuntimeContext, cause error) error { + if !contentread.IsPageContinuation(runtime.Str("page-token")) { + return nil + } + return withFetchErrorContext(cause, + "could not read this page", + "the cursor may have expired because the resource changed; re-run without --page-token to read from the start") +} + +// driveFetchUnavailable avoids suggesting a structured reader for access +// denials because it would run as the same identity and fail the same way. +func driveFetchUnavailable(fetchType string, cause error) error { + if fetchAccessDenied(cause) { + return withFetchErrorContext(cause, + fetchType+" not readable by this user", + "confirm you have read access to this resource, or ask its owner to share it (a structured command runs as the same user and will not bypass the denial)") + } + hint := map[string]string{ + "sheet": "use `sheets +cells-get` or `sheets +workbook-info` for structured data", + "bitable": "use `base +record-list` for structured records", + "slides": "slide content is read via fetch only — retry later, or open the deck in Lark/Feishu", + "file": "to download the raw file bytes use `drive +download --file-token `", + }[fetchType] + if hint == "" { + hint = "retry later, or open the resource in Lark/Feishu" + } + return withFetchErrorContext(cause, + "fetch unavailable for "+fetchType, + hint) +} + +// fetchAccessDenied recognizes typed, status-code, and legacy message forms. +func fetchAccessDenied(err error) bool { + if errs.IsPermission(err) { + return true + } + p, ok := errs.ProblemOf(err) + if !ok || p == nil { + return false + } + switch p.Subtype { + case errs.SubtypePermissionDenied, errs.SubtypeMissingScope, errs.SubtypeUserUnauthorized: + return true + } + if p.Code == 102 || p.Code == 401 || p.Code == 403 { + return true + } + msg := strings.ToLower(p.Message) + return strings.Contains(msg, "not authorized") || + strings.Contains(msg, "no permission") || + strings.Contains(msg, "permission denied") || + strings.Contains(msg, "forbidden") +} + +// fetchWikiDirect lets content-read unwrap a Wiki URL when get_node is unavailable. +func fetchWikiDirect(ctx context.Context, runtime *common.RuntimeContext, in driveFetchInput) (*driveFetchOutput, error) { + if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil { + return nil, err + } + maxRows := runtime.Int("embed-max-rows") + // A bare wiki token (--type wiki --token X) has no rawURL; rebuild /wiki/ + // so the fetch service gets a real URL to unwrap server-side. + wikiURL := in.rawURL + if wikiURL == "" { + wikiURL = common.BuildResourceURL(runtime.Config.Brand, "wiki", in.token) + } + opts := contentread.FetchOptions{ + MaxRows: maxRows, + Full: runtime.Bool("full"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + PageSize: runtime.Int("page-size"), + } + res, ferr := contentread.FetchMarkdown(ctx, runtime, wikiURL, "wiki", opts) + if ferr != nil { + if continuationErr := pageContinuationError(runtime, ferr); continuationErr != nil { + return nil, continuationErr + } + return nil, driveFetchUnavailable("wiki", ferr) + } + return &driveFetchOutput{ + content: res.Content, + title: res.Title, + updateTime: res.UpdateTime, + hasMore: res.HasMore, + nextToken: res.NextPageToken, + }, nil +} diff --git a/shortcuts/drive/drive_fetch_envelope.go b/shortcuts/drive/drive_fetch_envelope.go new file mode 100644 index 0000000000..8575467a88 --- /dev/null +++ b/shortcuts/drive/drive_fetch_envelope.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// fetchResource describes the resolved resource drive +fetch read from. Type is +// the canonical entity (doc, docx, sheet, bitable, slides, file, minutes, ...); +// Selector carries ?sheet=/?table= sub-resource selectors when present. Source +// records wiki provenance and is set only when the input was a wiki URL. +type fetchResource struct { + Type string `json:"type"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Token string `json:"token,omitempty"` + Selector map[string]string `json:"selector,omitempty"` + UpdateTime int64 `json:"update_time,omitempty"` // omitempty: minutes carry none + CreateTime string `json:"create_time,omitempty"` // omitempty: minutes only + NoteID string `json:"note_id,omitempty"` + NoteDocToken string `json:"note_doc_token,omitempty"` + VerbatimDocToken string `json:"verbatim_doc_token,omitempty"` + Source *fetchSource `json:"source,omitempty"` // wiki provenance, nil unless input was a wiki URL +} + +// fetchSource records that the input was a wiki node and how it unwrapped to the +// underlying resource. Emitted under resource.source so a caller can trace a +// read back to the wiki node it started from. When the get_node unwrap fails and +// the doc is read via direct fetch of the wiki URL, resource.type stays "wiki" +// (no unwrap happened) and source carries only InputURL (NodeToken/SpaceID absent). +type fetchSource struct { + Type string `json:"type"` // "wiki" + InputURL string `json:"input_url"` + NodeToken string `json:"node_token,omitempty"` + SpaceID string `json:"space_id,omitempty"` +} + +// fetchEnvelope is the unified drive +fetch output: inline Markdown or a local +// file descriptor, plus resource metadata for citations and follow-up reads. +// Internal routing details are intentionally omitted; resource.source records +// Wiki provenance only. +type fetchEnvelope struct { + ContentDeliveryHint string `json:"content_delivery_hint,omitempty"` + ContentInline *bool `json:"content_inline,omitempty"` + Content *string `json:"content,omitempty"` + ContentFile *common.FetchContentFile `json:"content_file,omitempty"` + ContentPreview string `json:"content_preview,omitempty"` + Resource fetchResource `json:"resource"` + Warnings []string `json:"warnings,omitempty"` + HasMore bool `json:"has_more,omitempty"` + NextPageToken string `json:"next_page_token,omitempty"` +} + +// newFetchEnvelope starts a drive +fetch envelope; chain withPagination / +// withWarnings for the optional pagination cursor and warnings. +func newFetchEnvelope(content string, res fetchResource) *fetchEnvelope { + return &fetchEnvelope{Content: &content, Resource: res} +} + +// withContentDelivery returns a copy so the pre-scanned envelope remains +// immutable if a content-safety provider finishes after its timeout. +func (e fetchEnvelope) withContentDelivery(delivery common.FetchContentDelivery) *fetchEnvelope { + if delivery.Inline() { + if delivery.InlineHint != "" { + inline := true + e.ContentDeliveryHint = delivery.InlineHint + e.ContentInline = &inline + } + return &e + } + inline := false + e.Content = nil + e.ContentInline = &inline + e.ContentFile = delivery.File + e.ContentPreview = delivery.Preview + return &e +} + +// withPagination records a pagination cursor so a --format json +// consumer sees has_more / next_page_token alongside the content. No-op when +// hasMore is false. +func (e *fetchEnvelope) withPagination(hasMore bool, nextToken string) *fetchEnvelope { + e.HasMore = hasMore + e.NextPageToken = strings.TrimSpace(nextToken) + return e +} + +// withWarnings appends non-empty warnings to the envelope. +func (e *fetchEnvelope) withWarnings(warnings ...string) *fetchEnvelope { + for _, w := range warnings { + if w = strings.TrimSpace(w); w != "" { + e.Warnings = append(e.Warnings, w) + } + } + return e +} diff --git a/shortcuts/drive/drive_fetch_input.go b/shortcuts/drive/drive_fetch_input.go new file mode 100644 index 0000000000..f6a0b680a1 --- /dev/null +++ b/shortcuts/drive/drive_fetch_input.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "net/url" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// driveFetchInput is the parsed --url / --token+--type input. inputType is the +// normalized dispatch type (doc/docx/sheet/bitable/slides/file/minutes) for non-wiki +// input, or "wiki" when the input is a wiki node (unwrapped at execute time). +type driveFetchInput struct { + rawURL string // original URL input ("" for bare token) + inputType string // normalized dispatch type, or "wiki" + token string // the resource/node token + selector map[string]string // curated ?sheet=/?table= for the envelope + query string // full RawQuery, reattached to rebuilt URLs + isBareToken bool +} + +// resolveDriveFetchInput parses --url / --token+--type into a driveFetchInput. +// A URL is auto-detected (--type, if given, must match); a bare token requires +// --type. doc/docx remain distinct; sheets→sheet, base→bitable. +// Wiki URLs (and --type wiki) stay "wiki" — the underlying type is resolved at +// execute via ResolveWikiNode. +func resolveDriveFetchInput(runtime *common.RuntimeContext) (driveFetchInput, error) { + rawURL := strings.TrimSpace(runtime.Str("url")) + tokenFlag := strings.TrimSpace(runtime.Str("token")) + inputType := strings.ToLower(strings.TrimSpace(runtime.Str("type"))) + + if rawURL != "" && tokenFlag != "" { + return driveFetchInput{}, common.ValidationErrorf("pass either --url or --token, not both").WithParam("--url") + } + if rawURL == "" && tokenFlag == "" { + return driveFetchInput{}, common.ValidationErrorf("one of --url or --token is required").WithParam("--url") + } + + if rawURL != "" { + u, perr := url.Parse(rawURL) + if perr != nil || u.Path == "" { + return driveFetchInput{}, common.ValidationErrorf("--url %q is not a recognized Lark resource URL (docx, doc, sheet, base, wiki, slides, file, minutes)", rawURL).WithParam("--url") + } + // minutes URLs (https://meetings.feishu.cn/minutes/) are not in + // ParseResourceURL's table (adding them there would change drive +inspect's + // rejection of minutes links); detect them here so --url accepts a minutes link. + var urlType, token string + if t, ok := minutesURLTokenFromURL(u); ok { + urlType, token = "minutes", t + } else { + ref, ok := common.ParseResourceURL(rawURL) + if !ok { + return driveFetchInput{}, common.ValidationErrorf("--url %q is not a recognized Lark resource URL (docx, doc, sheet, base, wiki, slides, file, minutes)", rawURL).WithParam("--url") + } + nt, ok := normalizeFetchType(ref.Type) + if !ok { + return driveFetchInput{}, common.ValidationErrorf("--url %q is not a fetchable resource type (got %q)", rawURL, ref.Type).WithParam("--url") + } + urlType, token = nt, ref.Token + } + if inputType != "" { + declared, ok := normalizeFetchType(inputType) + if !ok { + return driveFetchInput{}, common.ValidationErrorf("--type %q is not a recognized fetch type", inputType).WithParam("--type") + } + if declared != urlType { + return driveFetchInput{}, common.ValidationErrorf("--type %q conflicts with URL type %q; remove --type or use a matching value", inputType, urlType).WithParam("--type") + } + } + return driveFetchInput{ + rawURL: rawURL, + inputType: urlType, + token: token, + selector: captureSelector(u), + query: captureQuery(u), + }, nil + } + + // bare token + if inputType == "" { + return driveFetchInput{}, common.ValidationErrorf("--type is required with --token (allowed: doc, docx, sheet, base, bitable, slides, file, minutes, wiki)").WithParam("--type") + } + normalized, ok := normalizeFetchType(inputType) + if !ok { + return driveFetchInput{}, common.ValidationErrorf("--type %q is not a recognized fetch type (allowed: doc, docx, sheets, base, bitable, slides, file, minutes, wiki)", inputType).WithParam("--type") + } + if strings.ContainsAny(tokenFlag, "/?#") { + return driveFetchInput{}, common.ValidationErrorf("--token %q must be a bare token (no path/query/fragment)", tokenFlag).WithParam("--token") + } + return driveFetchInput{ + inputType: normalized, + token: tokenFlag, + isBareToken: true, + }, nil +} + +// normalizeFetchType canonicalizes aliases while preserving distinct resource +// types. sheets → "sheet"; base/bitable → "bitable". Returns ok=false for +// non-fetchable types (mindnote, folder, unknown). +func normalizeFetchType(t string) (string, bool) { + switch t { + case "doc", "docx": + return t, true + case "sheet", "sheets": + return "sheet", true + case "base", "bitable": + return "bitable", true + case "slides": + return "slides", true + case "file": + return "file", true + case "minutes": + return "minutes", true + case "wiki": + return "wiki", true + } + return "", false +} + +// captureSelector extracts the curated ?sheet=/?table=/?view= sub-resource +// selectors for the envelope's resource.selector. Returns nil when none are +// present. +func captureSelector(u *url.URL) map[string]string { + if u == nil { + return nil + } + q := u.Query() + sel := map[string]string{} + for _, k := range []string{"sheet", "table", "view"} { + if v := strings.TrimSpace(q.Get(k)); v != "" { + sel[k] = v + } + } + if len(sel) == 0 { + return nil + } + return sel +} + +// captureQuery returns the URL's full RawQuery (to reattach to rebuilt URLs so +// the fetch service sees the same selectors it would from a verbatim URL). +func captureQuery(u *url.URL) string { + if u == nil { + return "" + } + return strings.TrimSpace(u.RawQuery) +} + +// minutesURLTokenFromURL extracts the minute token from a +// https://meetings.feishu.cn/minutes/ URL. Minutes URLs are not in +// ParseResourceURL's table (adding them there would change drive +inspect's +// rejection of minutes links), so drive +fetch detects them here to accept a +// minutes link via --url. Returns ok=false when the path is not /minutes/. +func minutesURLTokenFromURL(u *url.URL) (string, bool) { + if u == nil || !strings.HasPrefix(u.Path, "/minutes/") { + return "", false + } + rest := strings.TrimRight(u.Path[len("/minutes/"):], "/") + if i := strings.IndexByte(rest, '/'); i >= 0 { + rest = rest[:i] + } + rest = strings.TrimSpace(rest) + if rest == "" { + return "", false + } + return rest, true +} diff --git a/shortcuts/drive/drive_fetch_spill_test.go b/shortcuts/drive/drive_fetch_spill_test.go new file mode 100644 index 0000000000..6c3783abc9 --- /dev/null +++ b/shortcuts/drive/drive_fetch_spill_test.go @@ -0,0 +1,223 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" +) + +const driveFetchFallbackHint = "Content remains inline because temporary-file delivery failed and may be truncated. If incomplete, rerun locally with --full --jq '.data.content' and redirect stdout to a new file; use --page-token only when shell redirection is unavailable." + +func TestEmitDriveFetchFullOversizeSpillsJSON(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + tempDir := t.TempDir() + t.Setenv("TMPDIR", tempDir) + content := strings.Repeat("large drive content\n", 1600) + runtime, stdout, stderr := newDriveSpillRuntime(t, "", true) + + err := emitDriveFetch(runtime, &driveFetchOutput{ + content: content, + warnings: []string{"kept warning"}, + }, fetchResource{Type: "file", Token: "boxcnSpill"}) + if err != nil { + t.Fatalf("emitDriveFetch() error = %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, stdout.String()) + } + data := envelope["data"].(map[string]interface{}) + if _, ok := data["content"]; ok { + t.Fatalf("spilled envelope retained inline content") + } + if inline, ok := data["content_inline"].(bool); !ok || inline { + t.Fatalf("content_inline = %#v, want false", data["content_inline"]) + } + file := data["content_file"].(map[string]interface{}) + path := file["path"].(string) + t.Cleanup(func() { _ = os.Remove(path) }) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read spill file: %v", err) + } + if string(got) != content { + t.Fatal("spill file does not contain the exact fetched content") + } + if file["temporary"] != true || int(file["size_bytes"].(float64)) != len(content) { + t.Fatalf("content_file = %#v", file) + } + if hint, _ := file["hint"].(string); !strings.Contains(hint, "Oversized content was saved to temporary file:") || + !strings.Contains(hint, "Consider reading or searching this file locally") { + t.Fatalf("content_file.hint = %q", hint) + } + warnings := data["warnings"].([]interface{}) + if len(warnings) != 1 || warnings[0] != "kept warning" { + t.Fatalf("warnings lost: %#v", warnings) + } + if stderr.Len() != 0 { + t.Fatalf("successful JSON spill wrote stderr: %q", stderr.String()) + } +} + +func TestEmitDriveFetchFullOversizeSpillsPretty(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + t.Setenv("TMPDIR", t.TempDir()) + content := strings.Repeat("do not print this whole body\n", 1200) + runtime, stdout, stderr := newDriveSpillRuntime(t, "pretty", true) + + if err := emitDriveFetch(runtime, &driveFetchOutput{content: content}, fetchResource{Type: "file"}); err != nil { + t.Fatalf("emitDriveFetch() error = %v", err) + } + got := stdout.String() + if !strings.Contains(got, "Content saved to:") || !strings.Contains(got, "Preview:") { + t.Fatalf("pretty output missing spill pointer: %q", got) + } + if !strings.Contains(got, "Hint: Oversized content was saved to temporary file:") || + !strings.Contains(got, "Consider reading or searching this file locally") { + t.Fatalf("pretty output missing structured hint: %q", got) + } + if strings.Contains(got, content) || len(got) >= len(content) { + t.Fatalf("pretty output retained oversized body: output=%d body=%d", len(got), len(content)) + } + if stderr.Len() != 0 { + t.Fatalf("successful pretty spill wrote stderr: %q", stderr.String()) + } +} + +func TestEmitDriveFetchSmallKeepsContentAndWarnsOnMissingCursor(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + runtime, stdout, stderr := newDriveSpillRuntime(t, "", true) + if err := emitDriveFetch(runtime, &driveFetchOutput{content: "# small", hasMore: true}, fetchResource{Type: "docx"}); err != nil { + t.Fatalf("emitDriveFetch() error = %v", err) + } + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v", err) + } + data := envelope["data"].(map[string]interface{}) + if data["content"] != "# small" { + t.Fatalf("content = %#v, want legacy inline body", data["content"]) + } + warnings, _ := data["warnings"].([]interface{}) + if len(warnings) != 1 || !strings.Contains(warnings[0].(string), "expected next_page_token") { + t.Fatalf("warnings = %#v, want missing cursor hint", warnings) + } + if !strings.Contains(stderr.String(), "expected next_page_token") { + t.Fatalf("stderr missing cursor hint: %q", stderr.String()) + } + for _, key := range []string{"content_inline", "content_file", "content_preview"} { + if _, ok := data[key]; ok { + t.Fatalf("small output unexpectedly contains %s", key) + } + } +} + +func TestEmitDriveFetchShowsNoteDocumentsAndWarnings(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + resource := fetchResource{ + Type: "minutes", + NoteID: "note123", + NoteDocToken: "doc-main", + VerbatimDocToken: "doc-verbatim", + } + out := &driveFetchOutput{content: "# Meeting", warnings: []string{"optional artifact omitted"}} + + t.Run("json", func(t *testing.T) { + runtime, stdout, _ := newDriveSpillRuntime(t, "", false) + if err := emitDriveFetch(runtime, out, resource); err != nil { + t.Fatalf("emitDriveFetch() error = %v", err) + } + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v", err) + } + data := envelope["data"].(map[string]interface{}) + got := data["resource"].(map[string]interface{}) + if got["note_id"] != "note123" || got["note_doc_token"] != "doc-main" || got["verbatim_doc_token"] != "doc-verbatim" { + t.Fatalf("resource = %#v", got) + } + }) + + t.Run("pretty", func(t *testing.T) { + runtime, stdout, _ := newDriveSpillRuntime(t, "pretty", false) + if err := emitDriveFetch(runtime, out, resource); err != nil { + t.Fatalf("emitDriveFetch() error = %v", err) + } + got := stdout.String() + for _, want := range []string{"# Meeting", "Related note:", "note_id: note123", "note_doc_token: doc-main", "verbatim_doc_token: doc-verbatim", "Warnings:", "- optional artifact omitted"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + }) +} + +func TestFetchEnvelopeContentDeliveryDoesNotMutateScannedInput(t *testing.T) { + original := newFetchEnvelope("body", fetchResource{Type: "docx"}) + emitted := original.withContentDelivery(common.FetchContentDelivery{ + File: &common.FetchContentFile{Path: "/tmp/body.md"}, + Preview: "preview", + }) + + if original.Content == nil || *original.Content != "body" || original.ContentFile != nil { + t.Fatalf("original envelope was mutated: %#v", original) + } + if emitted.Content != nil || emitted.ContentFile == nil || emitted.ContentFile.Path != "/tmp/body.md" { + t.Fatalf("emitted envelope = %#v, want saved-file metadata", emitted) + } +} + +func TestFetchEnvelopeContentDeliveryAddsInlineFallbackHint(t *testing.T) { + original := newFetchEnvelope("body", fetchResource{Type: "docx"}) + emitted := original.withContentDelivery(common.FetchContentDelivery{ + Content: "body", + InlineHint: driveFetchFallbackHint, + }) + + if emitted.ContentDeliveryHint != driveFetchFallbackHint || + emitted.ContentInline == nil || !*emitted.ContentInline { + t.Fatalf("emitted envelope = %#v, want inline fallback metadata", emitted) + } + if emitted.Content == nil || *emitted.Content != "body" { + t.Fatalf("emitted envelope lost inline content: %#v", emitted) + } + if original.ContentDeliveryHint != "" || original.ContentInline != nil { + t.Fatalf("original envelope was mutated: %#v", original) + } + raw, err := json.Marshal(emitted) + if err != nil { + t.Fatalf("marshal emitted envelope: %v", err) + } + if bytes.Index(raw, []byte(`"content_delivery_hint"`)) > bytes.Index(raw, []byte(`"content":"body"`)) { + t.Fatalf("inline hint must precede the oversized body: %s", raw) + } +} + +func newDriveSpillRuntime(t *testing.T, format string, full bool) (*common.RuntimeContext, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + cfg := driveTestConfig() + f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg) + parent := &cobra.Command{Use: "drive"} + cmd := &cobra.Command{Use: "+fetch"} + parent.AddCommand(cmd) + cmd.Flags().Bool("full", false, "") + if full { + _ = cmd.Flags().Set("full", "true") + } + runtime := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, f, core.AsUser) + runtime.Format = format + return runtime, stdout, stderr +} diff --git a/shortcuts/drive/drive_fetch_test.go b/shortcuts/drive/drive_fetch_test.go new file mode 100644 index 0000000000..1f887a8ef2 --- /dev/null +++ b/shortcuts/drive/drive_fetch_test.go @@ -0,0 +1,257 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "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/larksuite/cli/shortcuts/common/contentread" + "github.com/tidwall/gjson" +) + +func newDriveFetchTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+fetch"}, cfg, f, core.AsUser) + return rt, reg +} + +func TestNormalizeFetchTypePreservesDocKinds(t *testing.T) { + t.Parallel() + for _, resourceType := range []string{"doc", "docx"} { + got, ok := normalizeFetchType(resourceType) + if !ok || got != resourceType { + t.Errorf("normalizeFetchType(%q) = %q, %v", resourceType, got, ok) + } + } +} + +func TestDriveFetchConditionalScopesMatchIdentity(t *testing.T) { + userScopes := strings.Join(DriveFetch.ConditionalScopesForIdentity("user"), " ") + for _, want := range []string{"docx:document:readonly", "wiki:node:retrieve", "minutes:minutes.basic:read", "minutes:minutes.artifacts:read", "vc:note:read"} { + if !strings.Contains(userScopes, want) { + t.Errorf("user scopes %q missing %q", userScopes, want) + } + } + for _, notNeeded := range []string{"minutes:minutes:readonly", "minutes:minutes.transcript:export"} { + if strings.Contains(userScopes, notNeeded) { + t.Errorf("user scopes %q contain scope not needed by this read path: %q", userScopes, notNeeded) + } + } + botScopes := strings.Join(DriveFetch.ConditionalScopesForIdentity("bot"), " ") + if strings.Contains(botScopes, "minutes:") || strings.Contains(botScopes, "vc:note:read") { + t.Errorf("bot scopes contain user-only Minutes scopes: %q", botScopes) + } +} + +func TestValidateFetchTypeFlagsRejectsMinutesAsBot(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + factory, _, _, _ := cmdutil.TestFactory(t, cfg) + cmd := &cobra.Command{Use: "+fetch"} + cmd.Flags().Bool("full", false, "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().Int("page-size", 0, "") + cmd.Flags().String("include", "", "") + runtime := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsBot) + + err := validateFetchTypeFlags(runtime, "minutes") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("validateFetchTypeFlags() error = %T %v, want validation error", err, err) + } + if validationErr.Param != "--as" || !strings.Contains(validationErr.Hint, "--as user") { + t.Fatalf("validation error = %#v", validationErr) + } +} + +func TestWithFetchErrorContextPreservesTypedMetadataAndCause(t *testing.T) { + cause := errors.New("transport cause") + upstream := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope"). + WithMissingScopes("docx:document:readonly"). + WithLogID("log-1"). + WithHint("grant document access"). + WithCause(cause) + + got := withFetchErrorContext(upstream, "fetch unavailable", "retry from the start") + problem, ok := errs.ProblemOf(got) + if !ok || problem.Subtype != errs.SubtypeMissingScope || problem.LogID != "log-1" { + t.Fatalf("problem = %#v", problem) + } + var permissionErr *errs.PermissionError + if !errors.As(got, &permissionErr) || len(permissionErr.MissingScopes) != 1 || !errors.Is(got, cause) { + t.Fatalf("typed metadata or cause was lost: %#v", got) + } + if !strings.Contains(problem.Hint, "grant document access") || !strings.Contains(problem.Hint, "retry from the start") { + t.Fatalf("hint = %q", problem.Hint) + } + + raw := errors.New("raw transport failure") + wrapped := withFetchErrorContext(raw, "fetch unavailable", "retry later") + problem, ok = errs.ProblemOf(wrapped) + if !ok || problem.Subtype != errs.SubtypeServerError || !errors.Is(wrapped, raw) { + t.Fatalf("untyped error was not classified with its cause: %#v", wrapped) + } + if strings.Contains(problem.Message, "retry later") || problem.Hint != "retry later" { + t.Fatalf("recovery guidance must appear only in hint: %#v", problem) + } + + rateLimit := errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable() + rateLimit.RetryAfterSeconds = 17 + got = withFetchErrorContext(rateLimit, "fetch unavailable", "retry later") + var preservedRateLimit *errs.APIError + if !errors.As(got, &preservedRateLimit) || preservedRateLimit.Subtype != errs.SubtypeRateLimit || preservedRateLimit.RetryAfterSeconds != 17 { + t.Fatalf("rate-limit metadata was lost: %#v", got) + } +} + +func TestNonDocumentContinuationFailuresRecommendRestart(t *testing.T) { + newRuntime := func(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + runtime, registry := newDriveFetchTestRuntime(t) + runtime.Cmd.Flags().String("page-token", "", "") + runtime.Cmd.Flags().Bool("full", false, "") + runtime.Cmd.Flags().Int("page-size", 0, "") + runtime.Cmd.Flags().Int("embed-max-rows", 50, "") + _ = runtime.Cmd.Flags().Set("page-token", "cursor-2") + registry.Register(&httpmock.Stub{Method: "POST", URL: contentread.Path, Status: 500}) + return runtime, registry + } + assertRestartHint := func(t *testing.T, err error) { + t.Helper() + problem, ok := errs.ProblemOf(err) + if !ok || !strings.Contains(problem.Hint, "without --page-token") { + t.Fatalf("error = %#v, want continuation restart hint", err) + } + if strings.Contains(problem.Hint, "cells-get") || strings.Contains(problem.Hint, "record-list") { + t.Fatalf("continuation hint incorrectly redirected to a structured reader: %q", problem.Hint) + } + } + + t.Run("sheet", func(t *testing.T) { + runtime, _ := newRuntime(t) + in := driveFetchInput{inputType: "sheet", token: "shtContinuation", rawURL: "https://www.feishu.cn/sheets/shtContinuation"} + _, err := dispatchDriveFetch(context.Background(), runtime, in, "sheet", in.token, false) + assertRestartHint(t, err) + }) + + t.Run("wiki direct", func(t *testing.T) { + runtime, _ := newRuntime(t) + in := driveFetchInput{inputType: "wiki", token: "wikContinuation", rawURL: "https://www.feishu.cn/wiki/wikContinuation"} + _, err := fetchWikiDirect(context.Background(), runtime, in) + assertRestartHint(t, err) + }) +} + +func TestFetchWikiDirect_BareTokenBuildsWikiURL(t *testing.T) { + rt, reg := newDriveFetchTestRuntime(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"full_content": "# wiki doc"}}, + } + reg.Register(stub) + + in := driveFetchInput{inputType: "wiki", token: "wikTok", isBareToken: true, rawURL: ""} + if _, err := fetchWikiDirect(context.Background(), rt, in); err != nil { + t.Fatalf("fetchWikiDirect: %v", err) + } + want := `"url":"https://www.feishu.cn/wiki/wikTok"` + if !strings.Contains(string(stub.CapturedBody), want) { + t.Errorf("bare wiki token must forward /wiki/, got body: %s", stub.CapturedBody) + } +} + +func TestRunFetchWikiLegacyDocPreservesTypeAndURL(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + factory, stdout, _, registry := cmdutil.TestFactory(t, cfg) + cmd := &cobra.Command{Use: "+fetch"} + for _, name := range []string{"url", "token", "type", "page-token", "include"} { + cmd.Flags().String(name, "", "") + } + cmd.Flags().Bool("full", false, "") + cmd.Flags().Int("page-size", 0, "") + cmd.Flags().Int("embed-max-rows", 50, "") + _ = cmd.Flags().Set("url", "https://www.feishu.cn/wiki/wikLegacy") + runtime := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + + registry.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "doc", + "obj_token": "doccnLegacy", + "node_token": "wikLegacy", + "space_id": "space1", + }, + }}, + }) + fetchStub := &httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "full_content": `

Legacy document

`, + }}, + } + registry.Register(fetchStub) + + if err := RunFetch(context.Background(), runtime); err != nil { + t.Fatalf("RunFetch: %v", err) + } + if !strings.Contains(string(fetchStub.CapturedBody), `"url":"https://www.feishu.cn/doc/doccnLegacy"`) { + t.Fatalf("legacy Doc URL not preserved in request: %s", fetchStub.CapturedBody) + } + output := stdout.String() + if gjson.Get(output, "data.resource.type").String() != "doc" || + gjson.Get(output, "data.resource.url").String() != "https://www.feishu.cn/doc/doccnLegacy" { + t.Fatalf("legacy Doc identity not preserved in output: %s", output) + } +} + +func TestRunFetch_WikiGetNodeFailPaginatesViaDirectFetch(t *testing.T) { + rt, reg := newDriveFetchTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Status: 403, + }) + stub := &httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "full_content": "# wiki page", + "has_more": true, + "next_page_token": "tok-2", + }}, + } + reg.Register(stub) + + cmd := rt.Cmd + for _, f := range []string{"url", "type", "page-token", "include"} { + cmd.Flags().String(f, "", "") + } + cmd.Flags().Bool("full", false, "") + cmd.Flags().Int("page-size", 0, "") + cmd.Flags().Int("embed-max-rows", 50, "") + _ = cmd.Flags().Set("url", "https://www.feishu.cn/wiki/wikTok") + _ = cmd.Flags().Set("page-token", "tok") + + if err := RunFetch(context.Background(), rt); err != nil { + t.Fatalf("RunFetch: wiki get_node failure should fall back to direct fetch, got %v", err) + } + if !strings.Contains(string(stub.CapturedBody), `"page_token":"tok"`) { + t.Errorf("fetch service must receive the forwarded page_token, got body: %s", stub.CapturedBody) + } +} diff --git a/shortcuts/drive/drive_fetch_validate.go b/shortcuts/drive/drive_fetch_validate.go new file mode 100644 index 0000000000..9be994b8df --- /dev/null +++ b/shortcuts/drive/drive_fetch_validate.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "math" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" + "github.com/larksuite/cli/shortcuts/minutes" +) + +func hasPaginationFlags(runtime *common.RuntimeContext) bool { + return runtime.Bool("full") || strings.TrimSpace(runtime.Str("page-token")) != "" || runtime.Int("page-size") > 0 +} + +// validateFetchTypeFlags applies type-specific rules before or after Wiki +// resolution so unsupported flags are never silently ignored. +func validateFetchTypeFlags(runtime *common.RuntimeContext, fetchType string) error { + if fetchType == "minutes" && runtime.As() == core.AsBot { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "minutes can only be fetched with user identity"). + WithParam("--as"). + WithHint("rerun with `--as user`") + } + if hasPaginationFlags(runtime) && fetchType == "minutes" { + return common.ValidationErrorf("--full/--page-token/--page-size do not apply to minutes (got %s)", fetchType).WithParam("--full") + } + if strings.TrimSpace(runtime.Str("include")) != "" && fetchType != "minutes" { + return common.ValidationErrorf("--include only applies to minutes (got %s)", fetchType).WithParam("--include") + } + return nil +} + +// validateFetch is the Validate hook for drive +fetch. +func validateFetch(_ context.Context, runtime *common.RuntimeContext) error { + if err := common.ExactlyOneTyped(runtime, "url", "token"); err != nil { + return err + } + if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 0, 0, math.MaxInt32); err != nil { + return err + } + in, err := resolveDriveFetchInput(runtime) + if err != nil { + return err + } + // For Wiki input the resource type is unknown until unwrap, so defer its + // type-specific checks until execution. + if in.inputType != "wiki" { + if err := validateFetchTypeFlags(runtime, in.inputType); err != nil { + return err + } + } + if runtime.Bool("full") && (strings.TrimSpace(runtime.Str("page-token")) != "" || runtime.Int("page-size") > 0) { + return common.ValidationErrorf("--full cannot be combined with --page-token/--page-size").WithParam("--full") + } + if _, err := minutes.ParseIncludes(runtime.Str("include")); err != nil { + return err + } + return nil +} + +// PlanFetchDryRun previews API calls without executing them. For Wiki input it +// stops after get_node because dispatch depends on the live obj_type. +func PlanFetchDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + in, err := resolveDriveFetchInput(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + dry := common.NewDryRunAPI().Set("type", in.inputType).Set("token", common.MaskToken(in.token)) + + if in.inputType == "wiki" { + dry.Desc("2-step: resolve wiki node → dispatch by obj_type"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to underlying resource"). + Params(map[string]interface{}{"token": in.token}). + Set("note", "dispatched by obj_type from step 1 (doc/docx/sheet/bitable/slides/file/minutes)") + return dry.Set("embed_max_rows", runtime.Int("embed-max-rows")) + } + + switch in.inputType { + case "doc", "docx": + body := contentread.NewRequest(fetchResourceURL(runtime.Config.Brand, in, in.inputType, in.token, false)) + body.WithBlockID = true + contentread.ApplyPagination(&body, runtime.Bool("full"), strings.TrimSpace(runtime.Str("page-token")), runtime.Int("page-size")) + dry.POST(contentread.Path). + Desc("fetch document as paginated Markdown with block anchors"). + Body(body) + dry.POST("/open-apis/docs_ai/v1/documents//fetch"). + Desc("document API fallback (only if the first read path is unavailable)") + case "sheet", "bitable", "slides", "file": + body := contentread.NewRequest(fetchResourceURL(runtime.Config.Brand, in, in.inputType, in.token, false)) + contentread.ApplyPagination(&body, runtime.Bool("full"), strings.TrimSpace(runtime.Str("page-token")), runtime.Int("page-size")) + dry.POST(contentread.Path). + Desc(fmt.Sprintf("fetch %s as markdown (paginated)", in.inputType)). + Body(body) + case "minutes": + dry.GET(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", in.token)). + Desc("minutes: fetch metadata"). + GET(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", in.token)). + Desc("minutes: fetch summary, chapters, todos, keywords, and optional transcript"). + Set("include", runtime.Str("include")) + include, _ := minutes.ParseIncludes(runtime.Str("include")) + if include["note-doc"] { + dry.GET("/open-apis/vc/v1/notes/{note_id}"). + Desc("minutes: fetch related document tokens when note_id and vc:note:read are available") + } + } + return dry.Set("embed_max_rows", runtime.Int("embed-max-rows")) +} diff --git a/shortcuts/drive/drive_skill_doc_test.go b/shortcuts/drive/drive_skill_doc_test.go new file mode 100644 index 0000000000..600862d612 --- /dev/null +++ b/shortcuts/drive/drive_skill_doc_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// driveFrameworkFlags are injected by the common command runner into every +// command (not declared in DriveFetch.Flags), so they are valid in skill +// examples even though they never appear in DriveFetch.Flags. +var driveFrameworkFlags = map[string]bool{ + "as": true, "json": true, "dry-run": true, "format": true, "jq": true, + "yes": true, "print-schema": true, "flag-name": true, +} + +func TestSkillDriveFetchExampleFlagsAreRegistered(t *testing.T) { + t.Parallel() + data, err := os.ReadFile(filepath.Join("..", "..", "skills", "lark-drive", "references", "lark-drive-fetch.md")) + if err != nil { + t.Fatalf("skill doc not found: %v", err) + } + registered := map[string]bool{} + for _, f := range DriveFetch.Flags { + registered[f.Name] = true + } + joined := strings.ReplaceAll(string(data), "\\\n", " ") + flagRe := regexp.MustCompile(`--[a-z][a-z0-9-]*`) + var bad []string + seen := map[string]bool{} + for _, line := range strings.Split(joined, "\n") { + if !strings.Contains(line, "drive +fetch") { + continue + } + for _, m := range flagRe.FindAllString(line, -1) { + name := strings.TrimPrefix(m, "--") + if registered[name] || driveFrameworkFlags[name] || seen[name] { + continue + } + seen[name] = true + bad = append(bad, name) + } + } + if len(bad) > 0 { + t.Errorf("lark-drive-fetch.md uses flags not registered on drive +fetch: %s\n"+ + "delete a removed flag from the skill doc, or if it is a new framework "+ + "flag add it to driveFrameworkFlags", strings.Join(bad, ", ")) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 33183bd6ac..45fd7db208 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -46,5 +46,6 @@ func Shortcuts() []common.Shortcut { DriveSecureLabelUpdate, DriveSearch, DriveInspect, + DriveFetch, } } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 27444a566b..83f1d3ccf3 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -53,6 +53,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+secure-label-update", "+search", "+inspect", + "+fetch", } if len(got) != len(want) { diff --git a/shortcuts/minutes/minutes_fetch.go b/shortcuts/minutes/minutes_fetch.go new file mode 100644 index 0000000000..bc6507aa77 --- /dev/null +++ b/shortcuts/minutes/minutes_fetch.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/note" +) + +var minutesFetchIncludes = map[string]bool{"transcript": true, "note-doc": true} + +// MinutesResult contains rendered content, metadata, and opt-in extras. +type MinutesResult struct { + Content string + Title string + CreateTime string + NoteID string + NoteDocToken string + VerbatimDocToken string + Warnings []string +} + +// FetchMinutesMarkdown renders metadata and artifacts as Markdown. Optional +// transcript and note-document extras degrade to warnings without losing the body. +func FetchMinutesMarkdown(ctx context.Context, runtime *common.RuntimeContext, token string, include map[string]bool) (*MinutesResult, error) { + // 1. metadata: title / note_id / create_time + metaData, err := runtime.DoAPIJSONTyped(http.MethodGet, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(token)), nil, nil) + if err != nil { + return nil, err + } + minute, _ := metaData["minute"].(map[string]any) + if minute == nil { + return nil, errs.NewAPIError(errs.SubtypeNotFound, "minute not found: %s", token) + } + title := common.GetString(minute, "title") + noteID := common.GetString(minute, "note_id") + createTime := common.FormatTime(minute["create_time"]) + + // 2. AI artifacts: summary / chapters / todos / keywords (core content) + art, err := runtime.DoAPIJSONTyped(http.MethodGet, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(token)), nil, nil) + if err != nil { + return nil, err + } + content := renderMinutesMarkdown( + title, + common.GetString(art, "summary"), + common.GetSlice(art, "minute_chapters"), + common.GetSlice(art, "minute_todos"), + common.GetSlice(art, "keywords"), + ) + + result := &MinutesResult{Content: content, Title: title, CreateTime: createTime} + + // 3. Optional artifacts never make the core body fail. + if include["transcript"] { + if transcript := strings.TrimSpace(common.GetString(art, "transcript")); transcript != "" { + result.Content = appendSection(result.Content, "## 逐字稿", transcript) + } else { + result.Warnings = append(result.Warnings, "transcript omitted: the artifacts response did not include transcript content") + } + } + if include["note-doc"] { + result.NoteID = noteID + if noteID == "" { + result.Warnings = append(result.Warnings, "note documents omitted: the minute metadata did not include note_id") + } else if err := runtime.EnsureScopes([]string{"vc:note:read"}); err != nil { + result.Warnings = append(result.Warnings, optionalMinutesWarning(runtime, "note documents omitted", err)) + } else { + detail, err := note.FetchDetail(ctx, runtime, noteID) + if err != nil { + result.Warnings = append(result.Warnings, optionalMinutesWarning(runtime, "note documents omitted", err)) + } else { + result.NoteDocToken = detail.NoteDocToken + result.VerbatimDocToken = detail.VerbatimDocToken + if result.NoteDocToken == "" && result.VerbatimDocToken == "" { + result.Warnings = append(result.Warnings, "note documents omitted: the note detail did not include document tokens") + } + } + } + } + return result, nil +} + +// optionalMinutesWarning keeps recovery details from a typed error when an +// opt-in Minutes artifact degrades to a warning. Warnings are strings in the +// public envelope, so include the structured hint and log ID when available. +func optionalMinutesWarning(runtime *common.RuntimeContext, prefix string, err error) string { + err = runtime.PresentError(err) + warning := fmt.Sprintf("%s: %v", prefix, err) + problem, ok := errs.ProblemOf(err) + if !ok || problem == nil { + return warning + } + var context []string + if hint := strings.TrimSpace(problem.Hint); hint != "" && !strings.Contains(warning, hint) { + context = append(context, "hint: "+hint) + } + if logID := strings.TrimSpace(problem.LogID); logID != "" { + context = append(context, "log_id: "+logID) + } + if len(context) > 0 { + warning += " (" + strings.Join(context, "; ") + ")" + } + return warning +} + +// ParseIncludes parses the --include CSV into a set, rejecting unknown values. +func ParseIncludes(raw string) (map[string]bool, error) { + set := map[string]bool{} + for _, v := range common.SplitCSV(raw) { + if !minutesFetchIncludes[v] { + return nil, common.ValidationErrorf("invalid --include value %q (allowed: transcript, note-doc)", v) + } + set[v] = true + } + return set, nil +} + +// renderMinutesMarkdown assembles title, summary, chapters, todos, and keywords. +func renderMinutesMarkdown(title, summary string, chapters, todos, keywords []interface{}) string { + var b strings.Builder + if t := strings.TrimSpace(title); t != "" { + b.WriteString("# ") + b.WriteString(t) + } + body := b.String() + body = appendSection(body, "## 总结", summary) + body = appendSection(body, "## 章节", renderChapters(chapters)) + body = appendSection(body, "## 待办", renderTodos(todos)) + body = appendSection(body, "## 关键词", renderKeywords(keywords)) + return body +} + +// appendSection appends `heading\n\n` to base when body is non-empty, +// separating from existing content with a blank line. +func appendSection(base, heading, body string) string { + body = strings.TrimSpace(body) + if body == "" { + return base + } + var b strings.Builder + b.WriteString(base) + if b.Len() > 0 { + b.WriteString("\n\n") + } + b.WriteString(heading) + b.WriteString("\n\n") + b.WriteString(body) + return b.String() +} + +// renderChapters renders each chapter as `### title` + summary. Chapters are +// sorted by start timestamp only when every chapter carries one; if any chapter +// lacks a timestamp the API order is preserved (a 0 fallback would mis-order +// timestamp-less chapters ahead of timed ones). +func renderChapters(chapters []interface{}) string { + maps := make([]map[string]interface{}, 0, len(chapters)) + for _, c := range chapters { + if m, ok := c.(map[string]interface{}); ok { + maps = append(maps, m) + } + } + // Missing timestamps keep API order; treating them as zero would misorder chapters. + allTimed := len(maps) > 0 + for _, m := range maps { + if _, ok := chapterStartMs(m); !ok { + allTimed = false + break + } + } + if allTimed && len(maps) > 1 { + sort.SliceStable(maps, func(i, j int) bool { + mi, _ := chapterStartMs(maps[i]) + mj, _ := chapterStartMs(maps[j]) + return mi < mj + }) + } + + var parts []string + for _, ch := range maps { + title := strings.TrimSpace(common.GetString(ch, "title")) + summary := strings.TrimSpace(common.GetString(ch, "summary_content")) + var seg strings.Builder + if title != "" { + seg.WriteString("### ") + seg.WriteString(title) + } + if summary != "" { + if seg.Len() > 0 { + seg.WriteString("\n\n") + } + seg.WriteString(summary) + } + if seg.Len() > 0 { + parts = append(parts, seg.String()) + } + } + return strings.Join(parts, "\n\n") +} + +// renderTodos renders todos as a bullet list, each collapsed to a single line. +func renderTodos(todos []interface{}) string { + var lines []string + common.EachMap(todos, func(td map[string]interface{}) { + if content := strings.Join(strings.Fields(common.GetString(td, "content")), " "); content != "" { + lines = append(lines, "- "+content) + } + }) + return strings.Join(lines, "\n") +} + +// renderKeywords joins keyword strings with a Chinese enumeration comma. +func renderKeywords(keywords []interface{}) string { + var kw []string + for _, k := range keywords { + if s, ok := k.(string); ok { + if s = strings.TrimSpace(s); s != "" { + kw = append(kw, s) + } + } + } + return strings.Join(kw, "、") +} + +// chapterStartMs returns a chapter's start time in ms and whether a usable +// timestamp was found. It tries the candidate field names the artifacts API may +// use; start_ms arrives as a numeric *string* ("92000"), so strings are parsed +// too. ok=false lets renderChapters keep API order for a timestamp-less chapter +// instead of sorting it (as 0) ahead of every timed chapter. +func chapterStartMs(ch map[string]interface{}) (float64, bool) { + for _, k := range []string{"start_ms", "start_time", "start", "timestamp", "begin_time"} { + switch n := ch[k].(type) { + case string: + if f, err := strconv.ParseFloat(strings.TrimSpace(n), 64); err == nil { + return f, true + } + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + } + } + return 0, false +} diff --git a/shortcuts/minutes/minutes_fetch_test.go b/shortcuts/minutes/minutes_fetch_test.go new file mode 100644 index 0000000000..7e3e647cc4 --- /dev/null +++ b/shortcuts/minutes/minutes_fetch_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func newMinutesFetchRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + return newMinutesFetchRuntimeWithScopes(t, "") +} + +type minutesFetchTokenResolver struct { + scopes string +} + +func (r *minutesFetchTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) { + return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil +} + +func newMinutesFetchRuntimeWithScopes(t *testing.T, scopes string) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + cfg := defaultConfig() + factory, _, _, registry := cmdutil.TestFactory(t, cfg) + factory.Credential = credential.NewCredentialProvider(nil, nil, &minutesFetchTokenResolver{scopes: scopes}, factory.HttpClient) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+fetch"}, cfg, factory, core.AsUser) + return runtime, registry +} + +func registerMinutesFetchCore(registry *httpmock.Registry, token, noteID, transcript string) { + registry.Register(detailMinuteGetStub(token, noteID, "Test Meeting")) + registry.Register(detailArtifactsStub(token, transcript)) +} + +func TestFetchMinutesMarkdownUsesArtifactTranscript(t *testing.T) { + runtime, registry := newMinutesFetchRuntime(t) + registerMinutesFetchCore(registry, "toktranscript", "", "Speaker: hello") + + result, err := FetchMinutesMarkdown(context.Background(), runtime, "toktranscript", map[string]bool{"transcript": true}) + if err != nil { + t.Fatalf("FetchMinutesMarkdown() error = %v", err) + } + if !strings.Contains(result.Content, "## 逐字稿\n\nSpeaker: hello") { + t.Fatalf("Content does not include artifact transcript:\n%s", result.Content) + } + if len(result.Warnings) != 0 { + t.Fatalf("Warnings = %v, want none", result.Warnings) + } +} + +func TestFetchMinutesMarkdownReturnsStructuredNoteDocuments(t *testing.T) { + runtime, registry := newMinutesFetchRuntime(t) + registerMinutesFetchCore(registry, "toknote", "note123", "") + registry.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/notes/note123", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "note": map[string]interface{}{ + "artifacts": []interface{}{ + map[string]interface{}{"artifact_type": 1, "doc_token": "doc-main"}, + map[string]interface{}{"artifact_type": 2, "doc_token": "doc-verbatim"}, + }, + }, + }, + }, + }) + + result, err := FetchMinutesMarkdown(context.Background(), runtime, "toknote", map[string]bool{"note-doc": true}) + if err != nil { + t.Fatalf("FetchMinutesMarkdown() error = %v", err) + } + if result.NoteID != "note123" || result.NoteDocToken != "doc-main" || result.VerbatimDocToken != "doc-verbatim" { + t.Fatalf("note fields = %#v", result) + } + if len(result.Warnings) != 0 { + t.Fatalf("Warnings = %v, want none", result.Warnings) + } +} + +func TestFetchMinutesMarkdownNoteFailureDegradesToWarning(t *testing.T) { + runtime, registry := newMinutesFetchRuntime(t) + registerMinutesFetchCore(registry, "toknotefail", "note404", "") + registry.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/vc/v1/notes/note404", Status: 500}) + + result, err := FetchMinutesMarkdown(context.Background(), runtime, "toknotefail", map[string]bool{"note-doc": true}) + if err != nil { + t.Fatalf("FetchMinutesMarkdown() error = %v", err) + } + if result.NoteID != "note404" || result.NoteDocToken != "" || result.VerbatimDocToken != "" { + t.Fatalf("note fields = %#v", result) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "note documents omitted") { + t.Fatalf("Warnings = %v", result.Warnings) + } +} + +func TestFetchMinutesMarkdownMissingNoteScopeKeepsCoreContent(t *testing.T) { + runtime, registry := newMinutesFetchRuntimeWithScopes(t, + "minutes:minutes.basic:read minutes:minutes.artifacts:read") + registerMinutesFetchCore(registry, "toknotescope", "note123", "") + + result, err := FetchMinutesMarkdown(context.Background(), runtime, "toknotescope", map[string]bool{"note-doc": true}) + if err != nil { + t.Fatalf("FetchMinutesMarkdown() error = %v", err) + } + if !strings.Contains(result.Content, "Test Meeting") { + t.Fatalf("core content was lost: %q", result.Content) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "vc:note:read") || + !strings.Contains(result.Warnings[0], "auth login") { + t.Fatalf("Warnings = %v, want actionable missing-scope warning", result.Warnings) + } + if result.NoteID != "note123" || result.NoteDocToken != "" || result.VerbatimDocToken != "" { + t.Fatalf("note fields = %#v", result) + } +} + +// TestRenderChapters_PreservesAPIOrderWhenTimestampMissing guards the sort fix: +// a timestamp-less chapter must not jump to the front of the meeting (the +// 0-fallback bug). When any chapter lacks a timestamp, the API order is kept. +func TestRenderChapters_PreservesAPIOrderWhenTimestampMissing(t *testing.T) { + t.Parallel() + chapters := []interface{}{ + map[string]interface{}{"title": "First", "start_ms": "5000"}, + map[string]interface{}{"title": "Untimed"}, // no timestamp + map[string]interface{}{"title": "Third", "start_ms": "10000"}, + } + got := renderChapters(chapters) + + pos := func(name string) int { return strings.Index(got, name) } + if !(pos("First") < pos("Untimed") && pos("Untimed") < pos("Third")) { + t.Fatalf("API order not preserved (First=%d Untimed=%d Third=%d):\n%s", + pos("First"), pos("Untimed"), pos("Third"), got) + } +} + +// TestRenderChapters_SortsWhenAllTimed confirms chapters sort by start time +// when every chapter carries a timestamp (the API may deliver out of order). +func TestRenderChapters_SortsWhenAllTimed(t *testing.T) { + t.Parallel() + chapters := []interface{}{ + map[string]interface{}{"title": "Late", "start_ms": "20000"}, + map[string]interface{}{"title": "Early", "start_ms": "3000"}, + map[string]interface{}{"title": "Mid", "start_ms": "10000"}, + } + got := renderChapters(chapters) + + pos := func(name string) int { return strings.Index(got, name) } + if !(pos("Early") < pos("Mid") && pos("Mid") < pos("Late")) { + t.Fatalf("timed chapters not sorted by start (Early=%d Mid=%d Late=%d):\n%s", + pos("Early"), pos("Mid"), pos("Late"), got) + } +} diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 54c53b7c56..1a13026553 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -49,6 +49,7 @@ metadata: | 用户目标 | 优先命令 | 何时读 reference | |---|---|---| +| 速览 / 理解 / 总结 Base 内容(尤其跨数据表) | `drive +fetch --url "<原 URL>"` | 直接传原 URL 并读 [lark-drive-fetch.md](../lark-drive/references/lark-drive-fetch.md),不先 `+url-resolve`;精确记录 / 字段、筛选 / 统计、关联查询或全局结论仍走 Base 原生命令 | | 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token | | 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema;写入后报告新 Base 标识和 `permission_grant` | | Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` | @@ -109,6 +110,7 @@ metadata: 5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。 6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。 7. `+data-query` 可返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。 +8. 整份 Base 的速览 / 理解 / 总结用 `drive +fetch`;精确记录、字段、筛选、统计、关联查询或全局结论仍按以上规则走 Base 原生命令。 ## 写入前置规则 diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index dba530dc3d..65d02dea0d 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -35,7 +35,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '

## 快速决策 - 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。 -- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full` +- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch`,默认 `detail=simple`;无明确定位的整篇或跨章节阅读用 `docs +fetch --doc-format markdown`(不带 `--scope`);明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full` - block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids` - 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID - 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节 diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index 04e1ad1515..fc23f497e4 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -10,6 +10,9 @@ lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc" # Markdown 格式 lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown +# Markdown 整篇读取返回 has_more=true 时续读下一页 +lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown --page-token "" + # 带 block ID(用于后续 block 级更新) lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids @@ -33,6 +36,8 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \ ## 选 `--detail`(每块详细度) +> `--detail with-ids/full` 仅支持 XML;分页参数 `--full` 是另一个独立参数。 + | 意图 | `--detail` | 说明 | |------|-----------|------| | **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown,不含 block ID、样式属性、引用元数据 | @@ -103,6 +108,10 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \ `content` 的格式由 `--doc-format` 决定。`reference_map` 是正文引用数据的结构化 sidecar:一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key` 和 `real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content` 和 `reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `` 包裹,详见上文"局部读取的输出结构"。 +仅当 `--doc-format markdown` 且未指定 `--scope` 时,整篇读取才使用分页;服务端分页时响应包含 `has_more` 和 `next_page_token`,服务端不分页时首次读取即返回全部内容。 + +分页 Markdown 整篇读取不支持历史版本或显式 `--lang`。 + ## 参数 | 参数 | 必填 | 说明 | @@ -118,6 +127,10 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \ | `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0`) | | `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0`) | | `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) | +| `--full` | 否 | 仅 Markdown 整篇读取:关闭自动分页,一次返回全部内容;不能与 `--page-token` / `--page-size` 同用 | +| `--page-token` | 否 | 仅 Markdown 整篇读取:传入上次返回的 `next_page_token` 续读 | +| `--page-size` | 否 | 仅 Markdown 整篇读取:每页大小提示(默认 0 = 服务端默认) | +| `--embed-max-rows` | 否 | 仅 Markdown:每个表格最多返回 N 行(默认 50,0 = 不限) | | `--format` | 否 | `json`(默认)\| `pretty` | ## 图片、文件、画板的处理 diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 6044fc864b..d910577680 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-drive version: 1.0.0 -description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。" +description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入,也可通过 `drive +fetch` 读取 Sheet、Base、Slides、File、Minutes 等资源并返回 Markdown。当用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题或读取非 Doc 类型的资源时使用。doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。" metadata: requires: bins: ["lark-cli"] @@ -22,7 +22,7 @@ metadata: - 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。这是会改变文档归属和权限继承的写操作,执行前确认源节点与目标位置。 - 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":""}' --data '{"folder_token":"","name":"","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。 -- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url ''` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。 +- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。任务不读取内容但需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url ''` 进行识别;内容读取不以 `inspect` 为前置。具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。 - 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。 - 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。 - 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。 @@ -48,7 +48,6 @@ metadata: - 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。 - 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token `;不要误切到 `wiki` 域命令。 - `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。 -- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。 - `drive +inspect` / `drive +upload` 遇到 `not found`、`permission denied`、`missing scope` 时,默认停止重试;只有 `rate limit` 或临时网络错误才适合有限重试。 ## 修改标题 @@ -66,7 +65,7 @@ metadata: |----------|---------------------------------------------------------|-----------|----------| | `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | | `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | -| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | 不能直接当底层 `file_token`;优先用 `drive +inspect` 解包获取 `obj_token` | +| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | 完整 URL 可交给 `drive +fetch` 自动解包;不能直接当底层 `file_token`,其他操作需要 `file_token` 时用 `drive +inspect` 解包 | | `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | | `/page/` | `https://example.feishu.cn/page/pagcnxxxxxxxx/` | apps token | URL 路径中的 token 直接使用,资源类型为 `apps` | | `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 | @@ -74,16 +73,23 @@ metadata: ### Wiki 链接特殊处理 ```bash +# 读取内容:自动解包 +lark-cli drive +fetch --url 'https://xxx.feishu.cn/wiki/wikcnXXX' + +# 不读取内容但需要真实类型、token、标题或 canonical URL lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' ``` -知识库链接背后可能是 docx、sheet、bitable、slides、file 等不同对象。后续要做评论、下载、导出或内容读取时,优先用 `drive +inspect` 拿到 `type`、`token`、`title`、`url`;完整手动解析和跨 skill 路由见共享文档 [`lark-wiki-token-routing.md`](../lark-shared/references/lark-wiki-token-routing.md)。不要只根据 `/wiki/` 猜底层类型。 +知识库链接背后可能是 docx、sheet、bitable、slides、file 等不同对象。`drive +fetch` 可直接读取完整 Wiki URL,并按输出中的 `resource.type` / `resource.token` 决定是否继续调用实体 skill;评论、下载、导出、复制等依赖底层类型或 token 的操作使用 `drive +inspect`。需要手动解析和跨 skill 路由时见共享文档 [`lark-wiki-token-routing.md`](../lark-shared/references/lark-wiki-token-routing.md)。 ### 常见操作 Token 需求 | 操作 | 需要的 Token | 说明 | |------|-------------|------| -| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL | +| 读取 Doc / Docx 正文 | URL 或 `file_token` | 按 `lark-doc` 的快速决策使用 `docs +fetch`;可直接传入 URL | +| 读取非 Doc 资源或未知类型的 Wiki 内容 | URL 或资源 token | 使用 `drive +fetch`;Wiki 传完整 URL 自动解包 | +| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `!`,`slides` 使用 `!`;Base 只有记录局部评论,定位为 file_token(base_token) + `--block-id !!` | +| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL | | 下载文件 | `file_token` | 从文件 URL 中直接提取 | | 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token | @@ -91,7 +97,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' | 错误信息 | 原因 | 解决方案 | |----------|------|----------| -| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` | +| `not exist` | 使用了错误的 token | 检查 token 类型;Wiki 读取把完整 URL 传给 `drive +fetch`,需要底层 token 的其他操作先用 `drive +inspect` 获取 `obj_token` | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | | `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable/apps) | | `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 | @@ -117,6 +123,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | Shortcut | 说明 | |----------|----------| +| [`+fetch`](references/lark-drive-fetch.md) | 读取 Doc、Sheet、Base、Slides、File、Minutes 的内容并返回 Markdown;已知 Doc / Docx 的入口按 `lark-doc` 快速决策选择。 | | [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象;支持 `--edited-since`、`--created-by-me`、`--mine`、`--doc-types` 等扁平 flag;区分 original creator 与 owner 语义。 | | [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 | | [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 | diff --git a/skills/lark-drive/references/lark-drive-fetch.md b/skills/lark-drive/references/lark-drive-fetch.md new file mode 100644 index 0000000000..ec8f9829ac --- /dev/null +++ b/skills/lark-drive/references/lark-drive-fetch.md @@ -0,0 +1,88 @@ +# drive +fetch + +读取 Doc、Sheet、Base、Slides、File、Minutes 等飞书云空间资源的内容并返回 Markdown。传入 URL 时自动识别类型;Wiki 链接会自动解包到底层资源,且无需先执行 `drive +inspect`。 + +对 Docx,本 shortcut 与 `docs +fetch --doc-format markdown` 的整篇读取复用同一套 Markdown 读取链路。已知 Doc / Docx 是否使用本命令,按 [`lark-doc`](../../lark-doc/SKILL.md) 的“快速决策”选择。 + +Wiki URL 可直接使用本命令;首次结果不足时,根据 `data.resource.type`、`data.warnings`、`data.has_more` 和任务需要决定继续分页、整篇读取或切换实体 skill。 + +## 什么时候用它,什么时候用别的 + +| 目标 | 用什么 | +|---|---| +| `lark-doc` 快速决策选择 Drive,或需要读取其他支持类型并返回 Markdown | `drive +fetch` | +| 表格精确取单元格值、统计行数、筛选排序 | `sheets`(表格)/ `base`(多维表)原生命令 | +| Slides 需要图表精确数值 | `lark-slides` 原生命令 | +| Minutes 指定产物、逐字稿、基于逐字稿独立分析 | `minutes +detail` | +| 按词检索 Doc、读取指定章节或范围 | `docs +fetch`(`--scope` / `--keyword`) | +| 获取 block ID、原始结构或编辑前信息 | `docs +fetch --doc-format xml`,按需使用 `with-ids` / `full` | +| 获取原始文件字节并保存到本地 | `drive +download` | + +## 支持的类型 + +| 类型 | URL 路径 | fetch 读出来是什么 | +|---|---|---| +| 文档 docx / doc | `/docx/` `/doc/` | 整篇 Markdown,标题 / 表 / 图 / 画板挂 `{#block-id}` 锚点 | +| 电子表格 sheet | `/sheets/` | 文档名 + 每张子表的 GFM 表 | +| 多维表 base | `/base/` | 文档名 + 每张子表的 GFM 表 | +| 幻灯片 slides | `/slides/` | 标题分层 + 表格转 GFM + 图片描述 | +| 网盘文件 file | `/file/` | 提取文本(PDF / Word / Excel / 附件) | +| 妙记 minutes | `/minutes/` | 摘要 + 章节 + 待办 + 关键词;`--include transcript` 内联逐字稿,`--include note-doc` 获取关联纪要文档 token;取不到时保留正文并在 warnings 说明 | +| 知识库 wiki | `/wiki/` | 先解包到底层资源,再按上表读 | + +除 Minutes 外,读取默认请求分页;服务端支持分页时返回当前页和续读游标,不支持时直接返回全部内容。 + +## 命令 + +```bash +# 传 URL(推荐):自动识别类型,wiki 自动解包 +lark-cli drive +fetch --url "https://xxx.feishu.cn/docx/doxcnxxx" + +# 裸 token 必须显式 --type +lark-cli drive +fetch --token doxcnxxx --type docx + +# wiki 里只读某张子表:?table= / ?sheet= 选择器会保留 +lark-cli drive +fetch --url "https://xxx.feishu.cn/wiki/wikcnxxx?table=tblXXX" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--url` | 二选一 | 文档 URL(推荐) | +| `--token` + `--type` | 二选一 | 裸 token 需 `--type`(docx / sheet / bitable / slides / file / minutes / wiki;也接受别名 doc / sheets / base) | +| `--embed-max-rows` | 否 | 物化表格每表最多 N 行(默认 50,0 = 不限),超了截断并提示 | +| `--full` | 否 | 除 Minutes 外:关闭自动分页,一次返回全部内容 | +| `--page-token` | 否 | 除 Minutes 外:传入上次返回的 `next_page_token` 续读;不能与 `--full` 同用 | +| `--page-size` | 否 | 除 Minutes 外:每页大小提示(默认 0 = 服务端默认);不能与 `--full` 同用 | +| `--include` | 否 | 仅 minutes:`transcript` 内联逐字稿 / `note-doc` 取纪要文档 token | + +## 输出 + +默认输出遵循 CLI JSON envelope:`{ok, identity, data: {...}, ...}`。正文按交付方式出现在 `content` 或 `content_file`;以下字段均位于 `data`: + +- `data.content`:内联 Markdown 内容;超大正文自动落盘时可能不返回 +- `data.content_file` / `data.content_preview`:`--full` 的超大正文自动落盘时,完整内容位于 `data.content_file.path`,`content_preview` 仅用于确认内容 +- `data.content_delivery_hint` / `data.content_inline`:自动落盘不支持或写入失败时正文保持内联,`content_delivery_hint` 给出后续恢复方式 +- `data.resource`:`{type, title, url, token, selector, update_time, create_time, source, note_id, note_doc_token, verbatim_doc_token}` + - `selector`:URL 里的 `?sheet=` / `?table=` / `?view=` 透传过来 + - `source`:仅 wiki 输入出现,记录解包前的 wiki 节点 + - `create_time`:仅 minutes(minutes 没有 `update_time`) + - `note_id` / `note_doc_token` / `verbatim_doc_token`:仅 minutes 且指定 `--include note-doc`;取不到时省略并在 `data.warnings` 说明 +- `data.has_more` / `data.next_page_token`:服务端分页时标记是否还有内容并给出续读游标 +- `data.warnings`:提示信息(如妙记逐字稿取不到) + +## 内容读取的边界(拿不全时怎么办) + +- **表格被截断**:GFM 表超过 `--embed-max-rows`(默认 50 行)会截断,尾部写「还有 X 行」。要全量有两种方式——调大 `--embed-max-rows`(设 `0` 拿不截断的 Markdown,适合通读全表);或改用 `sheets +cells-get` / `base +record-list`(适合精确取数、统计、筛选)。 +- **返回内容分页**:除 Minutes 外,先读默认页;当前内容足够即停止,已命中但需要连续后文时,将 `data.next_page_token` 传给 `--page-token` 续读少量页面。整篇或跨章节覆盖、答案位置未知、需要多轮检索时只执行一次 `--full`;禁止对同一资源重复 `--full`,`--full` 失败或超时再回退分页。若 `data.has_more=true` 但 `data.next_page_token` 为空,视为结果不完整并说明;服务端不分页时首次读取即返回全部内容。 +- **完整内容交付**:`--full` 返回 `data.content_file` 时,后续直接对 `path` 本地 read / search,`content_preview` 不能替代完整正文,也不要再次 fetch 同一资源。若出现 `data.content_delivery_hint`,正文保持内联;当前内容足够时直接使用,不足时按 hint 优先在本地重定向,只有无法使用 shell 重定向时才用 `--page-token` 分页。 +- **File 读取回退**:`drive +fetch` 返回的正文足够回答时直接使用;正文不足且需要原始文件字节时用 `drive +download`,需要核对 PDF / HTML / 图片等预览版式时用 `drive +preview`。 +- **提纲 / 清单 / 跨章节总结**:先从目录或同级标题列出覆盖清单;最终答案必须让每个清单项都有明确对应,交付前逐项核对。可以合并表述,但不得静默省略;确实没有相关内容时明确说明。 +- **docx 内嵌的电子表格**:默认就展开成 GFM 表(受 `--embed-max-rows` 截断,截断行为同正文表)。 +- **docx 内嵌的多维表格**:默认展成 GFM 表(受 `--embed-max-rows` 截断);要全量或精确取数,拿 `[多维表格](token=xxx)` 里的 token 去 base 技能(`base +record-list`)。 + +## 正文里的两种标记 + +- `{#block-id}`(标题 / 表 / 图 / 画板后):定位**文档里的这块内容**,要编辑它先用 `docs +fetch --doc-format xml --detail with-ids` 拿到可编辑结构。 +- `[名称](token=xxx)`(画板、内嵌表后):`xxx` 是该**资源**本身的标识,和 block-id 不是一回事。画板 token 可用 `docs +media-download --type whiteboard` 取素材;内嵌多维表格 token 走 base 技能。 diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index cad2657817..e402e24938 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -46,7 +46,8 @@ metadata: | 我的妙记 / 搜索妙记 / 某段时间的妙记 | `+search` | | 妙记基础信息:标题 / 时长 / 封面 / 链接 | `minutes get` | | 下载妙记音视频文件、获取媒体下载链接 | `+download`(仅媒体;要妙记内容用 `+detail`) | -| 妙记总结 / 章节 / 待办 / 关键词 / 逐字稿 | `+detail --minute-tokens ` + 显式产物 flag | +| 速览一条妙记已有的总结 / 章节 / 待办 / 关键词 | `drive +fetch --url "<原 URL>" --as user`,返回连续 Markdown;详见 [`lark-drive-fetch.md`](../lark-drive/references/lark-drive-fetch.md) | +| 读取指定的总结 / 章节 / 待办 / 关键词 / 逐字稿产物 | `+detail --minute-tokens ` + 显式产物 flag | | 基于妙记**提炼/总结/分析/回顾**会议 | `+detail --minute-tokens --transcript`,再独立分析(**禁止照搬 AI 总结**) | | 拿这条妙记关联的纪要文档(`note_doc_token` / `verbatim_doc_token` / `shared_doc_tokens`) | `+detail` 取顶层 `note_id` → [`note +detail --note-id`](../lark-note/SKILL.md) | | 把本地音视频转纪要 / 逐字稿 / 文字稿 | `drive +upload` 取 `file_token` → `+upload` 生成 `minute_url` → `+detail` 拿产物 | diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 3ebaab16d4..e7f7f4f054 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -54,6 +54,7 @@ metadata: | 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) | | --- | --- | --- | --- | +| 速览 / 理解 / 总结整份工作簿(尤其跨子表) | `drive +fetch --url "<原 URL>"` 直接读取 Markdown;精确取值、指定范围、全量统计、筛选 / 排序 / 去重 / 分组仍走 Sheets 原生命令 | [`lark-drive-fetch`](../lark-drive/references/lark-drive-fetch.md) | 把每表默认最多 50 行的 Markdown 当作全量数据 | | 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `lark-sheets-read-data` | `+get-range`、`+range-get`、`+cells-read` | | 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` | | 写纯文本值(整块 CSV 平铺;列里**没有**需字面保真的数值 / 日期标签 / 编号——点分日期 `12.10`、编号 `001` 会被 csv-put 数值化,不算纯文本) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10`→`12.1`、`001`→`1`,尾零/前导零丢失),改用 `+table-put` 声明 `dtypes:object` | @@ -94,7 +95,8 @@ metadata: | 用户需求 | 读取路径 | |---|---| | "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准) | -| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 | +| 速览 / 理解 / 总结整份工作簿(尤其跨子表) | `drive +fetch --url "<原 URL>"` 直接读取 Markdown | +| 查询指定子表 / 范围,或精确取值 / 统计 / 汇总 | 走 Sheets 原生命令;纯值优先 `+csv-get`,按列类型结构化读整表用 `+table-get` | | 需要公式 / 样式 / 批注 | `+cells-get` | | 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5) | diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 12af2471ad..7110bd680a 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -85,7 +85,8 @@ metadata: | 一页改动很多、要改背景或删除若干元素,或要整页重建一页/多页 | 在原 presentation 内按页重建,不创建新 Slides 链接 | `slides +replace-pages`、`lark-slides-replace-pages.md` | | 给已有 PPT 追加或插入页面 | 一次一页,`--slide` 支持 `@file` 绕开 shell 转义 | `slides +add-slide`、`lark-slides-add-slide.md` | | 删除页面 | 按 `slide_id` 单页删除,删前先回读确认 | `slides +delete-slide`、`lark-slides-delete-slide.md` | -| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` | +| 速览 / 理解 / 总结整份 PPT(只关注内容) | `drive +fetch --url "<原 URL>"` 直接读取 Markdown | [`lark-drive-fetch.md`](../lark-drive/references/lark-drive-fetch.md) | +| 读取指定页、检查版式 / 元素结构,或编辑前回读 | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` | | 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) | | 获取幻灯片页面截图 | 按页码用 `--slide-number`,按 ID 用 `--slide-id`;单张用 `--output`,批量或全量用 `--output-dir`,每批最多 10 页串行执行;截图目录复用同一任务的 deck/task 标识,后续读取返回的实际路径 | `slides +screenshot`、`lark-slides-screenshot.md` | | 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 XML 里写 `` 占位符 | @@ -94,6 +95,8 @@ metadata: | 使用图标 | 禁止盲猜 iconType,必须先检索 IconPark,再写 ``,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve`、`iconpark.md` | | 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` | +需要图表中的精确数值时,直接用 Slides 原生读取;`drive +fetch` 仅用于不依赖元素细节的整份内容速览。 + **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。** **CRITICAL — 查看或回滚历史版本前,MUST 先读取 [`lark-slides-history.md`](references/lark-slides-history.md)。回滚接口只接受 `history_version_id`,不要把 `revision_id` 直接传给 `+history-revert`。** diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index 77e243c179..6c0a606bf7 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -6,7 +6,7 @@ - Coverage: 54.5% ## Summary -- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`. +- TestDocs_CreateAndFetchWorkflowAsBot: proves `docs +create` and `docs +fetch`; reads a document larger than 24 KiB normally, then fetches it with `--full` and verifies automatic temporary-file delivery through `content_file`, including its tail content. - TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token. - TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes. - TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`. @@ -21,7 +21,7 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | | ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content | -| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc `; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | | +| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsBot/fetch + fetch full with automatic spill; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc `; `--doc-format markdown`; `--full`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | live full case verifies stdout omits `content`, returns a temporary `content_file`, and preserves the fixture tail in the saved Markdown | | ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | | ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | | ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` | diff --git a/tests/cli_e2e/docs/docs_create_fetch_test.go b/tests/cli_e2e/docs/docs_create_fetch_test.go index e292615abf..3d47254f52 100644 --- a/tests/cli_e2e/docs/docs_create_fetch_test.go +++ b/tests/cli_e2e/docs/docs_create_fetch_test.go @@ -5,6 +5,9 @@ package docs import ( "context" + "os" + "path/filepath" + "strings" "testing" "time" @@ -18,6 +21,7 @@ import ( // TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle. func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) { clie2e.SkipWithoutTenantAccessToken(t) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) @@ -25,7 +29,9 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) { suffix := clie2e.GenerateSuffix() folderName := "lark-cli-e2e-docs-folder-" + suffix docTitle := "lark-cli-e2e-docs-" + suffix - docContent := "# Test Document\n\nThis document was created by lark-cli e2e test." + tailMarker := "docs-full-spill-tail-" + suffix + docContent := "# Test Document\n\nThis document was created by lark-cli e2e test.\n\n" + + strings.Repeat("Large document content for automatic spill verification.\n\n", 600) + tailMarker const defaultAs = "bot" folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "") @@ -53,6 +59,45 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) { assert.Contains(t, content, docTitle) assert.Contains(t, content, "This document was created by lark-cli e2e test.") }) + + t.Run("fetch full with automatic spill", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before fetch") + t.Setenv("TMPDIR", t.TempDir()) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", docToken, + "--doc-format", "markdown", + "--full", + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + document := gjson.Get(result.Stdout, "data.document") + require.True(t, document.Exists(), "stdout:\n%s", result.Stdout) + assert.False(t, document.Get("content").Exists(), "stdout:\n%s", result.Stdout) + contentInline := document.Get("content_inline") + require.True(t, contentInline.Exists(), "stdout:\n%s", result.Stdout) + assert.False(t, contentInline.Bool(), "stdout:\n%s", result.Stdout) + temporary := document.Get("content_file.temporary") + require.True(t, temporary.Exists(), "stdout:\n%s", result.Stdout) + assert.True(t, temporary.Bool(), "stdout:\n%s", result.Stdout) + assert.NotEmpty(t, document.Get("content_preview").String(), "stdout:\n%s", result.Stdout) + + outputPath := document.Get("content_file.path").String() + require.True(t, filepath.IsAbs(outputPath), "content_file.path=%q", outputPath) + t.Cleanup(func() { require.NoError(t, os.Remove(outputPath), "remove spill file %q", outputPath) }) + saved, readErr := os.ReadFile(outputPath) + require.NoError(t, readErr) + assert.Greater(t, len(saved), 24*1024) + assert.Contains(t, string(saved), tailMarker) + assert.Equal(t, int64(len(saved)), document.Get("content_file.size_bytes").Int()) + assert.Equal(t, "utf-8", document.Get("content_file.encoding").String()) + }) } func TestDocs_CreateAndFetchWorkflowAsUser(t *testing.T) { diff --git a/tests/cli_e2e/docs/docs_fetch_dryrun_test.go b/tests/cli_e2e/docs/docs_fetch_dryrun_test.go index 46a5cd047c..939182149e 100644 --- a/tests/cli_e2e/docs/docs_fetch_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_fetch_dryrun_test.go @@ -42,6 +42,62 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) { } } +func TestDocsFetchDryRunRejectsInvalidPageSize(t *testing.T) { + for _, value := range []string{"-1", "2147483648"} { + t.Run(value, func(t *testing.T) { + setDocsDryRunEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"docs", "+fetch", "--doc", "doxcnPage", "--doc-format", "markdown", + "--page-size", value, "--dry-run"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "--page-size") + }) + } +} + +func TestDocsFetchDryRunMarkdownWholeDocForwardsPagination(t *testing.T) { + setDocsDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", "doxcnPagination", + "--doc-format", "markdown", + "--page-token", "tok-1", + "--page-size", "5", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/search/v2/knowledge_qa/fetch_doc_info" { + t.Fatalf("url=%q, want paginated fetch endpoint\nstdout:\n%s", got, out) + } + if !clie2e.DryRunGet(out, "api.0.body.with_block_id").Bool() { + t.Fatalf("with_block_id=false, want true\nstdout:\n%s", out) + } + if !clie2e.DryRunGet(out, "api.0.body.enable_pagination").Bool() { + t.Fatalf("enable_pagination=false, want true\nstdout:\n%s", out) + } + if got := clie2e.DryRunGet(out, "api.0.body.page_token").String(); got != "tok-1" { + t.Fatalf("page_token=%q, want tok-1\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.body.page_size").Int(); got != 5 { + t.Fatalf("page_size=%d, want 5\nstdout:\n%s", got, out) + } +} + func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) { setDocsDryRunEnv(t) diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 14d0bf3d92..836fcb5381 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -1,9 +1,9 @@ # Drive CLI E2E Coverage ## Metrics -- Denominator: 40 leaf commands -- Covered: 22 -- Coverage: 55.0% +- Denominator: 41 leaf commands +- Covered: 23 +- Coverage: 56.1% ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. @@ -19,6 +19,7 @@ - TestDriveCommentOpsWorkflow: opt-in self-contained live workflow for the comment operation shortcuts, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file + file comment fixture, then `+batch-query-comments` finds it by ID, `+add-reply` attaches a reply, `+list-replies` surfaces it, `+update-reply` rewrites its content (confirmed by polling `+list-replies` until the new text lands), `+react-reply` attaches then removes a THUMBSUP reaction (confirmed by polling `+list-replies --need-reaction`, judging presence by count>0 because deleted reactions linger as count=0 entries), `+resolve-comment` marks it solved and `+restore-comment` reopens it (with polling reads between state flips to absorb rate limiting), `+delete-reply --yes` removes the created reply, and cleanup deletes the file. - TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows. - TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs. +- TestDrive_FetchFullAutomaticSpillWorkflow creates a Docx fixture larger than 24 KiB, fetches it with `drive +fetch --full`, and verifies automatic temporary-file delivery through `content_file`, including its tail content. - TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, converging every delete outcome to the resource-gone terminal state: async deletes (non-empty `task_id`) are verified via `drive +task_result --scenario task_check`, sync deletes (empty `task_id`) assert `deleted=true`, and the one verified backend transient (`server_error: "drive task failed"`) passes once the target is confirmed gone (retried up to 3 times otherwise); any other delete failure stays fatal. - TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary. - TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary. @@ -45,6 +46,7 @@ | ✓ | drive +download | shortcut | drive_download_dryrun_test.go::TestDriveDownloadDryRun_DefaultNamePlansMetadataBeforeDownload; drive_download_dryrun_test.go::TestDriveDownloadDryRun_ExplicitOutputSkipsMetadata; drive_upload_workflow_test.go::TestDrive_UploadWorkflow | omitted `--output` plans `metas.batch_query` before file download; explicit `--output` skips metadata; live workflow downloads an uploaded file both with explicit output and with default remote-name output | dry-run plus live fixture coverage | | ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet | | ✕ | drive +export-download | shortcut | | none | no export-download workflow yet | +| ✓ | drive +fetch | shortcut | drive_fetch_dryrun_test.go; drive_fetch_workflow_test.go::TestDrive_FetchFullAutomaticSpillWorkflow | `--token`; `--type docx`; `--full` | dry-run covers routing and pagination flags; live workflow verifies stdout omits `content`, returns a temporary `content_file`, and preserves the fixture tail in the saved Markdown | | ✕ | drive +import | shortcut | | none | no import workflow yet | | ✕ | drive +move | shortcut | | none | no move workflow yet | | ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery | diff --git a/tests/cli_e2e/drive/drive_fetch_dryrun_test.go b/tests/cli_e2e/drive/drive_fetch_dryrun_test.go new file mode 100644 index 0000000000..41206f481e --- /dev/null +++ b/tests/cli_e2e/drive/drive_fetch_dryrun_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// fetchPath is the content-read route reported for Doc, Docx, Sheet, Base, +// Slides, and File reads. +const fetchPath = "/open-apis/search/v2/knowledge_qa/fetch_doc_info" + +// --- Happy path: each URL type dispatches to the correct read path --- + +func TestDriveFetchDryRun_DocxURL(t *testing.T) { + setDriveFetchE2EEnv(t) + const url = "https://xxx.feishu.cn/docx/doxcnFetchE2E" + result := runFetchDryRun(t, "--url", url, "--dry-run") + result.AssertExitCode(t, 0) + + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.api.#").Int(), + "docx should have the primary read and document API fallback, stdout:\n%s", result.Stdout) + require.Equal(t, "POST", gjson.Get(result.Stdout, "data.api.0.method").String()) + require.Equal(t, fetchPath, gjson.Get(result.Stdout, "data.api.0.url").String(), + "step 0 should POST to the content-read route, stdout:\n%s", result.Stdout) + require.Equal(t, "docx", gjson.Get(result.Stdout, "data.type").String()) + require.Contains(t, result.Stdout, url, "body should forward the docx URL verbatim") + require.Contains(t, result.Stdout, "docs_ai/v1/documents", "step 1 should be the document API fallback") +} + +func TestDriveFetchDryRun_SheetURLPreservesSelector(t *testing.T) { + setDriveFetchE2EEnv(t) + const url = "https://xxx.feishu.cn/sheets/shtcnFetchE2E?sheet=Sheet1" + result := runFetchDryRun(t, "--url", url, "--dry-run") + result.AssertExitCode(t, 0) + + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.api.#").Int(), + "sheet should be a single fetch step, stdout:\n%s", result.Stdout) + require.Equal(t, "POST", gjson.Get(result.Stdout, "data.api.0.method").String()) + require.Equal(t, fetchPath, gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "sheet", gjson.Get(result.Stdout, "data.type").String()) + // The ?sheet= selector must survive verbatim into the forwarded body so the + // fetch service re-parses it server-side (CLI does not strip selectors). + require.Contains(t, result.Stdout, url, "body should forward the sheet URL + ?sheet= verbatim") +} + +func TestDriveFetchDryRun_BitableURL(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/base/bascnFetchE2E", "--dry-run") + result.AssertExitCode(t, 0) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, fetchPath, gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "bitable", gjson.Get(result.Stdout, "data.type").String()) +} + +func TestDriveFetchDryRun_SlidesURL(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/slides/slkcnFetchE2E", "--dry-run") + result.AssertExitCode(t, 0) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, fetchPath, gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "slides", gjson.Get(result.Stdout, "data.type").String()) +} + +func TestDriveFetchDryRun_FileURL(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/file/boxcnFetchE2E", "--dry-run") + result.AssertExitCode(t, 0) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, fetchPath, gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "file", gjson.Get(result.Stdout, "data.type").String()) +} + +func TestDriveFetchDryRun_MinutesURL(t *testing.T) { + setDriveFetchE2EEnv(t) + const token = "obcnMinFetchE2E" + result := runFetchDryRun(t, "--url", "https://meetings.feishu.cn/minutes/"+token, "--as", "user", "--dry-run") + result.AssertExitCode(t, 0) + + // Minutes always reads metadata and artifacts. + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.0.method").String()) + require.Equal(t, "/open-apis/minutes/v1/minutes/"+token, gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.1.method").String()) + require.Equal(t, "/open-apis/minutes/v1/minutes/"+token+"/artifacts", gjson.Get(result.Stdout, "data.api.1.url").String()) + require.Equal(t, "minutes", gjson.Get(result.Stdout, "data.type").String()) + require.NotContains(t, result.Stdout, "knowledge_qa", "Minutes must not touch the content-read route") +} + +func TestDriveFetchDryRun_WikiURL(t *testing.T) { + setDriveFetchE2EEnv(t) + const token = "wikcnFetchE2E" + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/wiki/"+token, "--dry-run") + result.AssertExitCode(t, 0) + + // wiki is 2-step at runtime but the dry-run only previews the get_node call + // (the dispatch step depends on obj_type from the live response). + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.0.method").String()) + require.Equal(t, "/open-apis/wiki/v2/spaces/get_node", gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "wiki", gjson.Get(result.Stdout, "data.type").String()) + require.Equal(t, token, gjson.Get(result.Stdout, "data.api.0.params.token").String()) + require.Contains(t, gjson.Get(result.Stdout, "data.note").String(), "obj_type", + "note should explain dispatch by obj_type") +} + +// --- Bare token with --type rebuilds a brand-standard URL --- + +func TestDriveFetchDryRun_BareTokenWithType(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--token", "boxcnBareToken", "--type", "file", "--dry-run") + result.AssertExitCode(t, 0) + + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, fetchPath, gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "file", gjson.Get(result.Stdout, "data.type").String()) + // The content-read API is URL-addressed, so a bare token is rebuilt into a canonical URL. + require.Contains(t, result.Stdout, "https://www.feishu.cn/file/boxcnBareToken", + "bare token should be rebuilt into a brand-standard file URL on the wire") +} + +func TestDriveFetchDryRun_LegacyDocPreservesTypeAndURL(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--token", "doccnLegacy", "--type", "doc", "--dry-run") + result.AssertExitCode(t, 0) + + require.Equal(t, "doc", gjson.Get(result.Stdout, "data.type").String()) + require.Equal(t, "https://www.feishu.cn/doc/doccnLegacy", + gjson.Get(result.Stdout, "data.api.0.body.url").String()) +} + +// --- Validation errors (exit 2 + stderr) --- + +func TestDriveFetchValidation_EmptyInput(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--dry-run") + result.AssertExitCode(t, 2) + require.NotEmpty(t, strings.TrimSpace(result.Stderr), "missing --url/--token should report an error") +} + +func TestDriveFetchValidation_UnsupportedURL(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://google.com/some/page", "--dry-run") + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "not a recognized Lark resource URL", + "unsupported URL validation, stderr:\n%s", result.Stderr) +} + +func TestDriveFetchValidation_BareTokenWithoutType(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--token", "shtcnNoType", "--dry-run") + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "--type is required with --token", + "bare token without --type, stderr:\n%s", result.Stderr) +} + +func TestDriveFetchValidation_InvalidPageSize(t *testing.T) { + for _, value := range []string{"-1", "2147483648"} { + t.Run(value, func(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/file/boxcnPage", + "--page-size", value, "--dry-run") + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "--page-size") + }) + } +} + +func TestDriveFetchValidation_MinutesRequiresUser(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://meetings.feishu.cn/minutes/obcnUserOnly", "--dry-run") + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "minutes can only be fetched with user identity") + require.Contains(t, result.Stderr, "--as user") +} + +func TestDriveFetchValidation_DocTypeMustMatchURL(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/doc/doccnLegacy", + "--type", "docx", "--dry-run") + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "conflicts with URL type") +} + +func TestDriveFetchValidation_FullOnSheetDisablesPagination(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/sheets/shtcnX", "--full", "--dry-run") + result.AssertExitCode(t, 0) + require.False(t, gjson.Get(result.Stdout, "data.api.0.body.enable_pagination").Exists(), + "--full must disable pagination for sheet (field omitted), stdout:\n%s", result.Stdout) +} + +func TestDriveFetchValidation_IncludeOnDocx(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/docx/doxcnX", "--include", "transcript", "--dry-run") + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "only applies to minutes", + "--include should be rejected on a docx, stderr:\n%s", result.Stderr) +} + +// --- Flag forwarding (doc pagination / --full / minutes --include) + ?table= --- + +func TestDriveFetchDryRun_DocxPaginationFlags(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/docx/doxcnPage", + "--page-token", "tok", "--page-size", "5", "--dry-run") + result.AssertExitCode(t, 0) + + require.True(t, gjson.Get(result.Stdout, "data.api.0.body.enable_pagination").Bool(), + "document read should enable pagination when --full is absent, stdout:\n%s", result.Stdout) + require.Equal(t, "tok", gjson.Get(result.Stdout, "data.api.0.body.page_token").String(), + "--page-token should forward into the body, stdout:\n%s", result.Stdout) + require.Equal(t, int64(5), gjson.Get(result.Stdout, "data.api.0.body.page_size").Int(), + "--page-size should forward into the body, stdout:\n%s", result.Stdout) + require.True(t, gjson.Get(result.Stdout, "data.api.0.body.with_block_id").Bool(), + "document read should request block-id anchors, stdout:\n%s", result.Stdout) +} + +func TestDriveFetchDryRun_DocxFullDisablesPagination(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/docx/doxcnFull", "--full", "--dry-run") + result.AssertExitCode(t, 0) + + require.False(t, gjson.Get(result.Stdout, "data.api.0.body.enable_pagination").Exists(), + "--full must disable pagination (field omitted), stdout:\n%s", result.Stdout) + require.True(t, gjson.Get(result.Stdout, "data.api.0.body.with_block_id").Bool(), + "--full still requests block-id anchors, stdout:\n%s", result.Stdout) +} + +func TestDriveFetchDryRun_FilePaginationFlags(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/file/boxcnPage", + "--page-token", "tok", "--page-size", "5", "--dry-run") + result.AssertExitCode(t, 0) + + require.True(t, gjson.Get(result.Stdout, "data.api.0.body.enable_pagination").Bool(), + "file read should enable pagination when --full is absent, stdout:\n%s", result.Stdout) + require.Equal(t, "tok", gjson.Get(result.Stdout, "data.api.0.body.page_token").String(), + "--page-token should forward into the body, stdout:\n%s", result.Stdout) + require.Equal(t, int64(5), gjson.Get(result.Stdout, "data.api.0.body.page_size").Int(), + "--page-size should forward into the body, stdout:\n%s", result.Stdout) + require.False(t, gjson.Get(result.Stdout, "data.api.0.body.with_block_id").Exists(), + "file read must not request block-id anchors (no write-back blocks), stdout:\n%s", result.Stdout) +} + +func TestDriveFetchDryRun_FileFullDisablesPagination(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/file/boxcnFull", "--full", "--dry-run") + result.AssertExitCode(t, 0) + + require.False(t, gjson.Get(result.Stdout, "data.api.0.body.enable_pagination").Exists(), + "--full must disable pagination (field omitted), stdout:\n%s", result.Stdout) + require.False(t, gjson.Get(result.Stdout, "data.api.0.body.with_block_id").Exists(), + "file read must not request block-id anchors, stdout:\n%s", result.Stdout) +} + +func TestDriveFetchDryRun_SheetPaginationFlags(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/sheets/shtcnX", + "--page-token", "tok", "--page-size", "5", "--dry-run") + result.AssertExitCode(t, 0) + require.True(t, gjson.Get(result.Stdout, "data.api.0.body.enable_pagination").Bool(), + "sheet read should request pagination when --full is absent, stdout:\n%s", result.Stdout) + require.Equal(t, "tok", gjson.Get(result.Stdout, "data.api.0.body.page_token").String(), + "--page-token should forward into the body, stdout:\n%s", result.Stdout) + require.Equal(t, int64(5), gjson.Get(result.Stdout, "data.api.0.body.page_size").Int(), + "--page-size should forward into the body, stdout:\n%s", result.Stdout) +} + +func TestDriveFetchDryRun_FileFullAndPageTokenRejected(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://xxx.feishu.cn/file/boxcnX", + "--full", "--page-token", "tok", "--dry-run") + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, "cannot be combined", + "--full + --page-token should be rejected on file, stderr:\n%s", result.Stderr) +} + +func TestDriveFetchDryRun_MinutesIncludeForwarded(t *testing.T) { + setDriveFetchE2EEnv(t) + result := runFetchDryRun(t, "--url", "https://meetings.feishu.cn/minutes/obcnInc", + "--include", "transcript,note-doc", "--as", "user", "--dry-run") + result.AssertExitCode(t, 0) + + require.Equal(t, "minutes", gjson.Get(result.Stdout, "data.type").String()) + require.Equal(t, "transcript,note-doc", gjson.Get(result.Stdout, "data.include").String(), + "--include should be forwarded to the Minutes read path, stdout:\n%s", result.Stdout) + require.Equal(t, int64(3), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, "/open-apis/vc/v1/notes/{note_id}", gjson.Get(result.Stdout, "data.api.2.url").String(), + "note-doc should preview its optional API, stdout:\n%s", result.Stdout) +} + +func TestDriveFetchDryRun_BitableTableSelectorPreserved(t *testing.T) { + setDriveFetchE2EEnv(t) + const url = "https://xxx.feishu.cn/base/bascnTblSel?table=tbl123" + result := runFetchDryRun(t, "--url", url, "--dry-run") + result.AssertExitCode(t, 0) + + require.Equal(t, "bitable", gjson.Get(result.Stdout, "data.type").String()) + require.Contains(t, result.Stdout, url, + "?table= selector should be forwarded verbatim into the body, stdout:\n%s", result.Stdout) +} + +// --- Helpers --- + +func runFetchDryRun(t *testing.T, args ...string) *clie2e.Result { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + defaultAs := "bot" + for _, arg := range args { + if arg == "--as" { + defaultAs = "" + break + } + } + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: append([]string{"drive", "+fetch"}, args...), + DefaultAs: defaultAs, + }) + require.NoError(t, err) + return result +} + +func setDriveFetchE2EEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "drive_fetch_e2e_app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_fetch_e2e_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} diff --git a/tests/cli_e2e/drive/drive_fetch_workflow_test.go b/tests/cli_e2e/drive/drive_fetch_workflow_test.go new file mode 100644 index 0000000000..03ab4eec0d --- /dev/null +++ b/tests/cli_e2e/drive/drive_fetch_workflow_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_FetchFullAutomaticSpillWorkflow(t *testing.T) { + clie2e.SkipWithoutTenantAccessToken(t) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + t.Setenv("TMPDIR", t.TempDir()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, t, ctx, "lark-cli-e2e-drive-fetch-"+suffix, "") + tailMarker := "drive-full-spill-tail-" + suffix + content := "# Fetch spill fixture\n\n" + + strings.Repeat("Large document content for automatic spill verification.\n\n", 600) + tailMarker + docToken := createDriveFetchWorkflowDoc(t, ctx, folderToken, content) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "+fetch", "--token", docToken, "--type", "docx", "--full"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + data := gjson.Get(result.Stdout, "data") + require.True(t, data.Exists(), "stdout:\n%s", result.Stdout) + require.False(t, data.Get("content").Exists(), "stdout:\n%s", result.Stdout) + require.True(t, data.Get("content_inline").Exists(), "stdout:\n%s", result.Stdout) + require.False(t, data.Get("content_inline").Bool(), "stdout:\n%s", result.Stdout) + require.True(t, data.Get("content_file.temporary").Bool(), "stdout:\n%s", result.Stdout) + require.NotEmpty(t, data.Get("content_preview").String(), "stdout:\n%s", result.Stdout) + + outputPath := data.Get("content_file.path").String() + require.True(t, filepath.IsAbs(outputPath), "content_file.path=%q", outputPath) + t.Cleanup(func() { require.NoError(t, os.Remove(outputPath), "remove spill file %q", outputPath) }) + saved, readErr := os.ReadFile(outputPath) + require.NoError(t, readErr) + require.Greater(t, len(saved), 24*1024) + require.Contains(t, string(saved), tailMarker) + require.Equal(t, int64(len(saved)), data.Get("content_file.size_bytes").Int()) + require.Equal(t, "utf-8", data.Get("content_file.encoding").String()) +} + +func createDriveFetchWorkflowDoc(t *testing.T, ctx context.Context, folderToken, content string) string { + t.Helper() + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--parent-token", folderToken, + "--doc-format", "markdown", + "--content", content, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + docToken := gjson.Get(result.Stdout, "data.document.document_id").String() + require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) + return docToken +}