Compare commits

...

10 Commits

Author SHA1 Message Date
zhaoyukun.yk
c5e73f99ea fix(skills): align skill guidance with the typed error contract
Skill references written before the typed-error refactor still taught retired envelope shapes. AI agents following them now read what the CLI actually emits:

- permission recovery reads error.missing_scopes instead of the upstream permission_violations detail
- confirmation gates use type=confirmation, subtype=confirmation_required, and flat risk/action fields
- drive duplicate-remote failures are typed validation envelopes (failed_precondition with params[]), not duplicate_remote_path with error.detail
- drive batch partial failures are ok:false results on stdout, not an error.type=partial_failure stderr envelope
- minutes edit-permission and word-replace misses branch on stable error.code/error.subtype, not message text
- slides replace failures are stderr typed envelopes only; no raw backend response is printed to stdout
- slides and Base command outputs show the ok/identity/data success envelope instead of the raw {code,msg} OpenAPI wrapper
2026-07-08 17:43: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
bubbmon233
f495cbb166 feat(mail): add message modify and trash shortcuts (#1567) 2026-07-07 21:44:40 +08:00
Yuxuan Zhao
6f95c5eb22 e2e: harden CLI E2E retry, cleanup, and domain selection (#1709) 2026-07-07 19:41:11 +08:00
liangshuo-1
4e2cbea94e chore: release v1.0.66 (#1782) 2026-07-07 19:33:39 +08:00
fangshuyu-768
f98dbfe247 Improve agent-facing error guidance for drive, markdown, and wiki (#1779) 2026-07-07 18:10:23 +08:00
106 changed files with 5212 additions and 624 deletions

View File

@@ -263,13 +263,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Run dry-run E2E tests
env:
@@ -277,7 +283,28 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
@@ -292,15 +319,22 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -310,16 +344,24 @@ jobs:
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests

View File

@@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file.
## [v1.0.66] - 2026-07-07
### Features
- support semantic recurring calendar operations (#1723)
- minute wait (#1768)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
### Misc
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
## [v1.0.65] - 2026-07-03
### Features
@@ -1371,6 +1398,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -14,11 +14,13 @@ import (
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f)
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
return rootCmd
}
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
// command-specific recovery guidance at the shortcut layer.
var wikiCodeMeta = map[int]CodeMeta{
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
}
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }

View File

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

View File

@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
exit 1
fi

54
scripts/domain-map.js Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};

71
scripts/domain-map.json Normal file
View File

@@ -0,0 +1,71 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}

224
scripts/e2e_domains.js Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});

View File

@@ -4,6 +4,7 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================
// Constants & Configuration
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/wiki/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-wiki/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = {
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
const normalized = normalizePath(filePath);
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
return labelDomainsForPath(filePath)[0] || "";
}
async function detectNewShortcutDomain(files) {

View File

@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
trap 'rm -rf "$tmp"' EXIT
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
mkdir -p "$tmp"
git_init() {

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

@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
var downloaded, skipped, failed, deletedLocal int
downloadFailed := 0
aborted := false
items := make([]drivePullItem, 0)
// Deterministic iteration order for output stability.
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
if drivePullHasTerminalFailure(items) {
if aborted {
break
}
targetFile := remoteFiles[rel]
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
failed++
downloadFailed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
break
}
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
"aborted": drivePullHasTerminalFailure(items),
"aborted": aborted,
},
"items": items,
}
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
return item, decision.Terminal
}
func drivePullHasTerminalFailure(items []drivePullItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// drivePullDownload streams one Drive file into the local mirror target and
// then best-effort aligns the local mtime to Drive's modified_time.
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {

View File

@@ -35,6 +35,7 @@ type drivePushItem struct {
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"`
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
Subtype string
Retryable bool
Terminal bool
Hint string
}
// DrivePush is a one-way, file-level mirror from a local directory onto a
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
// locally and now on Drive too), which is the worst-of-both-worlds
// outcome the review flagged.
uploadFailed := false
aborted := false
// folderCache holds rel_path → folder_token. Seeded from the remote
// listing (so we don't recreate folders that already exist) and
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
failed++
uploadFailed = true
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
for _, rel := range localPaths {
localFile := localFiles[rel]
if uploadFailed && drivePushHasTerminalFailure(items) {
if uploadFailed && aborted {
break
}
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
failed++
uploadFailed = true
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
break
}
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
failed++
uploadFailed = true
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break
}
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
failed++
uploadFailed = true
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
failed++
uploadFailed = true
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break
}
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
continue
}
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
if drivePushIsAlreadyDeleted(err) {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
continue
}
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
abortDelete = true
break
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_remote": deletedRemote,
"aborted": drivePushHasTerminalFailure(items),
"aborted": aborted,
},
"items": items,
}
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
Action: action,
SizeBytes: sizeBytes,
Error: err.Error(),
Hint: decision.Hint,
Phase: phase,
ErrorClass: decision.Class,
Code: decision.Code,
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
decision.Class = "file_size_limit"
case problem.Code == 1062009:
decision.Class = "upload_size_mismatch"
case problem.Code == 1061044:
decision.Class = "parent_node_missing"
decision.Terminal = true
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
decision.Class = "remote_not_found"
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
return decision
}
func drivePushHasTerminalFailure(items []drivePushItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
func driveTerminalBatchErrorClass(errorClass string) bool {
switch errorClass {
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
return true
default:
return false
}
func drivePushIsAlreadyDeleted(err error) bool {
problem, ok := errs.ProblemOf(err)
return ok && problem.Code == 1061007
}
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {

View File

@@ -732,6 +732,65 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
}
}
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/tok_orphan",
Body: map[string]interface{}{
"code": 1061007,
"msg": "file has been delete.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(0) {
t.Fatalf("summary.failed = %v, want 0", got)
}
if got := summary["deleted_remote"]; got != float64(0) {
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
t.Fatalf("unexpected already-deleted item: %#v", item)
}
}
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
@@ -1137,6 +1196,78 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
}
}
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
t.Fatalf("WriteFile a: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
t.Fatalf("WriteFile b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 1061044,
"msg": "parent node not exist.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b.txt" {
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
}
}
}
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
// --- Phase 2: Execute sync operations ---
var pulled, pushed, skipped, failed int
aborted := false
items := make([]driveSyncItem, 0)
// Build push infrastructure: local walk for push + remote views + folder cache.
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
// Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped.
for _, relDir := range localDirs {
if driveSyncHasTerminalFailure(items) {
if aborted {
break
}
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
// 2a. Pull new_remote files.
for _, entry := range newRemote {
if driveSyncHasTerminalFailure(items) {
if aborted {
break
}
targetFile, ok := pullRemoteFiles[entry.RelPath]
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break
}
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
// 2b. Push new_local files.
for _, entry := range newLocal {
if driveSyncHasTerminalFailure(items) {
if aborted {
break
}
localFile, ok := pushLocalFiles[entry.RelPath]
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break
}
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
// 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified {
if driveSyncHasTerminalFailure(items) {
if aborted {
break
}
remoteFile := remoteFiles[entry.RelPath]
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break
}
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
}
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil {
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
break
}
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break
}
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
items = append(items, item)
failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
break
}
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
"pushed": pushed,
"skipped": skipped,
"failed": failed,
"aborted": driveSyncHasTerminalFailure(items),
"aborted": aborted,
},
"items": items,
}
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
return item, decision.Terminal
}
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the
// user chose to skip.

View File

@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
// original message as read after a reply/reply-all/forward operation.
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
}
// hintReadReceiptRequest prints a stderr tip when a message that the caller

View File

@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
// TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
// Inject ANSI escape + message ID to verify sanitization
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
out := stderr.String()
if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
}
if !strings.Contains(out, "msg-123") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
if strings.Contains(out, "\nnext") {
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
}
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
}
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
}
}

View File

