Compare commits

...

9 Commits

Author SHA1 Message Date
coderabbitai[bot]
6cfc2317a1 📝 Add docstrings to feat/whiteboard-skill-cli-v0.2.12
Docstrings generation was requested by @cl900811.

* https://github.com/larksuite/cli/pull/1559#issuecomment-4787542760

The following files were modified:

* `shortcuts/whiteboard/whiteboard_query.go`
* `shortcuts/whiteboard/whiteboard_update.go`
2026-06-24 13:12:34 +00:00
cl900811
418068f728 fix(whiteboard): add whiteboard query shortcut unit test 2026-06-24 21:08:48 +08:00
cl900811
cb63055c22 fix(whiteboard): whiteboard shortcuts unit test 2026-06-24 19:30:12 +08:00
cl900811
aeb40e67af feat(whiteboard): pin whiteboard-cli to v0.2.12 in lark-whiteboard skill 2026-06-24 17:52:08 +08:00
cl900811
7889be8902 feat(lark-whiteboard): update shortcut, support query or update whiteboard by svg 2026-06-24 17:52:08 +08:00
ILUO
b46e60c156 feat: add task event consumer (#1510)
* feat: add task event consumer

* fix: address task event review feedback

* feat: remove legacy task event subscription shortcut

* test: strengthen task preconsume error assertions
2026-06-24 17:32:02 +08:00
chenxingtong-bytedance
d71bab0061 docs(im): clarify audio message opus requirement (#1271)
Fix IM shortcut behavior for audio messages to match the Feishu/Lark file upload API: --audio is for voice messages and supports only Opus audio. Non-Opus local/URL inputs such as mp3 and wav are now rejected before upload with an actionable typed validation error. Users can still send those files as attachments with --file
2026-06-24 14:41:54 +08:00
liangshuo-1
d11a6e97a4 chore: release v1.0.57 (#1553) 2026-06-23 20:43:41 +08:00
raistlin042
e4248d1154 fix: harden lark-apps +init/+html-publish and skill guidance (#1517)
* fix: reject +init into a different app's project directory

* fix: reject single HTML files larger than 10MB in +html-publish

* docs: clarify publish visibility, domain routing, and role/permission boundary
2026-06-23 20:18:10 +08:00
53 changed files with 1991 additions and 372 deletions

View File

@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
@@ -1212,6 +1236,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54

View File

@@ -26,6 +26,7 @@ func TestRunList_TextOutput(t *testing.T) {
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -55,4 +56,17 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
}
var foundTask bool
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
}
}
}
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
}

View File

@@ -96,6 +96,34 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["jq_root_path"] != ".event" {
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
}
if payload["single_consumer"] != true {
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -7,6 +7,7 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
@@ -17,6 +18,7 @@ func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}

23
events/task/native.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
// standard Lark V2 event envelope.
type TaskUpdateUserAccessV2Data struct {
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
}
var taskUpdateUserAccessCommitTypes = []string{
"task_create",
"task_deleted",
"task_summary_update",
"task_desc_update",
"task_assignees_update",
"task_followers_update",
"task_reminders_update",
"task_start_due_update",
"task_completed_update",
}

32
events/task/preconsume.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"failed to subscribe task event",
).WithCause(err)
}
return nil, nil
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
type stubAPIClient struct {
err error
method string
path string
body interface{}
calls int
}
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
s.method = method
s.path = path
s.body = body
s.calls++
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
}
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
rt := &stubAPIClient{}
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
}
if cleanup != nil {
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
}
if rt.calls != 1 {
t.Fatalf("calls = %d, want 1", rt.calls)
}
if rt.method != "POST" {
t.Errorf("method = %q, want POST", rt.method)
}
if rt.path != taskSubscriptionPath {
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
}
if rt.body != nil {
t.Errorf("body = %#v, want nil", rt.body)
}
}
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
}
}
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
rt := &stubAPIClient{err: wantErr}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != wantErr {
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
cause := errors.New("connection reset")
rt := &stubAPIClient{err: cause}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, cause) {
t.Fatalf("err = %v, want cause %v", err, cause)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
}
}

33
events/task/register.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package task registers Task-domain EventKeys.
package task
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
// Keys returns all Task-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeTaskUpdateUserAccessV2,
DisplayName: "Task updated",
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
EventType: eventTypeTaskUpdateUserAccessV2,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
},
PreConsume: taskSubscriptionPreConsume,
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
SingleConsumer: true,
},
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
}
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
}
if def.Schema.Native == nil {
t.Fatal("Schema.Native is nil")
}
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
}
if def.Process != nil {
t.Fatal("Native Task EventKey must not set Process")
}
if def.PreConsume == nil {
t.Fatal("PreConsume is nil")
}
if !def.SingleConsumer {
t.Fatal("SingleConsumer = false, want true")
}
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
t.Errorf("Scopes = %#v", def.Scopes)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
taskGUID := eventProps["task_guid"].(map[string]interface{})
if got := taskGUID["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
eventTypes := eventProps["event_types"].(map[string]interface{})
items := eventTypes["items"].(map[string]interface{})
rawEnum, ok := items["enum"].([]interface{})
if !ok {
t.Fatalf("event_types item enum missing: %#v", items["enum"])
}
got := make(map[string]bool, len(rawEnum))
for _, v := range rawEnum {
got[v.(string)] = true
}
for _, want := range taskUpdateUserAccessCommitTypes {
if !got[want] {
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
}
}
}
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.56",
"version": "1.0.57",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
@@ -84,6 +85,9 @@ var AppsHTMLPublish = common.Shortcut{
// for dry-run "advisory preview" semantics).
dry.Set("validation_error", err.Error())
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
dry.Set("oversize_html", hits)
}
dry.Set("file_count", len(candidates))
var totalSize int64
names := make([]string, 0, len(candidates))
@@ -140,18 +144,22 @@ type appsHTMLPublishSpec struct {
// per-environment .env.* files for every stage).
const maxSensitiveListInError = 5
// truncatedJoin joins items with ", ", capping at max entries and appending
// "(and N more)" for the remainder, so an inline error list stays readable when
// a payload has many hits.
func truncatedJoin(items []string, max int) string {
if len(items) <= max {
return strings.Join(items, ", ")
}
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
}
// sensitiveCandidatesError builds the Validate-time rejection when --path
// contains credential files and --allow-sensitive was not set.
func sensitiveCandidatesError(hits []string) error {
var sample string
if len(hits) <= maxSensitiveListInError {
sample = strings.Join(hits, ", ")
} else {
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
}
return appsValidationParamError("--path",
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
"--path contains %d credential file(s) that should not be published: %s",
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
}
@@ -168,6 +176,30 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
// Mutable for tests.
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
// 用 var 而非 const便于单测调小覆盖拦截路径。
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html大小写不敏感且单个 Size 超过
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
var hits []string
for _, c := range candidates {
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
hits = append(hits, c.RelPath)
}
}
return hits
}
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
func oversizeHTMLFilesError(hits []string) error {
return appsValidationParamError("--path",
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
}
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
// 目录形态:根目录下必须有 index.html。
// 单文件形态:文件名必须就是 index.html。
@@ -190,6 +222,9 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
return nil, oversizeHTMLFilesError(hits)
}
var rawTotal int64
for _, c := range candidates {
rawTotal += c.Size

View File

@@ -503,3 +503,82 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
cands := []htmlPublishCandidate{
{RelPath: "index.html", Size: 50},
{RelPath: "big.html", Size: 4096},
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
{RelPath: "huge.png", Size: 9000}, // 非 .html忽略
}
hits := oversizeHTMLFiles(cands)
if len(hits) != 2 {
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
}
for _, h := range hits {
if h == "huge.png" || h == "index.html" {
t.Fatalf("unexpected hit %q", h)
}
}
}
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
}
}
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected per-file oversize error")
}
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}

View File

