Compare commits

..

6 Commits

Author SHA1 Message Date
sunpeiyang.996
da237cc73a docs(skill): describe html5 block xml usage
Change-Id: I8bb82749c6eef9ef0470cb1d6e242cb5a0e763aa
2026-07-08 17:23:23 +08:00
max
9413e7cd8b feat(vc): refine meeting-events output and reaction forwarding (#1674)
Refine `vc +meeting-events` around a stable agent-facing output contract.

The command now exposes structured meeting metadata, current read identity, normalized event rows, warnings, and pagination fields across JSON/NDJSON/pretty output. Event rows include stable event identifiers, event time, actors, and event-specific payloads for participant, chat/reaction, transcript, and magic-share events.

Improve meeting status inference by treating participant-left events with meeting-ended leave reasons as an ended signal, and keep compatibility with payload-only event shapes by falling back to `payload.activity_event_type`.

Update `lark-vc-agent` guidance for forwarding meeting chat and reactions to IM. Agents should build Feishu post content from JSON events, emit IM `emotion` nodes only for whitelisted reaction keys, and fall back unsupported reaction keys to text.

Add focused unit and dry-run E2E coverage for the event-type fallback and `vc +meeting-events --dry-run` request shape.
2026-07-08 15:35:14 +08:00
raistlin042
047d729f72 docs: restore one-time authorization guidance in lark-apps skill (#1794) 2026-07-08 15:18:49 +08:00
chenxingyang1019
1a9f637866 fix(apps): make db --environment optional, auto-select branch server-side (#1735)
* fix(apps): make db --environment optional, auto-select branch server-side

All db shortcuts defaulted --environment to "dev", which forced single-env
apps (whose DB lives on the online branch, with no dev branch) to fail with
"Invalid DB Branch: dev" unless the user explicitly passed --environment
online.

Change the default to empty: when --environment is omitted the CLI sends no
env, letting the server pick the branch by the app's multi-env state
(multi-env → dev, single-env → online), matching miaoda-cli's behavior of
not carrying dbBranch when unset. Explicit --environment dev|online is
unchanged; explicit dev on a single-env app still errors as expected.

- 10 db shortcuts: dbEnvFlags default "dev" → "" (+db-execute, +db-table-list,
  +db-table-get, +db-quota-get, +db-data-export, +db-data-import,
  +db-changelog-list, +db-audit-list/-set/-status)
- dry-run e2e assertions updated: default env is now unset, not "dev"
- skill docs (lark-apps-db, lark-apps-db-execute) describe the auto-select

* fix(apps): omit empty --environment param; refine dry-run tests and skill doc

Address PR #1735 review:
- omit-empty: when --environment is unset, drop the env query key entirely
  instead of sending env="" — matches the family's omit-empty convention
  (cf. page_token) and miaoda-cli's "no dbBranch when unset". Add dbEnvParams
  helper; apply across all db shortcuts (execute, table-list/-get, quota-get,
  changelog-list, audit-list/-set/-status, data-export/-import) plus the
  export/import query params, queryExportTotal and audit-list table/status probes.
- e2e dry-run assertions pin env is omitted via .Exists() (was Equal "").
- skill doc (lark-apps-db): rewrite the --environment guidance from an agent's
  decision POV — read vs write, single-env writes hit online prod, explicit dev
  on single-env as a probe; drop redundant/changelog phrasing.

* fix(apps): db recovery --environment support + diff/migrate display fixes

- +db-recovery-diff/-apply: add --environment (env → query param on submit
  and both status polls), aligned with the recovery env IDL
- recovery diff: parse string row counts (inserted/deleted arrive as strings)
  so they render as "-N rows" instead of "no changes"; drop the redundant
  per-table data-row line when a schema action (drop/restore/alter) exists for
  the same table; count tables_affected by distinct tables
- +db-env-migrate: run a dry_run preview before apply to backfill the change
  count when the server reports changes_applied=0 on a cold apply (matches
  miaoda-cli's diff-then-apply)
- lark-apps-db.md: drop the redundant recovery clause (recovery follows the
  standard --environment rule)

* test(apps): cover no-env dry-run defaults + numericAsFloat string path

Address CodeRabbit review threads on PR #1735:
- numericAsFloat: add numeric-string cases ("13.5", " 13.5 ", int, empty)
- db-data-import: assert dry-run omits env when --environment unset (table
  still defaults to file basename)
- db-quota-get: assert dry-run omits env when --environment unset
2026-07-08 14:06:22 +08:00
liujinkun2025
34c4ba5581 fix: accept opaque wiki node tokens (#1789) 2026-07-08 11:40:58 +08:00
SunPeiYang996
9a6ba41684 feat: support whiteboard file inputs in docs XML (#1784)
* feat: support whiteboard file inputs in docs XML

* docs: prefer path syntax for whiteboard file inputs

* docs: keep lark-doc skill metadata unchanged

* fix: aggregate whiteboard path errors across markdown
2026-07-08 11:18:34 +08:00
48 changed files with 1944 additions and 2209 deletions

View File

@@ -47,34 +47,6 @@ jobs:
exit 1
fi
plugin-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
# No fetch_meta: the git-archive clean tree must embed only the
# committed meta_data stub (reproduces the bare-module customer state).
- name: Run plugin-integration L4 tests
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
sidecar-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -442,7 +414,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -462,18 +434,10 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Add them back here to make them required
# once they have proven stable.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -105,14 +105,6 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
existing := map[string]bool{}
token := ""
for {
params := map[string]interface{}{"env": env, "page_size": 100}
params := map[string]interface{}{"page_size": 100}
if env != "" {
params["env"] = env
}
if token != "" {
params["page_token"] = token
}
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
// fetchAuditEnabledTables 拉审计状态返回当前已开启审计的表名集合status 命令同源接口)。
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
statusParams := map[string]interface{}{}
if env != "" {
statusParams["env"] = env
}
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
if err != nil {
return nil, err
}
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
// buildAuditListParams 组装 audit_list 查询参数env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
params := dbEnvParams(rctx, map[string]interface{}{
"tables": strings.Join(tables, ","),
"page_size": rctx.Int("page-size"),
}
})
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Enable table audit").
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
stop := rctx.StartSpinner("Enabling audit logging for " + table)
defer stop()
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": dbEnv(rctx)},
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
stop()
if err != nil {
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Disable table audit").
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
}
table := strings.TrimSpace(rctx.Str("table"))
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": dbEnv(rctx)},
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"table": table, "enabled": false})
if err != nil {
return withAppsHint(err, dbAuditSetHint)

View File

@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
// buildAuditStatusParams 组装 audit_status 查询参数env 及可选 table单表查询
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": dbEnv(rctx)}
params := dbEnvParams(rctx, map[string]interface{}{})
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
params["table"] = t
}

View File

@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
// buildChangelogParams 组装 changelog_list 查询参数env / page_size 及可选 table/change_id/since/until/page_token。
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
params := dbEnvParams(rctx, map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
})
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
return common.NewDryRunAPI().
GET(appDataExportPath(appID)).
Desc("Export Miaoda app table data (raw bytes)").
Params(map[string]interface{}{
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
Params(dbEnvParams(rctx, map[string]interface{}{
"table": strings.TrimSpace(rctx.Str("table")),
"format": format, "limit": rctx.Int("limit"),
})
}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
// total 查询失败不阻断导出——回退到按导出文件内容数行。
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
exportQuery := larkcore.QueryParams{
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
}
if env := dbEnv(rctx); env != "" {
exportQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{dbEnv(rctx)},
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
},
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: exportQuery,
})
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
// queryExportTotal 调 GetAppTableRecordListpage_size=1取 total符合条件的记录总数
// 该接口与 +db-data-export 同为 spark:app:read scope避免导出命令被迫升级到写权限。
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
map[string]interface{}{"env": env, "page_size": 1}, nil)
params := map[string]interface{}{"page_size": 1}
if env != "" {
params["env"] = env
}
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
if err != nil {
return 0, err
}

View File

@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
return common.NewDryRunAPI().
POST(appDataImportPath(appID)).
Desc("Import data file into Miaoda app table (multipart upload)").
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
fd.AddField("file_name", fileName)
fd.AddFile("file", bytes.NewReader(content))
importQuery := larkcore.QueryParams{"table": []string{table}}
if env := dbEnv(rctx); env != "" {
importQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: appDataImportPath(appID),
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
QueryParams: importQuery,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {

View File

@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
}
}
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
p := env.API[0].Params
if _, ok := p["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
}
if p["table"] != "orders" {
t.Fatalf("table should still default to file basename, got params=%v", p)
}
}
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
func TestAppsDBDataImport_Success(t *testing.T) {
chdirTemp(t)

View File

@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
if err != nil {
return err
}
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply服务端在未经
// dry_run 预热时直接 apply虽发布成功却把 changes_applied 回填成 0展示「Migrated (0 changes)」)。
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
// 交由下面真实 apply 统一报同样的业务错。
pending := 0
var previewFrom, previewTo string
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
pending = len(projectMigrationChanges(preview["changes"]))
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
}
stop := rctx.StartSpinner("Applying migration (dev → online)")
defer stop()
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
return withAppsHint(err, dbEnvMigrateHint)
}
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
if from == "" {
from = previewFrom
}
if to == "" {
to = previewTo
}
taskID := common.GetString(submit, "task_id")
applied := intFromAny(submit["changes_applied"])
if applied == 0 {
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
applied = n
}
}
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
if applied == 0 && pending > 0 {
applied = pending
}
stop() // clear spinner before printing the result
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
rctx.OutFormat(out, nil, func(w io.Writer) {

View File

@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
// 异步submit 返 task_idstatus 立刻 applied → CLI 对外统一 migrated。
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
}
// 配额未对接storage_quota_bytes=0→ json 删 quota/usage_percent仅留已用量与 tables/views。
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
// query 不带 env 键(交服务端按应用形态自动选分支)。
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBQuotaGet,
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbQuotaURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
}
}
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
//
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"env": dbEnv(rctx),
return dbEnvParams(rctx, map[string]interface{}{
"transactional": false,
}
})
}
// resolveExecuteSQL 返回要执行的 SQL在用时DryRun/Execute现读使 --file 的内容

View File

@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
return common.NewDryRunAPI().
GET(appDbQuotaPath(appID)).
Desc("Get Miaoda app database storage usage").
Params(map[string]interface{}{"env": dbEnv(rctx)})
Params(dbEnvParams(rctx, map[string]interface{}{}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}

View File

@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
target := rctx.Str("target")
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
if err != nil {
return withAppsHint(err, dbRecoveryHint)
}
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
}
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
if err != nil {
return nil, withAppsHint(err, dbRecoveryHint)
}
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
}
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "preview_status")) {
@@ -195,13 +203,13 @@ type recoveryChange struct {
// recoveryDiffOutput 组装 diff 输出target / tables_affected / changes[] / estimated_seconds。
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
arr, _ := preview["changes"].([]interface{})
changes := make([]recoveryChange, 0, len(arr))
raw := make([]recoveryChange, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
changes = append(changes, recoveryChange{
raw = append(raw, recoveryChange{
Table: common.GetString(m, "table"),
Inserted: m["inserted"],
Deleted: m["deleted"],
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
DroppedAt: common.GetString(m, "dropped_at"),
})
}
tablesAffected := intFromAny(preview["tables_affected"])
if tablesAffected == 0 {
tablesAffected = len(changes)
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
// 两行 + tables_affected 翻倍。
hasSchema := map[string]bool{}
for _, c := range raw {
if c.Action != "" {
hasSchema[c.Table] = true
}
}
changes := make([]recoveryChange, 0, len(raw))
for _, c := range raw {
if c.Action == "" && hasSchema[c.Table] {
continue
}
changes = append(changes, c)
}
// tables_affected 按去重后的不同表数计(而非变更条数)。
seen := map[string]bool{}
for _, c := range changes {
seen[c.Table] = true
}
est := intFromAny(preview["estimated_seconds"])
if est == 0 {
est = 30 // PRD 兜底
}
return map[string]interface{}{
"target": target, "tables_affected": tablesAffected,
"target": target, "tables_affected": len(seen),
"changes": changes, "estimated_seconds": est,
}
}

View File

@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": dbEnv(rctx)}
params := dbEnvParams(rctx, map[string]interface{}{})
if rctx.Format == "pretty" {
params["format"] = "ddl"
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
}
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
params := dbEnvParams(rctx, map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
})
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
return 0, false
}
return f, true
case string:
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
s := strings.TrimSpace(v)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
case nil:
return 0, false
}

View File

@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
{"json.Number valid", json.Number("13.5"), 13.5, true},
{"json.Number invalid", json.Number("abc"), 0, false},
{"nil", nil, 0, false},
{"unsupported string", "x", 0, false},
{"non-numeric string", "x", 0, false},
{"numeric string", "13.5", 13.5, true},
{"numeric string int", "2", 2, true},
{"numeric string padded", " 13.5 ", 13.5, true},
{"empty string", "", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
return rctx.Str("environment")
}
// dbEnvParams 把 env 并入 params仅当显式指定了环境非空才带 env 键;未指定(空)时
// 省略该键由服务端按应用多环境状态自动选分支多环境→dev单环境→online。与家族对
// 空可选参数的 omit-empty 约定一致——不发空串wire 上真正不带 env。原样返回同一个 map 便于链式。
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
if env := dbEnv(rctx); env != "" {
params["env"] = env
}
return params
}
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env显式传了就报清晰的 validation 错,指向 --environment。
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
if rctx.Changed("env") {

View File

@@ -27,12 +27,17 @@ const (
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
whiteboardTag = "whiteboard"
whiteboardTypeAttr = "type"
whiteboardPathAttr = "path"
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
)
type html5BlockReferenceEntry struct {
@@ -58,6 +63,11 @@ type html5BlockStartTag struct {
SelfClosing bool
}
type whiteboardStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
@@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
return docsV2WriteInput{}, err
}
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -232,6 +246,248 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
return out, compactReferenceMap(refMap), nil
}
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
if !strings.Contains(content, "<whiteboard") {
return content, nil
}
rewrite := func(segment string) (string, error) {
return rewriteWhiteboardFileRefs(runtime, segment)
}
if strings.TrimSpace(format) != "markdown" {
return rewrite(content)
}
var rewriteErrs []error
out := applyOutsideCodeFences(content, func(segment string) string {
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
rewriteErrs = append(rewriteErrs, rewriteErr)
return segment
}
return outSegment
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
var rewriteErrs []error
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
if err != nil {
rewriteErrs = append(rewriteErrs, err)
return raw
}
return rewritten
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
startRaw, body, _, ok := splitWhiteboardElement(raw)
if !ok {
return raw, nil
}
tag, err := parseWhiteboardStartTag(startRaw)
if err != nil {
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
}
pathValue, hasPath := tag.attr(whiteboardPathAttr)
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
if !hasPath && !hasBodyPath {
return raw, nil
}
if hasPath && strings.TrimSpace(body) != "" {
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
}
if hasPath && hasBodyPath {
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
}
typRaw, ok := tag.attr(whiteboardTypeAttr)
if !ok || strings.TrimSpace(typRaw) == "" {
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
}
typ, ok := canonicalWhiteboardFileType(typRaw)
if !ok {
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
}
if hasBodyPath {
pathValue = bodyPath
}
data, err := readWhiteboardPath(runtime, pathValue, typ)
if err != nil {
return "", err
}
tag.setAttr(whiteboardTypeAttr, typ)
tag.removeAttrs(whiteboardPathAttr)
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
}
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
trimmed := strings.TrimSpace(raw)
selfClosing = strings.HasSuffix(trimmed, "/>")
if selfClosing {
return raw, "", true, true
}
startEnd := strings.Index(raw, ">")
if startEnd < 0 {
return "", "", false, false
}
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
if endStart < 0 || endStart < startEnd {
return "", "", false, false
}
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
}
func whiteboardBodyPathRef(body string) (string, bool) {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
return "", false
}
if strings.ContainsAny(trimmed, "\r\n") {
return "", false
}
return trimmed, true
}
func canonicalWhiteboardFileType(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "svg":
return "svg", true
case "mermaid":
return "mermaid", true
case "plantuml":
return "plantuml", true
default:
return "", false
}
}
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
}
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
WithParam("path").
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
WithCause(err)
}
return string(data), nil
}
func whiteboardExtAllowed(typ string, ext string) bool {
for _, allowed := range whiteboardAllowedExts(typ) {
if ext == allowed {
return true
}
}
return false
}
func whiteboardAllowedExts(typ string) []string {
switch typ {
case "svg":
return []string{".svg"}
case "mermaid":
return []string{".mermaid", ".mmd"}
case "plantuml":
return []string{".plantuml", ".puml", ".pu", ".uml"}
default:
return nil
}
}
func whiteboardExtList(typ string) string {
return strings.Join(whiteboardAllowedExts(typ), ", ")
}
func exampleWhiteboardExt(typ string) string {
exts := whiteboardAllowedExts(typ)
if len(exts) == 0 {
return "txt"
}
return strings.TrimPrefix(exts[0], ".")
}
func whiteboardContentForType(typ string, data string) string {
if typ == "svg" {
return data
}
return escapeXMLText(data)
}
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
messages := make([]string, 0, len(flatErrs))
params := make([]errs.InvalidParam, 0, len(flatErrs))
for _, err := range flatErrs {
messages = append(messages, err.Error())
params = append(params, whiteboardInvalidParamsFromError(err)...)
}
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
WithParam("whiteboard").
WithCause(errors.Join(flatErrs...))
if len(params) > 0 {
validationErr.WithParams(params...)
}
return validationErr
}
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
flatErrs := make([]error, 0, len(rewriteErrs))
for _, err := range rewriteErrs {
var validationErr *errs.ValidationError
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
continue
}
}
flatErrs = append(flatErrs, err)
}
return flatErrs
}
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return nil
}
if len(validationErr.Params) > 0 {
return validationErr.Params
}
if validationErr.Param != "" {
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
}
return nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
@@ -621,6 +877,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return whiteboardStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != whiteboardTag {
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
@@ -630,6 +914,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
return "", false
}
func (t whiteboardStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
@@ -650,6 +943,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
t.Attrs = attrs
}
func (t *whiteboardStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t *whiteboardStartTag) setAttr(name string, value string) {
for i, attr := range t.Attrs {
if attr.Name == name {
t.Attrs[i].Value = value
return
}
}
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
@@ -674,6 +992,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
return b.String()
}
func (t whiteboardStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(whiteboardTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
@@ -694,3 +1031,18 @@ func escapeXMLAttr(value string) string {
}
return b.String()
}
func escapeXMLText(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -6,11 +6,13 @@ package doc
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -116,6 +118,61 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
files := map[string]string{
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
"flow.mmd": "flowchart TD\nA --> B",
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
}
for name, content := range files {
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error: %v", name, err)
}
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
}, "\n"),
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
got := body["content"].(string)
for _, want := range []string{
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
} {
if !strings.Contains(got, want) {
t.Fatalf("content missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `path="@`) {
t.Fatalf("content still contains whiteboard path attr: %s", got)
}
if _, ok := body["reference_map"]; ok {
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
@@ -407,6 +464,119 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"missing.svg",
"missing.mmd",
"missing.puml",
}, []string{
`whiteboard svg path "missing.svg" cannot be read`,
`whiteboard mermaid path "missing.mmd" cannot be read`,
`whiteboard plantuml path "missing.puml" cannot be read`,
})
}
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
"```",
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
"```",
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"before.svg",
"after.puml",
}, []string{
`whiteboard svg path "before.svg" cannot be read`,
`whiteboard plantuml path "after.puml" cannot be read`,
})
if strings.Contains(err.Error(), "inside.svg") {
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
}
}
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "whiteboard" {
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
}
if validationErr.Cause == nil {
t.Fatal("expected aggregated error to preserve cause")
}
var childValidationErr *errs.ValidationError
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
}
gotParams := make(map[string]string, len(validationErr.Params))
for _, param := range validationErr.Params {
gotParams[param.Name] = param.Reason
}
if len(gotParams) != len(wantParams) {
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
}
for _, param := range wantParams {
reason, ok := gotParams[param]
if !ok {
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
}
if reason == "" {
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
}
}
for _, want := range wantMessages {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q:\n%v", want, err)
}
if !strings.Contains(validationErr.Cause.Error(), want) {
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
}
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)

