diff --git a/internal/validate/url.go b/internal/validate/url.go index 6d6ab0c4e..e25df97b9 100644 --- a/internal/validate/url.go +++ b/internal/validate/url.go @@ -181,6 +181,25 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport { return cloned } +// DialContextFunc is the signature for DialContext / DialTLSContext. +type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +// WrapDialContextWithIPCheck wraps a DialContext function to validate the +// remote IP after connection, rejecting local/internal addresses (SSRF protection). +func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialConn(ctx, origDial, network, addr) + if err != nil { + return nil, err + } + if err := validateConnRemoteIP(conn); err != nil { + conn.Close() + return nil, err + } + return conn, nil + } +} + func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) { if dialFn != nil { return dialFn(ctx, network, addr) diff --git a/shortcuts/minutes/minutes_download.go b/shortcuts/minutes/minutes_download.go new file mode 100644 index 000000000..9a8c55453 --- /dev/null +++ b/shortcuts/minutes/minutes_download.go @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + // disableClientTimeout removes the global 30s client timeout for large media downloads. + // The download is bounded by the caller's context (e.g. Ctrl+C). A fixed timeout + // would cut off legitimate large file transfers. + disableClientTimeout = 0 + + maxBatchSize = 50 + maxDownloadRedirects = 5 +) + +// validMinuteToken matches minute tokens: lowercase alphanumeric characters only. +var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`) + +var MinutesDownload = common.Shortcut{ + Service: "minutes", + Command: "+download", + Description: "Download audio/video media file of a minute", + Risk: "read", + Scopes: []string{"minutes:minutes.media:export"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch download (max 50)", Required: true}, + {Name: "output", Desc: "output path: file path for single token, directory for batch (default: current dir)"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + {Name: "url-only", Type: "bool", Desc: "only print the download URL(s) without downloading"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + tokens := common.SplitCSV(runtime.Str("minute-tokens")) + if len(tokens) == 0 { + return output.ErrValidation("--minute-tokens is required") + } + if len(tokens) > maxBatchSize { + return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize) + } + for _, token := range tokens { + if !validMinuteToken.MatchString(token) { + return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + tokens := common.SplitCSV(runtime.Str("minute-tokens")) + return common.NewDryRunAPI(). + GET("/open-apis/minutes/v1/minutes/:minute_token/media"). + Set("minute_tokens", tokens) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + tokens := common.SplitCSV(runtime.Str("minute-tokens")) + outputPath := runtime.Str("output") + overwrite := runtime.Bool("overwrite") + urlOnly := runtime.Bool("url-only") + errOut := runtime.IO().ErrOut + single := len(tokens) == 1 + + // Batch mode: --output must be a directory, not an existing file. + if !single && outputPath != "" { + if fi, err := os.Stat(outputPath); err == nil && !fi.IsDir() { + return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath) + } + } + + if !single { + fmt.Fprintf(errOut, "[minutes +download] batch: %d token(s)\n", len(tokens)) + } + + type result struct { + MinuteToken string `json:"minute_token"` + SavedPath string `json:"saved_path,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + Error string `json:"error,omitempty"` + } + + results := make([]result, len(tokens)) + seen := make(map[string]int) + usedNames := make(map[string]bool) + + // Clone the factory client for download use. We clone the struct (not the + // pointer) to avoid mutating the shared singleton's Timeout. The original + // transport chain is preserved so security headers and test mocks still work. + // SSRF protection: ValidateDownloadSourceURL (URL-level) + CheckRedirect + // (redirect-level). Transport-level IP check is intentionally omitted because + // download URLs originate from the trusted Lark API, not user input. + baseClient, err := runtime.Factory.HttpClient() + if err != nil { + return output.ErrNetwork("failed to get HTTP client: %s", err) + } + clonedClient := *baseClient + clonedClient.Timeout = disableClientTimeout + clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= maxDownloadRedirects { + return fmt.Errorf("too many redirects") + } + if len(via) > 0 { + prev := via[len(via)-1] + if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") { + return fmt.Errorf("redirect from https to http is not allowed") + } + } + return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String()) + } + dlClient := &clonedClient + + ticker := time.NewTicker(time.Second / 5) // rate-limit to 5 req/s + defer ticker.Stop() + + for i, token := range tokens { + if i > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } + + if err := validate.ResourceName(token, "--minute-tokens"); err != nil { + results[i] = result{MinuteToken: token, Error: err.Error()} + continue + } + if firstIdx, dup := seen[token]; dup { + results[i] = result{MinuteToken: token, Error: fmt.Sprintf("duplicate token, same as index %d", firstIdx)} + continue + } + seen[token] = i + + downloadURL, err := fetchDownloadURL(ctx, runtime, token) + if err != nil { + results[i] = result{MinuteToken: token, Error: err.Error()} + continue + } + + if urlOnly { + results[i] = result{MinuteToken: token, DownloadURL: downloadURL} + continue + } + + fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token)) + + // single token: --output is a file path; batch: --output is a directory + opts := downloadOpts{overwrite: overwrite, usedNames: usedNames} + if single { + opts.outputPath = outputPath + } else { + opts.outputDir = outputPath + } + + dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts) + if err != nil { + results[i] = result{MinuteToken: token, Error: err.Error()} + continue + } + results[i] = result{MinuteToken: token, SavedPath: dl.savedPath, SizeBytes: dl.sizeBytes} + } + + // output + if single { + r := results[0] + if r.Error != "" { + return output.ErrAPI(0, r.Error, nil) + } + if urlOnly { + runtime.Out(map[string]interface{}{"download_url": r.DownloadURL}, nil) + } else { + runtime.Out(map[string]interface{}{"saved_path": r.SavedPath, "size_bytes": r.SizeBytes}, nil) + } + return nil + } + + // batch output + successCount := 0 + for _, r := range results { + if r.Error == "" { + successCount++ + } + } + fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) + + runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil) + if successCount == 0 && len(results) > 0 { + return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil) + } + return nil + }, +} + +// fetchDownloadURL retrieves the pre-signed download URL for a minute token. +func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) { + data, err := runtime.DoAPIJSON(http.MethodGet, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)), + nil, nil) + if err != nil { + return "", err + } + downloadURL := common.GetString(data, "download_url") + if downloadURL == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken) + } + return downloadURL, nil +} + +type downloadResult struct { + savedPath string + sizeBytes int64 +} + +type downloadOpts struct { + outputPath string // explicit output file path (single mode only) + outputDir string // output directory (batch mode) + overwrite bool + usedNames map[string]bool // tracks used filenames to deduplicate in batch mode +} + +// downloadMediaFile streams a media file from a pre-signed URL to disk. +// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > .media. +func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) { + if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil { + return nil, output.ErrValidation("blocked download URL: %s", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, output.ErrNetwork("invalid download URL: %s", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, output.ErrNetwork("download failed: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if len(body) > 0 { + return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode) + } + + // resolve output path + outputPath := opts.outputPath + if outputPath == "" { + filename := resolveFilenameFromResponse(resp, minuteToken) + // Deduplicate filenames in batch mode: prefix with token on collision. + if opts.usedNames != nil { + if opts.usedNames[filename] { + filename = minuteToken + "-" + filename + } + opts.usedNames[filename] = true + } + outputPath = filepath.Join(opts.outputDir, filename) + } + + safePath, err := validate.SafeOutputPath(outputPath) + if err != nil { + return nil, output.ErrValidation("unsafe output path: %s", err) + } + if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { + return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err) + } + + sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) + } + return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil +} + +// resolveFilenameFromResponse derives the filename from HTTP response headers. +// Priority: Content-Disposition filename > Content-Type extension > .media. +func resolveFilenameFromResponse(resp *http.Response, minuteToken string) string { + if cd := resp.Header.Get("Content-Disposition"); cd != "" { + if _, params, err := mime.ParseMediaType(cd); err == nil { + if filename := params["filename"]; filename != "" { + return filename + } + } + } + if ext := extFromContentType(resp.Header.Get("Content-Type")); ext != "" { + return minuteToken + ext + } + return minuteToken + ".media" +} + +// preferredExt overrides Go's mime.ExtensionsByType which returns alphabetically sorted +// results (e.g. .m4v before .mp4 for video/mp4). +var preferredExt = map[string]string{ + "video/mp4": ".mp4", + "audio/mp4": ".m4a", + "audio/mpeg": ".mp3", +} + +// newDownloadClient wraps the base HTTP client with SSRF protection +// (redirect safety + transport-level IP validation). When the base transport +// is not *http.Transport (e.g. test mocks), it falls back to cloning +// http.DefaultTransport via NewDownloadHTTPClient. +// extFromContentType returns a file extension for the given Content-Type, or "" if unknown. +func extFromContentType(contentType string) string { + if contentType == "" { + return "" + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "" + } + if ext, ok := preferredExt[mediaType]; ok { + return ext + } + if exts, err := mime.ExtensionsByType(mediaType); err == nil && len(exts) > 0 { + return exts[0] + } + return "" +} diff --git a/shortcuts/minutes/minutes_download_test.go b/shortcuts/minutes/minutes_download_test.go new file mode 100644 index 000000000..5e6a4738b --- /dev/null +++ b/shortcuts/minutes/minutes_download_test.go @@ -0,0 +1,439 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +var warmOnce sync.Once + +func warmTokenCache(t *testing.T) { + t.Helper() + warmOnce.Do(func() { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + }) + s := common.Shortcut{ + Service: "test", + Command: "+warm", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + parent.Execute() + }) +} + +func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + warmTokenCache(t) + parent := &cobra.Command{Use: "minutes"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func defaultConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + } +} + +func mediaStub(token, downloadURL string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/minutes/v1/minutes/" + token + "/media", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"download_url": downloadURL}, + }, + } +} + +func downloadStub(url string, body []byte, contentType string) *httpmock.Stub { + return &httpmock.Stub{ + URL: url, + RawBody: body, + Headers: http.Header{"Content-Type": []string{contentType}}, + } +} + +// chdir changes the working directory and restores it when the test finishes. +func chdir(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to chdir to %s: %v", dir, err) + } + t.Cleanup(func() { os.Chdir(orig) }) +} + +// --------------------------------------------------------------------------- +// Unit tests: resolveOutputFromResponse +// --------------------------------------------------------------------------- + +func TestResolveFilenameFromResponse_ContentDisposition(t *testing.T) { + resp := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{`attachment; filename="meeting_recording.mp4"`}, + "Content-Type": []string{"video/mp4"}, + }, + } + got := resolveFilenameFromResponse(resp, "tok001") + if got != "meeting_recording.mp4" { + t.Errorf("expected Content-Disposition filename, got %q", got) + } +} + +func TestResolveFilenameFromResponse_ContentType(t *testing.T) { + resp := &http.Response{ + Header: http.Header{ + "Content-Type": []string{"video/mp4"}, + }, + } + got := resolveFilenameFromResponse(resp, "tok001") + if !strings.HasPrefix(got, "tok001") { + t.Errorf("expected token prefix, got %q", got) + } + if ext := got[len("tok001"):]; ext == "" { + t.Errorf("expected extension after token, got %q", got) + } +} + +func TestResolveFilenameFromResponse_Fallback(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + got := resolveFilenameFromResponse(resp, "tok001") + if got != "tok001.media" { + t.Errorf("expected fallback %q, got %q", "tok001.media", got) + } +} + +func TestResolveFilenameFromResponse_InvalidContentDisposition(t *testing.T) { + resp := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{"invalid;;;"}, + "Content-Type": []string{"audio/mpeg"}, + }, + } + got := resolveFilenameFromResponse(resp, "tok001") + if !strings.HasPrefix(got, "tok001") { + t.Errorf("expected token prefix from Content-Type fallback, got %q", got) + } +} + +func TestResolveFilenameFromResponse_EmptyDispositionFilename(t *testing.T) { + resp := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{"attachment"}, + "Content-Type": []string{"video/mp4"}, + }, + } + got := resolveFilenameFromResponse(resp, "tok001") + if got == "" { + t.Error("expected non-empty filename") + } + if !strings.HasPrefix(got, "tok001") { + t.Errorf("expected token prefix, got %q", got) + } +} + +// --------------------------------------------------------------------------- +// Validation tests +// --------------------------------------------------------------------------- + +func TestDownload_Validation_NoFlags(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, MinutesDownload, []string{"+download", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for no flags") + } +} + +func TestDownload_Validation_InvalidToken(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "obcn***invalid", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error for invalid token") + } + if !strings.Contains(err.Error(), "invalid minute token") { + t.Errorf("expected 'invalid minute token' error, got: %v", err) + } +} + +func TestDownload_Validation_OutputWithBatch(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "t1,t2", "--output", "file.mp4", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error for --output with --minute-tokens") + } +} + +// --------------------------------------------------------------------------- +// Integration tests: single mode +// --------------------------------------------------------------------------- + +func TestDownload_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001", "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "media") { + t.Errorf("dry-run should show media API path, got: %s", out) + } + if !strings.Contains(out, "tok001") { + t.Errorf("dry-run should show minute_token, got: %s", out) + } +} + +func TestDownload_UrlOnly(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(mediaStub("tok001", "https://example.com/presigned/download")) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001", "--url-only", "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "https://example.com/presigned/download") { + t.Errorf("url-only should output download URL, got: %s", stdout.String()) + } +} + +func TestDownload_FullDownload(t *testing.T) { + chdir(t, t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(mediaStub("tok001", "https://example.com/presigned/download")) + reg.Register(downloadStub("example.com/presigned/download", []byte("fake-video-content"), "video/mp4")) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001", "--output", "output.mp4", "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile("output.mp4") + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if string(data) != "fake-video-content" { + t.Errorf("file content = %q, want %q", string(data), "fake-video-content") + } +} + +func TestDownload_OverwriteProtection(t *testing.T) { + chdir(t, t.TempDir()) + if err := os.WriteFile("existing.mp4", []byte("old"), 0644); err != nil { + t.Fatalf("setup failed: %v", err) + } + + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(mediaStub("tok001", "https://example.com/presigned/download")) + reg.Register(downloadStub("example.com/presigned/download", []byte("new-content"), "video/mp4")) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001", "--output", "existing.mp4", "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected error for existing file without --overwrite") + } + if !strings.Contains(err.Error(), "exists") { + t.Errorf("error should mention file exists, got: %v", err) + } + + data, _ := os.ReadFile("existing.mp4") + if string(data) != "old" { + t.Errorf("original file should be preserved, got %q", string(data)) + } +} + +func TestDownload_HttpError(t *testing.T) { + chdir(t, t.TempDir()) + + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(mediaStub("tok001", "https://example.com/presigned/download")) + reg.Register(&httpmock.Stub{ + URL: "example.com/presigned/download", + Status: 403, + RawBody: []byte("Forbidden"), + }) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001", "--output", "output.mp4", "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected error for HTTP 403") + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("error should contain status code, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Integration tests: batch mode +// --------------------------------------------------------------------------- + +func TestDownload_Batch_UrlOnly(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(mediaStub("tok001", "https://example.com/download/1")) + reg.Register(mediaStub("tok002", "https://example.com/download/2")) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001,tok002", "--url-only", "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "download/1") || !strings.Contains(out, "download/2") { + t.Errorf("batch url-only should show both URLs, got: %s", out) + } +} + +func TestDownload_Batch_Download(t *testing.T) { + chdir(t, t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(mediaStub("tok001", "https://example.com/download/1")) + reg.Register(mediaStub("tok002", "https://example.com/download/2")) + reg.Register(downloadStub("example.com/download/1", []byte("content-1"), "video/mp4")) + reg.Register(downloadStub("example.com/download/2", []byte("content-2"), "video/mp4")) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001,tok002", "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // verify output structure + var result struct { + Data struct { + Downloads []struct { + MinuteToken string `json:"minute_token"` + SavedPath string `json:"saved_path"` + } `json:"downloads"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String()) + } + if len(result.Data.Downloads) != 2 { + t.Fatalf("expected 2 downloads, got %d", len(result.Data.Downloads)) + } +} + +func TestDownload_Batch_PartialFailure(t *testing.T) { + chdir(t, t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(mediaStub("tok001", "https://example.com/download/1")) + reg.Register(downloadStub("example.com/download/1", []byte("content-1"), "video/mp4")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/minutes/v1/minutes/tok002/media", + Status: 200, + Body: map[string]interface{}{ + "code": 99999, "msg": "permission denied", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001,tok002", "--as", "bot", + }, f, stdout) + // partial failure should not cause an overall error + if err != nil { + t.Fatalf("partial failure should not return error, got: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "tok001") || !strings.Contains(out, "tok002") { + t.Errorf("output should contain both tokens, got: %s", out) + } +} + +func TestDownload_Batch_DuplicateToken(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + // register media stub only once — dedup means only one API call + reg.Register(mediaStub("tok001", "https://example.com/download/1")) + + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001,tok001", "--url-only", "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "duplicate") { + t.Errorf("second token should report duplicate, got: %s", out) + } +} + +func TestDownload_Batch_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, MinutesDownload, []string{ + "+download", "--minute-tokens", "tok001,tok002", "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "tok001") || !strings.Contains(out, "tok002") { + t.Errorf("dry-run should show tokens, got: %s", out) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go new file mode 100644 index 000000000..9c1431f29 --- /dev/null +++ b/shortcuts/minutes/shortcuts.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all minutes shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + MinutesDownload, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 30fd506dd..3f8048fbe 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/shortcuts/event" "github.com/larksuite/cli/shortcuts/im" "github.com/larksuite/cli/shortcuts/mail" + "github.com/larksuite/cli/shortcuts/minutes" "github.com/larksuite/cli/shortcuts/sheets" "github.com/larksuite/cli/shortcuts/task" "github.com/larksuite/cli/shortcuts/vc" @@ -36,6 +37,7 @@ func init() { allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) + allShortcuts = append(allShortcuts, minutes.Shortcuts()...) allShortcuts = append(allShortcuts, task.Shortcuts()...) allShortcuts = append(allShortcuts, vc.Shortcuts()...) allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...) diff --git a/shortcuts/vc/artifact-Empty Artifacts-tok003/transcript.txt b/shortcuts/vc/artifact-Empty Artifacts-tok003/transcript.txt deleted file mode 100644 index 97dd81182..000000000 --- a/shortcuts/vc/artifact-Empty Artifacts-tok003/transcript.txt +++ /dev/null @@ -1 +0,0 @@ -{"code":0,"data":{},"msg":"ok"} \ No newline at end of file diff --git a/shortcuts/vc/artifact-No Note Meeting-tok002/transcript.txt b/shortcuts/vc/artifact-No Note Meeting-tok002/transcript.txt deleted file mode 100644 index 97dd81182..000000000 --- a/shortcuts/vc/artifact-No Note Meeting-tok002/transcript.txt +++ /dev/null @@ -1 +0,0 @@ -{"code":0,"data":{},"msg":"ok"} \ No newline at end of file diff --git a/shortcuts/vc/artifact-Test Minutes-tok001/transcript.txt b/shortcuts/vc/artifact-Test Minutes-tok001/transcript.txt deleted file mode 100644 index 97dd81182..000000000 --- a/shortcuts/vc/artifact-Test Minutes-tok001/transcript.txt +++ /dev/null @@ -1 +0,0 @@ -{"code":0,"data":{},"msg":"ok"} \ No newline at end of file diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index ecb6e1e37..eb7962c8d 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-minutes version: 1.0.0 -description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关的 AI 产物(总结、待办、章节)。飞书妙记的 URL 格式为: http(s):///minutes/" +description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关的 AI 产物(总结、待办、章节),下载妙记音视频文件。飞书妙记的 URL 格式为: http(s):///minutes/" metadata: requires: bins: ["lark-cli"] @@ -10,7 +10,7 @@ metadata: # minutes (v1) -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** ## 核心概念 @@ -48,6 +48,29 @@ lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2 - 用户未指定需要查询妙记的哪些内容时,默认查询基础元信息和相关联的纪要产物信息。 - 用户未明确指定查看纪要产物(逐字稿、总结、待办、章节)时,向用户展示对应产物的链接即可,不需要直接读取产物内容。 +## Shortcuts(推荐优先使用) + +Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。 + +| Shortcut | 说明 | +|----------|------| +| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | + +### 妙记音视频下载 + +下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。 + +```bash +# 下载音视频文件到本地 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4 + +# 仅获取下载链接 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only + +# 批量下载 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj +``` + ## API Resources @@ -67,6 +90,7 @@ lark-cli minutes [flags] # 调用 API | 方法 | 所需 scope | |------|-----------| | `minutes.get` | `minutes:minutes:readonly` | +| `+download` | `minutes:minutes.media:export` | diff --git a/skills/lark-minutes/references/lark-minutes-download.md b/skills/lark-minutes/references/lark-minutes-download.md new file mode 100644 index 000000000..64402728b --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-download.md @@ -0,0 +1,119 @@ + +# minutes +download + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +下载妙记的音视频媒体文件到本地,或获取有效期 1 天的下载链接。只读操作。 + +本 skill 对应 shortcut:`lark-cli minutes +download`。 + +## 命令 + +```bash +# 下载单个妙记的音视频文件 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c + +# 指定输出路径 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4 + +# 仅获取下载链接(有效期 1 天),不下载文件 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only + +# 批量下载多个妙记 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj + +# 批量下载到指定目录 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj --output ./downloads + +# 预览 API 调用 +lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-tokens ` | 是 | 妙记 Token,逗号分隔支持批量(最多 50 个) | +| `--output ` | 否 | 输出路径:单个 token 时为文件路径,批量时为目录(默认当前目录) | +| `--overwrite` | 否 | 覆盖已存在的输出文件 | +| `--url-only` | 否 | 仅返回下载链接,不下载文件 | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 核心约束 + +### 1. 妙记必须已完成转写 + +音视频文件仅在妙记转写完成后可下载。如果妙记尚未准备好,API 会返回 `2091003` 错误。 + +### 2. 下载链接有效期 1 天 + +`--url-only` 返回的链接有效期为 1 天,过期后需重新获取。 + +### 3. 频率限制 + +API 限流 5 次/秒,批量下载时需注意控制频率。 + +### 4. 所需权限 + +| 身份 | 所需权限 | +|------|---------| +| user / bot | `minutes:minutes.media:export` | + +## 输出结果 + +### 下载模式(默认) + +```json +{ + "saved_path": "访谈一则 & 澳成.mp4", + "size_bytes": 52428800 +} +``` + +| 字段 | 说明 | +|------|------| +| `saved_path` | 文件保存的本地路径 | +| `size_bytes` | 文件大小(字节) | + +### URL 模式(--url-only) + +```json +{ + "download_url": "https://..." +} +``` + +| 字段 | 说明 | +|------|------| +| `download_url` | 媒体文件下载链接(有效期 1 天) | + +## 如何获取 minute_token + +| 来源 | 获取方式 | +|------|---------| +| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c` → `obcnq3b9jl72l83w4f149w9c` | +| 妙记元信息查询 | `lark-cli minutes minutes get --params '{"minute_token": "obcn..."}'` | +| 会议纪要查询 | `lark-cli vc +notes --meeting-ids ` 返回结果中关联的妙记 token | + +## 常见错误与排查 + +| 错误现象 | 错误码 | 根本原因 | 解决方案 | +|---------|--------|---------|---------| +| 参数无效 | 2091001 | minute_token 格式不正确 | 检查 token 是否完整(24 位) | +| 资源不存在 | 2091002 | token 不存在 | 确认 minute_token 正确 | +| 妙记尚未准备好 | 2091003 | 转写未完成 | 等待转写完成后重试 | +| 资源已删除 | 2091004 | 妙记已被删除 | 确认妙记文件仍然存在 | +| 权限不足 | 2091005 | 无阅读权限 | 检查是否有该妙记的访问权限 | +| `missing required scope(s)` | — | 应用缺少权限 | 运行 `auth login --scope "minutes:minutes.media:export"` | + +## 提示 + +- 音视频文件可能较大,下载无固定超时限制(由用户 Ctrl+C 控制取消)。 +- 未指定 `--output` 时,默认使用妙记原始标题作为文件名(如 `Office Oncall流程2.0宣讲.mp4`)。 +- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [vc +notes](../../lark-vc/references/lark-vc-notes.md)。 + +## 参考 + +- [lark-minutes](../SKILL.md) — 妙记全部命令 +- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 会议纪要查询 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-vc/SKILL.md b/skills/lark-vc/SKILL.md index 9a540972d..c210d860a 100644 --- a/skills/lark-vc/SKILL.md +++ b/skills/lark-vc/SKILL.md @@ -31,6 +31,21 @@ metadata: ### 2. 整理会议纪要 1. 整理纪要文档时默认给出纪要文档和逐字稿链接即可,无需读取纪要文档或逐字稿内容。 2. 用户明确需要获取纪要文档中的总结、待办、章节产物时,再读取文档获取具体内容。 +3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 ``** 标签是封面图(AI 生成的总结可视化),应同时下载展示给用户: +```bash +# 1. 读取纪要内容 +lark-cli docs +fetch --doc +# 2. 从返回的 markdown 中提取第一个 的 token +# 3. 下载封面图到 artifact 目录(和逐字稿同目录,保持产物归拢) +# 并非所有纪要都有封面画板,没有 标签时跳过即可 +lark-cli docs +media-download --type whiteboard --token --output ./artifact-/cover +``` +> **产物目录规范**:同一会议的所有下载产物(封面图、逐字稿等)统一放到 `artifact-<title>/` 目录下,不要散落在当前工作目录。 + +> **`note_doc_token` vs `verbatim_doc_token` — 两份不同的文档,根据用户意图选择:** +> - `note_doc_token` → **智能纪要**(AI 总结 + 待办 + 章节)— 用户说"纪要""总结""待办""纪要内容"时用这个 +> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个 +> - 用户意图不明确时,应展示两个文档链接让用户选择,而不是替用户决定 ### 3. 纪要文档与逐字稿链接 1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。 diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md index 06147d4d9..53aeb880d 100644 --- a/skills/lark-vc/references/lark-vc-notes.md +++ b/skills/lark-vc/references/lark-vc-notes.md @@ -75,12 +75,14 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | 字段 | 说明 | |------|------| -| `note_doc_token` | 主纪要文档 Token | -| `verbatim_doc_token` | 逐字稿文档 Token | +| `note_doc_token` | **智能纪要**文档 Token — 包含 AI 总结、待办、章节(用户说"纪要"时用这个) | +| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳(用户说"逐字稿"时才用这个) | | `shared_doc_tokens` | 会中共享文档 Token 列表 | | `creator_id` | 创建者 ID | | `create_time` | 创建时间(格式化) | +> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 用 `note_doc_token`。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示两个文档链接让用户选择。 + ### minute-tokens 路径的 AI 产物 通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物: