Compare commits

..

1 Commits

Author SHA1 Message Date
shanglei
67ee0defab fix(format): handle typed slices in table formatting and add regression tests 2026-04-14 16:38:06 +08:00
139 changed files with 952 additions and 9238 deletions

View File

@@ -6,6 +6,3 @@ coverage:
patch:
default:
target: 60%
github_checks:
annotations: true

View File

@@ -1,116 +0,0 @@
name: Architecture Audit
on:
schedule:
- cron: '0 9 * * 1' # Monday 09:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code detection
run: |
echo "## Dead Code" >> report.md
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
count=$(wc -l < deadcode.txt | tr -d ' ')
echo "Found **$count** unreachable functions" >> report.md
echo '```' >> report.md
cat deadcode.txt >> report.md
echo '```' >> report.md
- name: Package complexity
run: |
echo "## Package Complexity" >> report.md
echo "" >> report.md
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
echo "" >> report.md
echo "| Package | Files | Lines | Deps |" >> report.md
echo "|---------|-------|-------|------|" >> report.md
found=0
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
dir=$(go list -f '{{.Dir}}' "$pkg")
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
deps=$(go list -f '{{len .Imports}}' "$pkg")
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "| _(none)_ | | | |" >> report.md
fi
- name: Dependency freshness
run: |
echo "## Outdated Dependencies" >> report.md
echo '```' >> report.md
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md
echo "" >> report.md
echo "Shortcut domains without E2E tests:" >> report.md
echo "" >> report.md
found=0
for domain in $(ls -d shortcuts/*/); do
name=$(basename "$domain")
if [ "$name" = "common" ]; then continue; fi
if [ ! -d "tests/cli_e2e/$name" ]; then
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "All shortcut domains have E2E test directories." >> report.md
fi
- name: Coverage
run: |
echo "## Coverage" >> report.md
packages=$(go list ./... | grep -v 'tests/cli_e2e')
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
echo "Current total coverage: **${total:-n/a}**" >> report.md
- name: Publish report
run: |
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat report.md >> $GITHUB_STEP_SUMMARY
- name: Upload report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: arch-audit-${{ github.run_number }}
path: report.md
retention-days: 90

View File

@@ -1,333 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
fast-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
exit 1
fi
- name: Check go.mod tidiness
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
lint:
needs: fast-gate
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: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
coverage:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
exit 1
fi
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
deadcode:
needs: fast-gate
if: ${{ github.event_name == 'pull_request' }}
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: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code check (incremental)
run: |
# Analyze current HEAD (strip line:col for stable diff across line shifts)
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
# Analyze base branch via worktree
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
git worktree remove -f /tmp/dc-base 2>/dev/null || true
exit 0
}
git worktree remove -f /tmp/dc-base
# Only new dead code blocks the PR
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
if [ -s /tmp/dc-new.txt ]; then
echo "::group::New dead code"
cat /tmp/dc-new.txt
echo "::endgroup::"
echo "::error::New dead code detected — remove unreachable functions before merging"
exit 1
fi
echo "No new dead code introduced"
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Run dry-run E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
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-live:
needs: [unit-test, lint]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
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"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
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
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
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: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Gitleaks
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
- name: govulncheck
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
license-header:
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
run: |
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi
done
if [ "$FAILED" = "1" ]; then
echo ""
echo "::error::One or more CI jobs failed — see table above"
exit 1
fi

83
.github/workflows/cli-e2e.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: CLI E2E Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
jobs:
cli-e2e:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
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"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
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
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all

58
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Coverage
on:
push:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
pull_request:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
permissions:
contents: read
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Generate coverage report
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY

28
.github/workflows/gitleaks.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Gitleaks
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
gitleaks:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
# GITHUB_TOKEN is provided automatically by GitHub Actions.
# GITLEAKS_KEY must be configured as a repository secret.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}

26
.github/workflows/license-header.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: License Header
on:
pull_request:
branches: [main]
paths:
- "**/*.go"
- "**/*.js"
- "**/*.py"
- .licenserc.yaml
- .github/workflows/license-header.yml
permissions:
contents: read
pull-requests: write
jobs:
header-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml

60
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Lint
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
permissions:
contents: read
jobs:
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Ensure go.mod and go.sum are tidy
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run govulncheck
continue-on-error: true # informational until Go version is upgraded
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown

43
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
- name: Build
run: go build -v ./...

View File

@@ -70,14 +70,6 @@ linters:
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
shortcuts-no-raw-http:
files:
- "**/shortcuts/**"
deny:
- pkg: "net/http"
desc: >-
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
The client layer handles auth, headers, and error normalization.
forbidigo:
forbid:
# ── os: already wrapped in internal/vfs ──
@@ -108,16 +100,6 @@ linters:
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── output: shortcuts must use ctx.Out() ──
- pattern: fmt\.Print(f|ln)?\b
msg: >-
use ctx.Out() or ctx.OutFormat() for structured JSON output.
fmt.Print* bypasses the output envelope and breaks --jq/--format.
# ── logging: shortcuts must return errors, not log.Fatal ──
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
msg: >-
use structured error return, not log.Fatal/Panic.
Shortcuts must return errors to the framework for proper exit code handling.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-

View File

@@ -18,11 +18,9 @@ make test # Full: vet + unit + integration
## Pre-PR Checks (match CI gates)
1. `make unit-test`
2. `go vet ./...`
3. `gofmt -l .` — must produce no output
4. `go mod tidy` — must not change `go.mod`/`go.sum`
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
2. `go mod tidy` — must not change `go.mod`/`go.sum`
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
## Commit & PR
@@ -78,26 +76,3 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
- Every behavior change needs a test alongside the change.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
### E2E Testing
**Dry-run E2E (required for every shortcut change)**
- Validates request structure without calling real APIs
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
- No secrets needed — runs on fork PRs
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
**Live E2E (required for new flows or behavior changes)**
- Validates real API round-trips
- Place in `tests/cli_e2e/<domain>/`
- Must be self-contained: create -> use -> cleanup
- Needs bot credentials (CI secrets, skipped on fork PRs)
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
| Change | Dry-run E2E | Live E2E |
|--------|:-----------:|:--------:|
| New shortcut | Required | Required |
| Modify shortcut flags/params | Required | If behavior changes |
| Shortcut bug fix | Required | If regression risk |
| Internal refactor (no shortcut impact) | Not needed | Not needed |

View File

@@ -2,60 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.13] - 2026-04-16
### Features
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
### Bug Fixes
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
### Documentation
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
### CI
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
## [v1.0.12] - 2026-04-15
### Features
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
- **mail**: Return recall hints for sent emails when recall is available (#481)
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
### Documentation
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
## [v1.0.11] - 2026-04-14
### Features
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
- Streamline interactive login by removing the extra auth confirmation step (#451)
### Bug Fixes
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
### Documentation
- **sheets**: Document value formats for formulas and special field types (#456)
- **readme**: Add Attendance to the features table (#460)
## [v1.0.10] - 2026-04-13
### Features
@@ -382,9 +328,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8

View File

@@ -10,12 +10,12 @@ import (
)
type initMsg struct {
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
@@ -29,11 +29,11 @@ type initMsg struct {
}
var initMsgZh = &initMsg{
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
@@ -46,11 +46,11 @@ var initMsgZh = &initMsg{
}
var initMsgEn = &initMsg{
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",

View File

@@ -48,12 +48,12 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
t.Helper()
fields := map[string]string{
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,

View File

@@ -101,16 +101,16 @@ type ServiceMethodOptions struct {
SchemaPath string
// Flags
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
)
@@ -17,13 +18,39 @@ var knownArrayFields = []string{
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
}
// isSliceLike reports whether v is any kind of slice (e.g. []interface{},
// []map[string]interface{}, []string, etc.), using reflect so that the
// check is not limited to a single concrete slice type.
func isSliceLike(v interface{}) bool {
if v == nil {
return false
}
return reflect.TypeOf(v).Kind() == reflect.Slice
}
// toGenericSlice converts any slice type to []interface{} by re-boxing each
// element. This only changes the outer container type; individual elements
// retain their original dynamic type (e.g. map[string]interface{} stays as-is).
// Returns nil if v is not a slice.
func toGenericSlice(v interface{}) []interface{} {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice {
return nil
}
out := make([]interface{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
out[i] = rv.Index(i).Interface()
}
return out
}
// FindArrayField finds the primary array field in a response's data object.
// It first checks knownArrayFields in priority order, then falls back to
// the lexicographically smallest unknown array field for deterministic results.
func FindArrayField(data map[string]interface{}) string {
for _, name := range knownArrayFields {
if arr, ok := data[name]; ok {
if _, isArr := arr.([]interface{}); isArr {
if isSliceLike(arr) {
return name
}
}
@@ -31,7 +58,7 @@ func FindArrayField(data map[string]interface{}) string {
// Fallback: lexicographically first array field (deterministic)
var candidates []string
for k, v := range data {
if _, isArr := v.([]interface{}); isArr {
if isSliceLike(v) {
candidates = append(candidates, k)
}
}
@@ -81,7 +108,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 1: Lark API envelope — result["data"][arrayField]
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
if field := FindArrayField(dataObj); field != "" {
if items, ok := dataObj[field].([]interface{}); ok {
if items := toGenericSlice(dataObj[field]); items != nil {
return items
}
}
@@ -90,7 +117,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 2: direct map — result[arrayField]
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
if field := FindArrayField(resultMap); field != "" {
if items, ok := resultMap[field].([]interface{}); ok {
if items := toGenericSlice(resultMap[field]); items != nil {
return items
}
}

View File

@@ -266,6 +266,129 @@ func TestExtractItems(t *testing.T) {
}
}
// --- Typed-slice regression tests ---
// These cover the scenario where shortcut code uses []map[string]interface{}
// (or other typed slices) instead of []interface{} in outData.
func TestExtractItems_TypedMapSlice(t *testing.T) {
// Simulates shortcut pattern: outData["chats"] = []map[string]interface{}{...}
data := map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_abc", "name": "Test Chat"},
{"chat_id": "oc_def", "name": "Dev Chat"},
},
"total": 2,
"has_more": false,
}
items := ExtractItems(data)
if len(items) != 2 {
t.Fatalf("expected 2 items from typed map slice, got %d", len(items))
}
// Verify elements are still map[string]interface{} (flattenItem can handle them)
for i, item := range items {
if _, ok := item.(map[string]interface{}); !ok {
t.Errorf("item[%d] should be map[string]interface{}, got %T", i, item)
}
}
}
func TestExtractItems_TypedMapSlice_InEnvelope(t *testing.T) {
// Typed slice inside a Lark API envelope: result["data"]["items"] = []map[string]interface{}{...}
data := map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"id": "1", "name": "Alice"},
},
"has_more": false,
},
}
items := ExtractItems(data)
if len(items) != 1 {
t.Fatalf("expected 1 item from typed slice in envelope, got %d", len(items))
}
}
func TestFormatValue_Table_TypedMapSlice(t *testing.T) {
// The core bug: --format table with []map[string]interface{} should render
// multi-column table, not a key-value two-column fallback.
data := map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_abc", "name": "Lark Dev"},
},
"total": 1,
"has_more": false,
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatTable)
out := buf.String()
// Should have column headers from the data fields
if !strings.Contains(out, "chat_id") {
t.Errorf("table should contain 'chat_id' column header, got:\n%s", out)
}
if !strings.Contains(out, "name") {
t.Errorf("table should contain 'name' column header, got:\n%s", out)
}
if !strings.Contains(out, "Lark Dev") {
t.Errorf("table should contain data value 'Lark Dev', got:\n%s", out)
}
// Should NOT render as key-value fallback (metadata as rows)
if strings.Contains(out, "has_more") {
t.Errorf("table should not contain metadata 'has_more' as a row, got:\n%s", out)
}
}
func TestFormatValue_CSV_TypedMapSlice(t *testing.T) {
data := map[string]interface{}{
"messages": []map[string]interface{}{
{"message_id": "om_abc", "content": "hello"},
{"message_id": "om_def", "content": "world"},
},
"total": 2,
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatCSV)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("CSV should have header + 2 rows, got %d lines:\n%s", len(lines), buf.String())
}
// Header should contain data field names, not top-level map keys
header := lines[0]
if !strings.Contains(header, "message_id") {
t.Errorf("CSV header should contain 'message_id', got: %s", header)
}
}
func TestFormatValue_NDJSON_TypedMapSlice(t *testing.T) {
data := map[string]interface{}{
"tasks": []map[string]interface{}{
{"guid": "t1", "url": "https://example.com/t1"},
{"guid": "t2", "url": "https://example.com/t2"},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatNDJSON)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String())
}
for i, line := range lines {
var obj map[string]interface{}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
t.Errorf("NDJSON line %d should be valid JSON: %s", i, line)
}
if _, ok := obj["guid"]; !ok {
t.Errorf("NDJSON line %d should contain 'guid' field, got: %s", i, line)
}
}
}
func TestFormatValue_LegacyFormats(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{

View File

@@ -146,20 +146,11 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunSkillsUpdate installs skills, trying the .well-known source first and
// falling back to the GitHub repo on failure or timeout.
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
func (u *Updater) RunSkillsUpdate() *NpmResult {
if u.SkillsUpdateOverride != nil {
return u.SkillsUpdateOverride()
}
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
@@ -168,7 +159,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
import "testing"
func TestTruncateStr(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate", "hello world", 5, "hello"},
{"empty", "", 5, ""},
{"zero limit", "hello", 0, ""},
{"CJK characters", "你好世界测试", 4, "你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStr(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}
func TestTruncateStrWithEllipsis(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate with ellipsis", "hello world", 8, "hello..."},
{"limit less than 3", "hello", 2, "he"},
{"limit equals 3", "hello world", 3, "..."},
{"empty", "", 5, ""},
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}

84
package-lock.json generated
View File

@@ -1,84 +0,0 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"cpu": [
"x64",
"arm64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"@clack/prompts": "^1.2.0"
},
"bin": {
"lark-cli": "scripts/run.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@clack/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.2.0",
"fast-string-width": "^1.1.0",
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/fast-string-truncated-width": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^1.2.0"
}
},
"node_modules/fast-wrap-ansi": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^1.1.0"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.13",
"version": "1.0.10",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
@@ -27,11 +27,7 @@
"license": "MIT",
"files": [
"scripts/install.js",
"scripts/install-wizard.js",
"scripts/run.js",
"CHANGELOG.md"
],
"dependencies": {
"@clack/prompts": "^1.2.0"
}
]
}

View File

@@ -1,372 +0,0 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
const p = require("@clack/prompts");
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
const SKILLS_REPO_FALLBACK = "larksuite/cli";
const isWindows = process.platform === "win32";
// ---------------------------------------------------------------------------
// i18n
// ---------------------------------------------------------------------------
const messages = {
zh: {
setup: "正在设置 Feishu/Lark CLI...",
step1: "正在安装 %s...",
step1Upgrade: "正在升级 %s (v%s → v%s)...",
step1Skip: "已安装 (v%s),跳过",
step1Done: "已全局安装",
step1Upgraded: "已升级到 v%s",
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
step2: "安装 AI Skills",
step2Skip: "已安装,跳过",
step2Spinner: "正在安装 Skills...",
step2Done: "Skills 已安装",
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
step3: "正在配置应用...",
step3NotFound: "未找到 lark-cli终止",
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
step3Skip: "跳过应用配置",
step3Done: "应用已配置",
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli跳过授权",
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等)?",
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
step4Done: "授权完成",
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n现在可以对你的 AI 工具Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
},
en: {
setup: "Setting up Feishu/Lark CLI...",
step1: "Installing %s globally...",
step1Upgrade: "Upgrading %s (v%s → v%s)...",
step1Skip: "Already installed (v%s). Skipped",
step1Done: "Installed globally",
step1Upgraded: "Upgraded to v%s",
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
step2: "Install AI skills",
step2Skip: "Already installed. Skipped",
step2Spinner: "Installing skills...",
step2Done: "Skills installed",
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
step3: "Configuring app...",
step3NotFound: "lark-cli not found. Aborting",
step3Found: "Found existing app (App ID: %s). Use this app?",
step3Skip: "Skipped app configuration",
step3Done: "App configured",
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
step4: "Authorization",
step4NotFound: "lark-cli not found. Skipping authorization",
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
step4Done: "Authorization complete",
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
cancelled: "Installation cancelled",
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function handleCancel(value, msg) {
if (p.isCancel(value)) {
p.cancel(msg.cancelled);
process.exit(0);
}
return value;
}
function execCmd(cmd, args, opts) {
if (isWindows) {
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
}
return execFileSync(cmd, args, opts);
}
function run(cmd, args, opts = {}) {
execCmd(cmd, args, { stdio: "inherit", ...opts });
}
function runSilent(cmd, args, opts = {}) {
return execCmd(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
});
}
function runSilentAsync(cmd, args, opts = {}) {
const actualCmd = isWindows ? "cmd.exe" : cmd;
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
return new Promise((resolve, reject) => {
execFile(actualCmd, actualArgs, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
}, (err, stdout) => {
if (err) reject(err);
else resolve(stdout);
});
});
}
function fmt(template, ...values) {
let i = 0;
return template.replace(/%s/g, () => values[i++] ?? "");
}
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
function whichLarkCli() {
try {
const prefix = execFileSync("npm", ["prefix", "-g"], {
stdio: ["ignore", "pipe", "pipe"],
}).toString().trim();
const bin = isWindows
? path.join(prefix, "lark-cli.cmd")
: path.join(prefix, "bin", "lark-cli");
if (fs.existsSync(bin)) return bin;
} catch (_) {
// fall through
}
// Fallback to which/where if npm prefix lookup fails.
try {
const cmd = isWindows ? "where" : "which";
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
.toString()
.split("\n")[0]
.trim();
} catch (_) {
return null;
}
}
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
function getLatestVersion() {
try {
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
const ver = out.toString().trim();
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
} catch (_) {
return null;
}
}
/** Compare two semver strings. Returns true if a < b. */
function semverLessThan(a, b) {
const pa = a.replace(/-.*$/, "").split(".").map(Number);
const pb = b.replace(/-.*$/, "").split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) < (pb[i] || 0)) return true;
if ((pa[i] || 0) > (pb[i] || 0)) return false;
}
return false;
}
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
function getGloballyInstalledVersion() {
try {
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
return match ? match[1] : "unknown";
} catch (_) {
return null;
}
}
/** Check whether lark-cli config already exists. Returns app ID or null. */
function getExistingAppId(binPath) {
try {
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
const json = JSON.parse(out.toString());
return json.appId || null;
} catch (_) {
return null;
}
}
/** Parse --lang from process.argv, returns "zh", "en", or null. */
function parseLangArg() {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (args[i] === "--lang" && args[i + 1]) {
const val = args[i + 1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
if (args[i].startsWith("--lang=")) {
const val = args[i].split("=")[1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
async function stepSelectLang() {
const fromArg = parseLangArg();
if (fromArg) return fromArg;
const lang = await p.select({
message: "请选择语言 / Select language",
options: [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
],
});
return handleCancel(lang, messages.zh);
}
async function stepInstallGlobally(msg) {
const installedVer = getGloballyInstalledVersion();
const latestVer = getLatestVersion();
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
if (installedVer && !needsUpgrade) {
p.log.info(fmt(msg.step1Skip, installedVer));
return false;
}
const s = p.spinner();
if (needsUpgrade) {
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
} else {
s.start(fmt(msg.step1, PKG));
}
try {
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
return needsUpgrade;
} catch (_) {
s.stop(fmt(msg.step1Fail, PKG));
process.exit(1);
}
}
async function skillsAlreadyInstalled() {
try {
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
timeout: 120000,
});
return /^lark-/m.test(out.toString());
} catch (_) {
return false;
}
}
async function stepInstallSkills(msg) {
const s = p.spinner();
s.start(msg.step2Spinner);
try {
if (await skillsAlreadyInstalled()) {
s.stop(msg.step2Skip);
return;
}
try {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
timeout: 120000,
});
} catch (_) {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
timeout: 120000,
});
}
s.stop(msg.step2Done);
} catch (_) {
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
process.exit(1);
}
}
async function stepConfigInit(msg, lang) {
const s = p.spinner();
s.start(msg.step3);
const larkCli = whichLarkCli();
if (!larkCli) {
s.stop(msg.step3NotFound);
process.exit(1);
}
const appId = getExistingAppId(larkCli);
s.stop(msg.step3);
if (appId) {
const reuse = await p.confirm({
message: fmt(msg.step3Found, appId),
});
if (handleCancel(reuse, msg) && reuse) {
p.log.info(msg.step3Skip);
return;
}
}
try {
run(larkCli, ["config", "init", "--new", "--lang", lang]);
p.log.success(msg.step3Done);
} catch (_) {
p.log.error(msg.step3Fail);
process.exit(1);
}
}
async function stepAuthLogin(msg) {
const larkCli = whichLarkCli();
if (!larkCli) {
p.log.warn(msg.step4NotFound);
return;
}
const yes = await p.confirm({
message: msg.step4Confirm,
});
if (p.isCancel(yes)) {
p.cancel(msg.cancelled);
process.exit(0);
}
if (!yes) {
p.log.info(msg.step4Skip);
return;
}
p.log.step(msg.step4);
try {
run(larkCli, ["auth", "login"]);
p.log.success(msg.step4Done);
} catch (_) {
p.log.warn(msg.step4Fail);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const lang = await stepSelectLang();
const msg = messages[lang];
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
}
main().catch((err) => {
p.cancel("Unexpected error: " + (err.message || err));
process.exit(1);
});

View File

@@ -3,10 +3,10 @@
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const { execSync } = require("child_process");
const os = require("os");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const VERSION = require("../package.json").version;
const REPO = "larksuite/cli";
const NAME = "lark-cli";
@@ -43,16 +43,13 @@ const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
fs.mkdirSync(binDir, { recursive: true });
function download(url, destPath) {
const args = [
"--fail", "--location", "--silent", "--show-error",
"--connect-timeout", "10", "--max-time", "120",
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
if (isWindows) args.unshift("--ssl-revoke-best-effort");
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
execSync(
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
{ stdio: ["ignore", "ignore", "pipe"] }
);
}
function install() {
@@ -67,12 +64,12 @@ function install() {
}
if (isWindows) {
execFileSync("powershell", [
"-Command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
], { stdio: "ignore" });
execSync(
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
{ stdio: "ignore" }
);
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
stdio: "ignore",
});
}
@@ -88,16 +85,6 @@ function install() {
}
}
// When triggered as a postinstall hook under npx, skip the binary download.
// The "install" wizard doesn't need it, and run.js calls install.js directly
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
const isNpxPostinstall =
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
if (isNpxPostinstall) {
process.exit(0);
}
try {
install();
} catch (err) {

View File

@@ -41,32 +41,21 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) {
}
}
// Intercept "install" subcommand — run the setup wizard directly,
// bypassing the native binary (which may not exist yet under npx).
const args = process.argv.slice(2);
if (args[0] === "install") {
require("./install-wizard.js");
} else {
// Auto-download binary if missing (e.g. npx skipped postinstall).
if (!fs.existsSync(bin)) {
try {
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
stdio: "inherit",
env: { ...process.env, LARK_CLI_RUN: "true" },
});
} catch (_) {
console.error(
`\nFailed to auto-install lark-cli binary.\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
}
try {
execFileSync(bin, args, { stdio: "inherit" });
} catch (e) {
process.exit(e.status || 1);
}
if (!fs.existsSync(bin)) {
console.error(
`Error: lark-cli binary not found at ${bin}\n\n` +
`This usually means the postinstall script was skipped.\n` +
`Common causes:\n` +
` - npm is configured with ignore-scripts=true\n` +
` - The postinstall download failed\n\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
try {
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
} catch (e) {
process.exit(e.status || 1);
}

View File

@@ -151,87 +151,6 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
}
}
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "field create",
shortcut: BaseFieldCreate,
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "field update",
shortcut: BaseFieldUpdate,
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
},
{
name: "record search",
shortcut: BaseRecordSearch,
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record upsert",
shortcut: BaseRecordUpsert,
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record batch create",
shortcut: BaseRecordBatchCreate,
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set filter",
shortcut: BaseViewSetFilter,
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set visible fields",
shortcut: BaseViewSetVisibleFields,
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set card",
shortcut: BaseViewSetCard,
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set timebar",
shortcut: BaseViewSetTimebar,
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if !strings.Contains(err.Error(), "lark-base skill") {
t.Fatalf("err=%v", err)
}
if strings.Contains(err.Error(), "array") {
t.Fatalf("err should not mention array: %v", err)
}
if got := stdout.String(); got != "" {
t.Fatalf("stdout=%q, want empty", got)
}
})
}
}
func TestBaseTableExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -340,7 +259,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
},
})
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
@@ -358,7 +277,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
},
})
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
@@ -1284,7 +1203,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
factory,
stdout,
)
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
t.Fatalf("err=%v", err)
}
})