@@ -0,0 +1,482 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func messageManageID(suffix string) string {
return "msg_abcdefghijklmnop_" + suffix
}
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/" + endpoint,
Body: body,
}
reg.Register(stub)
return stub
}
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
t.Helper()
success, ok := data["success_message_ids"].([]interface{})
if !ok {
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
}
failed, ok := data["failed_message_ids"].([]interface{})
if !ok {
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
}
return success, failed
}
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected validation error for %s, got nil", param)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem for %s, got %T", param, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr.Param != param {
t.Fatalf("param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected failed precondition error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem, got %T", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
}
}
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
}
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
}
cases := [][]string{
{""},
{" id_with_leading_space_12345"},
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
{"1234567890123456"},
{"short"},
{"msg_abcdefghijklmnop!"},
{"msg_abcdefghijklmnop\t"},
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
}
for _, tc := range cases {
_, err := normalizeMessageManageIDs(tc)
requireMessageManageValidationParam(t, err, "--message-ids")
}
}
func TestMessageModify_Metadata(t *testing.T) {
if MailMessageModify.Command != "+message-modify" {
t.Fatalf("Command = %q", MailMessageModify.Command)
}
if MailMessageModify.Risk != "write" {
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
}
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
}
requiredScopes := map[string]bool{
"mail:user_mailbox.message:modify": true,
}
for _, scope := range MailMessageModify.Scopes {
delete(requiredScopes, scope)
}
if len(requiredScopes) != 0 {
t.Errorf("Scopes missing %v", requiredScopes)
}
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
}
flags := map[string]common.Flag{}
for _, fl := range MailMessageModify.Flags {
flags[fl.Name] = fl
}
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
if _, ok := flags[name]; !ok {
t.Fatalf("missing --%s flag", name)
}
}
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
}
}
func TestMessageTrash_Metadata(t *testing.T) {
if MailMessageTrash.Command != "+message-trash" {
t.Fatalf("Command = %q", MailMessageTrash.Command)
}
if MailMessageTrash.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
}
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
}
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
}
}
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
token := auth.GetStoredToken("test-app", "ou_testuser")
if token == nil {
t.Fatal("expected test token")
}
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "UNREAD",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
}
}
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "read_receipt_request",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
}
}
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread,customA",
"--remove-label-ids", "FLAGGED",
"--add-folder", "folderA",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := body["add_folder"]; got != "folderA" {
t.Errorf("add_folder = %v, want folderA", got)
}
addLabels := body["add_label_ids"].([]interface{})
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
}
removeLabels := body["remove_label_ids"].([]interface{})
if removeLabels[0] != "FLAGGED" {
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 1 || success[0] != id || len(failed) != 0 {
t.Errorf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := messageManageID("1")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread",
"--remove-label-ids", "UNREAD",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-label-ids")
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
t.Fatalf("error = %v, want label intersection validation", err)
}
err = runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "trash",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-folder")
if !strings.Contains(err.Error(), "use +message-trash") {
t.Fatalf("error = %v, want TRASH validation", err)
}
}
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2 + "," + id1,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 41)
for i := range ids {
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
}
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", strings.Join(ids, ","),
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
messageIDs := body["message_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(messageIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
}
if body["add_folder"] != "ARCHIVED" {
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
}
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "archive",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2,
"--add-label-ids", "customA",
"--add-folder", "folderA",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/messages/batch_modify`,
`validation_api_plan`,
`/user_mailboxes/me/labels/customA`,
`/user_mailboxes/me/folders/folderA`,
`will_validate`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
}
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
}, f, stdout)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
}
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err = runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err with --yes: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := len(body["message_ids"].([]interface{})); got != 2 {
t.Fatalf("message_ids len = %d, want 2", got)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id,
"--yes",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
cases := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "trash newline in repeated flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
},
{
name: "trash tab in csv flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
},
{
name: "modify space in repeated flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
},
{
name: "modify space in csv flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
}
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
t.Fatalf("error = %v, want whitespace/control validation", err)
}
})
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
type messageModifyInput struct {
MessageIDs []string
AddLabelIDs []string
RemoveLabelIDs []string
AddFolder string
CustomLabelIDs []string
CustomFolderID string
ValidationAPIPlans []validationAPIPlan
}
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
// state labels, or a folder move to existing messages in batches of 20.
var MailMessageModify = common.Shortcut{
Service: "mail",
Command: "+message-modify",
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify"},
ConditionalScopes: []string{
"mail:user_mailbox.folder:read",
},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
},
Validate: validateMessageModify,
DryRun: dryRunMessageModify,
Execute: executeMessageModify,
}
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
_, err := buildMessageModifyInput(rt)
return err
}
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
input, _ := buildMessageModifyInput(rt)
api := common.NewDryRunAPI().
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
Set("validation_api_plan", input.ValidationAPIPlans)
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
}
return api
}
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
input, err := buildMessageModifyInput(rt)
if err != nil {
return err
}
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
return err
}
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
return err
}
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
emitMessageManageSummary(rt, messageManageSummary{
SuccessMessageIDs: input.MessageIDs,
FailedMessageIDs: []messageManageFailure{},
}, true)
return nil
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message modify batches failed")
}
return nil
}
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return messageModifyInput{}, err
}
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
if err != nil {
return messageModifyInput{}, err
}
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
if err != nil {
return messageModifyInput{}, err
}
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
return messageModifyInput{}, err
}
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
if err != nil {
return messageModifyInput{}, err
}
customLabels := append(customAddLabels, customRemoveLabels...)
customFolderID := ""
if customFolder {
customFolderID = folder
}
return messageModifyInput{
MessageIDs: messageIDs,
AddLabelIDs: addLabels,
RemoveLabelIDs: removeLabels,
AddFolder: folder,
CustomLabelIDs: customLabels,
CustomFolderID: customFolderID,
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
}, nil
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
// runner requires --yes before Execute.
var MailMessageTrash = common.Shortcut{
Service: "mail",
Command: "+message-trash",
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
},
Validate: validateMessageTrash,
DryRun: dryRunMessageTrash,
Execute: executeMessageTrash,
}
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
return err
}
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
api := common.NewDryRunAPI().
Desc("Soft-delete messages sequentially in batches of 20").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(messageIDs))
for _, batch := range chunkMessageManageIDs(messageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
Body(map[string]interface{}{"message_ids": batch})
}
return api
}
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return err
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(messageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
map[string]interface{}{"message_ids": batch})
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message trash batches failed")
}
return nil
}

View File

@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {

View File

@@ -0,0 +1,283 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"io"
"strings"
"unicode"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const mailMessageManageBatchSize = 20
var messageManageSystemLabels = map[string]string{
"UNREAD": "UNREAD",
"IMPORTANT": "IMPORTANT",
"OTHER": "OTHER",
"FLAGGED": "FLAGGED",
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
}
var messageManageSystemFolders = map[string]string{
"INBOX": "INBOX",
"SENT": "SENT",
"SPAM": "SPAM",
"ARCHIVE": "ARCHIVED",
"ARCHIVED": "ARCHIVED",
}
type messageManageSummary struct {
SuccessMessageIDs []string `json:"success_message_ids"`
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
}
type messageManageFailure struct {
MessageID string `json:"message_id"`
Reason string `json:"reason"`
}
type validationAPIPlan struct {
Method string `json:"method"`
Path string `json:"path"`
WillValidate bool `json:"will_validate"`
}
func normalizeMessageManageIDs(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
parts, err := splitMessageManageIDTokens(raw)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
if part == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if id != part {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateMessageManageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
return ids, nil
}
func splitMessageManageIDTokens(raw []string) ([]string, error) {
parts := make([]string, 0, len(raw))
for i, token := range raw {
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
}
}
parts = append(parts, strings.Split(token, ",")...)
}
return parts, nil
}
func validateMessageManageID(id string, index int) error {
if len(id) < 16 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
}
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
}
for _, r := range id {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
}
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
switch r {
case '+', '/', '=', '_', '-':
continue
default:
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
}
}
return nil
}
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
labels := make([]string, 0, len(raw))
custom := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, part := range raw {
id := strings.TrimSpace(part)
if id == "" {
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
}
if id != part {
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
}
normalized := id
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
normalized = system
} else {
custom = append(custom, id)
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
labels = append(labels, normalized)
}
if len(labels) > 20 {
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
}
return labels, custom, nil
}
func validateLabelIntersection(add, remove []string) error {
removeSet := make(map[string]struct{}, len(remove))
for _, id := range remove {
removeSet[id] = struct{}{}
}
for _, id := range add {
if _, ok := removeSet[id]; ok {
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
}
}
return nil
}
func normalizeMessageManageFolder(raw string) (string, bool, error) {
if raw == "" {
return "", false, nil
}
folder := strings.TrimSpace(raw)
if folder == "" {
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
}
if folder != raw {
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
}
if strings.EqualFold(folder, "TRASH") {
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
}
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
return system, false, nil
}
return folder, true, nil
}
func chunkMessageManageIDs(ids []string) [][]string {
if len(ids) == 0 {
return nil
}
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
end := start + mailMessageManageBatchSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := validateLabelReadScope(rt); err != nil {
return err
}
seen := map[string]struct{}{}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "label not found: %s", id)
}
}
return nil
}
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
if id == "" {
return nil
}
if err := validateFolderReadScope(rt); err != nil {
return err
}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "folder not found: %s", id)
}
return nil
}
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
body := map[string]interface{}{"message_ids": ids}
if len(addLabels) > 0 {
body["add_label_ids"] = addLabels
}
if len(removeLabels) > 0 {
body["remove_label_ids"] = removeLabels
}
if addFolder != "" {
body["add_folder"] = addFolder
}
return body
}
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
seenLabels := map[string]struct{}{}
for _, id := range customLabels {
if _, ok := seenLabels[id]; ok {
continue
}
seenLabels[id] = struct{}{}
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "labels", id),
WillValidate: true,
})
}
if customFolder != "" {
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "folders", customFolder),
WillValidate: true,
})
}
return plans
}
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
if noAPICalls {
fmt.Fprintln(w, "No changes requested; no API calls were made.")
}
for _, item := range summary.FailedMessageIDs {
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
}
})
}

View File

@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MailMessage,
MailMessages,
MailMessageModify,
MailMessageTrash,
MailThread,
MailTriage,
MailWatch,

View File

@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
case 90003087:
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
case 1061003, 1061044:
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
case 1061004, 1062501:
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
case 1061101:
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
case 233523001:
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
case 99991400:
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
}
}
return err

View File

@@ -9,6 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
Tips: []string{
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}, true)
spec, err := readMarkdownCreateSpec(runtime)
if err != nil {
return err
}
return validateMarkdownSpec(runtime, spec, true)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
spec, err := readMarkdownCreateSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
spec, err := readMarkdownCreateSpec(runtime)
if err != nil {
return err
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
return nil
},
}
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
return normalizeMarkdownCreateTargetSpec(spec)
}
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
if spec.FolderToken != "" {
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
if err != nil {
return markdownUploadSpec{}, err
}
spec.FolderToken = token
}
if spec.WikiToken != "" {
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
if err != nil {
return markdownUploadSpec{}, err
}
spec.WikiToken = token
}
return spec, nil
}
func normalizeMarkdownFolderToken(token string) (string, error) {
token = strings.TrimSpace(token)
if strings.Contains(token, "://") {
ref, ok := common.ParseResourceURL(token)
if !ok {
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
WithHint("Pass a Drive folder URL or raw folder token.")
}
if ref.Type != "folder" {
return "", markdownValidationParamError("--folder-token",
"--folder-token must identify a Drive folder; got a %s URL",
ref.Type,
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
}
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
return "", err
}
return ref.Token, nil
}
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
return "", err
}
switch markdownKnownResourceTokenKind(token) {
case "wiki":
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
WithHint("Pass it with --wiki-token instead.")
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
}
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
return "", err
}
return token, nil
}
func normalizeMarkdownWikiToken(token string) (string, error) {
token = strings.TrimSpace(token)
if strings.Contains(token, "://") {
ref, ok := common.ParseResourceURL(token)
if !ok {
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
WithHint("Pass a wiki node URL or raw wiki node token.")
}
if ref.Type != "wiki" {
return "", markdownValidationParamError("--wiki-token",
"--wiki-token must identify a wiki node; got a %s URL",
ref.Type,
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
}
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
return "", err
}
return ref.Token, nil
}
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
return "", err
}
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
}
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
return "", err
}
return token, nil
}
func rejectMarkdownPartialToken(token, flagName string) error {
if strings.ContainsAny(token, "/?#") {
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
}
return nil
}
func validateMarkdownTargetTokenName(token, flagName string) error {
if err := validate.ResourceName(token, flagName); err != nil {
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
}
return nil
}
func markdownKnownResourceTokenKind(token string) string {
lower := strings.ToLower(strings.TrimSpace(token))
switch {
case strings.HasPrefix(lower, "wik"):
return "wiki"
case strings.HasPrefix(lower, "docx"):
return "docx"
case strings.HasPrefix(lower, "doc"):
return "doc"
case strings.HasPrefix(lower, "sht"):
return "sheet"
case strings.HasPrefix(lower, "bas"):
return "bitable"
case strings.HasPrefix(lower, "mn"):
return "mindnote"
case strings.HasPrefix(lower, "sld"):
return "slides"
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
return "file"
default:
return ""
}
}

View File

@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
}
}
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"parent_type": "explorer"`) {
t.Fatalf("dry-run missing explorer parent_type: %s", out)
}
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
}
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
}
}
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
}, f, stdout)
if err == nil {
t.Fatalf("expected folder-token URL type error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
t.Fatalf("expected folder-token URL type error, got %v", err)
}
}
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
}, f, stdout)
if err == nil {
t.Fatalf("expected wiki-token URL type error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
t.Fatalf("expected wiki-token URL type error, got %v", err)
}
}
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
run func() (string, error)
wantMsg string
wantHint string
}{
{
name: "wiki token passed as folder token",
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
wantMsg: "--folder-token looks like a wiki node token",
wantHint: "--wiki-token",
},
{
name: "folder token path fragment",
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
wantMsg: "--folder-token must be a raw token",
wantHint: "full Lark URL",
},
{
name: "doc token passed as wiki token",
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
wantMsg: "--wiki-token must be a wiki node token",
wantHint: "",
},
{
name: "wiki token query fragment",
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
wantMsg: "--wiki-token must be a raw token",
wantHint: "path/query/fragment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.run()
if err == nil {
t.Fatalf("expected validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, tt.wantMsg) {
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
}
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
}
})
}
}
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
t.Parallel()
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
if err != nil {
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
}
if folderToken != "folder_token_raw" {
t.Fatalf("folder token = %q", folderToken)
}
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
if err != nil {
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
}
if wikiToken != "wik_placeholder_raw" {
t.Fatalf("wiki token = %q", wikiToken)
}
}
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
t.Parallel()
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf(quotaErr) ok=false")
}
if !strings.Contains(p.Hint, "storage quota is exhausted") {
t.Fatalf("quota hint = %q", p.Hint)
}
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
p, ok = errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf(serverErr) ok=false")
}
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
}
}
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())

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