@@ -99,7 +99,14 @@ var AppsInit = common.Shortcut{
dry.Set("dir_error", err.Error())
dir = defaultCloneDir(appID)
} else if isAlreadyInitialized(dir) {
dry.Set("already_initialized", true)
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
if existing != "" {
dry.Set("app_id_mismatch", existing)
}
dry.Set("dir_error", e.Error())
} else {
dry.Set("already_initialized", true)
}
} else if e := ensureEmptyDir(dir); e != nil {
dry.Set("dir_error", e.Error())
}
@@ -199,6 +206,61 @@ func isAlreadyInitialized(dir string) bool {
return err == nil && !info.IsDir()
}
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id用于判断目标目录是否同一个妙搭应用。
// 返回 (appID, isSparkProject, err)
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
// - 解析成功 → (trim 后的 app_id, true, nil)app_id 缺失/为空时为 ""
func readMetaAppID(dir string) (string, bool, error) {
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
}
var m struct {
AppID string `json:"app_id"`
}
if err := json.Unmarshal(b, &m); err != nil {
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
}
return strings.TrimSpace(m.AppID), true, nil
}
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
// - 不是妙搭工程(无 meta.json → nil交给 ensureEmptyDir 判空/非空)
// - 是妙搭工程且 app_id 与 appID 一致 → nil走已初始化短路复用本地代码
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
// - meta.json 损坏/不可读,无法确认 → 报错fail closed提示换目录
//
// 返回值 existing 是目录里已存在的 app_id仅"已是另一个 app"的拒绝场景非空),供调用方在
// dry-run 里回填 app_id_mismatch避免二次读 meta.json。
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
existing, isSpark, readErr := readMetaAppID(dir)
if readErr != nil {
return "", appsValidationParamError("--dir",
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
WithCause(readErr)
}
if !isSpark || existing == appID {
return existing, nil
}
if existing == "" {
// meta 存在但缺 app_id更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
return "", appsValidationParamError("--dir",
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("remove the directory and re-run +init, or choose a different --dir")
}
return existing, appsValidationParamError("--dir",
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
dir, existing, appID).
WithHint("choose a different --dir (or cd into the matching project) before running +init")
}
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
// the file does not exist, this is a no-op (we never create it).
@@ -378,6 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
return err
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.

View File

@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
@@ -394,6 +394,40 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
}
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
dir := relCloneDir(t)
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("mismatched app_id must error")
}
problem := requireAppsValidationProblem(t, err)
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--dir" {
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
}
if !strings.Contains(problem.Message, "different app") {
t.Fatalf("message=%q, want 'different app'", problem.Message)
}
for _, c := range f.calls {
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
}
}
}
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_x.git"),
@@ -1468,6 +1502,125 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
}
}
func TestReadMetaAppID(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 不存在 meta.json → ("", false, nil)
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
}
// 存在且有 app_id → (app_id, true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
}
// 存在但 app_id 空 → ("", true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
}
// 存在但坏 JSON → ("", false, err)(无法确认)
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
}
}
func TestEnsureInitDirMatchesApp(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 无 meta非妙搭工程→ nil交给 ensureEmptyDir
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
t.Fatalf("no meta should pass: %v", err)
}
// 同 app_id → (app_id, nil)(走已初始化短路)
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
}
// 不同 app_id → error换目录返回 existing=app_other断言 typed metadatasubtype/param
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
if errMismatch == nil {
t.Fatal("different app should error")
}
if existing != "app_other" {
t.Fatalf("mismatch should return existing app_id, got %q", existing)
}
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(errMismatch, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
}
if ve.Param != "--dir" {
t.Fatalf("param=%q, want --dir", ve.Param)
}
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
}
if !strings.Contains(problem.Hint, "different --dir") {
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
}
// 空 app_id缺 app_id 标记的半成品)→ error独立文案非 "different app"),返回 existing=""
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
if errEmpty == nil {
t.Fatal("empty meta app_id should error (cannot confirm same app)")
}
if emptyExisting != "" {
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
}
pEmpty := requireAppsValidationProblem(t, errEmpty)
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pEmpty.Message, "without an app_id") {
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
}
if strings.Contains(pEmpty.Message, "different app") {
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
}
// meta 损坏/不可读 → errorfail closed返回 existing=""
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
if errBad == nil {
t.Fatal("corrupted meta should fail closed")
}
if badExisting != "" {
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
}
pBad := requireAppsValidationProblem(t, errBad)
if pBad.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
}
var veBad *errs.ValidationError
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
}
}
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
// classification of an external-tool failure: a failing git subprocess
// surfaces as internal/external_tool with the cause preserved.

View File

@@ -469,6 +469,29 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesSend audio rejects non-opus local file", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"audio": "./voice.mp3",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend audio accepts opus and ogg local files", func(t *testing.T) {
for _, audio := range []string{"./voice.opus", "./voice.ogg"} {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"audio": audio,
}, nil)
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSend.Validate(%q) unexpected error = %v", audio, err)
}
}
})
t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -492,6 +515,17 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesReply audio rejects non-opus local file", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"audio": "./voice.mp3",
}, nil)
err := ImMessagesReply.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
}
})
t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"thread": "bad_thread",

View File

@@ -1048,6 +1048,42 @@ func detectIMFileType(filePath string) string {
}
}
const (
audioMessageInputDesc = "audio file key (file_xxx), URL, or cwd-relative local path for a voice message (absolute paths and .. are rejected); local paths and URLs must be Opus (.opus or Ogg Opus .ogg). For mp3/wav, convert to .opus first, or use --file to send as an attachment"
audioMessageHint = "Convert non-Opus audio to .opus and use --audio for a voice message, for example: ffmpeg -i input.mp3 -acodec libopus -ac 1 -ar 16000 output.opus. To send the original mp3/wav as an attachment, use --file instead."
)
func validateAudioMessageInput(flagName, value string) error {
value = strings.TrimSpace(value)
if value == "" || isMediaKey(value) {
return nil
}
ext := audioInputExt(value)
if ext == "" {
return nil
}
if ext == ".opus" || ext == ".ogg" {
return nil
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"%s supports only Opus audio files for audio messages, such as .opus files or Ogg Opus (.ogg) files",
flagName,
).WithParam(flagName).WithHint("%s", audioMessageHint)
}
func audioInputExt(value string) string {
if isURL(value) {
parsed, err := url.Parse(value)
if err != nil {
return ""
}
return strings.ToLower(path.Ext(parsed.Path))
}
return strings.ToLower(filepath.Ext(value))
}
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files

View File

@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -50,6 +51,56 @@ func TestDetectIMFileType(t *testing.T) {
}
}
func TestValidateAudioMessageInput(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{name: "empty", value: ""},
{name: "existing file key", value: "file_abc"},
{name: "opus file", value: "./voice.opus"},
{name: "ogg opus file", value: "./voice.ogg"},
{name: "uppercase opus", value: "./VOICE.OPUS"},
{name: "mp3 local file", value: "./voice.mp3", wantErr: true},
{name: "wav local file", value: "./voice.wav", wantErr: true},
{name: "extensionless local path", value: "./voice"},
{name: "opus url", value: "https://example.com/voice.opus?download=1"},
{name: "ogg url", value: "https://example.com/voice.ogg?download=1"},
{name: "mp3 url", value: "https://example.com/voice.mp3?download=1", wantErr: true},
{name: "extensionless url", value: "https://example.com/download?id=1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAudioMessageInput("--audio", tt.value)
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("validateAudioMessageInput(%q) error = %v", tt.value, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("validateAudioMessageInput(%q) error is not typed: %v", tt.value, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("ProblemOf(%q) = category %q subtype %q", tt.value, p.Category, p.Subtype)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok || validationErr.Param != "--audio" {
t.Fatalf("validateAudioMessageInput(%q) param = %q, want --audio", tt.value, validationErr.Param)
}
if !strings.Contains(p.Hint, "use --file") || !strings.Contains(p.Hint, "ffmpeg") {
t.Fatalf("validateAudioMessageInput(%q) hint = %q, want --file and ffmpeg guidance", tt.value, p.Hint)
}
return
}
if err != nil {
t.Fatalf("validateAudioMessageInput(%q) unexpected error = %v", tt.value, err)
}
})
}
}
// TestSplitCSV covers the shared helper that replaced the three identical functions
func TestSplitCSV(t *testing.T) {
tests := []struct {

View File

@@ -33,7 +33,7 @@ var ImMessagesReply = common.Shortcut{
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "audio", Desc: audioMessageInputDesc},
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
},
@@ -100,6 +100,9 @@ var ImMessagesReply = common.Shortcut{
return err
}
}
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
return err
}
if messageId == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")

View File

@@ -37,7 +37,7 @@ var ImMessagesSend = common.Shortcut{
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "audio", Desc: audioMessageInputDesc},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatFlag := runtime.Str("chat-id")
@@ -112,6 +112,9 @@ var ImMessagesSend = common.Shortcut{
return err
}
}
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
return err
}
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
return err