View File

@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
}
func jsonInputTip(flagName string) string {
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
}
func formatJSONError(flagName string, target string, err error) error {

View File

@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
}
}
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate == nil {
t.Fatal("expected validate hook")
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate != nil {
t.Fatalf("expected no validate hook, got non-nil")
}
}
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
func TestBaseFieldValidate(t *testing.T) {
ctx := context.Background()
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
@@ -255,29 +255,22 @@ func TestBaseRecordValidate(t *testing.T) {
if BaseRecordList.Validate != nil {
t.Fatalf("record list validate should be nil for repeatable --field-id")
}
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON before dry-run")
if BaseRecordSearch.Validate != nil {
t.Fatalf("record search validate should be nil for API passthrough")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil")
}
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
if BaseRecordUpsert.Validate != nil {
t.Fatalf("record upsert validate should be nil for API passthrough")
}
}
func TestBaseViewValidate(t *testing.T) {
ctx := context.Background()
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
}
}

View File

@@ -81,7 +81,16 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
pc := newParseCtx(runtime)
return parseJSONObject(pc, runtime.Str("json"), "json")
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
if raw == "" {
return nil, nil
}
var body map[string]interface{}
_ = common.ParseJSON([]byte(raw), &body)
if body == nil {
return nil, nil
}
return body, nil
}
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {

View File

@@ -6,7 +6,6 @@ package base
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -37,14 +36,7 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
}
var result map[string]interface{}
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return nil, formatJSONError(flagName, "object", err)
}
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
if result == nil {
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
return nil, formatJSONError(flagName, "object", err)
}
return result, nil
}

View File

@@ -38,10 +38,7 @@ func TestParseHelpers(t *testing.T) {
if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err)
}
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
t.Fatalf("err=%v", err)
}
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
@@ -66,7 +63,7 @@ func TestParseHelpers(t *testing.T) {
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
t.Fatalf("err=%v", err)
}
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -284,11 +281,11 @@ func TestJSONInputHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
t.Fatalf("syntaxErr=%v", syntaxErr)
}
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
t.Fatalf("typeErr=%v", typeErr)
}
}

View File

@@ -25,9 +25,6 @@ var BaseRecordBatchCreate = common.Shortcut{
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordBatchCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordBatchCreate(runtime)

View File

@@ -25,9 +25,6 @@ var BaseRecordBatchUpdate = common.Shortcut{
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordBatchUpdate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordBatchUpdate(runtime)

View File

@@ -113,9 +113,7 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
}
func validateRecordJSON(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
return nil
}
func recordListFields(runtime *common.RuntimeContext) []string {

View File

@@ -25,9 +25,6 @@ var BaseRecordSearch = common.Shortcut{
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordSearch,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordSearch(runtime)

View File

@@ -26,9 +26,6 @@ var BaseRecordUpsert = common.Shortcut{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordUpsert,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordUpsert(runtime)

View File

@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
}
func validateViewCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
_, err := parseObjectList(pc, runtime.Str("json"), "json")
return err
return nil
}
func validateViewJSONObject(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
return nil
}
func validateViewJSONValue(runtime *common.RuntimeContext) error {
return nil
}
func executeViewList(runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
return validateViewJSONValue(runtime)
},
DryRun: dryRunViewSetGroup,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
return validateViewJSONValue(runtime)
},
DryRun: dryRunViewSetSort,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -26,9 +26,6 @@ var BaseViewSetVisibleFields = common.Shortcut{
`Example: --json '{"visible_fields":["fldXXX"]}'`,
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},
DryRun: dryRunViewSetVisibleFields,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeViewSetVisibleFields(runtime)

View File

@@ -263,8 +263,8 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
}
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
// regardless of the current --as flag. Use this for APIs that must always be called
// with TAT even when the surrounding shortcut runs as user.
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
// that must be called with TAT even when the surrounding shortcut runs as user.
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ac, err := ctx.getAPIClient()
if err != nil {

View File

@@ -28,8 +28,6 @@ var DocsFetch = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
@@ -48,8 +46,6 @@ var DocsFetch = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)

View File

@@ -1,120 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveCreateFolderSpec struct {
Name string
FolderToken string
}
func newDriveCreateFolderSpec(runtime *common.RuntimeContext) driveCreateFolderSpec {
return driveCreateFolderSpec{
Name: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
func (s driveCreateFolderSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"name": s.Name,
"folder_token": s.FolderToken,
}
}
// DriveCreateFolder creates a new Drive folder under the specified parent
// folder, or under the caller's root folder when --folder-token is omitted.
var DriveCreateFolder = common.Shortcut{
Service: "drive",
Command: "+create-folder",
Description: "Create a folder in Drive",
Risk: "write",
Scopes: []string{"space:folder:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "name", Desc: "folder name", Required: true},
{Name: "folder-token", Desc: "parent folder token (default: root folder)"},
},
Tips: []string{
"Omit --folder-token to create the folder in the caller's root folder.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveCreateFolderSpec(newDriveCreateFolderSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveCreateFolderSpec(runtime)
dry := common.NewDryRunAPI().
Desc("Create a folder in Drive").
POST("/open-apis/drive/v1/files/create_folder").
Desc("[1] Create folder").
Body(spec.RequestBody())
if runtime.IsBot() {
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveCreateFolderSpec(runtime)
target := "root folder"
if spec.FolderToken != "" {
target = "folder " + common.MaskToken(spec.FolderToken)
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target)
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
spec.RequestBody(),
)
if err != nil {
return err
}
folderToken := common.GetString(data, "token")
if folderToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)")
}
out := map[string]interface{}{
"created": true,
"name": spec.Name,
"folder_token": folderToken,
"parent_folder_token": spec.FolderToken,
}
if url := common.GetString(data, "url"); url != "" {
out["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil {
out["permission_grant"] = grant
}
runtime.Out(out, nil)
return nil
},
}
func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error {
if spec.Name == "" {
return output.ErrValidation("--name must not be empty")
}
if nameBytes := len([]byte(spec.Name)); nameBytes > 256 {
return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes)
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
}

View File

@@ -1,266 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestValidateDriveCreateFolderSpecRejectsInvalidInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec driveCreateFolderSpec
wantErr string
}{
{
name: "empty name",
spec: driveCreateFolderSpec{},
wantErr: "--name must not be empty",
},
{
name: "name too long",
spec: driveCreateFolderSpec{
Name: strings.Repeat("a", 257),
},
wantErr: "maximum of 256 bytes",
},
{
name: "invalid folder token",
spec: driveCreateFolderSpec{
Name: "Reports",
FolderToken: "../bad",
},
wantErr: "--folder-token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCreateFolderSpec(tt.spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-folder"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("name", " Weekly Reports "); err != nil {
t.Fatalf("set --name: %v", err)
}
if err := cmd.Flags().Set("folder-token", " fld_parent "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
dry := DriveCreateFolder.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/drive/v1/files/create_folder" {
t.Fatalf("unexpected dry-run API call: %#v", got.API[0])
}
if got.API[0].Body["name"] != "Weekly Reports" {
t.Fatalf("name = %#v, want %q", got.API[0].Body["name"], "Weekly Reports")
}
if got.API[0].Body["folder_token"] != "fld_parent" {
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
}
}
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"token": "fld_created",
"url": "https://example.feishu.cn/drive/folder/fld_created",
},
},
}
reg.Register(createStub)
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/fld_created/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(permStub)
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", " Weekly Reports ",
"--folder-token", " fld_parent ",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["name"] != "Weekly Reports" {
t.Fatalf("name = %#v, want %q", body["name"], "Weekly Reports")
}
if body["folder_token"] != "fld_parent" {
t.Fatalf("folder_token = %#v, want %q", body["folder_token"], "fld_parent")
}
data := decodeDriveEnvelope(t, stdout)
if data["folder_token"] != "fld_created" {
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_created")
}
if data["parent_folder_token"] != "fld_parent" {
t.Fatalf("parent_folder_token = %#v, want %q", data["parent_folder_token"], "fld_parent")
}
if data["name"] != "Weekly Reports" {
t.Fatalf("name = %#v, want %q", data["name"], "Weekly Reports")
}
if data["url"] != "https://example.feishu.cn/drive/folder/fld_created" {
t.Fatalf("url = %#v, want folder url", data["url"])
}
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
permBody := decodeCapturedJSONBody(t, permStub)
if permBody["member_type"] != "openid" || permBody["member_id"] != "ou_current_user" || permBody["perm"] != "full_access" || permBody["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", permBody)
}
}
func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"token": "fld_root_child",
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", "Inbox",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["folder_token"] != "" {
t.Fatalf("folder_token = %#v, want empty string for root create", body["folder_token"])
}
data := decodeDriveEnvelope(t, stdout)
if data["folder_token"] != "fld_root_child" {
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_root_child")
}
if data["parent_folder_token"] != "" {
t.Fatalf("parent_folder_token = %#v, want empty string", data["parent_folder_token"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"url": "https://example.feishu.cn/drive/folder/unknown",
},
},
})
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", "Broken Folder",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "returned no folder token") {
t.Fatalf("err = %v, want missing folder token error", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty on error, got %s", stdout.String())
}
}

View File

@@ -9,7 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
DriveUpload,
DriveCreateFolder,
DriveCreateShortcut,
DriveDownload,
DriveAddComment,

View File

@@ -12,7 +12,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
got := Shortcuts()
want := []string{
"+upload",
"+create-folder",
"+create-shortcut",
"+download",
"+add-comment",

View File

@@ -74,7 +74,6 @@ var commonEventTypes = []string{
"approval.approval.updated",
"application.application.visibility.added_v6",
"task.task.update_tenant_v1",
"task.task.update_user_access_v2",
"task.task.comment_updated_v1",
"drive.notice.comment_add_v1",
}

View File

@@ -602,51 +602,3 @@ func TestMediaBufferReader(t *testing.T) {
}
}
}
func TestMediaBufferFileName(t *testing.T) {
tests := []struct {
label string
buf mediaBuffer
want string
}{
{"original URL filename", mediaBuffer{name: "report.pdf", ext: ".pdf"}, "report.pdf"},
{"name with spaces", mediaBuffer{name: "Q1 report.pdf", ext: ".pdf"}, "Q1 report.pdf"},
{"download fallback", mediaBuffer{name: "download", ext: ""}, "download"},
{"ext not leaked into name", mediaBuffer{name: "x", ext: ".mp4"}, "x"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
if got := tt.buf.FileName(); got != tt.want {
t.Fatalf("FileName() = %q, want %q", got, tt.want)
}
})
}
}
// TestNewMediaBufferFromBytesURLFilename locks in the URL -> mediaBuffer.name
// wiring so a future refactor cannot regress back to the "media.<ext>" synthetic
// filename that was shipped in 91067ec.
func TestNewMediaBufferFromBytesURLFilename(t *testing.T) {
tests := []struct {
label string
url string
want string
}{
{"path filename", "http://example.com/report.pdf", "report.pdf"},
{"filename survives query string", "http://example.com/videos/clip.mp4?token=abc", "clip.mp4"},
{"percent-encoded spaces decoded", "http://example.com/Q1%20report.pdf", "Q1 report.pdf"},
{"no path falls back to download", "http://example.com/", "download"},
{"non-http scheme falls back to download", "ftp://example.com/x.pdf", "download"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
mb := newMediaBufferFromBytes([]byte("payload"), ".pdf", tt.url)
if got := mb.FileName(); got != tt.want {
t.Fatalf("FileName() for %q = %q, want %q", tt.url, got, tt.want)
}
if got := mb.FileName(); strings.HasPrefix(got, "media") && tt.want != "media" {
t.Fatalf("regression: FileName() returned synthetic %q for %q", got, tt.url)
}
})
}
}

