diff --git a/internal/registry/scope_priorities.json b/internal/registry/scope_priorities.json index 99c3c6609..ff5f34ff4 100644 --- a/internal/registry/scope_priorities.json +++ b/internal/registry/scope_priorities.json @@ -439,6 +439,11 @@ "final_score": "78.7030", "recommend": "true" }, + { + "scope_name": "slides:presentation:screenshot", + "final_score": "78.7030", + "recommend": "true" + }, { "scope_name": "slides:presentation:create", "final_score": "79.4755", diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index a7772a3bd..6d04db76e 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -217,6 +217,12 @@ func (ctx *RuntimeContext) Float64(name string) float64 { return v } +// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting). +func (ctx *RuntimeContext) IntArray(name string) []int { + v, _ := ctx.Cmd.Flags().GetIntSlice(name) + return v +} + // StrArray returns a string-array flag value (repeated flag, no CSV splitting). func (ctx *RuntimeContext) StrArray(name string) []string { v, _ := ctx.Cmd.Flags().GetStringArray(name) @@ -1286,6 +1292,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f var d float64 fmt.Sscanf(fl.Default, "%g", &d) cmd.Flags().Float64(fl.Name, d, desc) + case "int_array": + cmd.Flags().IntSlice(fl.Name, nil, desc) case "string_array": cmd.Flags().StringArray(fl.Name, nil, desc) case "string_slice": diff --git a/shortcuts/common/runner_args_test.go b/shortcuts/common/runner_args_test.go index f04999da2..26b98910e 100644 --- a/shortcuts/common/runner_args_test.go +++ b/shortcuts/common/runner_args_test.go @@ -4,9 +4,12 @@ package common import ( + "context" + "reflect" "strings" "testing" + "github.com/larksuite/cli/internal/cmdutil" "github.com/spf13/cobra" ) @@ -56,3 +59,29 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) { t.Fatalf("expected no error for empty args, got: %v", err) } } + +func TestShortcutFlagIntArray(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + parent := &cobra.Command{Use: "root"} + var got []int + shortcut := Shortcut{ + Service: "slides", + Command: "+screenshot", + Description: "capture screenshots", + Flags: []Flag{ + {Name: "slide-number", Type: "int_array"}, + }, + Execute: func(ctx context.Context, runtime *RuntimeContext) error { + got = runtime.IntArray("slide-number") + return nil + }, + } + shortcut.Mount(parent, f) + parent.SetArgs([]string{"+screenshot", "--slide-number", "1", "--slide-number", "2,3"}) + if err := parent.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) { + t.Fatalf("slide-number = %#v, want %#v", got, want) + } +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 86eb80b74..afc4f1a82 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -18,7 +18,7 @@ const ( // Flag describes a CLI flag for a shortcut. type Flag struct { Name string // flag name (e.g. "calendar-id") - Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice" + Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice" Default string // default value as string Desc string // help text Hidden bool // hidden from --help, still readable at runtime diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index 2ef3650ec..f665810cb 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -11,5 +11,6 @@ func Shortcuts() []common.Shortcut { SlidesCreate, SlidesMediaUpload, SlidesReplaceSlide, + SlidesScreenshot, } } diff --git a/shortcuts/slides/slides_screenshot.go b/shortcuts/slides/slides_screenshot.go new file mode 100644 index 000000000..7c15d79c4 --- /dev/null +++ b/shortcuts/slides/slides_screenshot.go @@ -0,0 +1,442 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "regexp" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/shortcuts/common" +) + +const defaultSlidesScreenshotDir = ".lark-slides/screenshots" + +var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +// SlidesScreenshot fetches server-rendered slide screenshots and writes them to +// local files. The raw API returns Base64 image payloads; this shortcut keeps +// those payloads out of stdout so agents only see small file metadata. +var SlidesScreenshot = common.Shortcut{ + Service: "slides", + Command: "+screenshot", + Description: "Save slide screenshots to local files without printing Base64 image data", + Risk: "read", + Scopes: []string{"slides:presentation:screenshot"}, + // Only wiki URL inputs need wiki:node:read. Keep it conditional so plain + // slides IDs/URLs do not require an unrelated wiki scope. + ConditionalScopes: []string{"wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"}, + {Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"}, + {Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"}, + {Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}}, + {Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"}, + {Name: "output-name", Desc: "file name stem for --content render output"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + renderMode := runtime.Changed("content") + if renderMode { + if strings.TrimSpace(runtime.Str("content")) == "" { + return common.FlagErrorf("--content cannot be empty") + } + if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 { + return common.FlagErrorf("--content cannot be used with --slide-id or --slide-number") + } + if runtime.Changed("presentation") { + return common.FlagErrorf("--presentation cannot be used with --content") + } + } else { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } + if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil { + return err + } + if !hasSlideScreenshotSelector(runtime) { + return common.FlagErrorf("--slide-id or --slide-number is required") + } + } + if _, err := validateScreenshotOutputDir(runtime.Str("output-dir")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if runtime.Changed("content") { + return dryRunRenderScreenshot(runtime) + } + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id")) + slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + if len(slideIDs) == 0 && len(slideNumbers) == 0 { + return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required") + } + + presentationID := ref.Token + dry := common.NewDryRunAPI() + if ref.Kind == "wiki" { + presentationID = "" + dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to slides presentation"). + Params(map[string]interface{}{"token": ref.Token}) + } else { + dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir"))) + } + body := map[string]interface{}{} + if len(slideIDs) > 0 { + body["slide_ids"] = slideIDs + } + if len(slideNumbers) > 0 { + body["slide_numbers"] = slideNumbers + } + dry.POST(fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s/slide_images", + validate.EncodePathSegment(presentationID), + )). + Body(body) + return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Changed("content") { + return executeRenderScreenshot(runtime) + } + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id")) + slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number")) + if err != nil { + return err + } + if len(slideIDs) == 0 && len(slideNumbers) == 0 { + return common.FlagErrorf("--slide-id or --slide-number is required") + } + outputDir := runtime.Str("output-dir") + safeOutputDir, err := ensureScreenshotOutputDir(outputDir) + if err != nil { + return err + } + + url := fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s/slide_images", + validate.EncodePathSegment(presentationID), + ) + query := larkcore.QueryParams{} + body := map[string]interface{}{} + if len(slideIDs) > 0 { + body["slide_ids"] = slideIDs + } + if len(slideNumbers) > 0 { + body["slide_numbers"] = slideNumbers + } + data, err := runtime.DoAPIJSONWithLogID("POST", url, query, body) + if err != nil { + return err + } + + saved, err := saveSlideScreenshots(data, safeOutputDir) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "xml_presentation_id": presentationID, + "output_dir": outputDir, + "screenshots": saved, + }, nil) + return nil + }, +} + +func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI { + content := runtime.Str("content") + if strings.TrimSpace(content) == "" { + return common.NewDryRunAPI().Set("error", "--content cannot be empty") + } + if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 { + return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number") + } + if runtime.Changed("presentation") { + return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content") + } + dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file") + dry.POST("/open-apis/slides_ai/v1/slide_image/render"). + Body(map[string]interface{}{ + "content": fmt.Sprintf("", len(content)), + }) + return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution") +} + +func executeRenderScreenshot(runtime *common.RuntimeContext) error { + content := runtime.Str("content") + if strings.TrimSpace(content) == "" { + return common.FlagErrorf("--content cannot be empty") + } + if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 { + return common.FlagErrorf("--content cannot be used with --slide-id or --slide-number") + } + if runtime.Changed("presentation") { + return common.FlagErrorf("--presentation cannot be used with --content") + } + outputDir := runtime.Str("output-dir") + safeOutputDir, err := ensureScreenshotOutputDir(outputDir) + if err != nil { + return err + } + + data, err := runtime.DoAPIJSONWithLogID("POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{ + "content": content, + }) + if err != nil { + return err + } + saved, err := saveRenderedSlideScreenshot(data, safeOutputDir, runtime.Str("output-name")) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "output_dir": outputDir, + "screenshots": saved, + }, nil) + return nil +} + +func normalizeSlideIDs(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, v := range values { + s := strings.TrimSpace(v) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +func normalizeSlideNumbers(values []int) ([]int, error) { + out := make([]int, 0, len(values)) + seen := map[int]struct{}{} + for _, n := range values { + if n < 1 { + return nil, common.FlagErrorf("--slide-number must be a positive integer") + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out, nil +} + +func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool { + return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 +} + +func validateScreenshotOutputDir(outputDir string) (string, error) { + safeProbe, err := validate.SafeOutputPath(filepath.Join(outputDir, "probe.png")) + if err != nil { + return "", common.FlagErrorf("--output-dir invalid: %v", err) + } + return filepath.Dir(safeProbe), nil +} + +func ensureScreenshotOutputDir(outputDir string) (string, error) { + safeOutputDir, err := validateScreenshotOutputDir(outputDir) + if err != nil { + return "", err + } + if err := vfs.MkdirAll(safeOutputDir, 0o755); err != nil { + return "", output.Errorf(output.ExitAPI, "io_error", "create output directory %s: %v", outputDir, err) + } + return safeOutputDir, nil +} + +func saveSlideScreenshots(data map[string]interface{}, outputDir string) ([]map[string]interface{}, error) { + items := common.GetSlice(data, "slide_images") + if len(items) == 0 { + return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images") + } + saved := make([]map[string]interface{}, 0, len(items)) + for i, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i) + } + item, err := saveSlideScreenshotImage(m, outputDir, "", slideScreenshotFallbackName(m, i)) + if err != nil { + if _, ok := err.(*output.ExitError); ok { + return nil, err + } + return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err) + } + saved = append(saved, item) + } + return saved, nil +} + +func saveRenderedSlideScreenshot(data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) { + item := common.GetMap(data, "slide_image") + if item == nil { + return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image") + } + saved, err := saveSlideScreenshotImage(item, outputDir, outputName, "rendered-slide") + if err != nil { + if _, ok := err.(*output.ExitError); ok { + return nil, err + } + return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err) + } + return []map[string]interface{}{saved}, nil +} + +func saveSlideScreenshotImage(item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) { + slideID := strings.TrimSpace(common.GetString(item, "slide_id")) + ext, label, err := slideScreenshotFormat(item) + if err != nil { + if slideID != "" { + return nil, fmt.Errorf("%v for slide %s", err, slideID) + } + return nil, err + } + encoded := strings.TrimSpace(common.GetString(item, "data")) + if encoded == "" { + if slideID != "" { + return nil, fmt.Errorf("empty image data for slide %s", slideID) + } + return nil, fmt.Errorf("empty image data") + } + imageBytes, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + if slideID != "" { + return nil, fmt.Errorf("decode screenshot for slide %s: %v", slideID, err) + } + return nil, fmt.Errorf("decode screenshot: %v", err) + } + fileBase := strings.TrimSpace(outputName) + if fileBase == "" { + fileBase = slideID + } + if fileBase == "" { + fileBase = fallbackName + } + path := filepath.Join(outputDir, safeScreenshotFileName(fileBase, ext)) + if err := vfs.WriteFile(path, imageBytes, 0o644); err != nil { + return nil, output.Errorf(output.ExitAPI, "io_error", "write screenshot %s: %v", path, err) + } + return map[string]interface{}{ + "slide_id": slideID, + "slide_number": common.GetInt(item, "slide_number"), + "format": label, + "path": path, + "size": len(imageBytes), + }, nil +} + +func slideScreenshotFallbackName(item map[string]interface{}, index int) string { + if slideNumber := common.GetInt(item, "slide_number"); slideNumber > 0 { + return fmt.Sprintf("slide-%d", slideNumber) + } + return fmt.Sprintf("slide-%d", index+1) +} + +func slideScreenshotFormat(item map[string]interface{}) (string, string, error) { + format := common.GetInt(item, "format") + switch format { + case 1: + return "png", "png", nil + case 2: + return "jpg", "jpeg", nil + default: + return "", "", fmt.Errorf("unsupported screenshot format %d", format) + } +} + +func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error { + msg := fmt.Sprintf(format, args...) + detail := map[string]interface{}{ + "raw_data": summarizeScreenshotAPIData(data), + } + if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" { + detail["log_id"] = logID + } + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "api_error", + Message: msg, + Detail: detail, + }, + } +} + +func summarizeScreenshotAPIData(v interface{}) interface{} { + switch x := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(x)) + for k, val := range x { + out[k] = summarizeScreenshotAPIData(val) + } + return out + case []interface{}: + out := make([]interface{}, 0, len(x)) + for i, val := range x { + if i >= 20 { + out = append(out, fmt.Sprintf("", len(x)-i)) + break + } + out = append(out, summarizeScreenshotAPIData(val)) + } + return out + case string: + if len(x) > 512 { + return fmt.Sprintf("", len(x), x[:64]) + } + return x + default: + return x + } +} + +func safeScreenshotFileName(base string, ext string) string { + name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_") + name = strings.Trim(name, "._-") + if name == "" { + name = "slide" + } + return name + "." + ext +} diff --git a/shortcuts/slides/slides_screenshot_test.go b/shortcuts/slides/slides_screenshot_test.go new file mode 100644 index 000000000..447752bc4 --- /dev/null +++ b/shortcuts/slides/slides_screenshot_test.go @@ -0,0 +1,403 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestSlidesScreenshotDeclaredScopes(t *testing.T) { + got := SlidesScreenshot.DeclaredScopesForIdentity("user") + if len(got) != 1 || got[0] != "slides:presentation:screenshot" { + t.Fatalf("declared scopes = %#v, want [slides:presentation:screenshot]", got) + } +} + +func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + imageBytes := []byte("png-bytes") + jpegBytes := []byte("jpeg-bytes") + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "slide_images": []map[string]interface{}{ + { + "slide_id": "slide_1", + "format": 1, + "data": base64.StdEncoding.EncodeToString(imageBytes), + }, + { + "slide_id": "slide_2", + "slide_number": 2, + "format": 2, + "data": base64.StdEncoding.EncodeToString(jpegBytes), + }, + }, + }, + }, + } + reg.Register(stub) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-id", "slide_1", + "--output-dir", "shots", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + path := filepath.Join(dir, "shots", "slide_1.png") + gotBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read screenshot: %v", err) + } + if string(gotBytes) != string(imageBytes) { + t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes) + } + jpegPath := filepath.Join(dir, "shots", "slide_2.jpg") + gotJPEGBytes, err := os.ReadFile(jpegPath) + if err != nil { + t.Fatalf("read jpeg screenshot: %v", err) + } + if string(gotJPEGBytes) != string(jpegBytes) { + t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes) + } + if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) { + t.Fatalf("stdout leaked base64 image data: %s", stdout.String()) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"]) + } + items, ok := data["screenshots"].([]interface{}) + if !ok || len(items) != 2 { + t.Fatalf("screenshots = %#v, want two items", data["screenshots"]) + } + item, _ := items[0].(map[string]interface{}) + if item["slide_id"] != "slide_1" { + t.Fatalf("slide_id = %v, want slide_1", item["slide_id"]) + } + if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "slide_1.png")) { + t.Fatalf("path = %v, want shots/slide_1.png suffix", item["path"]) + } + item2, _ := items[1].(map[string]interface{}) + if item2["format"] != "jpeg" { + t.Fatalf("format = %v, want jpeg", item2["format"]) + } + if !strings.HasSuffix(item2["path"].(string), filepath.Join("shots", "slide_2.jpg")) { + t.Fatalf("path = %v, want shots/slide_2.jpg suffix", item2["path"]) + } + + var body struct { + SlideIDs []string `json:"slide_ids"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" { + t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs) + } +} + +func TestSlidesScreenshotListBySlideNumber(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "slide_images": []map[string]interface{}{ + { + "slide_number": 2, + "format": 1, + "data": base64.StdEncoding.EncodeToString([]byte("png-bytes")), + }, + }, + }, + }, + } + reg.Register(stub) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-number", "2", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body struct { + SlideNumbers []int `json:"slide_numbers"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 { + t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers) + } + path := filepath.Join(dir, defaultSlidesScreenshotDir, "slide-2.png") + if _, err := os.ReadFile(path); err != nil { + t.Fatalf("read screenshot without slide_id: %v", err) + } +} + +func TestSlidesScreenshotListRequiresSelector(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") { + t.Fatalf("error = %v, want missing selector error", err) + } +} + +func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + content := `` + if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil { + t.Fatalf("write input xml: %v", err) + } + imageBytes := []byte("rendered-png") + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/slide_image/render", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "slide_image": map[string]interface{}{ + "slide_id": "render_slide", + "slide_number": 1, + "format": 1, + "data": base64.StdEncoding.EncodeToString(imageBytes), + }, + }, + }, + } + reg.Register(stub) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", "@slide.xml", + "--output-dir", "shots", + "--output-name", "preview", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + path := filepath.Join(dir, "shots", "preview.png") + gotBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read rendered screenshot: %v", err) + } + if string(gotBytes) != string(imageBytes) { + t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes) + } + if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) { + t.Fatalf("stdout leaked base64 image data: %s", stdout.String()) + } + + var body struct { + Content string `json:"content"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if body.Content != content { + t.Fatalf("content = %q, want input XML", body.Content) + } + + data := decodeShortcutData(t, stdout) + items, ok := data["screenshots"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("screenshots = %#v, want one item", data["screenshots"]) + } + item, _ := items[0].(map[string]interface{}) + if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) { + t.Fatalf("path = %v, want shots/preview.png suffix", item["path"]) + } +} + +func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", ``, + "--slide-id", "slide_1", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") { + t.Fatalf("error = %v, want content/slide selector conflict", err) + } +} + +func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", ``, + "--presentation", "pres_abc", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--presentation cannot be used with --content") { + t.Fatalf("error = %v, want presentation/content conflict", err) + } +} + +func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) { + t.Run("list", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-number", "2", + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") { + t.Fatalf("dry-run missing list endpoint: %s", out) + } + if !strings.Contains(out, "slide_numbers") { + t.Fatalf("dry-run missing slide_numbers body: %s", out) + } + }) + + t.Run("render", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", ``, + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/slide_image/render") { + t.Fatalf("dry-run missing render endpoint: %s", out) + } + if !strings.Contains(out, "base64_output") { + t.Fatalf("dry-run missing base64 suppression note: %s", out) + } + }) +} + +func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-id", "slide_1", + "--output-dir", "../outside", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for unsafe output dir") + } + if !strings.Contains(err.Error(), "--output-dir invalid") { + t.Fatalf("error = %v, want output-dir validation", err) + } +} + +func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"log-123"}, + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "unexpected": "shape", + }, + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-id", "pJJ", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("error type = %T, want ExitError", err) + } + if exitErr.Detail == nil || exitErr.Detail.Detail == nil { + t.Fatalf("missing error detail: %+v", exitErr) + } + detail, ok := exitErr.Detail.Detail.(map[string]interface{}) + if !ok { + t.Fatalf("detail = %#v, want map", exitErr.Detail.Detail) + } + if detail["log_id"] != "log-123" { + t.Fatalf("log_id = %v, want log-123", detail["log_id"]) + } + raw, ok := detail["raw_data"].(map[string]interface{}) + if !ok { + t.Fatalf("raw_data = %#v, want map", detail["raw_data"]) + } + if raw["unexpected"] != "shape" { + t.Fatalf("raw_data.unexpected = %v, want shape", raw["unexpected"]) + } +} diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 749e76191..07747178c 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-slides version: 1.0.0 -description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" +description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、获取幻灯片截图、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] @@ -18,6 +18,7 @@ metadata: | 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` | | 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` | | 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` | +| 获取幻灯片页面截图 | 先确认 `slide_id` 列表,再用 shortcut 保存本地图片;不要把 Base64 输出给模型 | `slides +screenshot`、`lark-slides-screenshot.md` | | 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 | | 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `` 元素 | `xml-schema-quick-ref.md` | | 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) | @@ -81,6 +82,7 @@ lark-cli auth login --domain slides - 创建:[`lark-slides-create.md`](references/lark-slides-create.md) - 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md) +- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md) - 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md) - 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) - 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py) diff --git a/skills/lark-slides/references/lark-slides-screenshot.md b/skills/lark-slides/references/lark-slides-screenshot.md new file mode 100644 index 000000000..1cc6dc0ff --- /dev/null +++ b/skills/lark-slides/references/lark-slides-screenshot.md @@ -0,0 +1,104 @@ +# slides +screenshot + +## 用途 + +获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图,底层调用 `xml_presentation.slide_image.list`;传入 `--content` 时用于直接渲染单个 `` XML 片段预览,底层调用 `xml_presentation.slide_image.render`。两个 API 都返回 Base64 图片内容;本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。 + +## 命令 + +```bash +lark-cli slides +screenshot --as user \ + --presentation '' \ + --slide-id '' +``` + +渲染本地 XML 内容: + +```bash +lark-cli slides +screenshot --as user \ + --content @slide.xml +``` + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 | +| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入 | +| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 | +| `--content` | render 模式必需 | 要直接渲染的 `` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` | +| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 | +| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide` | + +## 示例 + +### 单页截图 + +```bash +lark-cli slides +screenshot --as user \ + --presentation slides_example_presentation_id \ + --slide-id slide_example_id +``` + +### 多页截图 + +```bash +lark-cli slides +screenshot --as user \ + --presentation slides_example_presentation_id \ + --slide-id slide_1 \ + --slide-id slide_2 \ + --output-dir .lark-slides/screenshots/demo +``` + +### 按页号截图 + +```bash +lark-cli slides +screenshot --as user \ + --presentation slides_example_presentation_id \ + --slide-number 1 \ + --slide-number 2 +``` + +### 渲染 XML 预览 + +```bash +lark-cli slides +screenshot --as user \ + --content @.lark-slides/out/demo/slide.xml \ + --output-name preview +``` + +## 返回值 + +返回 JSON 不包含 Base64 图片内容: + +```json +{ + "code": 0, + "data": { + "xml_presentation_id": "slides_example_presentation_id", + "output_dir": ".lark-slides/screenshots", + "screenshots": [ + { + "slide_id": "slide_example_id", + "slide_number": 1, + "format": "png", + "path": "/abs/path/.lark-slides/screenshots/slide_example_id.png", + "size": 12345 + } + ] + }, + "msg": "success" +} +``` + +## 获取 slide_id + +不知道页面 ID,传 slide id 即可。 + +## 注意事项 + +1. 优先使用 `slides +screenshot`,不要直接调用 `xml_presentation.slide_image.list` / `xml_presentation.slide_image.render` 后把 Base64 打到 stdout。 +2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。 +3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。 +4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。 +5. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。