From 96649585569fd34d9d9611999d826bf1e25e7090 Mon Sep 17 00:00:00 2001 From: "caichengjie.viper" Date: Tue, 14 Apr 2026 23:36:51 +0800 Subject: [PATCH] feat(slides): add image upload via +media-upload and @path placeholders in +create - New `slides +media-upload` shortcut: upload a local image to a slides presentation and return the file_token for use in . - `slides +create --slides` now supports `@./path.png` placeholders that are auto-uploaded and replaced with file_tokens. - Reject images >20 MB (multipart upload not supported for slide_file). - Support wiki URL resolution for --presentation flag. --- shortcuts/slides/helpers.go | 177 +++++++++ shortcuts/slides/helpers_test.go | 191 ++++++++++ shortcuts/slides/shortcuts.go | 1 + shortcuts/slides/slides_create.go | 91 ++++- shortcuts/slides/slides_create_test.go | 173 +++++++++ shortcuts/slides/slides_media_upload.go | 151 ++++++++ shortcuts/slides/slides_media_upload_test.go | 359 ++++++++++++++++++ skills/lark-slides/SKILL.md | 48 ++- .../references/lark-slides-create.md | 38 ++ .../references/lark-slides-media-upload.md | 143 +++++++ ...rk-slides-xml-presentation-slide-create.md | 5 + .../lark-slides/references/slide-templates.md | 100 +++++ .../references/xml-format-guide.md | 14 + .../references/xml-schema-quick-ref.md | 4 + 14 files changed, 1487 insertions(+), 8 deletions(-) create mode 100644 shortcuts/slides/helpers.go create mode 100644 shortcuts/slides/helpers_test.go create mode 100644 shortcuts/slides/slides_media_upload.go create mode 100644 shortcuts/slides/slides_media_upload_test.go create mode 100644 skills/lark-slides/references/lark-slides-media-upload.md diff --git a/shortcuts/slides/helpers.go b/shortcuts/slides/helpers.go new file mode 100644 index 00000000..8bdecbda --- /dev/null +++ b/shortcuts/slides/helpers.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// presentationRef holds a parsed --presentation input. +// +// Slides shortcuts accept three input shapes: +// - a raw xml_presentation_id token +// - a slides URL like https:///slides/ +// - a wiki URL like https:///wiki/ (must resolve to obj_type=slides) +type presentationRef struct { + Kind string // "slides" | "wiki" + Token string +} + +// parsePresentationRef extracts a presentation token from a token, slides URL, or wiki URL. +// Wiki tokens are returned unresolved; callers must run resolveWikiToSlidesToken to +// obtain the real xml_presentation_id and verify obj_type=slides. +func parsePresentationRef(input string) (presentationRef, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return presentationRef{}, output.ErrValidation("--presentation cannot be empty") + } + // URL inputs: parse properly and only honor /slides/ or /wiki/ when they + // appear as a prefix of the URL path. Substring matching previously let + // e.g. `https://x/docx/foo?next=/slides/abc` resolve to token "abc". + if strings.Contains(raw, "://") { + u, err := url.Parse(raw) + if err != nil || u.Path == "" { + return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw) + } + if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok { + return presentationRef{Kind: "slides", Token: token}, nil + } + if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok { + return presentationRef{Kind: "wiki", Token: token}, nil + } + return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw) + } + // Non-URL input must be a bare token — anything with path/query/fragment + // chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't + // get silently accepted. + if strings.ContainsAny(raw, "/?#") { + return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw) + } + return presentationRef{Kind: "slides", Token: raw}, nil +} + +// tokenAfterPathPrefix extracts the first path segment after prefix from path. +// Returns ("", false) if path doesn't start with prefix or the segment is empty. +func tokenAfterPathPrefix(path, prefix string) (string, bool) { + if !strings.HasPrefix(path, prefix) { + return "", false + } + rest := path[len(prefix):] + if i := strings.IndexByte(rest, '/'); i >= 0 { + rest = rest[:i] + } + rest = strings.TrimSpace(rest) + if rest == "" { + return "", false + } + return rest, true +} + +// resolvePresentationID resolves a parsed ref into an xml_presentation_id. +// Slides refs pass through; wiki refs are looked up via wiki.spaces.get_node and +// must resolve to obj_type=slides. +func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef) (string, error) { + switch ref.Kind { + case "slides": + return ref.Token, nil + case "wiki": + data, err := runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": ref.Token}, + nil, + ) + if err != nil { + return "", err + } + node := common.GetMap(data, "node") + objType := common.GetString(node, "obj_type") + objToken := common.GetString(node, "obj_token") + if objType == "" || objToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + } + if objType != "slides" { + return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType) + } + return objToken, nil + default: + return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind) + } +} + +// imgSrcPlaceholderRegex matches `src="@"` or `src='@'` inside tags. +// The "@" prefix is the magic marker for "this is a local file path; upload it and +// replace with file_token". +// +// Match groups: +// +// 1: opening quote character (so we can replace symmetrically) +// 2: the path string (everything inside the quotes after the leading @) +// +// We deliberately scope to rather than any src= so other +// schema elements (like icon/iconType) aren't accidentally rewritten. +// `\s*=\s*` tolerates `src = "..."` style attributes (XML allows whitespace +// around `=`); without it we'd silently leave such placeholders unrewritten. +var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`) + +// extractImagePlaceholderPaths returns the de-duplicated list of local paths +// referenced via in the given slide XML strings. +// +// Order is preserved (first occurrence wins) so dry-run / progress messages are +// stable across runs. +func extractImagePlaceholderPaths(slideXMLs []string) []string { + var paths []string + seen := map[string]bool{} + for _, xml := range slideXMLs { + matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1) + for _, m := range matches { + if m[1] != m[3] { + // Mismatched opening/closing quotes — Go's RE2 has no backreferences, + // so we filter it here. Treat as malformed XML and skip. + continue + } + path := strings.TrimSpace(m[2]) + if path == "" || seen[path] { + continue + } + seen[path] = true + paths = append(paths, path) + } + } + return paths +} + +// replaceImagePlaceholders rewrites occurrences in the input +// XML by looking up each path in tokens. Paths missing from the map are left +// untouched (callers should ensure the map is complete). +func replaceImagePlaceholders(slideXML string, tokens map[string]string) string { + return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string { + sub := imgSrcPlaceholderRegex.FindStringSubmatch(match) + if len(sub) < 4 { + return match + } + quote, path, closeQuote := sub[1], sub[2], sub[3] + if quote != closeQuote { + // Mismatched quotes — see extractImagePlaceholderPaths. + return match + } + token, ok := tokens[strings.TrimSpace(path)] + if !ok { + return match + } + // Replace only the `"@"` segment (quotes inclusive) so any + // surrounding attrs and whitespace around `=` stay intact. Looking up + // by the literal `@"` (with closing quote) avoids accidentally + // matching the same path elsewhere in the tag. + oldQuoted := fmt.Sprintf("%s@%s%s", quote, path, closeQuote) + newQuoted := fmt.Sprintf("%s%s%s", quote, token, closeQuote) + return strings.Replace(match, oldQuoted, newQuoted, 1) + }) +} diff --git a/shortcuts/slides/helpers_test.go b/shortcuts/slides/helpers_test.go new file mode 100644 index 00000000..83db445b --- /dev/null +++ b/shortcuts/slides/helpers_test.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "reflect" + "strings" + "testing" +) + +func TestParsePresentationRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantKind string + wantToken string + wantErr string + }{ + {name: "raw token", input: "slidesXXXXXXXXXXXXXXXXXXXXXX", wantKind: "slides", wantToken: "slidesXXXXXXXXXXXXXXXXXXXXXX"}, + {name: "slides URL", input: "https://x.feishu.cn/slides/abc123", wantKind: "slides", wantToken: "abc123"}, + {name: "slides URL with query", input: "https://x.feishu.cn/slides/abc123?from=share", wantKind: "slides", wantToken: "abc123"}, + {name: "slides URL with anchor", input: "https://x.feishu.cn/slides/abc123#p1", wantKind: "slides", wantToken: "abc123"}, + {name: "wiki URL", input: "https://x.feishu.cn/wiki/wikcn123", wantKind: "wiki", wantToken: "wikcn123"}, + {name: "trims whitespace", input: " abc123 ", wantKind: "slides", wantToken: "abc123"}, + {name: "empty", input: "", wantErr: "cannot be empty"}, + {name: "blank", input: " ", wantErr: "cannot be empty"}, + {name: "unsupported url", input: "https://x.feishu.cn/docx/foo", wantErr: "unsupported"}, + {name: "unsupported path", input: "foo/bar", wantErr: "unsupported"}, + // Regression: /slides/ inside a query string must NOT be treated as a slides marker. + {name: "slides marker inside query", input: "https://x.feishu.cn/docx/foo?next=/slides/abc", wantErr: "unsupported"}, + // Regression: /wiki/ as a path segment but not a prefix must not match. + {name: "wiki marker mid-path", input: "https://x.feishu.cn/docx/wiki/wikcn123", wantErr: "unsupported"}, + // Regression: bare relative path containing wiki/ is not a wiki ref. + {name: "non-url wiki segment", input: "tmp/wiki/wikcn123", wantErr: "unsupported"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parsePresentationRef(tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tt.wantKind || got.Token != tt.wantToken { + t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken) + } + }) + } +} + +func TestExtractImagePlaceholderPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in []string + want []string + }{ + { + name: "no placeholders", + in: []string{``}, + want: nil, + }, + { + name: "single placeholder", + in: []string{``}, + want: []string{"./pic.png"}, + }, + { + name: "single quotes", + in: []string{``}, + want: []string{"./a.png"}, + }, + { + name: "dedup across slides", + in: []string{ + ``, + ``, + }, + want: []string{"./shared.png", "./other.png"}, + }, + { + name: "ignores non-img src", + in: []string{``}, + want: []string{"./real.png"}, + }, + { + name: "preserves order of first occurrence", + in: []string{``}, + want: []string{"b.png", "a.png"}, + }, + { + // Regression: Go RE2 has no backreferences, so the regex captures + // opening and closing quotes independently. Mismatched pairs must + // be filtered out post-match instead of producing bogus paths. + name: "rejects mismatched quotes", + in: []string{``}, + want: []string{"./spaced.png"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractImagePlaceholderPaths(tt.in) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestReplaceImagePlaceholders(t *testing.T) { + t.Parallel() + + tokens := map[string]string{ + "./pic.png": "tok_abc", + "./b.png": "tok_b", + } + + tests := []struct { + name string + in string + want string + }{ + { + name: "single replacement preserves siblings", + in: ``, + want: ``, + }, + { + name: "multiple replacements", + in: ``, + want: ``, + }, + { + name: "single quotes", + in: ``, + want: ``, + }, + { + name: "leaves unknown placeholder untouched", + in: ``, + want: ``, + }, + { + name: "leaves http url alone", + in: ``, + want: ``, + }, + { + name: "leaves bare token alone", + in: ``, + want: ``, + }, + { + // Regression: placeholders with whitespace around `=` must be + // rewritten too (XML permits the form). Surrounding whitespace + // is preserved so the rewritten attribute reads naturally. + name: "tolerates whitespace around equals", + in: ``, + want: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := replaceImagePlaceholders(tt.in, tokens) + if got != tt.want { + t.Fatalf("got %q\nwant %q", got, tt.want) + } + }) + } +} diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index 19ad2449..3de3fdf8 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -9,5 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ SlidesCreate, + SlidesMediaUpload, } } diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go index 6ec5b3dc..f0e6a79e 100644 --- a/shortcuts/slides/slides_create.go +++ b/shortcuts/slides/slides_create.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "strings" "github.com/larksuite/cli/internal/output" @@ -27,10 +28,15 @@ var SlidesCreate = common.Shortcut{ Description: "Create a Lark Slides presentation", Risk: "write", AuthTypes: []string{"user", "bot"}, - Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"}, + // docs:document.media:upload is required by the @-placeholder upload path. + // Declared up-front (matching the convention used by other multi-API shortcuts + // like wiki_move) so the pre-flight check fails fast and lark-cli's + // auth login --scope hint guides the user, instead of leaving an orphaned + // empty presentation when the in-flight upload 403s. + Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"}, Flags: []common.Flag{ {Name: "title", Desc: "presentation title"}, - {Name: "slides", Desc: "slide content JSON array (each element is a XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"}, + {Name: "slides", Desc: "slide content JSON array (each element is a XML string, max 10; for more pages, create first then add via xml_presentation.slide.create). placeholders are auto-uploaded and replaced with file_token."}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if slidesStr := runtime.Str("slides"); slidesStr != "" { @@ -41,6 +47,21 @@ var SlidesCreate = common.Shortcut{ if len(slides) > maxSlidesPerCreate { return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate) } + // Validate placeholder paths up front so we don't create a presentation + // only to fail mid-way on a missing local file. + for _, path := range extractImagePlaceholderPaths(slides) { + stat, err := runtime.FileIO().Stat(path) + if err != nil { + return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path)) + } + if !stat.Mode().IsRegular() { + return common.FlagErrorf("--slides @%s: must be a regular file", path) + } + if stat.Size() > common.MaxDriveMediaUploadSinglePartSize { + return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload", + path, common.FormatSize(stat.Size())) + } + } } return nil }, @@ -61,16 +82,32 @@ var SlidesCreate = common.Shortcut{ var slides []string _ = json.Unmarshal([]byte(slidesStr), &slides) n := len(slides) - total := n + 1 + placeholders := extractImagePlaceholderPaths(slides) + total := n + 1 + len(placeholders) - dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)). + descSuffix := "" + if len(placeholders) > 0 { + descSuffix = fmt.Sprintf(" + upload %d image(s)", len(placeholders)) + } + dry.Desc(fmt.Sprintf("Create presentation%s + add %d slide(s)", descSuffix, n)). POST("/open-apis/slides_ai/v1/xml_presentations"). Desc(fmt.Sprintf("[1/%d] Create presentation", total)). Body(createBody) + // Upload steps come right after creation so they can use the new + // presentation_id as parent_node. + for i, path := range placeholders { + appendSlidesUploadDryRun(dry, path, "", i+2) + } + + slideStepStart := 2 + len(placeholders) + slideDescSuffix := "" + if len(placeholders) > 0 { + slideDescSuffix = " (img placeholders auto-replaced)" + } for i, slideXML := range slides { dry.POST("/open-apis/slides_ai/v1/xml_presentations//slide"). - Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)). + Desc(fmt.Sprintf("[%d/%d] Add slide %d%s", slideStepStart+i, total, i+1, slideDescSuffix)). Body(map[string]interface{}{ "slide": map[string]interface{}{"content": slideXML}, }) @@ -121,6 +158,23 @@ var SlidesCreate = common.Shortcut{ _ = json.Unmarshal([]byte(slidesStr), &slides) // already validated if len(slides) > 0 { + // Step 1.5: Upload any @path placeholders, then rewrite slide XML + // with the resulting file_tokens. Uploads run after creation so + // they can use the new presentation_id as parent_node. + placeholders := extractImagePlaceholderPaths(slides) + if len(placeholders) > 0 { + tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", + "image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)", + err, presentationID, uploaded) + } + for i := range slides { + slides[i] = replaceImagePlaceholders(slides[i], tokens) + } + result["images_uploaded"] = uploaded + } + slideURL := fmt.Sprintf( "/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID), @@ -205,6 +259,33 @@ func buildPresentationXML(title string) string { ) } +// uploadSlidesPlaceholders uploads each unique placeholder path against the +// presentation and returns the path→file_token map. The second return value is +// the number of files successfully uploaded before any error, so callers can +// surface progress in the failure message. +func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID string, paths []string) (map[string]string, int, error) { + tokens := make(map[string]string, len(paths)) + for i, path := range paths { + stat, err := runtime.FileIO().Stat(path) + if err != nil { + return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path)) + } + if !stat.Mode().IsRegular() { + return tokens, i, output.ErrValidation("@%s: must be a regular file", path) + } + fileName := filepath.Base(path) + fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n", + i+1, len(paths), fileName, common.FormatSize(stat.Size())) + + token, err := uploadSlidesMedia(runtime, path, fileName, stat.Size(), presentationID) + if err != nil { + return tokens, i, fmt.Errorf("@%s: %w", path, err) + } + tokens[path] = token + } + return tokens, len(paths), nil +} + // xmlEscape escapes special XML characters in text content. func xmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go index 4e4a9a29..1ba2ab11 100644 --- a/shortcuts/slides/slides_create_test.go +++ b/shortcuts/slides/slides_create_test.go @@ -6,6 +6,7 @@ package slides import ( "bytes" "encoding/json" + "os" "strings" "testing" @@ -651,3 +652,175 @@ func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]i } return data } + +// TestSlidesCreateWithImagePlaceholders verifies @path placeholders are uploaded +// once each (with dedup) and replaced with file_tokens before slide.create runs. +// +// Not parallel: uses os.Chdir to pin local file paths to a temp dir. +func TestSlidesCreateWithImagePlaceholders(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("a.png", []byte("aa"), 0o644); err != nil { + t.Fatalf("write a.png: %v", err) + } + if err := os.WriteFile("b.png", []byte("bb"), 0o644); err != nil { + t.Fatalf("write b.png: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation_id": "pres_img", + "revision_id": 1, + }, + }, + }) + + // Two distinct images → two upload calls. a.png is referenced twice but + // must be uploaded only once. + uploadStubA := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_a"}}, + } + uploadStubB := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_b"}}, + } + reg.Register(uploadStubA) + reg.Register(uploadStubB) + + // Slide stubs: capture the rewritten slide content to assert tokens were + // actually substituted into the XML. + slideStub1 := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s1", "revision_id": 2}}, + } + slideStub2 := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s2", "revision_id": 3}}, + } + reg.Register(slideStub1) + reg.Register(slideStub2) + registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img") + + slidesJSON := `[ + "", + "" + ]` + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Img test", + "--slides", slidesJSON, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + if data["images_uploaded"] != float64(2) { + t.Fatalf("images_uploaded = %v, want 2 (a.png deduped)", data["images_uploaded"]) + } + if data["slides_added"] != float64(2) { + t.Fatalf("slides_added = %v, want 2", data["slides_added"]) + } + + // Assert each slide.create body uses tokens (not @path placeholders), and + // that both upload tokens reach at least one slide so a buggy mapping + // where `@b.png` got rewritten to `tok_a` would still fail. + hasTokB := false + for _, stub := range []*httpmock.Stub{slideStub1, slideStub2} { + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode slide body: %v", err) + } + slide, _ := body["slide"].(map[string]interface{}) + content, _ := slide["content"].(string) + if strings.Contains(content, "@a.png") || strings.Contains(content, "@b.png") { + t.Fatalf("slide content still contains placeholder: %s", content) + } + if !strings.Contains(content, "tok_a") { + t.Fatalf("slide content missing tok_a: %s", content) + } + if strings.Contains(content, "tok_b") { + hasTokB = true + } + } + if !hasTokB { + t.Fatal("expected at least one slide body to contain tok_b") + } +} + +// TestSlidesCreatePlaceholderFileMissing verifies validation rejects a missing local file +// up front, before the presentation is created. +func TestSlidesCreatePlaceholderFileMissing(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + // No HTTP mocks registered — Validate must reject before any API call. + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + slidesJSON := `[""]` + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "missing img", + "--slides", slidesJSON, + "--as", "user", + }) + if err == nil { + t.Fatal("expected validation error for missing placeholder file") + } + if !strings.Contains(err.Error(), "missing.png") { + t.Fatalf("err = %v, want mention of missing.png", err) + } +} + +// TestSlidesCreateWithPlaceholdersDryRun verifies dry-run lists upload steps +// with placeholder files counted into the total. +func TestSlidesCreateWithPlaceholdersDryRun(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("p1.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile("p2.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + slidesJSON := `[""]` + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "dry imgs", + "--slides", slidesJSON, + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + // Bookend step markers: [1/4] = create presentation, [4/4] = add slide 1. + // Upload steps in between use the helper's own [N] labels (no /total). + for _, marker := range []string{"[1/4]", "[4/4]"} { + if !strings.Contains(out, marker) { + t.Fatalf("dry-run missing %s, got: %s", marker, out) + } + } + if strings.Count(out, "upload_all") != 2 { + t.Fatalf("dry-run should contain 2 upload_all calls, got: %s", out) + } + if !strings.Contains(out, slidesMediaParentType) { + t.Fatalf("dry-run missing parent_type %q, got: %s", slidesMediaParentType, out) + } + if !strings.Contains(out, "Create presentation + upload 2 image(s)") { + t.Fatalf("dry-run header should describe upload count, got: %s", out) + } +} diff --git a/shortcuts/slides/slides_media_upload.go b/shortcuts/slides/slides_media_upload.go new file mode 100644 index 00000000..ebf08652 --- /dev/null +++ b/shortcuts/slides/slides_media_upload.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// slidesMediaParentType is the only parent_type the slides backend accepts for +// media uploaded against an xml_presentation. Verified empirically: +// `slide_image` returns 1061001 unknown error, `slides_image` / `slides_file` +// return 1061002 params error, but `slide_file` returns a valid file_token +// that can be used as in slide XML. +// +// NOTE: `slide_file` is only accepted by the single-part upload_all endpoint. +// The multipart upload_prepare endpoint rejects it (99992402 field validation +// failed), so slides image uploads are capped at 20 MB. +const slidesMediaParentType = "slide_file" + +// SlidesMediaUpload uploads a local image to drive media against a slides +// presentation and returns the file_token. The token can be used as the value +// of in slide XML. +// +// This is the atomic building block for getting a local image into a slides +// deck. Higher-level shortcuts (e.g. +create with @path placeholders) reuse +// the same upload helpers. +var SlidesMediaUpload = common.Shortcut{ + Service: "slides", + Command: "+media-upload", + Description: "Upload a local image to a slides presentation and return the file_token (use as )", + Risk: "write", + // wiki:node:read is required by the wiki-URL resolution path. Declared + // up-front (matching the convention used by other multi-API shortcuts) so + // users without it get the standard auth login --scope hint at pre-flight. + Scopes: []string{"docs:document.media:upload", "wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local image path (max 20 MB)", Required: true}, + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + dry := common.NewDryRunAPI() + parentNode := ref.Token + stepBase := 1 + if ref.Kind == "wiki" { + parentNode = "" + stepBase = 2 + dry.Desc("2-step orchestration: resolve wiki → upload media"). + 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("Upload local file to slides presentation") + } + appendSlidesUploadDryRun(dry, filePath, parentNode, stepBase) + return dry.Set("presentation_id", ref.Token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + stat, err := runtime.FileIO().Stat(filePath) + if err != nil { + return common.WrapInputStatError(err, "file not found") + } + if !stat.Mode().IsRegular() { + return output.ErrValidation("file must be a regular file: %s", filePath) + } + + if stat.Size() > common.MaxDriveMediaUploadSinglePartSize { + return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload", + filepath.Base(filePath), common.FormatSize(stat.Size())) + } + + fileName := filepath.Base(filePath) + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> presentation %s\n", + fileName, common.FormatSize(stat.Size()), common.MaskToken(presentationID)) + + fileToken, err := uploadSlidesMedia(runtime, filePath, fileName, stat.Size(), presentationID) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "size": stat.Size(), + "presentation_id": presentationID, + }, nil) + return nil + }, +} + +// uploadSlidesMedia is the shared upload helper used by both +media-upload and +// the +create placeholder pipeline. Always uses parent_type=slide_file with the +// presentation_id as parent_node — verified to be the only working combo. +// +// Callers must ensure fileSize ≤ MaxDriveMediaUploadSinglePartSize (20 MB) +// because the multipart upload API does not accept parent_type=slide_file. +func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, presentationID string) (string, error) { + if fileSize > common.MaxDriveMediaUploadSinglePartSize { + return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload", + fileName, common.FormatSize(fileSize)) + } + parent := presentationID + return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: slidesMediaParentType, + ParentNode: &parent, + }) +} + +// appendSlidesUploadDryRun renders the upload_all step for a single file. +func appendSlidesUploadDryRun(d *common.DryRunAPI, filePath, parentNode string, step int) { + d.POST("/open-apis/drive/v1/medias/upload_all"). + Desc(fmt.Sprintf("[%d] Upload local file (max 20 MB)", step)). + Body(map[string]interface{}{ + "file_name": filepath.Base(filePath), + "parent_type": slidesMediaParentType, + "parent_node": parentNode, + "size": "", + "file": "@" + filePath, + }) +} diff --git a/shortcuts/slides/slides_media_upload_test.go b/shortcuts/slides/slides_media_upload_test.go new file mode 100644 index 00000000..c78d9ea1 --- /dev/null +++ b/shortcuts/slides/slides_media_upload_test.go @@ -0,0 +1,359 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "encoding/json" + "mime" + "mime/multipart" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// TestSlidesMediaUploadBasic verifies the happy path: token + presentation_id +// with a real (small) local file. +func TestSlidesMediaUploadBasic(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_tok_xyz"}, + }, + } + reg.Register(uploadStub) + + err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{ + "+media-upload", + "--file", "img.png", + "--presentation", "pres_abc", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeShortcutData(t, stdout) + if data["file_token"] != "file_tok_xyz" { + t.Fatalf("file_token = %v, want file_tok_xyz", data["file_token"]) + } + if data["presentation_id"] != "pres_abc" { + t.Fatalf("presentation_id = %v, want pres_abc", data["presentation_id"]) + } + if data["file_name"] != "img.png" { + t.Fatalf("file_name = %v, want img.png", data["file_name"]) + } + if data["size"] != float64(len("png-bytes")) { + t.Fatalf("size = %v, want %d", data["size"], len("png-bytes")) + } + + body := decodeMultipartBody(t, uploadStub) + if got := body.Fields["parent_type"]; got != slidesMediaParentType { + t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType) + } + if got := body.Fields["parent_node"]; got != "pres_abc" { + t.Fatalf("parent_node = %q, want pres_abc", got) + } + if got := body.Fields["file_name"]; got != "img.png" { + t.Fatalf("file_name = %q, want img.png", got) + } +} + +// TestSlidesMediaUploadFromSlidesURL verifies that a slides URL is accepted +// and the path-segment token is used as parent_node. +func TestSlidesMediaUploadFromSlidesURL(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("p.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}}, + } + reg.Register(stub) + + err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{ + "+media-upload", + "--file", "p.png", + "--presentation", "https://x.feishu.cn/slides/url_token_123?from=share", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeMultipartBody(t, stub) + if got := body.Fields["parent_node"]; got != "url_token_123" { + t.Fatalf("parent_node = %q, want url_token_123", got) + } + + data := decodeShortcutData(t, stdout) + if data["presentation_id"] != "url_token_123" { + t.Fatalf("presentation_id = %v, want url_token_123", data["presentation_id"]) + } +} + +// TestSlidesMediaUploadFromWikiURL verifies wiki URL → get_node lookup is performed +// and the resolved obj_token is used as parent_node. +func TestSlidesMediaUploadFromWikiURL(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "slides", + "obj_token": "real_pres_id", + }, + }, + }, + }) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}}, + } + reg.Register(uploadStub) + + err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{ + "+media-upload", + "--file", "w.png", + "--presentation", "https://x.feishu.cn/wiki/wikcn_xyz", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeMultipartBody(t, uploadStub) + if got := body.Fields["parent_node"]; got != "real_pres_id" { + t.Fatalf("parent_node = %q, want real_pres_id", got) + } +} + +// TestSlidesMediaUploadWikiWrongType verifies wiki resolution rejects non-slides docs. +func TestSlidesMediaUploadWikiWrongType(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "docx", + "obj_token": "docx_tok", + }, + }, + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{ + "+media-upload", + "--file", "w.png", + "--presentation", "https://x.feishu.cn/wiki/wikcn", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for non-slides wiki node") + } + if !strings.Contains(err.Error(), "docx") { + t.Fatalf("err = %v, want mention of resolved obj_type", err) + } +} + +// TestSlidesMediaUploadFileNotFound verifies a missing local file fails fast. +func TestSlidesMediaUploadFileNotFound(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{ + "+media-upload", + "--file", "missing.png", + "--presentation", "pres_abc", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") { + t.Fatalf("err = %v, want file-not-found error", err) + } +} + +// TestSlidesMediaUploadInvalidPresentation verifies validation rejects a bad ref. +func TestSlidesMediaUploadInvalidPresentation(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{ + "+media-upload", + "--file", "any.png", + "--presentation", "https://x.feishu.cn/docx/foo", + "--as", "user", + }) + if err == nil { + t.Fatal("expected validation error for unsupported presentation URL") + } + if !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("err = %v, want 'unsupported' mention", err) + } +} + +// TestSlidesMediaUploadDryRun verifies dry-run prints the upload step. +func TestSlidesMediaUploadDryRun(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("dry.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{ + "+media-upload", + "--file", "dry.png", + "--presentation", "pres_abc", + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") { + t.Fatalf("dry-run should mention upload_all, got: %s", out) + } + if !strings.Contains(out, slidesMediaParentType) { + t.Fatalf("dry-run should mention parent_type %q, got: %s", slidesMediaParentType, out) + } +} + +// runSlidesShortcut mounts and executes a slides shortcut with the given args. +func runSlidesShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, sc common.Shortcut, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "slides"} + sc.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// decodeShortcutData parses the JSON envelope and returns the data map. +func decodeShortcutData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data: %#v", envelope) + } + return data +} + +// withSlidesTestWorkingDir chdirs to dir for this test (restored on cleanup). +// Not compatible with t.Parallel — chdir is process-wide. +func withSlidesTestWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) +} + +type capturedMultipart struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipart { + t.Helper() + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content-type %q: %v", contentType, err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := capturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}} + for { + part, err := reader.NextPart() + if err != nil { + break + } + data := readAll(t, part) + if part.FileName() != "" { + body.Files[part.FormName()] = data + continue + } + body.Fields[part.FormName()] = string(data) + } + return body +} + +func readAll(t *testing.T, r interface { + Read(p []byte) (n int, err error) +}) []byte { + t.Helper() + var buf bytes.Buffer + tmp := make([]byte, 4096) + for { + n, err := r.Read(tmp) + if n > 0 { + buf.Write(tmp[:n]) + } + if err != nil { + break + } + } + return buf.Bytes() +} diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 95d792c8..98ef935a 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -83,6 +83,14 @@ Step 2: 生成大纲 → 用户确认 → 创建 - 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认 - 10 页以内:用 slides +create --slides '[...]' 一步创建 PPT 并添加所有页面 - 超过 10 页:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加 + - 含本地图片: + · 新建带图 PPT —— 在 slide XML 里写 , + +create 会自动上传并替换为 file_token(详见 lark-slides-create.md) + · 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID` + 拿到 file_token,再用它写进 slide XML 调 xml_presentation.slide.create + · 给已有页加图 —— XML API 无元素级编辑,需要整页替换;必守规则和流程见下方「给已有 PPT 的已有页加图」章节 + · 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png); + 绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行 - 每页 slide 需要完整的 XML:背景、文本、图形、配色 - 复杂元素(table、chart)需参考 XSD 原文 @@ -99,6 +107,7 @@ Step 3: 审查 & 交付 新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号: ```bash +# 追加到末尾 lark-cli slides xml_presentation.slide create \ --as user \ --params '{"xml_presentation_id":"YOUR_ID"}' \ @@ -108,8 +117,29 @@ lark-cli slides xml_presentation.slide create \ 在这里放置 shape、line、table、chart 等元素 ' '{slide:{content:$content}}')" + +# 插到指定页之前:before_slide_id 必须在 --data body 里,与 slide 同级 +# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾 +lark-cli slides xml_presentation.slide create \ + --as user \ + --params '{"xml_presentation_id":"YOUR_ID"}' \ + --data "$(jq -n --arg content '...' --arg before 'TARGET_SLIDE_ID' \ + '{slide:{content:$content}, before_slide_id:$before}')" ``` +### 给已有 PPT 的已有页加图(整页替换) + +XML API 没有元素级编辑接口(见核心规则 7)。想给某一页加图,只能**整页替换**:读原 slide → 加 `` → 原位 create 新页 → 删除旧页。 + +**必守 4 条**: + +1. **先 create 后 delete** —— 顺序反了且 create 失败会丢页 +2. **原 slide 的所有元素必须原样搬到新 XML**(标题、正文、形状、原有 img)—— 只写新 `` 会把原页其他内容全删掉 +3. **`before_slide_id=<旧 slide_id>` 必传,且必须放在 `--data` body 里**(与 `slide` 同级),**不能放在 `--params`** —— `--params` 只接 path/query 参数,body 字段塞进去会被 CLI 当未知 query 参数静默下发,服务端忽略,结果是新页追加到末尾、打乱页序。正确形状:`--data '{"slide":{"content":"..."},"before_slide_id":"<旧 slide_id>"}'` +4. **新 `` 坐标避开现有元素** —— 读原 `` 里元素的 `topLeftX/Y/width/height` 挑空白区;空间不够就先缩小/挪动现有元素再放图 + +完整 bash 模板和 `+media-upload` 参数见 [+media-upload 文档](references/lark-slides-media-upload.md)。 + ### 风格快速判断表 > **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。 @@ -229,7 +259,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides + [flags]` | Shortcut | 说明 | |----------|------| -| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面),bot 模式自动授权 | +| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `` 占位符自动上传),bot 模式自动授权 | +| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 ``),最大 20 MB | ## API Resources @@ -257,12 +288,15 @@ lark-cli slides [flags] # 调用 API 4. **文本通过 `` 表达**:必须用 `