View File

@@ -242,7 +242,6 @@ func Shortcuts() []common.Shortcut {
GetMyTasks,
GetRelatedTasks,
SearchTask,
SubscribeTaskEvent,
UploadAttachmentTask,
CreateTasklist,
SearchTasklist,

View File

@@ -1,40 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"github.com/larksuite/cli/shortcuts/common"
)
var SubscribeTaskEvent = common.Shortcut{
Service: "task",
Command: "+subscribe-event",
Description: "subscribe to task events",
Risk: "write",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/task/v2/task_v2/task_subscription").
Params(map[string]interface{}{"user_id_type": "open_id"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := map[string]interface{}{"user_id_type": "open_id"}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/task_v2/task_subscription", params, nil); err != nil {
return err
}
outData := map[string]interface{}{"ok": true}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
})
return nil
},
}

View File

@@ -1,163 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSubscribeTaskEvent(t *testing.T) {
tests := []struct {
name string
mode string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "execute json (user identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute json (bot identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute api error",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 401,
"msg": "Unauthorized",
"error": map[string]interface{}{
"log_id": "test-log-id",
},
},
})
},
wantErr: true,
wantParts: []string{"Unauthorized"},
},
{
name: "dry run",
mode: "dryrun",
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch tt.mode {
case "execute":
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
if tt.register != nil {
tt.register(reg)
}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
out := err.Error()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("error missing %q: %s", want, out)
}
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
case "dryrun":
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
}
})
}
}
// TestSubscribeTaskEvent_MalformedResponse covers the parse-response arm: a 200
// with an unparseable body surfaces a typed internal invalid_response error
// (exit 5).
func TestSubscribeTaskEvent_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Status: 200,
RawBody: []byte("{not-json"),
})
args := []string{"+subscribe-event", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}

View File

