项目文件夹

文件
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

306 行
11 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// FormatRaw sends raw whiteboard node JSON to the create-nodes API.
FormatRaw = "raw"
// FormatPlantUML sends PlantUML source through the diagram import API.
FormatPlantUML = "plantuml"
// FormatMermaid sends Mermaid source through the diagram import API.
FormatMermaid = "mermaid"
// FormatSVG sends SVG source through the diagram import API.
FormatSVG = "svg"
)
var formatCodeMap = map[string]int{
FormatRaw: 0,
FormatPlantUML: 1,
FormatMermaid: 2,
FormatSVG: 3,
}
var wbUpdateScopes = []string{"board:whiteboard:node:create"}
var wbUpdateAuthTypes = []string{"user", "bot"}
var wbUpdateFlags = []common.Flag{
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
{Name: "source", Desc: "Input whiteboard data.", Required: true, Input: []string{common.Stdin, common.File}},
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid | svg. Default is raw.", Required: false},
}
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
itoken := runtime.Str("idempotent-token")
if err := common.RejectDangerousCharsTyped("--idempotent-token", itoken); err != nil {
return err
}
if itoken != "" && len(itoken) < 10 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").WithParam("--idempotent-token")
}
// 检查 --input_format 标志
format := getFormat(runtime)
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid && format != FormatSVG {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid | svg").WithParam("--input_format")
}
return nil
}
// getFormat 获取 format,默认返回 raw
func getFormat(runtime *common.RuntimeContext) string {
format := runtime.Str("input_format")
if format == "" {
return FormatRaw
}
return format
}
func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// 读取输入内容
input := runtime.Str("source")
if input == "" {
return common.NewDryRunAPI().Desc("read input failed: source is required")
}
format := getFormat(runtime)
token := runtime.Str("whiteboard-token")
overwrite := runtime.Bool("overwrite")
descStr := "will call whiteboard open api to update content."
desc := common.NewDryRunAPI().Desc(descStr)
switch format {
case FormatRaw:
nodes, err, _ := parseWBcliNodes([]byte(input))
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
reqBody := rawNodesCreateReq{
Nodes: nodes,
Overwrite: overwrite,
}
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc("create all nodes of the whiteboard.")
case FormatPlantUML, FormatMermaid, FormatSVG:
syntaxType := formatCodeMap[format]
reqBody := plantumlCreateReq{
PlantUmlCode: input,
SyntaxType: syntaxType,
ParseMode: 1,
DiagramType: 0,
Overwrite: overwrite,
}
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc(fmt.Sprintf("create %s node on the whiteboard.", format))
}
return desc
}
func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("whiteboard-token")
overwrite := runtime.Bool("overwrite")
idempotentToken := runtime.Str("idempotent-token")
format := getFormat(runtime)
input := runtime.Str("source")
if input == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "read input failed: source is required").WithParam("--source")
}
switch format {
case FormatRaw:
return updateWhiteboardByRawNodes(ctx, runtime, token, []byte(input), overwrite, idempotentToken)
case FormatPlantUML, FormatMermaid, FormatSVG:
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported format: %s", format).WithParam("--input_format")
}
}
// WhiteboardUpdateDescription describes the whiteboard update shortcut.
const WhiteboardUpdateDescription = "Update an existing whiteboard in lark document with mermaid, plantuml or whiteboard dsl. refer to lark-whiteboard skill for more details."
// WhiteboardUpdate registers the `whiteboard +update` shortcut.
var WhiteboardUpdate = common.Shortcut{
Service: "whiteboard",
Command: "+update",
Description: WhiteboardUpdateDescription,
Risk: "write",
Scopes: wbUpdateScopes,
AuthTypes: wbUpdateAuthTypes,
Flags: wbUpdateFlags,
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
Validate: wbUpdateValidate,
DryRun: wbUpdateDryRun,
Execute: wbUpdateExecute,
}
// WhiteboardUpdateOld 向前兼容历史版本 Doc 域下的更新命令
var WhiteboardUpdateOld = common.Shortcut{
Service: "docs",
Command: "+whiteboard-update",
Description: WhiteboardUpdateDescription,
Risk: "write",
Scopes: wbUpdateScopes,
AuthTypes: wbUpdateAuthTypes,
Flags: wbUpdateFlags,
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
Validate: wbUpdateValidate,
DryRun: wbUpdateDryRun,
Execute: wbUpdateExecute,
}
type plantumlCreateReq struct {
PlantUmlCode string `json:"plant_uml_code"`
SyntaxType int `json:"syntax_type"`
DiagramType int `json:"diagram_type,omitempty"`
ParseMode int `json:"parse_mode,omitempty"`
Overwrite bool `json:"overwrite,omitempty"`
}
type rawNodesCreateReq struct {
Nodes []interface{} `json:"nodes"`
Overwrite bool `json:"overwrite,omitempty"`
}
func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bool) {
var wbOutput WbCliOutput
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).WithParam("--source").WithCause(err), false
}
if (wbOutput.Code != 0 || wbOutput.Data.To != "openapi") && wbOutput.RawNodes == nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
}
if wbOutput.RawNodes != nil {
wbNodes = wbOutput.RawNodes
isRaw = true
} else {
if wbOutput.Data.Result.Nodes == nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
}
wbNodes = wbOutput.Data.Result.Nodes
}
return wbNodes, nil, isRaw
}
// updateWhiteboardByCode 使用 plantuml/mermaid 代码更新画板
func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, format string, overwrite bool, idempotentToken string) error {
syntaxType := formatCodeMap[format]
reqBody := plantumlCreateReq{
PlantUmlCode: string(input),
SyntaxType: syntaxType,
ParseMode: 1,
DiagramType: 0, // 0 表示自动识别
Overwrite: overwrite,
}
params := map[string]interface{}{}
if idempotentToken != "" {
params["client_token"] = idempotentToken
}
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)), params, reqBody)
if err != nil {
return err
}
nodeID := common.GetString(data, "node_id")
if nodeID == "" {
return wbInvalidResponse("update whiteboard by code failed: missing data.node_id")
}
outData := map[string]string{"created_node_id": nodeID}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "New node created.\n")
fmt.Fprintf(w, "Update whiteboard success")
})
return nil
}
// updateWhiteboardByRawNodes 使用原始 Open API 格式数据更新画板
func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, overwrite bool, idempotentToken string) error {
nodes, err, isRaw := parseWBcliNodes(input)
if err != nil {
return err
}
reqBody := rawNodesCreateReq{
Nodes: nodes,
Overwrite: overwrite,
}
params := map[string]interface{}{}
if idempotentToken != "" {
params["client_token"] = idempotentToken
}
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), params, reqBody)
if err != nil {
// Raw open-api JSON is hand-edited far more often than the DSL path, so
// steer the user back to the recommended workflow on any API failure.
if isRaw {
if p, ok := errs.ProblemOf(err); ok {
rawHint := "It is not advised to edit openapi format json directly. " +
"Please follow instruction in lark-whiteboard skill, using whiteboard-cli " +
"to transcript Whiteboard DSL pattern instead."
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + rawHint
} else {
p.Hint = rawHint
}
}
}
return err
}
nodeIDs, err := stringSlice(data["ids"])
if err != nil {
return err
}
outData := map[string]string{"created_node_ids": strings.Join(nodeIDs, ",")}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["created_node_ids"] != "" {
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
}
fmt.Fprintf(w, "Update whiteboard success")
})
return nil
}
// stringSlice coerces the JSON ids array into []string. A missing or malformed
// ids field is a response-shape bug, not a successful update with no output.
func stringSlice(v interface{}) ([]string, error) {
switch raw := v.(type) {
case []interface{}:
out := make([]string, 0, len(raw))
for i, e := range raw {
s, ok := e.(string)
if !ok {
return nil, wbInvalidResponse("update whiteboard failed: data.ids[%d] must be a string", i)
}
out = append(out, s)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("update whiteboard failed: data.ids must be an array of strings")
}
}