@@ -6,6 +6,7 @@ package wiki
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -26,3 +27,17 @@ func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
}
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
}
func appendWikiProblemHint(err error, hint string) error {
if strings.TrimSpace(hint) == "" {
return err
}
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
}
return err
}

View File

@@ -5,12 +5,14 @@ package wiki
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"reflect"
"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"
@@ -130,6 +132,155 @@ func TestWikiNodeListRequiresSpaceID(t *testing.T) {
}
}
func TestWikiNodeListRejectsNonNumericSpaceID(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "wikcnABC", "--as", "user",
}, factory, nil)
if err == nil {
t.Fatalf("expected numeric space_id validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--space-id" {
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--space-id", p, validationErr.Param)
}
if !strings.Contains(p.Message, "--space-id must be a numeric wiki space_id") || !strings.Contains(p.Hint, "+space-list") {
t.Fatalf("expected numeric space_id validation error, got %v", err)
}
}
func TestWikiNodeListRejectsDocumentURLAsParentNodeToken(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list",
"--space-id", "7211568716812369922",
"--parent-node-token", "https://feishu.cn/docx/docxABC",
"--as", "user",
}, factory, nil)
if err == nil {
t.Fatalf("expected parent-node-token URL type validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--parent-node-token" {
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--parent-node-token", p, validationErr.Param)
}
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
t.Fatalf("expected parent-node-token URL type validation error, got %v", err)
}
}
func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
t.Parallel()
token, err := normalizeWikiNodeListParentToken("https://feishu.cn/wiki/wikcnPARENT?from=copy")
if err != nil {
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
}
if token != "wikcnPARENT" {
t.Fatalf("token = %q, want wikcnPARENT", token)
}
}
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()
if err := validateWikiNodeListSpaceID("https://example.invalid/wiki/space"); err == nil {
t.Fatalf("expected URL space-id validation error")
} else {
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "not a URL or path") || !strings.Contains(p.Hint, "+space-list") {
t.Fatalf("problem = %#v, want URL/path message and +space-list hint", p)
}
}
tests := []struct {
name string
input string
wantMsg string
}{
{
name: "partial wiki path",
input: "wik_placeholder/child",
wantMsg: "raw wiki node token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := normalizeWikiNodeListParentToken(tt.input)
if err == nil {
t.Fatalf("expected parent token validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, tt.wantMsg) {
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
}
})
}
}
func TestWikiNodeListAcceptsEmptyParentToken(t *testing.T) {
t.Parallel()
token, err := normalizeWikiNodeListParentToken("")
if err != nil {
t.Fatalf("normalizeWikiNodeListParentToken(empty) error = %v", err)
}
if token != "" {
t.Fatalf("token = %q, want empty", token)
}
}
func TestWikiNodeListProblemAddsActionableHint(t *testing.T) {
t.Parallel()
err := errs.NewAPIError(errs.SubtypeInvalidParameters, "param err: invalid page_token").WithCode(131002)
got := wikiNodeListProblem(err, nil)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf() ok=false")
}
if !strings.Contains(p.Hint, "page token is invalid or stale") {
t.Fatalf("hint = %q, want invalid page token guidance", p.Hint)
}
}
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -137,14 +288,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"space_id": "7211568716812369922",
"node_token": "wik_node_1",
"obj_token": "docx_1",
"obj_type": "docx",
@@ -154,7 +305,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
"has_child": true,
},
map[string]interface{}{
"space_id": "space_123",
"space_id": "7211568716812369922",
"node_token": "wik_node_2",
"obj_token": "docx_2",
"obj_type": "docx",
@@ -170,7 +321,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -208,21 +359,22 @@ 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/space_123/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{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"space_id": "7211568716812369922",
"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,
@@ -235,7 +387,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--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)
@@ -257,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)
}
}
@@ -286,7 +438,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{
"space_id": "space_personal_42",
"space_id": "7211568716812369923",
"name": "My Library",
"space_type": "my_library",
},
@@ -296,14 +448,14 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
// Step 2: list nodes in the resolved space.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
URL: "/open-apis/wiki/v2/spaces/7211568716812369923/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_personal_42",
"space_id": "7211568716812369923",
"node_token": "wik_personal_1",
"title": "Personal Note",
},
@@ -334,8 +486,8 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
}
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
if envelope.Data.Nodes[0]["space_id"] != "7211568716812369923" {
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
}
}
@@ -758,21 +910,21 @@ func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
// test pins down the "default = single page" contract.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
map[string]interface{}{"space_id": "7211568716812369922", "node_token": "wik_1", "title": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -802,14 +954,14 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"space_id": "7211568716812369922",
"node_token": "wik_1",
"obj_type": "docx",
"obj_token": "docx_1",
@@ -822,7 +974,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)

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