@@ -5,6 +5,7 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -21,40 +22,54 @@ import (
)
const (
// WhiteboardQueryAsImage exports a whiteboard preview image.
WhiteboardQueryAsImage = "image"
WhiteboardQueryAsCode = "code"
WhiteboardQueryAsRaw = "raw"
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
WhiteboardQueryAsSvg = "svg"
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
WhiteboardQueryAsCode = "code"
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
WhiteboardQueryAsRaw = "raw"
)
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
type SyntaxType int
const (
// SyntaxTypePlantUML marks PlantUML code blocks.
SyntaxTypePlantUML SyntaxType = 1
SyntaxTypeMermaid SyntaxType = 2
// SyntaxTypeMermaid marks Mermaid code blocks.
SyntaxTypeMermaid SyntaxType = 2
)
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
var SyntaxTypeNameMap = map[SyntaxType]string{
SyntaxTypePlantUML: "plantuml",
SyntaxTypeMermaid: "mermaid",
}
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
var SyntaxTypeExtensionMap = map[SyntaxType]string{
SyntaxTypePlantUML: ".puml",
SyntaxTypeMermaid: ".mmd",
}
// String returns the CLI-facing name for the syntax type.
func (s SyntaxType) String() string {
return SyntaxTypeNameMap[s]
}
// ExtensionName returns the default file extension for the syntax type.
func (s SyntaxType) ExtensionName() string {
return SyntaxTypeExtensionMap[s]
}
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
func (s SyntaxType) IsValid() bool {
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
}
// WhiteboardQuery registers the `whiteboard +query` shortcut.
var WhiteboardQuery = common.Shortcut{
Service: "whiteboard",
Command: "+query",
@@ -64,8 +79,8 @@ var WhiteboardQuery = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as code/raw, it will output directly.", Required: false},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
},
HasFormat: true,
@@ -86,8 +101,8 @@ var WhiteboardQuery = common.Shortcut{
}
as := runtime.Str("output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
}
return nil
},
@@ -107,8 +122,13 @@ var WhiteboardQuery = common.Shortcut{
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardQueryAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | code | raw")
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -119,17 +139,110 @@ var WhiteboardQuery = common.Shortcut{
switch as {
case WhiteboardQueryAsImage:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardQueryAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardQueryAsCode:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardQueryAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
}
},
}
// exportReq defines the request body for whiteboard export APIs.
type exportReq struct {
ExportType string `json:"export_type"`
}
// exportResp models the whiteboard export response envelope.
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
// exportWhiteboardSvg exports a whiteboard as SVG and writes the result to stdout or a file.
// It requests the SVG export for the given whiteboard token and saves the decoded content when an output path is provided.
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
if exportData.Code != 0 {
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
}
} else if resp.StatusCode == http.StatusOK {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
}
if resp.StatusCode != http.StatusOK {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
// exportWhiteboardPreview downloads a whiteboard preview image and saves it as a PNG file.
//
// It reports the saved file path and image size on success.
// Returns an error if the API request fails, the response is rejected, or the file cannot be saved.
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
@@ -331,6 +444,9 @@ func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string)
return nil
}
// saveOutputFile writes exported content to a file or directory and returns the final path and written size.
// If outPath is a directory, it creates a file named whiteboard_<token><ext>. If outPath is a file path,
// it adjusts the file extension to ext, validates the path, and respects the overwrite flag.
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
// Step 1: Get final output path
info, err := runtime.FileIO().Stat(outPath)
@@ -367,6 +483,8 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
switch ext {
case ".png":
contentType = "image/png"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":

View File

@@ -6,6 +6,8 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -13,6 +15,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -20,6 +23,7 @@ import (
"github.com/spf13/cobra"
)
// TestSyntaxType verifies syntax names, extensions, and validity checks.
func TestSyntaxType(t *testing.T) {
t.Parallel()
@@ -75,6 +79,7 @@ func TestSyntaxType(t *testing.T) {
}
}
// TestWhiteboardQuery_Validate verifies query flag validation for supported output modes.
func TestWhiteboardQuery_Validate(t *testing.T) {
ctx := context.Background()
chdirTemp(t)
@@ -199,6 +204,9 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
})
}
}
@@ -232,6 +240,7 @@ func TestExportWhiteboardPreview_HTTPError(t *testing.T) {
}
}
// TestExportWhiteboardPreview_HTTPNotFoundIsAPIError verifies 404 preview downloads surface as typed API errors.
func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
@@ -255,6 +264,7 @@ func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
}
}
// TestWhiteboardQuery_DryRun verifies dry-run output for the supported query modes.
func TestWhiteboardQuery_DryRun(t *testing.T) {
t.Parallel()
@@ -307,6 +317,64 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
}
}
// TestWhiteboardQuery_DryRun_InvalidOutputAs verifies dry-run guidance for unsupported output modes.
func TestWhiteboardQuery_DryRun_InvalidOutputAs(t *testing.T) {
t.Parallel()
ctx := context.Background()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "invalid",
}, nil)
dryRun := WhiteboardQuery.DryRun(ctx, rt)
if dryRun == nil {
t.Fatal("WhiteboardQuery.DryRun() returned nil")
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !strings.Contains(string(data), "image | svg | code | raw") {
t.Fatalf("dry run desc = %s, want invalid output_as guidance", string(data))
}
}
// TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError verifies invalid output modes return typed validation errors.
func TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError(t *testing.T) {
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "invalid",
}, nil)
err := WhiteboardQuery.Execute(context.Background(), rt)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--output_as" {
t.Errorf("Param = %q, want %q", ve.Param, "--output_as")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
}
// TestWhiteboardQuery_ShortcutRegistration verifies the whiteboard query shortcut metadata.
func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
t.Parallel()
@@ -325,6 +393,7 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
}
}
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
func TestSaveOutputFile(t *testing.T) {
t.Parallel()
@@ -476,6 +545,7 @@ func TestSaveOutputFile(t *testing.T) {
}
}
// TestSaveOutputFile_InvalidFinalPathTypedError verifies invalid save paths return typed validation errors.
func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
chdirTemp(t)
@@ -491,6 +561,19 @@ func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--output" {
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("expected path-validation cause to be preserved, err=%v", err)
}
}
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
@@ -525,6 +608,7 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
return err
}
// TestWhiteboardQueryExecute_AsRaw verifies raw query execution emits the raw node payload.
func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -553,6 +637,7 @@ func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
}
}
// TestWhiteboardQueryExecute_AsCode verifies code query execution emits extracted diagram source.
func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
@@ -583,6 +668,7 @@ func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
}
}
// TestExportWhiteboardCode_EmptyNodes verifies code export handles empty whiteboards.
func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -605,6 +691,7 @@ func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
}
}
// TestExportWhiteboardCode_NoCodeBlocks verifies code export reports whiteboards without code blocks.
func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -629,6 +716,7 @@ func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
}
}
// TestExportWhiteboardCode_InvalidSyntaxType verifies unknown syntax types are rejected.
func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -658,6 +746,7 @@ func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
}
}
// TestExportWhiteboardCode_MultipleCodeBlocks verifies multiple code blocks are exported together.
func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -697,6 +786,7 @@ func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
}
}
// TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput verifies direct PlantUML output for a single code block.
func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -730,6 +820,7 @@ func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
}
}
// TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput verifies direct Mermaid output for a single code block.
func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -763,6 +854,7 @@ func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
}
}
// TestExportWhiteboardPreview verifies preview downloads can be written to disk.
func TestExportWhiteboardPreview(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -791,6 +883,7 @@ func TestExportWhiteboardPreview(t *testing.T) {
}
}
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -813,6 +906,7 @@ func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
}
}
// TestFetchWhiteboardNodes_APIError verifies node fetch failures preserve typed API errors.
func TestFetchWhiteboardNodes_APIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -842,6 +936,7 @@ func TestFetchWhiteboardNodes_APIError(t *testing.T) {
}
}
// TestFetchWhiteboardNodes_InvalidResponseTypedError verifies malformed node responses become typed invalid-response errors.
func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
tests := []struct {
name string
@@ -901,6 +996,474 @@ func TestFetchWhiteboardNodes_MissingNodesIsEmpty(t *testing.T) {
}
}
// TestExportWhiteboardSvg_DirectOutput verifies SVG export is printed when no output path is provided.
func TestExportWhiteboardSvg_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg", "--output_as", "svg"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(stdout.String(), "svg_content") {
t.Fatalf("stdout missing svg_content key: %s", stdout.String())
}
}
// TestExportWhiteboardSvg_SaveToFile verifies SVG export is written to the requested file.
func TestExportWhiteboardSvg_SaveToFile(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-file", "--output_as", "svg", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data, err := os.ReadFile("output.svg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != svgContent {
t.Fatalf("svg content = %q, want %q", string(data), svgContent)
}
}
// TestExportWhiteboardSvg_PrettyOutput verifies pretty output includes inline SVG content.
func TestExportWhiteboardSvg_PrettyOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0L10 10"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-pretty/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-pretty", "--output_as", "svg", "--format", "pretty"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, svgContent) {
t.Fatalf("stdout = %q, want svg content", got)
}
}
// TestExportWhiteboardSvg_SaveToFile_PrettyOutput verifies pretty output reports the saved SVG path and size.
func TestExportWhiteboardSvg_SaveToFile_PrettyOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><ellipse cx="60" cy="40" rx="50" ry="30"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file-pretty/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-file-pretty", "--output_as", "svg", "--output", "output", "--overwrite", "--format", "pretty"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "SVG saved to output.svg") || !strings.Contains(got, "File size:") {
t.Fatalf("stdout = %q, want save summary", got)
}
}
// TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite verifies existing SVG outputs require --overwrite.
func TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
if err := os.WriteFile("output.svg", []byte("existing content"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><line x1="0" y1="0" x2="1" y2="1"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-existing/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-existing", "--output_as", "svg", "--output", "output"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for existing output without overwrite")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--overwrite" {
t.Errorf("Param = %q, want %q", ve.Param, "--overwrite")
}
}
// TestExportWhiteboardSvg_HTTP5xx verifies plain HTTP 5xx failures are classified as retryable network errors.
func TestExportWhiteboardSvg_HTTP5xx(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx/export",
Status: 502,
RawBody: []byte("bad gateway"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 502")
}
var ne *errs.NetworkError
if !errors.As(err, &ne) {
t.Fatalf("error is not *errs.NetworkError: %T (%v)", err, err)
}
if ne.Subtype != errs.SubtypeNetworkServer {
t.Errorf("Subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkServer)
}
if ne.Code != 502 {
t.Errorf("Code = %d, want 502", ne.Code)
}
if !ne.Retryable {
t.Error("expected Retryable = true")
}
}
// TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError verifies API envelopes take precedence over generic 5xx handling.
func TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx-json/export",
Status: 502,
ContentType: "application/json",
RawBody: []byte(`{"code":99002,"msg":"export task failed"}`),
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx-json", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 502 JSON envelope")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
var ne *errs.NetworkError
if errors.As(err, &ne) {
t.Fatalf("expected JSON envelope to win over HTTP 5xx fallback, got *errs.NetworkError: %v", err)
}
if apiErr.Subtype != errs.SubtypeUnknown {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
}
if apiErr.Code != 99002 {
t.Errorf("Code = %d, want 99002", apiErr.Code)
}
}
// TestExportWhiteboardSvg_HTTP4xx verifies plain HTTP 4xx failures are surfaced as API errors.
func TestExportWhiteboardSvg_HTTP4xx(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-403/export",
Status: 403,
RawBody: []byte("forbidden"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-403", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 403")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Subtype != errs.SubtypeUnknown {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
}
if apiErr.Code != 403 {
t.Errorf("Code = %d, want 403", apiErr.Code)
}
}
// TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError verifies not-found envelopes preserve the typed API error classification.
func TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/missing-token-svg/export",
Status: 404,
ContentType: "application/json",
RawBody: []byte(`{"code":99001,"msg":"whiteboard not found"}`),
})
args := []string{"+query", "--whiteboard-token", "missing-token-svg", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 404 JSON envelope")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
}
if apiErr.Code != 99001 {
t.Errorf("Code = %d, want 99001", apiErr.Code)
}
}
// TestExportWhiteboardSvg_HTTPNotFoundPlainText verifies plain-text 404 responses surface as not-found API errors.
func TestExportWhiteboardSvg_HTTPNotFoundPlainText(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/missing-token-svg-plain/export",
Status: 404,
ContentType: "text/plain",
RawBody: []byte("whiteboard not found"),
})
args := []string{"+query", "--whiteboard-token", "missing-token-svg-plain", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 404 plain text response")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
}
if apiErr.Code != 404 {
t.Errorf("Code = %d, want 404", apiErr.Code)
}
}
// TestExportWhiteboardSvg_InvalidJSON verifies malformed success responses are rejected as invalid responses.
func TestExportWhiteboardSvg_InvalidJSON(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badjson/export",
Status: 200,
RawBody: []byte("not json at all"),
ContentType: "application/json",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-badjson", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
assertInvalidResponse(t, err)
}
// TestExportWhiteboardSvg_InvalidBody200PlainText verifies plain-text 200 responses are rejected as invalid export responses.
func TestExportWhiteboardSvg_InvalidBody200PlainText(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-plain-200/export",
Status: 200,
RawBody: []byte("not json at all"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-plain-200", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for plain text success response")
}
assertInvalidResponse(t, err)
}
// TestExportWhiteboardSvg_NonZeroCode verifies non-zero API codes are returned as typed API errors.
func TestExportWhiteboardSvg_NonZeroCode(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-apierr/export",
Body: map[string]interface{}{
"code": 99001,
"msg": "whiteboard not found",
"data": map[string]interface{}{},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-apierr", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for non-zero code")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Code != 99001 {
t.Errorf("Code = %d, want 99001", apiErr.Code)
}
}
// TestExportWhiteboardSvg_InvalidBase64 verifies invalid SVG payload encoding is rejected.
func TestExportWhiteboardSvg_InvalidBase64(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badbase64/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": "!!!not-valid-base64!!!",
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-badbase64", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for invalid base64")
}
assertInvalidResponse(t, err)
}
// TestWhiteboardQuery_Validate_SvgValid verifies svg is accepted as a valid query output format.
func TestWhiteboardQuery_Validate_SvgValid(t *testing.T) {
ctx := context.Background()
chdirTemp(t)
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
}, nil)
if err := WhiteboardQuery.Validate(ctx, rt); err != nil {
t.Fatalf("expected svg to be valid, got err=%v", err)
}
}
// TestWhiteboardQuery_DryRun_Svg verifies the svg dry-run request uses the export endpoint and body.
func TestWhiteboardQuery_DryRun_Svg(t *testing.T) {
t.Parallel()
ctx := context.Background()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
}, nil)
dryRun := WhiteboardQuery.DryRun(ctx, rt)
if dryRun == nil {
t.Fatal("DryRun() returned nil for svg")
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
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"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.API) != 1 {
t.Fatalf("len(api) = %d, want 1", len(got.API))
}
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...-123/export" {
t.Fatalf("url = %q", got.API[0].URL)
}
if got.API[0].Body["export_type"] != "svg" {
t.Fatalf("body = %#v, want export_type=svg", got.API[0].Body)
}
if _, ok := got.API[0].Params["export_type"]; ok {
t.Fatalf("params should not include export_type, got %#v", got.API[0].Params)
}
}
// assertInvalidResponse verifies an error is classified as a typed invalid-response failure.
func assertInvalidResponse(t *testing.T, err error) {
t.Helper()
if err == nil {

View File

@@ -17,15 +17,21 @@ import (
)
const (
FormatRaw = "raw"
// 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 = "mermaid"
// 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"}
@@ -35,9 +41,14 @@ var wbUpdateFlags = []common.Flag{
{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. Default is raw.", Required: false},
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid | svg. Default is raw.", Required: false},
}
// wbUpdateValidate validates the whiteboard update command arguments.
//
// It checks the whiteboard token and idempotent token for dangerous control
// characters, enforces a minimum length for a non-empty idempotent token, and
// ensures the input format is one of raw, plantuml, mermaid, or svg.
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
@@ -53,8 +64,8 @@ func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error
// 检查 --input_format 标志
format := getFormat(runtime)
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid").WithParam("--input_format")
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
}
@@ -68,6 +79,8 @@ func getFormat(runtime *common.RuntimeContext) string {
return format
}
// wbUpdateDryRun describes the HTTP request used to update a whiteboard.
// It returns a failure description when source is missing or cannot be parsed.
func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// 读取输入内容
input := runtime.Str("source")
@@ -91,7 +104,7 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
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:
case FormatPlantUML, FormatMermaid, FormatSVG:
syntaxType := formatCodeMap[format]
reqBody := plantumlCreateReq{
PlantUmlCode: input,
@@ -106,6 +119,10 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
return desc
}
// wbUpdateExecute updates a whiteboard from the supplied source input.
// It requires --source and dispatches to the raw node update path for raw input
// or the diagram import path for PlantUML, Mermaid, and SVG input.
// It returns an error if the source is missing or the input format is unsupported.
func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("whiteboard-token")
overwrite := runtime.Bool("overwrite")
@@ -120,15 +137,17 @@ func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error
switch format {
case FormatRaw:
return updateWhiteboardByRawNodes(ctx, runtime, token, []byte(input), overwrite, idempotentToken)
case FormatPlantUML, FormatMermaid:
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",

View File

@@ -6,6 +6,7 @@ package whiteboard
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -18,6 +19,7 @@ import (
"github.com/spf13/cobra"
)
// TestWhiteboardUpdate_Validate verifies update flag validation for supported input formats.
func TestWhiteboardUpdate_Validate(t *testing.T) {
ctx := context.Background()
@@ -53,6 +55,15 @@ func TestWhiteboardUpdate_Validate(t *testing.T) {
},
wantErr: false,
},
{
name: "valid: svg format",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"input_format": "svg",
"source": "<svg/>",
},
wantErr: false,
},
{
name: "valid: with idempotent-token",
flags: map[string]string{
@@ -117,25 +128,26 @@ func TestWhiteboardUpdate_Validate_TypedErrors(t *testing.T) {
"idempotent-token": "short",
"source": "{}",
}, nil)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token")
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token", false)
})
t.Run("bad input_format", func(t *testing.T) {
rt := newTestRuntime(map[string]string{
"whiteboard-token": "t",
"input_format": "svg",
"input_format": "png",
"source": "{}",
}, nil)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format")
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format", false)
})
t.Run("malformed source json", func(t *testing.T) {
_, err, _ := parseWBcliNodes([]byte("not-json"))
assertValidationParam(t, err, "--source")
assertValidationParam(t, err, "--source", true)
})
}
func assertValidationParam(t *testing.T, err error, wantParam string) {
// assertValidationParam verifies a validation error carries the expected flag param.
func assertValidationParam(t *testing.T, err error, wantParam string, wantJSONCause bool) {
t.Helper()
if err == nil {
t.Fatalf("expected error, got nil")
@@ -150,8 +162,25 @@ func assertValidationParam(t *testing.T, err error, wantParam string) {
if ve.Param != wantParam {
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if wantJSONCause {
var syntaxErr *json.SyntaxError
if !errors.As(err, &syntaxErr) {
t.Fatalf("expected json syntax cause to be preserved, err=%v", err)
}
}
}
// TestGetFormat verifies input format defaults and explicit format selection.
func TestGetFormat(t *testing.T) {
t.Parallel()
@@ -180,6 +209,11 @@ func TestGetFormat(t *testing.T) {
flagVal: FormatMermaid,
expected: FormatMermaid,
},
{
name: "svg returns svg",
flagVal: FormatSVG,
expected: FormatSVG,
},
}
for _, tt := range tests {
@@ -193,6 +227,7 @@ func TestGetFormat(t *testing.T) {
}
}
// TestWhiteboardUpdate_ShortcutRegistration verifies the shortcut metadata for update commands.
func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
t.Parallel()
@@ -213,6 +248,7 @@ func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
}
}
// TestShortcutsIncludesExpectedCommands verifies the whiteboard shortcut registry includes query and update.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
@@ -237,6 +273,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
}
}
// TestParseWBcliNodes verifies whiteboard CLI output parsing for raw and wrapped node payloads.
func TestParseWBcliNodes(t *testing.T) {
t.Parallel()
@@ -285,6 +322,7 @@ func TestParseWBcliNodes(t *testing.T) {
}
}
// TestWBUpdateDryRun verifies dry-run requests for the supported whiteboard update formats.
func TestWBUpdateDryRun(t *testing.T) {
ctx := context.Background()
@@ -317,6 +355,14 @@ func TestWBUpdateDryRun(t *testing.T) {
"source": "graph TD\nA-->B",
},
},
{
name: "dry run svg format",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"input_format": "svg",
"source": "<svg/>",
},
},
}
for _, tt := range tests {
@@ -362,6 +408,7 @@ func runUpdateShortcut(t *testing.T, shortcut common.Shortcut, args []string, fa
return err
}
// TestWhiteboardUpdateExecute_RawFormat verifies raw node updates call the raw nodes endpoint.
func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -385,6 +432,7 @@ func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_PlantUMLFormat verifies PlantUML updates use the diagram import endpoint.
func TestWhiteboardUpdateExecute_PlantUMLFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -410,6 +458,7 @@ Bob -> Alice : hello
}
}
// TestWhiteboardUpdateExecute_PlantUMLInvalidResponse verifies missing node IDs are treated as invalid responses.
func TestWhiteboardUpdateExecute_PlantUMLInvalidResponse(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -431,6 +480,7 @@ Bob -> Alice : hello
assertInvalidResponse(t, err)
}
// TestWhiteboardUpdateExecute_MermaidFormat verifies Mermaid updates use the diagram import endpoint.
func TestWhiteboardUpdateExecute_MermaidFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -455,6 +505,44 @@ A-->B`
}
}
// TestWhiteboardUpdateExecute_SVGFormat verifies svg update requests use syntax_type=3 and send the source payload.
func TestWhiteboardUpdateExecute_SVGFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
// SVG shares the /nodes/plantuml endpoint with plantuml/mermaid via syntax_type=3.
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg/nodes/plantuml",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node_id": "node1",
},
},
}
reg.Register(stub)
source := `<svg xmlns="http://www.w3.org/2000/svg"/>`
args := []string{"+update", "--whiteboard-token", "test-token-svg", "--input_format", "svg", "--source", source}
if err := runUpdateShortcut(t, WhiteboardUpdate, 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))
}
if got := body["syntax_type"]; got != float64(3) {
t.Fatalf("syntax_type = %#v, want 3; body=%s", got, string(stub.CapturedBody))
}
if got := body["plant_uml_code"]; got != source {
t.Fatalf("plant_uml_code = %#v, want %q; body=%s", got, source, string(stub.CapturedBody))
}
}
// TestWhiteboardUpdateExecute_RawInvalidResponse verifies malformed raw update responses are rejected.
func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
tests := []struct {
name string
@@ -494,6 +582,7 @@ func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_RawWithIdempotent verifies raw updates pass through the idempotency token.
func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -518,6 +607,7 @@ func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_RawFormatWithRawNodes verifies raw-node payloads are forwarded without DSL wrapping.
func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -541,6 +631,7 @@ func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_RawAPIError verifies raw update API failures preserve typed error metadata and hints.
func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -577,6 +668,7 @@ func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_PlantUMLAPIError verifies diagram update API failures preserve typed error metadata.
func TestWhiteboardUpdateExecute_PlantUMLAPIError(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -607,6 +699,7 @@ invalid
}
}
// TestWhiteboardUpdateExecute_WithOverwrite verifies diagram updates send overwrite=true when requested.
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -631,6 +724,7 @@ A-->B`
}
}
// TestWhiteboardUpdateExecute_RawWithOverwrite verifies raw updates send overwrite=true when requested.
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)

