Compare commits

...

14 Commits

Author SHA1 Message Date
dc-bytedance
29b0fe6751 style: use English comments in remote scopes source 2026-07-08 16:54:25 +08:00
dc-bytedance
61c6e1cc40 test: sanitize token placeholders and add legalDomainsFor coverage 2026-07-08 16:54:25 +08:00
dc-bytedance
a5bd310d7c refactor: drop service description getters orphaned by interactive page removal 2026-07-08 16:54:23 +08:00
dc-bytedance
20c2a2d0f9 feat: pass through status_message from authorization response 2026-07-08 15:35:42 +08:00
dc-bytedance
b65146ef2c refactor: remove dead auto-approve filter chain 2026-07-08 15:35:42 +08:00
dc-bytedance
8b53da5c6f feat: use remote scopes for login and drop interactive page 2026-07-08 15:35:42 +08:00
dc-bytedance
520ff2263e feat: add remote scopes.json fetch with binary validation 2026-07-08 15:35:41 +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
85 changed files with 4550 additions and 1046 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

@@ -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

@@ -49,6 +49,9 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Short: "Device Flow authorization login",
Long: `Device Flow authorization login.
With no --scope/--domain/--recommend flag, this requests scopes for all known
business domains (equivalent to --domain all); pass --domain or --scope to narrow it.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
@@ -71,7 +74,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmdutil.SetRisk(cmd, "write")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request scopes for all known domains (equivalent to --domain all)")
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
@@ -144,33 +147,6 @@ func authLoginRun(opts *LoginOptions) error {
}
selectedDomains := opts.Domains
scopeLevel := "" // "common" or "all" (from interactive mode)
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains(config.Brand)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains(config.Brand)
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
}
available := make([]string, 0, len(knownDomains))
for k := range knownDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
}
}
}
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
@@ -178,30 +154,51 @@ func authLoginRun(opts *LoginOptions) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
}
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
if err != nil {
return err
// scopeOnly is the one path that must never touch the domain catalog
// (remote or local): --scope given alone, with neither --domain nor
// --recommend. Every other path — including bare `auth login`, now that
// the interactive picker is gone — needs the legal domain set to resolve
// scopes.
scopeOnly := opts.Scope != "" && !opts.Recommend && len(selectedDomains) == 0
var remote map[string][]string
var remoteOK bool
if !scopeOnly {
// Pull the remote scopes.json once for this login (not cached); any
// read failure (network/timeout/non-2xx/malformed) silently falls back
// to the local full computation — no warning, no telemetry.
remote, remoteOK = larkauth.FetchRemoteScopes(config.Brand)
legalDomains, allLegalDomains := legalDomainsFor(remote, remoteOK, config.Brand)
// Expand --domain all against the resolved legal domain set.
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = allLegalDomains
break
}
if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
}
if len(selectedDomains) > 0 {
// Validate explicitly-supplied domain names and suggest corrections.
for _, d := range selectedDomains {
if !legalDomains[d] {
if suggestion := suggestDomain(d, legalDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
}
available := make([]string, 0, len(legalDomains))
for k := range legalDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
}
}
selectedDomains = result.Domains
scopeLevel = result.ScopeLevel
} else {
log(msg.HintHeader)
log("Common options:")
log(msg.HintCommon1)
log(msg.HintCommon2)
log(msg.HintCommon3)
log(msg.HintCommon4)
log("")
log("View all options:")
log(msg.HintFooter)
log("")
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
// Bare `auth login` and `--recommend` without `--domain` both span
// the full legal domain set now that the interactive picker and
// the local auto-approve filter are gone (--recommend ≡ --domain all).
selectedDomains = allLegalDomains
}
}
@@ -215,19 +212,8 @@ func authLoginRun(opts *LoginOptions) error {
// --scope, --domain, and --recommend combine additively so callers can,
// for example, request all `docs` scopes plus a few specific `drive`
// scopes in a single command.
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
}
// Filter to auto-approve scopes if --recommend or interactive "common"
if opts.Recommend || scopeLevel == "common" {
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
}
if len(selectedDomains) > 0 {
candidateScopes := resolveScopesForDomains(selectedDomains, remote, remoteOK, config.Brand)
if len(candidateScopes) == 0 && opts.Scope == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
@@ -382,10 +368,10 @@ func authLoginRun(opts *LoginOptions) error {
}
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, result.Token.StatusMessage)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, result.Token.StatusMessage)
return nil
}
@@ -465,10 +451,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
}
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, result.Token.StatusMessage)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, result.Token.StatusMessage)
return nil
}
@@ -550,6 +536,30 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
return result
}
// resolveScopesForDomains resolves the scope set for the given domains. When
// the remote scopes.json is available it takes the union of each domain's
// user_scopes from the remote result (remote is authoritative, including
// domains this CLI build doesn't know about locally); otherwise it falls back
// to the local synthesis via collectScopesForDomains. Always returns a
// deduplicated, alphabetically sorted slice.
func resolveScopesForDomains(domains []string, remote map[string][]string, remoteOK bool, brand core.LarkBrand) []string {
if remoteOK {
set := make(map[string]bool)
for _, d := range domains {
for _, s := range remote[d] {
set[s] = true
}
}
out := make([]string, 0, len(set))
for s := range set {
out = append(out, s)
}
sort.Strings(out)
return out
}
return collectScopesForDomains(domains, "user", brand)
}
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
@@ -582,6 +592,25 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
return domains
}
// legalDomainsFor returns the authoritative domain set for this login: the
// remote scopes.json keys when available (a remote-listed domain unknown to
// this CLI build is still legal), otherwise the local known-domain set.
// Returns both a membership set (for --domain validation) and a sorted slice
// (for `all` expansion and the bare-login/--recommend-without-domain default).
func legalDomainsFor(remote map[string][]string, remoteOK bool, brand core.LarkBrand) (map[string]bool, []string) {
if remoteOK {
set := make(map[string]bool, len(remote))
sorted := make([]string, 0, len(remote))
for d := range remote {
set[d] = true
sorted = append(sorted, d)
}
sort.Strings(sorted)
return set, sorted
}
return allKnownDomains(brand), sortedKnownDomains(brand)
}
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
// Empty AuthTypes defaults to ["user"].
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {

View File

@@ -1,188 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
)
// domainMeta describes a domain for the interactive selector.
type domainMeta struct {
Name string
Title string
Description string
}
// interactiveResult holds the user's selections from the interactive form.
type interactiveResult struct {
Domains []string
ScopeLevel string // "common" or "all"
}
// getDomainMetadata returns metadata for all known domains, sorted by name.
func getDomainMetadata(lang string) []domainMeta {
seen := make(map[string]bool)
var domains []domainMeta
// 1. Domains from from_meta projects (skip domains with auth_domain)
for _, project := range registry.ListFromMetaProjects() {
if registry.HasAuthDomain(project) {
seen[project] = true
continue
}
dm := buildDomainMeta(project, lang)
domains = append(domains, dm)
seen[project] = true
}
// 2. Shortcut-only domains
shortcutOnlyNames := getShortcutOnlyDomainNames()
for _, name := range shortcutOnlyNames {
if !seen[name] {
dm := buildDomainMeta(name, lang)
domains = append(domains, dm)
seen[name] = true
}
}
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
// (skip domains with auth_domain — they are folded into their parent)
shortcutOnlySet := make(map[string]bool)
for _, n := range shortcutOnlyNames {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
dm := buildDomainMeta(sc.Service, lang)
domains = append(domains, dm)
}
seen[sc.Service] = true
}
}
sort.Slice(domains, func(i, j int) bool {
return domains[i].Name < domains[j].Name
})
return domains
}
// buildDomainMeta constructs a domainMeta for a given service name and language.
// It reads from the service_descriptions.json config first, falling back to
// from_meta spec fields if not found.
func buildDomainMeta(name, lang string) domainMeta {
title := registry.GetServiceTitle(name, lang)
desc := registry.GetServiceDetailDescription(name, lang)
if title != "" || desc != "" {
return domainMeta{
Name: name,
Title: title,
Description: desc,
}
}
// Fallback: read from the typed service spec (legacy)
dm := domainMeta{Name: name}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
}
return dm
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
options := make([]huh.Option[string], len(allDomains))
for i, dm := range allDomains {
var label string
switch {
case dm.Title != "" && dm.Description != "":
label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description)
case dm.Title != "":
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title)
default:
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description)
}
options[i] = huh.NewOption(label, dm.Name)
}
var selectedDomains []string
var permLevel string
// Phase 1a: domain selection
// Phase 1b: permission level (shown after domain selection completes)
form1 := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title(msg.SelectDomains).
Description(msg.DomainHint).
Options(options...).
Value(&selectedDomains).
Validate(func(s []string) error {
if len(s) == 0 {
return fmt.Errorf(msg.ErrNoDomain)
}
return nil
}),
),
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.PermLevel).
Options(
huh.NewOption(msg.PermCommon, "common"),
huh.NewOption(msg.PermAll, "all"),
).
Value(&permLevel),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form1.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if len(selectedDomains) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user", brand)
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}
// Print summary
permLabel := msg.PermAllLabel
if permLevel == "common" {
permLabel = msg.PermCommonLabel
}
fmt.Fprintf(ios.ErrOut, msg.Summary)
fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", "))
fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel)
scopePreview := strings.Join(scopes, ", ")
if len(scopePreview) > 80 {
scopePreview = strings.Join(scopes[:3], ", ") + ", ..."
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,
}, nil
}

View File

@@ -6,21 +6,6 @@ package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
DomainHint string
PermLevel string
PermCommon string
PermAll string
Summary string
SummaryDomains string
SummaryPerm string
SummaryScopes string
PermAllLabel string
PermCommonLabel string
ErrNoDomain string
ConfirmAuth string
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
@@ -34,31 +19,9 @@ type loginMsg struct {
NewlyGrantedScopes string
NoScopes string
StatusHint string
// Non-interactive hint (no flags)
HintHeader string
HintCommon1 string
HintCommon2 string
HintCommon3 string
HintCommon4 string
HintFooter string
}
var loginMsgZh = &loginMsg{
SelectDomains: "选择要授权的业务域",
DomainHint: "空格=选择, 回车=确认",
PermLevel: "权限类型",
PermCommon: "常用权限",
PermAll: "全部权限",
Summary: "\n摘要:\n",
SummaryDomains: " 域: %s\n",
SummaryPerm: " 权限: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "全部权限",
PermCommonLabel: "常用权限",
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改包括 URL 编码/解码、添加空格或标点)。",
@@ -71,30 +34,9 @@ var loginMsgZh = &loginMsg{
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
HintCommon2: " --domain all 授权所有已知域的权限",
HintCommon3: " --domain calendar,task 授权日历和任务域的权限",
HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限",
HintFooter: " lark-cli auth login --help",
}
var loginMsgEn = &loginMsg{
SelectDomains: "Select domains to authorize",
DomainHint: "Space=toggle, Enter=confirm",
PermLevel: "Permission level",
PermCommon: "Common scopes",
PermAll: "All scopes",
Summary: "\nSummary:\n",
SummaryDomains: " Domains: %s\n",
SummaryPerm: " Level: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "All scopes",
PermCommonLabel: "Common scopes",
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
@@ -107,13 +49,6 @@ var loginMsgEn = &loginMsg{
NewlyGrantedScopes: " Newly granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",
HintCommon2: " --domain all authorize all known domain scopes",
HintCommon3: " --domain calendar,task authorize calendar and task scopes",
HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes",
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
@@ -123,10 +58,3 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
}
return loginMsgZh
}
// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -17,8 +17,8 @@ func TestGetLoginMsg_Zh(t *testing.T) {
if msg != loginMsgZh {
t.Error("expected zh message set")
}
if msg.SelectDomains != "选择要授权的业务域" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
if msg.OpenURL != "在浏览器中打开以下链接进行认证:\n\n" {
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
}
}
@@ -27,8 +27,8 @@ func TestGetLoginMsg_En(t *testing.T) {
if msg != loginMsgEn {
t.Error("expected en message set")
}
if msg.SelectDomains != "Select domains to authorize" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
if msg.OpenURL != "Open this URL in your browser to authenticate:\n\n" {
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
}
}
@@ -77,24 +77,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
if got == msg.AuthorizedUser {
t.Errorf("%s AuthorizedUser has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {
t.Errorf("%s SummaryDomains has no format verb", lang)
}
// SummaryPerm should contain %s
got = fmt.Sprintf(msg.SummaryPerm, "all")
if got == msg.SummaryPerm {
t.Errorf("%s SummaryPerm has no format verb", lang)
}
// SummaryScopes should contain %d and %s
got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c")
if got == msg.SummaryScopes {
t.Errorf("%s SummaryScopes has no format verb", lang)
}
}
}

View File

@@ -140,13 +140,15 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
}
// writeLoginSuccess emits the successful login payload in either JSON or text
// format together with the computed scope breakdown.
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
// format together with the computed scope breakdown. statusMessage is the
// authorization response's status_message text (e.g. pending-approval),
// passed through verbatim into the JSON payload; text mode does not render it.
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary, statusMessage string) {
if summary == nil {
summary = &loginScopeSummary{}
}
if opts.JSON {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil, statusMessage))
fmt.Fprintln(f.IOStreams.Out, string(b))
return
}
@@ -161,14 +163,17 @@ func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, op
// handleLoginScopeIssue prints or returns a structured missing-scope result
// while preserving a successful login outcome when authorization completed.
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
// statusMessage is the authorization response's status_message text, passed
// through into the JSON payload when authorization actually succeeded
// (partial grant); it is unused on the failed-login path.
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName, statusMessage string) error {
if issue == nil {
return nil
}
loginSucceeded := openId != ""
if opts.JSON {
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue, statusMessage))
fmt.Fprintln(f.IOStreams.Out, string(b))
return output.ErrBare(output.ExitAuth)
}
@@ -198,7 +203,13 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
// authorizationCompletePayload builds the JSON payload for a completed login,
// optionally attaching a warning when requested scopes are missing.
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
// statusMessage is the authorization response's status_message text (e.g.
// "user hasn't chosen yet" / "tenant doesn't allow this" / "pending
// approval"), passed through verbatim — the CLI does not parse, classify, or
// truncate it. The key is always present; an empty string means
// the upstream response carried no message, matching the stable-shape
// convention used by the other summary fields above.
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue, statusMessage string) map[string]interface{} {
if summary == nil {
summary = &loginScopeSummary{}
}
@@ -212,6 +223,7 @@ func authorizationCompletePayload(openId, userName string, summary *loginScopeSu
"already_granted": emptyIfNil(summary.AlreadyGranted),
"missing": emptyIfNil(summary.Missing),
"granted": emptyIfNil(summary.Granted),
"status_message": statusMessage,
}
if issue != nil {
payload["warning"] = map[string]interface{}{

View File

@@ -40,6 +40,7 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
},
"", // openId empty -> loginSucceeded = false
"tester",
"", // statusMessage unused on the failed-login path
)
if err == nil {
@@ -59,3 +60,24 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
}
}
// TestAuthorizationCompletePayload_StatusMessage asserts that the
// authorization response's status_message text is passed through into the
// JSON payload verbatim (CLI does not parse/classify/truncate it), and
// that the field is always present, using an empty string when there is no
// message, consistent with the stable-output-shape convention already used
// by "scope" and the other summary fields.
func TestAuthorizationCompletePayload_StatusMessage(t *testing.T) {
summary := &loginScopeSummary{Granted: []string{"a:b:c"}}
p := authorizationCompletePayload("ou_x", "u", summary, nil, "审批中,请等待管理员处理")
if p["status_message"] != "审批中,请等待管理员处理" {
t.Fatalf("status_message = %v", p["status_message"])
}
// No message from upstream -> stable empty string, not an absent key.
p2 := authorizationCompletePayload("ou_x", "u", summary, nil, "")
if p2["status_message"] != "" {
t.Fatalf("empty status_message should be \"\", got %v", p2["status_message"])
}
}

View File

@@ -9,7 +9,7 @@ import (
"errors"
"io"
"net/http"
"slices"
"reflect"
"sort"
"strings"
"testing"
@@ -202,25 +202,6 @@ func TestSortedKnownDomains(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
for _, name := range getShortcutOnlyDomainNames() {
zhDesc := registry.GetServiceDescription(name, "zh")
enDesc := registry.GetServiceDescription(name, "en")
if zhDesc == "" {
t.Errorf("missing zh description for shortcut-only domain %q", name)
}
if enDesc == "" {
t.Errorf("missing en description for shortcut-only domain %q", name)
}
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
@@ -260,75 +241,82 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
}
}
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
func TestResolveScopesForDomains_RemoteUsed(t *testing.T) {
remote := map[string][]string{
"im": {"im:message:send", "im:chat:read"},
"docs": {"docs:doc:read"},
}
// from_meta projects must be present
for _, p := range registry.ListFromMetaProjects() {
if !nameSet[p] {
t.Errorf("from_meta project %q missing from getDomainMetadata", p)
}
got := resolveScopesForDomains([]string{"im"}, remote, true, core.BrandFeishu)
want := []string{"im:chat:read", "im:message:send"} // deduped, ascending by sort.Strings
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
func TestResolveScopesForDomains_UnionAcrossDomains(t *testing.T) {
remote := map[string][]string{
"im": {"im:message:send"},
"docs": {"docs:doc:read"},
}
for _, name := range getShortcutOnlyDomainNames() {
if !nameSet[name] {
t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name)
}
got := resolveScopesForDomains([]string{"im", "docs"}, remote, true, core.BrandFeishu)
want := []string{"docs:doc:read", "im:message:send"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestGetDomainMetadata_Sorted(t *testing.T) {
domains := getDomainMetadata("zh")
for i := 1; i < len(domains); i++ {
if domains[i].Name < domains[i-1].Name {
t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name)
}
func TestResolveScopesForDomains_FallbackToLocal(t *testing.T) {
// remoteOK=false -> falls back to local collectScopesForDomains; im must yield non-empty local scopes
got := resolveScopesForDomains([]string{"im"}, nil, false, core.BrandFeishu)
if len(got) == 0 {
t.Fatal("fallback should return non-empty local scopes for im")
}
}
func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Title == "" {
t.Errorf("domain %q has empty Title", dm.Name)
func TestLegalDomainsFor_RemoteUsed(t *testing.T) {
// includes "newbiz", a domain unknown to this CLI build, verifying a remote-listed domain is still legal
remote := map[string][]string{
"im": {"im:message:send"},
"docs": {"docs:doc:read"},
"newbiz": {"newbiz:thing:read"},
}
set, sorted := legalDomainsFor(remote, true, core.BrandFeishu)
wantSorted := []string{"docs", "im", "newbiz"} // remote keys, ascending by sort.Strings
if !reflect.DeepEqual(sorted, wantSorted) {
t.Fatalf("sorted = %v, want %v", sorted, wantSorted)
}
if len(set) != len(wantSorted) {
t.Fatalf("set size = %d, want %d", len(set), len(wantSorted))
}
for _, d := range wantSorted {
if !set[d] {
t.Errorf("set missing domain %q", d)
}
}
if !set["newbiz"] {
t.Error("remote-listed domain unknown to this build should still be legal")
}
}
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
// TestFactory has IsTerminal=false by default
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
err := authLoginRun(opts)
if err == nil {
t.Fatal("expected error for non-terminal without flags")
func TestLegalDomainsFor_FallbackToLocal(t *testing.T) {
// remoteOK=false -> falls back to local allKnownDomains/sortedKnownDomains
set, sorted := legalDomainsFor(nil, false, core.BrandFeishu)
if len(sorted) == 0 {
t.Fatal("fallback should return non-empty local domain slice")
}
// Should mention specifying scopes
msg := err.Error()
if !strings.Contains(msg, "scopes") {
t.Errorf("expected error to mention scopes, got: %s", msg)
// set and sorted are two views of the same local domain set and must correspond
if len(set) != len(sorted) {
t.Fatalf("set size %d != sorted size %d", len(set), len(sorted))
}
// Stderr should explain the split-flow path for non-streaming agents.
stderrStr := stderr.String()
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
if !strings.Contains(stderrStr, want) {
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
for _, d := range sorted {
if !set[d] {
t.Errorf("set missing local domain %q", d)
}
}
// fallback adopts the local sort order directly, which must equal sortedKnownDomains
if want := sortedKnownDomains(core.BrandFeishu); !reflect.DeepEqual(sorted, want) {
t.Fatalf("sorted = %v, want sortedKnownDomains %v", sorted, want)
}
}
func TestEnsureRequestedScopesGranted(t *testing.T) {
@@ -376,7 +364,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
})
}, "")
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
@@ -406,7 +394,7 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
}, "ou_user", "tester", "")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -448,7 +436,7 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
}, "ou_user", "tester", "")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -480,7 +468,7 @@ func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Granted: []string{"offline_access"},
})
}, "")
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
@@ -565,7 +553,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary, "")
got := stderr.String()
for _, want := range tt.expectedPresent {
@@ -819,7 +807,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
Requested: []string{"im:message:send"},
NewlyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send"},
})
}, "")
got := stderr.String()
for _, want := range []string{
@@ -1167,15 +1155,6 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "event" {
t.Error("event should not appear in interactive domain list")
}
}
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains("")
if domains["whiteboard"] {
@@ -1200,12 +1179,3 @@ func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
}
}
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "whiteboard" {
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
}
}
}

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

@@ -34,6 +34,7 @@ type DeviceFlowTokenData struct {
ExpiresIn int
RefreshExpiresIn int
Scope string
StatusMessage string // authorization result text from the response's status_message (e.g. pending-approval); passed through verbatim, not parsed
}
// DeviceFlowResult is the result of polling the token endpoint.
@@ -222,6 +223,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
ExpiresIn: tokenExpiresIn,
RefreshExpiresIn: refreshExpiresIn,
Scope: getStr(data, "scope"),
StatusMessage: getStr(data, "status_message"),
},
}
}

View File

@@ -216,3 +216,34 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
}
}
// TestPollDeviceToken_SuccessIncludesStatusMessage asserts that the success
// branch reads the token response's status_message field verbatim into
// DeviceFlowTokenData.StatusMessage. The CLI is a pure passthrough here — no
// parsing/classification of the text.
func TestPollDeviceToken_SuccessIncludesStatusMessage(t *testing.T) {
t.Parallel()
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "test-token",
"refresh_token": "test-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "a:b:c",
"status_message": "审批中,请等待管理员处理",
},
})
result := PollDeviceToken(context.Background(), httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 10, nil)
if result == nil || !result.OK || result.Token == nil {
t.Fatalf("PollDeviceToken() = %+v, want OK with a token", result)
}
if result.Token.StatusMessage != "审批中,请等待管理员处理" {
t.Fatalf("StatusMessage = %q, want the approval-pending text", result.Token.StatusMessage)
}
}

View File

@@ -0,0 +1,107 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
)
const (
remoteScopesPath = "/lark-cli/apis/scopes.json"
remoteScopesTimeout = 1 * time.Second
maxRemoteScopesSize = 10 * 1024 * 1024 // 10MB, aligned with internal/registry/remote.go
)
// remoteScopesURLForTest is the injection seam for unit tests to point at an
// httptest server. It is empty at runtime, where the brand-hardcoded production
// URL is used, and must never carry an internal / non-production domain.
var remoteScopesURLForTest string
type remoteScopesFile struct {
Scopes map[string]remoteDomainScopes `json:"scopes"`
}
type remoteDomainScopes struct {
UserScopes []string `json:"user_scopes"`
TenantScopes []string `json:"tenant_scopes"`
}
func remoteScopesURL(brand core.LarkBrand) string {
if remoteScopesURLForTest != "" {
return remoteScopesURLForTest
}
return core.ResolveOpenBaseURL(brand) + remoteScopesPath
}
// FetchRemoteScopes fetches and binary-validates the remote scopes.json.
// It returns (domain -> user_scopes, true) when the whole file is usable, or
// (nil, false) when the caller should fall back to the local set. Any failure
// (network / timeout / non-2xx / empty / bad JSON / structure mismatch /
// malformed scope) returns (nil, false) silently — no warning, no telemetry.
func FetchRemoteScopes(brand core.LarkBrand) (map[string][]string, bool) {
client := transport.NewHTTPClient(remoteScopesTimeout)
req, err := http.NewRequest(http.MethodGet, remoteScopesURL(brand), nil)
if err != nil {
return nil, false
}
resp, err := client.Do(req)
if err != nil {
return nil, false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRemoteScopesSize))
if err != nil || len(body) == 0 {
return nil, false
}
var file remoteScopesFile
if err := json.Unmarshal(body, &file); err != nil {
return nil, false
}
return validateRemoteScopes(file)
}
func validateRemoteScopes(file remoteScopesFile) (map[string][]string, bool) {
if len(file.Scopes) == 0 {
return nil, false
}
result := make(map[string][]string, len(file.Scopes))
for domain, ds := range file.Scopes {
if ds.UserScopes == nil { // missing user_scopes field / null → whole file untrusted
return nil, false
}
for _, s := range ds.UserScopes {
if !isValidScopeFormat(s) {
return nil, false
}
}
result[domain] = ds.UserScopes
}
return result, true
}
// isValidScopeFormat checks the service:resource:action shape: exactly three
// ":"-separated segments, each non-empty. The resource segment may contain "."
// (e.g. vc:meeting.meetingevent:read). i18n / tenant / version are not checked.
func isValidScopeFormat(s string) bool {
parts := strings.Split(s, ":")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
}
return true
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
)
func withRemoteScopesURL(t *testing.T, url string) {
t.Helper()
prev := remoteScopesURLForTest
remoteScopesURLForTest = url
t.Cleanup(func() { remoteScopesURLForTest = prev })
}
func TestFetchRemoteScopes(t *testing.T) {
cases := []struct {
name string
status int
body string
wantOK bool
wantDom string
wantLen int
}{
{
name: "whole usable returns all user_scopes incl unknown domain",
status: 200,
body: `{"version":"1.5.3","scopes":{"bitable":{"i18n_name":{"zh_cn":"多维表格"},"user_scopes":["base:app:copy","base:app:create"],"tenant_scopes":["base:app:copy"]},"brandnewdomain":{"user_scopes":["newsvc:res:read"]}}}`,
wantOK: true,
wantDom: "brandnewdomain",
wantLen: 1,
},
{name: "missing scopes key falls back", status: 200, body: `{"version":"1"}`, wantOK: false},
{name: "empty scopes falls back", status: 200, body: `{"scopes":{}}`, wantOK: false},
{name: "domain missing user_scopes falls back", status: 200, body: `{"scopes":{"im":{"i18n_name":{"zh_cn":"消息"}}}}`, wantOK: false},
{name: "malformed scope falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message"]}}}`, wantOK: false},
{name: "non-2xx falls back", status: 500, body: `{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`, wantOK: false},
{name: "empty body falls back", status: 200, body: ``, wantOK: false},
{name: "bad json falls back", status: 200, body: `{not json`, wantOK: false},
{name: "i18n/tenant missing still usable", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message:send_as_bot","vc:meeting.meetingevent:read"]}}}`, wantOK: true, wantDom: "im", wantLen: 2},
{name: "empty user_scopes array is usable", status: 200, body: `{"scopes":{"im":{"user_scopes":[]}}}`, wantOK: true, wantDom: "im", wantLen: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(srv.Close)
withRemoteScopesURL(t, srv.URL)
got, ok := FetchRemoteScopes(core.BrandFeishu)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if tc.wantOK {
if _, exists := got[tc.wantDom]; !exists {
t.Fatalf("domain %q missing in result %v", tc.wantDom, got)
}
if len(got[tc.wantDom]) != tc.wantLen {
t.Fatalf("len(%s) = %d, want %d", tc.wantDom, len(got[tc.wantDom]), tc.wantLen)
}
}
})
}
}
func TestFetchRemoteScopesTimeoutFallsBack(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(remoteScopesTimeout + 500*time.Millisecond)
_, _ = w.Write([]byte(`{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`))
}))
t.Cleanup(srv.Close)
withRemoteScopesURL(t, srv.URL)
_, ok := FetchRemoteScopes(core.BrandFeishu)
if ok {
t.Fatal("expected fallback (ok=false) on timeout")
}
}
func TestRemoteScopesURLByBrand(t *testing.T) {
// With an empty seam the production URL is used: core.ResolveOpenBaseURL(brand) + path
remoteScopesURLForTest = ""
feishu := remoteScopesURL(core.BrandFeishu)
lark := remoteScopesURL(core.BrandLark)
if !strings.HasSuffix(feishu, "/lark-cli/apis/scopes.json") || !strings.Contains(feishu, "open.feishu.cn") {
t.Fatalf("feishu url unexpected: %s", feishu)
}
if !strings.Contains(lark, "open.larksuite.com") {
t.Fatalf("lark url unexpected: %s", lark)
}
}