@@ -48,27 +48,19 @@ var WikiNodeList = common.Shortcut{
"--space-id my_library is a per-user alias and is only valid with --as user.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// my_library is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
if _, err := readWikiNodeListSpec(runtime); err != nil {
return err
}
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
spec, err := readWikiNodeListSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
params["parent_node_token"] = pt
if spec.ParentNodeToken != "" {
params["parent_node_token"] = spec.ParentNodeToken
}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
@@ -80,7 +72,7 @@ var WikiNodeList = common.Shortcut{
// When the caller passes my_library, +node-list must first resolve it
// to the real per-user space_id before listing nodes, mirroring the
// two-step orchestration used by +node-create.
if spaceID == wikiMyLibrarySpaceID {
if spec.SpaceID == wikiMyLibrarySpaceID {
return d.
Desc("2-step orchestration: resolve my_library -> list nodes").
GET("/open-apis/wiki/v2/spaces/my_library").
@@ -91,13 +83,17 @@ var WikiNodeList = common.Shortcut{
Set("space_id", "<resolved_space_id>")
}
return d.
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
Params(params).
Set("space_id", spaceID)
Set("space_id", spec.SpaceID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaceID := strings.TrimSpace(runtime.Str("space-id"))
spec, err := readWikiNodeListSpec(runtime)
if err != nil {
return err
}
spaceID := spec.SpaceID
// Resolve the my_library alias to the per-user real space_id before
// listing, so the subsequent request hits a concrete space endpoint.
@@ -110,7 +106,7 @@ var WikiNodeList = common.Shortcut{
spaceID = resolved
}
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
if err != nil {
return err
}
@@ -127,10 +123,99 @@ var WikiNodeList = common.Shortcut{
},
}
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
type wikiNodeListSpec struct {
SpaceID string
ParentNodeToken string
}
func readWikiNodeListSpec(runtime *common.RuntimeContext) (wikiNodeListSpec, error) {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// my_library is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return wikiNodeListSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit numeric --space-id").WithParam("--space-id")
}
if err := validateWikiNodeListSpaceID(spaceID); err != nil {
return wikiNodeListSpec{}, err
}
parentNodeToken, err := normalizeWikiNodeListParentToken(strings.TrimSpace(runtime.Str("parent-node-token")))
if err != nil {
return wikiNodeListSpec{}, err
}
return wikiNodeListSpec{SpaceID: spaceID, ParentNodeToken: parentNodeToken}, nil
}
func validateWikiNodeListSpaceID(spaceID string) error {
if spaceID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
}
if spaceID == wikiMyLibrarySpaceID {
return nil
}
if strings.Contains(spaceID, "://") || strings.ContainsAny(spaceID, "/?#") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id must be a numeric wiki space_id, not a URL or path",
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to discover space IDs.")
}
if !isDecimalWikiSpaceID(spaceID) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id must be a numeric wiki space_id; do not pass a wiki node token, document token, or title",
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to list accessible wiki spaces, then pass the numeric `space_id`.")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
}
return nil
}
func isDecimalWikiSpaceID(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
if parentNodeToken == "" {
return "", nil
}
if strings.Contains(parentNodeToken, "://") {
ref, ok := common.ParseResourceURL(parentNodeToken)
if !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token URL is unsupported",
).WithParam("--parent-node-token").WithHint("Pass a raw wiki node token from `wiki +node-get` or `wiki +node-list`.")
}
if ref.Type != "wiki" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must identify a wiki node; got a %s URL",
ref.Type,
).WithParam("--parent-node-token").WithHint("Resolve the document URL with `lark-cli wiki +node-get --node-token <url>` and use its `node_token`.")
}
parentNodeToken = ref.Token
}
if strings.ContainsAny(parentNodeToken, "/?#") {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
).WithParam("--parent-node-token")
}
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
return "", err
}
return parentNodeToken, nil
}
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken string) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
@@ -153,7 +238,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
}
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
return nil, false, "", wikiNodeListProblem(err, runtime)
}
items, _ := data["items"].([]interface{})
for _, item := range items {
@@ -177,6 +262,36 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
return nodes, lastHasMore, lastPageToken, nil
}
func wikiNodeListProblem(err error, runtime *common.RuntimeContext) error {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
switch p.Code {
case 131002:
msg := strings.ToLower(p.Message)
switch {
case strings.Contains(msg, "page_token"):
appendWikiProblemHint(err, "The page token is invalid or stale. Use only the `page_token` returned by the immediately preceding `wiki +node-list` response, or omit --page-token and start over.")
case strings.Contains(msg, "space_id"):
appendWikiProblemHint(err, "The --space-id value must be the numeric wiki space_id from `wiki +space-list`; do not pass a wiki URL, node token, document token, or title.")
default:
appendWikiProblemHint(err, "Check the wiki +node-list flags. Fix the parameter before retrying; this is not a transient error.")
}
case 131005:
appendWikiProblemHint(err, "The target wiki space or parent node was not found. Re-discover the space with `wiki +space-list` and the parent with `wiki +node-list`/`wiki +node-get`; do not retry the same stale token.")
case 131006:
if runtime != nil && runtime.As().IsBot() {
appendWikiProblemHint(err, "The bot/app identity cannot read this wiki space or node. Grant the app the required wiki scope and ensure the app or bot has access to the target knowledge space.")
} else {
appendWikiProblemHint(err, "The current user cannot read this wiki space or node. Switch to a user with access or ask the space owner to grant read permission.")
}
case 99991400:
appendWikiProblemHint(err, "Rate limited by the wiki API. Stop immediate retries and retry later with exponential backoff or a smaller --page-limit.")
}
return err
}
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(m, "space_id"),

