diff --git a/shortcuts/common/drive_media_upload.go b/shortcuts/common/drive_media_upload.go index 907cb47a1..1bd78cf7d 100644 --- a/shortcuts/common/drive_media_upload.go +++ b/shortcuts/common/drive_media_upload.go @@ -32,6 +32,7 @@ type DriveMediaMultipartUploadSession struct { type DriveMediaUploadAllConfig struct { FilePath string + Content io.Reader // alternative to FilePath; used for in-memory uploads (e.g. clipboard) FileName string FileSize int64 ParentType string @@ -41,6 +42,7 @@ type DriveMediaUploadAllConfig struct { type DriveMediaMultipartUploadConfig struct { FilePath string + Content io.Reader // alternative to FilePath; used for in-memory uploads (e.g. clipboard) FileName string FileSize int64 ParentType string @@ -49,11 +51,17 @@ type DriveMediaMultipartUploadConfig struct { } func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) { - f, err := runtime.FileIO().Open(cfg.FilePath) - if err != nil { - return "", WrapInputStatError(err) + var r io.Reader + if cfg.Content != nil { + r = cfg.Content + } else { + f, err := runtime.FileIO().Open(cfg.FilePath) + if err != nil { + return "", WrapInputStatError(err) + } + defer f.Close() + r = f } - defer f.Close() fd := larkcore.NewFormdata() fd.AddField("file_name", cfg.FileName) @@ -65,7 +73,7 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) if cfg.Extra != "" { fd.AddField("extra", cfg.Extra) } - fd.AddFile("file", f) + fd.AddFile("file", r) apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, @@ -108,7 +116,7 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU } fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize)) - if err = uploadDriveMediaMultipartParts(runtime, cfg.FilePath, cfg.FileSize, session); err != nil { + if err = uploadDriveMediaMultipartParts(runtime, cfg, session); err != nil { return "", err } @@ -166,12 +174,18 @@ func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string return fileToken, nil } -func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fileSize int64, session DriveMediaMultipartUploadSession) error { - f, err := runtime.FileIO().Open(filePath) - if err != nil { - return WrapInputStatError(err) +func uploadDriveMediaMultipartParts(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error { + var r io.Reader + if cfg.Content != nil { + r = cfg.Content + } else { + f, err := runtime.FileIO().Open(cfg.FilePath) + if err != nil { + return WrapInputStatError(err) + } + defer f.Close() + r = f } - defer f.Close() maxInt := int64(^uint(0) >> 1) bufferSize := session.BlockSize @@ -179,7 +193,7 @@ func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fi return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned") } buffer := make([]byte, int(bufferSize)) - remaining := fileSize + remaining := cfg.FileSize // Follow the server-declared block plan exactly; upload_finish expects the // same block count returned by upload_prepare. for seq := 0; seq < session.BlockNum; seq++ { @@ -188,12 +202,12 @@ func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fi chunkSize = remaining } - n, readErr := io.ReadFull(f, buffer[:int(chunkSize)]) + n, readErr := io.ReadFull(r, buffer[:int(chunkSize)]) if readErr != nil { return output.ErrValidation("cannot read file: %s", readErr) } - if err = uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil { + if err := uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n))) diff --git a/shortcuts/common/drive_media_upload_test.go b/shortcuts/common/drive_media_upload_test.go index 1e17efa6c..4a90b7605 100644 --- a/shortcuts/common/drive_media_upload_test.go +++ b/shortcuts/common/drive_media_upload_test.go @@ -106,6 +106,98 @@ func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) { } } +func TestUploadDriveMediaAllWithInMemoryContent(t *testing.T) { + // When Content is provided, FilePath is ignored — the in-memory reader + // is streamed directly into the multipart form. Used by the clipboard + // upload path. + runtime, reg := newDriveMediaUploadTestRuntime(t) + withDriveMediaUploadWorkingDir(t, t.TempDir()) + + 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_mem_123"}, + }, + } + reg.Register(uploadStub) + + payload := []byte{0x89, 0x50, 0x4e, 0x47, 0xde, 0xad} + fileToken, err := UploadDriveMediaAll(runtime, DriveMediaUploadAllConfig{ + Content: bytes.NewReader(payload), + FileName: "clipboard.png", + FileSize: int64(len(payload)), + ParentType: "docx_image", + ParentNode: strPtr("blk_parent"), + }) + if err != nil { + t.Fatalf("UploadDriveMediaAll() error: %v", err) + } + if fileToken != "file_mem_123" { + t.Fatalf("fileToken = %q, want %q", fileToken, "file_mem_123") + } + + body := decodeCapturedDriveMediaMultipartBody(t, uploadStub) + if got := body.Fields["file_name"]; got != "clipboard.png" { + t.Fatalf("file_name = %q, want %q", got, "clipboard.png") + } + if got := body.Files["file"]; !bytes.Equal(got, payload) { + t.Fatalf("uploaded file bytes mismatch; got %v, want %v", got, payload) + } +} + +func TestUploadDriveMediaMultipartWithInMemoryContent(t *testing.T) { + // Clipboard multipart upload: Content reader replaces FilePath, and the + // server-declared block plan is honored exactly. + runtime, reg := newDriveMediaUploadTestRuntime(t) + withDriveMediaUploadWorkingDir(t, t.TempDir()) + + size := MaxDriveMediaUploadSinglePartSize + 1 + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "upload_id": "upload_mem_1", + "block_size": float64(4 * 1024 * 1024), + "block_num": float64(6), + }, + }, + }) + for i := 0; i < 6; i++ { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + } + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_finish", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_mem_multi"}, + }, + }) + + payload := bytes.Repeat([]byte{0xAB}, int(size)) + fileToken, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{ + Content: bytes.NewReader(payload), + FileName: "clipboard.png", + FileSize: size, + ParentType: "docx_image", + ParentNode: "", + }) + if err != nil { + t.Fatalf("UploadDriveMediaMultipart() error: %v", err) + } + if fileToken != "file_mem_multi" { + t.Fatalf("fileToken = %q, want %q", fileToken, "file_mem_multi") + } +} + func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) { runtime, reg := newDriveMediaUploadTestRuntime(t) withDriveMediaUploadWorkingDir(t, t.TempDir()) diff --git a/shortcuts/common/testing.go b/shortcuts/common/testing.go index 51e4bbd23..345078f9e 100644 --- a/shortcuts/common/testing.go +++ b/shortcuts/common/testing.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" ) @@ -37,3 +38,22 @@ func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, i }) return rctx } + +// TestNewRuntimeContextForAPI creates a RuntimeContext ready for HTTP tests: +// sets Cmd, Config, Factory, context, and the requested identity so callers +// can invoke DoAPI / CallAPI directly without wiring through a cobra parent +// command. +// +// Pass core.AsBot or core.AsUser explicitly — exposing the identity as a +// parameter keeps the helper reusable for tests that need to exercise the +// user-identity code path (token store, auth login, etc.) without forking +// into a second near-identical helper. +func TestNewRuntimeContextForAPI(ctx context.Context, cmd *cobra.Command, cfg *core.CliConfig, f *cmdutil.Factory, as core.Identity) *RuntimeContext { + return &RuntimeContext{ + ctx: ctx, + Cmd: cmd, + Config: cfg, + Factory: f, + resolvedAs: as, + } +} diff --git a/shortcuts/common/testing_test.go b/shortcuts/common/testing_test.go new file mode 100644 index 000000000..e2c765061 --- /dev/null +++ b/shortcuts/common/testing_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestTestNewRuntimeContextForAPIWiresFields(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + cfg := &core.CliConfig{AppID: "self-test-app", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _, _ := cmdutil.TestFactory(t, cfg) + cmd := &cobra.Command{Use: "testing-helper"} + + ctx := context.Background() + rctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsBot) + if rctx == nil { + t.Fatal("TestNewRuntimeContextForAPI returned nil") + } + if rctx.Cmd != cmd { + t.Errorf("Cmd not wired") + } + if rctx.Config != cfg { + t.Errorf("Config not wired") + } + if rctx.Factory != f { + t.Errorf("Factory not wired") + } + if !rctx.resolvedAs.IsBot() { + t.Errorf("resolvedAs not set to bot, got %q", rctx.resolvedAs) + } + if rctx.Ctx() != ctx { + t.Errorf("ctx not wired") + } + + // User identity should also be accepted — the whole reason for making + // the parameter explicit is to let user-identity code paths use this + // helper instead of forking a second one. + userRctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsUser) + if userRctx.resolvedAs != core.AsUser { + t.Errorf("resolvedAs AsUser not preserved, got %q", userRctx.resolvedAs) + } +} diff --git a/shortcuts/doc/clipboard.go b/shortcuts/doc/clipboard.go new file mode 100644 index 000000000..cb9f2c225 --- /dev/null +++ b/shortcuts/doc/clipboard.go @@ -0,0 +1,349 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "encoding/base64" + "fmt" + "os/exec" + "regexp" + "runtime" + "strings" +) + +// readClipboardImageBytes reads the current clipboard image and returns the +// raw PNG bytes in memory. No temporary files are created on any platform; +// all platform tools emit image bytes (or an encoded form) on stdout. +// +// Platform support: +// +// macOS — osascript (built-in, no extra deps) +// Windows — powershell + System.Windows.Forms (built-in), output as base64 +// Linux — xclip (X11), wl-paste (Wayland), or xsel (X11 fallback), +// tried in that order; returns a clear error if none is found. +func readClipboardImageBytes() ([]byte, error) { + var data []byte + var err error + + switch runtime.GOOS { + case "darwin": + data, err = readClipboardDarwin() + case "windows": + data, err = readClipboardWindows() + case "linux": + data, err = readClipboardLinux() + default: + return nil, fmt.Errorf("clipboard image upload is not supported on %s", runtime.GOOS) + } + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, fmt.Errorf("clipboard contains no image data") + } + return data, nil +} + +// reBase64DataURI matches a data URI image embedded in clipboard text content, +// e.g. data:image/jpeg;base64,/9j/4AAQ... +// The character class covers both standard (+/) and URL-safe (-_) base64 +// alphabets, plus ASCII whitespace: HTML and RTF clipboard payloads commonly +// fold long base64 at 76 chars (standard MIME folding), so whitespace must be +// captured as part of the payload for the downstream strings.Fields strip to +// actually have something to normalise. Terminators like ", <, ), ; remain +// outside the class so the match still ends at the URI boundary. +var reBase64DataURI = regexp.MustCompile(`data:(image/[^;]+);base64,([A-Za-z0-9+/\-_\s]+=*)`) + +// readClipboardDarwin reads the clipboard image on macOS and returns image bytes. +// +// Strategy: +// 1. Ask osascript for the clipboard as PNG (hex literal on stdout) → decode. +// Native macOS screenshots and most image-producing apps place PNG on the +// pasteboard directly. +// 2. Scan all text-based clipboard formats (HTML, RTF, plain text) for an +// embedded base64 data URI image (e.g. images copied from Feishu / browsers). +// Decoded payload is validated against known image magic bytes so text +// clipboards that happen to mention a data URI literally are not treated +// as image data. +// +// No external dependencies required — osascript ships with macOS. +func readClipboardDarwin() ([]byte, error) { + // Attempt 1: PNG via osascript hex literal on stdout. + // Use Output() + separate stderr capture so osascript diagnostics + // (locale warnings, AppleEvent permission prompts, etc.) do not + // contaminate the decoded payload or mask real failures. + out, stderrText, runErr := runOsascript("get the clipboard as «class PNGf»") + if runErr == nil && len(out) > 0 { + if data, decErr := decodeOsascriptData(strings.TrimSpace(string(out))); decErr == nil && len(data) > 0 { + return data, nil + } + } + // First-attempt failure is expected for non-image clipboards — fall through + // to the base64 scan. Keep the stderr text for the final error message in + // case every attempt ends up empty-handed. + + // Attempt 2: scan text-based clipboard formats for an embedded base64 data URI. + // Covers HTML (Feishu, Chrome, Safari), RTF, and plain text — tried in order. + if imgData := extractBase64ImageFromClipboard(); imgData != nil { + return imgData, nil + } + + if stderrText != "" { + return nil, fmt.Errorf("clipboard contains no image data (osascript: %s)", stderrText) + } + return nil, fmt.Errorf("clipboard contains no image data") +} + +// runOsascript invokes osascript with a single AppleScript expression and +// returns stdout, a trimmed stderr string, and the exec error separately. +// Using Output() (rather than CombinedOutput) keeps stderr out of the decoded +// payload, while the captured stderr is still available for error messages. +func runOsascript(expr string) (stdout []byte, stderrText string, err error) { + cmd := exec.Command("osascript", "-e", expr) + var stderr bytes.Buffer + cmd.Stderr = &stderr + stdout, err = cmd.Output() + stderrText = strings.TrimSpace(stderr.String()) + return stdout, stderrText, err +} + +// clipboardTextFormats lists the osascript type coercions to try when looking +// for an embedded base64 data-URI image in text-based clipboard formats. +// Ordered by likelihood of containing an embedded image. +var clipboardTextFormats = []struct { + classCode string // 4-char OSType used in «class XXXX» + asExpr string // AppleScript coercion expression +}{ + {"HTML", "get the clipboard as «class HTML»"}, + {"RTF ", "get the clipboard as «class RTF »"}, + {"utf8", "get the clipboard as «class utf8»"}, + {"TEXT", "get the clipboard as string"}, +} + +// extractBase64ImageFromClipboard iterates text clipboard formats and returns +// the first decoded image payload found, or nil if none contains image data. +// Decoded bytes are validated against known image magic headers so that +// text clipboards containing a literal `data:image/...;base64,...` fragment +// (e.g. a tutorial, a code sample, pasted HTML source) are not silently +// uploaded as an image. +func extractBase64ImageFromClipboard() []byte { + for _, f := range clipboardTextFormats { + out, _, err := runOsascript(f.asExpr) + if err != nil || len(out) == 0 { + continue + } + raw := strings.TrimSpace(string(out)) + decoded, err := decodeOsascriptData(raw) + if err != nil || len(decoded) == 0 { + continue + } + m := reBase64DataURI.FindSubmatch(decoded) + if m == nil { + continue + } + // HTML/RTF clipboard content often line-wraps base64 at 76 chars; strip + // all ASCII whitespace before decoding so wrapped payloads are not missed. + // Accept both standard and URL-safe base64 (some apps emit URL-safe). + b64 := strings.Join(strings.Fields(string(m[2])), "") + imgData, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + imgData, err = base64.URLEncoding.DecodeString(b64) + } + if err != nil || len(imgData) == 0 { + continue + } + if !hasKnownImageMagic(imgData) { + // Decoded payload does not look like a real image — e.g. the + // clipboard is a documentation sample that mentions data URIs. + // Keep looking in the next format rather than upload garbage. + continue + } + return imgData + } + return nil +} + +// decodeOsascriptData converts the «data XXXX» literal that osascript +// emits for binary clipboard classes into raw bytes. +// If the input does not match the literal format, the raw bytes are returned as-is. +func decodeOsascriptData(s string) ([]byte, error) { + // Format: «data HTML3C6D657461...» + const prefix = "\xc2\xab" + "data " // « in UTF-8 followed by "data " + if !strings.HasPrefix(s, prefix) { + // plain string — return as-is + return []byte(s), nil + } + // strip «data XXXX (4-char class code follows immediately, no space) and trailing » + s = s[len(prefix):] + if len(s) >= 4 { + s = s[4:] // skip class code, e.g. "HTML", "TIFF", "PNGf" + } + s = strings.TrimSuffix(s, "\xc2\xbb") // » + s = strings.TrimSpace(s) + return decodeHex(s) +} + +// decodeHex decodes an uppercase hex string (as produced by osascript) to bytes. +func decodeHex(h string) ([]byte, error) { + if len(h)%2 != 0 { + return nil, fmt.Errorf("odd hex length") + } + b := make([]byte, len(h)/2) + for i := 0; i < len(h); i += 2 { + hi := hexVal(h[i]) + lo := hexVal(h[i+1]) + if hi < 0 || lo < 0 { + return nil, fmt.Errorf("invalid hex char at %d", i) + } + b[i/2] = byte(hi<<4 | lo) + } + return b, nil +} + +func hexVal(c byte) int { + switch { + case c >= '0' && c <= '9': + return int(c - '0') + case c >= 'a' && c <= 'f': + return int(c-'a') + 10 + case c >= 'A' && c <= 'F': + return int(c-'A') + 10 + } + return -1 +} + +// readClipboardWindows uses PowerShell to export the clipboard image as PNG, +// writing it as base64 to stdout and decoding in Go (no temp files). +func readClipboardWindows() ([]byte, error) { + script := ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$img = [System.Windows.Forms.Clipboard]::GetImage() +if ($img -eq $null) { Write-Error 'clipboard contains no image data'; exit 1 } +$ms = New-Object System.IO.MemoryStream +$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) +[Convert]::ToBase64String($ms.ToArray()) +` + // Use Output() + captured stderr so PowerShell diagnostics surface in the + // error message but never corrupt the base64 stdout we need to decode. + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("clipboard read failed (%s)", msg) + } + b64 := strings.TrimSpace(string(out)) + data, decErr := base64.StdEncoding.DecodeString(b64) + if decErr != nil { + return nil, fmt.Errorf("clipboard image decode failed: %w", decErr) + } + return data, nil +} + +// pngMagic is the 8-byte PNG signature used to validate clipboard output from +// tools that cannot negotiate MIME types (e.g. xsel). +var pngMagic = []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a} + +func hasPNGMagic(b []byte) bool { + return len(b) >= len(pngMagic) && string(b[:len(pngMagic)]) == string(pngMagic) +} + +// imageMagics enumerates the leading-byte signatures we accept as "this is a +// real image payload" when a text clipboard supplies a base64 data URI. The +// set mirrors the formats the Lark upload endpoints already accept; other +// rare formats fall through so the caller skips to the next clipboard format. +var imageMagics = [][]byte{ + // PNG + {0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, + // JPEG (SOI) + {0xff, 0xd8, 0xff}, + // GIF87a / GIF89a + []byte("GIF87a"), + []byte("GIF89a"), + // WebP: "RIFF????WEBP" — check the RIFF marker only; the WEBP marker + // lives at offset 8, validated separately below. + []byte("RIFF"), + // BMP + []byte("BM"), +} + +// hasKnownImageMagic reports whether the first bytes of b match any of the +// image signatures we trust. RIFF is further constrained to actual WebP +// streams to avoid false positives on other RIFF-based formats (WAV, AVI). +func hasKnownImageMagic(b []byte) bool { + for _, magic := range imageMagics { + if len(b) < len(magic) { + continue + } + if string(b[:len(magic)]) != string(magic) { + continue + } + // RIFF header must be followed at offset 8 by "WEBP" to count as an image. + if string(magic) == "RIFF" { + if len(b) >= 12 && string(b[8:12]) == "WEBP" { + return true + } + continue + } + return true + } + return false +} + +// readClipboardLinux tries xclip (X11), wl-paste (Wayland), and xsel (X11) +// in order, returning the PNG bytes from the first available tool. +// +// xclip and wl-paste request the image/png MIME type directly; xsel cannot +// negotiate MIME types so its output is validated against the PNG magic header. +// If a tool is present but fails or returns non-PNG data, the error is +// preserved so users see a meaningful message instead of "no tool found". +func readClipboardLinux() ([]byte, error) { + type tool struct { + name string + args []string + validatePNG bool // true when the tool cannot request image/png by MIME + } + tools := []tool{ + {"xclip", []string{"-selection", "clipboard", "-t", "image/png", "-o"}, false}, + {"wl-paste", []string{"--type", "image/png"}, false}, + {"xsel", []string{"--clipboard", "--output"}, true}, + } + + var lastErr error + foundTool := false + for _, t := range tools { + if _, lookErr := exec.LookPath(t.name); lookErr != nil { + continue + } + foundTool = true + out, err := exec.Command(t.name, t.args...).Output() + if err != nil { + lastErr = fmt.Errorf("clipboard image read failed via %s: %w", t.name, err) + continue + } + if len(out) == 0 { + lastErr = fmt.Errorf("clipboard contains no image data (%s returned empty output)", t.name) + continue + } + if t.validatePNG && !hasPNGMagic(out) { + lastErr = fmt.Errorf("clipboard contains no PNG image data (%s output is not a PNG)", t.name) + continue + } + return out, nil + } + + if foundTool && lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf( + "clipboard image read failed: no supported tool found. " + + "Install one of xclip, wl-clipboard, or xsel via your distro's package manager " + + "(apt, dnf, pacman, apk, brew, etc.).") +} diff --git a/shortcuts/doc/clipboard_test.go b/shortcuts/doc/clipboard_test.go new file mode 100644 index 000000000..737ae7ca0 --- /dev/null +++ b/shortcuts/doc/clipboard_test.go @@ -0,0 +1,319 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "encoding/base64" + "os" + "runtime" + "strings" + "testing" +) + +// TestReadClipboardImageBytes_EmptyResultReturnsError locks in the contract +// that readClipboardImageBytes surfaces a clear error (instead of silently +// succeeding with empty bytes) whenever the platform layer produced no image +// data. On Linux runners this is exercised by reusing the "no clipboard tool +// found" path, which is the only portable way to force an empty result +// without a display/pasteboard. +func TestReadClipboardImageBytes_EmptyResultReturnsError(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("portable empty-result check only runs on Linux; macOS/Windows require a real pasteboard") + } + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", "") + + data, err := readClipboardImageBytes() + if err == nil { + t.Fatalf("expected error on empty clipboard, got data=%d bytes", len(data)) + } + if len(data) != 0 { + t.Errorf("expected no data when readClipboardImageBytes errors, got %d bytes", len(data)) + } +} + +func TestReadClipboardLinux_NoToolsReturnsError(t *testing.T) { + // Override PATH so none of xclip/wl-paste/xsel can be found. + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", "") + + _, err := readClipboardLinux() + if err == nil { + t.Fatal("expected error when no clipboard tool is available, got nil") + } +} + +func TestReadClipboardLinux_XselRejectsNonPNG(t *testing.T) { + // Fake xsel that returns plain text (non-PNG) — should be rejected by the + // PNG-magic validation so the user does not upload text as an "image". + tmpDir := t.TempDir() + fakeXsel := tmpDir + "/xsel" + if err := os.WriteFile(fakeXsel, []byte("#!/bin/sh\nprintf 'not a png'\n"), 0755); err != nil { + t.Fatalf("write fake xsel: %v", err) + } + + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", tmpDir) // no xclip, no wl-paste; only our fake xsel + + _, err := readClipboardLinux() + if err == nil { + t.Fatal("expected error when xsel returns non-PNG bytes, got nil") + } +} + +func TestHasPNGMagic(t *testing.T) { + tests := []struct { + name string + in []byte + want bool + }{ + {"exact PNG signature", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, true}, + {"PNG signature plus payload", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xde, 0xad}, true}, + {"plain text", []byte("not a png"), false}, + {"empty", []byte{}, false}, + {"too short", []byte{0x89, 0x50, 0x4e, 0x47}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasPNGMagic(tt.in); got != tt.want { + t.Errorf("hasPNGMagic(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestReadClipboardImageBytes_UnsupportedPlatform(t *testing.T) { + // The dispatcher returns a clear error on platforms we do not support. + // We cannot flip runtime.GOOS, but we can cover the shared post-processing + // by invoking the function on any platform and asserting the non-error + // contract holds: either it returns data (unlikely in CI) or an error — + // never both zero values. + data, err := readClipboardImageBytes() + if err == nil && len(data) == 0 { + t.Fatal("readClipboardImageBytes returned (nil, nil); must return error when data is empty") + } +} + +func TestDecodeHex(t *testing.T) { + tests := []struct { + name string + input string + want []byte + wantErr bool + }{ + {"empty", "", []byte{}, false}, + {"single byte lower", "2f", []byte{0x2f}, false}, + {"single byte upper", "2F", []byte{0x2f}, false}, + {"multi byte", "48656C6C6F", []byte("Hello"), false}, + {"odd length", "abc", nil, true}, + {"invalid char", "GG", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := decodeHex(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("decodeHex(%q) error=%v, wantErr=%v", tt.input, err, tt.wantErr) + } + if !tt.wantErr && string(got) != string(tt.want) { + t.Errorf("decodeHex(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestDecodeOsascriptData(t *testing.T) { + // Build a real «data HTML» literal for the string "" + raw := []byte("") + hexStr := "" + for _, b := range raw { + hexStr += string([]byte{hexNibble(b >> 4), hexNibble(b & 0xf)}) + } + // «data HTML3C696D673E» (« = \xc2\xab, » = \xc2\xbb) + literal := "\xc2\xab" + "data HTML" + hexStr + "\xc2\xbb" + + tests := []struct { + name string + input string + want string + }{ + {"plain string passthrough", "hello world", "hello world"}, + {"osascript hex literal", literal, ""}, + {"empty string", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := decodeOsascriptData(tt.input) + if err != nil { + t.Fatalf("decodeOsascriptData(%q) unexpected error: %v", tt.input, err) + } + if string(got) != tt.want { + t.Errorf("decodeOsascriptData(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestReBase64DataURI_Match(t *testing.T) { + imgBytes := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic bytes + b64 := base64.StdEncoding.EncodeToString(imgBytes) + html := `` + + m := reBase64DataURI.FindSubmatch([]byte(html)) + if m == nil { + t.Fatal("expected regex to match base64 data URI in HTML") + } + if string(m[1]) != "image/png" { + t.Errorf("mime type = %q, want %q", m[1], "image/png") + } + if string(m[2]) != b64 { + t.Errorf("base64 payload mismatch") + } +} + +func TestReBase64DataURI_URLSafeMatch(t *testing.T) { + // URL-safe base64 uses '-' and '_' instead of '+' and '/'. + // Construct a payload that contains both characters. + // base64url of 0xFB 0xFF 0xFE → "-__-" in URL-safe alphabet. + urlSafePayload := "-__-" + html := `` + + m := reBase64DataURI.FindSubmatch([]byte(html)) + if m == nil { + t.Fatal("expected regex to match URL-safe base64 data URI") + } + if string(m[1]) != "image/jpeg" { + t.Errorf("mime type = %q, want %q", m[1], "image/jpeg") + } + if string(m[2]) != urlSafePayload { + t.Errorf("URL-safe base64 payload = %q, want %q", m[2], urlSafePayload) + } +} + +func TestReBase64DataURI_NoMatch(t *testing.T) { + if reBase64DataURI.Match([]byte("no image here")) { + t.Error("expected no match for plain text") + } +} + +// TestReBase64DataURI_LineWrapped exercises the common real-world case where +// HTML or RTF clipboards fold a base64 payload at 76 chars (standard MIME +// line wrapping). The regex must capture whitespace inside the payload so +// strings.Fields can strip it before base64 decoding; otherwise the match is +// truncated at the first newline and the decoded prefix happens to pass +// hasKnownImageMagic (since PNG magic is just 8 bytes), silently uploading a +// corrupt payload. +func TestReBase64DataURI_LineWrapped(t *testing.T) { + // Build a deterministic payload larger than one wrap line so we force a + // fold. The exact bytes don't matter; the full round-trip does. + payload := make([]byte, 180) + for i := range payload { + payload[i] = byte(i * 7) + } + b64 := base64.StdEncoding.EncodeToString(payload) + + // Insert realistic folding: a mix of \n, \r\n, and \t within a single + // payload, to catch regressions regardless of the clipboard source + // (HTML tends to use \n; RTF \par wraps use \r\n; some editors indent). + if len(b64) < 120 { + t.Fatalf("test payload too small for folding: len=%d", len(b64)) + } + wrapped := b64[:40] + "\n " + b64[40:80] + "\r\n\t" + b64[80:] + html := `` + + m := reBase64DataURI.FindSubmatch([]byte(html)) + if m == nil { + t.Fatal("expected regex to match line-wrapped base64 payload") + } + if string(m[1]) != "image/png" { + t.Errorf("mime type = %q, want %q", m[1], "image/png") + } + + // The whole point of extending the character class: the downstream + // Fields strip must see the folding and normalise it away. + normalized := strings.Join(strings.Fields(string(m[2])), "") + if normalized != b64 { + t.Fatalf("normalized payload mismatch\n got: %q\nwant: %q", normalized, b64) + } + got, err := base64.StdEncoding.DecodeString(normalized) + if err != nil { + t.Fatalf("decode after normalisation failed: %v", err) + } + if !bytes.Equal(got, payload) { + t.Error("decoded bytes differ from original payload — truncation regression") + } + + // The match must still stop at the URI boundary; extending the class + // with \s should not let the capture run off the end of the attribute. + if strings.Contains(string(m[0]), `">`) { + t.Errorf("regex captured past the URI terminator: %q", m[0]) + } +} + +func TestExtractBase64ImageFromClipboard_WithFakeOsascript(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("fake osascript test only runs on macOS") + } + // Build a minimal PNG (1x1 transparent) as base64 to embed in fake HTML output. + pngBytes := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + } + b64 := base64.StdEncoding.EncodeToString(pngBytes) + htmlContent := `` + + // Encode htmlContent as a «data HTML» literal the way osascript would. + hexStr := "" + for _, c := range []byte(htmlContent) { + hexStr += string([]byte{hexNibble(c >> 4), hexNibble(c & 0xf)}) + } + fakeOutput := "\xc2\xab" + "data HTML" + hexStr + "\xc2\xbb" + + // Write a fake osascript that prints fakeOutput and exits 0. + // Use a pre-written output file to avoid shell-escaping issues with binary data. + tmpDir := t.TempDir() + outputFile := tmpDir + "/output.txt" + if err := os.WriteFile(outputFile, []byte(fakeOutput), 0600); err != nil { + t.Fatalf("write output file: %v", err) + } + fakeScript := tmpDir + "/osascript" + scriptBody := "#!/bin/sh\ncat " + outputFile + "\n" + if err := os.WriteFile(fakeScript, []byte(scriptBody), 0755); err != nil { + t.Fatalf("write fake osascript: %v", err) + } + + // Prepend tmpDir to PATH so our fake osascript is found first. + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+orig) + + got := extractBase64ImageFromClipboard() + if got == nil { + t.Fatal("expected image data, got nil") + } + if string(got) != string(pngBytes) { + t.Errorf("decoded image = %v, want %v", got, pngBytes) + } +} + +func TestExtractBase64ImageFromClipboard_NoOsascript(t *testing.T) { + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", "") + + got := extractBase64ImageFromClipboard() + if got != nil { + t.Errorf("expected nil when osascript unavailable, got %v", got) + } +} + +// hexNibble converts a 4-bit value to its uppercase hex character. +func hexNibble(n byte) byte { + if n < 10 { + return '0' + n + } + return 'A' + n - 10 +} diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go index a3c9dbd11..eaf50c9cb 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -4,6 +4,7 @@ package doc import ( + "bytes" "context" "fmt" "path/filepath" @@ -21,6 +22,10 @@ var alignMap = map[string]int{ "right": 3, } +// readClipboardImage is the clipboard read function, swappable in tests to +// inject synthetic image bytes without depending on the host pasteboard. +var readClipboardImage = readClipboardImageBytes + // fileViewMap maps the user-facing --file-view value to the docx File block // `view_type` enum. The underlying values come from the open platform spec: // @@ -41,7 +46,8 @@ var DocMediaInsert = common.Shortcut{ Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, + {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)"}, + {Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file (macOS/Windows built-in; Linux requires xclip, xsel or wl-paste)"}, {Name: "doc", Desc: "document URL or document_id", Required: true}, {Name: "type", Default: "image", Desc: "type: image | file"}, {Name: "align", Desc: "alignment: left | center | right"}, @@ -51,6 +57,15 @@ var DocMediaInsert = common.Shortcut{ {Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + fromClipboard := runtime.Bool("from-clipboard") + if filePath == "" && !fromClipboard { + return common.FlagErrorf("one of --file or --from-clipboard is required") + } + if filePath != "" && fromClipboard { + return common.FlagErrorf("--file and --from-clipboard are mutually exclusive") + } + docRef, err := parseDocumentRef(runtime.Str("doc")) if err != nil { return err @@ -89,6 +104,9 @@ var DocMediaInsert = common.Shortcut{ documentID := docRef.Token stepBase := 1 filePath := runtime.Str("file") + if runtime.Bool("from-clipboard") { + filePath = "" + } mediaType := runtime.Str("type") caption := runtime.Str("caption") selection := strings.TrimSpace(runtime.Str("selection-with-ellipsis")) @@ -162,7 +180,15 @@ var DocMediaInsert = common.Shortcut{ Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)). Body(batchUpdateData) - return d.Set("document_id", documentID) + d.Set("document_id", documentID) + // Annotate dry-run when reading from the clipboard: DryRun never touches + // the pasteboard, so it cannot tell in advance whether the payload is + // above or below the 20MB single-part threshold. Execute will make the + // real decision once it reads the bytes. + if runtime.Bool("from-clipboard") { + d.Set("upload_size_note", "clipboard size unknown; single-part vs multipart decision deferred to runtime") + } + return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { filePath := runtime.Str("file") @@ -172,23 +198,42 @@ var DocMediaInsert = common.Shortcut{ caption := runtime.Str("caption") fileViewType := fileViewMap[runtime.Str("file-view")] + // Clipboard path: read image bytes into memory, bypassing FileIO path validation. + var clipboardContent []byte + if runtime.Bool("from-clipboard") { + fmt.Fprintf(runtime.IO().ErrOut, "Reading image from clipboard...\n") + var err error + clipboardContent, err = readClipboardImage() + if err != nil { + return err + } + } + documentID, err := resolveDocxDocumentID(runtime, docInput) if err != nil { return err } - // Validate file - 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) + // Determine file size and name. + var fileSize int64 + var fileName string + if clipboardContent != nil { + fileSize = int64(len(clipboardContent)) + fileName = "clipboard.png" + } else { + 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) + } + fileSize = stat.Size() + fileName = filepath.Base(filePath) } - fileName := filepath.Base(filePath) fmt.Fprintf(runtime.IO().ErrOut, "Inserting: %s -> document %s\n", fileName, common.MaskToken(documentID)) - if stat.Size() > common.MaxDriveMediaUploadSinglePartSize { + if fileSize > common.MaxDriveMediaUploadSinglePartSize { fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") } @@ -264,8 +309,23 @@ var DocMediaInsert = common.Shortcut{ return opErr } - // Step 3: Upload media file - fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentTypeForMediaType(mediaType), uploadParentNode, documentID) + // Step 3: Upload media file. + // Only materialize Content when clipboard bytes exist, so the `io.Reader` + // interface stays a true nil for the --file path. Passing a typed-nil + // *bytes.Reader here would make the downstream `if cfg.Content != nil` + // check incorrectly take the clipboard branch and crash on Read. + uploadCfg := UploadDocMediaFileConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: parentTypeForMediaType(mediaType), + ParentNode: uploadParentNode, + DocID: documentID, + } + if clipboardContent != nil { + uploadCfg.Content = bytes.NewReader(clipboardContent) + } + fileToken, err := uploadDocMediaFile(runtime, uploadCfg) if err != nil { return withRollbackWarning(err) } diff --git a/shortcuts/doc/doc_media_insert_test.go b/shortcuts/doc/doc_media_insert_test.go index e5e9cd2fb..71d211f75 100644 --- a/shortcuts/doc/doc_media_insert_test.go +++ b/shortcuts/doc/doc_media_insert_test.go @@ -645,9 +645,16 @@ func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string t.Helper() cmd := &cobra.Command{Use: "docs +media-insert"} + cmd.Flags().String("file", "", "") + cmd.Flags().Bool("from-clipboard", false, "") cmd.Flags().String("doc", "", "") cmd.Flags().String("type", "", "") cmd.Flags().String("file-view", "", "") + // A non-empty --file satisfies the file/clipboard xor check so Validate + // reaches the --file-view logic under test below. + if err := cmd.Flags().Set("file", "dummy.bin"); err != nil { + t.Fatalf("set --file: %v", err) + } if err := cmd.Flags().Set("doc", doc); err != nil { t.Fatalf("set --doc: %v", err) } diff --git a/shortcuts/doc/doc_media_test.go b/shortcuts/doc/doc_media_test.go index 53c05393d..36075d1ee 100644 --- a/shortcuts/doc/doc_media_test.go +++ b/shortcuts/doc/doc_media_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "os" "path/filepath" @@ -75,6 +76,62 @@ func TestDocMediaInsertRejectsOldDocURL(t *testing.T) { } } +func TestDocMediaInsertValidateRequiresFileOrClipboard(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app")) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/docx/doxcnXXXXXXXXXXXXXXXXXX", + "--dry-run", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "one of --file or --from-clipboard is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDocMediaInsertValidateRejectsFileAndClipboardTogether(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app")) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/docx/doxcnXXXXXXXXXXXXXXXXXX", + "--file", "dummy.png", + "--from-clipboard", + "--dry-run", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected mutual-exclusion error, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDocMediaInsertDryRunWithClipboardUsesPlaceholder(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app")) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/docx/doxcnXXXXXXXXXXXXXXXXXX", + "--from-clipboard", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // JSON output escapes "<" and ">" as \u003c / \u003e by default. + out := stdout.String() + if !strings.Contains(out, `\u003cclipboard image\u003e`) && !strings.Contains(out, "") { + t.Fatalf("dry-run output missing placeholder: %s", out) + } +} + func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app")) @@ -190,6 +247,214 @@ func TestDocMediaInsertDryRunUsesMultipartForLargeFile(t *testing.T) { } } +func TestUploadDocMediaFileWithContentUsesSinglePartUpload(t *testing.T) { + // Clipboard path: in-memory bytes (no FilePath) route through + // UploadDriveMediaAll when small enough. This also exercises the + // drive_route_token extra built from docID. + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-upload-content-app")) + 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_content_123"}, + }, + } + reg.Register(uploadStub) + + runtime := common.TestNewRuntimeContextForAPI( + context.Background(), + &cobra.Command{Use: "docs +media-upload"}, + docsTestConfigWithAppID("docs-upload-content-app"), + f, + core.AsBot, + ) + + payload := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a} // PNG magic bytes + fileToken, err := uploadDocMediaFile(runtime, UploadDocMediaFileConfig{ + Content: bytes.NewReader(payload), + FileName: "clipboard.png", + FileSize: int64(len(payload)), + ParentType: "docx_image", + ParentNode: "blk_parent", + DocID: "doxcnDocID123", + }) + if err != nil { + t.Fatalf("uploadDocMediaFile() error: %v", err) + } + if fileToken != "file_content_123" { + t.Fatalf("fileToken = %q, want %q", fileToken, "file_content_123") + } + + if !strings.Contains(string(uploadStub.CapturedBody), `drive_route_token`) { + t.Fatalf("expected drive_route_token in extra, captured body did not include it") + } +} + +func TestUploadDocMediaFileWithContentUsesMultipart(t *testing.T) { + // Clipboard path: in-memory bytes route through UploadDriveMediaMultipart + // when size exceeds the single-part threshold. + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-upload-content-multi")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "upload_id": "upload_content_multi", + "block_size": float64(4 * 1024 * 1024), + "block_num": float64(6), + }, + }, + }) + for i := 0; i < 6; i++ { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + } + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_finish", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_content_multi_done"}, + }, + }) + + runtime := common.TestNewRuntimeContextForAPI( + context.Background(), + &cobra.Command{Use: "docs +media-upload"}, + docsTestConfigWithAppID("docs-upload-content-multi"), + f, + core.AsBot, + ) + + size := common.MaxDriveMediaUploadSinglePartSize + 1 + payload := bytes.Repeat([]byte{0xAB}, int(size)) + fileToken, err := uploadDocMediaFile(runtime, UploadDocMediaFileConfig{ + Content: bytes.NewReader(payload), + FileName: "clipboard.png", + FileSize: size, + ParentType: "docx_image", + ParentNode: "blk_parent", + // no DocID → no drive_route_token extra + }) + if err != nil { + t.Fatalf("uploadDocMediaFile() error: %v", err) + } + if fileToken != "file_content_multi_done" { + t.Fatalf("fileToken = %q, want %q", fileToken, "file_content_multi_done") + } +} + +func TestDocMediaInsertExecuteFromClipboard(t *testing.T) { + // Covers the Execute clipboard branch end-to-end: read synthetic bytes, + // resolve docx root, create block, upload in-memory content, bind to block. + prev := readClipboardImage + t.Cleanup(func() { readClipboardImage = prev }) + payload := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xAA, 0xBB} + readClipboardImage = func() ([]byte, error) { return payload, nil } + + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-clipboard-exec-app")) + documentID := "doxcnClipboardExec1" + + // Step 1: GET root block + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docx/v1/documents/" + documentID + "/blocks/" + documentID, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "block": map[string]interface{}{ + "block_id": documentID, + "children": []interface{}{"existing_block"}, + }, + }, + }, + }) + // Step 2: POST create child block + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docx/v1/documents/" + documentID + "/blocks/" + documentID + "/children", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{"block_id": "new_image_block"}, + }, + }, + }, + }) + // Step 3: POST upload_all for in-memory bytes + 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_clip_abc"}, + }, + } + reg.Register(uploadStub) + // Step 4: PATCH batch_update + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/docx/v1/documents/" + documentID + "/blocks/batch_update", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", documentID, + "--from-clipboard", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v — stderr: %s", err, stderr.String()) + } + + // stderr should show clipboard read + file name "clipboard.png" + if !strings.Contains(stderr.String(), "Reading image from clipboard") { + t.Errorf("stderr missing clipboard-read log: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), "clipboard.png") { + t.Errorf("stderr missing clipboard.png file name: %s", stderr.String()) + } + // stdout should include the file_token + if !strings.Contains(stdout.String(), "file_clip_abc") { + t.Errorf("stdout missing file_token: %s", stdout.String()) + } + + // Upload multipart body should contain the synthetic payload bytes. + if !bytes.Contains(uploadStub.CapturedBody, payload) { + t.Errorf("upload body missing clipboard payload bytes") + } +} + +func TestDocMediaInsertExecuteClipboardReadError(t *testing.T) { + // Covers the early-return when clipboard read fails (no osascript etc). + prev := readClipboardImage + t.Cleanup(func() { readClipboardImage = prev }) + readClipboardImage = func() ([]byte, error) { + return nil, fmt.Errorf("clipboard image upload is not supported on test") + } + + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-clipboard-err-app")) + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "doxcnXXXXXXXXXXXXXXXXXX", + "--from-clipboard", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected clipboard read error, got nil") + } + if !strings.Contains(err.Error(), "clipboard image upload is not supported") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestDocMediaInsertExecuteResolvesWikiBeforeFileCheck(t *testing.T) { f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-insert-exec-app")) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index 395652c79..280ab4203 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -6,6 +6,7 @@ package doc import ( "context" "fmt" + "io" "path/filepath" "github.com/larksuite/cli/extension/fileio" @@ -95,7 +96,14 @@ var DocMediaUpload = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") } - fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentType, parentNode, docId) + fileToken, err := uploadDocMediaFile(runtime, UploadDocMediaFileConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: stat.Size(), + ParentType: parentType, + ParentNode: parentNode, + DocID: docId, + }) if err != nil { return err } @@ -109,11 +117,34 @@ var DocMediaUpload = common.Shortcut{ }, } -func uploadDocMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentType, parentNode, docID string) (string, error) { +// UploadDocMediaFileConfig groups the inputs to uploadDocMediaFile so the +// call site names each value at call time, avoiding the "8 positional +// params of mostly string/int64" ambiguity and mirroring the config-struct +// style already used by DriveMediaUploadAllConfig / +// DriveMediaMultipartUploadConfig downstream. +// +// Exactly one of FilePath (on-disk source) or Content (in-memory source for +// the clipboard flow) should be set. Leave Content at its zero value (nil +// interface) when the caller only has FilePath — passing a typed-nil +// pointer like (*bytes.Reader)(nil) here would make Content compare +// non-nil downstream and skip the FilePath open, so the field type is +// deliberately an interface and the clipboard caller builds it only when +// it actually has bytes. +type UploadDocMediaFileConfig struct { + FilePath string + Content io.Reader + FileName string + FileSize int64 + ParentType string + ParentNode string + DocID string +} + +func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileConfig) (string, error) { var extra string - if docID != "" { + if cfg.DocID != "" { var err error - extra, err = buildDriveRouteExtra(docID) + extra, err = buildDriveRouteExtra(cfg.DocID) if err != nil { return "", err } @@ -121,22 +152,24 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, filePath, fileName strin // Doc media uploads share the generic Drive media transport. The doc-specific // routing only shows up in parent_type/parent_node and optional route extra. - if fileSize <= common.MaxDriveMediaUploadSinglePartSize { + if cfg.FileSize <= common.MaxDriveMediaUploadSinglePartSize { return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ - FilePath: filePath, - FileName: fileName, - FileSize: fileSize, - ParentType: parentType, - ParentNode: &parentNode, + FilePath: cfg.FilePath, + Content: cfg.Content, + FileName: cfg.FileName, + FileSize: cfg.FileSize, + ParentType: cfg.ParentType, + ParentNode: &cfg.ParentNode, Extra: extra, }) } return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ - FilePath: filePath, - FileName: fileName, - FileSize: fileSize, - ParentType: parentType, - ParentNode: parentNode, + FilePath: cfg.FilePath, + Content: cfg.Content, + FileName: cfg.FileName, + FileSize: cfg.FileSize, + ParentType: cfg.ParentType, + ParentNode: cfg.ParentNode, Extra: extra, }) }