mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
9 Commits
features/F
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cfc2317a1 | ||
|
|
418068f728 | ||
|
|
cb63055c22 | ||
|
|
aeb40e67af | ||
|
|
7889be8902 | ||
|
|
b46e60c156 | ||
|
|
d71bab0061 | ||
|
|
d11a6e97a4 | ||
|
|
e4248d1154 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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
23
events/task/native.go
Normal 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
32
events/task/preconsume.go
Normal 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
|
||||
}
|
||||
119
events/task/preconsume_test.go
Normal file
119
events/task/preconsume_test.go
Normal 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
33
events/task/register.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
95
events/task/register_test.go
Normal file
95
events/task/register_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 metadata(subtype/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 损坏/不可读 → error(fail 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -242,7 +242,6 @@ func Shortcuts() []common.Shortcut {
|
||||
GetMyTasks,
|
||||
GetRelatedTasks,
|
||||
SearchTask,
|
||||
SubscribeTaskEvent,
|
||||
UploadAttachmentTask,
|
||||
CreateTasklist,
|
||||
SearchTasklist,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)),立即停止并转述超限项。
|
||||
|
||||
@@ -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`,不要把源码、依赖目录、构建缓存一起发布。
|
||||
|
||||
@@ -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`;否则向用户确认目录名)。
|
||||
|
||||
@@ -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) |
|
||||
|
||||
78
skills/lark-event/references/lark-event-task.md
Normal file
78
skills/lark-event/references/lark-event-task.md
Normal 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
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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 原生格式 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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#写入画板)
|
||||
|
||||
@@ -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#写入画板)
|
||||
|
||||
85
skills/lark-whiteboard/routes/svg-edit.md
Normal file
85
skills/lark-whiteboard/routes/svg-edit.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
@@ -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 |
|
||||
|
||||
62
tests/cli_e2e/im/message_audio_dryrun_test.go
Normal file
62
tests/cli_e2e/im/message_audio_dryrun_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user