View File

@@ -584,7 +584,6 @@ func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType strin
type mediaBuffer struct {
data []byte
ext string // file extension including leading dot, e.g. ".mp4"
name string // original file name extracted from the source URL
}
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
@@ -599,14 +598,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
return newMediaBufferFromBytes(data, ext, rawURL), nil
}
// newMediaBufferFromBytes builds a mediaBuffer from already-downloaded bytes.
// Split out from newMediaBuffer so the URL-to-filename wiring is testable
// without going through the hardened download transport.
func newMediaBufferFromBytes(data []byte, ext, rawURL string) *mediaBuffer {
return &mediaBuffer{data: data, ext: ext, name: fileNameFromURL(rawURL)}
return &mediaBuffer{data: data, ext: ext}, nil
}
// Reader returns a new io.Reader over the buffered data. Each call returns a
@@ -616,9 +608,9 @@ func (b *mediaBuffer) Reader() io.Reader {
return bytes.NewReader(b.data)
}
// FileName returns the original file name extracted from the source URL.
// FileName returns a synthetic file name based on the URL extension.
func (b *mediaBuffer) FileName() string {
return b.name
return "media" + b.ext
}
// FileType returns the IM file type detected from the extension.
@@ -1139,7 +1131,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
fd.AddField("image_type", imageType)
fd.AddFile("image", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
@@ -1180,7 +1172,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,
@@ -1208,7 +1200,7 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
fd.AddField("image_type", imageType)
fd.AddFile("image", r)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
@@ -1240,7 +1232,7 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
}
fd.AddFile("file", r)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,

View File

@@ -892,38 +892,3 @@ func TestResolveLocalMediaFile(t *testing.T) {
t.Fatalf("resolveLocalMedia(file) = %q, want %q", got, "file_via_resolve")
}
}
// TestUploadFileToIMPreservesLocalFileName locks in that local uploads keep
// the basename of the caller-supplied path as the multipart file_name, so the
// URL-side fix for mediaBuffer cannot silently regress the local branch later.
func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
var gotBody string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/files") {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
gotBody = string(body)
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_key": "file_uploaded"},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
cmdutil.TestChdir(t, t.TempDir())
localName := "Q1-meeting-notes.pdf"
if err := os.WriteFile(localName, []byte("pdfdata"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
t.Fatalf("uploadFileToIM() error = %v", err)
}
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
t.Fatalf("upload body missing local filename %q; got: %q", localName, gotBody)
}
}

View File

@@ -144,8 +144,6 @@ type DraftProjection struct {
BodyText string `json:"body_text,omitempty"`
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
HasSignature bool `json:"has_signature,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
Warnings []string `json:"warnings,omitempty"`
@@ -184,22 +182,6 @@ type PatchOp struct {
FileName string `json:"filename,omitempty"`
ContentType string `json:"content_type,omitempty"`
Target AttachmentTarget `json:"target,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
// fetching and interpolating the signature. The patch layer uses this
// pre-rendered content for insert_signature ops.
RenderedSignatureHTML string `json:"-"`
SignatureImages []SignatureImage `json:"-"`
}
// SignatureImage holds pre-downloaded image data for signature inline images.
// Populated by the shortcut layer, consumed by the patch layer.
type SignatureImage struct {
CID string
ContentType string
FileName string
Data []byte
}
func (p Patch) Validate() error {
@@ -292,12 +274,6 @@ func (op PatchOp) Validate() error {
if !op.Target.hasKey() {
return fmt.Errorf("remove_inline requires target with at least one of part_id or cid")
}
case "insert_signature":
if strings.TrimSpace(op.SignatureID) == "" {
return fmt.Errorf("insert_signature requires signature_id")
}
case "remove_signature":
// No required fields.
default:
return fmt.Errorf("unsupported op %q", op.Op)
}

View File

@@ -33,12 +33,10 @@ var protectedHeaders = map[string]bool{
// bodyChangingOps lists patch operations that modify the HTML body content,
// which is the trigger for running local image path resolution.
var bodyChangingOps = map[string]bool{
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
"insert_signature": true,
"remove_signature": true,
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
}
func Apply(dctx *DraftCtx, snapshot *DraftSnapshot, patch Patch) error {
@@ -123,10 +121,6 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
return fmt.Errorf("remove_inline: %w", err)
}
return removeInline(snapshot, partID)
case "insert_signature":
return insertSignatureOp(snapshot, op)
case "remove_signature":
return removeSignatureOp(snapshot)
default:
return fmt.Errorf("unsupported patch op %q", op.Op)
}
@@ -290,7 +284,7 @@ func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) e
if htmlPart == nil {
return setBody(snapshot, value, options)
}
_, quotePart := SplitAtQuote(string(htmlPart.Body))
_, quotePart := splitAtQuote(string(htmlPart.Body))
if quotePart == "" {
// No quote block found — fall back to regular set_body.
return setBody(snapshot, value, options)
@@ -1141,166 +1135,3 @@ func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLoc
removeOrphanedInlineParts(snapshot.Body, refSet)
return nil
}
// ── Signature patch operations ──
// insertSignatureOp inserts a pre-rendered signature into the HTML body.
// The RenderedSignatureHTML and SignatureImages fields must be populated
// by the shortcut layer before calling Apply.
func insertSignatureOp(snapshot *DraftSnapshot, op PatchOp) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("insert_signature: no HTML body part found; use set_body first")
}
html := string(htmlPart.Body)
// Collect CIDs from old signature before removing it, so we can
// clean up orphaned MIME inline parts and avoid duplicates.
oldSigCIDs := collectSignatureCIDsFromHTML(html)
// Remove existing signature (if any), including preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned MIME inline parts from old signature.
for _, cid := range oldSigCIDs {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
// Split at quote and insert signature between body and quote.
body, quote := SplitAtQuote(html)
sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML)
html = body + sigBlock + quote
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
// Add signature inline images to the MIME tree.
for _, img := range op.SignatureImages {
addInlinePartToSnapshot(snapshot, img.Data, img.ContentType, img.FileName, img.CID)
}
syncTextPartFromHTML(snapshot, html)
return nil
}
// removeSignatureOp removes the signature block from the HTML body.
func removeSignatureOp(snapshot *DraftSnapshot) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("remove_signature: no HTML body part found")
}
html := string(htmlPart.Body)
if !signatureWrapperRe.MatchString(html) {
return fmt.Errorf("no signature found in draft body")
}
// Collect CIDs referenced by the signature before removing it.
sigCIDs := collectSignatureCIDsFromHTML(html)
// Remove signature and preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML).
for _, cid := range sigCIDs {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
syncTextPartFromHTML(snapshot, html)
return nil
}
// syncTextPartFromHTML regenerates the text/plain part from the current HTML,
// mirroring the coupled-body logic in tryApplyCoupledBodySetBody.
func syncTextPartFromHTML(snapshot *DraftSnapshot, html string) {
if snapshot.PrimaryTextPartID == "" {
return
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if textPart == nil {
return
}
textPart.Body = []byte(plainTextFromHTML(html))
textPart.Dirty = true
}
// Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and
// RemoveSignatureHTML are exported from projection.go to avoid duplication
// with the mail package's signature_html.go.
// collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML.
func collectSignatureCIDsFromHTML(html string) []string {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return nil
}
sigEnd := FindMatchingCloseDiv(html, loc[0])
sigHTML := html[loc[0]:sigEnd]
matches := cidRefRegexp.FindAllStringSubmatch(sigHTML, -1)
cids := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) >= 2 {
cids = append(cids, m[1])
}
}
return cids
}
// removeMIMEPartByCID removes the first MIME part with the given Content-ID.
func removeMIMEPartByCID(root *Part, cid string) {
if root == nil {
return
}
normalizedCID := strings.Trim(cid, "<>")
for i, child := range root.Children {
if child == nil {
continue
}
childCID := strings.Trim(child.ContentID, "<>")
if strings.EqualFold(childCID, normalizedCID) {
root.Children = append(root.Children[:i], root.Children[i+1:]...)
return
}
removeMIMEPartByCID(child, cid)
}
}
// addInlinePartToSnapshot adds an inline image part to the MIME tree.
func addInlinePartToSnapshot(snapshot *DraftSnapshot, data []byte, contentType, filename, cid string) {
part := &Part{
MediaType: contentType,
ContentDisposition: "inline",
ContentID: strings.Trim(cid, "<>"),
Body: data,
Dirty: true,
}
if filename != "" {
part.MediaParams = map[string]string{"name": filename}
}
// Find or create the multipart/related container.
if snapshot.Body == nil {
return
}
if snapshot.Body.IsMultipart() {
snapshot.Body.Children = append(snapshot.Body.Children, part)
}
// Non-multipart body: inline part is not added. This is expected when
// the draft has a simple text/html body without multipart/related wrapper.
// The signature HTML still references the CID, but the image won't render.
// In practice, compose shortcuts wrap the body in multipart/related when
// inline images are present, so this path rarely triggers.
}
// containsCIDIgnoreCase checks if html contains a "cid:<value>" reference,
// case-insensitively. Aligned with other CID comparisons in this package.
func containsCIDIgnoreCase(html, cid string) bool {
return strings.Contains(strings.ToLower(html), "cid:"+strings.ToLower(cid))
}

View File