View File

@@ -65,7 +65,7 @@
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `*.batch_trash`
3. 用户确认后 → `+message-trash --message-ids ... --yes`
## 身份选择:优先使用 user 身份
@@ -82,12 +82,13 @@
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 读单封邮件,`+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **确认投递** 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
8. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
9. **已读回执**
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

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

@@ -76,4 +76,4 @@ CLI 提供三种互斥的 scope 表达方式:
## 不在本 skill 范围
- OpenAPI spec 全量导出、实时日志 tail、Webhook 消费、多鉴权方式:本期不支持。
- 身份选择、权限不足处理(`permission_violations`→`console_url`、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
- 身份选择、权限不足处理(`missing_scopes`→`console_url`、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。

View File

@@ -85,7 +85,7 @@ metadata:
## 身份与权限降级
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`
- user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
- user 身份报 scope/授权不足,或错误中包含 `missing_scopes` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
- user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次bot 仍失败就停止重试并按权限错误处理。
- `91403` 或明确不可访问错误不要循环换身份重试。
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。

View File

@@ -98,16 +98,35 @@ lark-cli base +dashboard-block-get \
## 返回结构总览
服务端响应外层仍然是标准 OpenAPI 包装
CLI 输出标准成功信封(判断成功用 `ok == true` 或退出码 0不要找 `code == 0`
```json
{
"code": 0,
"msg": "success",
"ok": true,
"identity": "user",
"data": {
"dimensions": [...],
"measures": [...],
"main_data": [...]
"dimensions": [
{
"field_name": "地区",
"alias": "dim_region"
}
],
"measures": [
{
"field_name": "销售额",
"alias": "me_sales"
}
],
"main_data": [
{
"dim_region": {
"value": "华东"
},
"me_sales": {
"value": 12345
}
}
]
}
}
```

View File

@@ -347,28 +347,51 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|------|------|------|------|
| `format` | string | 是 | 固定为 `"flat"`,表示返回扁平化的对象数组 |
## API 出参详情
## CLI 出参详情
**成功时**
**成功时**stdout判断成功用 `ok == true` 或退出码 0
```json
{"code": 0, "data": {"main_data": [{"dim_city": {"value": "北京"}, "total_amount": {"value": 12345.00}}, ...]}, "msg": ""}
{
"ok": true,
"identity": "user",
"data": {
"main_data": [
{
"dim_city": {
"value": "北京"
},
"total_amount": {
"value": 12345.00
}
},
{
"dim_city": {
"value": "上海"
},
"total_amount": {
"value": 6789.00
}
}
]
}
}
```
**失败时**
**失败时**stderr 类型化错误信封,非零退出码;`error.code` 是上游 API 错误码):
```json
{"code": 800004006, "data": {"error": {"code": 800004006, ...}}, "msg": "DSL validation failed"}
{"ok": false, "identity": "user", "error": {"type": "api", "subtype": "...", "code": 800004006, "message": "DSL validation failed", "hint": "..."}}
```
**Response 字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | int | 状态码0 为成功 |
| `msg` | string | 错误信息 |
| `ok` | bool | 是否成功 |
| `data.main_data` | []object | 查询结果数组,每个元素为一行数据 |
| `data.error` | object | 失败时的错误详情 |
| `error.code` | int | 失败时的上游 API 错误码 |
| `error.message` / `error.hint` | string | 失败原因与建议的恢复动作 |
每行数据的字段值封装在 CellValue 中:
@@ -387,7 +410,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
## 返回值
命令成功后输出 `data` 字段的内容
命令成功后,成功信封的 `data` 字段即查询结果
```json
{

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,7 +41,7 @@ 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

View File

@@ -54,7 +54,7 @@ lark-cli drive +member-add \
}
```
批量部分失败时,`partial``true`CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count``succeeded_count``members``missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
批量部分失败时,`partial``true`同一份结果以 `ok:false` 部分失败信封写到 **stdout**stderr 不再输出单独的错误信封CLI 以非零退出码结束。检查 `data` 中的 `requested_count``succeeded_count``members``missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
## 行为说明

View File

@@ -17,11 +17,11 @@
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error` |
`summary.failed > 0` 时命令以 **非零状态码**`exit=1``error.type=partial_failure`)退出,且同一份 `summary + items` `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
`summary.failed > 0` 时命令以 **非零状态码**`exit=1`)退出同一份 `summary + items` `ok:false` 部分失败信封写到 **stdout**(字段在 `data.summary` / `data.items`,另附 `data.note` 说明失败情况stderr 不再输出单独的错误信封;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation``error.subtype=failed_precondition``error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|
@@ -80,7 +80,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
- `--delete-local --yes`**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `ok:false` 部分失败结果非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
- 不传 `--delete-local``summary.deleted_local` 永远是 0命令对本地"多余"文件视而不见。

View File

@@ -15,15 +15,16 @@
| `summary.skipped` | 因 `--if-exists=skip``--if-exists=smart` 命中“无需传输”而跳过的文件数 |
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` |
| `summary.aborted` | 命中终止性错误并停止后续批处理时为 `true` |
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` / `hint` / `phase` / `error_class` / `code` / `subtype` / `retryable` |
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `already_deleted` / `failed` / `delete_failed`
> 本地目录(包括空目录)会被镜像到 Drive新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token不会重复 `create_folder`,也不会出现在 `items[]` 里。
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation``error.subtype=failed_precondition``error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|
@@ -95,6 +96,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**stderr 上有提示),命令以非零状态退出,远端不会被破坏。
- 删除阶段如果服务端返回 `1061007 file has been delete`,说明目标远端文件在本次 DELETE 前已经不存在;这已经满足 `--delete-remote` 的目标状态,输出会记为 `action: "already_deleted"`,不计入 `summary.failed`,也不计入 `summary.deleted_remote`
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
- 不传 `--delete-remote``summary.deleted_remote` 永远是 0命令对远端"多余"文件视而不见。
- 在线文档docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
@@ -110,22 +112,46 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
"uploaded": 0,
"skipped": 0,
"failed": 0,
"deleted_remote": 0
"deleted_remote": 0,
"aborted": false
},
"items": [
{"rel_path": "...", "file_token": "...", "action": "folder_created"},
{"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0},
{"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0},
{"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0},
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "...", "hint": "...", "phase": "upload", "error_class": "...", "code": 0, "subtype": "...", "retryable": false},
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
{"rel_path": "...", "file_token": "...", "action": "already_deleted"},
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "...", "hint": "...", "phase": "delete", "error_class": "...", "code": 0, "subtype": "...", "retryable": false}
]
}
```
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。
## 失败处理与 agent 行为
`+push` 的失败项带结构化字段agent 必须优先读 `items[].error_class` / `phase` / `code`,不要只看自然语言 `error` 文本。`summary.aborted=true` 表示命令已经遇到终止性错误并停止后续批处理;这时**不要原样重试**,先修复根因。
常见终止性错误:
| `error_class` | 常见 `code` | 含义 | Agent 应对 |
|---|---:|---|---|
| `app_scope_missing` | `99991672` | 应用身份缺少 Drive / 文件夹相关 scope | 停止重试,引导开通错误里列出的应用身份权限,例如 `space:folder:create``drive:drive` |
| `user_scope_missing` | `99991679` | 用户身份缺少授权 | 停止重试,走 `lark-cli auth login --scope ...` 补错误里列出的 scope |
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试检查目标文件夹权限、身份类型user / bot和资源可见性 |
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |
非终止但需要解释的状态:
- `file_size_limit` / `1061043`:文件超过 Drive 上传限制。不要继续尝试同一文件;改拆分或换存储方式。
- `upload_size_mismatch` / `1062009`:本地文件在上传过程中发生变化,或声明大小与实际读取大小不一致。重新扫描本地文件后再 push。
- `remote_not_found` / `1061007`:一般表示远端文件已不存在。删除阶段的 `1061007` 会被视为 `already_deleted` 成功项;其他阶段需重新列表确认远端状态。
## 性能注意
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。

