Compare commits

...

2 Commits

Author SHA1 Message Date
zhengkenghong
60e6bc2f3b feat(whiteboard): route node update through batch_update
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:48 +08:00
zhengkenghong
1fd29e75a6 feat: add whiteboard node shortcuts
Add whiteboard node create, update, and delete shortcuts with focused dry-run and unit coverage. Document the new node-level operations in the embedded lark-whiteboard skill and split node shortcut unit tests by command for maintainability.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:45 +08:00
24 changed files with 1707 additions and 13 deletions

View File

@@ -159,8 +159,7 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(acct.Brand)))
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
})
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
@@ -201,14 +202,27 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
// ShortcutHeaders extracts Shortcut info from the context and returns
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
func ShortcutHeaders(ctx context.Context) http.Header {
name, ok := ShortcutNameFromContext(ctx)
if !ok {
return nil
}
h := make(http.Header)
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
if name, ok := ShortcutNameFromContext(ctx); ok {
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
}
}
if name, value := extraHeaderFromEnv(); name != "" && value != "" {
h.Set(name, value)
}
if len(h) == 0 {
return nil
}
return h
}
func extraHeaderFromEnv() (string, string) {
name := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderName))
value := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderValue))
if name == "" || value == "" {
return "", ""
}
return name, value
}

View File

@@ -3,7 +3,12 @@
package core
import "strings"
import (
"os"
"strings"
"github.com/larksuite/cli/internal/envvars"
)
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
@@ -61,5 +66,8 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
func ResolveOpenBaseURL(brand LarkBrand) string {
if override := strings.TrimRight(strings.TrimSpace(os.Getenv(envvars.CliOpenBaseURL)), "/"); override != "" {
return override
}
return ResolveEndpoints(brand).Open
}

View File