View File

@@ -1,7 +1,7 @@
---
name: lark-apps
version: 1.0.0
description: "妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。"
description: "妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。"
metadata:
requires:
bins: ["lark-cli"]
@@ -48,8 +48,14 @@ metadata:
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}` 仅进编辑态,不能顶替发布当分享链接。
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`:进应用编辑/开发态、管理与继续开发应用的入口。发布成功后,连同发布态链接一并提供给用户(说明"管理 / 继续开发去这里");但它仅进编辑态,**不能**顶替发布态链接当分享链接。
- 发布态链接来源html → `+html-publish``data.url`;全栈 → `+release-get` 轮询 `finished``online_url` / `failed``error_logs`
- **可见范围**发布态链接html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set``tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。
## 能力边界
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web处理。
## app_id 获取
@@ -69,4 +75,4 @@ metadata:
## 高影响动作:确认与预授权
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
- **不豁免底线**会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)即便已预授权,也`--dry-run` 确认。
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项

View File

@@ -11,7 +11,7 @@
- 必填:`--app-id``--path`
- `--path` 可以是单个文件或目录;入口必须是 `index.html`
- 可选:`--allow-sensitive`,跳过凭据文件扫描。
- 客户端打包 tar.gz 上传发布;压缩包上限当前为 20MB未压缩候选文件总量也有保护上限
- 客户端打包 tar.gz 上传发布。三条硬性大小限制,任一超限即被客户端拒绝、无法发布:单个 `.html` 文件 ≤ 10MB、打包后 tar.gz ≤ 20MB未压缩候选文件总量 ≤ 200MB
## 示例
@@ -33,12 +33,19 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
- 发布态访问链接以本命令成功返回的 `data.url` 为准。
- 重新发布前,`+list``is_published=true` 只能说明历史上发布过,不代表当前本地产物已经部署。
## 发布前置门(第一步,先于任何其他动作)
收到发布意图后,第一个动作是量三个尺寸,不是读文件内容、不是打包:
1. 单个 `.html` ≤ 10MB / tar.gz ≤ 20MB / 未压缩总量 ≤ 200MB。
2. 任一超限 → 立即 STOP把超限数字转述给用户交还决定权。
3. 三项都通过 → 才进入下面的命令骨架。
## 预览与发布边界
- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。
- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`
- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist``.git` 目录会被自动排除,不会进入压缩包`node_modules`、源码缓存等仍建议手动精简以控制包体
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist``.git` 目录会被自动排除,不会进入压缩包。
- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id <id> --path <dir-or-index.html>`
## 安全规则
@@ -48,4 +55,3 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
## 常见失败
- 缺少 `index.html`:目录根放置 `index.html`,或单文件路径直接指向名为 `index.html` 的文件。
- 包体过大:让用户精简 `--path`,不要把源码、依赖目录、构建缓存一起发布。