View File

@@ -19,7 +19,7 @@
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path``+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token``type`、名称、大小和时间字段;其中 `created_time``modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
如果 Drive 中多个条目映射到同一个 `rel_path``+status` 会在下载/hash 前直接失败,在 stderr 返回类型化错误信封(`error.type=validation``error.subtype=failed_precondition``error.params[]` 每条的 `name` 是冲突的 `rel_path``reason` 枚举该路径下所有碰撞条目(`type` + `file_token`。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略`error.hint` 也给出了同样的恢复选项)
## 命令
@@ -76,20 +76,18 @@ lark-cli drive +status \
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "duplicate_remote_path",
"message": "multiple Drive entries map to the same rel_path",
"detail": {
"duplicates_remote": [
{
"rel_path": "dup.txt",
"entries": [
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
]
}
]
}
"type": "validation",
"subtype": "failed_precondition",
"message": "1 rel_path(s) map to multiple Drive entries",
"hint": "resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help",
"params": [
{
"name": "dup.txt",
"reason": "2 Drive entries collide here: file <full_file_token>, folder <folder_token>"
}
]
}
}
```

View File

@@ -79,7 +79,7 @@ metadata:
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `*.batch_trash`
3. 用户确认后 → `+message-trash --message-ids ... --yes`
## 身份选择:优先使用 user 身份
@@ -96,13 +96,14 @@ metadata:
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message``+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
9. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
10. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
11. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -119,6 +120,8 @@ metadata:
- 查看发送邮件后的投递状态发送成功后查看邮件投递状态也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
- 撤回已发送邮件撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
- 收信规则创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
- 分享邮件到 IM分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
@@ -192,7 +195,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 编辑转发草稿

View File

@@ -0,0 +1,48 @@
# mail +message-modify
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
## Common Commands
```bash
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
lark-cli mail +message-modify --message-ids <id> --add-folder archive
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.

View File

@@ -0,0 +1,41 @@
# mail +message-trash
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
## Common Commands
```bash
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.

View File

@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 相关命令

View File

@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 编辑回复草稿

View File

@@ -1,6 +1,6 @@
---
name: lark-markdown
version: 1.2.1
version: 1.2.2
description: "飞书 Markdown查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。"
metadata:
requires:
@@ -25,7 +25,8 @@ metadata:
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
- 用户要把本地 Markdown **导入成在线新版文档docx**,不要用本 skill改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
- `markdown +create` / `+overwrite` 命中 `missing scope``permission denied``not found``version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
- `markdown +create` / `+overwrite` 命中 `missing scope``permission denied``not found``quota_exceeded``version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate_limit``server_error` 或临时网络错误才做有限退避重试。
- `markdown +create` 的目标参数不要猜Drive 文件夹用 `--folder-token`Wiki 节点用 `--wiki-token`。如果用户给的是 URL可以直接传完整 URLCLI 会归一成 token。不要把 doc/sheet/wiki URL 放进 `--folder-token` 试错。
## 核心边界

View File

@@ -32,11 +32,21 @@ lark-cli markdown +create \
--folder-token fldcn_xxx \
--file ./README.md
# 创建到指定文件夹(可直接传 Drive folder URL
lark-cli markdown +create \
--folder-token "https://feishu.cn/drive/folder/fldcn_xxx" \
--file ./README.md
# 创建到指定 wiki 节点
lark-cli markdown +create \
--wiki-token wikcn_xxx \
--file ./README.md
# 创建到指定 wiki 节点(可直接传 wiki URL
lark-cli markdown +create \
--wiki-token "https://feishu.cn/wiki/wikcn_xxx" \
--file ./README.md
# 预览底层请求
lark-cli markdown +create \
--name README.md \
@@ -48,8 +58,8 @@ lark-cli markdown +create \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--folder-token` | 否 | 目标 Drive 文件夹 token`--wiki-token` 互斥;省略时创建到根目录 |
| `--wiki-token` | 否 | 目标 wiki 节点 token`--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
| `--folder-token` | 否 | 目标 Drive 文件夹 token 或 Drive folder URL;与 `--wiki-token` 互斥;省略时创建到根目录 |
| `--wiki-token` | 否 | 目标 wiki 节点 token 或 wiki URL;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file``-`stdin |
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
@@ -58,6 +68,8 @@ lark-cli markdown +create \
- `--content``--file` 必须二选一
- `--folder-token``--wiki-token` 互斥
- `--folder-token` 只能是 Drive 文件夹;不要传 wiki/doc/sheet/base/file token 或 URL
- `--wiki-token` 只能是 Wiki 节点;如果只有 docx/sheet/base 等文档 URL先用 `lark-cli wiki +node-get --node-token <url>` 解析出 `node_token`
- `--name` 必须带 `.md` 后缀
- `--file` 指向的本地文件名也必须带 `.md` 后缀
-`--wiki-token` 时,返回值中不会附带 `/file/<token>` URL因为 wiki 承载文件没有稳定的独立 file URL
@@ -88,6 +100,14 @@ lark-cli markdown +create \
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
## 失败处理
- `not_found` / `1061044`:父目录或 wiki 节点不存在,或 token 类型放错参数。修正 `--folder-token` / `--wiki-token` 后再试,不要重复提交同一参数。
- `quota_exceeded` / `1061101`:目标存储空间配额已满。释放空间、换父目录/节点或请管理员扩容后再试。
- `permission_denied` / `missing_scope`:区分身份处理。`--as user` 看用户授权和目标 ACL`--as bot` 看应用 scope 与目标目录/节点 ACL。
- `rate_limit`:停止立即重试,使用退避。
- `server_error` / `233523001`:可以稍后有限重试;若重复出现,保留 `log_id` / request id 给服务端排查。
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览

View File

@@ -120,9 +120,9 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`
**无编辑权限**:若 CLI 返回稳定字段 `error.subtype=permission_denied`,且 `error.code``40005``+todo` / `+word-replace` 等编辑接口)或 `2091005`(如标题更新),表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope``error.message` 只作人读说明,不要用它做分支判断。
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回稳定字段 `error.code=40001``error.subtype=not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。`error.message` 只作人读说明,不要用它做分支判断。
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。

View File

