mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(doc): add --from-clipboard flag to docs +media-insert (#508)
* feat(doc): add --from-clipboard flag to docs +media-insert
Allow users to upload the current clipboard image directly to a Lark
document without saving to a local file first.
- New --from-clipboard bool flag (mutually exclusive with --file)
- shortcuts/doc/clipboard.go: readClipboardToTempFile() with per-OS impl
macOS — osascript (built-in, no extra deps)
Windows — PowerShell + System.Windows.Forms (built-in)
Linux — tries xclip / wl-paste / xsel in order; clear install hint
on failure
- No new Go dependencies, no Cgo
- Temp file is created before upload and removed via defer cleanup()
- --file changed from Required:true to optional; Validate enforces
exactly-one of --file / --from-clipboard
* fix(doc): fix clipboard image read on macOS for screenshots and browser-copied images
- Add TIFF fallback (macOS screenshots default to TIFF, not PNG)
- Add HTML base64 fallback (images copied from Feishu/browser embed data URI)
- Use current directory for temp file so FileIO path validation passes
* fix(doc): scan HTML/RTF/text clipboard formats for base64 image data URIs
Extend attempt-3 fallback to iterate all text-based clipboard formats
(HTML, RTF, UTF-8, plain text) rather than only HTML. Any format that
contains a "data:<mime>;base64,<data>" pattern is accepted, covering
images copied from Feishu, Chrome, Safari, and other apps that embed
base64 in non-HTML clipboard slots. Also handle URL-safe base64.
* test(doc): add unit tests for clipboard helpers to meet 60% coverage threshold
Cover decodeHex, hexVal, decodeOsascriptData, reBase64DataURI, and
extractBase64ImageFromClipboard (via fake osascript on PATH).
Package coverage: 57% → 61.2%.
* fix(doc): address CodeRabbit review comments on clipboard feature
- Extend reBase64DataURI regex to cover URL-safe base64 chars (-_) so
URL-safe payloads are matched before decoding is attempted
- Fix readClipboardLinux to continue to next tool when a found tool
returns empty output instead of failing immediately
- Guard fake-osascript test with runtime.GOOS == "darwin" skip
- Use os.PathListSeparator instead of hardcoded ":" in test PATH setup
* fix(doc): replace os.* temp-file clipboard path with in-memory streaming
Fixes forbidigo lint violations in shortcuts/doc: os.CreateTemp, os.Remove,
os.Stat, os.WriteFile are banned in shortcuts/; replaced with vfs.* equivalents
for sips TIFF→PNG conversion, and eliminated temp files entirely elsewhere by
having platform clipboard readers return []byte directly.
- readClipboardDarwin: osascript outputs hex literals decoded in Go (no file I/O)
- readClipboardWindows: PowerShell outputs base64 to stdout, decoded in Go
- readClipboardLinux: tool stdout bytes returned directly
- convertTIFFToPNGViaSips: still needs temp files — uses vfs.CreateTemp/Remove
- DriveMediaUploadAllConfig/DriveMediaMultipartUploadConfig: add Content io.Reader
field so in-memory clipboard bytes skip FileIO.Open() path
- Fix ineffassign in clipboard_test.go (scriptBody double-assignment)
- Update TestReadClipboardLinux_NoToolsReturnsError for new signature
* fix(doc): address CodeRabbit review comments on Linux clipboard path
- Update --from-clipboard flag description to list xclip, xsel and wl-paste
- Preserve last backend-specific error in readClipboardLinux so users see
a meaningful message when a tool is found but fails
- Validate PNG magic bytes for xsel output (xsel cannot negotiate MIME types)
- Add URL-safe base64 regression test for reBase64DataURI
* fix(doc): strip whitespace from base64 payload before decoding clipboard data URI
HTML and RTF clipboard content often line-wraps base64 at 76 characters.
FindSubmatch returns the raw wrapped token so direct decode would fail.
Normalize whitespace with strings.Fields before passing to base64.Decode.
* fix(doc): drop TIFF fallback and internal/vfs import on macOS clipboard
depguard rule shortcuts-no-vfs forbids shortcuts/ from importing
internal/vfs directly. The only caller was the sips TIFF→PNG
conversion, which was already a fragile best-effort fallback that
required temp files.
Remove the TIFF fallback entirely; the remaining two attempts cover
the real-world cases:
1. osascript → PNG hex literal — native screenshots and most apps
2. scan text clipboard formats for base64 data URI — Feishu/browsers
* test(doc): cover readClipboardLinux xsel PNG validation and dispatcher path
Added tests:
- TestReadClipboardLinux_XselRejectsNonPNG: fake xsel that returns plain
text is rejected by the PNG-magic check, preventing text from being
uploaded as an "image".
- TestHasPNGMagic: table-driven coverage of the PNG signature check.
- TestReadClipboardImageBytes_UnsupportedPlatform: exercises the shared
dispatcher post-processing and asserts the (nil, nil) invariant.
Raises clipboard.go diff coverage and brings the package from 61.6% to
63.8% overall.
* test: cover in-memory Content upload paths for clipboard feature
Adds unit tests for the new Content io.Reader branches introduced by
the clipboard feature:
- UploadDriveMediaAll with in-memory Content (drive_media_upload.go 87.5%)
- UploadDriveMediaMultipart with in-memory Content (84.6%)
- uploadDocMediaFile single-part and multipart with clipboard bytes
(doc_media_upload.go 0% -> 88.9%)
Adds TestNewRuntimeContextForAPI helper that wires Factory, context,
and bot identity so package tests can invoke DoAPI without mounting
the full cobra command tree.
* test: cover clipboard Validate/DryRun branches and testing helper
Adds unit tests for the clipboard-related Validate/DryRun paths that
Codecov patch-coverage was flagging as uncovered:
- Validate error when neither --file nor --from-clipboard is supplied
- Validate error when both are supplied (mutual exclusion)
- DryRun output contains <clipboard image> placeholder
- Self-test for TestNewRuntimeContextForAPI so shortcuts/common
sees coverage for the new helper (not just shortcuts/doc)
* test: cover Execute clipboard branch via injectable readClipboardImage
Makes readClipboardImageBytes swappable in tests by routing the call
through a package-level variable readClipboardImage. Tests inject a
synthetic PNG payload so the full Execute clipboard flow
(resolve → create block → upload in-memory bytes → bind) runs under
unit test without a real pasteboard.
Covers:
- TestDocMediaInsertExecuteFromClipboard: end-to-end happy path
- TestDocMediaInsertExecuteClipboardReadError: early-return on
readClipboardImage() failure
* ci: re-trigger pull_request workflow for PR #508
Previous push to 9dedb7a did not trigger the main CI workflow via
the pull_request event (only PR Labels ran). The workflow_dispatch
run I triggered manually lacks PR-scoped secrets so security and
e2e-live failed. An empty commit replays the pull_request event so
the full matrix (deadcode, license-header, security, e2e-live) runs
with proper context.
* test(doc): guard info.Size() behind err check to prevent nil-deref
CodeRabbit flagged that 't.Fatalf("... size=%d err=%v", info.Size(), err)'
evaluates info.Size() even when os.Stat returned (nil, err), which nil-derefs.
Split the check into two stages so the error-path t.Fatalf does not touch
info.
* fix(doc): address fangshuyu-768 review on clipboard PR
Seven code changes driven by review feedback:
1. clipboard.go: stop using CombinedOutput() on osascript / powershell.
Stdout is decoded, stderr is captured separately via cmd.Stderr and
surfaced in the terminal error message, so locale warnings or
AppleEvent permission prompts no longer pollute the hex/base64
payload or mask the real failure.
2. clipboard.go: validate decoded base64 data URI bytes against known
image magic headers (PNG/JPEG/GIF/WebP/BMP). A text clipboard that
happens to contain a literal 'data:image/...;base64,...' fragment
(documentation, tutorials, pasted HTML source) no longer silently
becomes an image upload.
3. clipboard.go: simplify the Linux 'no tool found' install hint to a
distro-agnostic phrasing instead of apt/yum only.
4. clipboard_test.go: delete the stale TestReadClipboardToTempFile_*
tests. They referenced a readClipboardToTempFile function that no
longer exists and only exercised os.CreateTemp/os.Remove. Replace
with TestReadClipboardImageBytes_EmptyResultReturnsError which
actually locks in the 'empty clipboard' → error contract of the
current API (Linux-only since mac/Windows need a real pasteboard).
5. doc_media_upload.go: introduce UploadDocMediaFileConfig struct so
uploadDocMediaFile takes a named config instead of 8 positional
params. Drops the //nolint:lll the old call site had to carry.
6. doc_media_insert.go: convert the clipboard upload call to the new
config struct and only set Config.Content when the clipboard branch
actually produced bytes — this also fixes a latent typed-nil bug
where a nil *bytes.Reader was being passed through an io.Reader
parameter, which tripped the 'if cfg.Content != nil' check in
UploadDriveMediaAll and crashed --file uploads.
7. shortcuts/common/testing.go: TestNewRuntimeContextForAPI now takes
the identity as an explicit core.Identity parameter instead of
hardcoding core.AsBot, and its self-test covers both AsBot and
AsUser. Existing call sites pass core.AsBot explicitly.
Also annotates DryRun output with an 'upload_size_note' when
--from-clipboard is set, since DryRun never reads the pasteboard and
can't predict whether the payload will take the single-part or
multipart path.
* fix(doc): capture line-wrapped base64 in clipboard data URI regex (#586)
HTML and RTF clipboard content commonly folds base64 payloads at
76 chars (standard MIME folding). The previous character class
[A-Za-z0-9+/\-_]+=* stopped at the first \n, so the downstream
strings.Fields normalisation was a no-op (nothing to strip) and
extractBase64ImageFromClipboard silently uploaded a truncated
payload whose 8-byte prefix happened to pass hasKnownImageMagic.
Extend the class to include \s so the Fields strip actually has
whitespace to remove before base64 decoding. Terminators (", <,
), ;) remain outside the class so the match still ends at the
URI boundary.
Add TestReBase64DataURI_LineWrapped covering \n, \r\n, and \t
folds, full round-trip byte-equality, and the terminator-boundary
invariant so any future regression trips a failing test.
* docs(skill): add clipboard-empty fallback guidance for +media-insert
When --from-clipboard returns 'no image data' (empty clipboard, non-image
content, or Linux without xclip/wl-paste/xsel), the agent must NOT silently
swallow the error. It should tell the user the clipboard had no image, ask
for a local file path, then retry the same insert command with --file.
Lists three anti-patterns (silent success, guessing a file path, pre-emptive
save-then-file workaround) that agents have been tempted into.
* docs(skill): user-stated source trumps clipboard/file heuristic
The heuristic table (prefer --from-clipboard when image is on the
clipboard) is a fallback for when the user is vague. If the user
explicitly says 'use the screenshot I just copied' → clipboard; if
they give a path → --file. Agent must not silently swap sources even
when the other looks 'better'.
---------
Co-authored-by: fangshuyu-768 <shuyufang768@outlook.com>
This commit is contained in:
@@ -40,6 +40,7 @@ type DriveMediaUploadAllConfig struct {
|
||||
// Reader, when non-nil, is used as the upload source instead of opening
|
||||
// FilePath. Callers must set FileName and FileSize explicitly. The reader
|
||||
// is NOT closed by UploadDriveMediaAll; the caller owns its lifetime.
|
||||
// Used by the clipboard path in docs +media-insert.
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
@@ -50,6 +51,8 @@ type DriveMediaMultipartUploadConfig struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
Extra string
|
||||
// Reader mirrors DriveMediaUploadAllConfig.Reader for chunked uploads.
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
||||
@@ -118,7 +121,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
|
||||
}
|
||||
|
||||
@@ -176,12 +179,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.Reader != nil {
|
||||
r = cfg.Reader
|
||||
} 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
|
||||
@@ -189,7 +198,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++ {
|
||||
@@ -198,12 +207,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)))
|
||||
|
||||
@@ -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{
|
||||
Reader: 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{
|
||||
Reader: 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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
50
shortcuts/common/testing_test.go
Normal file
50
shortcuts/common/testing_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
349
shortcuts/doc/clipboard.go
Normal file
349
shortcuts/doc/clipboard.go
Normal file
@@ -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<hex>» 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.).")
|
||||
}
|
||||
319
shortcuts/doc/clipboard_test.go
Normal file
319
shortcuts/doc/clipboard_test.go
Normal file
@@ -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<hex>» literal for the string "<img>"
|
||||
raw := []byte("<img>")
|
||||
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, "<img>"},
|
||||
{"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 := `<img src="data:image/png;base64,` + b64 + `">`
|
||||
|
||||
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 := `<img src="data:image/jpeg;base64,` + urlSafePayload + `">`
|
||||
|
||||
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 := `<img src="data:image/png;base64,` + wrapped + `">`
|
||||
|
||||
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 := `<img src="data:image/png;base64,` + b64 + `">`
|
||||
|
||||
// Encode htmlContent as a «data HTML<hex>» 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
|
||||
}
|
||||
@@ -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 = "<clipboard image>"
|
||||
}
|
||||
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.Reader = bytes.NewReader(clipboardContent)
|
||||
}
|
||||
fileToken, err := uploadDocMediaFile(runtime, uploadCfg)
|
||||
if err != nil {
|
||||
return withRollbackWarning(err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, "<clipboard image>") {
|
||||
t.Fatalf("dry-run output missing <clipboard image> 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{
|
||||
Reader: 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{
|
||||
Reader: 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{
|
||||
|
||||
@@ -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 Reader (in-memory source for
|
||||
// the clipboard flow) should be set. Leave Reader at its zero value (nil
|
||||
// interface) when the caller only has FilePath — passing a typed-nil
|
||||
// pointer like (*bytes.Reader)(nil) here would make Reader 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
|
||||
Reader 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,
|
||||
Reader: cfg.Reader,
|
||||
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,
|
||||
Reader: cfg.Reader,
|
||||
FileName: cfg.FileName,
|
||||
FileSize: cfg.FileSize,
|
||||
ParentType: cfg.ParentType,
|
||||
ParentNode: cfg.ParentNode,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,6 +133,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert an image/file at the end of a Lark document. Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
@@ -3,16 +3,54 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把“创建空 block → 上传文件 → 设置 token”三步合并成一个命令,在**文档末尾**插入本地图片或文件。
|
||||
把"创建空 block → 上传文件 → 设置 token"三步合并成一个命令,在**文档末尾**插入本地图片或文件。
|
||||
|
||||
## 来源选择(Agent 必读)
|
||||
|
||||
> **最高优先级:用户明确指定了来源,就严格按用户的来。** 下面的启发式只在用户没表态时生效。
|
||||
>
|
||||
> - 用户说"把这张截图插进去"、"用剪切板里的图"、"我刚复制的" → 无条件走 `--from-clipboard`。
|
||||
> - 用户说"用 `~/Downloads/foo.png`"、"插本地这个文件"、给了具体路径 → 无条件走 `--file`。
|
||||
> - 用户两者都没说清 → 按下表的启发式推断。
|
||||
>
|
||||
> 即使推断看起来更"优"(比如用户说了路径但你觉得走剪切板更省事),也**不要自作主张**换来源。要换,先问。
|
||||
|
||||
按下列顺序判断,**不要反向做**:
|
||||
|
||||
| 用户的图片来源 | 命令 | 禁止做法 |
|
||||
|----------------|------|----------|
|
||||
| 图片已经在剪切板里(截图快捷键、从飞书/浏览器复制、从设计稿复制) | `--from-clipboard` | ❌ 不要先把剪切板存到本地文件再用 `--file`。多一步文件 I/O,还得清理临时文件。 |
|
||||
| 图片是磁盘上的真实文件 | `--file <path>` | — |
|
||||
| 图片是 URL | 先下载到本地 → `--file`;或用 `drive` 相关命令 | — |
|
||||
|
||||
`--from-clipboard` 走进程内存直传,不产生临时文件;macOS / Windows 内置支持,Linux 需要 `xclip` 或 `wl-paste` 或 `xsel` 任一。
|
||||
|
||||
### 剪切板为空时的 fallback
|
||||
|
||||
`--from-clipboard` 失败(剪切板里不是图片 / 没有图片 / Linux 上三个工具都没装)时,命令会返回 `clipboard contains no image data`(或类似的平台错误)。**这不是错误退出理由,而是 fallback 信号。**
|
||||
|
||||
**Agent 的标准处置顺序**(每一步失败再进下一步,不要并行):
|
||||
|
||||
1. 先用 `--from-clipboard` 试一次。
|
||||
2. 如果返回"no image data"类错误,**向用户明确说明剪切板里没有可识别的图片**,请用户提供本地文件路径或重新复制一张图。
|
||||
3. 拿到本地路径后,用 `--file <path>` 重试**同一条插入命令**(其他参数如 `--doc` / `--align` / `--caption` 保持不变)。
|
||||
|
||||
**禁止做法**:
|
||||
- ❌ 不要悄悄把空剪切板当"成功但没插入"处理。必须提示用户。
|
||||
- ❌ 不要在剪切板失败后自行瞎猜某个本地文件路径(比如最近修改的 png)。必须让用户给路径。
|
||||
- ❌ 不要用"先让用户保存剪切板到磁盘再 `--file`"的建议绕过 `--from-clipboard`,当且仅当剪切板确实没图片时才退回本地路径。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 插入图片(默认)
|
||||
# 🟢 推荐:从剪切板直接插入(无需先存盘)
|
||||
lark-cli docs +media-insert --doc doxcnXXX --from-clipboard
|
||||
|
||||
# 从本地文件插入
|
||||
lark-cli docs +media-insert --doc doxcnXXX --file ./image.png
|
||||
|
||||
# doc 支持直接传 docx URL(自动提取 document_id)
|
||||
lark-cli docs +media-insert --doc "https://xxx.feishu.cn/docx/doxcnXXX" --file ./image.png
|
||||
lark-cli docs +media-insert --doc "https://xxx.feishu.cn/docx/doxcnXXX" --from-clipboard
|
||||
|
||||
# 如果上一步是 create-doc,优先传返回值里的 doc_id
|
||||
# 不要把 /wiki/... 形式的 doc_url 直接传给 docs +media-insert
|
||||
@@ -22,7 +60,7 @@ lark-cli docs +media-insert --doc doxcnReturnedByCreateDoc --file ./image.png
|
||||
lark-cli docs +media-insert --doc doxcnXXX --file ./spec.pdf --type file
|
||||
|
||||
# 图片对齐与描述(caption)
|
||||
lark-cli docs +media-insert --doc doxcnXXX --file ./arch.png --align center --caption "架构图"
|
||||
lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --align center --caption "架构图"
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -30,17 +68,28 @@ lark-cli docs +media-insert --doc doxcnXXX --file ./arch.png --align center --ca
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc <id>` | 是 | 文档 ID 或 docx URL(仅支持 `/docx/<document_id>` 形式自动提取;**不支持 `/wiki/...` URL 自动提取**) |
|
||||
| `--file <path>` | 是 | 本地文件路径(文件大于 20MB 时自动切换分片上传) |
|
||||
| `--type <type>` | 否 | `image`(默认)或 `file` |
|
||||
| `--from-clipboard` | 二选一 | 从系统剪切板读取图片(与 `--file` 互斥)。macOS/Windows 内置支持,Linux 需要 `xclip` / `wl-paste` / `xsel` 之一。 |
|
||||
| `--file <path>` | 二选一 | 本地文件路径(文件大于 20MB 时自动切换分片上传) |
|
||||
| `--type <type>` | 否 | `image`(默认)或 `file`。`--from-clipboard` 目前只产出 image。 |
|
||||
| `--align <align>` | 否 | 仅图片:`left` / `center`(默认)/ `right` |
|
||||
| `--caption <text>` | 否 | 仅图片:图片描述 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果上一步是 [`lark-doc-create`](lark-doc-create.md),并且它在知识库/知识空间场景下返回的是 `/wiki/...` 形式的 `doc_url`,后续调用 `docs +media-insert` 时应优先传 `doc_id`,不要直接传这个 `doc_url`。
|
||||
|
||||
## 平台注意(仅 `--from-clipboard`)
|
||||
|
||||
| 平台 | 依赖 | 典型错误 |
|
||||
|------|------|---------|
|
||||
| macOS | osascript(内置) | 剪切板为空 / 不是图片 → "clipboard contains no image data" |
|
||||
| Windows | PowerShell + System.Windows.Forms(内置) | 同上 |
|
||||
| Linux | `xclip` 或 `wl-paste` 或 `xsel` 任一 | 都没安装 → 报错会提示用发行版包管理器安装 |
|
||||
|
||||
命令不支持读取 TIFF 等非 PNG/JPEG/GIF/WebP/BMP 的冷门格式;遇到这类剪切板会返回 "contains no image data",此时才考虑先用系统工具转成文件再 `--file`。
|
||||
|
||||
## 输出
|
||||
|
||||
命令成功后会输出 JSON,包含:`document_id`、`block_id`、`file_token`、`file_name`、`type`。
|
||||
命令成功后会输出 JSON,包含:`document_id`、`block_id`、`file_token`、`file_name`(剪切板路径下为 `clipboard.png`)、`type`。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作**(会修改文档内容)—— 执行前必须确认用户意图。
|
||||
@@ -48,4 +97,4 @@ lark-cli docs +media-insert --doc doxcnXXX --file ./arch.png --align center --ca
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容(可用于确认插入后的结果、以及提取媒体 token)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
Reference in New Issue
Block a user