...

`,不能把文字直接写在 shape 内 5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id` 6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片 +7. **没有元素级编辑能力**:飞书 slides XML API 只有 slide 级 `create` / `delete`,**没有更新单个 shape/img 坐标或尺寸的接口**。不要向用户承诺"微调坐标/尺寸"、"挪一下这个图"。要改只能整页重建(`xml_presentations.get` 读回 → 改 XML → `slide.delete` 旧页 + `slide.create` 新页),且 `slide_id` 会变、默认追加到末尾(要回原位需 `before_slide_id`)。整页重建前必须先告知用户代价并确认 +8. **`` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 ``」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。 ## 权限表 | 方法 | 所需 scope | |------|-----------| -| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only` | +| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload`) | +| `slides +media-upload` | `docs:document.media:upload`(wiki URL 解析还需 `wiki:node:read`) | | `xml_presentations.get` | `slides:presentation:read` | | `xml_presentation.slide.create` | `slides:presentation:update` 或 `slides:presentation:write_only` | | `xml_presentation.slide.delete` | `slides:presentation:update` 或 `slides:presentation:write_only` | @@ -278,6 +312,9 @@ lark-cli slides [flags] # 调用 API | 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 | | 403 | 权限不足 | 检查是否拥有对应的 scope | | 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 | +| 1061002 | params error(媒体上传时) | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` | +| 1061004 | forbidden:当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 | +| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 | ## 创建前自查 @@ -301,17 +338,22 @@ lark-cli slides [flags] # 调用 API | 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 | | 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 | | 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 | +| 图片被裁掉一部分 | `` 的 `width`/`height` 是裁剪后尺寸,比例和原图不一致时会自动裁剪;要整图显示就让 `width:height` 对齐原图比例 | +| 给已有页加图后,原页文字/形状消失了 | 替换整页时必须把原 slide 的 `` 所有元素原样搬过来,不能只写新 `` | | 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 | | 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) | | 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 | | API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 | | API 返回 3350001 | `xml_presentation_id` 不是通过 `xml_presentations.create` 创建的,或 token 不正确 | +| 图片不显示 / `` 仍是 `@path` | `@` 占位符**只在 `+create --slides` 中替换**;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` 写进 src | +| 上传图片报 1061002 params error | `parent_type` 必须是 `slide_file`(slides 唯一接受值);不要手拼,用 `slides +media-upload` | ## 参考文档 | 文档 | 说明 | |------|------| -| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut:创建 PPT(支持 `--slides` 一步添加页面)** | +| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut:创建 PPT(支持 `--slides` 一步添加页面,含 `@` 占位符自动上传图片)** | +| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut:上传本地图片,返回 `file_token`** | | [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** | | [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 | | [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 | diff --git a/skills/lark-slides/references/lark-slides-create.md b/skills/lark-slides/references/lark-slides-create.md index 2df2498f..ed8ea4cc 100644 --- a/skills/lark-slides/references/lark-slides-create.md +++ b/skills/lark-slides/references/lark-slides-create.md @@ -34,6 +34,7 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run - **`revision_id`**(integer):演示文稿版本号 - **`slide_ids`**(string[],可选):仅传 `--slides` 时返回,成功添加的页面 ID 列表 - **`slides_added`**(integer,可选):仅传 `--slides` 时返回,成功添加的页面数量 +- **`images_uploaded`**(integer,可选):仅 `--slides` 中含 `@<本地路径>` 占位符时返回,已上传的去重后图片数量 - **`permission_grant`**(object,可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限 > [!IMPORTANT] @@ -68,6 +69,43 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run JSON string 数组,每个元素是一页 slide 的完整 XML。CLI 内部负责包装成 API 所需的 `{"slide": {"content": "..."}}` 格式并逐页调用。 +### 本地图片:`@` 占位符 + +`` 元素的 `src` 属性如果以 `@` 开头,CLI 会把它当作本地文件路径,自动上传到当前演示文稿,并把占位符替换为返回的 `file_token`。 + +```bash +lark-cli slides +create --as user --title "图测试" --slides '[ + "" +]' +``` + +行为: + +- 路径相对于**当前工作目录**(CWD)解析;**必须是 CWD 内的相对路径**(如 `./pic.png`、`./assets/x.png`) +- 同一份图被多次引用时**只上传一次**(按路径去重) +- `src` 不以 `@` 开头的会原样保留,但**只允许写 `slides +media-upload` 拿到的 `file_token`**;**禁止写 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 通常显示破图。要用网图必须先下载到 CWD 内、再走上传流程 +- 单张图片最大 20 MB(slides upload API 不支持分片上传) +- 校验阶段就会检查所有占位符文件存在及大小;缺文件或超限直接报错,不会创建空白 PPT 占位 +- 创空白 PPT → 上传所有图 → 替换 token → 逐页创建 slide,按这个顺序执行 + +> [!IMPORTANT] +> **路径必须在 CWD 内**:`@/abs/path/x.png` 或 `@../up/x.png` 这种会被 CLI 拒绝(报 `unsafe file path`)。如果素材在别的目录,先 `cd` 过去再执行。 + +### 给已有 PPT 加带图新页 + +`+create --slides` 只在新建 PPT 时使用 `@` 占位符。给已有 PPT 加带图新页要分两步(CLI 没封装这个组合): + +```bash +# 1) 上传图片 +TOKEN=$(lark-cli slides +media-upload --as user \ + --file ./pic.png --presentation $PRES_ID | jq -r .data.file_token) + +# 2) 用返回的 file_token 创建带图新页 +lark-cli slides xml_presentation.slide create --as user \ + --params "{\"xml_presentation_id\":\"$PRES_ID\"}" \ + --data "{\"slide\":{\"content\":\"\"}}" +``` + ## 创建后续步骤 如果没有使用 `--slides`,`slides +create` 返回的 `xml_presentation_id` 用于后续操作: diff --git a/skills/lark-slides/references/lark-slides-media-upload.md b/skills/lark-slides/references/lark-slides-media-upload.md new file mode 100644 index 00000000..c29f3397 --- /dev/null +++ b/skills/lark-slides/references/lark-slides-media-upload.md @@ -0,0 +1,143 @@ + +# slides +media-upload(上传本地图片到飞书幻灯片) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +把本地图片上传到指定演示文稿的 drive 媒体库,返回 `file_token`。**返回的 token 作为 `` 的值塞进 slide XML 即可显示图片。** + +## 命令 + +```bash +# 直接传 xml_presentation_id +lark-cli slides +media-upload --as user \ + --file ./pic.png \ + --presentation slidesXXXXXXXXXXXXXXXXXXXXXX + +# 传 slides URL 也行 +lark-cli slides +media-upload --as user \ + --file ./chart.png \ + --presentation "https://xxx.feishu.cn/slides/slidesXXXXXXXXXXXXXXXXXXXXXX" + +# 传 wiki URL(CLI 自动 wiki.spaces.get_node 解析为真实 token,校验 obj_type=slides) +lark-cli slides +media-upload --as user \ + --file ./pic.png \ + --presentation "https://xxx.feishu.cn/wiki/wikcnXXXXXX" + +# 预览(不实际上传) +lark-cli slides +media-upload --file ./pic.png --presentation $PRES_ID --dry-run +``` + +## 返回值 + +```json +{ + "file_token": "boxcnXXXXXXXXXXXXXXXXXXXXXX", + "file_name": "pic.png", + "size": 12345, + "presentation_id": "slidesXXXXXXXXXXXXXXXXXXXXXX" +} +``` + +- **`file_token`**:把它写进 `` +- **`file_name` / `size`**:上传文件元信息 +- **`presentation_id`**:解析后的真实 `xml_presentation_id`(wiki URL 解析后会变化) + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file` | 是 | 本地图片路径,**必须是 CWD 内的相对路径**(如 `./pic.png`)。**最大 20 MB**(slides upload API 不支持分片上传) | +| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL,或 `/wiki/` URL | + +> [!IMPORTANT] +> **路径必须在 CWD 内**:`--file /abs/path/x.png` 或 `--file ../up/x.png` 会被 CLI 拒绝(报 `unsafe file path`)。如果素材在别的目录,先 `cd` 过去再执行。 + +## 使用流程 + +### 给已有 PPT 加带图新页 + +```bash +# 1) 上传图片 +TOKEN=$(lark-cli slides +media-upload --as user \ + --file ./pic.png \ + --presentation $PRES_ID | jq -r .data.file_token) + +# 2) 用 file_token 创建带图新页 +lark-cli slides xml_presentation.slide create --as user \ + --params "{\"xml_presentation_id\":\"$PRES_ID\"}" \ + --data "{\"slide\":{\"content\":\"\"}}" +``` + +### 新建带图 PPT(推荐用 `+create --slides` 的 `@` 占位符,一步到位) + +```bash +# 不需要单独 +media-upload,写 src="@<本地路径>" 即可 +lark-cli slides +create --as user --title "图测试" --slides '[ + "" +]' +``` + +详见 [+create 文档](lark-slides-create.md#本地图片path-占位符)。 + +### 给已有 PPT 的已有页加图(整页替换) + +> ⚠️ slides XML API 没有元素级编辑接口(见 SKILL.md 核心规则 7)—— 没法"往现有 slide 上贴一张图",只能**把整页替换**:读原 slide → 加 `` → 原位插入新页 → 删除旧页。 + +```bash +PRES_ID=xxx +TARGET_SLIDE_ID=yyy # 要加图的那一页 + +# 1) 上传图片拿 file_token +TOKEN=$(lark-cli slides +media-upload --as user \ + --file ./pic.png --presentation $PRES_ID | jq -r '.data.file_token') + +# 2) 读整份 PPT,摘出目标 slide 的完整 XML(保留所有 shape/line/img 原样) +lark-cli slides xml_presentations get --as user \ + --params "{\"xml_presentation_id\":\"$PRES_ID\"}" \ + | jq -r '.data.xml_presentation.content' + +# 3) 在 agent 侧拼新 slide XML:原有所有元素原样保留 + 新增一个 +# 关键:先看原 里现有元素的 topLeftX/Y/width/height,把 放到空白区 + +# 4) 原位 create(before_slide_id = 旧 slide_id) +lark-cli slides xml_presentation.slide create --as user \ + --params "{\"xml_presentation_id\":\"$PRES_ID\"}" \ + --data "$(jq -n --arg content "$NEW_XML" --arg before "$TARGET_SLIDE_ID" \ + '{slide:{content:$content}, before_slide_id:$before}')" + +# 5) create 成功后删旧页 +lark-cli slides xml_presentation.slide delete --as user \ + --params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$TARGET_SLIDE_ID\"}" +``` + +**必须遵守**: + +1. **先 create 后 delete** —— 顺序反了且 create 失败会丢页 +2. **原 slide 的所有元素必须原样搬过来** —— 只写新 `` 会把原页标题/正文/形状全删掉 +3. **`before_slide_id=<旧 slide_id>` 必传** —— 不传新页追加到末尾,打乱页序 +4. **`` 坐标避开现有元素** —— 先读现有元素 bbox 挑空白区;空间不够就缩小/挪动现有元素后再放图 +5. **`` 的 `width:height` 仍需对齐原图比例** —— 比例不一致会被裁剪,参见 [xml-schema-quick-ref.md](xml-schema-quick-ref.md) `` 说明 +6. **`slide_id` 会变** —— 新页有新 ID,外部有深链依赖的要告知用户 + +## 工作原理 + +`+media-upload` 内部调用 `POST /open-apis/drive/v1/medias/upload_all`(单次上传,最大 20 MB),固定使用: + +- `parent_type=slide_file`(slides 后端唯一接受的取值,已实测验证) +- `parent_node=` + +**不要尝试用 `slides_image`、`slide_image` 等 parent_type**——后端会返回 1061001 / 1061002 错误。这是 slides 的特殊约定。 + +## 常见错误 + +| 错误码 | 含义 | 解决方案 | +|--------|------|----------| +| 1061002 | params error / 不支持的 parent_type | 不要用原生 API 自己拼 parent_type;用 `+media-upload` 即可 | +| 1061004 | forbidden:当前身份对该演示文稿无编辑权限 | 确认当前身份(user 或 bot)对目标 PPT 有编辑权限。bot 模式常见原因:PPT 不是该 bot 创建的——可用 `+create --as bot` 新建,或以 user 身份给 bot 授权 `lark-cli drive permission.members create --as user ...` | +| 1061044 | parent node not exist | `--presentation` 给的 token 不对,或不是 slides 类型 | +| 403 | 权限不足 | 检查 `docs:document.media:upload` scope;wiki URL 还需要 `wiki:node:read` | + +## 相关命令 + +- [+create](lark-slides-create.md) — 新建 PPT(支持 `@` 占位符自动上传图片) +- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 创建 slide 页面(拿到 file_token 后塞进 XML) diff --git a/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md b/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md index 19dded96..5cb1ca81 100644 --- a/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +++ b/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md @@ -161,6 +161,11 @@ lark-cli slides xml_presentation.slide create --as user \ | `` | 图形元素容器(shape、img、table、chart 等) | | `` | 演讲者备注 | +> [!IMPORTANT] +> **本地图片必须先上传**:`xml_presentation.slide.create` 不识别 `@./local.png` 占位符(那是 `+create --slides` 的语法糖)。直接调本接口添加带图新页时,必须先用 [`slides +media-upload`](lark-slides-media-upload.md) 拿到 `file_token`,再写进 ``。 +> +> 如果是从零开始建带图 PPT,**强烈建议改用 [`slides +create --slides '[...]'`](lark-slides-create.md#本地图片path-占位符)** 一步搞定(自动上传 + 替换 token)。 + ## 常见错误 | 错误码 | 含义 | 解决方案 | diff --git a/skills/lark-slides/references/slide-templates.md b/skills/lark-slides/references/slide-templates.md index e5419212..f68782f3 100644 --- a/skills/lark-slides/references/slide-templates.md +++ b/skills/lark-slides/references/slide-templates.md @@ -79,6 +79,106 @@ lark-cli slides xml_presentation.slide create --as user \
``` +## 带图版式 + +> **关键提醒**:`` 的 `width:height` = 原图比例时才不会被裁剪。每个模板都标注了图框比例和建议原图比例,**选模板前先对照你的素材比例**,不要硬塞(如把横图放进竖框,会被左右裁掉大半)。把 `@./your-image.jpg` 替换为实际路径(仅 `+create --slides` 支持 `@` 占位符;其他场景需先用 `slides +media-upload` 拿 `file_token`)。 + +### 封面右图(左字右图) + +图框 400×225(**16:9**),建议原图:横幅 16:9(桌面壁纸、产品 banner、landscape 照片) + +```xml + + + + +