@@ -126,8 +126,8 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
| 未指定操作 | 单条模式传 `--operation`,或批量传 `--todos` |
| `--todos` 与单条 flags 冲突 | 二选一 |
| `todos[i]` 校验失败 | 检查该条 `operation` 与字段组合 |
| `error.type` = `no_edit_permission` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` |
| 缺少 OAuth scope`permission_violations``minutes:minutes:update` | `lark-cli auth login --scope "minutes:minutes:update"` |
| `error.code=40005` `error.subtype=permission_denied` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope``error.message` 只作人读说明 |
| 缺少 OAuth scope`error.missing_scopes``minutes:minutes:update` | `lark-cli auth login --scope "minutes:minutes:update"` |
## 参考

View File

@@ -69,7 +69,7 @@ LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli a
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
错误响应中包含关键信息:
- `permission_violations`:列出缺失的 scope (N选1)
- `missing_scopes`:列出缺失的 scope (N选1)
- `console_url`:飞书开发者后台的权限配置链接
- `hint`:建议的修复命令
@@ -178,22 +178,22 @@ lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门
```json
{
"ok": false,
"identity": "bot",
"error": {
"type": "confirmation_required",
"type": "confirmation",
"subtype": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": {
"level": "high-risk-write",
"action": "drive +delete"
}
"risk": "high-risk-write",
"action": "drive +delete"
}
}
```
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation"``error.subtype == "confirmation_required"`
2. **向用户确认**:把 `error.action``error.risk` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁

View File

@@ -65,15 +65,15 @@ lark-cli slides xml_presentations get --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 3,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
},
"msg": "success"
}
}
```
@@ -94,12 +94,12 @@ lark-cli slides xml_presentation.slide create --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"slide_id": "slide_example_id",
"revision_id": 100
},
"msg": "success"
}
}
```
@@ -116,11 +116,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"revision_id": 101
},
"msg": "success"
}
}
```

View File

@@ -66,7 +66,8 @@ lark-cli slides +screenshot --as user \
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"output_dir": ".lark-slides/screenshots",
@@ -79,8 +80,7 @@ lark-cli slides +screenshot --as user \
"size": 12345
}
]
},
"msg": "success"
}
}
```

View File

@@ -141,12 +141,12 @@ lark-cli slides xml_presentation.slide create --as user \
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"slide_id": "slide_example_id",
"revision_id": 100
},
"msg": "success"
}
}
```

View File

@@ -61,11 +61,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presenta
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"revision_id": 100
},
"msg": "success"
}
}
```

View File