View File

@@ -5,6 +5,7 @@ package vc
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -15,6 +16,7 @@ import (
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -25,6 +27,9 @@ const (
minVCMeetingEventsPageSize = 20
maxVCMeetingEventsPageSize = 100
maxVCMeetingEventsPages = 200
leaveReasonUserLeft = 1
leaveReasonMeetingEnded = 2
leaveReasonKicked = 3
)
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
@@ -41,11 +46,11 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
return ts, nil
}
// VCMeetingEvents lists bot meeting events for a meeting.
// VCMeetingEvents lists meeting events for a meeting.
var VCMeetingEvents = common.Shortcut{
Service: "vc",
Command: "+meeting-events",
Description: "List bot meeting events by meeting ID",
Description: "List meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
@@ -99,20 +104,28 @@ var VCMeetingEvents = common.Shortcut{
return err
}
events = compactMeetingEvents(events)
outData := map[string]interface{}{
"events": events,
"has_more": data["has_more"],
"page_token": data["page_token"],
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
outData := buildMeetingEventsOutput(data, events, identity, identityWarning)
metadata := map[string]interface{}{
"row_type": "metadata",
"meeting": outData.Meeting,
"identity": outData.Identity,
"has_more": outData.HasMore,
"page_token": outData.PageToken,
}
if len(outData.Warnings) > 0 {
metadata["warnings"] = outData.Warnings
}
ndjsonData := meetingEventsEventRows(outData.Events, metadata)
timeline := buildMeetingEventTimeline(events)
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
if len(timeline.entries) == 0 {
fmt.Fprintln(w, "No meeting events.")
return
}
io.WriteString(w, renderMeetingEventsPretty(timeline))
})
if runtime.Format == "ndjson" {
runtime.OutFormat(ndjsonData, &output.Meta{Count: len(events)}, func(w io.Writer) {})
} else {
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
renderMeetingEventsCompactPretty(w, outData, timeline)
})
}
if runtime.Format == "pretty" && pageToken != "" {
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
if hasMore {
@@ -123,6 +136,400 @@ var VCMeetingEvents = common.Shortcut{
},
}
type meetingEventsOutput struct {
Meeting meetingEventsMeeting `json:"meeting"`
Identity meetingEventsIdentity `json:"identity"`
Events []meetingEventsEvent `json:"events"`
Warnings []string `json:"warnings,omitempty"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token,omitempty"`
}
type meetingEventsMeeting struct {
ID string `json:"id,omitempty"`
Topic string `json:"topic,omitempty"`
MeetingNo string `json:"meeting_no,omitempty"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Status string `json:"status"`
}
type meetingEventsIdentity struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
ParticipantType string `json:"participant_type,omitempty"`
Role string `json:"role,omitempty"`
Label string `json:"label,omitempty"`
}
type meetingEventsEvent struct {
EventID string `json:"event_id,omitempty"`
EventType string `json:"event_type,omitempty"`
EventTime string `json:"event_time,omitempty"`
Actors []meetingEventsIdentity `json:"actors,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
}
type meetingEventsEndSignal struct {
Ended bool
EndTime time.Time
HasEndTime bool
}
func buildMeetingEventsOutput(data map[string]interface{}, events []interface{}, identity meetingEventsIdentity, warnings ...string) meetingEventsOutput {
output := meetingEventsOutput{
Meeting: meetingEventsMeetingFromPayload(nil),
Identity: identity,
HasMore: common.GetBool(data, "has_more"),
PageToken: common.GetString(data, "page_token"),
}
for _, warning := range warnings {
if warning = strings.TrimSpace(warning); warning != "" {
output.Warnings = append(output.Warnings, warning)
}
}
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
continue
}
payload := common.GetMap(event, "payload")
if meeting := common.GetMap(payload, "meeting"); meeting != nil {
output.Meeting = meetingEventsMeetingFromPayload(meeting)
}
output.Events = append(output.Events, meetingEventsEventFromPayload(event, output.Identity))
}
applyMeetingEventsEndSignal(&output.Meeting, meetingEventsEndSignalFromEvents(events))
return output
}
func meetingEventsCurrentIdentity(runtime *common.RuntimeContext) (meetingEventsIdentity, string) {
if runtime.As() == core.AsBot {
botInfo, err := runtime.BotInfo()
if err != nil {
return meetingEventsBotIdentity(nil), fmt.Sprintf("identity unavailable: %v", err)
}
return meetingEventsBotIdentity(botInfo), ""
}
userOpenID := strings.TrimSpace(runtime.UserOpenId())
identity := meetingEventsIdentity{
ID: userOpenID,
Name: strings.TrimSpace(runtime.Config.UserName),
ParticipantType: "human",
}
identity.Label = identityLabel(identity)
if userOpenID == "" {
return identity, "identity unavailable: current user open_id is unavailable"
}
return identity, ""
}
func meetingEventsBotIdentity(botInfo *common.BotInfo) meetingEventsIdentity {
if botInfo == nil {
return meetingEventsIdentity{ParticipantType: "bot", Label: "bot"}
}
identity := meetingEventsIdentity{
ID: botInfo.OpenID,
Name: botInfo.AppName,
ParticipantType: "bot",
}
identity.Label = identityLabel(identity)
return identity
}
func meetingEventsMeetingFromPayload(meeting map[string]interface{}) meetingEventsMeeting {
out := meetingEventsMeeting{
ID: common.GetString(meeting, "id"),
Topic: common.GetString(meeting, "topic"),
MeetingNo: common.GetString(meeting, "meeting_no"),
StartTime: meetingEventsTimeString(common.GetString(meeting, "start_time")),
EndTime: meetingEventsTimeString(common.GetString(meeting, "end_time")),
Status: "unknown",
}
start, hasStart := parseFlexibleTime(out.StartTime)
end, hasEnd := parseFlexibleTime(out.EndTime)
if hasStart && !hasEnd {
out.Status = "ongoing"
}
if hasStart && hasEnd {
if end.After(start) {
out.Status = "ended"
} else {
out.Status = "ongoing"
out.EndTime = ""
}
}
return out
}
func applyMeetingEventsEndSignal(meeting *meetingEventsMeeting, signal meetingEventsEndSignal) {
if meeting == nil || !signal.Ended {
return
}
meeting.Status = "ended"
if signal.HasEndTime {
meeting.EndTime = signal.EndTime.UTC().Format(time.RFC3339)
}
}
func meetingEventsEndSignalFromEvents(events []interface{}) meetingEventsEndSignal {
var signal meetingEventsEndSignal
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil || meetingEventType(event) != "participant_left" {
continue
}
payload := common.GetMap(event, "payload")
if payload == nil {
continue
}
fallbackTime, fallbackOK := parseFlexibleTime(common.GetString(event, "event_time"))
for _, rawItem := range common.GetSlice(payload, "participant_left_items") {
item, _ := rawItem.(map[string]interface{})
if item == nil || int(common.GetFloat(item, "leave_reason")) != leaveReasonMeetingEnded {
continue
}
signal.Ended = true
endTime, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
if !ok {
endTime, ok = fallbackTime, fallbackOK
}
if ok && (!signal.HasEndTime || endTime.After(signal.EndTime)) {
signal.EndTime = endTime
signal.HasEndTime = true
}
}
}
return signal
}
func meetingEventsEventFromPayload(event map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsEvent {
payload := common.GetMap(event, "payload")
out := meetingEventsEvent{
EventID: common.GetString(event, "event_id"),
EventType: meetingEventType(event),
EventTime: meetingEventsTimeString(common.GetString(event, "event_time")),
Payload: payload,
}
out.Actors = eventActors(out.EventType, payload, selfIdentity)
return out
}
func eventActors(eventType string, payload map[string]interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
var actors []meetingEventsIdentity
addFromItems := func(key, participantKey string) {
for _, raw := range common.GetSlice(payload, key) {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
if participant := common.GetMap(item, participantKey); participant != nil {
actors = append(actors, meetingEventsIdentityFromParticipant(participant, selfIdentity))
}
}
}
switch eventType {
case "participant_joined":
addFromItems("participant_joined_items", "participant")
case "participant_left":
addFromItems("participant_left_items", "participant")
case "transcript_received":
addFromItems("transcript_received_items", "speaker")
case "chat_received":
addFromItems("chat_received_items", "operator")
case "magic_share_started":
addFromItems("magic_share_started_items", "operator")
case "magic_share_ended":
addFromItems("magic_share_ended_items", "operator")
}
return actors
}
func meetingEventsIdentityFromParticipant(participant map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsIdentity {
identity := meetingEventsIdentity{
ID: common.GetString(participant, "id"),
Name: common.GetString(participant, "user_name"),
ParticipantType: meetingEventsParticipantType(participant),
Role: meetingEventsParticipantRole(participant),
}
if identity.ID != "" && selfIdentity.ID != "" && identity.ID == selfIdentity.ID {
if selfIdentity.ParticipantType == "bot" && (identity.ParticipantType == "" || identity.ParticipantType == "human") {
identity.ParticipantType = "bot"
}
if selfIdentity.ParticipantType == "bot" && (identity.Role == "" || identity.Role == "participant") {
identity.Role = "bot"
}
}
if identity.ParticipantType == "" {
identity.ParticipantType = "human"
}
if identity.Role == "" {
identity.Role = "participant"
}
identity.Label = identityLabel(identity)
return identity
}
func meetingEventsParticipantType(participant map[string]interface{}) string {
if raw := meetingEventsParticipantTypeFromParticipantType(fieldValueString(participant, "participant_type")); raw != "" {
return raw
}
return meetingEventsParticipantTypeFromUserType(fieldValueString(participant, "user_type"))
}
func meetingEventsParticipantTypeFromParticipantType(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "user", "human":
return "human"
case "2", "bot", "app":
return "bot"
case "":
return ""
default:
return "unknown"
}
}
func meetingEventsParticipantRole(participant map[string]interface{}) string {
if raw := meetingEventsRoleFromParticipantRole(fieldValueString(participant, "role")); raw != "" {
return raw
}
return meetingEventsRoleFromEventUserRole(fieldValueString(participant, "user_role"))
}
func meetingEventsParticipantTypeFromUserType(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "user", "human":
return "human"
case "2", "10", "bot", "app":
return "bot"
case "":
return ""
default:
return "unknown"
}
}
func meetingEventsRoleFromParticipantRole(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "host":
return "host"
case "2", "co_host", "cohost":
return "co_host"
case "3", "participant", "attendee":
return "participant"
case "4", "bot", "app":
return "bot"
case "":
return ""
default:
return raw
}
}
func meetingEventsRoleFromEventUserRole(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "participant", "attendee":
return "participant"
case "2", "host":
return "host"
case "4", "bot", "app":
return "bot"
case "", "0":
return ""
default:
return raw
}
}
func fieldValueString(values map[string]interface{}, key string) string {
if values == nil {
return ""
}
switch value := values[key].(type) {
case string:
return value
case int:
return strconv.Itoa(value)
case int64:
return strconv.FormatInt(value, 10)
case float64:
return strconv.FormatInt(int64(value), 10)
case json.Number:
return value.String()
default:
return ""
}
}
func identityLabel(identity meetingEventsIdentity) string {
name := identity.Name
if name == "" {
name = identity.ID
}
if name == "" {
name = "unknown"
}
var tags []string
if identity.ParticipantType != "" {
tags = append(tags, identity.ParticipantType)
}
if identity.Role != "" && identity.Role != identity.ParticipantType {
tags = append(tags, identity.Role)
}
if len(tags) == 0 {
return name
}
return fmt.Sprintf("%s [%s]", name, strings.Join(tags, ","))
}
func meetingEventsTimeString(raw string) string {
if parsed, ok := parseFlexibleTime(raw); ok {
return parsed.UTC().Format(time.RFC3339)
}
return strings.TrimSpace(raw)
}
func meetingEventsEventRows(events []meetingEventsEvent, metadata map[string]interface{}) []interface{} {
rows := make([]interface{}, 0, len(events)+1)
for _, event := range events {
row := meetingEventsEventRow(event)
rows = append(rows, row)
}
if metadata != nil {
rows = append(rows, metadata)
}
return rows
}
func meetingEventsEventRow(event meetingEventsEvent) map[string]interface{} {
raw, err := json.Marshal(event)
if err != nil {
return map[string]interface{}{"row_type": "event"}
}
var row map[string]interface{}
if err := json.Unmarshal(raw, &row); err != nil {
return map[string]interface{}{"row_type": "event"}
}
row["row_type"] = "event"
return row
}
func renderMeetingEventsCompactPretty(w io.Writer, data meetingEventsOutput, timeline meetingTimeline) {
if data.Identity.Label != "" {
fmt.Fprintf(w, "当前身份:%s\n", escapePrettyText(data.Identity.Label))
}
if len(timeline.entries) == 0 {
fmt.Fprintln(w, "No meeting events.")
return
}
io.WriteString(w, renderMeetingEventsPretty(timeline))
}
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
if runtime.Bool("page-all") {
return maxVCMeetingEventsPageSize, nil
@@ -323,7 +730,6 @@ type meetingTimelineEntry struct {
when time.Time
hasWhen bool
sequence int
group int
subject string
description string
details []string
@@ -332,7 +738,6 @@ type meetingTimelineEntry struct {
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
timeline := meetingTimeline{}
var sequence int
var group int
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
@@ -345,11 +750,11 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
}
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
for _, entry := range buildTimelineEntriesForEvent(event, &sequence) {
timeline.entries = append(timeline.entries, entry)
}
group++
}
applyMeetingTimelineEndSignal(&timeline, meetingEventsEndSignalFromEvents(events))
sort.SliceStable(timeline.entries, func(i, j int) bool {
left := timeline.entries[i]
right := timeline.entries[j]
@@ -370,6 +775,24 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
return timeline
}
func applyMeetingTimelineEndSignal(timeline *meetingTimeline, signal meetingEventsEndSignal) {
if timeline == nil || !signal.Ended {
return
}
if signal.HasEndTime {
if !timeline.hasStart || signal.EndTime.After(timeline.startTime) {
timeline.endTime = signal.EndTime
timeline.hasEnd = true
return
}
timeline.hasEnd = false
return
}
if timeline.hasStart && timeline.hasEnd && !timeline.endTime.After(timeline.startTime) {
timeline.hasEnd = false
}
}
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
if timeline == nil || meeting == nil {
return
@@ -391,7 +814,7 @@ func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interfa
}
}
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int) []meetingTimelineEntry {
payload := common.GetMap(event, "payload")
if payload == nil {
return nil
@@ -400,26 +823,26 @@ func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, g
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
switch eventType {
case "participant_joined":
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence)
case "participant_left":
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence)
case "transcript_received":
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
return transcriptEntries(payload, eventTime, eventTimeOK, sequence)
case "chat_received":
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
return chatEntries(payload, eventTime, eventTimeOK, sequence)
case "magic_share_started":
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence)
case "magic_share_ended":
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence)
default:
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
}
}
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_joined_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -432,15 +855,15 @@ func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "加入了会议", nil))
}
return entries
}
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_left_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -453,15 +876,15 @@ func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Ti
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, leaveAction(item), nil))
}
return entries
}
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "transcript_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -479,15 +902,15 @@ func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, f
if text != "" {
description = text
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
}
return entries
}
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "chat_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -507,15 +930,15 @@ func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbac
} else {
description = fmt.Sprintf("[%s] %s", typeLabel, description)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
}
return entries
}
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_started_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -538,15 +961,15 @@ func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.
if url != "" {
details = append(details, "URL: "+url)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, details))
}
return entries
}
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_ended_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -559,17 +982,16 @@ func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Ti
if subject == "" {
subject = "未知用户"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "结束共享", nil))
}
return entries
}
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string, details []string) meetingTimelineEntry {
entry := meetingTimelineEntry{
when: when,
hasWhen: hasWhen,
sequence: *sequence,
group: group,
subject: subject,
description: description,
details: details,
@@ -713,9 +1135,9 @@ func needsColon(description string) bool {
func leaveAction(item map[string]interface{}) string {
switch int(common.GetFloat(item, "leave_reason")) {
case 2:
case leaveReasonMeetingEnded:
return "因会议结束离开了会议"
case 3:
case leaveReasonKicked:
return "被移出了会议"
default:
return "离开了会议"

View File

@@ -5,6 +5,7 @@ package vc
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
@@ -54,6 +55,33 @@ func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *ht
}
}
func botInfoStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "bot_001",
"app_name": "Demo Bot",
},
},
}
}
func botInfoErrorStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Status: 500,
Body: map[string]interface{}{
"code": 99991663,
"msg": "bot info unavailable",
},
}
}
func participantJoinedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-1",
@@ -73,6 +101,8 @@ func participantJoinedEvent() map[string]interface{} {
"participant": map[string]interface{}{
"id": "bot_001",
"user_name": "Demo Bot",
"user_type": 2,
"user_role": 4,
},
"join_time": "2026-04-17T08:00:00Z",
},
@@ -90,6 +120,36 @@ func participantJoinedEventOngoing() map[string]interface{} {
return event
}
func participantLeftEventWithReason(leaveReason int) map[string]interface{} {
return map[string]interface{}{
"event_id": "event-left",
"event_type": "participant_left",
"event_time": "2026-04-17T07:18:50Z",
"payload": map[string]interface{}{
"activity_event_type": "participant_left",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776410100",
"end_time": "1776410100",
},
"participant_left_items": []interface{}{
map[string]interface{}{
"participant": map[string]interface{}{
"id": "bot_001",
"user_name": "Demo Bot",
"user_type": 2,
"user_role": 4,
},
"leave_time": "1776410330000",
"leave_reason": leaveReason,
},
},
},
}
}
func chatReceivedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-2",
@@ -112,7 +172,7 @@ func chatReceivedEvent() map[string]interface{} {
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 3,
"message_type": 1,
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
@@ -140,7 +200,7 @@ func multiChatReceivedEvent() map[string]interface{} {
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "第一条\n第二行",
"message_type": 3,
"message_type": 1,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
@@ -149,6 +209,44 @@ func multiChatReceivedEvent() map[string]interface{} {
},
map[string]interface{}{
"content": "第二条",
"message_type": 1,
"send_time": "1776408062000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
},
},
}
}
func mixedChatAndReactionEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-reaction",
"event_type": "chat_received",
"event_time": "2026-04-17T08:05:00Z",
"payload": map[string]interface{}{
"activity_event_type": "chat_received",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 1,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
map[string]interface{}{
"content": "OK",
"message_type": 3,
"send_time": "1776408062000",
"operator": map[string]interface{}{
@@ -414,7 +512,7 @@ func TestMeetingEvents_DryRun(t *testing.T) {
"--start", "1710000000",
"--end", "1710003600",
"--dry-run",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -442,7 +540,7 @@ func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
"--meeting-id", "7628568141510692381",
"--page-all",
"--dry-run",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -457,24 +555,39 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--page-all",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v: %s", err, stdout.String())
}
events := common.GetSlice(common.GetMap(envelope, "data"), "events")
if got := len(events); got != 2 {
t.Fatalf("events len = %d, want 2: %s", got, stdout.String())
}
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if _, ok := event["summary"]; ok {
t.Fatalf("event should not expose summary: %s", stdout.String())
}
if _, ok := event["raw"]; ok {
t.Fatalf("event should not expose raw: %s", stdout.String())
}
}
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
}
if !strings.Contains(out, `"has_more":false`) {
t.Fatalf("expected final has_more=false: %s", stdout.String())
}
@@ -483,6 +596,80 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"identity":{"id":"bot_001","name":"DemoBot","participant_type":"bot","label":"DemoBot[bot]"}`,
`"role":"bot"`,
`"event_type":"participant_joined"`,
`"actors":[`,
`"start_time":"2026-04-17T06:35:00Z"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"events":[`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
for _, unwanted := range []string{
`"current_participants":`,
`"is_self":`,
`"summary":`,
`"raw":`,
} {
if strings.Contains(out, unwanted) {
t.Fatalf("json output should not contain %q: %s", unwanted, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
reg.Register(botInfoErrorStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"event_type":"participant_joined"`,
`"identity":{"participant_type":"bot","label":"bot"}`,
`"warnings":[`,
`identityunavailable`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_UserIdentitySkipsBotInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
@@ -498,26 +685,205 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"identity":{"id":"ou_testuser","participant_type":"human","label":"ou_testuser[human]"}`,
`"event_type":"participant_joined"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"events":[`,
`"has_more":false`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
t.Fatalf("user json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_OngoingMeetingOmitsEndTime(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
t.Fatalf("invalid json output: %v\n%s", err, stdout.String())
}
data := common.GetMap(envelope, "data")
meeting := common.GetMap(data, "meeting")
if got := common.GetString(meeting, "status"); got != "ongoing" {
t.Fatalf("meeting status = %q, want ongoing: %s", got, stdout.String())
}
if _, ok := meeting["end_time"]; ok {
t.Fatalf("ongoing meeting should not expose dirty top-level end_time: %s", stdout.String())
}
}
func TestBuildMeetingEventsOutput_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
participantLeftEventWithReason(leaveReasonMeetingEnded),
}, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "ended" {
t.Fatalf("meeting status = %q, want ended", got)
}
if got := out.Meeting.EndTime; got != "2026-04-17T07:18:50Z" {
t.Fatalf("meeting end_time = %q, want leave time", got)
}
}
func TestBuildMeetingEventsOutput_NormalLeaveReasonDoesNotEndMeeting(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
participantLeftEventWithReason(leaveReasonUserLeft),
}, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "ongoing" {
t.Fatalf("meeting status = %q, want ongoing", got)
}
if got := out.Meeting.EndTime; got != "" {
t.Fatalf("meeting end_time = %q, want empty", got)
}
}
func TestRenderMeetingEventsPretty_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
timeline := buildMeetingEventTimeline([]interface{}{
participantLeftEventWithReason(leaveReasonMeetingEnded),
})
got := renderMeetingEventsPretty(timeline)
if strings.Contains(got, "进行中") {
t.Fatalf("pretty output should not show ongoing for meeting-ended leave reason: %s", got)
}
if !strings.Contains(got, "会议时间2026-04-17 15:15:00 - 2026-04-17 15:18:50") {
t.Fatalf("pretty output missing derived meeting end window: %s", got)
}
}
func TestBuildMeetingEventsOutput_UsesLatestMeetingSnapshot(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
participantJoinedEventOngoing(),
participantJoinedEvent(),
}, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "ended" {
t.Fatalf("meeting status = %q, want ended", got)
}
if got := out.Meeting.EndTime; got != "2026-04-17T07:35:00Z" {
t.Fatalf("meeting end_time = %q, want latest ended snapshot", got)
}
if got := len(out.Events); got != 2 {
t.Fatalf("events len = %d, want 2", got)
}
}
func TestBuildMeetingEventsOutput_EmptyEventsHasUnknownMeetingStatus(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, nil, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "unknown" {
t.Fatalf("meeting status = %q, want unknown", got)
}
}
func TestMeetingEventsMeetingFromPayload_StartOnlyIsOngoing(t *testing.T) {
got := meetingEventsMeetingFromPayload(map[string]interface{}{
"id": "m1",
"start_time": "1776410100",
})
if got.Status != "ongoing" {
t.Fatalf("meeting status = %q, want ongoing", got.Status)
}
if got.StartTime != "2026-04-17T07:15:00Z" {
t.Fatalf("meeting start_time = %q, want normalized RFC3339", got.StartTime)
}
if got.EndTime != "" {
t.Fatalf("meeting end_time = %q, want empty", got.EndTime)
}
}
func TestMeetingEvents_ExecuteNDJSONIncludesMetadataRow(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "ndjson",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 2 {
t.Fatalf("ndjson lines = %d, want 2: %s", len(lines), stdout.String())
}
if !strings.Contains(lines[0], `"row_type":"event"`) || !strings.Contains(lines[0], `"event_type":"participant_joined"`) {
t.Fatalf("first ndjson row should be event: %s", lines[0])
}
for _, unwanted := range []string{
`"summary":`,
`"raw":`,
} {
if strings.Contains(lines[0], unwanted) {
t.Fatalf("event ndjson row should not contain %q: %s", unwanted, lines[0])
}
}
for _, want := range []string{
`"row_type":"metadata"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"identity":`,
} {
if !strings.Contains(lines[1], want) {
t.Fatalf("metadata ndjson row missing %q: %s", want, lines[1])
}
}
}
func TestMeetingEventsEventRows_OmitsEmptyEventFields(t *testing.T) {
rows := meetingEventsEventRows([]meetingEventsEvent{
{EventType: "unknown_event"},
}, nil)
if len(rows) != 1 {
t.Fatalf("rows len = %d, want 1", len(rows))
}
row, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("row type = %T, want map", rows[0])
}
for _, unwanted := range []string{"event_id", "event_time", "actors", "payload"} {
if _, exists := row[unwanted]; exists {
t.Fatalf("row should omit %q when empty: %#v", unwanted, row)
}
}
if got := row["row_type"]; got != "event" {
t.Fatalf("row_type = %v, want event", got)
}
if got := row["event_type"]; got != "unknown_event" {
t.Fatalf("event_type = %v, want unknown_event", got)
}
}
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -536,20 +902,54 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
t.Fatalf("json output should not contain %q: %s", unwanted, out)
}
}
if !strings.Contains(out, `"message_type": 3`) {
if !strings.Contains(out, `"message_type": 1`) {
t.Fatalf("json output should keep numeric fields: %s", out)
}
}
func TestMeetingEvents_ExecuteJSON_PreservesReactionItems(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{mixedChatAndReactionEvent()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"event_type":"chat_received"`,
`"chat_received_items":[`,
`"content":"OK"`,
`"message_type":3`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
if strings.Contains(out, `"im_post"`) {
t.Fatalf("json output should not include IM post payload: %s", stdout.String())
}
}
func TestMeetingEvents_ExecutePretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -558,11 +958,12 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"当前身份Demo Bot [bot]",
"会议主题:项目例会",
"会议时间2026-04-17 15:15:00进行中",
"Demo Bot(bot_001) 加入了会议",
"Alice(u1): [reaction] 第一条\\n第二行",
"Alice(u1): [reaction] 第二条",
"Alice(u1): [text] 第一条\\n第二行",
"Alice(u1): [text] 第二条",
"Bob(u2) 开始共享「共享文档」",
"URL: https://example.com/doc",
"page_token: 1710000000000000000",
@@ -582,12 +983,13 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -606,12 +1008,13 @@ func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T)
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub(nil, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -850,9 +1253,9 @@ func TestLeaveAction(t *testing.T) {
item map[string]interface{}
want string
}{
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
{name: "meeting ended", item: map[string]interface{}{"leave_reason": leaveReasonMeetingEnded}, want: "因会议结束离开了会议"},
{name: "kicked", item: map[string]interface{}{"leave_reason": leaveReasonKicked}, want: "被移出了会议"},
{name: "default", item: map[string]interface{}{"leave_reason": leaveReasonUserLeft}, want: "离开了会议"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -884,6 +1287,70 @@ func TestMeetingEventUserWithID(t *testing.T) {
}
}
func TestMeetingEventsIdentityFromParticipant_UsesContractFields(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"user_type": 1,
"user_role": 2,
}, meetingEventsIdentity{})
if got.ParticipantType != "human" || got.Role != "host" {
t.Fatalf("identity = %#v, want participant_type=human role=host", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UserRoleParticipant(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"user_type": 1,
"user_role": 1,
}, meetingEventsIdentity{})
if got.Role != "participant" {
t.Fatalf("identity = %#v, want role=participant", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UserTypeApp(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "ou_app",
"user_name": "Demo Bot",
"user_type": 10,
"user_role": 1,
}, meetingEventsIdentity{})
if got.ParticipantType != "bot" {
t.Fatalf("identity = %#v, want participant_type=bot", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UnknownUserType(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u_unknown",
"user_name": "Unknown",
"user_type": 0,
"user_role": 1,
}, meetingEventsIdentity{})
if got.ParticipantType != "unknown" {
t.Fatalf("identity = %#v, want participant_type=unknown", got)
}
}
func TestMeetingEventsIdentityFromParticipant_IgnoresGenericTypeField(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"type": "bot",
}, meetingEventsIdentity{})
if got.ParticipantType != "human" {
t.Fatalf("identity = %#v, generic type field should not drive participant_type", got)
}
}
func TestMeetingEventSummary(t *testing.T) {
tests := []struct {
name string
@@ -933,6 +1400,22 @@ func TestMeetingEventSummary(t *testing.T) {
}
}
func TestMeetingEventsEventFromPayloadUsesActivityEventTypeFallback(t *testing.T) {
event := participantJoinedEvent()
delete(event, "event_type")
got := meetingEventsEventFromPayload(event, meetingEventsIdentity{})
if got.EventType != "participant_joined" {
t.Fatalf("EventType = %q, want participant_joined", got.EventType)
}
if len(got.Actors) != 1 {
t.Fatalf("actors len = %d, want 1: %#v", len(got.Actors), got.Actors)
}
if got.Actors[0].ID != "bot_001" {
t.Fatalf("actor id = %q, want bot_001", got.Actors[0].ID)
}
}
func TestEscapePrettyText(t *testing.T) {
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
want := `line1\nline2\t\r\u0007`

View File

@@ -199,6 +199,19 @@ func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
}
}
func TestWikiNodeListAcceptsOpaqueParentNodeToken(t *testing.T) {
t.Parallel()
const opaqueNodeToken = "Q6ZM_EXAMPLE_TOKEN"
token, err := normalizeWikiNodeListParentToken(opaqueNodeToken)
if err != nil {
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
}
if token != opaqueNodeToken {
t.Fatalf("token = %q, want %q", token, opaqueNodeToken)
}
}
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
t.Parallel()
@@ -224,11 +237,6 @@ func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
input: "wik_placeholder/child",
wantMsg: "raw wiki node token",
},
{
name: "document token",
input: "docx_placeholder_parent",
wantMsg: "must be a wiki node token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -351,10 +359,11 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
const parentNodeToken = "Q6ZM_EXAMPLE_TOKEN"
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=" + parentNodeToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
@@ -365,7 +374,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
"node_token": "wik_child",
"obj_token": "docx_child",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"parent_node_token": parentNodeToken,
"node_type": "origin",
"title": "Child Doc",
"has_child": false,
@@ -378,7 +387,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", parentNodeToken, "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -400,8 +409,8 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
}
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
if envelope.Data.Nodes[0]["parent_node_token"] != parentNodeToken {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], parentNodeToken)
}
}