@@ -58,6 +58,13 @@ func TestResolveOpenBaseURL(t *testing.T) {
}
}
func TestResolveOpenBaseURL_EnvOverride(t *testing.T) {
t.Setenv("LARKSUITE_CLI_OPEN_BASE_URL", "https://open.feishu-boe.cn/")
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu-boe.cn" {
t.Errorf("ResolveOpenBaseURL(feishu with env override) = %q", got)
}
}
func TestParseBrand(t *testing.T) {
cases := []struct {
in string

View File

@@ -22,6 +22,10 @@ const (
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliOpenBaseURL = "LARKSUITE_CLI_OPEN_BASE_URL"
CliExtraHeaderName = "LARKSUITE_CLI_EXTRA_HEADER_NAME"
CliExtraHeaderValue = "LARKSUITE_CLI_EXTRA_HEADER_VALUE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
CliCAPath = "LARKSUITE_CLI_CA_PATH"

View File

@@ -98,6 +98,26 @@ func TestCallAPITyped_Success(t *testing.T) {
}
}
func TestCallAPITyped_ExtraHeaderFromEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_NAME", "x-tt-env")
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_VALUE", "boe_whiteboard_test")
rt, reg := newCallAPITypedRuntime(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/wb/nodes/batch_update",
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"ids": []interface{}{"a1:1"}}},
}
reg.Register(stub)
_, err := rt.CallAPITyped("PUT", "/open-apis/board/v1/whiteboards/wb/nodes/batch_update", nil, map[string]any{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stub.CapturedHeaders.Get("x-tt-env"); got != "boe_whiteboard_test" {
t.Fatalf("x-tt-env header = %q, want boe_whiteboard_test", got)
}
}
// TestAPIClassifyContext verifies the classify context is built from the
// runtime: Brand / AppID from config, Identity from the resolved caller, and
// LarkCmd from the running command path.

View File

@@ -14,6 +14,9 @@ func Shortcuts() []common.Shortcut {
WhiteboardUpdateOld,
WhiteboardExport,
WhiteboardQuery,
WhiteboardNodeCreate,
WhiteboardNodeUpdate,
WhiteboardNodeDelete,
}
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
type whiteboardNodeBatchPayload struct {
Nodes []map[string]interface{} `json:"nodes"`
}
func parseWhiteboardNodeBatchPayload(raw []byte, requireID bool) (whiteboardNodeBatchPayload, error) {
var payload whiteboardNodeBatchPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).
WithParam("--source").
WithCause(err)
}
if len(payload.Nodes) == 0 {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, `--source must include non-empty "nodes"`).
WithParam("--source")
}
if requireID {
for i, node := range payload.Nodes {
id, ok := node["id"].(string)
if !ok || strings.TrimSpace(id) == "" {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "nodes[%d].id must be a non-empty string", i).
WithParam("--source")
}
}
}
return payload, nil
}
func parseWhiteboardNodeIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids is required").
WithParam("--node-ids")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids item %d must not be empty", i+1).
WithParam("--node-ids")
}
if _, ok := seen[id]; ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate node id %q", id).
WithParam("--node-ids")
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func validateOptionalWhiteboardNodeIdempotentToken(raw string) error {
if err := common.RejectDangerousCharsTyped("--idempotent-token", raw); err != nil {
return err
}
if raw != "" && len(raw) < 10 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").
WithParam("--idempotent-token")
}
return nil
}
func callWhiteboardNodeWrite(ctx context.Context, runtime *common.RuntimeContext, method, apiPath string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
Body: body,
QueryParams: whiteboardNodeQueryParams(params),
}
resp, err := runtime.DoAPI(req)
if err != nil {
return nil, err
}
data, classifyErr := runtime.ClassifyAPIResponse(resp)
if classifyErr == nil {
return data, nil
}
if resp.StatusCode >= http.StatusBadRequest {
return data, classifyErr
}
if isWhiteboardNodeNonObjectSuccess(classifyErr, resp) {
return nil, nil
}
return data, classifyErr
}
func whiteboardNodeQueryParams(params map[string]interface{}) larkcore.QueryParams {
query := make(larkcore.QueryParams)
for key, value := range params {
switch typed := value.(type) {
case []string:
for _, item := range typed {
query.Add(key, item)
}
case []interface{}:
for _, item := range typed {
query.Add(key, whiteboardNodeQueryValue(item))
}
default:
query.Set(key, whiteboardNodeQueryValue(value))
}
}
return query
}
func whiteboardNodeQueryValue(value interface{}) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func isWhiteboardNodeNonObjectSuccess(err error, resp *larkcore.ApiResp) bool {
if resp == nil {
return false
}
if _, ok := errs.ProblemOf(err); !ok {
return false
}
result, parseErr := client.ParseJSONResponse(resp)
if parseErr != nil {
return false
}
_, isObject := result.(map[string]interface{})
return !isObject
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"testing"
)
func TestShortcutsIncludesWhiteboardNodeCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+update",
"+export",
"+query",
"+node-create",
"+node-update",
"+node-delete",
}
seen := make(map[string]bool, len(got))
for _, shortcut := range got {
if seen[shortcut.Command] {
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
}
seen[shortcut.Command] = true
}
for _, command := range want {
if !seen[command] {
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
}
}
}
func TestParseWhiteboardNodeBatchPayload_MissingNodes(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{}`), false)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_EmptyNodes(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[]}`), false)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_InvalidJSONPreservesCause(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`not-json`), false)
assertValidationParam(t, err, "--source", true)
}
func TestParseWhiteboardNodeBatchPayload_RequireIDMissingID(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"text":{"text":"x"}}]}`), true)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_RequireIDBlankID(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":" ","text":{"text":"x"}}]}`), true)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_PreservesArbitraryFields(t *testing.T) {
t.Parallel()
payload, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":"node-1","type":"shape","custom":{"x":1},"points":[1,2]}]}`), true)
if err != nil {
t.Fatalf("parseWhiteboardNodeBatchPayload() error = %v", err)
}
if len(payload.Nodes) != 1 {
t.Fatalf("len(payload.Nodes) = %d, want 1", len(payload.Nodes))
}
node := payload.Nodes[0]
if got := node["id"]; got != "node-1" {
t.Errorf("node[id] = %v, want node-1", got)
}
if got := node["type"]; got != "shape" {
t.Errorf("node[type] = %v, want shape", got)
}
custom, ok := node["custom"].(map[string]interface{})
if !ok {
t.Fatalf("node[custom] = %T, want map[string]interface{}", node["custom"])
}
if got := custom["x"]; got != float64(1) {
t.Errorf("node[custom][x] = %v, want 1", got)
}
points, ok := node["points"].([]interface{})
if !ok {
t.Fatalf("node[points] = %T, want []interface{}", node["points"])
}
if len(points) != 2 || points[0] != float64(1) || points[1] != float64(2) {
t.Errorf("node[points] = %#v, want [1 2]", points)
}
}
func TestParseWhiteboardNodeIDs_TrimsItems(t *testing.T) {
t.Parallel()
ids, err := parseWhiteboardNodeIDs(" nodeA, nodeB ,nodeC ")
if err != nil {
t.Fatalf("parseWhiteboardNodeIDs() error = %v", err)
}
want := []string{"nodeA", "nodeB", "nodeC"}
if len(ids) != len(want) {
t.Fatalf("len(ids) = %d, want %d", len(ids), len(want))
}
for i := range want {
if ids[i] != want[i] {
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want[i])
}
}
}
func TestParseWhiteboardNodeIDs_RejectsEmptyInput(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs(" ")
assertValidationParam(t, err, "--node-ids", false)
}
func TestParseWhiteboardNodeIDs_RejectsEmptyItems(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs("nodeA, ,nodeB")
assertValidationParam(t, err, "--node-ids", false)
}
func TestParseWhiteboardNodeIDs_RejectsDuplicateIDs(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs("nodeA,nodeB,nodeA")
assertValidationParam(t, err, "--node-ids", false)
}
func TestValidateOptionalWhiteboardNodeIdempotentToken_TooShort(t *testing.T) {
t.Parallel()
err := validateOptionalWhiteboardNodeIdempotentToken("short")
assertValidationParam(t, err, "--idempotent-token", false)
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeCreateScopes = []string{"board:whiteboard:node:create"}
var wbNodeCreateAuthTypes = []string{"user", "bot"}
var wbNodeCreateFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to create nodes in. You need edit permission on the whiteboard.", Required: true},
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array.`, Required: true, Input: []string{common.Stdin, common.File}},
{Name: "idempotent-token", Desc: "idempotent token to make create requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
type whiteboardNodeCreateReq struct {
Nodes []map[string]interface{} `json:"nodes"`
}
func wbNodeCreateValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
return err
}
func wbNodeCreateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
dry := common.NewDryRunAPI().
POST(wbNodeCreateDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeCreateReq{Nodes: payload.Nodes}).
Desc("create nodes in the whiteboard.")
if params := wbNodeCreateParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeCreateExecute(_ context.Context, runtime *common.RuntimeContext) error {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
http.MethodPost,
wbNodeCreateURL(runtime.Str("whiteboard-token")),
wbNodeCreateParams(runtime),
whiteboardNodeCreateReq{Nodes: payload.Nodes},
)
if err != nil {
return err
}
nodeIDs, err := whiteboardNodeCreateIDs(data)
if err != nil {
return err
}
outData := map[string]string{}
if nodeIDs != nil {
outData["ids"] = strings.Join(nodeIDs, ",")
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["ids"] != "" {
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
}
fmt.Fprintf(w, "Create whiteboard nodes success")
})
return nil
}
func wbNodeCreateURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(token))
}
func wbNodeCreateDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))
}
func wbNodeCreateParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
func whiteboardNodeCreateIDs(data map[string]interface{}) ([]string, error) {
switch raw := data["ids"].(type) {
case nil:
return nil, nil
case []interface{}:
out := make([]string, 0, len(raw))
for i, value := range raw {
id, ok := value.(string)
if !ok {
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids[%d] must be a string", i)
}
out = append(out, id)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids must be an array of strings")
}
}
// WhiteboardNodeCreate registers the `whiteboard +node-create` shortcut.
var WhiteboardNodeCreate = common.Shortcut{
Service: "whiteboard",
Command: "+node-create",
Description: "Create nodes in an existing whiteboard.",
Risk: "write",
Scopes: wbNodeCreateScopes,
AuthTypes: wbNodeCreateAuthTypes,
Flags: wbNodeCreateFlags,
Validate: wbNodeCreateValidate,
DryRun: wbNodeCreateDryRun,
Execute: wbNodeCreateExecute,
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeCreateValidate_InvalidSourceTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"source": "not-json",
}, nil)
err := wbNodeCreateValidate(context.Background(), rt)
assertValidationParam(t, err, "--source", true)
}
func TestWhiteboardNodeCreateDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"idempotent-token": "create-token-12345",
"source": `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
}, nil)
dryRun := wbNodeCreateDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeCreateDryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "POST" {
t.Fatalf("method = %q, want POST", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes" {
t.Fatalf("url = %q, want node-create URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "create-token-12345" {
t.Fatalf("params.client_token = %#v, want create-token-12345", got.API[0].Params["client_token"])
}
nodes, ok := got.API[0].Body["nodes"].([]interface{})
if !ok || len(nodes) != 1 {
t.Fatalf("body.nodes = %#v, want one node", got.API[0].Body["nodes"])
}
node, ok := nodes[0].(map[string]interface{})
if !ok || node["type"] != "composite_shape" {
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
}
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
}
}
func TestWhiteboardNodeCreateExecute_PostsNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"node-1"},
},
},
}
reg.Register(stub)
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
nodes, ok := body["nodes"].([]interface{})
if !ok || len(nodes) != 1 {
t.Fatalf("body.nodes = %#v, want one node; body=%s", body["nodes"], string(stub.CapturedBody))
}
node, ok := nodes[0].(map[string]interface{})
if !ok || node["type"] != "composite_shape" {
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
}
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
}
if !strings.Contains(stdout.String(), `"ids": "node-1"`) {
t.Fatalf("stdout=%s, want ids node-1", stdout.String())
}
}
func TestWhiteboardNodeCreateExecute_AllowsMissingIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
}
func TestWhiteboardNodeCreateExecute_RejectsMalformedIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []interface{}{"node-1", 2},
},
},
})
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout)
if err == nil {
t.Fatal("expected malformed ids error, got nil")
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError", err)
}
if internalErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeDeleteScopes = []string{"board:whiteboard:node:delete"}
var wbNodeDeleteAuthTypes = []string{"user", "bot"}
var wbNodeDeleteFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to delete nodes from. You need edit permission on the whiteboard.", Required: true},
{Name: "node-ids", Desc: "comma-separated whiteboard node IDs to delete.", Required: true},
{Name: "idempotent-token", Desc: "idempotent token to make delete requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
type whiteboardNodeDeleteReq struct {
IDs []string `json:"ids"`
}
func wbNodeDeleteValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
return err
}
func wbNodeDeleteDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
if err != nil {
return common.NewDryRunAPI().Desc("parse node ids failed: " + err.Error())
}
dry := common.NewDryRunAPI().
DELETE(wbNodeDeleteDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeDeleteReq{IDs: ids}).
Desc("delete nodes from the whiteboard.")
if params := wbNodeDeleteParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeDeleteExecute(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
if err != nil {
return err
}
if _, err := callWhiteboardNodeWrite(
ctx,
runtime,
http.MethodDelete,
wbNodeDeleteURL(runtime.Str("whiteboard-token")),
wbNodeDeleteParams(runtime),
whiteboardNodeDeleteReq{IDs: ids},
); err != nil {
return err
}
outData := map[string]interface{}{
"ids": strings.Join(ids, ","),
"count": len(ids),
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d nodes deleted.\n", len(ids))
fmt.Fprintf(w, "Delete whiteboard nodes success")
})
return nil
}
func wbNodeDeleteURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(token))
}
func wbNodeDeleteDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))
}
func wbNodeDeleteParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
// WhiteboardNodeDelete registers the `whiteboard +node-delete` shortcut.
var WhiteboardNodeDelete = common.Shortcut{
Service: "whiteboard",
Command: "+node-delete",
Description: "Delete nodes from an existing whiteboard.",
Risk: "high-risk-write",
Scopes: wbNodeDeleteScopes,
AuthTypes: wbNodeDeleteAuthTypes,
Flags: wbNodeDeleteFlags,
Validate: wbNodeDeleteValidate,
DryRun: wbNodeDeleteDryRun,
Execute: wbNodeDeleteExecute,
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeDeleteValidate_InvalidNodeIDsTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"node-ids": "nodeA,,nodeB",
}, nil)
err := wbNodeDeleteValidate(context.Background(), rt)
assertValidationParam(t, err, "--node-ids", false)
}
func TestWhiteboardNodeDeleteMetadata_RiskHighRiskWrite(t *testing.T) {
t.Parallel()
if WhiteboardNodeDelete.Risk != "high-risk-write" {
t.Fatalf("Risk = %q, want high-risk-write", WhiteboardNodeDelete.Risk)
}
}
func TestWhiteboardNodeDeleteDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"node-ids": "nodeA,nodeB",
"idempotent-token": "delete-token-12345",
}, nil)
dryRun := wbNodeDeleteDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeDeleteDryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "DELETE" {
t.Fatalf("method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_delete" {
t.Fatalf("url = %q, want masked node-delete URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "delete-token-12345" {
t.Fatalf("params.client_token = %#v, want delete-token-12345", got.API[0].Params["client_token"])
}
ids, ok := got.API[0].Body["ids"].([]interface{})
if !ok || len(ids) != 2 {
t.Fatalf("body.ids = %#v, want two ids", got.API[0].Body["ids"])
}
if ids[0] != "nodeA" || ids[1] != "nodeB" {
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
}
}
func TestWhiteboardNodeDeleteExecute_PostsIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
stub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_delete",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
args := []string{"+node-delete", "--whiteboard-token", "test-board", "--node-ids", "nodeA,nodeB"}
if err := runUpdateShortcut(t, WhiteboardNodeDelete, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
ids, ok := body["ids"].([]interface{})
if !ok || len(ids) != 2 {
t.Fatalf("body.ids = %#v, want two ids; body=%s", body["ids"], string(stub.CapturedBody))
}
if ids[0] != "nodeA" || ids[1] != "nodeB" {
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
}
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeUpdateScopes = []string{"board:whiteboard:node:update"}
var wbNodeUpdateAuthTypes = []string{"user", "bot"}
var wbNodeUpdateFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update nodes in. You need edit permission on the whiteboard.", Required: true},
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array. Each node must include "id"; the batch_update body sends the full nodes array.`, Required: true, Input: []string{common.Stdin, common.File}},
{Name: "idempotent-token", Desc: "idempotent token to make batch update requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
func wbNodeUpdateValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
return err
}
func wbNodeUpdateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
dry := common.NewDryRunAPI().
PUT(wbNodeBatchUpdateDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeBatchUpdateBody(payload)).
Desc("batch update nodes in the whiteboard.")
if params := wbNodeUpdateParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
http.MethodPut,
wbNodeBatchUpdateURL(runtime.Str("whiteboard-token")),
wbNodeUpdateParams(runtime),
whiteboardNodeBatchUpdateBody(payload),
)
if err != nil {
return err
}
updatedNodeIDs, err := whiteboardNodeUpdateIDs(data)
if err != nil {
return err
}
outData := map[string]interface{}{
"ids": strings.Join(updatedNodeIDs, ","),
"count": len(updatedNodeIDs),
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d nodes updated.\n", len(updatedNodeIDs))
fmt.Fprintf(w, "Update whiteboard nodes success")
})
return nil
}
func wbNodeBatchUpdateURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", url.PathEscape(token))
}
func wbNodeBatchUpdateDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", common.MaskToken(url.PathEscape(token)))
}
func wbNodeUpdateParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
func whiteboardNodeBatchUpdateBody(payload whiteboardNodeBatchPayload) map[string]interface{} {
return map[string]interface{}{"nodes": payload.Nodes}
}
func whiteboardNodeUpdateIDs(data map[string]interface{}) ([]string, error) {
switch raw := data["ids"].(type) {
case nil:
return nil, nil
case []interface{}:
out := make([]string, 0, len(raw))
for i, value := range raw {
id, ok := value.(string)
if !ok {
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids[%d] must be a string", i)
}
out = append(out, id)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids must be an array of strings")
}
}
// WhiteboardNodeUpdate registers the `whiteboard +node-update` shortcut.
var WhiteboardNodeUpdate = common.Shortcut{
Service: "whiteboard",
Command: "+node-update",
Description: "Update nodes in an existing whiteboard.",
Risk: "write",
Scopes: wbNodeUpdateScopes,
AuthTypes: wbNodeUpdateAuthTypes,
Flags: wbNodeUpdateFlags,
Tips: []string{
`Pass --source as JSON with a non-empty "nodes" array; each node must include "id".`,
`Execution sends one whiteboard.node batch_update request and preserves node ids in the request body.`,
`Use --idempotent-token for retry-safe batch_update requests; the token is sent as client_token only when provided.`,
},
Validate: wbNodeUpdateValidate,
DryRun: wbNodeUpdateDryRun,
Execute: wbNodeUpdateExecute,
}

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeUpdateValidate_SourceMissingIDTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"source": `{"nodes":[{"type":"text","text":{"content":"hello"}}]}`,
}, nil)
err := wbNodeUpdateValidate(context.Background(), rt)
assertValidationParam(t, err, "--source", false)
}
func TestWhiteboardNodeUpdateDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"idempotent-token": "update-token-12345",
"source": `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`,
}, nil)
dryRun := wbNodeUpdateDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeUpdateDryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "PUT" {
t.Fatalf("method = %q, want PUT", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_update" {
t.Fatalf("url = %q, want masked batch_update URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "update-token-12345" {
t.Fatalf("params.client_token = %#v, want update-token-12345", got.API[0].Params["client_token"])
}
nodes, ok := got.API[0].Body["nodes"].([]interface{})
if !ok || len(nodes) != 2 {
t.Fatalf("body.nodes = %#v, want two nodes", got.API[0].Body["nodes"])
}
wantText := []string{"hello A", "hello B"}
for i := range nodes {
node, ok := nodes[i].(map[string]interface{})
if !ok {
t.Fatalf("body.nodes[%d] = %T, want map; nodes=%#v", i, nodes[i], nodes)
}
if node["id"] != []string{"nodeA", "nodeB"}[i] {
t.Fatalf("body.nodes[%d].id = %#v", i, node["id"])
}
text, ok := node["text"].(map[string]interface{})
if !ok || text["content"] != wantText[i] {
t.Fatalf("body.nodes[%d].text = %#v, want content %q", i, node["text"], wantText[i])
}
}
}
func TestWhiteboardNodeUpdateExecute_BatchUpdatesNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
var capturedQuery string
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"nodeA", "nodeB"},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
}
reg.Register(stub)
source := `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source, "--idempotent-token", "update-token-12345"}
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
assertNodeBatchUpdateCapturedBody(t, stub.CapturedBody, []string{"hello A", "hello B"})
if !strings.Contains(capturedQuery, "client_token=update-token-12345") {
t.Fatalf("query = %q, want client_token", capturedQuery)
}
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
}
if !strings.Contains(stdout.String(), `"count": 2`) {
t.Fatalf("stdout=%s, want count 2", stdout.String())
}
}
func TestWhiteboardNodeUpdateExecute_WithoutIdempotentTokenOmitsClientToken(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
var capturedQuery string
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"nodeA"},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
}
reg.Register(stub)
source := `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}}]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if capturedQuery != "" {
t.Fatalf("query = %q, want empty when --idempotent-token is absent", capturedQuery)
}
}
func TestWhiteboardNodeUpdateExecute_BatchFailureReturnsAPIError(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 1254001,
"msg": "node not found",
"data": map[string]interface{}{},
},
})
source := `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout)
if err == nil {
t.Fatal("expected batch update failure error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false for %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("Category = %q, want %q", problem.Category, errs.CategoryAPI)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *errs.APIError reachable via errors.As", err)
}
}
func TestWhiteboardNodeUpdateTips_MentionTemporaryNonAtomicBehavior(t *testing.T) {
t.Parallel()
tips := strings.Join(WhiteboardNodeUpdate.Tips, "\n")
for _, want := range []string{"batch_update", "client_token", "one whiteboard.node batch_update request"} {
if !strings.Contains(tips, want) {
t.Fatalf("tips = %q, want substring %q", tips, want)
}
}
for _, banned := range []string{"fans out", "non-atomic", "Temporary behavior"} {
if strings.Contains(tips, banned) {
t.Fatalf("tips = %q, should not contain old fan-out wording %q", tips, banned)
}
}
}
func assertNodeBatchUpdateCapturedBody(t *testing.T, raw []byte, wantContent []string) {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(raw))
}
nodes, ok := body["nodes"].([]interface{})
if !ok || len(nodes) != len(wantContent) {
t.Fatalf("body.nodes = %#v, want %d nodes; body=%s", body["nodes"], len(wantContent), string(raw))
}
for i, rawNode := range nodes {
node, ok := rawNode.(map[string]interface{})
if !ok {
t.Fatalf("body.nodes[%d] = %T, want map; body=%s", i, rawNode, string(raw))
}
if _, exists := node["id"]; !exists {
t.Fatalf("body.nodes[%d].id absent; body=%s", i, string(raw))
}
text, ok := node["text"].(map[string]interface{})
if !ok || text["content"] != wantContent[i] {
t.Fatalf("body.nodes[%d].text = %#v, want content %q; body=%s", i, node["text"], wantContent[i], string(raw))
}
}
}