@@ -1,203 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// insert_signature — basic insertion into HTML body
// ---------------------------------------------------------------------------
func TestInsertSignature_BasicHTML(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Sig test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-123",
RenderedSignatureHTML: "<div>-- My Signature</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if !strings.Contains(html, "My Signature") {
t.Error("signature not found in HTML body")
}
if !strings.Contains(html, `class="lark-mail-signature"`) {
t.Error("signature wrapper class not found")
}
if !strings.Contains(html, `id="sig-123"`) {
t.Error("signature ID not found")
}
// Body text should come before signature
bodyIdx := strings.Index(html, "Hello")
sigIdx := strings.Index(html, "My Signature")
if bodyIdx > sigIdx {
t.Error("signature should appear after body text")
}
}
// ---------------------------------------------------------------------------
// insert_signature — with quoted content (reply/forward)
// ---------------------------------------------------------------------------
func TestInsertSignature_BeforeQuote(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Reply with sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>My reply</p><div id="lark-mail-quote-cli123" class="history-quote-wrapper"><div>quoted content</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-456",
RenderedSignatureHTML: "<div>-- Reply Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
sigIdx := strings.Index(html, "Reply Sig")
quoteIdx := strings.Index(html, "quoted content")
if sigIdx < 0 || quoteIdx < 0 {
t.Fatalf("missing signature or quote in: %s", html)
}
if sigIdx > quoteIdx {
t.Error("signature should appear before quote block")
}
}
// ---------------------------------------------------------------------------
// insert_signature — replaces existing signature
// ---------------------------------------------------------------------------
func TestInsertSignature_ReplacesExisting(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Replace sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p><div id="old-sig" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- Old Sig</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "new-sig",
RenderedSignatureHTML: "<div>-- New Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if strings.Contains(html, "Old Sig") {
t.Error("old signature should have been removed")
}
if !strings.Contains(html, "New Sig") {
t.Error("new signature not found")
}
}
// ---------------------------------------------------------------------------
// insert_signature — no HTML body
// ---------------------------------------------------------------------------
func TestInsertSignature_NoHTMLBody(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Plain text
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Just plain text`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-x",
RenderedSignatureHTML: "<div>sig</div>",
}},
})
if err == nil {
t.Fatal("expected error for insert_signature on plain text draft")
}
if !strings.Contains(err.Error(), "no HTML body") {
t.Fatalf("expected 'no HTML body' error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// remove_signature — removes existing signature
// ---------------------------------------------------------------------------
func TestRemoveSignature_Basic(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Remove sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p><div id="sig-rm" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- My Sig</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_signature"}},
})
if err != nil {
t.Fatalf("Apply remove_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if strings.Contains(html, "My Sig") {
t.Error("signature should have been removed")
}
if strings.Contains(html, "lark-mail-signature") {
t.Error("signature wrapper should have been removed")
}
if !strings.Contains(html, "Hello") {
t.Error("body text should be preserved")
}
}
// ---------------------------------------------------------------------------
// remove_signature — no signature present
// ---------------------------------------------------------------------------
func TestRemoveSignature_NoSignature(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: No sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>No signature here</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_signature"}},
})
if err == nil {
t.Fatal("expected error when removing non-existent signature")
}
if !strings.Contains(err.Error(), "no signature found") {
t.Fatalf("expected 'no signature found' error, got: %v", err)
}
}

View File

@@ -4,7 +4,6 @@
package draft
import (
"html"
"regexp"
"strings"
)
@@ -28,18 +27,6 @@ var quoteWrapperRe = regexp.MustCompile(`<div\s[^>]*class="[^"]*` + QuoteWrapper
var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`)
// SignatureWrapperClass is the CSS class for the mail signature container.
const SignatureWrapperClass = "lark-mail-signature"
var signatureWrapperRe = regexp.MustCompile(
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"`)
// signatureIDRe extracts the id from a signature wrapper div, regardless of
// whether id appears before or after the class attribute.
var signatureIDRe = regexp.MustCompile(
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"[^>]*id="([^"]*)"` +
`|<div\s[^>]*id="([^"]*)"[^>]*class="[^"]*` + SignatureWrapperClass)
func Project(snapshot *DraftSnapshot) DraftProjection {
proj := DraftProjection{
Subject: snapshot.Subject,
@@ -58,17 +45,6 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
html := string(part.Body)
proj.BodyHTMLSummary = summarizeHTML(html)
proj.HasQuotedContent = hasQuotedContent(html)
proj.HasSignature = signatureWrapperRe.MatchString(html)
if proj.HasSignature {
if m := signatureIDRe.FindStringSubmatch(html); m != nil {
// alternation regex: id is in m[1] (class-first) or m[2] (id-first)
if m[1] != "" {
proj.SignatureID = m[1]
} else if len(m) >= 3 {
proj.SignatureID = m[2]
}
}
}
}
parts := flattenParts(snapshot.Body)
@@ -152,10 +128,10 @@ func hasQuotedContent(html string) bool {
return quoteWrapperRe.MatchString(html)
}
// SplitAtQuote splits an HTML body into the user-authored content and
// splitAtQuote splits an HTML body into the user-authored content and
// the trailing reply/forward quote block. If no quote block is found,
// quote is empty and body is the original html unchanged.
func SplitAtQuote(html string) (body, quote string) {
func splitAtQuote(html string) (body, quote string) {
loc := quoteWrapperRe.FindStringIndex(html)
if loc == nil {
return html, ""
@@ -163,70 +139,6 @@ func SplitAtQuote(html string) (body, quote string) {
return html[:loc[0]], html[loc[0]:]
}
// ── Exported signature HTML utilities ──
// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package).
// signatureSpacingRe matches 1-2 empty-line divs before the signature.
var signatureSpacingRe = regexp.MustCompile(
`(?:<div[^>]*><div[^>]*><br></div></div>\s*){1,2}$`)
// SignatureSpacingRe returns the compiled regex for signature spacing detection.
func SignatureSpacingRe() *regexp.Regexp { return signatureSpacingRe }
// SignatureSpacing returns the 2 empty-line divs placed before the signature,
// matching the structure generated by the Lark mail editor.
func SignatureSpacing() string {
line := `<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto"><br></div></div>`
return line + line
}
// BuildSignatureHTML wraps signature content in the standard signature container div.
// sigID is HTML-escaped to prevent attribute injection.
func BuildSignatureHTML(sigID, content string) string {
return `<div id="` + html.EscapeString(sigID) + `" class="` + SignatureWrapperClass + `" style="padding-top:6px;padding-bottom:6px">` + content + `</div>`
}
// FindMatchingCloseDiv finds the position after the closing </div> that matches
// the <div at startPos, tracking nesting depth.
func FindMatchingCloseDiv(html string, startPos int) int {
depth := 0
i := startPos
for i < len(html) {
if strings.HasPrefix(html[i:], "<div") {
depth++
i += 4
} else if strings.HasPrefix(html[i:], "</div>") {
depth--
i += 6
if depth == 0 {
return i
}
} else {
i++
}
}
return len(html)
}
// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML.
// Returns the HTML unchanged if no signature is found.
func RemoveSignatureHTML(html string) string {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return html
}
sigStart := loc[0]
sigEnd := FindMatchingCloseDiv(html, sigStart)
// Extend backward to include preceding spacing.
beforeSig := html[:sigStart]
if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil {
sigStart = spacingLoc[0]
}
return html[:sigStart] + html[sigEnd:]
}
func summarizeHTML(html string) string {
trimmed := strings.TrimSpace(html)
runes := []rune(trimmed)

View File

@@ -100,7 +100,7 @@ Content-Type: text/html; charset=UTF-8
func TestSplitAtQuoteReply(t *testing.T) {
html := `<div>My reply</div><div class="history-quote-wrapper"><div>quoted</div></div>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != `<div>My reply</div>` {
t.Fatalf("body = %q", body)
}
@@ -111,7 +111,7 @@ func TestSplitAtQuoteReply(t *testing.T) {
func TestSplitAtQuoteForward(t *testing.T) {
html := `<div>note</div><div id="lark-mail-quote-cli123456" class="history-quote-wrapper"><div>quoted</div></div>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != `<div>note</div>` {
t.Fatalf("body = %q", body)
}
@@ -122,7 +122,7 @@ func TestSplitAtQuoteForward(t *testing.T) {
func TestSplitAtQuoteNoQuote(t *testing.T) {
html := `<div>no quote here</div>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != html {
t.Fatalf("body = %q, want original html", body)
}
@@ -169,7 +169,7 @@ Content-Type: text/html; charset=UTF-8
func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
html := `<p>The CSS class history-quote-wrapper is used for quotes.</p>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != html {
t.Fatalf("body should be unchanged, got %q", body)
}

View File

@@ -1933,23 +1933,6 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
return nil
}
// buildSendResult builds the output map for a successful send, including
// recall tip if the backend indicates the message is recallable.
func buildSendResult(resData map[string]interface{}, mailboxID string) map[string]interface{} {
result := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}
if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" {
messageID, _ := resData["message_id"].(string)
result["recall_available"] = true
result["recall_tip"] = fmt.Sprintf(
`This message can be recalled within 24 hours. To recall: lark-cli mail user_mailbox.sent_messages recall --params '{"user_mailbox_id":"%s","message_id":"%s"}'`,
mailboxID, messageID)
}
return result
}
// validateFolderReadScope checks that the user's token includes the
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
// before hitting the folders API. System folders are resolved locally and

View File

@@ -46,7 +46,6 @@ var MailDraftCreate = common.Shortcut{
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
signatureFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
input, err := parseDraftCreateInput(runtime)
@@ -73,9 +72,6 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the full email body")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
@@ -86,15 +82,11 @@ var MailDraftCreate = common.Shortcut{
if err != nil {
return err
}
rawEML, err := buildRawEMLForDraftCreate(runtime, input)
if err != nil {
return err
}
mailboxID := resolveComposeMailboxID(runtime)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
if err != nil {
return err
}
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult)
if err != nil {
return err
}
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("create draft failed: %w", err)
@@ -129,7 +121,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
return input, nil
}
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult) (string, error) {
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput) (string, error) {
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
@@ -161,18 +153,12 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
var autoResolvedPaths []string
if input.PlainText {
bld = bld.TextBody([]byte(input.Body))
} else if bodyIsHTML(input.Body) || sigResult != nil {
htmlBody := input.Body
if !bodyIsHTML(input.Body) {
htmlBody = buildBodyDiv(input.Body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
} else if bodyIsHTML(input.Body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body)
if resolveErr != nil {
return "", resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -183,7 +169,6 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return "", err
}

View File

@@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
Body: `<p>Hello <b>world</b></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
Attach: "./big.txt",
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err == nil {
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
}
@@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err == nil {
t.Fatal("expected error for orphaned --inline CID not referenced in body")
}
@@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err == nil {
t.Fatal("expected error for missing CID reference")
}
@@ -153,7 +153,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
PlainText: true,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}

View File

@@ -92,24 +92,6 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return output.ErrValidation("parse draft raw EML failed: %v", err)
}
// Pre-process insert_signature ops: resolve signature using the draft's
// From address so alias/shared-mailbox senders get correct template vars.
var draftFromEmail string
if len(snapshot.From) > 0 {
draftFromEmail = snapshot.From[0].Address
}
for i := range patch.Ops {
if patch.Ops[i].Op == "insert_signature" {
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
if sigErr != nil {
return sigErr
}
if sigResult != nil {
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
patch.Ops[i].SignatureImages = sigResult.Images
}
}
}
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
return output.ErrValidation("apply draft patch failed: %v", err)
@@ -331,8 +313,6 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature from the HTML body"},
},
"supported_ops_by_group": []map[string]interface{}{
{
@@ -368,13 +348,6 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
},
{
"group": "signature",
"ops": []map[string]interface{}{
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature and its preceding spacing from the HTML body"},
},
},
},
"recommended_usage": []string{
"Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits",

View File

@@ -34,7 +34,7 @@ var MailForward = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
signatureFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
@@ -64,9 +64,6 @@ var MailForward = common.Shortcut{
return err
}
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -80,12 +77,7 @@ var MailForward = common.Shortcut{
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -122,7 +114,7 @@ var MailForward = common.Shortcut{
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -146,13 +138,8 @@ var MailForward = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + forwardQuote
fullHTML := resolved + forwardQuote
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -163,7 +150,7 @@ var MailForward = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
@@ -235,7 +222,10 @@ var MailForward = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -32,7 +32,7 @@ var MailReply = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
signatureFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -56,9 +56,6 @@ var MailReply = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -77,12 +74,7 @@ var MailReply = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -100,7 +92,7 @@ var MailReply = common.Shortcut{
}
replyTo = mergeAddrLists(replyTo, toFlag)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -147,13 +139,8 @@ var MailReply = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
fullHTML := resolved + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -164,7 +151,7 @@ var MailReply = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
@@ -198,7 +185,10 @@ var MailReply = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -33,7 +33,7 @@ var MailReplyAll = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
signatureFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -57,9 +57,6 @@ var MailReplyAll = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -79,12 +76,7 @@ var MailReplyAll = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -118,7 +110,7 @@ var MailReplyAll = common.Shortcut{
return err
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -161,13 +153,8 @@ var MailReplyAll = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
fullHTML := resolved + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -178,7 +165,7 @@ var MailReplyAll = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
@@ -212,7 +199,10 @@ var MailReplyAll = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -32,7 +32,7 @@ var MailSend = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
signatureFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
to := runtime.Str("to")
subject := runtime.Str("subject")
@@ -62,9 +62,6 @@ var MailSend = common.Shortcut{
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -79,13 +76,6 @@ var MailSend = common.Shortcut{
confirmSend := runtime.Bool("confirm-send")
senderEmail := resolveComposeSenderEmail(runtime)
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
if err != nil {
return err
}
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
Subject(subject).
@@ -106,19 +96,12 @@ var MailSend = common.Shortcut{
var autoResolvedPaths []string
if plainText {
bld = bld.TextBody([]byte(body))
} else if bodyIsHTML(body) || sigResult != nil {
// If signature is requested on plain-text body, auto-upgrade to HTML.
htmlBody := body
if !bodyIsHTML(body) {
htmlBody = buildBodyDiv(body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
} else if bodyIsHTML(body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body)
if resolveErr != nil {
return resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -129,7 +112,6 @@ var MailSend = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return err
}
@@ -150,6 +132,7 @@ var MailSend = common.Shortcut{
return fmt.Errorf("failed to build EML: %w", err)
}
mailboxID := resolveComposeMailboxID(runtime)
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
@@ -166,7 +149,10 @@ var MailSend = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
return nil
},
}

View File

@@ -1,216 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"regexp"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/signature"
)
var MailSignature = common.Shortcut{
Service: "mail",
Command: "+signature",
Description: "List or view email signatures with default usage info.",
Risk: "read",
Scopes: []string{"mail:user_mailbox:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "from", Default: "me", Desc: "Mailbox address (default: me)"},
{Name: "detail", Desc: "Signature ID to view rendered details. Omit to list all signatures."},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := runtime.Str("from")
if mailboxID == "" {
mailboxID = "me"
}
return common.NewDryRunAPI().
Desc("List or view email signatures").
GET(mailboxPath(mailboxID, "signatures"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
mailboxID := runtime.Str("from")
if mailboxID == "" {
mailboxID = "me"
}
detailID := runtime.Str("detail")
resp, err := signature.ListAll(runtime, mailboxID)
if err != nil {
return err
}
if detailID != "" {
return executeSignatureDetail(runtime, resp, detailID, mailboxID)
}
return executeSignatureList(runtime, resp)
},
}
func executeSignatureList(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse) error {
// Build default signature ID maps from usages.
sendDefaults := map[string]bool{}
replyDefaults := map[string]bool{}
for _, usage := range resp.Usages {
if usage.SendMailSignatureID != "" && usage.SendMailSignatureID != "0" {
sendDefaults[usage.SendMailSignatureID] = true
}
if usage.ReplySignatureID != "" && usage.ReplySignatureID != "0" {
replyDefaults[usage.ReplySignatureID] = true
}
}
lang := resolveLang(runtime)
items := make([]map[string]interface{}, 0, len(resp.Signatures))
for _, sig := range resp.Signatures {
item := map[string]interface{}{
"id": sig.ID,
"name": sig.Name,
"type": string(sig.SignatureType),
}
if len(sig.Images) > 0 {
item["images"] = len(sig.Images)
}
// Short content preview (rendered for TENANT).
rendered := signature.InterpolateTemplate(&sig, lang, "", "")
item["content_preview"] = contentPreview(rendered, 200, lang)
if sendDefaults[sig.ID] {
item["is_send_default"] = true
}
if replyDefaults[sig.ID] {
item["is_reply_default"] = true
}
items = append(items, item)
}
runtime.OutFormat(
map[string]interface{}{"signatures": items},
&output.Meta{Count: len(items)},
nil,
)
return nil
}
func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse, sigID, mailboxID string) error {
var sig *signature.Signature
for i := range resp.Signatures {
if resp.Signatures[i].ID == sigID {
sig = &resp.Signatures[i]
break
}
}
if sig == nil {
return output.ErrValidation("signature not found: %s", sigID)
}
lang := resolveLang(runtime)
detail := map[string]interface{}{
"id": sig.ID,
"name": sig.Name,
"type": string(sig.SignatureType),
}
// Usage info.
for _, usage := range resp.Usages {
if usage.SendMailSignatureID == sig.ID {
detail["is_send_default"] = true
}
if usage.ReplySignatureID == sig.ID {
detail["is_reply_default"] = true
}
}
// Images metadata — output the full structure from API.
if len(sig.Images) > 0 {
detail["images"] = sig.Images
}
// Template variables (TENANT signatures): show resolved values.
if sig.HasTemplateVars() {
vars := make(map[string]string, len(sig.UserFields))
for key, field := range sig.UserFields {
vars[key] = field.Resolve(lang)
}
detail["template_vars"] = vars
}
// Rendered content preview.
rendered := signature.InterpolateTemplate(sig, lang, "", "")
detail["content_preview"] = contentPreview(rendered, 200, lang)
runtime.Out(detail, nil)
return nil
}
// resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us").
func resolveLang(runtime *common.RuntimeContext) string {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return "zh_cn"
}
cfg, err := runtime.Factory.Config()
if err != nil {
return "zh_cn"
}
app := multi.FindApp(cfg.ProfileName)
if app == nil {
return "zh_cn"
}
switch app.Lang {
case "en":
return "en_us"
case "ja":
return "ja_jp"
default:
return "zh_cn"
}
}
// contentPreview converts HTML to a compact plain-text preview.
// <img> tags become a localized image placeholder, all other tags become
// spaces, then consecutive whitespace is collapsed. Result is truncated
// to maxLen runes.
func contentPreview(html string, maxLen int, lang string) string {
placeholder := "[image]"
if strings.HasPrefix(lang, "zh") {
placeholder = "[图片]"
}
imgRe := regexp.MustCompile(`<img[^>]*>`)
s := imgRe.ReplaceAllString(html, placeholder)
// Strip remaining tags, replacing each with a space.
var result strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
result.WriteByte(' ')
case r == '>':
inTag = false
case !inTag:
result.WriteRune(r)
}
}
// Collapse whitespace and trim.
text := strings.Join(strings.Fields(result.String()), " ")
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) <= maxLen {
return text
}
return string(runes[:maxLen]) + "..."
}

View File

@@ -19,6 +19,5 @@ func Shortcuts() []common.Shortcut {
MailDraftCreate,
MailDraftEdit,
MailForward,
MailSignature,
}
}

View File

@@ -1,157 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"encoding/json"
"regexp"
"strings"
)
// variableMetaProps represents the JSON structure in data-variable-meta-props attributes.
type variableMetaProps struct {
ID string `json:"id"`
Type string `json:"type"` // "text" or "image"
DisplayName string `json:"displayName"` // human-readable label
Width string `json:"width"` // image width (for type=image)
Style string `json:"style"` // CSS style
Circle bool `json:"circle"` // circular image
}
// variableSpanRe matches <span data-variable-meta-props='...'> and captures the JSON and inner content.
// Group 1: JSON attribute value (double-quoted), Group 2: (single-quoted), Group 3: inner content.
//
// Limitation: uses regex instead of DOM parsing (Go has no built-in DOMParser like JS).
// If a variable <span> contains nested <span> tags, [\s\S]*? will match to the
// innermost </span>, potentially truncating content. In practice, Lark's signature
// templates do not nest <span> inside variable spans (verified against mail-editor
// source and test data). If this becomes an issue, consider using golang.org/x/net/html.
var variableSpanRe = regexp.MustCompile(
`<span\s+data-variable-meta-props=(?:"([^"]*?)"|'([^']*?)')>([\s\S]*?)</span>`)
// InterpolateTemplate replaces template variables in a TENANT signature's content HTML.
// For USER signatures (no template variables), it returns sig.Content unchanged.
//
// Parameters:
// - sig: the signature object
// - lang: language code for i18n ("zh_cn", "en_us", "ja_jp")
// - senderName: sender display name (overrides B-NAME)
// - senderEmail: sender email address (overrides B-ENTERPRISE-EMAIL)
func InterpolateTemplate(sig *Signature, lang, senderName, senderEmail string) string {
if !sig.HasTemplateVars() {
return sig.Content
}
// Build value map from user_fields with i18n resolution.
valueMap := make(map[string]string, len(sig.UserFields)+2)
for key, field := range sig.UserFields {
valueMap[key] = field.Resolve(lang)
}
// Fixed injections override API values.
if senderName != "" {
valueMap["B-NAME"] = senderName
}
if senderEmail != "" {
valueMap["B-ENTERPRISE-EMAIL"] = senderEmail
}
// Replace each <span data-variable-meta-props='...'> with interpolated content.
result := variableSpanRe.ReplaceAllStringFunc(sig.Content, func(match string) string {
submatches := variableSpanRe.FindStringSubmatch(match)
if submatches == nil {
return match
}
// JSON is in group 1 (double-quoted) or group 2 (single-quoted).
attrJSON := submatches[1]
if attrJSON == "" {
attrJSON = submatches[2]
}
// Unescape HTML entities in the JSON attribute value.
attrJSON = unescapeHTMLEntities(attrJSON)
var meta variableMetaProps
if err := json.Unmarshal([]byte(attrJSON), &meta); err != nil {
return match // preserve original on parse failure
}
val, ok := valueMap[meta.ID]
if !ok {
val = "" // variable not in map, replace with empty
}
switch meta.Type {
case "text":
return interpolateText(val, meta.Style)
case "image":
return interpolateImage(val, meta)
default:
return val
}
})
return result
}
// interpolateText returns the replacement for a text variable.
func interpolateText(val, style string) string {
if val == "" {
return ""
}
// If value looks like a URL, wrap in <a>.
if isURL(val) {
escaped := escapeHTML(val)
return `<a href="` + escaped + `" target="_blank" rel="noopener noreferrer">` + escaped + `</a>`
}
if style != "" {
return `<span style="` + escapeHTML(style) + `">` + escapeHTML(val) + `</span>`
}
return escapeHTML(val)
}
// interpolateImage returns the replacement for an image variable.
func interpolateImage(val string, meta variableMetaProps) string {
if val == "" {
return ""
}
var attrs []string
attrs = append(attrs, `src="`+escapeHTML(val)+`"`)
if meta.Width != "" {
attrs = append(attrs, `width="`+escapeHTML(meta.Width)+`"`)
}
var styles []string
if meta.Style != "" {
styles = append(styles, meta.Style)
}
if meta.Circle {
styles = append(styles, "border-radius: 100%")
}
if len(styles) > 0 {
attrs = append(attrs, `style="`+escapeHTML(strings.Join(styles, ";"))+`"`)
}
return `<img ` + strings.Join(attrs, " ") + `>`
}
func isURL(s string) bool {
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func unescapeHTMLEntities(s string) string {
s = strings.ReplaceAll(s, "&quot;", `"`)
s = strings.ReplaceAll(s, "&amp;", "&")
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
return s
}

View File

@@ -1,137 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"strings"
"testing"
)
func TestInterpolateTemplate_UserSignatureUnchanged(t *testing.T) {
sig := &Signature{
Content: "<b>My signature</b>",
SignatureType: SignatureTypeUser,
}
got := InterpolateTemplate(sig, "zh_cn", "Alice", "alice@example.com")
if got != sig.Content {
t.Errorf("USER signature should be unchanged, got %q", got)
}
}
func TestInterpolateTemplate_TenantTextVariables(t *testing.T) {
sig := &Signature{
Content: `姓名:<span data-variable-meta-props='{"id":"B-NAME","type":"text"}'>{text}</span>, 部门:<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-NAME", "B-DEPARTMENT"},
UserFields: map[string]UserFieldValue{
"B-NAME": {DefaultVal: "张三", I18nVals: map[string]string{"zh_cn": "", "en_us": "Zhang San"}},
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "研发部", "en_us": "R&D"}},
},
}
// zh_cn: B-DEPARTMENT should resolve to "研发部" (from i18n), B-NAME overridden by senderName
got := InterpolateTemplate(sig, "zh_cn", "李四", "lisi@example.com")
if !strings.Contains(got, "李四") {
t.Errorf("expected senderName override for B-NAME, got %q", got)
}
if !strings.Contains(got, "研发部") {
t.Errorf("expected zh_cn i18n value for B-DEPARTMENT, got %q", got)
}
if strings.Contains(got, "{text}") {
t.Errorf("should not contain raw placeholder {text}, got %q", got)
}
if strings.Contains(got, "data-variable-meta-props") {
t.Errorf("should not contain data-variable-meta-props attribute, got %q", got)
}
}
func TestInterpolateTemplate_I18nFallback(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-DEPARTMENT"},
UserFields: map[string]UserFieldValue{
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "", "en_us": ""}},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, "默认部门") {
t.Errorf("expected fallback to DefaultVal, got %q", got)
}
}
func TestInterpolateTemplate_HTMLEntityEscaping(t *testing.T) {
// Simulate the HTML-entity-escaped attribute format from real API responses.
sig := &Signature{
Content: `<span data-variable-meta-props="{&quot;id&quot;:&quot;B-NAME&quot;,&quot;type&quot;:&quot;text&quot;}">{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-NAME"},
UserFields: map[string]UserFieldValue{
"B-NAME": {DefaultVal: "default"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "陈煌", "")
if !strings.Contains(got, "陈煌") {
t.Errorf("expected interpolated name, got %q", got)
}
}
func TestInterpolateTemplate_URLAsText(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-URL","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-URL"},
UserFields: map[string]UserFieldValue{
"B-URL": {DefaultVal: "https://example.com"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, "<a href=") {
t.Errorf("expected URL to be wrapped in <a> tag, got %q", got)
}
if !strings.Contains(got, "https://example.com") {
t.Errorf("expected URL in output, got %q", got)
}
}
func TestInterpolateTemplate_ImageVariable(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-LOGO","type":"image","width":"40"}'><img src="cid:old"/></span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-LOGO"},
UserFields: map[string]UserFieldValue{
"B-LOGO": {DefaultVal: "cid:new-logo-cid"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, `src="cid:new-logo-cid"`) {
t.Errorf("expected new image src, got %q", got)
}
if !strings.Contains(got, `width="40"`) {
t.Errorf("expected width attribute, got %q", got)
}
}
func TestUserFieldValue_Resolve(t *testing.T) {
v := UserFieldValue{
DefaultVal: "default",
I18nVals: map[string]string{"zh_cn": "中文", "en_us": "", "ja_jp": "日本語"},
}
if got := v.Resolve("zh_cn"); got != "中文" {
t.Errorf("zh_cn = %q, want 中文", got)
}
if got := v.Resolve("en_us"); got != "default" {
t.Errorf("en_us (empty) should fallback to default, got %q", got)
}
if got := v.Resolve("ja_jp"); got != "日本語" {
t.Errorf("ja_jp = %q, want 日本語", got)
}
if got := v.Resolve("fr_fr"); got != "default" {
t.Errorf("unknown lang should fallback, got %q", got)
}
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
// SignatureType represents the type of a mail signature.
type SignatureType string
const (
SignatureTypeUser SignatureType = "USER"
SignatureTypeTenant SignatureType = "TENANT"
)
// SignatureDevice represents the device platform a signature is designed for.
type SignatureDevice string
const (
DevicePC SignatureDevice = "PC"
DeviceMobile SignatureDevice = "MOBILE"
)
// SignatureImage holds metadata for an inline image embedded in a signature.
type SignatureImage struct {
ImageName string `json:"image_name,omitempty"`
FileKey string `json:"file_key,omitempty"`
CID string `json:"cid,omitempty"`
FileSize string `json:"file_size,omitempty"`
Header string `json:"header,omitempty"`
ImageWidth int32 `json:"image_width,omitempty"`
ImageHeight int32 `json:"image_height,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
}
// UserFieldValue holds a template variable value with multi-language support.
type UserFieldValue struct {
DefaultVal string `json:"default_val"`
I18nVals map[string]string `json:"i18n_vals"` // keys: "zh_cn", "en_us", "ja_jp"
}
// Resolve returns the localized value for the given language code.
// Falls back to DefaultVal when the language key is missing or empty.
func (v UserFieldValue) Resolve(lang string) string {
if val, ok := v.I18nVals[lang]; ok && val != "" {
return val
}
return v.DefaultVal
}
// Signature represents a single mail signature returned by the API.
type Signature struct {
ID string `json:"id"`
Name string `json:"name"`
SignatureType SignatureType `json:"signature_type"`
SignatureDevice SignatureDevice `json:"signature_device"`
Content string `json:"content"`
Images []SignatureImage `json:"images,omitempty"`
TemplateJSONKeys []string `json:"template_json_keys,omitempty"`
UserFields map[string]UserFieldValue `json:"user_fields,omitempty"`
}
// IsTenant returns true if this is a tenant/corporate signature with template variables.
func (s *Signature) IsTenant() bool {
return s.SignatureType == SignatureTypeTenant
}
// HasTemplateVars returns true if the signature contains template variables that need interpolation.
func (s *Signature) HasTemplateVars() bool {
return len(s.TemplateJSONKeys) > 0
}
// SignatureUsage indicates which signature is used by default for a given email address.
type SignatureUsage struct {
EmailAddress string `json:"email_address"`
SendMailSignatureID string `json:"send_mail_signature_id"`
ReplySignatureID string `json:"reply_signature_id"`
}
// GetSignaturesResponse is the parsed response from the get_signatures API.
type GetSignaturesResponse struct {
Signatures []Signature `json:"signatures"`
Usages []SignatureUsage `json:"usages"`
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"encoding/json"
"fmt"
"net/url"
"github.com/larksuite/cli/shortcuts/common"
)
// processCache holds per-mailbox cached responses.
// CLI runs one command per process, so a package-level map is sufficient —
// it is naturally scoped to a single Execute lifecycle.
var processCache = map[string]*GetSignaturesResponse{}
func signaturesPath(mailboxID string) string {
return "/open-apis/mail/v1/user_mailboxes/" + url.PathEscape(mailboxID) + "/settings/signatures"
}
// ListAll fetches all signatures and usage info for a mailbox.
// Results are cached per mailboxID within the current Execute lifecycle.
func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesResponse, error) {
if cached, ok := processCache[mailboxID]; ok {
return cached, nil
}
data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil)
if err != nil {
return nil, fmt.Errorf("get signatures: %w", err)
}
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("get signatures: marshal response: %w", err)
}
var resp GetSignaturesResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("get signatures: unmarshal response: %w", err)
}
processCache[mailboxID] = &resp
return &resp, nil
}
// List returns all signatures for a mailbox.
func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error) {
resp, err := ListAll(runtime, mailboxID)
if err != nil {
return nil, err
}
return resp.Signatures, nil
}
// Get returns a single signature by ID. Returns an error if not found.
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
resp, err := ListAll(runtime, mailboxID)
if err != nil {
return nil, err
}
for i := range resp.Signatures {
if resp.Signatures[i].ID == signatureID {
return &resp.Signatures[i], nil
}
}
return nil, fmt.Errorf("signature not found: %s", signatureID)
}

View File

@@ -1,245 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/signature"
)
// signatureFlag is the common flag definition for --signature-id, shared by all compose shortcuts.
var signatureFlag = common.Flag{
Name: "signature-id",
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
}
// signatureResult holds the pre-processed signature data ready for HTML injection.
type signatureResult struct {
ID string
RenderedContent string
Images []draftpkg.SignatureImage
}
// resolveSignature fetches, interpolates, and downloads images for a signature.
// Returns nil if signatureID is empty.
// resolveSignature fetches, interpolates, and downloads images for a signature.
// fromEmail is the --from address (may be an alias); used to match the correct
// sender identity for template interpolation. Pass "" to use the primary address.
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
if signatureID == "" {
return nil, nil
}
sig, err := signature.Get(runtime, mailboxID, signatureID)
if err != nil {
return nil, err
}
// Resolve sender info for template interpolation.
lang := resolveLang(runtime)
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
// Download signature inline images. The file_key field contains a
// direct download URL provided by the mail backend.
var images []draftpkg.SignatureImage
for _, img := range sig.Images {
if img.DownloadURL == "" || img.CID == "" {
continue
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
ContentType: ct,
FileName: img.ImageName,
Data: data,
})
}
return &signatureResult{
ID: sig.ID,
RenderedContent: rendered,
Images: images,
}, nil
}
// injectSignatureIntoBody inserts signature HTML into the body, before the quote block.
// It removes any existing signature first, then places the new signature between
// the user-authored content and the quote block (if any).
// Returns the new full HTML body.
func injectSignatureIntoBody(bodyHTML string, sig *signatureResult) string {
if sig == nil {
return bodyHTML
}
cleaned := draftpkg.RemoveSignatureHTML(bodyHTML)
userContent, quote := draftpkg.SplitAtQuote(cleaned)
sigBlock := draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sig.ID, sig.RenderedContent)
return userContent + sigBlock + quote
}
// addSignatureImagesToBuilder adds signature inline images to the EML builder.
func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) emlbuilder.Builder {
if sig == nil {
return bld
}
for _, img := range sig.Images {
cid := normalizeInlineCID(img.CID)
if cid == "" {
continue
}
bld = bld.AddInline(img.Data, img.ContentType, img.FileName, cid)
}
return bld
}
// resolveSenderInfo fetches senderName and senderEmail via the send_as API.
// resolveSenderInfo fetches send_as addresses and returns the name/email
// for signature interpolation. If fromEmail is non-empty, it matches
// that address in the sendable list (for alias/send_as scenarios);
// otherwise falls back to the first (primary) address.
func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) {
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
if err != nil {
return "", ""
}
addrs, ok := data["sendable_addresses"].([]interface{})
if !ok || len(addrs) == 0 {
return "", ""
}
// If fromEmail is specified, find the matching address.
if fromEmail != "" {
for _, a := range addrs {
m, ok := a.(map[string]interface{})
if !ok {
continue
}
e, _ := m["email_address"].(string)
if strings.EqualFold(e, fromEmail) {
n, _ := m["name"].(string)
return n, e
}
}
}
// Fall back to the first sendable address (primary).
first, ok := addrs[0].(map[string]interface{})
if !ok {
return "", ""
}
n, _ := first["name"].(string)
e, _ := first["email_address"].(string)
return n, e
}
// downloadSignatureImage downloads a signature image by its direct URL.
// Security: enforces https, does not send Bearer token (URL is pre-signed),
// uses context timeout, and limits response size. Aligned with
// downloadAttachmentContent in helpers.go.
func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err)
}
if u.Scheme != "https" {
return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme)
}
if u.Host == "" {
return nil, "", fmt.Errorf("signature image download: URL has no host")
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
// Do NOT send Authorization: the download URL is pre-signed.
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body))
}
const maxSize = 10 * 1024 * 1024
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, "", fmt.Errorf("signature image download: read body: %w", err)
}
if len(data) > maxSize {
return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit")
}
ct := resp.Header.Get("Content-Type")
if ct == "" || ct == "application/octet-stream" {
ct = contentTypeFromFilename(filename)
}
return data, ct, nil
}
func contentTypeFromFilename(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".svg":
return "image/svg+xml"
case ".bmp":
return "image/bmp"
default:
return "application/octet-stream"
}
}
// signatureCIDs returns the CID list from a signatureResult, for inline CID validation.
func signatureCIDs(sig *signatureResult) []string {
if sig == nil {
return nil
}
cids := make([]string, 0, len(sig.Images))
for _, img := range sig.Images {
cid := normalizeInlineCID(img.CID)
if cid != "" {
cids = append(cids, cid)
}
}
return cids
}
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
}
return nil
}

View File

@@ -1,333 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func dataValidationBasePath(token string) string {
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
validate.EncodePathSegment(token))
}
func dataValidationSheetPath(token, sheetID string) string {
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
}
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
var typed []string
if err := json.Unmarshal([]byte(value), &typed); err != nil {
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
}
if typed == nil {
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
}
arr := make([]interface{}, len(typed))
for i, s := range typed {
arr[i] = s
}
return arr, nil
}
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return nil, err
}
if len(ranges) == 0 {
return nil, common.FlagErrorf("--ranges must not be empty")
}
for i, r := range ranges {
s, _ := r.(string)
if _, _, ok := splitSheetRange(s); !ok {
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
}
}
return ranges, nil
}
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
if err != nil {
return nil, err
}
if len(condValues) == 0 {
return nil, common.FlagErrorf("--condition-values must not be empty")
}
dv := map[string]interface{}{
"conditionValues": condValues,
}
opts := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("multiple") {
opts["multipleValues"] = runtime.Bool("multiple")
}
if runtime.Cmd.Flags().Changed("highlight") {
opts["highlightValidData"] = runtime.Bool("highlight")
}
if runtime.Str("colors") != "" {
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
if err != nil {
return nil, err
}
if len(colors) != len(condValues) {
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
}
opts["colors"] = colors
}
if len(opts) > 0 {
dv["options"] = opts
}
return dv, nil
}
// SheetSetDropdown sets dropdown list validation on a range.
var SheetSetDropdown = common.Shortcut{
Service: "sheets",
Command: "+set-dropdown",
Description: "Set dropdown list on a cell range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetUpdateDropdown updates dropdown list settings for given ranges.
var SheetUpdateDropdown = common.Shortcut{
Service: "sheets",
Command: "+update-dropdown",
Description: "Update dropdown list settings",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, err := validateRangesFlag(runtime); err != nil {
return err
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
Body(map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetGetDropdown queries dropdown list settings for a range.
var SheetGetDropdown = common.Shortcut{
Service: "sheets",
Command: "+get-dropdown",
Description: "Get dropdown list settings for a range",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
Set("token", token).Set("range", runtime.Str("range"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
}, nil,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
var SheetDeleteDropdown = common.Shortcut{
Service: "sheets",
Command: "+delete-dropdown",
Description: "Delete dropdown list from cell ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
_, err := validateRangesFlag(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"dataValidationRanges": dvRanges,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
map[string]interface{}{
"dataValidationRanges": dvRanges,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -1,552 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── SetDropdown ─────────────────────────────────────────────────────────────
func TestSetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": "not-json",
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"mixed types", `["ok", 1, null]`},
{"all numbers", `[1, 2, 3]`},
{"null literal", `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": tc.input,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
}
})
}
}
func TestSetDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "bad-json",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "A2:A100", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `[]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
"colors": `["#FF0000"]`,
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors length") {
t.Fatalf("expected length mismatch error, got: %v", err)
}
}
func TestSetDropdownValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": true, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation: %s", got)
}
if !strings.Contains(got, `"dataValidationType":"list"`) {
t.Fatalf("DryRun missing dataValidationType: %s", got)
}
}
func TestSetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dv, _ := body["dataValidation"].(map[string]interface{})
opts, _ := dv["options"].(map[string]interface{})
if opts["multipleValues"] != true {
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
}
if opts["highlightValidData"] != true {
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
}
}
func TestSetDropdownExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
func TestSetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── UpdateDropdown ──────────────────────────────────────────────────────────
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": "not-json", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `[]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "{not-array}",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestUpdateDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `sheet1`) {
t.Fatalf("DryRun missing sheet_id: %s", got)
}
}
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["new1","new2"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["opt1"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetDropdown ─────────────────────────────────────────────────────────────
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestGetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation path: %s", got)
}
}
func TestGetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{
map[string]interface{}{
"dataValidationType": "list",
"conditionValues": []interface{}{"opt1", "opt2"},
"ranges": []interface{}{"s1!A2:A100"},
},
},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "dataValidations") {
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
}
}
func TestGetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteDropdown ──────────────────────────────────────────────────────────
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestDeleteDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dataValidationRanges`) {
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
}
}
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"rangeResults": []interface{}{
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
},
}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "rangeResults") {
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
}
}
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dvRanges, _ := body["dataValidationRanges"].([]interface{})
if len(dvRanges) != 2 {
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
}
}
func TestDeleteDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// suppress unused import for bytes in case the test helpers already import it
var _ = (*bytes.Buffer)(nil)

View File

@@ -36,9 +36,5 @@ func Shortcuts() []common.Shortcut {
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
}
}

View File

@@ -1,177 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// presentationRef holds a parsed --presentation input.
//
// Slides shortcuts accept three input shapes:
// - a raw xml_presentation_id token
// - a slides URL like https://<host>/slides/<token>
// - a wiki URL like https://<host>/wiki/<token> (must resolve to obj_type=slides)
type presentationRef struct {
Kind string // "slides" | "wiki"
Token string
}
// parsePresentationRef extracts a presentation token from a token, slides URL, or wiki URL.
// Wiki tokens are returned unresolved; callers must run resolveWikiToSlidesToken to
// obtain the real xml_presentation_id and verify obj_type=slides.
func parsePresentationRef(input string) (presentationRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
}
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
// appear as a prefix of the URL path. Substring matching previously let
// e.g. `https://x/docx/foo?next=/slides/abc` resolve to token "abc".
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
return presentationRef{Kind: "slides", Token: token}, nil
}
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
return presentationRef{Kind: "wiki", Token: token}, nil
}
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
// Non-URL input must be a bare token — anything with path/query/fragment
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
// get silently accepted.
if strings.ContainsAny(raw, "/?#") {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
return presentationRef{Kind: "slides", Token: raw}, nil
}
// tokenAfterPathPrefix extracts the first path segment after prefix from path.
// Returns ("", false) if path doesn't start with prefix or the segment is empty.
func tokenAfterPathPrefix(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
// resolvePresentationID resolves a parsed ref into an xml_presentation_id.
// Slides refs pass through; wiki refs are looked up via wiki.spaces.get_node and
// must resolve to obj_type=slides.
func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef) (string, error) {
switch ref.Kind {
case "slides":
return ref.Token, nil
case "wiki":
data, err := runtime.CallAPI(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return "", err
}
node := common.GetMap(data, "node")
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
}
if objType != "slides" {
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
}
return objToken, nil
default:
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
}
}
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
// The "@" prefix is the magic marker for "this is a local file path; upload it and
// replace with file_token".
//
// Match groups:
//
// 1: opening quote character (so we can replace symmetrically)
// 2: the path string (everything inside the quotes after the leading @)
//
// We deliberately scope to <img ... src="@..."> rather than any src= so other
// schema elements (like icon/iconType) aren't accidentally rewritten.
// `\s*=\s*` tolerates `src = "..."` style attributes (XML allows whitespace
// around `=`); without it we'd silently leave such placeholders unrewritten.
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
// referenced via <img src="@path"> in the given slide XML strings.
//
// Order is preserved (first occurrence wins) so dry-run / progress messages are
// stable across runs.
func extractImagePlaceholderPaths(slideXMLs []string) []string {
var paths []string
seen := map[string]bool{}
for _, xml := range slideXMLs {
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
for _, m := range matches {
if m[1] != m[3] {
// Mismatched opening/closing quotes — Go's RE2 has no backreferences,
// so we filter it here. Treat as malformed XML and skip.
continue
}
path := strings.TrimSpace(m[2])
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
// XML by looking up each path in tokens. Paths missing from the map are left
// untouched (callers should ensure the map is complete).
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
if len(sub) < 4 {
return match
}
quote, path, closeQuote := sub[1], sub[2], sub[3]
if quote != closeQuote {
// Mismatched quotes — see extractImagePlaceholderPaths.
return match
}
token, ok := tokens[strings.TrimSpace(path)]
if !ok {
return match
}
// Replace only the `"@<path>"` segment (quotes inclusive) so any
// surrounding attrs and whitespace around `=` stay intact. Looking up
// by the literal `@<path>"` (with closing quote) avoids accidentally
// matching the same path elsewhere in the tag.
oldQuoted := fmt.Sprintf("%s@%s%s", quote, path, closeQuote)
newQuoted := fmt.Sprintf("%s%s%s", quote, token, closeQuote)
return strings.Replace(match, oldQuoted, newQuoted, 1)
})
}

View File

@@ -1,191 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"reflect"
"strings"
"testing"
)
func TestParsePresentationRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantKind string
wantToken string
wantErr string
}{
{name: "raw token", input: "slidesXXXXXXXXXXXXXXXXXXXXXX", wantKind: "slides", wantToken: "slidesXXXXXXXXXXXXXXXXXXXXXX"},
{name: "slides URL", input: "https://x.feishu.cn/slides/abc123", wantKind: "slides", wantToken: "abc123"},
{name: "slides URL with query", input: "https://x.feishu.cn/slides/abc123?from=share", wantKind: "slides", wantToken: "abc123"},
{name: "slides URL with anchor", input: "https://x.feishu.cn/slides/abc123#p1", wantKind: "slides", wantToken: "abc123"},
{name: "wiki URL", input: "https://x.feishu.cn/wiki/wikcn123", wantKind: "wiki", wantToken: "wikcn123"},
{name: "trims whitespace", input: " abc123 ", wantKind: "slides", wantToken: "abc123"},
{name: "empty", input: "", wantErr: "cannot be empty"},
{name: "blank", input: " ", wantErr: "cannot be empty"},
{name: "unsupported url", input: "https://x.feishu.cn/docx/foo", wantErr: "unsupported"},
{name: "unsupported path", input: "foo/bar", wantErr: "unsupported"},
// Regression: /slides/ inside a query string must NOT be treated as a slides marker.
{name: "slides marker inside query", input: "https://x.feishu.cn/docx/foo?next=/slides/abc", wantErr: "unsupported"},
// Regression: /wiki/ as a path segment but not a prefix must not match.
{name: "wiki marker mid-path", input: "https://x.feishu.cn/docx/wiki/wikcn123", wantErr: "unsupported"},
// Regression: bare relative path containing wiki/ is not a wiki ref.
{name: "non-url wiki segment", input: "tmp/wiki/wikcn123", wantErr: "unsupported"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parsePresentationRef(tt.input)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want substring %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Kind != tt.wantKind || got.Token != tt.wantToken {
t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken)
}
})
}
}
func TestExtractImagePlaceholderPaths(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in []string
want []string
}{
{
name: "no placeholders",
in: []string{`<slide><data><img src="https://x.com/a.png"/></data></slide>`},
want: nil,
},
{
name: "single placeholder",
in: []string{`<slide><data><img src="@./pic.png" topLeftX="10"/></data></slide>`},
want: []string{"./pic.png"},
},
{
name: "single quotes",
in: []string{`<img src='@./a.png'/>`},
want: []string{"./a.png"},
},
{
name: "dedup across slides",
in: []string{
`<slide><data><img src="@./shared.png"/></data></slide>`,
`<slide><data><img src="@./shared.png" topLeftX="100"/><img src="@./other.png"/></data></slide>`,
},
want: []string{"./shared.png", "./other.png"},
},
{
name: "ignores non-img src",
in: []string{`<icon src="@./fake.png"/><img src="@./real.png"/>`},
want: []string{"./real.png"},
},
{
name: "preserves order of first occurrence",
in: []string{`<img src="@b.png"/><img src="@a.png"/><img src="@b.png"/>`},
want: []string{"b.png", "a.png"},
},
{
// Regression: Go RE2 has no backreferences, so the regex captures
// opening and closing quotes independently. Mismatched pairs must
// be filtered out post-match instead of producing bogus paths.
name: "rejects mismatched quotes",
in: []string{`<img src="@./oops.png'/>`},
want: nil,
},
{
// Regression: XML allows whitespace around `=`; placeholders in
// `src = "@..."` form must still be detected.
name: "tolerates whitespace around equals",
in: []string{`<img src = "@./spaced.png" />`},
want: []string{"./spaced.png"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := extractImagePlaceholderPaths(tt.in)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
})
}
}
func TestReplaceImagePlaceholders(t *testing.T) {
t.Parallel()
tokens := map[string]string{
"./pic.png": "tok_abc",
"./b.png": "tok_b",
}
tests := []struct {
name string
in string
want string
}{
{
name: "single replacement preserves siblings",
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
},
{
name: "multiple replacements",
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
want: `<img src="tok_abc"/><img src="tok_b"/>`,
},
{
name: "single quotes",
in: `<img src='@./pic.png'/>`,
want: `<img src='tok_abc'/>`,
},
{
name: "leaves unknown placeholder untouched",
in: `<img src="@./missing.png"/>`,
want: `<img src="@./missing.png"/>`,
},
{
name: "leaves http url alone",
in: `<img src="https://x.com/a.png"/>`,
want: `<img src="https://x.com/a.png"/>`,
},
{
name: "leaves bare token alone",
in: `<img src="existing_token"/>`,
want: `<img src="existing_token"/>`,
},
{
// Regression: placeholders with whitespace around `=` must be
// rewritten too (XML permits the form). Surrounding whitespace
// is preserved so the rewritten attribute reads naturally.
name: "tolerates whitespace around equals",
in: `<img src = "@./pic.png" topLeftX="10"/>`,
want: `<img src = "tok_abc" topLeftX="10"/>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := replaceImagePlaceholders(tt.in, tokens)
if got != tt.want {
t.Fatalf("got %q\nwant %q", got, tt.want)
}
})
}
}

View File

@@ -9,6 +9,5 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
}
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -28,15 +27,10 @@ var SlidesCreate = common.Shortcut{
Description: "Create a Lark Slides presentation",
Risk: "write",
AuthTypes: []string{"user", "bot"},
// docs:document.media:upload is required by the @-placeholder upload path.
// Declared up-front (matching the convention used by other multi-API shortcuts
// like wiki_move) so the pre-flight check fails fast and lark-cli's
// auth login --scope hint guides the user, instead of leaving an orphaned
// empty presentation when the in-flight upload 403s.
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token."},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if slidesStr := runtime.Str("slides"); slidesStr != "" {
@@ -47,21 +41,6 @@ var SlidesCreate = common.Shortcut{
if len(slides) > maxSlidesPerCreate {
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
}
// Validate placeholder paths up front so we don't create a presentation
// only to fail mid-way on a missing local file.
for _, path := range extractImagePlaceholderPaths(slides) {
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return common.FlagErrorf("--slides @%s: must be a regular file", path)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
path, common.FormatSize(stat.Size()))
}
}
}
return nil
},
@@ -82,32 +61,16 @@ var SlidesCreate = common.Shortcut{
var slides []string
_ = json.Unmarshal([]byte(slidesStr), &slides)
n := len(slides)
placeholders := extractImagePlaceholderPaths(slides)
total := n + 1 + len(placeholders)
total := n + 1
descSuffix := ""
if len(placeholders) > 0 {
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(placeholders))
}
dry.Desc(fmt.Sprintf("Create presentation%s + add %d slide(s)", descSuffix, n)).
dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(createBody)
// Upload steps come right after creation so they can use the new
// presentation_id as parent_node.
for i, path := range placeholders {
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
}
slideStepStart := 2 + len(placeholders)
slideDescSuffix := ""
if len(placeholders) > 0 {
slideDescSuffix = " (img placeholders auto-replaced)"
}
for i, slideXML := range slides {
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add slide %d%s", slideStepStart+i, total, i+1, slideDescSuffix)).
Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
})
@@ -158,23 +121,6 @@ var SlidesCreate = common.Shortcut{
_ = json.Unmarshal([]byte(slidesStr), &slides) // already validated
if len(slides) > 0 {
// Step 1.5: Upload any @path placeholders, then rewrite slide XML
// with the resulting file_tokens. Uploads run after creation so
// they can use the new presentation_id as parent_node.
placeholders := extractImagePlaceholderPaths(slides)
if len(placeholders) > 0 {
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
}
for i := range slides {
slides[i] = replaceImagePlaceholders(slides[i], tokens)
}
result["images_uploaded"] = uploaded
}
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
@@ -259,33 +205,6 @@ func buildPresentationXML(title string) string {
)
}
// uploadSlidesPlaceholders uploads each unique placeholder path against the
// presentation and returns the path→file_token map. The second return value is
// the number of files successfully uploaded before any error, so callers can
// surface progress in the failure message.
func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID string, paths []string) (map[string]string, int, error) {
tokens := make(map[string]string, len(paths))
for i, path := range paths {
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return tokens, i, output.ErrValidation("@%s: must be a regular file", path)
}
fileName := filepath.Base(path)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
i+1, len(paths), fileName, common.FormatSize(stat.Size()))
token, err := uploadSlidesMedia(runtime, path, fileName, stat.Size(), presentationID)
if err != nil {
return tokens, i, fmt.Errorf("@%s: %w", path, err)
}
tokens[path] = token
}
return tokens, len(paths), nil
}
// xmlEscape escapes special XML characters in text content.
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")

View File

@@ -6,7 +6,6 @@ package slides
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
@@ -652,175 +651,3 @@ func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]i
}
return data
}
// TestSlidesCreateWithImagePlaceholders verifies @path placeholders are uploaded
// once each (with dedup) and replaced with file_tokens before slide.create runs.
//
// Not parallel: uses os.Chdir to pin local file paths to a temp dir.
func TestSlidesCreateWithImagePlaceholders(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("a.png", []byte("aa"), 0o644); err != nil {
t.Fatalf("write a.png: %v", err)
}
if err := os.WriteFile("b.png", []byte("bb"), 0o644); err != nil {
t.Fatalf("write b.png: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_img",
"revision_id": 1,
},
},
})
// Two distinct images → two upload calls. a.png is referenced twice but
// must be uploaded only once.
uploadStubA := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_a"}},
}
uploadStubB := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_b"}},
}
reg.Register(uploadStubA)
reg.Register(uploadStubB)
// Slide stubs: capture the rewritten slide content to assert tokens were
// actually substituted into the XML.
slideStub1 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s1", "revision_id": 2}},
}
slideStub2 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s2", "revision_id": 3}},
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img")
slidesJSON := `[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"10\"/><img src=\"@b.png\" topLeftX=\"20\"/></data></slide>",
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"30\"/></data></slide>"
]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Img test",
"--slides", slidesJSON,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["images_uploaded"] != float64(2) {
t.Fatalf("images_uploaded = %v, want 2 (a.png deduped)", data["images_uploaded"])
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
// Assert each slide.create body uses tokens (not @path placeholders), and
// that both upload tokens reach at least one slide so a buggy mapping
// where `@b.png` got rewritten to `tok_a` would still fail.
hasTokB := false
for _, stub := range []*httpmock.Stub{slideStub1, slideStub2} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
slide, _ := body["slide"].(map[string]interface{})
content, _ := slide["content"].(string)
if strings.Contains(content, "@a.png") || strings.Contains(content, "@b.png") {
t.Fatalf("slide content still contains placeholder: %s", content)
}
if !strings.Contains(content, "tok_a") {
t.Fatalf("slide content missing tok_a: %s", content)
}
if strings.Contains(content, "tok_b") {
hasTokB = true
}
}
if !hasTokB {
t.Fatal("expected at least one slide body to contain tok_b")
}
}
// TestSlidesCreatePlaceholderFileMissing verifies validation rejects a missing local file
// up front, before the presentation is created.
func TestSlidesCreatePlaceholderFileMissing(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
// No HTTP mocks registered — Validate must reject before any API call.
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
slidesJSON := `["<slide><data><img src=\"@./missing.png\"/></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "missing img",
"--slides", slidesJSON,
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for missing placeholder file")
}
if !strings.Contains(err.Error(), "missing.png") {
t.Fatalf("err = %v, want mention of missing.png", err)
}
}
// TestSlidesCreateWithPlaceholdersDryRun verifies dry-run lists upload steps
// with placeholder files counted into the total.
func TestSlidesCreateWithPlaceholdersDryRun(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("p1.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile("p2.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
slidesJSON := `["<slide><data><img src=\"@p1.png\"/><img src=\"@p2.png\"/></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "dry imgs",
"--slides", slidesJSON,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Bookend step markers: [1/4] = create presentation, [4/4] = add slide 1.
// Upload steps in between use the helper's own [N] labels (no /total).
for _, marker := range []string{"[1/4]", "[4/4]"} {
if !strings.Contains(out, marker) {
t.Fatalf("dry-run missing %s, got: %s", marker, out)
}
}
if strings.Count(out, "upload_all") != 2 {
t.Fatalf("dry-run should contain 2 upload_all calls, got: %s", out)
}
if !strings.Contains(out, slidesMediaParentType) {
t.Fatalf("dry-run missing parent_type %q, got: %s", slidesMediaParentType, out)
}
if !strings.Contains(out, "Create presentation + upload 2 image(s)") {
t.Fatalf("dry-run header should describe upload count, got: %s", out)
}
}

View File

@@ -1,151 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"path/filepath"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// slidesMediaParentType is the only parent_type the slides backend accepts for
// media uploaded against an xml_presentation. Verified empirically:
// `slide_image` returns 1061001 unknown error, `slides_image` / `slides_file`
// return 1061002 params error, but `slide_file` returns a valid file_token
// that can be used as <img src="..."> in slide XML.
//
// NOTE: `slide_file` is only accepted by the single-part upload_all endpoint.
// The multipart upload_prepare endpoint rejects it (99992402 field validation
// failed), so slides image uploads are capped at 20 MB.
const slidesMediaParentType = "slide_file"
// SlidesMediaUpload uploads a local image to drive media against a slides
// presentation and returns the file_token. The token can be used as the value
// of <img src="..."> in slide XML.
//
// This is the atomic building block for getting a local image into a slides
// deck. Higher-level shortcuts (e.g. +create with @path placeholders) reuse
// the same upload helpers.
var SlidesMediaUpload = common.Shortcut{
Service: "slides",
Command: "+media-upload",
Description: "Upload a local image to a slides presentation and return the file_token (use as <img src=...>)",
Risk: "write",
// wiki:node:read is required by the wiki-URL resolution path. Declared
// up-front (matching the convention used by other multi-API shortcuts) so
// users without it get the standard auth login --scope hint at pre-flight.
Scopes: []string{"docs:document.media:upload", "wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local image path (max 20 MB)", Required: true},
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
filePath := runtime.Str("file")
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dry := common.NewDryRunAPI()
parentNode := ref.Token
stepBase := 1
if ref.Kind == "wiki" {
parentNode = "<resolved_slides_token>"
stepBase = 2
dry.Desc("2-step orchestration: resolve wiki → upload media").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc("Upload local file to slides presentation")
}
appendSlidesUploadDryRun(dry, filePath, parentNode, stepBase)
return dry.Set("presentation_id", ref.Token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
filepath.Base(filePath), common.FormatSize(stat.Size()))
}
fileName := filepath.Base(filePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> presentation %s\n",
fileName, common.FormatSize(stat.Size()), common.MaskToken(presentationID))
fileToken, err := uploadSlidesMedia(runtime, filePath, fileName, stat.Size(), presentationID)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"size": stat.Size(),
"presentation_id": presentationID,
}, nil)
return nil
},
}
// uploadSlidesMedia is the shared upload helper used by both +media-upload and
// the +create placeholder pipeline. Always uses parent_type=slide_file with the
// presentation_id as parent_node — verified to be the only working combo.
//
// Callers must ensure fileSize ≤ MaxDriveMediaUploadSinglePartSize (20 MB)
// because the multipart upload API does not accept parent_type=slide_file.
func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, presentationID string) (string, error) {
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
fileName, common.FormatSize(fileSize))
}
parent := presentationID
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: slidesMediaParentType,
ParentNode: &parent,
})
}
// appendSlidesUploadDryRun renders the upload_all step for a single file.
func appendSlidesUploadDryRun(d *common.DryRunAPI, filePath, parentNode string, step int) {
d.POST("/open-apis/drive/v1/medias/upload_all").
Desc(fmt.Sprintf("[%d] Upload local file (max 20 MB)", step)).
Body(map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": slidesMediaParentType,
"parent_node": parentNode,
"size": "<file_size>",
"file": "@" + filePath,
})
}