View File

@@ -69,8 +69,8 @@ var WikiNodeGet = common.Shortcut{
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"--node-token accepts a raw wiki node_token, obj_token, or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens, pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
},
@@ -235,29 +235,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
).WithParam("--node-token")
} else {
spec.Token = tokenInput
if looksLikeWikiNodeToken(spec.Token) {
if spec.ObjType == "" {
spec.SourceKind = "raw-node"
// node_tokens take no obj_type; reject a conflicting flag rather
// than silently passing it (the API would just ignore it, but the
// mismatch signals caller confusion).
if spec.ObjType != "" {
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
spec.Token,
).WithParam("--obj-type")
}
} else {
spec.SourceKind = "raw-obj"
// A raw obj_token needs an explicit obj_type: get_node would
// otherwise default to "doc" and fail confusingly for docx /
// sheet / bitable / ... Fail fast with the same upfront contract
// as +node-delete instead of deferring to an opaque API error.
if spec.ObjType == "" {
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
).WithParam("--obj-type")
}
}
}
@@ -270,18 +251,6 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
return spec, nil
}
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
//
// This is a heuristic based on the current Lark token-naming convention, not a
// guaranteed invariant: if Lark ever introduces a non-node token type that
// also starts with `wik`, it would be misclassified. Worst case is a
// confusing API error (no data risk); revisit if the token scheme changes.
func looksLikeWikiNodeToken(token string) bool {
return strings.HasPrefix(strings.ToLower(token), "wik")
}
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
// don't need one.