View File

@@ -2,7 +2,7 @@
name: lark-whiteboard
version: 1.0.0
description: >
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容。
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容,并支持按节点增量创建、更新和删除
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。不负责飞书云文档内容编辑lark-doc、文档内嵌电子表格/Baselark-sheets / lark-base
metadata:
requires:
@@ -28,7 +28,10 @@ metadata:
| 导出 SVG 矢量图 | [`+export --output-type svg`](references/lark-whiteboard-export.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 检查画板是否由代码绘制 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 仅微调节点文字/颜色 | `+export --output-type raw` → 手动改 JSON → `+update --input_format raw` |
| 定位节点 id / 查看原始节点结构 | [`+export --output-type raw`](references/lark-whiteboard-export.md) |
| 已知 node id, 微调文字/颜色/样式 | [`+node-update`](references/lark-whiteboard-node-update.md); 先用 `+export --output-type raw` 定位节点 |
| 追加已编译好的 OpenAPI 节点 | [`+node-create`](references/lark-whiteboard-node-create.md); 节点建议由 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi` 生成后整理成 `{ "nodes": [...] }` |
| 删除已知节点 | [`+node-delete`](references/lark-whiteboard-node-delete.md); 删除前先确认 node id, 真实执行需要 `--yes` |
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml/svg`](references/lark-whiteboard-update.md) |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
@@ -39,6 +42,9 @@ metadata:
|---------------------------------------------------|---|
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
| [`+node-create`](references/lark-whiteboard-node-create.md) | 向已有画板追加 OpenAPI 节点;适合已由工具生成节点数据的增量新增 |
| [`+node-update`](references/lark-whiteboard-node-update.md) | 按节点 id 批量更新已有节点;执行层发起一次 batch_update 请求 |
| [`+node-delete`](references/lark-whiteboard-node-delete.md) | 按节点 id 删除已有节点;高风险写操作,执行前必须确认目标节点 |
---