@@ -65,15 +65,15 @@ lark-cli slides xml_presentation.slide get --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"slide": {
"slide_id": "slide_example_id",
"content": "<slide id=\"slide_example_id\"><style/><data>...</data></slide>"
},
"revision_id": 100
},
"msg": "success"
}
}
```

View File

@@ -130,24 +130,28 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"revision_id": 105
},
"msg": "success"
}
}
```
### 失败(任一 part 失败,整批不生效)
失败时返回非零错误码(如 3350001。若后端能定位失败的 part`data` 中可能附带:
失败时命令以非零退出码结束stderr 返回类型化错误信封(`error.type` / `error.subtype` / `error.code`(如 3350001/ `error.message` / `error.hint`)。这个普通写命令的失败路径不会在 stdout 额外打印后端原始响应;脚本和 agent 应以退出码与 stderr 信封为准。
```json
{
"code": 3350001,
"data": {
"failed_part_index": 0,
"failed_reason": "block not found"
"ok": false,
"identity": "user",
"error": {
"type": "api",
"subtype": "...",
"code": 3350001,
"message": "...",
"hint": "..."
}
}
```
@@ -155,8 +159,10 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.revision_id` | integer | 成功时返回更新后最新版本号 |
| `data.failed_part_index` | integer | 失败的 part 在 `parts` 数组中的索引(从 0 起) |
| `data.failed_reason` | string | 失败原因 |
| `error.code` | integer | 失败时的上游 API 错误码 |
| `error.subtype` | string | 类型化错误子类,脚本可与 `error.code` 一起判断恢复动作 |
| `error.message` | string | 失败原因的人读说明,脚本不要用它做分支判断 |
| `error.hint` | string | 建议的恢复动作 |
## 常见错误

View File

@@ -56,15 +56,15 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 1,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
},
"msg": "success"
}
}
```

View File

@@ -18,7 +18,7 @@ metadata:
## 内测提示
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `missing_scopes`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围。
- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`
## 定位
@@ -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
```
如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查:
@@ -167,7 +170,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
## 应用身份权限配置检查
应用身份 `--as bot``no permission``missing required scope(s)``permission_violations``ErrNotInGray``20017` 时,不要引导用户执行 `auth login`。按顺序检查:
应用身份 `--as bot``no permission``missing required scope(s)``missing_scopes``ErrNotInGray``20017` 时,不要引导用户执行 `auth login`。按顺序检查:
1. 以 CLI 返回的 metadata / error envelope 为准,确认提示的 VC Agent 相关权限已开通。常见读取 active meeting / events 需要会中事件读取权限;应用机器人入会 / 离会需要 bot 入会写权限。
2. 应用已发布并安装到当前租户。

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

@@ -1,6 +1,6 @@
---
name: lark-wiki
version: 1.0.1
version: 1.0.2
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责上传文件到知识库节点下走 lark-drive、编辑文档/表格/Base 内容(走 lark-doc / lark-sheets / lark-base。"
metadata:
requires:
@@ -34,6 +34,8 @@ metadata:
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`
- 用户要列出 Wiki 节点:先用 `wiki +space-list --as user` 拿数字 `space_id`,再用 `wiki +node-list --space-id <space_id>`。不要把 wiki URL、node token、doc token、名称直接当 `--space-id`。钻子节点时 `--parent-node-token` 必须是 wiki node token如果用户给的是 docx/sheet/base URL先用 `wiki +node-get --node-token <url>` 解析出 `node_token`
- `wiki +node-list` 命中 `invalid_parameters``not_found``permission_denied` 时,不要重复调用同一参数;按 hint 修 `space_id` / `parent_node_token` / 权限。只有 `rate_limit` 才做退避重试。
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门 / 应用”四类之一,再决定 `--member-type`,不要先调 `wiki +member-add` 再根据报错反推类型。
- 用户说“部门 + bot”这是已知不支持路径。不要继续尝试 `wiki +member-add --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
- 用户说“用户 / 群 / 应用 + 添加成员”:先解析对应 ID再执行 `wiki +member-add`

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

@@ -11,6 +11,9 @@ lark-cli wiki +node-list --space-id <SPACE_ID>
# Drill into a sub-directory (still single page by default)
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token <NODE_TOKEN>
# Drill with a wiki URL (CLI normalizes /wiki/<token> to node_token)
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token "https://feishu.cn/wiki/wikcn_xxx"
# Personal document library (user identity only)
lark-cli wiki +node-list --space-id my_library --as user
@@ -31,8 +34,8 @@ lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) |
| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root |
| `--space-id` | string | **Yes** | — | Numeric wiki space ID. Use `my_library` for personal document library (user only) |
| `--parent-node-token` | string | No | — | Parent wiki node token, or a `/wiki/<token>` URL; omit to list the space root |
| `--page-size` | int | No | 50 | Page size, 1-50 |
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
@@ -82,6 +85,10 @@ lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikc
## Notes
- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront.
- `--space-id` is a numeric wiki `space_id`. Do not pass a wiki URL, wiki node token, document token, or title. Use `lark-cli wiki +space-list --as user` to discover it.
- `--parent-node-token` must resolve to a wiki node token. If you have a docx/sheet/base/file URL, first run `lark-cli wiki +node-get --node-token <url>` and use the returned `node_token`.
- Treat `invalid_parameters` (`space_id is not int`, `invalid page_token`), `not_found` (`node not found by parent node token`), and `permission_denied` as terminal for the current arguments. Fix the argument or permission before retrying.
- For `rate_limit`, stop immediate retries and retry later with exponential backoff or a smaller `--page-limit`.
## Required Scope

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

@@ -24,7 +24,17 @@ import (
const EnvBinaryPath = "LARK_CLI_BIN"
const projectRootMarkerDir = "tests"
const cliBinaryName = "lark-cli"
const CleanupTimeout = 30 * time.Second
const (
// CleanupTimeout is the outer teardown budget. Keep it above any
// per-resource wait so cleanup command retries still have room to run.
CleanupTimeout = 60 * time.Second
defaultRetryAttempts = 4
defaultRetryInitialDelay = time.Second
defaultRetryMaxDelay = 6 * time.Second
defaultRetryBackoffMultiple = 2
)
func SkipWithoutUserToken(t *testing.T) {
t.Helper()
@@ -102,6 +112,34 @@ type Result struct {
RunErr error
}
type cleanupWarningError struct {
err error
}
func (e *cleanupWarningError) Error() string {
return e.err.Error()
}
func (e *cleanupWarningError) Unwrap() error {
return e.err
}
// CleanupWarning marks a cleanup verification issue as non-fatal after the
// destructive cleanup command itself has already succeeded.
func CleanupWarning(err error) error {
if err == nil {
return nil
}
return &cleanupWarningError{err: err}
}
// IsCleanupWarning reports whether err should be logged without failing the
// parent test.
func IsCleanupWarning(err error) bool {
var warning *cleanupWarningError
return errors.As(err, &warning)
}
// RetryOptions configures retry behavior for flaky external API calls.
type RetryOptions struct {
Attempts int
@@ -111,8 +149,25 @@ type RetryOptions struct {
ShouldRetry func(*Result) bool
}
// WaitOptions configures a bounded poll loop for eventually consistent cleanup
// or verification checks.
type WaitOptions struct {
Timeout time.Duration
Interval time.Duration
TimeoutError func() error
}
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
// Service errors that return {"error":{"retryable":true}} are retried with
// bounded exponential backoff so individual tests do not need to remember
// RunCmdWithRetry for normal transient server contention.
func RunCmd(ctx context.Context, req Request) (*Result, error) {
return RunCmdWithRetry(ctx, req, RetryOptions{
ShouldRetry: ResultHasRetryableError,
})
}
func runCmdOnce(ctx context.Context, req Request) (*Result, error) {
binaryPath, err := ResolveBinaryPath(req)
if err != nil {
return nil, err
@@ -186,16 +241,16 @@ func buildCommandEnv(req Request) []string {
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {
opts.Attempts = 4
opts.Attempts = defaultRetryAttempts
}
if opts.InitialDelay <= 0 {
opts.InitialDelay = 1 * time.Second
opts.InitialDelay = defaultRetryInitialDelay
}
if opts.MaxDelay <= 0 {
opts.MaxDelay = 6 * time.Second
opts.MaxDelay = defaultRetryMaxDelay
}
if opts.BackoffMultiple <= 1 {
opts.BackoffMultiple = 2
opts.BackoffMultiple = defaultRetryBackoffMultiple
}
if opts.ShouldRetry == nil {
opts.ShouldRetry = func(result *Result) bool {
@@ -206,7 +261,7 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
delay := opts.InitialDelay
var lastResult *Result
for attempt := 1; attempt <= opts.Attempts; attempt++ {
result, err := RunCmd(ctx, req)
result, err := runCmdOnce(ctx, req)
if err != nil {
return nil, err
}
@@ -234,6 +289,63 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
return lastResult, nil
}
// ResultHasRetryableError reports whether lark-cli returned a structured
// service error with error.retryable=true in either output stream.
func ResultHasRetryableError(result *Result) bool {
if result == nil {
return false
}
return rawHasRetryableError(result.Stdout) || rawHasRetryableError(result.Stderr)
}
func rawHasRetryableError(raw string) bool {
payload := extractJSONPayload(raw)
if payload == "" {
return false
}
return gjson.Get(payload, "error.retryable").Bool()
}
// WaitForCondition polls condition until it returns true, an error, the context
// is canceled, or the configured timeout expires.
func WaitForCondition(ctx context.Context, opts WaitOptions, condition func() (bool, error)) error {
if condition == nil {
return errors.New("wait condition is nil")
}
if opts.Timeout <= 0 {
opts.Timeout = CleanupTimeout
}
if opts.Interval <= 0 {
opts.Interval = time.Second
}
deadline := time.NewTimer(opts.Timeout)
defer deadline.Stop()
ticker := time.NewTicker(opts.Interval)
defer ticker.Stop()
for {
done, err := condition()
if err != nil {
return err
}
if done {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
if opts.TimeoutError != nil {
return opts.TimeoutError()
}
return fmt.Errorf("condition still false after %s", opts.Timeout)
case <-ticker.C:
}
}
}
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
func GenerateSuffix() string {
now := time.Now().UTC()
@@ -251,6 +363,10 @@ func ReportCleanupFailure(parentT *testing.T, prefix string, result *Result, err
parentT.Helper()
if err != nil {
if IsCleanupWarning(err) {
parentT.Logf("%s: %v", prefix, err)
return
}
parentT.Errorf("%s: %v", prefix, err)
return
}
@@ -271,26 +387,11 @@ func isCleanupSuppressedResult(result *Result) bool {
return false
}
raw := strings.TrimSpace(result.Stdout)
if raw == "" {
raw = strings.TrimSpace(result.Stderr)
payload := extractJSONPayload(result.Stdout)
if payload == "" {
payload = extractJSONPayload(result.Stderr)
}
if raw == "" {
return false
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return false
}
payload := raw[start:]
if !gjson.Valid(payload) {
if payload == "" {
return false
}
@@ -306,6 +407,32 @@ func isCleanupSuppressedResult(result *Result) bool {
return errType == "api_error" && (errCode == 800004135 || strings.Contains(errMessage, " limited"))
}
func extractJSONPayload(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if gjson.Valid(raw) {
return raw
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return ""
}
payload := raw[start:]
if !gjson.Valid(payload) {
return ""
}
return payload
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {

View File

@@ -5,10 +5,12 @@ package clie2e
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -223,6 +225,88 @@ func TestRunCmd(t *testing.T) {
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "2\n", string(countBytes))
})
t.Run("does not retry non-retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"always-non-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestRunCmdWithRetry(t *testing.T) {
t.Run("does not include RunCmd default retry as a nested retry", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmdWithRetry(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
}, RetryOptions{
Attempts: 1,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
ShouldRetry: ResultHasRetryableError,
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestWaitForCondition(t *testing.T) {
t.Run("polls until condition succeeds", func(t *testing.T) {
attempts := 0
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: 50 * time.Millisecond,
Interval: time.Millisecond,
}, func() (bool, error) {
attempts++
return attempts == 2, nil
})
require.NoError(t, err)
assert.Equal(t, 2, attempts)
})
t.Run("returns custom timeout error", func(t *testing.T) {
wantErr := errors.New("still visible")
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: time.Millisecond,
Interval: time.Millisecond,
TimeoutError: func() error { return wantErr },
}, func() (bool, error) {
return false, nil
})
assert.ErrorIs(t, err, wantErr)
})
}
type fakeCLI struct {
@@ -260,6 +344,35 @@ if [ "$1" = "emit-stdin" ]; then
exit 0
fi
if [ "$1" = "fail-once-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
if [ "$count" -eq 1 ]; then
echo "Deleting folder fake..." >&2
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
exit 1
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "always-non-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
echo '{"ok":false,"error":{"type":"api","code":123,"message":"validation failed","retryable":false}}' >&2
exit 1
fi
exit_code=0
while [ "$#" -gt 0 ]; do
case "$1" in

View File

@@ -21,7 +21,7 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
package docs
import (
"context"
@@ -23,7 +23,7 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
Args: []string{
"docs", "+fetch",
"--doc", "doxcnDryRunCompat",
"--api-version", "legacy",
"--api-version", "v1",
"--dry-run",
},
DefaultAs: "bot",

View File

@@ -9,7 +9,7 @@
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.

Some files were not shown because too many files have changed in this diff Show More