View File

@@ -1,359 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestSlidesMediaUploadBasic verifies the happy path: token + presentation_id
// with a real (small) local file.
func TestSlidesMediaUploadBasic(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_tok_xyz"},
},
}
reg.Register(uploadStub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "img.png",
"--presentation", "pres_abc",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["file_token"] != "file_tok_xyz" {
t.Fatalf("file_token = %v, want file_tok_xyz", data["file_token"])
}
if data["presentation_id"] != "pres_abc" {
t.Fatalf("presentation_id = %v, want pres_abc", data["presentation_id"])
}
if data["file_name"] != "img.png" {
t.Fatalf("file_name = %v, want img.png", data["file_name"])
}
if data["size"] != float64(len("png-bytes")) {
t.Fatalf("size = %v, want %d", data["size"], len("png-bytes"))
}
body := decodeMultipartBody(t, uploadStub)
if got := body.Fields["parent_type"]; got != slidesMediaParentType {
t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType)
}
if got := body.Fields["parent_node"]; got != "pres_abc" {
t.Fatalf("parent_node = %q, want pres_abc", got)
}
if got := body.Fields["file_name"]; got != "img.png" {
t.Fatalf("file_name = %q, want img.png", got)
}
}
// TestSlidesMediaUploadFromSlidesURL verifies that a slides URL is accepted
// and the path-segment token is used as parent_node.
func TestSlidesMediaUploadFromSlidesURL(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("p.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "p.png",
"--presentation", "https://x.feishu.cn/slides/url_token_123?from=share",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeMultipartBody(t, stub)
if got := body.Fields["parent_node"]; got != "url_token_123" {
t.Fatalf("parent_node = %q, want url_token_123", got)
}
data := decodeShortcutData(t, stdout)
if data["presentation_id"] != "url_token_123" {
t.Fatalf("presentation_id = %v, want url_token_123", data["presentation_id"])
}
}
// TestSlidesMediaUploadFromWikiURL verifies wiki URL → get_node lookup is performed
// and the resolved obj_token is used as parent_node.
func TestSlidesMediaUploadFromWikiURL(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "slides",
"obj_token": "real_pres_id",
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
}
reg.Register(uploadStub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "w.png",
"--presentation", "https://x.feishu.cn/wiki/wikcn_xyz",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeMultipartBody(t, uploadStub)
if got := body.Fields["parent_node"]; got != "real_pres_id" {
t.Fatalf("parent_node = %q, want real_pres_id", got)
}
}
// TestSlidesMediaUploadWikiWrongType verifies wiki resolution rejects non-slides docs.
func TestSlidesMediaUploadWikiWrongType(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docx_tok",
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "w.png",
"--presentation", "https://x.feishu.cn/wiki/wikcn",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for non-slides wiki node")
}
if !strings.Contains(err.Error(), "docx") {
t.Fatalf("err = %v, want mention of resolved obj_type", err)
}
}
// TestSlidesMediaUploadFileNotFound verifies a missing local file fails fast.
func TestSlidesMediaUploadFileNotFound(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "missing.png",
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
t.Fatalf("err = %v, want file-not-found error", err)
}
}
// TestSlidesMediaUploadInvalidPresentation verifies validation rejects a bad ref.
func TestSlidesMediaUploadInvalidPresentation(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "any.png",
"--presentation", "https://x.feishu.cn/docx/foo",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for unsupported presentation URL")
}
if !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("err = %v, want 'unsupported' mention", err)
}
}
// TestSlidesMediaUploadDryRun verifies dry-run prints the upload step.
func TestSlidesMediaUploadDryRun(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("dry.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "dry.png",
"--presentation", "pres_abc",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
t.Fatalf("dry-run should mention upload_all, got: %s", out)
}
if !strings.Contains(out, slidesMediaParentType) {
t.Fatalf("dry-run should mention parent_type %q, got: %s", slidesMediaParentType, out)
}
}
// runSlidesShortcut mounts and executes a slides shortcut with the given args.
func runSlidesShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, sc common.Shortcut, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
sc.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// decodeShortcutData parses the JSON envelope and returns the data map.
func decodeShortcutData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data: %#v", envelope)
}
return data
}
// withSlidesTestWorkingDir chdirs to dir for this test (restored on cleanup).
// Not compatible with t.Parallel — chdir is process-wide.
func withSlidesTestWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(cwd)
})
}
type capturedMultipart struct {
Fields map[string]string
Files map[string][]byte
}
func decodeMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipart {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content-type %q: %v", contentType, err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
for {
part, err := reader.NextPart()
if err != nil {
break
}
data := readAll(t, part)
if part.FileName() != "" {
body.Files[part.FormName()] = data
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}
func readAll(t *testing.T, r interface {
Read(p []byte) (n int, err error)
}) []byte {
t.Helper()
var buf bytes.Buffer
tmp := make([]byte, 4096)
for {
n, err := r.Read(tmp)
if n > 0 {
buf.Write(tmp[:n])
}
if err != nil {
break
}
}
return buf.Bytes()
}

View File

@@ -223,7 +223,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
CreateTask,
UpdateTask,
SetAncestorTask,
CommentTask,
CompleteTask,
ReopenTask,
@@ -231,11 +230,7 @@ func Shortcuts() []common.Shortcut {
FollowersTask,
ReminderTask,
GetMyTasks,
GetRelatedTasks,
SearchTask,
SubscribeTaskEvent,
CreateTasklist,
SearchTasklist,
AddTaskToTasklist,
MembersTasklist,
}

View File

@@ -1,155 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
relatedTasksDefaultPageLimit = 20
relatedTasksMaxPageLimit = 40
relatedTasksPageSize = 100
)
var GetRelatedTasks = common.Shortcut{
Service: "task",
Command: "+get-related-tasks",
Description: "list tasks related to me",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{
"user_id_type": "open_id",
"page_size": relatedTasksPageSize,
}
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
params["completed"] = false
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
return common.NewDryRunAPI().
GET("/open-apis/task/v2/task_v2/list_related_task").
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
queryParams.Set("completed", "false")
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = relatedTasksDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = relatedTasksMaxPageLimit
}
if pageLimit > relatedTasksMaxPageLimit {
pageLimit = relatedTasksMaxPageLimit
}
var allItems []interface{}
var lastPageToken string
var lastHasMore bool
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
}
}
data, err := HandleTaskApiResult(result, err, "list related tasks")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
queryParams.Set("page_token", lastPageToken)
}
userOpenID := runtime.UserOpenId()
filtered := make([]map[string]interface{}, 0, len(allItems))
for _, item := range allItems {
task, ok := item.(map[string]interface{})
if !ok {
continue
}
if runtime.Bool("created-by-me") {
creator, _ := task["creator"].(map[string]interface{})
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
continue
}
}
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
continue
}
filtered = append(filtered, outputRelatedTask(task))
}
outData := map[string]interface{}{
"items": filtered,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
if len(filtered) == 0 {
fmt.Fprintln(w, "No related tasks found.")
return
}
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
})
return nil
},
}
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
members, _ := task["members"].([]interface{})
for _, member := range members {
memberObj, _ := member.(map[string]interface{})
role, _ := memberObj["role"].(string)
id, _ := memberObj["id"].(string)
if strings.EqualFold(role, "follower") && id == userOpenID {
return true
}
}
return false
}