View File

@@ -18,7 +18,7 @@
- `preview`:预览图片。推荐 `--output ./preview` 这类无后缀文件路径CLI 会按实际图片类型保存为 `./preview.png``./preview.jpg`。如果 `--output` 是目录,会保存为该目录下的 `whiteboard_<whiteboard-token>.png/.jpg`;如果显式写了后缀,需要和实际图片类型匹配。`--overwrite` 检查的是补齐后缀后的最终路径,例如返回 PNG 时 `--output ./preview` 对应覆盖 `./preview.png`
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `source`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
- `raw`:飞书 OpenAPI 原生画板节点格式。主要用于定位 `data.nodes[].id` 和核对节点字段。已知 node id 的局部修改优先用 [`+node-update`](./lark-whiteboard-node-update.md),删除用 [`+node-delete`](./lark-whiteboard-node-delete.md);不要手动改 raw JSON 后用 `+update --input_format raw` 做节点级微调。复杂设计/修改参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
## 示例

View File

@@ -0,0 +1,97 @@
# whiteboard +node-create
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
向已有画板追加 OpenAPI 节点。它适合局部新增已编译好的节点, 不适合从零创作复杂图表。
## 适用场景
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
- 已经有可追加的 OpenAPI `nodes[]`
- 需要向已有画板追加节点, 而不是覆盖整图。
## 不适用场景
- 从零创作复杂图表, 或需要自动布局、批量排版、复杂连线计算。
- 只有 DSL / Mermaid / SVG, 还没有转换成 OpenAPI 节点。
- 只想在文档正文里插入或移动画板块, 这属于 `lark-doc`
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--whiteboard-token` | 是 | 画板 token。 |
| `--source` | 是 | JSON, 必须包含非空 `nodes` 数组。支持 `@path` 文件读取或 `-` stdin。 |
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符。重试同一次逻辑新增时复用同一个值。 |
## 输入
`nodes[]` 必须是飞书 OpenAPI 画板节点, 不是 whiteboard-cli DSL。不要把 `{"type":"shape","shape":...}` 这类 DSL 节点直接传给本命令。
推荐先用 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi --format json` 生成 OpenAPI 结果, 再整理成 `{ "nodes": [...] }`
```json
{
"nodes": [
{
"id": "tmpNode",
"type": "composite_shape",
"x": 0,
"y": 0,
"width": 260,
"height": 45,
"text": {
"text": "hello",
"font_weight": "regular",
"font_size": 14,
"horizontal_align": "center",
"vertical_align": "mid"
},
"style": {
"border_color": "#3370ff",
"border_width": "narrow",
"border_style": "solid",
"fill_color": "#e8f3ff"
},
"composite_shape": {
"type": "round_rect"
}
}
]
}
```
## 示例
```bash
lark-cli whiteboard +node-create \
--whiteboard-token <whiteboard_token> \
--source @./nodes.json \
--idempotent-token <10+字符唯一串> \
--as user \
--dry-run
lark-cli whiteboard +node-create \
--whiteboard-token <whiteboard_token> \
--source @./nodes.json \
--idempotent-token <10+字符唯一串> \
--as user
```
## 输出
JSON 输出使用 `data.ids`, 多个 id 用逗号拼接:
```json
{
"data": {
"ids": "o2:5"
}
}
```
## Safety
- 写入前先用 `--dry-run` 检查 method、URL、params 和 body。
- 对手写节点尤其要先 dry-rundry-run 只能验证请求结构, 不能证明节点语义一定可插入。
- 复杂图表继续走 `whiteboard-cli -> +update` 或 workflow 路径, 不要把 `+node-create` 当作默认创作入口。