主标题

+
+ +

副标题

+
+ + + + +

底部信息

+
+ + +
+
+``` + +### 三卡片带图(上图下文) + +每个图框 240×180(**4:3**),建议原图:4:3 或接近正方形的图(产品照、截图、icon 类) + +```xml + + + + +

核心亮点

+
+ + + + + + + + + + + + +

特性一

+
+ +

简短描述文案,控制在两行以内。

+
+ + + +
+
+``` + +### 左右分栏(图在左,文在右) + +图框 360×540(**2:3 竖幅**),建议原图:2:3 或 3:4 竖幅(人像照、产品竖拍、海报) + +> 如果你只有横幅图,不要硬塞进这个竖框 —— 改用"顶部横幅图 + 下方文字"的版式(把这里的图框改成 960×240 横条放在顶部)。 + +```xml + + + + + + + +

场景标题

+
+ + + + +

一句话描述这个场景的价值。

+
+ + +
    +
  • 要点一

  • +
  • 要点二

  • +
  • 要点三

  • +
+
+
+
+
+``` + ## 深色结尾页 ```xml diff --git a/skills/lark-slides/references/xml-format-guide.md b/skills/lark-slides/references/xml-format-guide.md index 0897c36b..7e5c31f5 100644 --- a/skills/lark-slides/references/xml-format-guide.md +++ b/skills/lark-slides/references/xml-format-guide.md @@ -219,6 +219,20 @@ `img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`。 +`src` 只接受两种值: + +| `src` 形式 | 说明 | +|---|---| +| `file_token`(如 `boxcnXXXXXXXXXXXXXXXXXXXXXX`) | 通过 `slides +media-upload` 上传后返回的 token | +| `@<本地路径>`(如 `@./assets/chart.png`) | **仅在 `slides +create --slides` 中可用**:CLI 会自动上传该文件并替换为 file_token | + +> **禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,`src="https://..."` 在 PPT 里通常显示破图。要用网图必须先 `curl`/下载到 CWD 内,再走上传流程拿 `file_token`。 + +本地图片的两种姿势: + +- **新建带图 PPT**:`+create --slides` 里直接写 `src="@./pic.png"`,CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token +- **给已有 PPT 加带图新页**:先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token,再用 token 写进 `xml_presentation.slide create` 的 XML + ### `` ```xml diff --git a/skills/lark-slides/references/xml-schema-quick-ref.md b/skills/lark-slides/references/xml-schema-quick-ref.md index 7f296f4e..d7fab4fb 100644 --- a/skills/lark-slides/references/xml-schema-quick-ref.md +++ b/skills/lark-slides/references/xml-schema-quick-ref.md @@ -132,6 +132,10 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出 ``` +`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。 + +> **注意**:`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。 + ### icon ```xml