View File

@@ -1,207 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestTaskFollowedBy(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
userOpenID string
want bool
}{
{
name: "contains follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
map[string]interface{}{"id": "ou_2", "role": "follower"},
},
},
userOpenID: "ou_2",
want: true,
},
{
name: "missing follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
},
},
userOpenID: "ou_3",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := taskFollowedBy(tt.task, tt.userOpenID)
if got != tt.want {
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetRelatedTasks_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "with page token and incomplete filter",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("include-complete", "false")
_ = cmd.Flags().Set("page-token", "pt_001")
},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
},
{
name: "default query params",
setup: func(cmd *cobra.Command) {},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Bool("include-complete", true, "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
out := GetRelatedTasks.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestGetRelatedTasks_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json created by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "done",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123",
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
},
},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
},
{
name: "pretty pagination followed by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_2",
"items": []interface{}{
map[string]interface{}{
"guid": "task-1",
"summary": "Task One",
"url": "https://example.com/task-1",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "page_token=pt_2",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-2",
"summary": "Task Two",
"url": "https://example.com/task-2",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
},
wantParts: []string{"Task One", "Task Two"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := GetRelatedTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -1,247 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"strconv"
"strings"
"time"
)
func splitAndTrimCSV(input string) []string {
parts := strings.Split(input, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startMillis, endMillis string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
}
hasStart = true
startMillis = startSec + "000"
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
}
hasEnd = true
endMillis = endSec + "000"
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
}
return startMillis, endMillis, nil
}
func parseTimeRangeRFC3339(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startTime, endTime string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
}
hasStart = true
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
}
hasEnd = true
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
}
return startTime, endTime, nil
}
func formatTaskDateTimeMillis(msStr string) string {
if msStr == "" || msStr == "0" {
return ""
}
ms, err := strconv.ParseInt(msStr, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(ms).Local().Format(time.DateTime)
}
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"url": urlVal,
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
out["updated_at"] = updated
}
}
if dueObj, ok := task["due"].(map[string]interface{}); ok {
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
out["due_at"] = dueAt
}
}
}
return out
}
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"description": task["description"],
"status": task["status"],
"source": task["source"],
"mode": task["mode"],
"subtask_count": task["subtask_count"],
"tasklists": task["tasklists"],
"url": urlVal,
}
if creator, ok := task["creator"].(map[string]interface{}); ok {
out["creator"] = creator
}
if members, ok := task["members"].([]interface{}); ok {
out["members"] = members
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
return out
}
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
timeRange := map[string]interface{}{}
if start != "" {
timeRange["start_time"] = start
}
if end != "" {
timeRange["end_time"] = end
}
if len(timeRange) == 0 {
return nil
}
return map[string]interface{}{key: timeRange}
}
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
for k, v := range src {
dst[k] = v
}
}
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
if strings.TrimSpace(query) != "" {
return nil
}
if len(filter) > 0 {
return nil
}
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
}
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
var b strings.Builder
for i, item := range items {
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
if status, _ := item["status"].(string); status != "" {
fmt.Fprintf(&b, " Status: %s\n", status)
}
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(&b, " Created: %s\n", created)
}
if completed, _ := item["completed_at"].(string); completed != "" {
fmt.Fprintf(&b, " Completed: %s\n", completed)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(&b, " URL: %s\n", urlVal)
}
b.WriteString("\n")
}
if hasMore && pageToken != "" {
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
}
return b.String()
}