View File

@@ -0,0 +1,75 @@
# whiteboard +node-delete
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
按 node id 删除已有节点。这是高风险写操作, 只能删除已经确认的目标节点。
## 适用场景
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
- 已经确认要删除的 node id。
- 需要删除已有画板中的局部节点。
## 不适用场景
- 不知道目标 node id。
- 只是想隐藏、移动或更新节点。
- 需要清空或整体替换画板。
## 定位节点
先导出 raw 节点结构:
```bash
lark-cli whiteboard +export \
--whiteboard-token <whiteboard_token> \
--output-type raw \
--as user
```
从返回的 `data.nodes[].id` 读取目标 node id。不要删除从上下文猜测出来的 ambient 节点。
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--whiteboard-token` | 是 | 画板 token。 |
| `--node-ids` | 是 | 要删除的 node id, 多个 id 用英文逗号分隔。 |
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符。重试同一次逻辑删除时复用同一个值。 |
| `--yes` | 真实执行需要 | 高风险写操作确认。先 dry-run, 确认目标后再传。 |
## 示例
```bash
lark-cli whiteboard +node-delete \
--whiteboard-token <whiteboard_token> \
--node-ids <node_id_1>,<node_id_2> \
--idempotent-token <10+字符唯一串> \
--as user \
--dry-run
lark-cli whiteboard +node-delete \
--whiteboard-token <whiteboard_token> \
--node-ids <node_id_1>,<node_id_2> \
--idempotent-token <10+字符唯一串> \
--as user \
--yes
```
## 输出
```json
{
"data": {
"ids": "o2:5,o2:6",
"count": 2
}
}
```
## Safety
- 删除前必须用 `+export --output-type raw` 确认 node id。
- 先运行 `--dry-run`, 检查 method 是 `DELETE`, URL 是 `/nodes/batch_delete`, body 是 `{"ids":[...]}`
- 只有确认目标节点后才传 `--yes`
- 不要因为用户说“删掉这个”就删除最近消息里的节点;缺少 node id 时先导出 raw 或要求定位依据。

View File

@@ -0,0 +1,100 @@
# whiteboard +node-update
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
按 node id 更新已有节点字段。CLI 输入是批量形态, 执行层会发起一次 `batch_update` OpenAPI 请求。
## 适用场景
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
- 已经知道目标 node id。
- 需要局部修改文字、颜色、样式、位置等节点字段。
## 不适用场景
- 不知道目标 node id。
- 需要重绘复杂图表或重新布局。
- 想替换整个画板内容。
## 定位节点
先导出 raw 节点结构:
```bash
lark-cli whiteboard +export \
--whiteboard-token <whiteboard_token> \
--output-type raw \
--as user
```
从返回的 `data.nodes[].id` 读取目标 node id, 再构造更新输入。
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--whiteboard-token` | 是 | 画板 token。 |
| `--source` | 是 | JSON, 必须包含非空 `nodes` 数组, 每个 node 必须包含 `id`。支持 `@path` 文件读取或 `-` stdin。 |
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符;非空时作为 `client_token` 随 batch_update 请求发送。 |
## 输入
CLI 输入保持批量形态:
```json
{
"nodes": [
{
"id": "o2:5",
"type": "composite_shape",
"text": {
"text": "updated",
"font_weight": "regular",
"font_size": 14,
"horizontal_align": "center",
"vertical_align": "mid"
}
}
]
}
```
执行时所有节点会保持在同一个请求中:
- `PUT /open-apis/board/v1/whiteboards/:whiteboard_id/nodes/batch_update`
- body 为 `{"nodes": [...]}`, 节点内的 `id` 会保留。
- `--idempotent-token` 非空时, query 参数带 `client_token=<token>`
## 示例
```bash
lark-cli whiteboard +node-update \
--whiteboard-token <whiteboard_token> \
--source @./node-updates.json \
--idempotent-token <10+字符唯一串> \
--as user \
--dry-run
lark-cli whiteboard +node-update \
--whiteboard-token <whiteboard_token> \
--source @./node-updates.json \
--idempotent-token <10+字符唯一串> \
--as user
```
## 输出
```json
{
"data": {
"ids": "o2:5",
"count": 1
}
}
```
## Safety
- 多节点更新前先使用 `--dry-run` 检查 batch_update method、URL、params 和 body。
- batch_update 后端不承诺跨阶段事务回滚;如服务端提示请求未完整完成, 需用 `+export --output-type raw` 读回目标节点确认状态。
- 不要在节点更新失败时自动回退到 `+update --overwrite`, 除非用户明确要求替换整个画板。