View File

@@ -31,6 +31,7 @@ lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
## Agent 规则
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 的已初始化仓库。
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 且其 app_id 与 `--app-id` 一致的已初始化仓库。
- 目标目录已含 `.spark/meta.json` 时,`+init` 会跳过 clone/scaffold但仍执行一次 env-pull 刷新本地环境变量;告知用户“仓库已初始化,本地环境变量已刷新,可直接开发”,不要误报失败或重复 clone。
- `+init` 输出没有必要原样复述;告诉用户 clone path、分支和下一步即可。
- 新建应用做本地初始化时,若选定的目标目录已存在,不要复用,改用一个不冲突的目录名(已预授权”放手做”时自动追加后缀如 `-2`;否则向用户确认目录名)。

View File

@@ -1,7 +1,7 @@
---
name: lark-event
version: 1.0.0
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
metadata:
requires:
bins: ["lark-cli"]
@@ -148,6 +148,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
| Topic | Reference | Coverage |
|------------|------------------------------------------------------------------------------|---|
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender) |
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |

View File

@@ -0,0 +1,78 @@
# Task Events
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (1)
| EventKey | Purpose |
|---|---|
| `task.task.update_user_access_v2` | A visible task has been created, deleted, or updated |
This key uses a **Native schema** (V2 envelope; output rooted at `.event`) and carries a **PreConsume hook** that calls the Task event subscription API before listening.
## Scopes & auth
| EventKey | Scope | Auth |
|---|---|---|
| `task.task.update_user_access_v2` | `task:task:read` | user, bot |
Supports `--as user` or `--as bot`.
- `--as user`: receive task updates visible to the current user through authorship, assignment, following, or other access.
- `--as bot`: receive task updates for tasks the application is responsible for.
## `task.task.update_user_access_v2`
### Subscription behavior
On startup, `event consume` calls:
```text
POST /open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id
```
The Task subscription API has no matching unsubscribe endpoint in the current CLI metadata, so graceful exit has no cleanup call for this EventKey. Re-running the consumer repeats the subscribe call for the selected identity.
This EventKey is single-consumer per local bus subscription: start one `event consume task.task.update_user_access_v2` process for a given app/profile/identity at a time.
### Output fields (V2 envelope; root path `.event`)
| Field | Type | Description |
|---|---|---|
| `.header.event_id` | string | Globally unique event ID; safe for deduplication |
| `.header.create_time` | string (timestamp_ms) | Event creation time in milliseconds |
| `.event.event_types[]` | string enum | Task commit types included in this event |
| `.event.task_guid` | string (kind=task_guid) | Task GUID that changed |
Commit types:
```text
task_assignees_update
task_completed_update
task_create
task_deleted
task_desc_update
task_followers_update
task_reminders_update
task_start_due_update
task_summary_update
```
### Example
```bash
# Stream task update events for the current user
lark-cli event consume task.task.update_user_access_v2 --as user
# Sample one event for payload inspection
lark-cli event consume task.task.update_user_access_v2 \
--as user --max-events 1 --timeout 2m
# Project to a compact task-update record
lark-cli event consume task.task.update_user_access_v2 \
--as user \
--jq '{event_id: .header.event_id, task_guid: .event.task_guid, event_types: .event.event_types, timestamp: .header.create_time}'
# Consume as the app identity
lark-cli event consume task.task.update_user_access_v2 --as bot
```

View File

@@ -61,6 +61,10 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
### Audio Messages
`--audio` sends a voice message and supports only Opus audio files, for example `.opus` files or Ogg Opus (`.ogg`) files. For `mp3`, `wav`, or other non-Opus audio, either convert to `.opus` first and keep using `--audio`, or send the original file as an attachment with `--file`.
### Flag Types
Flags support two layers:

View File