View File

@@ -1,286 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
)
func TestSplitAndTrimCSV(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
{name: "empty input", input: "", want: []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitAndTrimCSV(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
}{
{
name: "with timestamps and due",
task: map[string]interface{}{
"guid": "task-123",
"summary": "summary",
"url": "https://example.com/task-123&suite_entity_num=t1",
"created_at": "1775174400000",
"due": map[string]interface{}{
"timestamp": "1775174400000",
},
},
},
{
name: "with completed and updated",
task: map[string]interface{}{
"guid": "task-456",
"summary": "done",
"url": "https://example.com/task-456",
"completed_at": "1775174400000",
"updated_at": "1775174400000",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := outputTaskSummary(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected summary output: %#v", got)
}
if got["url"] == "" {
t.Fatalf("expected url in output, got %#v", got)
}
})
}
}
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run("parse:"+tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeMillis(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
}
if tt.wantStart == "" && start != "" {
t.Fatalf("start = %q, want empty", start)
}
if tt.wantEnd == "" && end != "" {
t.Fatalf("end = %q, want empty", end)
}
if tt.wantStart == "non-empty" && start == "" {
t.Fatalf("start should not be empty")
}
if tt.wantEnd == "non-empty" && end == "" {
t.Fatalf("end should not be empty")
}
})
}
filterTests := []struct {
name string
query string
filter map[string]interface{}
wantErr bool
}{
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
}
for _, tt := range filterTests {
t.Run("filter:"+tt.name, func(t *testing.T) {
err := requireSearchFilter(tt.query, tt.filter, "search")
if tt.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
outputTests := []struct {
name string
task map[string]interface{}
}{
{
name: "full related task",
task: map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "todo",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123&suite_entity_num=t1",
"creator": map[string]interface{}{"id": "ou_1"},
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
"created_at": "1775174400000",
"completed_at": "1775174400000",
},
},
{
name: "minimal related task",
task: map[string]interface{}{
"guid": "task-456",
"summary": "Minimal",
"url": "https://example.com/task-456",
},
},
}
for _, tt := range outputTests {
t.Run("output:"+tt.name, func(t *testing.T) {
got := outputRelatedTask(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected related task output: %#v", got)
}
})
}
rangeTests := []struct {
name string
start string
end string
wantNil bool
}{
{name: "empty range", start: "", end: "", wantNil: true},
{name: "full range", start: "1", end: "2", wantNil: false},
}
for _, tt := range rangeTests {
t.Run("range:"+tt.name, func(t *testing.T) {
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
if tt.wantNil && got != nil {
t.Fatalf("expected nil, got %#v", got)
}
if !tt.wantNil && got == nil {
t.Fatalf("expected range filter, got nil")
}
})
}
}
func TestRenderRelatedTasksPretty(t *testing.T) {
tests := []struct {
name string
items []map[string]interface{}
hasMore bool
pageToken string
wantParts []string
}{
{
name: "includes next token",
items: []map[string]interface{}{
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
},
hasMore: true,
pageToken: "pt_123",
wantParts: []string{"Related Task", "Next page token: pt_123"},
},
{
name: "without next token",
items: []map[string]interface{}{
{"guid": "task-456", "summary": "Another Task"},
},
hasMore: false,
pageToken: "",
wantParts: []string{"Another Task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeRFC3339(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
}
if tt.wantStart == "rfc3339" {
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
t.Fatalf("expected RFC3339 start, got %q", start)
}
} else if start != tt.wantStart {
t.Fatalf("unexpected start: %q", start)
}
if tt.wantEnd == "rfc3339" {
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
t.Fatalf("expected RFC3339 end, got %q", end)
}
} else if end != tt.wantEnd {
t.Fatalf("unexpected end: %q", end)
}
})
}
})
}
}

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
taskSearchDefaultPageLimit = 20
taskSearchMaxPageLimit = 40
)
var SearchTask = common.Shortcut{
Service: "task",
Command: "+search",
Description: "search tasks",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
{Name: "follower", Desc: "follower open_ids, comma-separated"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTaskSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = taskSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = taskSearchMaxPageLimit
}
if pageLimit > taskSearchMaxPageLimit {
pageLimit = taskSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasks")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
taskID, _ := itemMap["id"].(string)
if taskID == "" {
continue
}
task, err := getTaskDetail(runtime, taskID)
if err != nil {
metaData, _ := itemMap["meta_data"].(map[string]interface{})
appLink, _ := metaData["app_link"].(string)
enriched = append(enriched, map[string]interface{}{
"guid": taskID,
"url": truncateTaskURL(appLink),
})
continue
}
enriched = append(enriched, outputTaskSummary(task))
}
outData := map[string]interface{}{
"items": enriched,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No tasks found.")
return
}
for i, item := range enriched {
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(w, " Created: %s\n", created)
}
if dueAt, _ := item["due_at"].(string); dueAt != "" {
fmt.Fprintf(w, " Due: %s\n", dueAt)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["creator_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
filter["assignee_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
filter["follower_ids"] = ids
}
if runtime.Cmd.Flags().Changed("completed") {
filter["is_completed"] = runtime.Bool("completed")
}
if dueRange := runtime.Str("due"); dueRange != "" {
start, end, err := parseTimeRangeRFC3339(dueRange)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
}
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
mergeIntoFilter(filter, dueFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
}
}
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
if err != nil {
return nil, err
}
task, _ := data["task"].(map[string]interface{})
if task == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
}
return task, nil
}

View File

@@ -1,300 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTaskSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "query creator due and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "release")
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
_ = cmd.Flags().Set("completed", "true")
_ = cmd.Flags().Set("due", "-1d,+1d")
_ = cmd.Flags().Set("page-token", "pt_123")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" || body["page_token"] != "pt_123" {
t.Fatalf("unexpected body: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := dueTime["start_time"].(string)
endTime, _ := dueTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected due_time: %#v", dueTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
{
name: "assignee follower and incomplete",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("assignee", "ou_assignee")
_ = cmd.Flags().Set("follower", "ou_follower")
_ = cmd.Flags().Set("completed", "false")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
t.Fatalf("unexpected filter: %#v", filter)
}
if filter["is_completed"] != false {
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTaskSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTaskSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
},
{
name: "dry run error on invalid due",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("due", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTask.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSearchTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
},
{
name: "fallback to app link",
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-999",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
},
{
name: "empty pretty with pagination",
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasks found."},
},
{
name: "pretty with next page token",
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_next",
"items": []interface{}{
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-321",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
},
},
})
},
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
var SetAncestorTask = common.Shortcut{
Service: "task",
Command: "+set-ancestor",
Description: "set or clear a task ancestor",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task guid to update", Required: true},
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
taskID := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskID := runtime.Str("task-id")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
QueryParams: queryParams,
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
}
}
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
return err
}
outData := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{
"guid": taskID,
},
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
} else {
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
}
})
return nil
},
}
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
if ancestorID == "" {
return map[string]interface{}{}
}
return map[string]interface{}{
"ancestor_guid": ancestorID,
}
}

View File

@@ -1,166 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildSetAncestorBody(t *testing.T) {
tests := []struct {
name string
ancestorID string
want map[string]interface{}
}{
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildSetAncestorBody(tt.ancestorID)
if len(got) != len(tt.want) {
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
}
for k, want := range tt.want {
if got[k] != want {
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
}
}
})
}
}
func TestSetAncestorTask_DryRun(t *testing.T) {
tests := []struct {
name string
taskID string
ancestor string
wantParts []string
}{
{
name: "with ancestor",
taskID: "task-123",
ancestor: "task-456",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
},
{
name: "clear ancestor",
taskID: "task-123",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("ancestor-id", "", "")
_ = cmd.Flags().Set("task-id", tt.taskID)
if tt.ancestor != "" {
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
out := SetAncestorTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSetAncestorTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "json output with ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"guid": "task-123"`},
},
{
name: "pretty output clears ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
},
{
name: "api-level error (code!=0) returns error",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 10003,
"msg": "permission denied",
},
})
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if out := stdout.String(); out != "" {
t.Fatalf("expected empty stdout on error, got: %s", out)
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
var SubscribeTaskEvent = common.Shortcut{
Service: "task",
Command: "+subscribe-event",
Description: "subscribe to task events",
Risk: "write",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/task/v2/task_v2/task_subscription").
Params(map[string]interface{}{"user_id_type": "open_id"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
QueryParams: queryParams,
})
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
// Parse and validate the envelope to avoid false-success output.
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
}
}
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
return err
}
outData := map[string]interface{}{"ok": true}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
})
return nil
},
}

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSubscribeTaskEvent(t *testing.T) {
tests := []struct {
name string
mode string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "execute json (user identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute json (bot identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute api error",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 401,
"msg": "Unauthorized",
"error": map[string]interface{}{
"log_id": "test-log-id",
},
},
})
},
wantErr: true,
wantParts: []string{"Unauthorized"},
},
{
name: "dry run",
mode: "dryrun",
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch tt.mode {
case "execute":
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
if tt.register != nil {
tt.register(reg)
}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
out := err.Error()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("error missing %q: %s", want, out)
}
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
case "dryrun":
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
}
})
}
}

View File

@@ -1,209 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
tasklistSearchDefaultPageLimit = 20
tasklistSearchMaxPageLimit = 40
)
var SearchTasklist = common.Shortcut{
Service: "task",
Command: "+tasklist-search",
Description: "search tasklists",
Risk: "read",
Scopes: []string{"task:tasklist:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTasklistSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = tasklistSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = tasklistSearchMaxPageLimit
}
if pageLimit > tasklistSearchMaxPageLimit {
pageLimit = tasklistSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasklists")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
tasklistID, _ := itemMap["id"].(string)
if tasklistID == "" {
continue
}
tasklist, err := getTasklistDetail(runtime, tasklistID)
if err != nil {
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklistID,
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
})
continue
}
urlVal, _ := tasklist["url"].(string)
urlVal = truncateTaskURL(urlVal)
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklist["guid"],
"name": tasklist["name"],
"url": urlVal,
"creator": tasklist["creator"],
})
}
outData := map[string]interface{}{
"items": tasklists,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
if len(tasklists) == 0 {
fmt.Fprintln(w, "No tasklists found.")
return
}
for i, tasklist := range tasklists {
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["user_id"] = ids
}
if createTime := runtime.Str("create-time"); createTime != "" {
start, end, err := parseTimeRangeRFC3339(createTime)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
}
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
mergeIntoFilter(filter, timeFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
}
}
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
if err != nil {
return nil, err
}
tasklist, _ := data["tasklist"].(map[string]interface{})
if tasklist == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
}
return tasklist, nil
}