View File

@@ -30,6 +30,12 @@
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码SVG/DSL 或其他方式绘制的画板)
│ ├─ 已知 node id, 只需局部微调
│ │ → +export --output-type raw 确认节点 → +node-update → +export --output-type raw 或 preview 复验
│ ├─ 已知 node id, 需要删除局部节点
│ │ → +export --output-type raw 确认节点 → +node-delete --dry-run → +node-delete --yes → preview 复验
│ ├─ 已生成 OpenAPI nodes[] 且只需追加
│ │ → +node-create --dry-run → +node-create → +export --output-type raw 或 preview 复验
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
│ │ → +export --output-type preview → 看图 → +export --output-type raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
@@ -83,5 +89,6 @@ diagram.png ← 渲染结果
- Mermaid / PlantUML / SVG 产物直接用对应的 `mermaid` / `plantuml` / `svg` 写入。
- 只有 DSL 产物或已明确需要 OpenAPI 原生节点格式时,才先用 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi --format json` 转换,再用 `raw` 写入。
- 如果目标是向已有画板追加已编译好的 OpenAPI `nodes[]`, 优先用 [`whiteboard +node-create`](./lark-whiteboard-node-create.md), 不要为了追加节点覆盖整图。
具体命令示例、`--overwrite``--idempotent-token``--as user/bot` 的使用方式,统一参考 [`whiteboard +update`](./lark-whiteboard-update.md)。

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardNodeCreateDryRun_RequestShape(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+node-create",
"--whiteboard-token", "wbcnCreateDryRun",
"--source", `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.#").Int(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, "/nodes") || strings.Contains(gotURL, "wbcnCreateDryRun") {
t.Fatalf("url=%q, want masked board whiteboard nodes URL\nstdout:\n%s", gotURL, out)
}
require.Equal(t, "composite_shape", clie2e.DryRunGet(out, "api.0.body.nodes.0.type").String(), out)
require.Equal(t, "round_rect", clie2e.DryRunGet(out, "api.0.body.nodes.0.composite_shape.type").String(), out)
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardNodeDeleteDryRun_RequestShape(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+node-delete",
"--whiteboard-token", "wbcnDeleteDryRun",
"--node-ids", "nodeA,nodeB",
"--idempotent-token", "delete-token-12345",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.#").Int(), out)
require.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.0.method").String(), out)
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") ||
!strings.HasSuffix(gotURL, "/nodes/batch_delete") ||
strings.Contains(gotURL, "wbcnDeleteDryRun") {
t.Fatalf("url=%q, want masked board whiteboard batch delete URL\nstdout:\n%s", gotURL, out)
}
require.Equal(t, "delete-token-12345", clie2e.DryRunGet(out, "api.0.params.client_token").String(), out)
require.Equal(t, "nodeA", clie2e.DryRunGet(out, "api.0.body.ids.0").String(), out)
require.Equal(t, "nodeB", clie2e.DryRunGet(out, "api.0.body.ids.1").String(), out)
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardNodeUpdateDryRun_RequestShape(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+node-update",
"--whiteboard-token", "wbcnUpdateDryRun",
"--source", `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}},{"id":"nodeB","type":"text","text":{"content":"hello B"}}]}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(2), clie2e.DryRunGet(out, "api.#").Int(), out)
for i, nodeID := range []string{"nodeA", "nodeB"} {
require.Equal(t, "PUT", clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".method").String(), out)
gotURL := clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") ||
!strings.HasSuffix(gotURL, "/nodes/"+nodeID) ||
strings.Contains(gotURL, "wbcnUpdateDryRun") {
t.Fatalf("url=%q, want masked board whiteboard node update URL ending with %s\nstdout:\n%s", gotURL, nodeID, out)
}
require.False(t, clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.id").Exists(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.type").String(), out)
require.Equal(t, "hello "+string(rune('A'+i)), clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.text.content").String(), out)
}
}