@@ -147,6 +147,9 @@ lark-cli im +messages-reply --message-id om_xxx --file ./report.pdf
# Reply with a local video (--video-cover is required as the video cover)
lark-cli im +messages-reply --message-id om_xxx --video ./demo.mp4 --video-cover ./cover.png
# Reply with a voice message
lark-cli im +messages-reply --message-id om_xxx --audio ./voice.opus
# With an idempotency key
lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency-key my-unique-id
@@ -159,6 +162,7 @@ lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' -
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
- `--audio` sends a voice message and accepts only Opus audio (`.opus` or Ogg Opus `.ogg`) for local paths and URLs. For `mp3`, `wav`, or other non-Opus audio, convert to `.opus` before using `--audio`, or use `--file` to send the original audio as an attachment.
## Parameters
@@ -173,7 +177,7 @@ lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' -
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`) |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`); **must be used together with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`) |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |

View File

@@ -150,7 +150,7 @@ lark-cli im +messages-send --chat-id oc_xxx --file ./report.pdf
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover ./cover.png
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover img_xxx
# Send audio
# Send a voice message
lark-cli im +messages-send --chat-id oc_xxx --audio ./voice.opus
# Use an idempotency key (same key sends only once within 1 hour)
@@ -165,6 +165,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
- `--audio` sends a voice message and accepts only Opus audio (`.opus` or Ogg Opus `.ogg`) for local paths and URLs. For `mp3`, `wav`, or other non-Opus audio, convert to `.opus` before using `--audio`, or use `--file` to send the original audio as an attachment.
## Parameters
@@ -179,7 +180,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically. **Must be paired with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |

View File

@@ -51,7 +51,6 @@ metadata:
| [`+get-my-tasks`](references/lark-task-get-my-tasks.md) | List tasks assigned to me |
| [`+get-related-tasks`](references/lark-task-get-related-tasks.md) | list tasks related to me |
| [`+search`](references/lark-task-search.md) | search tasks |
| [`+subscribe-event`](references/lark-task-subscribe-event.md) | subscribe to task events |
| [`+upload-attachment`](references/lark-task-upload-attachment.md) | upload a local file as an attachment to a task |
| [`+tasklist-create`](references/lark-task-tasklist-create.md) | create a tasklist and optionally add tasks |
| [`+tasklist-search`](references/lark-task-tasklist-search.md) | search tasklists |

View File

@@ -1,86 +0,0 @@
# task +subscribe-event
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
>
> **⚠️ Note:** This API supports both `user` and `bot` identities. Use `user` to subscribe the current user's accessible tasks; use `bot` to subscribe tasks the **application is responsible for**.
Subscribe task update events with the current identity.
This shortcut is different from `event +subscribe`:
- `task +subscribe-event` registers task-event access for the **current identity**
- with `--as user`, it subscribes the **current user** to task events for tasks they created, are responsible for, or follow
- with `--as bot`, it subscribes using the **application identity** for tasks the application is responsible for
The task event type is:
```text
task.task.update_user_access_v2
```
Within this event, task changes are represented by commit types (string values). Deduped list:
```text
task_assignees_update
task_completed_update
task_create
task_deleted
task_desc_update
task_followers_update
task_reminders_update
task_start_due_update
task_summary_update
```
Event payload shape (example):
```json
{
"event_id": "evt_xxx",
"event_types": ["task_summary_update"],
"task_guid": "task_guid_xxx",
"timestamp": "1775793266152",
"type": "task.task.update_user_access_v2"
}
```
- `type`: event type, should be `task.task.update_user_access_v2`
- `event_id`: unique event id (useful for dedup)
- `event_types`: list of commit types (see the deduped list above)
- `task_guid`: the task GUID that changed
- `timestamp`: event timestamp (ms)
In practice, this means:
- with `--as user`, the subscribed user can receive updates for tasks visible to them through authorship, assignment, or following
- with `--as bot`, the subscription covers tasks the application is responsible for
To actually receive the subscribed events, use the standard event WebSocket receiver:
```bash
lark-cli event +subscribe --event-types task.task.update_user_access_v2 --compact --quiet
```
The full flow is:
1. Register the subscription with `lark-cli task +subscribe-event [--as user|bot]`
2. Receive those events with `lark-cli event +subscribe --event-types task.task.update_user_access_v2 ...`
## Recommended Commands
```bash
lark-cli task +subscribe-event
```
# Subscribe with app identity
lark-cli task +subscribe-event --as bot
## Parameters
This shortcut has no additional parameters.
## Workflow
1. Confirm whether the user wants to subscribe with `user` identity or `bot` identity.
2. Execute `lark-cli task +subscribe-event`
3. Report whether the subscription succeeded, and clarify which identity the subscription applies to.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -12,7 +12,7 @@ metadata:
> [!IMPORTANT]
> - 运行 `lark-cli --version`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 -v`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.12 -v`,确认可用,无需询问用户。
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
@@ -24,11 +24,11 @@ metadata:
| 用户需求 | 行动 |
|-----------------------------------------|-----------------------------------------------------------------------------------------------|
| 查看画板内容 / 导出图片 | [`+query --output_as image`](references/lark-whiteboard-query.md) |
| 查看画板内容 / 导出图片 / 导出 SVG 矢量图 | [`+query --output_as image/svg`](references/lark-whiteboard-query.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 修改节点文字/颜色(简单改动) | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
| 用户**已提供** Mermaid/PlantUML 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml`](references/lark-whiteboard-update.md) |
| 仅微调节点文字/颜色 | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
| 用户**已提供** 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)** |
@@ -36,8 +36,8 @@ metadata:
| Shortcut | 说明 |
|---|---|
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid 或 OpenAPI 原生格式 |
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、SVG 矢量图、代码或原始节点结构 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
---

View File

@@ -336,7 +336,7 @@ DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignS
先出骨架图导出坐标,再基于坐标补充连线和注解:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 -i skeleton.json -o step1.png -l coords.json
npx -y @larksuite/whiteboard-cli@^0.2.12 -i skeleton.json -o step1.png -l coords.json
```
`coords.json` 包含每个带 id 节点的精确坐标absX, absY, width, height

View File

@@ -272,14 +272,14 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
x?: number; y?: number;
width?: WBSizeValue; // 默认 48
height?: WBSizeValue; // 默认 48保持正方形
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.11 --icons 输出中选取
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.12 --icons 输出中选取
color?: string; // 可选颜色覆盖hex 格式如 '#FF6600'
}
```
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 --icons
npx -y @larksuite/whiteboard-cli@^0.2.12 --icons
```
用法:

View File

@@ -2,20 +2,21 @@
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询画板内容,支持导出为预览图片、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
查询画板内容,支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
## 参数
| 参数 | 必填 | 说明 |
|----------------------|----|------------------------------------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token需要拥有画板的读权限 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as code/raw` 时可选,不填则直接输出到终端 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`SVG 矢量图)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as svg/code/raw` 时可选,不填则直接输出到终端 |
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
## 输出格式
- `image`:预览图片
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `code`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
@@ -38,7 +39,17 @@ lark-cli whiteboard +query \
--output_as code
```
### 示例 3导出画板原始节点结构到文件
### 示例 3导出画板为 SVG 矢量图
```bash
lark-cli whiteboard +query \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as svg \
--output ./whiteboard.svg \
--as user
```
### 示例 4导出画板原始节点结构到文件
```bash
lark-cli whiteboard +query \

View File

@@ -2,11 +2,12 @@
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新画板内容,支持种输入格式:
更新画板内容,支持种输入格式:
- `raw`:飞书 OpenAPI 原生画板节点格式,不推荐直接编辑。
- `plantuml`PlantUML 代码
- `mermaid`Mermaid 代码
- `svg`SVG 文本
输入内容可以通过管道从 stdin 读取,或通过 `--source` 指定文件。
@@ -18,7 +19,7 @@
| `--idempotent-token` | 否 | 幂等 token确保更新操作幂等最小长度 10 个字符 |
| `--overwrite` | 否 | 覆盖更新,在更新前删除所有现有内容,默认为 false |
| `--source` | 是 | 输入画板内容,支持使用 `@path` 从文件读取,或 `-` 从 stdin 读取 |
| `--input_format` | 否 | 输入格式:`raw``plantuml``mermaid`,默认为 `raw` |
| `--input_format` | 否 | 输入格式:`raw``plantuml``mermaid``svg`,默认为 `raw` |
### 以 raw (OpenAPI 原生画板节点格式) 创作
@@ -74,7 +75,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--source - --input_format raw \
@@ -88,7 +89,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 生成 OpenAPI 格式到文件
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <DSL 文件> --to openapi --format json -o ./temp.json
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <DSL 文件> --to openapi --format json -o ./temp.json
# 从文件读取并更新
lark-cli whiteboard +update \
@@ -98,3 +99,24 @@ lark-cli whiteboard +update \
--source @./temp.json \
--overwrite --as user
```
### 示例 5使用 SVG 写入画板(从文件读取)
适用于从零创建(直接写入 SVG和编辑现有画板编辑工作流详见 [`../routes/svg-edit.md`](../routes/svg-edit.md))。
```bash
# 编写或导出 SVG 文件
cat > diagram.svg << 'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100">
<rect x="10" y="10" width="80" height="40" fill="#4A90E2"/>
<text x="50" y="35" text-anchor="middle" fill="#fff">Hello</text>
</svg>
EOF
# 从文件读取并更新
lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--input_format svg \
--source @./diagram.svg \
--overwrite --as user
```

View File

@@ -29,9 +29,11 @@
+query --output_as code
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码DSL 或其他方式绘制的画板)
│ ├─ 只改文字/颜色 → +query --output_as raw → 手动改 JSON → +update --input_format raw
└─ 重绘/结构调整 → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
├─ 无代码(SVG/DSL 或其他方式绘制的画板)
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
→ +query --output_as image → 看图 → +query --output_as raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
│ → [`../routes/svg-edit.md`](../routes/svg-edit.md)(视觉高保真还原,大部分场景适用)
└─ 用户有明确要求 → 以用户要求优先
```
@@ -79,7 +81,7 @@ diagram.png ← 渲染结果
> 因此,若需要整体更新画板内容,需携带 --overwrite flag 覆盖式更新。
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \

View File

@@ -13,7 +13,7 @@ Step 1: 路由 & 读取知识
Step 2: 生成完整 DSL含颜色
- 按 content.md 规划信息量和分组
- 按 layout.md 选择布局模式和间距
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 --icons` 查看可用图标
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.12 --icons` 查看可用图标
- 按 style.md 上色(用户没指定时用默认经典色板)
- 按 schema.md 语法输出完整 JSON
- 连线参考 connectors.md排版参考 typography.md
@@ -25,12 +25,12 @@ Step 2: 生成完整 DSL含颜色
Step 3: 渲染 & 审查 → 交付
- 渲染前自查(见下方检查清单)
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json -o diagram.png
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.json -o diagram.png
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.json --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -16,10 +16,10 @@ Step 3: 渲染验证 & 写入画板 & 交付
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
2. 保存为 diagram.mmd
3. 渲染仅用于预览验证PNG 不是最终产物):
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd -o diagram.png
npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.mmd -o diagram.png
4. 审查 PNG有问题修改后重新渲染最多 2 轮)
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.mmd --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -0,0 +1,85 @@
# SVG 编辑路径
通过导出画板的 SVG → 编辑 SVG → 回写画板,实现对已有画板的可视化编辑。
---
## ⚠️ 有损性警告
SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层级/表格结构/连线绑定/容器类型/mention/节点 ID/锁定/评论)会丢失。
**保留的信息**:形状几何(位置/大小/路径)、文本内容与基本格式(字号/粗体/斜体/对齐)、填充色/描边色/透明度(线性渐变降级为第一个 stop-color 纯色)、连接器路径形状与箭头样式、`<g>` 嵌套的基本分组关系≥2 子元素时重建为 DirectFocusGroup
---
## Workflow
### 0. 用户确认(强制)
在执行任何编辑前,**必须**向用户说明:
> SVG 编辑只保证视觉层面对齐,画板语义(层级/节点类型/思维导图结构/表格结构/连线绑定/容器类型/mention 等)将不可恢复,是否继续?
**用户未确认前不得执行后续步骤。**
### 1. 导出当前画板 SVG
```bash
lark-cli whiteboard +query \
--whiteboard-token <TOKEN> \
--output_as svg \
--output <dir>/original.svg \
--as user
```
### 2. 编辑 SVG
在导出的 SVG 上进行修改。参考 [`svg.md` § 画板怎么处理 SVG](./svg.md#画板怎么处理-svg) 了解可识别元素与不支持的装饰特性。
**技术约束**
- 新增文字必须用 `<text>`(不是 `<path>`容器宽度留够CJK ≈ 1em / Latin ≈ 0.6em
- 避免 `skewX` / `skewY` / `matrix(...)` 变换
- 禁止使用 `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>`
**编辑原则**(区别于从零创作):
- **风格一致**:新增/修改元素应匹配导出 SVG 中已有的配色、字号、线宽、间距风格,不引入突兀的视觉差异
- **最小改动**:只修改用户要求的部分,不主动"优化"或重排无关区域
- **结构稳定**:尽量保留原有 `<g>` 层级结构,避免不必要的重组导致分组关系变化
- **连线协调**:连接器端点绑定已丢失,若移动了形状,必须手动同步调整视觉上连接到该形状的 connector path 端点坐标,否则连线会"断开"
- **内部引用完整性**:不要随意删改 `<defs>` 中被 `url(#id)` 引用的元素(`<marker>`/`<linearGradient>` 等)或修改其 `id`,否则引用方会失效
### 3. 渲染审查
```bash
# 渲染 PNG 预览
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/edited.svg -o <dir>/edited.png -f svg
# 几何检查text-overflow / node-overlap
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/edited.svg -f svg --check
```
结合 PNG 视觉效果和 `--check` 报告进行调整,有问题则修改 SVG 后重新渲染(最多 2 轮)。
- SVG 本地渲染预览时,画板中的图片因 session 原因无法正常显示,属于预期内的行为。
### 4. 写回画板
`--overwrite` 会清空原画板内容,确认后再执行
```bash
# dry-run 探测
lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source @<dir>/edited.svg \
--input_format svg \
--idempotent-token <10+字符唯一串> \
--overwrite --dry-run --as user
# 用户确认后执行
lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source @<dir>/edited.svg \
--input_format svg \
--idempotent-token <10+字符唯一串> \
--overwrite --as user
```

View File

@@ -30,12 +30,12 @@
```
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
写文件 <dir>/diagram.svg
渲染 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
渲染 npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
```
`npx -y @larksuite/whiteboard-cli@^0.2.11 --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
`npx -y @larksuite/whiteboard-cli@^0.2.12 --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
## 画板怎么处理 SVG

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
## Layout 规则

View File

@@ -10,7 +10,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
## Layout 规则

View File

@@ -9,7 +9,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
- 不适合手动心算坐标
## Layout 规则

View File

@@ -12,6 +12,7 @@
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
- TestIM_MessagesSendAudioDryRunRejectsNonOpus: proves the `im +messages-send --audio` dry-run validation rejects non-Opus local audio before upload, with typed validation metadata and recovery guidance.
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
@@ -27,7 +28,7 @@
| ✓ | im +messages-reply | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/reply to message in thread as bot | `--message-id`; `--text`; `--reply-in-thread` | reply is read back via thread list |
| ✕ | im +messages-resources-download | shortcut | | none | needs a stable image/file message fixture plus file_key proof; left uncovered |
| ✕ | im +messages-search | shortcut | | none | freshly sent messages were not indexed deterministically in UAT time for a stable read-after-write proof |
| ✓ | im +messages-send | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/send message as user; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot | `--chat-id`; `--text` | covered where returned message IDs feed follow-up reads |
| ✓ | im +messages-send | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/send message as user; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot; im/message_audio_dryrun_test.go::TestIM_MessagesSendAudioDryRunRejectsNonOpus | `--chat-id`; `--text`; `--audio ./voice.mp3 --dry-run` | live text sends feed follow-up reads; dry-run pins non-Opus audio validation before upload |
| ✓ | im +threads-messages-list | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/list thread replies as bot | `--thread` | proves threaded reply is persisted |
| ✕ | im chat.members create | api | | none | no member mutation workflow yet |
| ✕ | im chat.members get | api | | none | no member get workflow yet |

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessagesSendAudioDryRunRejectsNonOpus(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "im_audio_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_audio_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
workDir := t.TempDir()
audioPath := filepath.Join(workDir, "voice.mp3")
require.NoError(t, os.WriteFile(audioPath, []byte("not real mp3; validation checks extension before upload"), 0o600))
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+messages-send",
"--chat-id", "oc_123",
"--audio", "./voice.mp3",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
if got := gjson.Get(result.Stderr, "error.type").String(); got != "validation" {
t.Fatalf("error.type = %q, want validation\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.subtype").String(); got != "invalid_argument" {
t.Fatalf("error.subtype = %q, want invalid_argument\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.param").String(); got != "--audio" {
t.Fatalf("error.param = %q, want --audio\nstderr:\n%s", got, result.Stderr)
}
message := gjson.Get(result.Stderr, "error.message").String()
if !strings.Contains(message, "--audio supports only Opus audio files") {
t.Fatalf("error.message = %q, want Opus guidance\nstderr:\n%s", message, result.Stderr)
}
hint := gjson.Get(result.Stderr, "error.hint").String()
if !strings.Contains(hint, "--file") || !strings.Contains(hint, "ffmpeg") {
t.Fatalf("error.hint = %q, want --file and ffmpeg guidance\nstderr:\n%s", hint, result.Stderr)
}
}