View File

@@ -1,263 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTasklistSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "creator create-time and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("creator", "ou_creator")
_ = cmd.Flags().Set("create-time", "-7d,+0d")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := createTime["start_time"].(string)
endTime, _ := createTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected create_time: %#v", createTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTasklistSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTasklistSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("create-time", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTasklist.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTasklist.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSearchTasklist_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
},
},
})
},
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
},
{
name: "fallback on detail error",
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "tl-fallback"`},
},
{
name: "pretty fallback avoids nil name",
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
},
{
name: "empty pretty with pagination",
args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasklists found."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTasklist
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -23,8 +23,6 @@ type WbCliOutput struct {
}
type WbCliOutputData struct {
To string `json:"to"`
Result struct {
Nodes []interface{} `json:"nodes"`
} `json:"result"`
To string `json:"to"`
Result interface{} `json:"result"`
}

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -30,8 +31,9 @@ var formatCodeMap = map[string]int{
FormatMermaid: 2,
}
var wbUpdateScopes = []string{"board:whiteboard:node:create"}
var wbUpdateScopes = []string{"board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:delete"}
var wbUpdateAuthTypes = []string{"user", "bot"}
var skipDeleteNodesBatchSleep = false // for accelerate UT testing only
var wbUpdateFlags = []common.Flag{
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
@@ -80,6 +82,19 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
token := runtime.Str("whiteboard-token")
overwrite := runtime.Bool("overwrite")
descStr := "will call whiteboard open api to update content."
var delNum int
var err error
if overwrite {
// 还是会读取一下 whiteboard nodes确认是否有节点要删除
delNum, _, err = clearWhiteboardContent(ctx, runtime, token, []string{}, true)
if err != nil {
return common.NewDryRunAPI().Desc("read whiteboard nodes failed: " + err.Error())
}
if delNum > 0 {
descStr += fmt.Sprintf(" %d existing nodes deleted before update.", delNum)
}
}
desc := common.NewDryRunAPI().Desc(descStr)
switch format {
@@ -88,11 +103,7 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
reqBody := rawNodesCreateReq{
Nodes: nodes,
Overwrite: overwrite,
}
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc("create all nodes of the whiteboard.")
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(nodes).Desc("create all nodes of the whiteboard.")
case FormatPlantUML, FormatMermaid:
syntaxType := formatCodeMap[format]
reqBody := plantumlCreateReq{
@@ -100,11 +111,16 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
SyntaxType: syntaxType,
ParseMode: 1,
DiagramType: 0,
Overwrite: overwrite,
}
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc(fmt.Sprintf("create %s node on the whiteboard.", format))
}
if overwrite && delNum > 0 {
// 在 DryRun 中只记录意图,不实际拉取和计算节点
desc.GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Desc("get all nodes of the whiteboard to delete, then filter out newly created ones.")
desc.DELETE(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))).Body("{\"ids\":[\"...\"]}").
Desc(fmt.Sprintf("delete all old nodes of the whiteboard 100 nodes at a time. This API may be called multiple times and is not reversible. %d whiteboard nodes will be deleted while update.", delNum))
}
return desc
}
@@ -169,17 +185,31 @@ type createResponse struct {
} `json:"data"`
}
type deleteResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
type simpleNodeResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Nodes []struct {
Id string `json:"id"`
Children []string `json:"children"`
} `json:"nodes"`
} `json:"data"`
}
type deleteNodeReqBody struct {
Ids []string `json:"ids"`
}
type plantumlCreateReq struct {
PlantUmlCode string `json:"plant_uml_code"`
SyntaxType int `json:"syntax_type"`
DiagramType int `json:"diagram_type,omitempty"`
ParseMode int `json:"parse_mode,omitempty"`
Overwrite bool `json:"overwrite,omitempty"`
}
type rawNodesCreateReq struct {
Nodes []interface{} `json:"nodes"`
Overwrite bool `json:"overwrite,omitempty"`
}
type plantumlCreateResp struct {
@@ -190,7 +220,7 @@ type plantumlCreateResp struct {
} `json:"data"`
}
func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bool) {
func parseWBcliNodes(rawjson []byte) (wbNodes interface{}, err error, isRaw bool) {
var wbOutput WbCliOutput
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
return nil, output.Errorf(output.ExitValidation, "parsing", fmt.Sprintf("unmarshal input json failed: %v", err)), false
@@ -199,17 +229,121 @@ func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bo
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
}
if wbOutput.RawNodes != nil {
wbNodes = wbOutput.RawNodes
wbNodes = struct {
Nodes []interface{} `json:"nodes"`
}{
Nodes: wbOutput.RawNodes,
}
isRaw = true
} else {
if wbOutput.Data.Result.Nodes == nil {
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
}
wbNodes = wbOutput.Data.Result.Nodes
wbNodes = wbOutput.Data.Result
}
return wbNodes, nil, isRaw
}
func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext, wbToken string, newNodeIDs []string, dryRun bool) (int, []string, error) {
resp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
})
if err != nil {
return 0, nil, output.ErrNetwork(fmt.Sprintf("get whiteboard nodes failed: %v", err))
}
if resp.StatusCode != http.StatusOK {
return 0, nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
}
var nodes simpleNodeResp
err = json.Unmarshal(resp.RawBody, &nodes)
if err != nil {
return 0, nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard nodes failed: %v", err))
}
if nodes.Code != 0 {
return 0, nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
}
// 收集所有新节点及其 children 的 ID递归处理
protectedIDs := make(map[string]bool)
for _, id := range newNodeIDs {
protectedIDs[id] = true
}
// 构建 node map 以便快速查找
nodeMap := make(map[string][]string)
if nodes.Data.Nodes != nil {
for _, node := range nodes.Data.Nodes {
nodeMap[node.Id] = node.Children
}
}
// 递归收集所有 children
visited := make(map[string]bool)
var collectChildren func(id string)
collectChildren = func(id string) {
if visited[id] {
return
}
visited[id] = true
if children, ok := nodeMap[id]; ok {
for _, child := range children {
protectedIDs[child] = true
collectChildren(child)
}
}
}
for _, id := range newNodeIDs {
collectChildren(id)
}
// 确定要删除的节点
nodeIds := make([]string, 0, len(nodes.Data.Nodes))
if nodes.Data.Nodes != nil {
for _, node := range nodes.Data.Nodes {
nodeIds = append(nodeIds, node.Id)
}
}
delIds := make([]string, 0, len(nodeIds))
for _, nodeId := range nodeIds {
if !protectedIDs[nodeId] {
delIds = append(delIds, nodeId)
}
}
if dryRun {
return len(delIds), delIds, nil
}
// 实际删除节点按每批最多100个进行切分
for i := 0; i < len(delIds); i += 100 {
if !skipDeleteNodesBatchSleep {
time.Sleep(time.Millisecond * 1000) // 画板内删除大量节点时,内部会有大量写操作,需要稍等一下,避免被限流
}
end := i + 100
if end > len(delIds) {
end = len(delIds)
}
batchIds := delIds[i:end]
delReq := deleteNodeReqBody{
Ids: batchIds,
}
resp, err = runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodDelete,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(wbToken)),
Body: delReq,
})
if err != nil {
return 0, nil, output.ErrNetwork(fmt.Sprintf("delete whiteboard nodes failed: %v", err))
}
if resp.StatusCode != http.StatusOK {
return 0, nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
}
var delResp deleteResponse
err = json.Unmarshal(resp.RawBody, &delResp)
if err != nil {
return 0, nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard delete response failed: %v", err))
}
if delResp.Code != 0 {
return 0, nil, output.ErrAPI(delResp.Code, "delete whiteboard nodes failed", fmt.Sprintf("delete whiteboard nodes failed: %s", delResp.Msg))
}
}
return len(delIds), delIds, nil
}
// updateWhiteboardByCode 使用 plantuml/mermaid 代码更新画板
func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, format string, overwrite bool, idempotentToken string) error {
syntaxType := formatCodeMap[format]
@@ -218,7 +352,6 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
SyntaxType: syntaxType,
ParseMode: 1,
DiagramType: 0, // 0 表示自动识别
Overwrite: overwrite,
}
req := &larkcore.ApiReq{
@@ -250,7 +383,20 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
outData := make(map[string]string)
outData["created_node_id"] = createResp.Data.NodeID
newNodeIDs := []string{createResp.Data.NodeID}
if overwrite {
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, newNodeIDs, false)
if err != nil {
return err
}
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["deleted_nodes_num"] != "" {
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
}
if outData["created_node_id"] != "" {
fmt.Fprintf(w, "New node created.\n")
}
@@ -267,15 +413,11 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
return err
}
outData := make(map[string]string)
reqBody := rawNodesCreateReq{
Nodes: nodes,
Overwrite: overwrite,
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
Body: reqBody,
Body: nodes,
QueryParams: map[string][]string{},
}
if idempotentToken != "" {
@@ -310,7 +452,19 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
}
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
if overwrite {
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, createResp.Data.NodeIDs, false)
if err != nil {
return err
}
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["deleted_nodes_num"] != "" {
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
}
if outData["created_node_ids"] != "" {
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
}

View File

@@ -478,9 +478,36 @@ invalid
}
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
// Skip sleep for testing
origSkip := skipDeleteNodesBatchSleep
skipDeleteNodesBatchSleep = true
defer func() { skipDeleteNodesBatchSleep = origSkip }()
factory, stdout, reg := newUpdateExecuteFactory(t)
// Mock: Create nodes API response with overwrite in request body
// Mock 1: Get existing nodes (for clearWhiteboardContent)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"nodes": []map[string]interface{}{
{
"id": "old-node-1",
"children": []string{},
},
{
"id": "old-node-2",
"children": []string{},
},
},
},
},
})
// Mock 2: Create nodes API response
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/plantuml",
@@ -493,6 +520,16 @@ func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
},
})
// Mock 3: Delete nodes batch
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/batch_delete",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
},
})
source := `graph TD
A-->B`
args := []string{"+update", "--whiteboard-token", "test-token-overwrite", "--input_format", "mermaid", "--overwrite", "--source", source}
@@ -502,9 +539,36 @@ A-->B`
}
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
// Skip sleep for testing
origSkip := skipDeleteNodesBatchSleep
skipDeleteNodesBatchSleep = true
defer func() { skipDeleteNodesBatchSleep = origSkip }()
factory, stdout, reg := newUpdateExecuteFactory(t)
// Mock: Create nodes API response with overwrite in request body
// Mock 1: Get existing nodes (for clearWhiteboardContent)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"nodes": []map[string]interface{}{
{
"id": "old-node-1",
"children": []string{"old-child-1"},
},
{
"id": "old-child-1",
"children": []string{},
},
},
},
},
})
// Mock 2: Create nodes API response
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
@@ -517,6 +581,16 @@ func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
},
})
// Mock 3: Delete nodes batch
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes/batch_delete",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
},
})
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
args := []string{"+update", "--whiteboard-token", "test-token-raw-overwrite", "--input_format", "raw", "--overwrite", "--source", source}
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {

View File

@@ -58,11 +58,7 @@ var WikiNodeCreate = common.Shortcut{
return validateWikiNodeCreateSpec(readWikiNodeCreateSpec(runtime), runtime.As())
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
if runtime.IsBot() {
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
}
return dry
return buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiNodeCreateSpec(runtime)
@@ -74,7 +70,7 @@ var WikiNodeCreate = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy)
runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil)
runtime.Out(wikiNodeCreateOutput(execution), nil)
return nil
},
}
@@ -297,9 +293,6 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
if err != nil {
return nil, err
}
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
}
return &wikiNodeCreateExecution{
Node: node,
@@ -469,15 +462,3 @@ func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interfa
"has_child": node.HasChild,
}
}
func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} {
if execution == nil || execution.Node == nil {
return map[string]interface{}{}
}
out := wikiNodeCreateOutput(execution)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
out["permission_grant"] = grant
}
return out
}

View File

@@ -29,7 +29,6 @@ type fakeWikiNodeCreateClient struct {
spaces map[string]*wikiSpaceRecord
nodes map[string]*wikiNodeRecord
createNode *wikiNodeRecord
returnNilNode bool
createErr error
getSpaceErr error
getNodeErr error
@@ -66,9 +65,6 @@ func (fake *fakeWikiNodeCreateClient) CreateNode(ctx context.Context, spaceID st
if fake.createErr != nil {
return nil, fake.createErr
}
if fake.returnNilNode {
return nil, nil
}
if fake.createNode != nil {
return fake.createNode, nil
}
@@ -85,15 +81,6 @@ func wikiTestConfig() *core.CliConfig {
}
}
func wikiPermissionTestConfig(userOpenID string) *core.CliConfig {
return &core.CliConfig{
AppID: fmt.Sprintf("wiki-permission-test-app-%d", wikiTestConfigSeq.Add(1)),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "wiki"}
@@ -281,26 +268,6 @@ func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) {
}
}
func TestRunWikiNodeCreateRejectsNilCreatedNode(t *testing.T) {
t.Parallel()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library", SpaceType: "my_library"},
},
returnNilNode: true,
}
_, err := runWikiNodeCreate(context.Background(), client, core.AsUser, wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
})
if err == nil || !strings.Contains(err.Error(), "wiki node create returned no node") {
t.Fatalf("expected missing node error, got %v", err)
}
}
func TestWikiNodeCreateDryRunShowsMyLibraryLookup(t *testing.T) {
t.Parallel()
@@ -517,126 +484,3 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
t.Fatalf("stderr = %q, want completed creation message", got)
}
}
func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_created",
"obj_token": "docx_created",
"obj_type": "docx",
"node_type": "origin",
"title": "Wiki Node",
"has_child": false,
},
},
"msg": "success",
},
}
reg.Register(createStub)
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/wik_created/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(permStub)
err := mountAndRunWiki(t, WikiNodeCreate, []string{
"+node-create",
"--space-id", "space_123",
"--title", "Wiki Node",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new wiki node." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal permission body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
if body["perm_type"] != "container" {
t.Fatalf("perm_type = %#v, want %q", body["perm_type"], "container")
}
}
func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_created",
"obj_token": "docx_created",
"obj_type": "docx",
"node_type": "origin",
"title": "Wiki Node",
"has_child": false,
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeCreate, []string{
"+node-create",
"--space-id", "space_123",
"--title", "Wiki Node",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) {
t.Parallel()
if got := augmentWikiNodeCreateOutput(nil, nil); len(got) != 0 {
t.Fatalf("augmentWikiNodeCreateOutput(nil, nil) = %#v, want empty map", got)
}
if got := augmentWikiNodeCreateOutput(nil, &wikiNodeCreateExecution{}); len(got) != 0 {
t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got)
}
}

View File

@@ -6,7 +6,6 @@
- 用户要把本地 `.xlsx` / `.csv` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
## 修改标题

View File

@@ -60,48 +60,6 @@ lark-cli mail user_mailbox.messages -h
`-h` 输出即可用 flag 的权威来源。reference 文档中的参数表可辅助理解语义,但实际 flag 名称以 `-h` 为准。
### 收件人搜索:查找邮箱地址
当需要查找收件人邮箱地址时,使用联系人搜索接口。支持多种搜索方式,如:
- **按人名搜索**:如"给张三发邮件" → query="张三"
- **按邮箱关键词搜索**:如"发到 larkmail 的邮箱" → query="@larkmail"
- **按群名搜索**:如"发给项目群" → query="项目群"
```bash
lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
```
搜索结果包含多种实体类型:
| `type` 值 | `tag` 示例 | 说明 |
|-----------|-----------|------|
| `user` / `chatter` | `chatter` | 个人用户 |
| `enterprise_mail_group` | `mail_group` | 企业邮件组 |
| `chat` / `group` | `chat_group_tenant` / `chat_group_normal` | 群聊(有群邮件地址) |
| `external_contact` | `external_contact` | 外部联系人 |
**处理规则:**
1. 从结果中筛选有 `email` 字段的条目
2. 无论匹配数量多少,都必须列出候选项供用户确认后再使用(搜索是模糊匹配,单条结果不代表精确命中)。展示尽可能多的字段帮助用户区分:
```text
找到以下匹配"张三"的结果:
1. 张三 <zhangsan@example.com>
类型user | 部门:研发团队
---
找到多个匹配"组"的结果,请选择:
1. 团队邮件组 <team@example.com>
类型enterprise_mail_group | 标签mail_group
2. 项目群 <project@example.com>
类型chat | 成员数50 | 标签chat_group_normal
3. 张群 <zhangqun@example.com>
类型user | 部门:研发团队 | 备注名:张群同学
```
可用字段:`name`(名称)、`email`(邮箱)、`department`(部门)、`tag`(标签)、`display_name`(备注名)、`type`(实体类型)、`member_count`(成员数,群类型时展示)。字段为空时省略。
3. 若无匹配,告知用户未找到,建议换关键词或直接提供邮箱地址
4. 用户确认后,将 `email` 传入 compose shortcut 的 `--to` / `--cc` / `--bcc` 参数
**注意:** 用户直接提供完整邮箱地址时不需要搜索,直接使用即可。
### 命令选择:先判断邮件类型,再决定草稿还是发送
| 邮件类型 | 存草稿(不发送) | 直接发送 |
@@ -159,30 +117,6 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
返回每个收件人的投递状态(`status`1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
### 撤回邮件
发送成功后,若响应中包含 `recall_available: true`说明该邮件支持撤回24 小时内已投递的邮件)。
**撤回操作:**
```bash
lark-cli mail user_mailbox.sent_messages recall --as user \
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
```
- 返回 `recall_status: available` 表示撤回请求已受理(异步执行)
- 返回 `recall_status: unavailable` 表示不可撤回,`recall_restriction_reason` 说明原因
**查询撤回进度:**
```bash
lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
```
- `recall_status: in_progress` — 撤回进行中,可稍后再查
- `recall_status: done` — 撤回完成,查看 `recall_result``all_success` / `all_fail` / `some_fail`)和每个收件人的详情
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。

View File

@@ -157,9 +157,7 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
--values '[["=SUM(C2:C5)"]]'
```
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
**限制**
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用
- 公式支持跨表引用(IMPORTRANGE
- @人仅支持同租户用户,单次最多 50 人
- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号
- 下拉列表需先调用设置下拉列表接口,值中的字符串不能包含逗号

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