View File

@@ -31,6 +31,22 @@ func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
}
}
func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
t.Parallel()
const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != opaqueNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, opaqueNodeToken)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": opaqueNodeToken}) {
t.Fatalf("RequestParams() = %v, want {token: %s}", got, opaqueNodeToken)
}
}
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
t.Parallel()
@@ -43,23 +59,30 @@ func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
}
}
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
t.Parallel()
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
// upfront instead of defaulting to "doc" and hitting an opaque API error.
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
}
}
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
}
}

View File

@@ -207,11 +207,6 @@ func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
).WithParam("--parent-node-token")
}
if !looksLikeWikiNodeToken(parentNodeToken) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
}
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
return "", err
}

View File

@@ -18,7 +18,6 @@ import (
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
@@ -586,15 +585,11 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
}
func TestBuildAllowedHosts(t *testing.T) {
feishu := core.Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
feishu := struct{ Open, Accounts, MCP string }{
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
}
lark := core.Endpoints{
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
MCP: "https://mcp.larksuite.com",
lark := struct{ Open, Accounts, MCP string }{
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts

View File

@@ -12,6 +12,16 @@ metadata:
妙搭应用属于用户资产。默认用 `--as user`认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
## 身份与一次性授权
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错:
```bash
lark-cli auth login --domain apps
```
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。
## 意图路由
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):

View File

@@ -11,7 +11,7 @@
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`shell 解析路径CLI 仅接收内容)。
- `--file``.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
- `--environment` 枚举:`dev` / `online`**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`
- `--environment` 枚举:`dev` / `online`**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`
- risk 是 `high-risk-write`SQL 可含 DML/DDL任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`详见下「Agent 规则」)。

View File

@@ -28,7 +28,7 @@
## 约定(先读)
- **环境 `--environment dev|online`所有 db 命令统一默认 `dev`**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment``+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义`+db-recovery-*` 作用于当前库,二者**没有** `--environment`
- **环境 `--environment dev|online`可省略**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`未开多环境的 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment``+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义**没有** `--environment`
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒validation / exit 2。路径在别处先 `cd` 过去或改成相对路径。
- **高危操作必须带 `--yes`**`+db-env-create``+db-data-import``+db-env-migrate``+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
- **时间参数按口语自然传**`--since`/`--until`/`--target`),格式见末尾。
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
## Agent 规则
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty``+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
- 四个高危命令(`+db-env-create``+db-data-import``+db-env-migrate``+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_requiredexit 10按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。

View File

@@ -44,6 +44,8 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`CLI 会在写入前读取文件并展开为内联内容。
### 步骤 2B: SubAgent 使用 SVG 插入图表
主 Agent 启动 SubAgent让它用 `docs +create` / `docs +update` 插入:
@@ -56,6 +58,8 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
- doc token、插入位置标题 / block_id / command

View File

@@ -41,13 +41,35 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>``<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>``<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- `<html5-block>` — 在飞书文档「HTML 块」iframe 里加载的单文件 HTML。
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
## html
1. 写入 HTML 内容块时,把 HTML 存为本地 `.html` 文件XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
2. 格式如下:
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="use-iframe" content="true">
<meta name="html-box-height-mode" content="auto">
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
<title></title>
</head>
<body>
...
</body>
</html>
```
# 四、块级复制与移动
## 移动block_move_after

View File

@@ -73,12 +73,14 @@ metadata:
- 再根据 `note_id``minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md)
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`
7. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果
8. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉
9. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`
6. 命令默认输出结构化事件契约:`meeting``identity``events``warnings``has_more``page_token``identity` 表示当前读取身份,事件 actor 含 `participant_type``role` 和可读 `label`,事件细节保留在 `payload`
7. 输出格式默认优先 `--format pretty`(时间线更易读,并带当前身份标签);需要稳定字段做结构化处理时用 `--format json`;需要流式消费事件时用 `--format ndjson`
8. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果
9. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉
10. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果
11. **会中聊天 / 互动转发到 IM 时基于 JSON 事件构造 IM post。** `chat_received_items[].message_type == 3` 表示会中 reaction构造 IM post 时,先用 [`lark-im` reaction emoji 白名单](../lark-im/references/lark-im-reactions.md) 判断同一 item 的 `content`:白名单内才写成 Feishu post `emotion` 节点,不在白名单内则保留原始 key 并写成文本节点,例如 `[CanNotSee]`。普通聊天按文本发送。不要从 pretty/Markdown 重新拼消息,也不要把整条消息退化成纯文本;只降级非法 reaction key。用户已说“发给我 / 推送给我 / 发到我的单聊”时,默认用 bot 身份直接发当前用户;收件人不明确时只补问收件人
12. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
13. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`
### 3. 发送会中文本或会中表情(写操作)
@@ -119,13 +121,14 @@ lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type
```bash
# 1. 入会,捕获 meeting.id
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
AS=bot
JOIN=$(lark-cli vc +meeting-join --as "$AS" --meeting-number 123456789 --format json)
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
# 2. 会中轮询事件
# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
# 沿用入会身份;默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
# 典型间隔 10-30 秒
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
lark-cli vc +meeting-events --as "$AS" --meeting-id "$MID" --page-all --format pretty
# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_id / minute_token 决策读取
lark-cli vc +detail --meeting-ids "$MID"
@@ -137,7 +140,7 @@ lark-cli vc +detail --meeting-ids "$MID"
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
```
如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查:

View File

@@ -14,17 +14,14 @@
## 命令
```bash
# 默认用法:全量拉取当前可见事件
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
# 默认用法:全量拉取当前身份可见事件;输出易读时间线
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format pretty
# 指定时间范围,并拉全该时间窗内当前可见事件
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
# 基于上一次保存的 page_token 继续查新增事件
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
# 调试或控制返回体大小时,显式只查一页
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-token <last_page_token> --page-all --format pretty
```
## 参数
@@ -54,9 +51,10 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
### 2. 身份来源是读取事件的权限锚点
- 用户身份路径:先用 `+meeting-list-active --as user` 发现当前登录用户的会议,再用 `+meeting-events --as user` 读取该 `meeting_id`
- 用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接用 `--as bot`
- 不要混用身份。身份不一致时,常见结果是空列表、`no permission``bot is not in meeting`
- `+meeting-events` 支持 `--as user``--as bot`
-身份路径:用户身份发现的会议继续用用户身份读取
- 应用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接查
- 不要在拿到 `meeting_id` 后随意切换身份。身份不一致时,常见结果是空列表、`no permission``bot is not in meeting`
### 3. 读取事件前必须先拿到可见的 meeting_id
@@ -67,21 +65,21 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
lark-cli vc +meeting-join --as bot --meeting-number 123456789
# 再查询事件
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id>
lark-cli vc +meeting-events --as bot --meeting-id <id>
```
如果应用机器人已经在会中,也可以先通过 active meeting 找会:
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
```
如果只是查询当前登录用户所在会议:
如果查询当前登录用户所在会议:
```bash
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
```
若应用机器人已离会、未入会、或会议已经无法再判断身份,后端通常会报:
@@ -104,18 +102,19 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
执行准则:
- **默认命令模板**`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format pretty`
- **默认命令模板**`lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format pretty`
- 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-token <returned_page_token> --page-all --format pretty`
- 只有在用户明确要求“就看第一页”“先不要翻页”时,才不要默认带 `--page-all`
- 只要你是基于 `+meeting-events` 来回答一场**正在进行中的会议内容**,就不能直接复用上一次查询结果。无论用户是在问“现在是谁在说话”“刚刚发生了什么”“最新事件有哪些”,还是让你“总结一下这个会议讲什么”,都必须先重新执行一次 `+meeting-events`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
### 5. pretty / json 输出差异
### 5. 输出格式差异
- `--format pretty`:输出会议主题、会议时间和逐条时间线,适合快速理解“发生了什么”,也是本 skill 的默认推荐格式
- `--format json`:保留完整原始 `events[]` 结构——参会人 open_id、聊天原文、share_doc、分页字段都在原始响应里适合提取字段、联动其他命令或做进一步程序处理
- `--format json`:结构化契约,顶层包含 `meeting``identity``events``has_more``page_token``identity` 表示当前读取身份;事件 actor 统一含 `participant_type``role``label`;每条事件保留 `payload` 便于追溯细节
- `--format pretty`:默认推荐格式,输出当前身份和逐条时间线,适合快速理解“发生了什么”
- `--format ndjson`:输出事件行,并带 metadata 行,适合流式消费。
**选型原则**:只目标是告诉用户“发生了什么”,默认就`--page-all --format pretty`只有在需要完整原始消息流和结构化字段时,才改用 `json`
**选型原则**:只`pretty``json``ndjson` 之间选择。目标是告诉用户“发生了什么”,用 `--page-all --format pretty`需要稳定字段给 agent 做结构化消费、总结、转发或二次处理时用 `--format json`;需要流式消费时用 `--format ndjson`
> **注意**pretty 输出中的正文文本会做单行转义,真实换行会显示为 `\n`,避免打乱时间线布局。
@@ -132,10 +131,10 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
执行准则:
- 如果上下文已有明确 `meeting_id` 和来源身份,直接用同一身份执行 `+meeting-events --page-all --format json`
- 如果上下文没有明确 `meeting_id`,先按用户当前意图选择身份:问“我/当前用户所在会议”用 `lark-cli vc +meeting-list-active --as user --format pretty`;问“应用机器人可见的目标用户会议”用 `lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format pretty`。返回多个会议时先让用户选择。
- 如果上下文已有明确 `meeting_id`,沿用该 `meeting_id` 的来源身份执行 `+meeting-events --page-all --format json`
- 如果上下文没有明确 `meeting_id`,先按用户当前意图选择身份:问“我/当前用户所在会议”用 `lark-cli vc +meeting-list-active --as user --format json`;问“应用机器人可见的目标用户会议”用 `lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json`。返回多个会议时先让用户选择。
- 如果上下文只有 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配;匹配到唯一会议后再查事件。不要为了总结会议而自动调用 `+meeting-join`
- 这类问题拿到 `meeting_id` 后,用 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
- 这类问题拿到 `meeting_id` 后,用同一身份执行 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format json` 拉取最新事件流。
- 如果事件中出现共享文档线索,例如:
- `magic_share_started`
- `share_doc.title`
@@ -159,7 +158,10 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
| 字段 | 说明 |
|------|------|
| `events` | 事件列表 |
| `meeting` | 会议身份与时间状态,包含 `id/topic/meeting_no/start_time/end_time/status` |
| `identity` | 当前读取身份,包含 `id/name/participant_type/label` |
| `events` | 结构化事件列表;每条事件含参与者 `actors` 和事件细节 `payload` |
| `warnings` | 非阻断告警列表;事件列表本身仍可使用 |
| `has_more` | 是否还有下一页 |
| `page_token` | 下一页游标 |
@@ -174,6 +176,32 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
| `magic_share_started` | 开始共享内容 / 文档 |
| `magic_share_ended` | 结束共享 |
### Forwarding meeting chat and reactions to IM
转发到 IM 时Agent 必须先用 `+meeting-events --format json` 的结构化事件构造完整 Feishu `post` 内容,再调用 IM 发送 shortcut。不要解析 pretty/Markdown 输出,也不要先生成纯文本或 Markdown 后再期望 IM 侧二次识别 reaction。
`event_type == "chat_received"` 的事件逐项处理 `payload.chat_received_items`
- `message_type == 3` 是会中 reaction构造 IM `post` 内容时,以 [`lark-im` reaction emoji 列表](../../lark-im/references/lark-im-reactions.md) 作为 IM `emotion` 白名单。白名单内的 key 写成 `{"tag":"emotion","emoji_type":"<content>"}`,例如 `JIAYI``THUMBSUP``OK`
- 对不在 IM reaction emoji 白名单内的 reaction key保留原始 key 但写成文本节点,例如 `{"tag":"text","text":"[<content>]"}`;不应直接写入 `emotion.emoji_type`,否则 IM 发送会失败。
- 不要大小写归一化或猜测映射;`content` 是原始 reaction key必须原样判断。
- 其他聊天消息写成文本节点:`{"tag":"text","text":"<content>"}`
- 最终调用 `im +messages-send --msg-type post --content '<post-json>'`,其中 `<post-json>` 应混合使用可渲染 `emotion` 节点和文本 fallback不要用 `--markdown` 承载会中 reaction。
- 如果 IM 返回 `message_content_emotion_tag's emoji_type is invalid`,只降级非法 reaction key不要把整条消息退化成纯文本。
- 如果用户原始请求已经明确“发给我 / 推送给我 / 发到我的聊天框 / 发到我的单聊”,这已经覆盖本次收件人、内容和发送动作,直接发送给当前用户,不要再二次询问“是否发送”。
- 默认用应用身份 `--as bot` 发送;只有用户明确要求“用本人身份 / 用户身份发送”时才切到 `--as user`
- 如果用户要求发给某个群或其他人但收件人不可唯一确定,只询问缺失的收件人信息。
```bash
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <id> \
--page-all \
--format json
```
如果用户已经要求“发给我”,`<open_id>` 使用当前用户的 open_id需要解析时先用用户查询能力获取当前用户信息。构造 IM post 时只发送用户请求范围内的会中内容,不要把前一条自然语言预览当作发送内容。
## pretty 输出示例
```text
@@ -197,28 +225,29 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
## Agent 组合场景
### 场景 1入会后查看会中发生了什么
### 场景 1入会后读取会中发生了什么
```bash
# 第 1 步:加入会议,记录返回的 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
# 第 2 步:查询事件
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
# 第 2 步:用 meeting.id 读取当前可见事件
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
```
### 场景 1b应用机器人已在会中先发现 meeting_id 再读事件
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
```
### 场景 1c当前登录用户正在会中先发现 meeting_id 再读事件
```bash
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
```
### 场景 2过滤某段时间内的事件
@@ -226,7 +255,7 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
```bash
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <meeting.id> \
--meeting-id <id> \
--start 2026-04-17T15:00:00+08:00 \
--end 2026-04-17T16:00:00+08:00 \
--page-all \
@@ -240,7 +269,7 @@ lark-cli vc +meeting-events \
# 这次直接从该游标继续拉新增事件
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <meeting.id> \
--meeting-id <id> \
--page-token <last_page_token> \
--page-all \
--format pretty
@@ -257,10 +286,9 @@ lark-cli vc +meeting-events \
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
| `not a 9-digit meeting number` | 把 9 位会议号误传给 `--meeting-id` | 如果只是查询会中内容,先用 `+meeting-list-active``meeting_no` 匹配拿长数字 `meeting_id`;只有用户明确要求入会时才用 `+meeting-join --as bot --meeting-number <9位号>` |
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果本来是用户身份发现的 `meeting_id`,改回 `--as user`;如果确实要应用身份读取,先 `+meeting-join --as bot --meeting-number <9位号>` 真实入会再查。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
| 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果 `meeting_id` 来自用户身份发现,改回 `--as user`;如果确实要应用身份读取,先让应用机器人入会或确认它曾参会后再用 `--as bot`。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
| 用户身份无权限 / 不可见 | 当前用户不是该会议的可见参与者,或 `meeting_id` 不是从用户身份路径获得 | 不要反复执行 `auth login`。先确认 `meeting_id` 是否来自 `+meeting-list-active --as user`;如果用户明确要切到应用身份,再通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id或排查后端问题 |

View File

@@ -29,7 +29,7 @@ lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
| 用户身份 | `--as user` | 当前登录用户正在参加的会议 | 继续 `+meeting-events --as user` |
| 应用身份 | `--as bot --user-id <user_open_id>` | 目标用户正在参加、且应用机器人也在会中的会议 | 继续 `+meeting-events --as bot` |
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把用身份拿到的 `meeting_id` 改用应用身份查,也不要把用身份拿到的 `meeting_id` 改用用户身份查,除非用户明确要求切换场景
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把用身份拿到的 `meeting_id` 改用用户身份读事件,也不要把用身份拿到的 `meeting_id` 强制切到应用身份
应用身份返回空,不代表目标用户不在任何会议中,只能说明没有找到“目标用户在会中且应用机器人也在会中”的当前会。
@@ -38,22 +38,22 @@ lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
```bash
# 方式 1先让应用机器人入会直接从 join 响应拿 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
# 方式 2应用机器人已经在会中时用应用身份发现 meeting_id
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
# 方式 3只回答当前登录用户所在会议发生了什么
# 方式 3查询当前登录用户所在会议发生了什么
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
```
## 多会议选择
- 如果返回多个会议,不要自动挑第一个。
- 向用户展示每个候选的 `meeting_title` / `meeting_no` / `meeting_id`,等待用户选择。
- 选择后继续使用发现该会议时的同一身份调用 `+meeting-events`
- 选择后同一身份执行 `+meeting-events` 读取事件
## 9 位会议号匹配
@@ -80,7 +80,7 @@ lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|---------|---------|---------|
| `--user-id is required when --as bot` | 应用身份未传目标用户 | 传入目标用户 open_id |
| 用户身份返回空列表 | 当前登录用户没有可见的进行中会议 | 确认用户是否在会中,或是否切错身份 |
| 用户身份不支持 | 当前接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先拿目标用户 open_id,再执行 `+meeting-list-active --as bot --user-id <user_open_id>`;同时按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
| 用户身份无权限 / 不可见 | 当前登录用户没有可见的进行中会议,或当前身份无法读取该会议 | 不要反复执行 `auth login`。先确认当前登录用户是否在会中、是否切错 profile如果用户明确要查询应用机器人可见的会议拿目标用户 open_id 执行 `+meeting-list-active --as bot --user-id <user_open_id>`,并按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
| 应用身份返回空列表 | 没有满足“目标用户在会中且应用机器人也在会中”的当前会 | 先让应用机器人入会,或确认 `user_id` 和会议状态 |
| `--user-id` 格式错误 | 传入了 internal user_id 或其他非 `ou_...` 值 | 改传目标用户 open_id |
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |

View File

@@ -19,7 +19,7 @@ lark-cli wiki +node-get \
|------|------|----------|---------|-------------|
| `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`). Matches the `--node-token` naming used by sibling `+node-delete` / `+node-copy` / `+move`. |
| `--token` | string | — (deprecated) | — | Deprecated original name; still accepted for backward compatibility but emits a `Flag --token has been deprecated, use --node-token instead` warning on stderr. New scripts should use `--node-token`. |
| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) |
| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from typed Lark URLs. If omitted for a raw token, the shortcut treats it as a wiki `node_token`. |
| `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space |
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |

View File

@@ -15,11 +15,11 @@ import (
)
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URLCLI 永远走 DBA 模式
// ?transactional=falsesql body 由 --sql 透传,默认 env=dev
// ?transactional=falsesql body 由 --sql 透传,默认不传 env(空值,由服务端按 workspace 定分支)
func TestAppsDBExecuteDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultEnvIsDevAndTransactionalFalse", func(t *testing.T) {
t.Run("DefaultEnvUnsetAndTransactionalFalse", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
@@ -37,8 +37,8 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
"CLI is DBA mode → must send transactional=false in query")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
"transactional should be in query, not body")
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String(),
"default env must be dev (not production)")
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
})
t.Run("OnlineEnvSwitch", func(t *testing.T) {

View File

@@ -19,7 +19,7 @@ import (
func TestAppsDBTableListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
t.Run("DefaultsToNoEnvAndPageSize20", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
@@ -32,7 +32,8 @@ func TestAppsDBTableListDryRun(t *testing.T) {
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestVCMeetingEventsDryRun(t *testing.T) {
setVCDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"vc", "+meeting-events",
"--meeting-id", "7628568141510692381",
"--page-token", "1710000000000000000",
"--page-size", "40",
"--start", "1710000000",
"--end", "1710003600",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(1), gjson.Get(out, "api.#").Int(), "stdout:\n%s", out)
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/vc/v1/bots/events", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "7628568141510692381", gjson.Get(out, "api.0.params.meeting_id").String(), "stdout:\n%s", out)
require.Equal(t, "1710000000000000000", gjson.Get(out, "api.0.params.page_token").String(), "stdout:\n%s", out)
require.Equal(t, "40", gjson.Get(out, "api.0.params.page_size").String(), "stdout:\n%s", out)
require.Equal(t, "1710000000", gjson.Get(out, "api.0.params.start_time").String(), "stdout:\n%s", out)
require.Equal(t, "1710003600", gjson.Get(out, "api.0.params.end_time").String(), "stdout:\n%s", out)
}

View File

@@ -15,7 +15,7 @@ import (
)
func TestVCMeetingMessageSendDryRun(t *testing.T) {
setVCMeetingMessageSendDryRunEnv(t)
setVCDryRunEnv(t)
tests := []struct {
name string
@@ -81,7 +81,7 @@ func TestVCMeetingMessageSendDryRun(t *testing.T) {
}
func TestVCMeetingMessageSendDryRunRejectsLongUUID(t *testing.T) {
setVCMeetingMessageSendDryRunEnv(t)
setVCDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
@@ -104,10 +104,10 @@ func TestVCMeetingMessageSendDryRunRejectsLongUUID(t *testing.T) {
require.Empty(t, result.Stdout)
}
func setVCMeetingMessageSendDryRunEnv(t *testing.T) {
func setVCDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "vc_meeting_message_send_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "vc_meeting_message_send_dryrun_secret")
t.Setenv("LARKSUITE_CLI_APP_ID", "vc_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "vc_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}

View File

@@ -1,265 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/tidwall/gjson"
)
// seededCatalogVersion is far newer than the embedded stub's 0.0.0, so the
// runtime overlay in internal/registry unconditionally applies it.
const seededCatalogVersion = "9.9.9"
// seededCatalogJSON is a remote_meta.json (registry.MergedRegistry) carrying one
// obviously-synthetic service. Seeding it into a bare-module fork's on-disk cache
// gives the runtime catalog real data WITHOUT any network, so a test can prove
// SchemaCatalog() consults that runtime catalog (issue #1764) rather than the
// embedded-only (empty stub) catalog. Fields mirror internal/meta.Service.
const seededCatalogJSON = `{
"version": "9.9.9",
"services": [
{
"name": "plugine2e",
"version": "v1",
"title": "plugin_e2e synthetic service",
"description": "synthetic fixture for the runtime-catalog test; not a real API",
"servicePath": "/open-apis/plugine2e/v1",
"resources": {
"widgets": {
"methods": {
"get": {
"id": "plugine2e.widgets.get",
"path": "/open-apis/plugine2e/v1/widgets/:id",
"httpMethod": "GET",
"description": "synthetic read method",
"risk": "read",
"accessTokens": ["tenant"],
"parameters": {
"id": {"type": "string", "location": "path", "required": true, "description": "synthetic id"}
}
}
}
}
}
}
]
}`
// runWithSeededCatalog runs bin against a fresh LARKSUITE_CLI_CONFIG_DIR whose
// cache already holds cacheJSON as remote_meta.json (plus a fresh, high-version
// cache-meta so the overlay applies and the TTL never triggers a refetch). Remote
// meta is left ON so the on-disk cache overlay is consulted, but a long
// LARKSUITE_CLI_META_TTL keeps the run offline and deterministic. This models a
// bare-module binary that has runtime metadata available from a warm cache.
func runWithSeededCatalog(t *testing.T, bin, cacheJSON string, args ...string) result {
t.Helper()
cfg := t.TempDir()
cacheDir := filepath.Join(cfg, "cache")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
t.Fatalf("mkdir cache dir: %v", err)
}
writeFile(t, filepath.Join(cacheDir, "remote_meta.json"), cacheJSON)
writeFile(t, filepath.Join(cacheDir, "remote_meta.meta.json"),
fmt.Sprintf(`{"last_check_at":%d,"version":%q,"brand":""}`, time.Now().Unix(), seededCatalogVersion))
env := append(os.Environ(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+cfg,
"LARKSUITE_CLI_META_TTL=1000000",
)
return runWithEnv(t, bin, env, args...)
}
// plainPlugin registers a minimal observer-only plugin with NO Restrict rule
// -- unlike readonly_test.go's plugins, it cannot deny "schema" as
// out-of-domain, so any failure the command produces below is the command's
// own behavior against the empty stub catalog, not a policy denial.
const plainPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("plain", "0.1.0").
Observer(platform.After, "noop", platform.All(),
func(_ context.Context, _ platform.Invocation) {}).
FailOpen().
MustBuild())
}
`
// TestDegradeStubMetadataSchema pins the #1764 stub-metadata degrade path.
// The clean tree embeds only the empty meta_data_default.json stub
// (internal/registry/catalog.go's SchemaCatalog falls through to
// RuntimeCatalog when EmbeddedServicesTyped() is empty), and run()'s isolated
// environment disables the remote overlay fetch and points the cache dir at
// an empty tmp dir, so cmd/schema/schema.go's runSchema sees
// catalog.Services() == 0 unconditionally -- the exact "offline with a cold
// cache, remote meta off" branch documented at cmd/schema/schema.go:96-101.
//
// Observed real output for both `schema` and `schema im.messages.reply`
// (identical -- runSchema checks catalog.Services()==0 before parsing args):
//
// exit=2
// stdout=(empty)
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"No API metadata available",
// "hint":"this binary has no embedded API metadata; run any command with
// network access to the open platform once so metadata can be fetched and
// cached"}}
//
// This is the PINNED "graceful degrade" criterion: a structured JSON envelope
// (gjson.Valid, no "panic:" substring) carrying a validation/failed_precondition
// error with an actionable hint, NOT the raw Go panic crash that
// install_test.go's TestInstallMustBuildInitPanicCrashesBinary pins for a
// genuinely broken plugin, and NOT an "Unknown"-shaped internal error.
// Note: exit==2 alone does not prove "not a crash" -- a genuine Go panic also
// exits 2. The two real discriminators against a crash are the absence of a
// "panic:" substring in stderr and stderr being valid JSON (gjson.Valid); both
// are asserted below.
func TestDegradeStubMetadataSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
cases := []struct {
name string
args []string
}{
{"schema root", []string{"schema"}},
{"schema with path", []string{"schema", "im.messages.reply"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := run(t, bin, tc.args...)
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 2 {
t.Fatalf("exit=%d want 2 (graceful validation exit); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr contains a raw Go panic trace, not a graceful degrade; stderr=%s", res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not a structured JSON envelope: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "No API metadata available" {
t.Errorf("error.message=%q want %q", msg, "No API metadata available")
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "no embedded API metadata") {
t.Errorf("error.hint=%q want to contain %q", hint, "no embedded API metadata")
}
})
}
}
// TestRuntimeCatalogResolvesSchema pins the PRIMARY #1764 fix: a bare-module fork
// (embedded stub only) resolves `schema` against the RUNTIME catalog seeded from
// the on-disk cache, not the embedded-only catalog. Before f0b6f35f the module
// build read the embedded-only catalog and returned "Unknown service: <svc>" even
// though the runtime registry had metadata; after it, registry.SchemaCatalog()
// falls back to the merged runtime catalog and the lookup succeeds.
//
// This is the counterpart to TestDegradeStubMetadataSchema: that test pins the
// cold-cache corner (no runtime data -> graceful "No API metadata available");
// this one pins the warm-cache main path (runtime data present -> schema works),
// so a regression that re-embeds the embedded-only lookup fails HERE with
// "Unknown service" rather than silently passing.
func TestRuntimeCatalogResolvesSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
res := runWithSeededCatalog(t, bin, seededCatalogJSON, "schema", "plugine2e")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
out := res.stdout + res.stderr
if strings.Contains(out, "Unknown service") {
t.Fatalf("schema returned \"Unknown service\" -> runtime catalog NOT consulted (issue #1764 regression); out=%s", out)
}
if strings.Contains(out, "No API metadata available") {
t.Fatalf("schema saw no metadata -> the seeded runtime cache was not loaded; out=%s", out)
}
if res.exit != 0 {
t.Fatalf("exit=%d want 0 (schema resolved from runtime catalog); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(out, "plugine2e") {
t.Errorf("schema output does not mention the seeded service; out=%s", out)
}
}
// credentialBlockPlugin registers a credential.Provider whose ResolveAccount
// (and ResolveToken) unconditionally return a *credential.BlockError.
// internal/credential/credential_provider.go's doResolveAccount returns this
// error straight from the provider loop -- before any defaultAcct fallback
// and, transitively, before the LarkClient/HttpClient phases that would issue
// a real network call ever run (see internal/cmdutil/factory_default.go's
// Phase 2 -> Phase 4 ordering).
const credentialBlockPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/credential"
)
type blockProvider struct{}
func (blockProvider) Name() string { return "block-cred" }
func (blockProvider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func (blockProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func init() {
credential.Register(blockProvider{})
}
`
// TestSubsystemCredentialBlock pins the credential.BlockError offline effect.
// Observed real output for `docs +fetch --doc nonexistent`, run twice across
// separate `go test -count` invocations (byte-identical both times, unlike
// the transport-abort case -- credential resolution happens once, before any
// endpoint is chosen, so there is no varying destination URL to leak into the
// message):
//
// exit=5
// stdout=(empty)
// stderr={"ok":false,"identity":"bot","error":{"type":"internal","subtype":"unknown",
// "message":"blocked by block-cred: blocked for test"}}
func TestSubsystemCredentialBlock(t *testing.T) {
bin := buildFork(t, "credential-block", credentialBlockPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 5 {
t.Fatalf("exit=%d want 5; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "internal" {
t.Errorf("error.type=%q want internal", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "unknown" {
t.Errorf("error.subtype=%q want unknown", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "blocked by block-cred: blocked for test" {
t.Errorf("error.message=%q want %q", msg, "blocked by block-cred: blocked for test")
}
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"testing"
"github.com/tidwall/gjson"
)
// TestDiagnostics asserts the VERIFIED stdout shapes of the two policy/plugin
// diagnostic commands on a fork carrying the readonly Restrict rule:
// - `config policy show`: source_name == the plugin name that installed the
// active rule.
// - `config plugins show`: {"plugins":[{"name","version","capabilities",...,
// "hooks":{...}}],"total":N} with the readonly plugin present.
func TestDiagnostics(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
pol := run(t, bin, "config", "policy", "show")
if pol.exit != 0 || !gjson.Valid(pol.stdout) {
t.Fatalf("policy show exit=%d stdout=%s stderr=%s", pol.exit, pol.stdout, pol.stderr)
}
if src := gjson.Get(pol.stdout, "source_name").String(); src != "readonly" {
t.Errorf("policy source_name=%q want readonly (stdout=%s)", src, pol.stdout)
}
plug := run(t, bin, "config", "plugins", "show")
if plug.exit != 0 || !gjson.Valid(plug.stdout) {
t.Fatalf("plugins show exit=%d stdout=%s", plug.exit, plug.stdout)
}
if total := gjson.Get(plug.stdout, "total").Int(); total < 1 {
t.Errorf("plugins total=%d want >=1 (stdout=%s)", total, plug.stdout)
}
if name := gjson.Get(plug.stdout, "plugins.0.name").String(); name != "readonly" {
t.Errorf("plugins.0.name=%q want readonly", name)
}
}

View File

@@ -1,210 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package plugin_e2e exercises the extension/platform plugin contract the way a
// real customer does: it builds a fork of lark-cli with a plugin blank-imported,
// then runs that fork as a subprocess and asserts the real stderr/stdout
// envelopes and exit codes. This is L4 coverage — the in-process unit and
// integration tests (extension/..., cmd/...) assert Go error values in the test
// process and structurally cannot observe envelope serialization, exit codes, or
// the blank-import -> init -> Register -> InstallAll assembly chain.
//
// Mechanism (the "customer build", mirrors xcaddy's build mode):
// 1. `git archive HEAD` a clean tree containing only committed files (so the
// fork embeds the tracked meta_data stub, reproducing the bare-module state).
// 2. Generate a customer module: go.mod (cli's requires + `replace` to the
// archived tree) + go.sum copy + main.go (blank-imports the plugin package)
// + plugin package (its init() calls platform.Register).
// 3. `go build` the fork (offline-capable via the warm module cache), then run
// it as a subprocess and assert.
package plugin_e2e
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// cleanTree is the git-archived, committed-only source tree of the repo under
// test, shared by every fork build. Populated by TestMain (smoke_test.go) —
// TestMain must live in a _test.go file to be recognized by `go test`, so the
// entry point sits there while the rest of the harness mechanism lives here.
var cleanTree string
// baseDir holds the archive tree plus every generated customer module.
var baseDir string
// repoRoot resolves the lark-cli module root from the test's working directory
// (which `go test` sets to the package dir, tests/plugin_e2e).
func repoRoot() (string, error) {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// gitArchive extracts HEAD's committed tree into dst by streaming `git archive`
// into `tar -x`. Only tracked files are included — gitignored build artifacts
// (e.g. the fetched meta_data.json) are absent, exactly as a module consumer
// would see them. It wires the two processes with an explicit pipe rather than a
// shell, so dst never reaches a shell command line.
func gitArchive(root, dst string) error {
archive := exec.Command("git", "archive", "HEAD")
archive.Dir = root
extract := exec.Command("tar", "-x", "-C", dst)
pipe, err := archive.StdoutPipe()
if err != nil {
return err
}
extract.Stdin = pipe
// Each process gets its own stderr buffer: os/exec spawns a copy goroutine
// per command, so a shared strings.Builder would be written concurrently by
// both (git archive and tar run in parallel) -- a data race, since
// strings.Builder is not concurrency-safe.
var archiveErr, extractErr strings.Builder
archive.Stderr = &archiveErr
extract.Stderr = &extractErr
if err := extract.Start(); err != nil {
return err
}
if err := archive.Run(); err != nil {
_ = extract.Wait()
return fmt.Errorf("git archive: %w: %s", err, archiveErr.String())
}
if err := extract.Wait(); err != nil {
return fmt.Errorf("tar extract: %w: %s", err, extractErr.String())
}
return nil
}
// builtForks caches fork binaries by name so identical forks are built once.
var builtForks = map[string]string{}
// buildFork generates a customer module whose plugin package body is pluginSrc,
// builds the fork, and returns the binary path. Forks are cached by name.
func buildFork(t *testing.T, name, pluginSrc string) string {
t.Helper()
if bin, ok := builtForks[name]; ok {
return bin
}
mod := filepath.Join(baseDir, "fork-"+name)
if err := os.MkdirAll(filepath.Join(mod, "plugin"), 0o755); err != nil {
t.Fatalf("mkdir customer module: %v", err)
}
// go.mod: reuse cli's require graph, rename the module, replace cli with the
// local archived tree. This avoids `go mod tidy` (no network at test time).
rawMod, err := os.ReadFile(filepath.Join(cleanTree, "go.mod"))
if err != nil {
t.Fatalf("read archived go.mod: %v", err)
}
gomod := strings.Replace(string(rawMod), "module github.com/larksuite/cli", "module larkcustomer", 1)
gomod += "\nrequire github.com/larksuite/cli v0.0.0\n\nreplace github.com/larksuite/cli => " + cleanTree + "\n"
writeFile(t, filepath.Join(mod, "go.mod"), gomod)
// go.sum: transitive dependency hashes are identical to cli's.
rawSum, err := os.ReadFile(filepath.Join(cleanTree, "go.sum"))
if err != nil {
t.Fatalf("read archived go.sum: %v", err)
}
writeFile(t, filepath.Join(mod, "go.sum"), string(rawSum))
writeFile(t, filepath.Join(mod, "main.go"), customerMain)
writeFile(t, filepath.Join(mod, "plugin", "plugin.go"), pluginSrc)
bin := filepath.Join(mod, "fork-bin")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = mod
// -mod=mod fixes require annotations copied from cli's go.mod; the default
// GOPROXY resolves any dep missing from the cache (goproxy in CI/dev).
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork %q failed: %v\n%s", name, err, out)
}
builtForks[name] = bin
return bin
}
const customerMain = `// Code generated by plugin_e2e; DO NOT EDIT.
package main
import (
"os"
"github.com/larksuite/cli/cmd"
_ "larkcustomer/plugin" // blank import triggers plugin init() -> platform.Register
)
func main() { os.Exit(cmd.Execute()) }
`
// result is a subprocess run outcome.
type result struct {
stdout string
stderr string
exit int
}
// run executes the fork binary with args in an isolated, offline environment and
// captures stdout/stderr/exit. Each call gets a fresh empty
// LARKSUITE_CLI_CONFIG_DIR and LARKSUITE_CLI_REMOTE_META=off, so the fork never
// inherits the host's ~/.lark-cli cache or makes a startup metadata fetch to the
// open platform. That reproduces the bare-module customer state (no embedded
// metadata, cold cache) deterministically on any machine, including CI: without
// it, whether a command's assertion is reached depends on whether a live network
// fetch happened to succeed. Tests that need runtime metadata seed it explicitly
// via runWithSeededCatalog.
func run(t *testing.T, bin string, args ...string) result {
t.Helper()
return runWithEnv(t, bin, isolatedEnv(t), args...)
}
// isolatedEnv is the bare-module, offline environment shared by run() and (as a
// base) by runWithSeededCatalog.
func isolatedEnv(t *testing.T) []string {
t.Helper()
return append(os.Environ(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_REMOTE_META=off",
)
}
// runWithEnv runs bin as a subprocess with the given full environment, capturing
// stdout/stderr/exit.
func runWithEnv(t *testing.T, bin string, env []string, args ...string) result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, args...)
c.Env = env
var stdout, stderr strings.Builder
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
exit := 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exit = ee.ExitCode()
} else {
t.Fatalf("run %v: %v", args, err)
}
}
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

View File

@@ -1,339 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// multipleRestrictPlugin registers TWO distinct plugins that each call
// Restrict() with an independently valid Rule. cmdpolicy.Resolve rejects
// more than one distinct Restrict-owner regardless of each rule's own
// validity (internal/cmdpolicy/resolver.go's distinctOwners check runs
// before ValidateRule).
const multipleRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("restrict-a", "0.1.0").
Restrict(&platform.Rule{
Name: "a-rule",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
platform.Register(
platform.NewPlugin("restrict-b", "0.1.0").
Restrict(&platform.Rule{
Name: "b-rule",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestInstallMultipleRestrictPluginsPin pins reason_code=multiple_restrict_plugins.
// Observed real output (any command, e.g. "schema" -- the fatal guard walks
// every RunE in the tree so it fires regardless of which command runs):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"multiple plugins called Restrict; only one plugin may own the
// policy: [restrict-a restrict-b]",
// "hint":"plugin policy configuration is broken (reason_code
// multiple_restrict_plugins); fix the plugin's Restrict rule or remove the
// conflicting plugin"}}
func TestInstallMultipleRestrictPluginsPin(t *testing.T) {
bin := buildFork(t, "multiple-restrict", multipleRestrictPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "multiple_restrict_plugins")
}
// invalidRulePlugin registers a single plugin whose Restrict Rule carries a
// syntactically-invalid MaxRisk value. Neither the Builder nor the staging
// Registrar validate Rule *contents* (only nilness) -- semantic validation
// happens later, in cmdpolicy.ValidateRule, called from
// cmd/platform_bootstrap.go's applyUserPolicyPruning -> cmdpolicy.Resolve.
const invalidRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("invalid-rule", "0.1.0").
Restrict(&platform.Rule{
Name: "bad-risk",
Allow: []string{"docs/**"},
MaxRisk: platform.Risk("bogus"),
}).
MustBuild())
}
`
// TestInstallInvalidRulePin pins reason_code=invalid_rule. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"invalid-rule\" rule invalid: invalid max_risk \"bogus\":
// must be one of read|write|high-risk-write",
// "hint":"plugin policy configuration is broken (reason_code invalid_rule);
// fix the plugin's Restrict rule or remove the conflicting plugin"}}
func TestInstallInvalidRulePin(t *testing.T) {
bin := buildFork(t, "invalid-rule", invalidRulePlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "invalid_rule")
}
// installFailedPlugin is a hand-written bare platform.Plugin (not
// Builder-based -- Install returning a plain error is not expressible
// through the Builder's fluent API) whose Install always returns an error.
// FailurePolicy=FailClosed makes the host abort rather than warn+skip.
const installFailedPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"errors"
"github.com/larksuite/cli/extension/platform"
)
type installFailed struct{}
func (installFailed) Name() string { return "install-failed" }
func (installFailed) Version() string { return "0.1.0" }
func (installFailed) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (installFailed) Install(r platform.Registrar) error {
return errors.New("deliberate install failure")
}
func init() { platform.Register(installFailed{}) }
`
// TestInstallFailedPin pins reason_code=install_failed. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"install-failed\" (install_failed): Install returned
// error: deliberate install failure",
// "hint":"plugin \"install-failed\" failed to install (reason_code
// install_failed); fix or remove the plugin before running commands"}}
func TestInstallFailedPin(t *testing.T) {
bin := buildFork(t, "install-failed", installFailedPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "install_failed")
}
// installPanicPlugin is a hand-written bare Plugin whose Install panics.
// safeCallInstall (internal/platform/host.go) recovers and converts the
// panic into a typed install_panic error rather than crashing the binary.
const installPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type installPanic struct{}
func (installPanic) Name() string { return "install-panic" }
func (installPanic) Version() string { return "0.1.0" }
func (installPanic) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (installPanic) Install(r platform.Registrar) error {
panic("deliberate install panic")
}
func init() { platform.Register(installPanic{}) }
`
// TestInstallPanicPin pins reason_code=install_panic. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"install-panic\" (install_panic): Install panicked:
// deliberate install panic",
// "hint":"plugin \"install-panic\" failed to install (reason_code
// install_panic); fix or remove the plugin before running commands"}}
func TestInstallPanicPin(t *testing.T) {
bin := buildFork(t, "install-panic", installPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "install_panic")
}
// pluginNamePanicPlugin is a hand-written bare Plugin whose Name() panics.
// InstallAll's outer loop calls safeCallName BEFORE it ever reads
// Capabilities(), so this aborts unconditionally regardless of what
// Capabilities() would have declared (host.go's isUntrustedConfigError
// list) -- Capabilities() here is a throwaway zero value, never invoked.
const pluginNamePanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type pluginNamePanic struct{}
func (pluginNamePanic) Name() string { panic("deliberate name panic") }
func (pluginNamePanic) Version() string { return "0.1.0" }
func (pluginNamePanic) Capabilities() platform.Capabilities {
return platform.Capabilities{}
}
func (pluginNamePanic) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(pluginNamePanic{}) }
`
// TestInstallPluginNamePanicPin pins reason_code=plugin_name_panic. Observed real
// output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"<unknown>\" (plugin_name_panic): Plugin.Name()
// panicked: deliberate name panic",
// "hint":"plugin \"<unknown>\" failed to install (reason_code
// plugin_name_panic); fix or remove the plugin before running commands"}}
func TestInstallPluginNamePanicPin(t *testing.T) {
bin := buildFork(t, "plugin-name-panic", pluginNamePanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "plugin_name_panic")
}
// capabilitiesPanicPlugin is a hand-written bare Plugin whose Capabilities()
// panics. readFailurePolicy (internal/platform/host.go) re-invokes
// Capabilities() to decide FailOpen vs FailClosed, panics again, and its
// recover leaves the pre-set FailClosed default in place -- so this aborts
// unconditionally too, without the plugin ever declaring a real policy.
const capabilitiesPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type capabilitiesPanic struct{}
func (capabilitiesPanic) Name() string { return "capabilities-panic" }
func (capabilitiesPanic) Version() string { return "0.1.0" }
func (capabilitiesPanic) Capabilities() platform.Capabilities {
panic("deliberate capabilities panic")
}
func (capabilitiesPanic) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(capabilitiesPanic{}) }
`
// TestInstallCapabilitiesPanicPin pins reason_code=capabilities_panic. Observed
// real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"capabilities-panic\" (capabilities_panic):
// Plugin.Capabilities() panicked: deliberate capabilities panic",
// "hint":"plugin \"capabilities-panic\" failed to install (reason_code
// capabilities_panic); fix or remove the plugin before running commands"}}
func TestInstallCapabilitiesPanicPin(t *testing.T) {
bin := buildFork(t, "capabilities-panic", capabilitiesPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "capabilities_panic")
}
// restrictsMismatchPlugin is a hand-written bare Plugin that declares
// Capabilities.Restricts=true (paired with the required FailClosed) but
// whose Install never calls r.Restrict. stagingRegistrar.validateSelf
// (internal/platform/staging.go) checks this exact declared-vs-actual
// consistency after Install returns.
const restrictsMismatchPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type restrictsMismatch struct{}
func (restrictsMismatch) Name() string { return "restricts-mismatch" }
func (restrictsMismatch) Version() string { return "0.1.0" }
func (restrictsMismatch) Capabilities() platform.Capabilities {
return platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}
}
func (restrictsMismatch) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(restrictsMismatch{}) }
`
// TestInstallRestrictsMismatchPin pins reason_code=restricts_mismatch.
// Observed real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"restricts-mismatch\" (restricts_mismatch):
// Capabilities.Restricts=true but Install did not call r.Restrict",
// "hint":"plugin \"restricts-mismatch\" failed to install (reason_code
// restricts_mismatch); fix or remove the plugin before running commands"}}
func TestInstallRestrictsMismatchPin(t *testing.T) {
bin := buildFork(t, "restricts-mismatch", restrictsMismatchPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "restricts_mismatch")
}
// mustBuildPanicPlugin calls MustBuild() on a Builder with an invalid plugin
// name ("BadName!!" fails ^[a-z0-9][a-z0-9-]*$). This panics from
// plugin.init(), which runs from the blank-import BEFORE main() has a
// chance to install any recover-and-envelope guard -- so, unlike every
// other case here, this crashes the process outright: no JSON envelope,
// non-zero exit, a raw Go panic trace on stderr.
const mustBuildPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(platform.NewPlugin("BadName!!", "0.1.0").MustBuild())
}
`
// TestInstallMustBuildInitPanicCrashesBinary pins the MustBuild init-panic crash
// shape. This is NOT the plugin_install envelope -- it is a bare Go panic
// crash, because it happens in init(), before main()'s recover guard
// exists. Observed real output (schema):
//
// exit=2
// stderr=panic: plugin "BadName!!": invalid plugin name "BadName!!": must
// match ^[a-z0-9][a-z0-9-]*$
//
// goroutine 1 [running]:
// larkcustomer/plugin.init.0(...)
// .../plugin/plugin.go:7
// ...
func TestInstallMustBuildInitPanicCrashesBinary(t *testing.T) {
bin := buildFork(t, "mustbuild-panic", mustBuildPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit == 0 {
t.Fatalf("expected non-zero exit on init panic; exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if gjson.Valid(res.stderr) {
t.Fatalf("expected a raw panic trace, not a JSON envelope; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr missing Go panic trace; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, `invalid plugin name "BadName!!"`) {
t.Errorf("stderr missing the Builder's invalid-name message; stderr=%s", res.stderr)
}
}

View File

@@ -1,298 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// auditPlugin registers a single After observer matching every command that
// logs "[audit] <path>" to stderr. Based on (a simplified form of) the
// shipped extension/platform/examples/audit-observer example.
const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
FailOpen().
MustBuild())
}
`
// TestObservePin pins the audit observer's stderr line format. Observed
// real output (docs +fetch --doc nonexistent, a real read-risk command that
// fails downstream with an API error unrelated to the plugin):
//
// exit=1
// stderr=[audit] docs/+fetch
// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}}
//
// The observer line always leads, on its own line, before whatever the
// command itself writes to stderr.
func TestObservePin(t *testing.T) {
bin := buildFork(t, "audit", auditPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") {
t.Fatalf("stderr missing audit line; stderr=%s", res.stderr)
}
}
// auditRestrictPlugin combines an After observer with a Restrict rule in one
// plugin, so a denied command's stderr carries both the observer's
// side-effect and the denial envelope: the framework's contract is that
// After observers fire even for denied commands (see
// extension/platform/invocation.go's DeniedByPolicy doc).
const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit-restrict", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
Restrict(&platform.Rule{
Name: "agent-readonly",
Allow: []string{"docs/**", "im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestObserveOnDeniedPin pins the audit-contract case: a denied command's
// stderr carries BOTH the observer's audit line AND the denial envelope,
// concatenated in a single stream, audit line first. Observed real output
// (docs +update --doc-token x --content y, denied write_not_allowed):
//
// exit=2
// stderr=[audit] docs/+update
// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
//
// The leading "[audit] ..." line means gjson.Valid on the raw stderr is
// false; the JSON envelope must be sliced out from the first '{' before
// parsing it as JSON.
func TestObserveOnDeniedPin(t *testing.T) {
bin := buildFork(t, "audit-restrict", auditRestrictPlugin)
res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(res.stderr, "[audit] docs/+update\n") {
t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr)
}
i := strings.Index(res.stderr, "{")
if i < 0 {
t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr)
}
envelope := res.stderr[i:]
if !gjson.Valid(envelope) {
t.Fatalf("sliced envelope not JSON: %s", envelope)
}
if got := gjson.Get(envelope, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") {
t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint)
}
}
// observerPanicPlugin's After observer panics unconditionally. runObserverSafe
// (internal/hook/install.go) must isolate the panic so command dispatch
// still completes normally.
const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("observer-panic", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, _ platform.Invocation) {
panic("boom")
}).
FailOpen().
MustBuild())
}
`
// TestObserverPanicIsolationPin pins panic isolation: an After observer that
// always panics must not affect the command's own outcome. The assertion is
// baseline-relative -- the panicking-observer fork's exit code must equal the
// noop-observer baseline fork's for the same `schema` command (a local,
// network-free, read-risk command), whatever that shared exit code is.
// Observed real output at pin time:
//
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
// baseline: exit=0 stderr=(empty)
//
// The panic is fully swallowed by runObserverSafe (internal/hook/install.go),
// surfacing only as a stderr warning line, never as a non-zero exit or crash.
func TestObserverPanicIsolationPin(t *testing.T) {
bin := buildFork(t, "observer-panic", observerPanicPlugin)
res := run(t, bin, "schema")
baselineBin := buildFork(t, "smoke", noopPlugin)
baseline := run(t, baselineBin, "schema")
if res.exit != baseline.exit {
t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr)
}
if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) {
t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr)
}
}
// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError
// instead of calling next.
const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-abort", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
return &platform.AbortError{
HookName: "guard",
Reason: "blocked for test",
}
}
}).
FailOpen().
MustBuild())
}
`
// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError
// returned by a Wrapper is converted by wrapAbortError
// (internal/hook/install.go) into the SAME envelope shape as a Restrict
// denial -- error.type=="validation", error.subtype=="failed_precondition"
// -- NOT a distinct "hook" error type. Observed real output (docs +fetch
// --doc nonexistent, wrapper aborts unconditionally before calling next):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-abort.guard\" aborted: blocked for test",
// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the
// request to satisfy the hook's policy, or remove the plugin"}}
//
// HookName is namespaced to "<plugin-name>.<hookName>" ("wrap-abort.guard")
// regardless of the HookName the plugin set on the AbortError itself
// (namespacedWrap overwrites it) -- see internal/hook/install.go.
func TestWrapAbortPin(t *testing.T) {
bin := buildFork(t, "wrap-abort", wrapAbortPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) {
t.Errorf("error.hint=%q want to contain the abort hint", hint)
}
}
// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory
// closure itself, not the returned Handler).
const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-panic", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
panic("wrap boom")
}).
FailOpen().
MustBuild())
}
`
// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper
// factory does not crash the process. recoverWrap (internal/hook/install.go)
// converts the panic into the same validation/failed_precondition shape as
// wrap-abort, with a distinct message/hint pair. Observed real output (docs
// +fetch --doc nonexistent, wrapper factory panics unconditionally):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-panic.guard\" panicked: wrap boom",
// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this
// command; report the panic to the plugin author or remove the plugin"}}
func TestWrapPanicPin(t *testing.T) {
bin := buildFork(t, "wrap-panic", wrapPanicPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) {
t.Errorf("error.hint=%q want to contain the panic hint", hint)
}
}

View File

@@ -1,205 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// readonlyPlugin registers a Restrict rule that only allows read-risk
// commands under the docs/** and im/** domains. It mirrors the official
// example readonly-policy configuration.
const readonlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("readonly", "0.1.0").
Restrict(&platform.Rule{
Name: "agent-readonly",
Allow: []string{"docs/**", "im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestReadonlyDenial asserts the VERIFIED denial envelope shape: stderr is
// valid JSON, error.type=="validation", error.subtype=="failed_precondition",
// error.hint contains the literal "reason_code <X>" substring, and the
// process exits 2. reason_code lives only in the hint string, not a
// structured field.
func TestReadonlyDenial(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
// Note: reason_code mixed_children_policy is intentionally NOT covered here.
// It requires a parent command whose *enumerated children* have mixed
// allow/deny outcomes, which needs the full command tree from API metadata.
// This L4 harness builds a bare-module fork (embedded stub only), so offline
// a parent like "sheets" has no known children and collapses to
// domain_not_allowed -- identical to the "leaf out of allow list" case and
// not a distinct reason_code. Covered instead by the in-process cmdpolicy
// unit tests, which construct a mixed-children tree directly.
cases := []struct {
name string
args []string
reasonCode string
}{
{"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}, "write_not_allowed"},
{"leaf out of allow list", []string{"schema"}, "domain_not_allowed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
})
}
}
// TestReadonlyAllows asserts the allow-path: a read command inside an
// allowed domain must NOT be denied by the policy gate. It may still fail
// downstream (e.g. api/auth error), but that failure must not carry the
// denial envelope shape and must not exit 2.
func TestReadonlyAllows(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit == 2 {
t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr)
}
if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" {
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
}
}
// identityPlugin registers a Restrict rule scoped to bot identities only.
// im +messages-search declares AuthTypes:["user"] (see
// shortcuts/im/im_messages_search.go), so it has no intersection with the
// rule's bot-only whitelist regardless of which --as value the caller
// passes: platform.Rule.Identities is checked against the command's own
// static supported-identities annotation, not the runtime --as flag.
const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("identity-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "bot-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
Identities: []platform.Identity{platform.IdentityBot},
}).
MustBuild())
}
`
// denylistPlugin registers a Restrict rule that allows the docs/** domain
// but explicitly denies docs/+search (a real read-risk leaf, see
// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the
// command is rejected before MaxRisk is even consulted.
const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("denylist-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "deny-search",
Allow: []string{"docs/**"},
Deny: []string{"docs/+search"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// multiRulePlugin registers two scope-exclusive Restrict rules (im-only,
// docs-only). A command outside both domains (e.g. the top-level "schema"
// command, itself read-risk and already proven to hit domain_not_allowed
// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is
// rejected by both rules, so cmdpolicy's OR-engine collapses the two
// per-rule denials into the aggregate reason_code "no_matching_rule".
const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("multi-rule-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "im-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
}).
Restrict(&platform.Rule{
Name: "docs-only",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
// reason_code across this package -- both policy denials (this file) and
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
// error.type=="validation", error.subtype=="failed_precondition", and
// error.hint containing "reason_code <wantReasonCode>". Both paths render
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
// reason_code in the hint STRING, not a structured error.detail.reason_code
// field (contradicting internal/platform/error.go:34's comment).
func assertReasonCodeEnvelope(t *testing.T, res result, wantReasonCode string) {
t.Helper()
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "reason_code "+wantReasonCode) {
t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode)
}
}
// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only
// rule rejects a command whose declared AuthTypes don't include "bot".
func TestIdentityMismatchDenial(t *testing.T) {
bin := buildFork(t, "identity", identityPlugin)
res := run(t, bin, "im", "+messages-search", "--as", "user")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "identity_mismatch")
}
// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit
// rejects the command even though it also matches Allow.
func TestDenylistDenial(t *testing.T) {
bin := buildFork(t, "denylist", denylistPlugin)
res := run(t, bin, "docs", "+search")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "command_denylisted")
}
// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected
// by every rule in a multi-Restrict() plugin gets the aggregate reason_code,
// not either rule's own per-rule reason_code.
func TestMultiRuleDenial(t *testing.T) {
bin := buildFork(t, "multirule", multiRulePlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "no_matching_rule")
}

View File

@@ -1,69 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"os"
"path/filepath"
"testing"
)
// TestMain archives HEAD's committed tree once for the whole package before
// any fork build runs. It lives here (not in harness.go) because `go test`
// only discovers TestMain in a _test.go file — a TestMain defined in a plain
// .go file is silently never invoked.
// NOTE: exactly one TestMain is allowed per package — do not add another in other _test.go files here.
func TestMain(m *testing.M) {
root, err := repoRoot()
if err != nil {
panic("locate repo root: " + err.Error())
}
baseDir, err = os.MkdirTemp("", "plugin-e2e-")
if err != nil {
panic("mkdtemp: " + err.Error())
}
cleanTree = filepath.Join(baseDir, "larkcli-clean")
if err := os.MkdirAll(cleanTree, 0o755); err != nil {
panic("mkdir clean tree: " + err.Error())
}
if err := gitArchive(root, cleanTree); err != nil {
panic("git archive: " + err.Error())
}
code := m.Run()
_ = os.RemoveAll(baseDir)
os.Exit(code)
}
// noopPlugin registers a plugin that installs nothing observable, proving
// the blank-import -> init -> Register -> InstallAll assembly chain links
// and the fork boots.
const noopPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("smoke", "0.0.1").
Observer(platform.After, "noop", platform.All(),
func(_ context.Context, _ platform.Invocation) {}).
FailOpen().
MustBuild())
}
`
func TestSmokeForkBoots(t *testing.T) {
bin := buildFork(t, "smoke", noopPlugin)
res := run(t, bin, "--help")
if res.exit != 0 {
t.Fatalf("--help exit=%d stderr=%s", res.exit, res.stderr)
}
if res.stdout == "" {
t.Fatalf("--help produced empty stdout")
}
}

View File

@@ -1,466 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar_e2e proves the sidecar auth-proxy wire protocol end-to-end,
// offline and secret-free: a real fork binary (built with -tags authsidecar,
// exercising the REAL extension/transport/sidecar interceptor) signs a
// request with HMAC-SHA256 and routes it to an in-test sidecar, which
// verifies the signature using the REAL sidecar.Verify / sidecar.CanonicalRequest
// from github.com/larksuite/cli/sidecar, injects a synthetic token, and
// forwards to an in-test mock upstream.
//
// DEVIATION FROM THE ORIGINAL PLAN: the plan called for driving the real
// sidecar/server-demo binary (built with -tags authsidecar_demo) as the
// middle process. That is infeasible for an OFFLINE test, for three
// independent reasons, all verified in source:
//
// 1. sidecar/server-demo/handler.go:171 resolves a REAL token via
// h.cred.ResolveToken(...), which errors out unless the machine has run
// `lark-cli auth login` — there is no way to make it return a token
// without live credentials.
// 2. sidecar/server-demo/main.go builds handler.allowedHosts from
// core.ResolveEndpoints(BrandFeishu/BrandLark) only — real feishu/lark
// hosts. An in-test mock (127.0.0.1:<port>) is never in that allowlist
// and would be rejected with 403 (handler.go step 4).
// 3. sidecar/server-demo/handler.go:184 pins the forward scheme to
// "https://" + targetHost, ignoring the client-supplied scheme. It can
// never be redirected to an http:// mock.
//
// server-demo's verify+inject logic is ALREADY covered by
// `go test -tags authsidecar_demo ./sidecar/server-demo/` (see the
// sidecar-test Makefile target, item 3) — that is unit-level coverage of the
// same code paths this file would otherwise exercise via a real subprocess.
//
// So instead, this test builds its OWN in-test sidecar (an httptest.Server)
// that mirrors server-demo/handler.go's verify+inject steps 0-8 exactly,
// using the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
// same symbols server-demo itself uses. This is the standard shape for this
// kind of test: one real external process (the fork binary, compiled with
// the production interceptor code) plus two in-process httptest.Server
// stand-ins (sidecar, upstream). It proves the real wire protocol end-to-end
// without requiring live credentials, real feishu/lark hosts, or TLS.
//
// Every key/token/app-id here is an obviously-synthetic placeholder; nothing
// in this file can authenticate against anything real.
package sidecar_e2e
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/larksuite/cli/sidecar"
)
// Synthetic, obviously-fake fixtures. None of these are real secrets.
const (
testProxyKey = "test-proxy-key-not-a-real-secret-000000000000"
testAppID = "cli_test_app_not_real"
injectedToken = "fake-injected-token-not-real"
)
// TestSidecarHMACRoundTrip drives the whole wire protocol as three named
// steps so the flow is readable at a glance; each step's mechanics live in a
// dedicated helper below.
func TestSidecarHMACRoundTrip(t *testing.T) {
// Two in-process stand-ins: the mock upstream (for open.feishu.cn) and the
// in-test sidecar (server-demo's verify+inject, via the real protocol pkg).
upstream := startMockUpstream(t)
sc := startInTestSidecar(t, []byte(testProxyKey), upstream.URL)
// One real external process: lark-cli built with -tags authsidecar, run
// fully offline against the in-test sidecar.
bin := buildAuthsidecarFork(t)
runFork(t, bin, sc.URL)
// Assert the three properties of a correct round trip.
assertInterceptorSigned(t, sc) // (a)+(c) fork -> sidecar
assertInjectedTokenReachedUpstream(t, upstream) // (b) sidecar -> upstream
}
// --- request capture -------------------------------------------------------
// capturedRequest snapshots the parts of an *http.Request that matter for
// assertions, taken before the request (and its body reader) is consumed or
// goes out of scope.
type capturedRequest struct {
method string
path string
headers http.Header
body []byte
}
// requestSink stores the request a stub server saw, guarded so the httptest
// handler goroutine and the test goroutine can hand it over safely.
type requestSink struct {
mu sync.Mutex
req *capturedRequest
}
func (s *requestSink) capture(r *http.Request, body []byte) {
snap := capturedRequest{
method: r.Method,
path: r.URL.RequestURI(),
headers: r.Header.Clone(),
body: body,
}
s.mu.Lock()
s.req = &snap
s.mu.Unlock()
}
func (s *requestSink) get() *capturedRequest {
s.mu.Lock()
defer s.mu.Unlock()
return s.req
}
// --- mock upstream (stands in for open.feishu.cn) --------------------------
type mockUpstream struct {
*httptest.Server
sink requestSink
}
func startMockUpstream(t *testing.T) *mockUpstream {
t.Helper()
m := &mockUpstream{}
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
m.sink.capture(r, body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"document":{"content":"mock content"}}}`))
}))
t.Cleanup(m.Close)
return m
}
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
type inTestSidecar struct {
*httptest.Server
key []byte
upstreamURL string
sink requestSink
mu sync.Mutex // guards verifyRan/verifyErr
verifyRan bool
verifyErr error
}
func startInTestSidecar(t *testing.T, key []byte, upstreamURL string) *inTestSidecar {
t.Helper()
s := &inTestSidecar{key: key, upstreamURL: upstreamURL}
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(s.Close)
return s
}
// handle is the request flow: capture -> verify (steps 0-4) -> inject+forward.
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
s.sink.capture(r, body)
authHeader, ok := s.verifyProxyRequest(w, r, body)
if !ok {
return
}
s.forwardWithInjectedToken(w, r, body, authHeader)
}
// verifyProxyRequest mirrors server-demo/handler.go steps 0-4: protocol
// version, body SHA256, target validation, and HMAC signature verification.
// It records whether verification ran and its result (for assertions) and
// returns the auth header the client committed to. On any failure it writes
// the HTTP error and returns ok=false.
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, ok bool) {
// Step 0: protocol version.
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return "", false
}
// Step 1-2: timestamp + body SHA256.
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return "", false
}
// Step 3: target host, identity, auth-header (all covered by the sig).
targetHost, perr := parseTargetHost(r.Header.Get(sidecar.HeaderProxyTarget))
if perr != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+perr.Error(), http.StatusForbidden)
return "", false
}
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
// Step 4: verify HMAC signature over the canonical request.
err := sidecar.Verify(s.key, sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: r.URL.RequestURI(),
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}, r.Header.Get(sidecar.HeaderProxySignature))
s.mu.Lock()
s.verifyRan = true
s.verifyErr = err
s.mu.Unlock()
if err != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
return "", false
}
return authHeader, true
}
// forwardWithInjectedToken mirrors server-demo's inject+forward. Unlike
// server-demo (which forwards to "https://"+targetHost), this test forwards to
// the in-test MOCK's URL — proving the sidecar's inject step without needing a
// real upstream or a route to targetHost. It strips any client-supplied auth
// headers first (the sidecar is the sole source of auth material), injects the
// synthetic token into the committed header, and relays the response back.
func (s *inTestSidecar) forwardWithInjectedToken(w http.ResponseWriter, r *http.Request, body []byte, authHeader string) {
freq, err := http.NewRequest(r.Method, s.upstreamURL+r.URL.RequestURI(), bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to build forward request", http.StatusInternalServerError)
return
}
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
freq.Header.Add(k, v)
}
}
freq.Header.Del("Authorization")
freq.Header.Del(sidecar.HeaderMCPUAT)
freq.Header.Del(sidecar.HeaderMCPTAT)
if authHeader == "Authorization" {
freq.Header.Set("Authorization", "Bearer "+injectedToken)
} else {
freq.Header.Set(authHeader, injectedToken)
}
resp, err := http.DefaultClient.Do(freq)
if err != nil {
http.Error(w, "forward failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(respBody)
}
// verifyResult reports whether step 4 ran and, if so, its error.
func (s *inTestSidecar) verifyResult() (ran bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.verifyRan, s.verifyErr
}
// isProxyHeader reports whether name is one of the sidecar wire-protocol
// headers that must not be copied through to the forwarded (mock upstream)
// request. Mirrors sidecar/server-demo/handler.go's isProxyHeader.
func isProxyHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case http.CanonicalHeaderKey(sidecar.HeaderProxyVersion),
http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
return true
}
return false
}
// parseTargetHost validates X-Lark-Proxy-Target and returns its host.
// Mirrors sidecar/server-demo/handler.go's parseTarget: the header must be
// "https://<host>" with no path, query, fragment, or userinfo. Only the host
// is used, both as HMAC signing input and to record what the fork believed
// its real destination was — the actual forward in this test always goes to
// the in-test mock, never to this host.
func parseTargetHost(target string) (string, error) {
u, err := url.Parse(target)
if err != nil {
return "", fmt.Errorf("parse: %w", err)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}
// --- fork build + run ------------------------------------------------------
// buildAuthsidecarFork builds the REAL lark-cli with -tags authsidecar (the
// production interceptor) and returns the binary path.
func buildAuthsidecarFork(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "forkbin")
build := exec.Command("go", "build", "-tags", "authsidecar", "-o", bin, ".")
build.Dir = repoRoot(t)
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork binary: %v\n%s", err, out)
}
return bin
}
// runFork runs the fork against the in-test sidecar, fully offline. The fork's
// exit status is logged but NOT asserted — this test judges wire behavior
// (what reached the sidecar/upstream), not the command's own success.
func runFork(t *testing.T, binPath, sidecarURL string) {
t.Helper()
scURL, err := url.Parse(sidecarURL)
if err != nil {
t.Fatalf("parse sidecar URL: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, "docs", "+fetch", "--doc", "nonexistent", "--as", "user")
cmd.Env = append(os.Environ(),
"LARKSUITE_CLI_AUTH_PROXY=http://"+scURL.Host,
"LARKSUITE_CLI_PROXY_KEY="+testProxyKey,
"LARKSUITE_CLI_APP_ID="+testAppID,
"LARKSUITE_CLI_BRAND=feishu",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
t.Logf("fork exit error (informational only, not asserted): %v", runErr)
t.Logf("fork stdout: %s", stdout.String())
t.Logf("fork stderr: %s", stderr.String())
}
// repoRoot resolves the lark-cli module root from the test's working
// directory (which `go test` sets to the package dir, tests/sidecar_e2e).
func repoRoot(t *testing.T) string {
t.Helper()
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("resolve repo root: %v", err)
}
return strings.TrimSpace(string(out))
}
// --- assertions ------------------------------------------------------------
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c):
// the real interceptor ran (all proxy headers present, identity=user), stripped
// every real/sentinel auth header before signing, and produced a signature that
// verified against the shared key.
func assertInterceptorSigned(t *testing.T, sc *inTestSidecar) {
t.Helper()
got := sc.sink.get()
if got == nil {
t.Fatal("sidecar never received a request from the fork — interceptor did not route to AUTH_PROXY")
}
ran, verifyErr := sc.verifyResult()
if !ran {
t.Fatal("sidecar received a request but never reached HMAC verification (rejected earlier — see handler headers)")
}
if verifyErr != nil {
t.Fatalf("HMAC verification failed on the fork's own signed request: %v", verifyErr)
}
t.Logf("fork->sidecar headers: %v", got.headers)
// No real/sentinel auth ever left the fork: the interceptor strips the
// sentinel before signing, so this hop must carry no auth header at all.
if auth := got.headers.Get("Authorization"); auth != "" {
t.Fatalf("fork->sidecar hop leaked an Authorization header (want none, interceptor should have stripped it): %q", auth)
}
if v := got.headers.Get(sidecar.HeaderMCPUAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPUAT, v)
}
if v := got.headers.Get(sidecar.HeaderMCPTAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPTAT, v)
}
// Proxy headers must be present (proves the interceptor actually ran).
for _, h := range []string{
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
sidecar.HeaderProxyAuthHeader,
} {
if got.headers.Get(h) == "" {
t.Fatalf("fork->sidecar hop missing required proxy header %s", h)
}
}
if id := got.headers.Get(sidecar.HeaderProxyIdentity); id != sidecar.IdentityUser {
t.Fatalf("fork->sidecar identity = %q, want %q", id, sidecar.IdentityUser)
}
}
// assertInjectedTokenReachedUpstream checks the sidecar -> upstream hop
// (assertion b): the mock saw exactly the sidecar-injected synthetic token,
// never a sentinel or a real one — proving injection actually happened.
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
t.Helper()
got := up.sink.get()
if got == nil {
t.Fatal("mock upstream never received a forwarded request — sidecar did not forward after verification")
}
t.Logf("sidecar->mock headers: %v", got.headers)
wantAuth := "Bearer " + injectedToken
gotAuth := got.headers.Get("Authorization")
if gotAuth != wantAuth {
t.Fatalf("mock upstream Authorization = %q, want %q", gotAuth, wantAuth)
}
// Belt-and-suspenders: the value the mock saw must not be either sentinel,
// proving the only token that ever reached "upstream" was the injected one.
if gotAuth == "Bearer "+sidecar.SentinelUAT || gotAuth == "Bearer "+sidecar.SentinelTAT {
t.Fatalf("mock upstream received a sentinel token instead of the injected one: %q", gotAuth)
}
}