View File

@@ -165,9 +165,6 @@ const DefaultScopeScore = 0
var cachedScopePriorities map[string]int
var cachedAutoApproveSet map[string]bool
var cachedPlatformAutoApprove map[string]bool // from scope_priorities.json only
var cachedOverrideAutoAllow map[string]bool // from scope_overrides.json allow only
var cachedOverrideAutoDeny map[string]bool // from scope_overrides.json deny only
// scopePriorityEntry is used to parse scope_priorities.json entries.
type scopePriorityEntry struct {
@@ -264,90 +261,6 @@ func LoadAutoApproveSet() map[string]bool {
return cachedAutoApproveSet
}
// LoadPlatformAutoApproveSet returns scopes with AutoApprove rule on the platform
// (from scope_priorities.json only, before overrides).
func LoadPlatformAutoApproveSet() map[string]bool {
if cachedPlatformAutoApprove != nil {
return cachedPlatformAutoApprove
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil {
var entries []scopePriorityEntry
if json.Unmarshal(data, &entries) == nil {
for _, entry := range entries {
if entry.Recommend == "true" {
m[entry.ScopeName] = true
}
}
}
}
cachedPlatformAutoApprove = m
return cachedPlatformAutoApprove
}
// LoadOverrideAutoApproveAllow returns scopes explicitly listed in
// scope_overrides.json recommend.allow (our desired additions).
func LoadOverrideAutoApproveAllow() map[string]bool {
if cachedOverrideAutoAllow != nil {
return cachedOverrideAutoAllow
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
var wrapper struct {
AutoApprove struct {
Allow []string `json:"allow"`
} `json:"recommend"`
}
if json.Unmarshal(data, &wrapper) == nil {
for _, s := range wrapper.AutoApprove.Allow {
m[s] = true
}
}
}
cachedOverrideAutoAllow = m
return cachedOverrideAutoAllow
}
// LoadOverrideAutoApproveDeny returns scopes explicitly listed in
// scope_overrides.json recommend.deny
func LoadOverrideAutoApproveDeny() map[string]bool {
if cachedOverrideAutoDeny != nil {
return cachedOverrideAutoDeny
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
var wrapper struct {
AutoApprove struct {
Deny []string `json:"deny"`
} `json:"recommend"`
}
if json.Unmarshal(data, &wrapper) == nil {
for _, s := range wrapper.AutoApprove.Deny {
m[s] = true
}
}
}
cachedOverrideAutoDeny = m
return cachedOverrideAutoDeny
}
// IsAutoApproveScope returns true if the scope has AutoApprove rule.
func IsAutoApproveScope(scope string) bool {
return LoadAutoApproveSet()[scope]
}
// FilterAutoApproveScopes filters a scope list to only include auto-approve scopes.
func FilterAutoApproveScopes(scopes []string) []string {
autoApprove := LoadAutoApproveSet()
var result []string
for _, s := range scopes {
if autoApprove[s] {
result = append(result, s)
}
}
return result
}
// GetScopeScore returns the priority score for a scope, or DefaultScopeScore if not found.
func GetScopeScore(scope string) int {
priorities := LoadScopePriorities()

View File

@@ -235,83 +235,6 @@ func TestLoadAutoApproveSet(t *testing.T) {
t.Logf("Auto-approve set has %d scopes", len(aaSet))
}
func TestLoadPlatformAutoApproveSet(t *testing.T) {
paaSet := LoadPlatformAutoApproveSet()
// This should only include scopes from scope_priorities.json with AutoApprove rule.
// It does NOT apply deny overrides.
if len(paaSet) == 0 {
t.Fatal("expected non-empty platform auto-approve set")
}
t.Logf("Platform auto-approve set has %d scopes", len(paaSet))
}
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
}
}
func TestLoadOverrideAutoApproveDeny(t *testing.T) {
denySet := LoadOverrideAutoApproveDeny()
// deny list may be empty if all entries are moved to _deny (commented out)
t.Logf("Override deny set has %d scopes", len(denySet))
}
func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
}
// Completely unknown scope
if IsAutoApproveScope("zzz:unknown:scope") {
t.Error("expected unknown scope to NOT be auto-approve")
}
}
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
}
result := FilterAutoApproveScopes(scopes)
if len(result) < 1 {
t.Fatal("expected at least 1 auto-approve scope in result")
}
// Check that sheets:spreadsheet:read is included
found := false
for _, s := range result {
if s == "sheets:spreadsheet:read" {
found = true
}
// Ensure unknown scopes are not included
if s == "zzz:unknown:scope" {
t.Error("unknown scope should not be in auto-approve result")
}
}
if !found {
t.Error("expected sheets:spreadsheet:read in result")
}
}
func TestFilterAutoApproveScopes_Empty(t *testing.T) {
result := FilterAutoApproveScopes(nil)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
result = FilterAutoApproveScopes([]string{})
if result != nil {
t.Errorf("expected nil for empty input, got %v", result)
}
}
// --- Helper functions ---
func TestGetRegistryDir(t *testing.T) {

View File

@@ -40,9 +40,6 @@ func resetInit() {
cachedAllScopes = nil
cachedScopePriorities = nil
cachedAutoApproveSet = nil
cachedPlatformAutoApprove = nil
cachedOverrideAutoAllow = nil
cachedOverrideAutoDeny = nil
refreshOnce = sync.Once{}
configuredBrand = ""
enableRemoteMeta = true // tests exercise remote logic

View File

@@ -58,26 +58,6 @@ func GetServiceDescription(name, lang string) string {
return loc.Description
}
// GetServiceTitle returns the localized title for a service domain.
// Returns empty string if not found.
func GetServiceTitle(name, lang string) string {
loc := getServiceLocale(name, lang)
if loc == nil {
return ""
}
return loc.Title
}
// GetServiceDetailDescription returns the localized detail description for a service domain.
// Returns empty string if not found.
func GetServiceDetailDescription(name, lang string) string {
loc := getServiceLocale(name, lang)
if loc == nil {
return ""
}
return loc.Description
}
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
// When auth_domain is set, the service's scopes are collected under the
// parent domain during auth login.

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

@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -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

@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -14,6 +14,18 @@ import (
"github.com/tidwall/gjson"
)
const (
driveDeleteVisibilityTimeout = 30 * time.Second
driveDeleteVisibilityPoll = 3 * time.Second
)
var driveDeleteVisibilityWait = clie2e.WaitOptions{
// This wait only covers the post-delete visibility lag after Drive accepts
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
Timeout: driveDeleteVisibilityTimeout,
Interval: driveDeleteVisibilityPoll,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
@@ -60,14 +72,18 @@ func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
// returned a suppressed not_found or partial API error but the resource still
// exists.
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
}
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
if defaultAs == "" {
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{})
})
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}
@@ -82,35 +98,21 @@ func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
}
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
if err := WaitDriveResourceDeleted(ctx, token, docType, defaultAs); err != nil {
return deleteResult, err
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
return deleteResult, clie2e.CleanupWarning(
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
)
}
return deleteResult, nil
}
func WaitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) error {
deadline := time.NewTimer(20 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
deleted, err := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
if err != nil {
return err
}
if deleted {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("drive resource %s/%s still exists after delete", docType, token)
case <-ticker.C:
}
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
opts.TimeoutError = func() error {
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
})
}
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {

View File

@@ -5,8 +5,13 @@ package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -16,3 +21,59 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
require.NotEmpty(t, folderToken)
return folderToken
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
Timeout: 10 * time.Millisecond,
Interval: time.Millisecond,
})
require.NotNil(t, result)
assert.Equal(t, 0, result.ExitCode)
require.Error(t, err)
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
})
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
require.NotNil(t, result)
assert.Equal(t, 1, result.ExitCode)
require.Error(t, err)
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
assert.Contains(t, err.Error(), "still exists after delete failed")
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi
echo "unexpected fake CLI args: $*" >&2
exit 2
`
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return binaryPath
}

View File

@@ -1,9 +1,9 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 63 leaf commands
- Covered: 14
- Coverage: 22.2%
- Denominator: 65 leaf commands
- Covered: 16
- Coverage: 24.6%
## Summary
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
@@ -20,6 +20,8 @@
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"testing"
@@ -203,6 +204,11 @@ type wikiNodeInfo struct {
ObjType string
}
const (
wikiDeleteVisibilityTimeout = 30 * time.Second
wikiDeleteVisibilityPoll = 3 * time.Second
)
// deleteWikiNodeAndVerify removes a wiki node, then polls get_node until the
// original node token is gone. Wiki cleanup cannot use drive +delete because
// wiki origin nodes need the backing obj_token and parent nodes must delete
@@ -333,28 +339,34 @@ func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string)
}
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
deadline := time.NewTimer(20 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
var lastTransientErr error
for {
opts := clie2e.WaitOptions{
Timeout: wikiDeleteVisibilityTimeout,
Interval: wikiDeleteVisibilityPoll,
TimeoutError: func() error {
if lastTransientErr != nil {
return fmt.Errorf("wiki node %s delete verification kept hitting transient errors: %w", nodeToken, lastTransientErr)
}
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
},
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
if err != nil {
return err
if isWikiVerifyTransientError(err) {
lastTransientErr = err
return false, nil
} else {
return false, err
}
}
if deleted {
return nil
return true, nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
case <-ticker.C:
}
}
return false, nil
})
}
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
@@ -375,9 +387,31 @@ func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
if isWikiNodeDeletedResult(result) {
return true, nil
}
if isWikiVerifyTransientResult(result) {
return false, wikiVerifyTransientError{
err: fmt.Errorf("verify wiki node %s after delete hit transient response: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr),
}
}
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
}
type wikiVerifyTransientError struct {
err error
}
func (e wikiVerifyTransientError) Error() string {
return e.err.Error()
}
func (e wikiVerifyTransientError) Unwrap() error {
return e.err
}
func isWikiVerifyTransientError(err error) bool {
var transient wikiVerifyTransientError
return err != nil && errors.As(err, &transient)
}
func wikiAPISuccess(stdout string) bool {
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
return ok.Bool()
@@ -404,6 +438,55 @@ func isWikiNodeDeletedResult(result *clie2e.Result) bool {
strings.Contains(combined, "not found")
}
func isWikiVerifyTransientResult(result *clie2e.Result) bool {
if result == nil {
return false
}
payload := result.Stdout
if strings.TrimSpace(payload) == "" {
payload = result.Stderr
}
if gjson.Get(payload, "error.type").String() != "internal" ||
gjson.Get(payload, "error.subtype").String() != "invalid_response" {
return false
}
message := strings.ToLower(gjson.Get(payload, "error.message").String())
return strings.Contains(message, "http 429") ||
strings.Contains(message, "http 500") ||
strings.Contains(message, "http 502") ||
strings.Contains(message, "http 503") ||
strings.Contains(message, "http 504")
}
func TestWikiVerifyTransientResult(t *testing.T) {
t.Run("matches invalid response from transient http status", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: failed to parse TAT response (HTTP 429): invalid character 'r' looking for beginning of value"}}`,
}
require.True(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match unrelated invalid response", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: malformed body"}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match api errors", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: `{"ok":false,"error":{"type":"api","subtype":"conflict","message":"resource contention occurred, please retry","retryable":true}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
}
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string, parentNodeTokens ...string) gjson.Result {
t.Helper()