Compare commits

..

1 Commits

Author SHA1 Message Date
anguohui
4d3c709914 chore: add PPE headers and pin miaoda-cli alpha for testing 2026-07-13 17:00:39 +08:00
998 changed files with 8977 additions and 98838 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,7 +1,4 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -1,5 +1,4 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -9,12 +8,6 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -54,34 +47,6 @@ jobs:
exit 1
fi
plugin-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
# No fetch_meta: the git-archive clean tree must embed only the
# committed meta_data stub (reproduces the bare-module customer state).
- name: Run plugin-integration L4 tests
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
sidecar-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -211,11 +176,7 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
# have dedicated jobs; exclude the whole subtree so none of them runs a
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
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
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
@@ -302,11 +263,6 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -320,23 +276,6 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -370,22 +309,16 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
LARKSUITE_CLI_BRAND: feishu
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -396,68 +329,31 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
id: build_cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
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
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
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
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -467,7 +363,7 @@ jobs:
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
@@ -520,7 +416,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -540,19 +436,10 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
# needed or on a fork, license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Graduation to required is tracked in
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
# consecutive weeks with zero false positives).
# 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 }}" \

View File

@@ -9,40 +9,7 @@ permissions:
contents: read
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
goreleaser:
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -59,79 +26,35 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
needs: build-release
needs: goreleaser
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.14.0'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -25,16 +25,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -250,16 +253,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -10,10 +10,9 @@
## Build & Test
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
make test # Full: vet + unit + integration
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
```
## Notification Opt-Outs
@@ -106,20 +105,6 @@ Signatures that are easy to guess wrong:
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
### Typed data over loose maps
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
### Transcribe faithfully — no silent fallbacks
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
### Use `vfs.*` instead of `os.*`
All filesystem access goes through `internal/vfs`. This enables test mocking.
@@ -131,7 +116,6 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
### Tests
- Every behavior change needs a test alongside the change.
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.

View File

@@ -2,290 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
- **slides**: add history rollback shortcuts (#1714)
- **base**: support per-record batch updates (#1889)
### Bug Fixes
- preserve slides schema issues
- allow jq examples in quality gate dry-runs
- **im**: warn when flag pagination is truncated (#1906)
- **slides**: warn on text shape overflow
- **slides**: exempt chart roundtrip attributes from lint
- **slides**: detect image text occlusion
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
### Documentation
- clarify drive upload overwrite guidance (#1982)
### Tests
- isolate unit tests from user state (#1883)
### Refactoring
- converge success output through a single Emitter that owns the write (#1899)
## [v1.0.73] - 2026-07-20
### Features
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
### Bug Fixes
- **slides**: detect visual elements outside canvas
- reduce public content credential fixture false positives
- standardize CLI shortcut text in English (#1942)
### Documentation
- **base**: reduce filter and update retry loops (#1879)
- **vc**: default transcript routing to smart notes over minutes (#1961)
- clarify local trigger automation (#1958)
### Tests
- synchronize temporary Git maintenance (#1946)
### Misc
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
- [codex] support bot menu events (#1765)
## [v1.0.72] - 2026-07-17
### Features
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [v1.0.71] - 2026-07-16
### Features
- add wiki move-to-drive shortcut (#1869)
- **apps**: add role management shortcuts (#1881)
- **drive**: add secure label support and clarify comment location API (#1913)
### Bug Fixes
- **base**: improve dashboard shortcut guidance (#1787)
### Documentation
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
### Misc
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
- **drive**: optimize drive +delete workflow (#1909)
## [v1.0.70] - 2026-07-15
### Features
- add minutes permission application shortcut (#1876)
- **drive**: support apps in list comments (#1877)
- slide style
- edit ppt template
- **slides**: add sxsd validation to slides lint
- **slides**: validate iconpark icon types in slides lint
- **slides**: lint before create
- **apps**: add automation trigger commands for Miaoda (#1886)
### Bug Fixes
- unify dry-run output contract (#1870)
- **skills**: align skill guidance with the typed error contract (#1786)
- **slides**: limit slides screenshot page requests
- **slides**: detect lark slides text overflow overlap
- **vc**: align meeting query scopes by identity (#1850)
### Documentation
- clarify task search relevance filters (#1884)
- surface minutes permission application in skill description (#1890)
- clarify okr progress children (#1861)
- **slides**: prefer slides xml-get shortcut
- **calendar**: document setting meeting owner via full API (#1903)
### Refactoring
- **slides**: streamline create workflow and validate SML namespaces
### Misc
- **slides**: address PR review feedback
## [v1.0.69] - 2026-07-13
### Features
- support docs fetch selection anchors (#1815)
- **apps**: support modern_html app type with TOS publish path and app type querying
- **im**: show bot sender display names when reading messages (#1829)
- add drive list comments shortcut (#1845)
- support wiki sources in drive export (#1802)
- add application domain with slash command management shortcuts (#1806)
- validate IM idempotency key length (#1797)
- surface reply context and mentions in im.message.receive_v1 (#1798)
### Bug Fixes
- route brand-sensitive endpoints through the resolver (#1836)
### Documentation
- document OKR block XML guidance (#1648)
- refine doubao whiteboard workflow routing (#1841)
- clarify Mindnote token handling (#1827)
### Tests
- isolate semantic waiver fixtures from wall clock
### Misc
- Merge lark sheets development branch (#1833)
## [v1.0.68] - 2026-07-09
### Features
@@ -1722,17 +1438,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -51,27 +51,19 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
live-skills-test: fetch_meta
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
go test -v -count=1 ./cmd/update \
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
# Deliberate: local `make test` exercises the L4 plugin contract by default.
integration-test: build
go test -v -count=1 ./tests/...
@@ -113,14 +105,6 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -285,29 +285,6 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,29 +286,6 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -23,41 +23,6 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -130,13 +130,6 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
if opts.Method == "" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"HTTP method must not be empty").
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
WithParam("<method>")
}
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
return client.RawApiRequest{}, nil, err
@@ -250,9 +243,9 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return apiDryRun(f, request, config, opts)
return apiDryRun(f, request, config, opts.Format)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -304,19 +297,8 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
@@ -344,18 +326,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)

View File

@@ -1,396 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type apiFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func apiPaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -69,7 +69,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -79,42 +79,12 @@ func TestApiCmd_DryRun(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
output := stdout.String()
if !strings.Contains(output, "Dry Run") {
t.Error("expected dry run output")
}
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", got["data"])
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
}
if strings.Contains(stdout.String(), "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
}
}
func TestApiCmd_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
}
}
@@ -182,22 +152,6 @@ func TestApiCmd_MissingArgs(t *testing.T) {
}
}
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected validation error for empty HTTP method")
}
if !strings.Contains(err.Error(), "method") {
t.Fatalf("error should name the method argument, got: %v", err)
}
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -352,9 +306,6 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
@@ -374,33 +325,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
}
savedPath, _ := got["saved_path"].(string)
if savedPath == "" {
t.Fatalf("saved_path missing from output: %#v", got)
}
// The file must land inside the temporary cwd — this pins the isolation
// contract: rolling back TestChdir would leave download.bin in the repo.
wantDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
if err != nil {
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
}
if gotDir != wantDir {
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
}
content, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if string(content) != "fake-binary-content" {
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
}
}
@@ -1074,23 +1000,11 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates auth command tests from the host machine: config, logs
// and the registry cache are redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Domain-completion
// tests read the registry, so without seeding a clean checkout would either
// fail or trigger a remote metadata fetch.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
if err != nil {
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"

View File

@@ -31,7 +31,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -1,130 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -16,14 +16,12 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/adapter/lark/websocket"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
"github.com/larksuite/cli/internal/event/bus"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/transport"
)
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
var domain string
cmd := &cobra.Command{
@@ -46,13 +44,7 @@ func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
}
tr := transport.New()
ingress := &websocket.FeishuSource{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: domain,
Logger: logger,
}
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap, ingress)
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()

View File

@@ -27,7 +27,7 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f, compileCatalog())
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()

View File

@@ -16,7 +16,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/cmd/event/render"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
@@ -24,10 +23,8 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/consume"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -40,10 +37,9 @@ type consumeCmdOpts struct {
maxEvents int
timeout time.Duration
dryRun bool
}
func NewCmdConsume(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
var o consumeCmdOpts
cmd := &cobra.Command{
@@ -61,7 +57,7 @@ Use 'event list' to see all available EventKeys.
Use 'event schema <EventKey>' for parameter details.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConsume(cmd, f, snap, args[0], o)
return runConsume(cmd, f, args[0], o)
},
}
@@ -70,7 +66,6 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
cmd.Flags().BoolVar(&o.dryRun, "dry-run", false, "Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
@@ -81,7 +76,7 @@ Use 'event schema <EventKey>' for parameter details.`,
return cmd
}
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, eventKey string, o consumeCmdOpts) error {
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
ignoreBrokenPipe()
@@ -95,11 +90,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot,
return err
}
entry, ok := snap.Resolve(eventKey)
keyDef, ok := eventlib.Lookup(eventKey)
if !ok {
return unknownEventKeyErr(snap, eventKey)
return unknownEventKeyErr(eventKey)
}
keyDef := entry.Definition()
identity, err := resolveIdentity(cmd, f, keyDef)
if err != nil {
@@ -126,16 +120,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot,
domain := core.ResolveEndpoints(cfg.Brand).Open
// Surface auth errors before forking the bus daemon. A dry run instead
// reports the unusable credential as a blocked precondition: the caller
// asked what would happen, and "a real run would refuse to authenticate"
// is a legitimate part of that answer.
var tokenErr error
// Surface auth errors before forking the bus daemon.
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
if !o.dryRun {
return err
}
tokenErr = err
return err
}
apiClient, err := f.NewAPIClient()
@@ -182,31 +169,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
svc := &appconsume.Service{
Strategies: consumeStrategies,
Identity: identityResolverFunc(func(context.Context, *catalog.Entry) (string, error) { return string(identity), nil }),
Preflight: preflightReaderFunc(func(ctx context.Context, _ *catalog.Entry, _ string) ([]appconsume.Precondition, error) {
return readPreconditions(ctx, pf, appVerErr, tokenErr), nil
}),
}
req := appconsume.Request{
EventKey: eventKey,
Params: paramMap,
JQExpr: o.jqExpr,
OutputDir: outputDir,
DryRun: o.dryRun,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
}
decision, err := svc.Decide(cmd.Context(), entry, req, appconsume.ExecutionContext{API: runtime})
if err != nil {
if err := preflightEventTypes(pf); err != nil {
return err
}
if o.dryRun {
return render.WriteDecisionJSON(f.IOStreams.Out, f.IOStreams.ErrOut, string(identity), decision.View())
if err := preflightScopes(cmd.Context(), pf); err != nil {
return err
}
ctx, cancel := context.WithCancel(cmd.Context())
@@ -237,26 +204,23 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot,
watchStdinEOF(os.Stdin, cancel, errOut)
}
runner := streamRunnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error {
return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
EventKey: eventKey,
Def: keyDef,
Params: decision.NormalizedParams(),
ParamsNormalized: true,
JQExpr: o.jqExpr,
Quiet: o.quiet,
OutputDir: outputDir,
Runtime: runtime,
Out: f.IOStreams.Out,
ErrOut: errOut,
RemoteAPIClient: botRuntime,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
Prepare: prepare,
})
})
return svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: runtime})
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
EventKey: eventKey,
Params: paramMap,
JQExpr: o.jqExpr,
Quiet: o.quiet,
OutputDir: outputDir,
Runtime: runtime,
Out: f.IOStreams.Out,
ErrOut: errOut,
RemoteAPIClient: botRuntime,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
}); err != nil {
return err
}
return nil
}
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
@@ -284,14 +248,10 @@ type preflightCtx struct {
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes
// (user: UAT stored; bot: appVer.TenantScopes). checked reports whether a
// comparison actually happened: "the ledger was unavailable" and "the check
// passed" are different answers, and only the caller can decide how loudly to
// say the first one.
func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err error) {
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
return true, nil
return nil
}
if ctx == nil {
ctx = context.Background()
@@ -301,24 +261,24 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err e
switch {
case pf.identity.IsBot():
if pf.appVer == nil {
return false, nil
return nil
}
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
case pf.identity == core.AsUser:
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
if err != nil || result == nil || result.Scopes == "" {
return false, nil //nolint:nilerr // best-effort: the bus handshake surfaces the real auth error
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
}
storedScopes = result.Scopes
default:
return false, nil
return nil
}
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
if len(missing) == 0 {
return true, nil
return nil
}
return true, errs.NewPermissionError(errs.SubtypeMissingScope,
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).

View File

@@ -1,75 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// A dry run in a degraded environment (the test factory has no reachable
// platform, so every weak read-only check comes back unanswered) still exits
// zero with a structured decision that honestly says "unknown" — and performs
// none of its declared write effects.
func TestDryRun_DegradedEnvironmentStaysHonestAndSideEffectFree(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_test"})
snap := compileCatalog()
tmp := t.TempDir()
prevWD, _ := os.Getwd()
if err := os.Chdir(tmp); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(prevWD) })
cmd := NewCmdConsume(f, snap)
cmd.SetArgs([]string{"im.message.receive_v1", "--as", "bot", "--dry-run", "--output-dir", "events-out"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run must not fail on unusable credentials, got: %v", err)
}
var envelope struct {
OK bool `json:"ok"`
DryRun bool `json:"dry_run"`
Data struct {
Decision struct {
Status string `json:"status"`
Preconditions []struct {
Name string `json:"name"`
Status string `json:"status"`
} `json:"preconditions"`
WouldWrite []string `json:"would_write"`
} `json:"decision"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String())
}
if !envelope.OK || !envelope.DryRun {
t.Errorf("want ok=true dry_run=true, got: %s", stdout.String())
}
if envelope.Data.Decision.Status != "unknown" {
t.Errorf("unanswerable weak checks must render unknown, not fake readiness; got status %q", envelope.Data.Decision.Status)
}
names := map[string]string{}
for _, p := range envelope.Data.Decision.Preconditions {
names[p.Name] = p.Status
}
if names["credentials_available"] == "" || names["console_event_published"] == "" || names["scopes_granted"] == "" {
t.Errorf("preconditions must name every check, got: %v", names)
}
// The declared write side effects must stay declarations: the requested
// output dir must not exist after a dry run.
if _, err := os.Stat(filepath.Join(tmp, "events-out")); !os.IsNotExist(err) {
t.Error("dry-run created the output directory; the preview performed a side effect")
}
}

View File

@@ -18,13 +18,12 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
SilenceUsage: true,
}
snap := compileCatalog()
cmd.AddCommand(NewCmdConsume(f, snap))
cmd.AddCommand(NewCmdList(f, snap))
cmd.AddCommand(NewCmdSchema(f, snap))
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f, snap))
cmd.AddCommand(NewCmdBus(f))
return cmd
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/output"
)
@@ -288,10 +288,9 @@ func errorAs(err error, target interface{}) bool {
func TestNewCmdFactories_WireFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
snap := compileCatalog()
t.Run("consume", func(t *testing.T) {
cmd := NewCmdConsume(f, snap)
cmd := NewCmdConsume(f)
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("consume missing --%s flag", flag)
@@ -321,22 +320,14 @@ func TestNewCmdFactories_WireFlags(t *testing.T) {
})
t.Run("list", func(t *testing.T) {
cmd := NewCmdList(f, snap)
cmd := NewCmdList(f)
if cmd.Flags().Lookup("json") == nil {
t.Error("list missing --json flag")
}
domainFlag := cmd.Flags().Lookup("domain")
if domainFlag == nil {
t.Fatal("list missing --domain flag")
}
wantUsage := "Only list EventKeys of this domain. Valid domains: " + strings.Join(snap.Domains(), ", ")
if domainFlag.Usage != wantUsage {
t.Errorf("--domain usage = %q, want %q", domainFlag.Usage, wantUsage)
}
})
t.Run("bus", func(t *testing.T) {
cmd := NewCmdBus(f, snap)
cmd := NewCmdBus(f)
if !cmd.Hidden {
t.Error("bus should be hidden (internal daemon entrypoint)")
}

View File

@@ -1,81 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"flag"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
var updateGolden = flag.Bool("update", false, "rewrite golden files instead of comparing")
// goldenSchemaKeys picks one key per rendering path so every branch of the
// list/schema output stays pinned: a processed key with a flat custom schema,
// a native key with field overrides, a callback key with a single consumer,
// and a key with a required parameter plus a pre-consume hook.
var goldenSchemaKeys = map[string]string{
"schema_im_message_receive": "im.message.receive_v1",
"schema_im_chat_updated": "im.chat.updated_v1",
"schema_card_action_trigger": "card.action.trigger",
"schema_board_whiteboard": "board.whiteboard.updated_v1",
}
// The golden files pin stdout byte-for-byte. The output is deterministic:
// the snapshot keeps keys sorted, encoding/json sorts object keys, and nothing
// on the rendering path reads the clock or randomness. Regenerate with:
//
// go test ./cmd/event/ -run TestGolden -update
func TestGolden_ListOutput(t *testing.T) {
snap := compileCatalog()
for name, asJSON := range map[string]bool{"list_text": false, "list_json": true} {
t.Run(name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, snap, "", asJSON); err != nil {
t.Fatalf("runList: %v", err)
}
assertGolden(t, name, stdout.String())
})
}
}
func TestGolden_SchemaOutput(t *testing.T) {
snap := compileCatalog()
for name, key := range goldenSchemaKeys {
for suffix, asJSON := range map[string]bool{"_text": false, "_json": true} {
t.Run(name+suffix, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, snap, key, asJSON); err != nil {
t.Fatalf("runSchema(%s): %v", key, err)
}
assertGolden(t, name+suffix, stdout.String())
})
}
}
}
func assertGolden(t *testing.T, name, got string) {
t.Helper()
path := filepath.Join("testdata", "golden", name+".golden")
if *updateGolden {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("missing golden %s (regenerate with -update): %v", name, err)
}
if string(want) != got {
t.Errorf("output drifted from golden %s\n--- want\n%s\n--- got\n%s", name, want, got)
}
}

View File

@@ -10,44 +10,31 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/output"
)
func NewCmdList(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
var domain string
cmd := &cobra.Command{
Use: "list",
Short: "List all available EventKeys",
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --domain to keep one domain only, --json for machine-readable output.",
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
RunE: func(cmd *cobra.Command, args []string) error {
return runList(f, snap, domain, asJSON)
return runList(f, asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
cmd.Flags().StringVar(&domain, "domain", "", fmt.Sprintf(
"Only list EventKeys of this domain. Valid domains: %s",
strings.Join(snap.Domains(), ", "),
))
cmdutil.SetRisk(cmd, "read")
return cmd
}
func runList(f *cmdutil.Factory, snap *catalog.Snapshot, domain string, asJSON bool) error {
entries, err := entriesForDomain(snap, domain)
if err != nil {
return err
}
func runList(f *cmdutil.Factory, asJSON bool) error {
all := eventlib.ListAll()
if asJSON {
return writeListJSON(f, entries)
}
all := make([]*eventlib.KeyDefinition, 0, len(entries))
for _, entry := range entries {
all = append(all, entry.Definition())
return writeListJSON(f, all)
}
if len(all) == 0 {
@@ -117,43 +104,18 @@ func runList(f *cmdutil.Factory, snap *catalog.Snapshot, domain string, asJSON b
return nil
}
// listRow is the JSON shape of one `event list --json` row. It is a named
// type (not a function-local literal) so the render contract test can walk
// its fields and reject accidental additions to the public output.
type listRow struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
}
// entriesForDomain filters at the snapshot query layer: without a domain the
// full catalog comes back untouched; with one, rows are only removed, never
// reshaped. An unknown domain is rejected with the valid set spelled out.
func entriesForDomain(snap *catalog.Snapshot, domain string) ([]*catalog.Entry, error) {
if domain == "" {
return snap.Entries(), nil
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
type row struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
}
var filtered []*catalog.Entry
for _, entry := range snap.Entries() {
if entry.Descriptor().Domain == domain {
filtered = append(filtered, entry)
}
}
if len(filtered) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown domain: %s", domain).
WithParam("--domain").
WithHint("valid domains: %s", strings.Join(snap.Domains(), ", "))
}
return filtered, nil
}
func writeListJSON(f *cmdutil.Factory, entries []*catalog.Entry) error {
rows := make([]listRow, len(entries))
for i, entry := range entries {
rows[i] = listRow{
KeyDefinition: entry.Definition(),
ResolvedSchema: entry.Output().SchemaJSON,
rows := make([]row, len(all))
for i, def := range all {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
}
output.PrintJson(f.IOStreams.Out, rows)
return nil

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// Filtering only removes rows: the vc selection must be exactly the catalog's
// vc keys, and every remaining row keeps the unfiltered field set.
func TestListDomain_FilterKeepsExactlyTheRequestedDomain(t *testing.T) {
snap := compileCatalog()
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, snap, "vc", true); err != nil {
t.Fatal(err)
}
var rows []map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
t.Fatal(err)
}
want := map[string]bool{}
for _, key := range snap.Keys() {
if strings.HasPrefix(key, "vc.") {
want[key] = true
}
}
if len(want) == 0 {
t.Fatal("the catalog has no vc keys; the filter test proves nothing")
}
got := map[string]bool{}
for _, row := range rows {
var key string
_ = json.Unmarshal(row["key"], &key)
got[key] = true
for _, field := range []string{"event_type", "schema", "resolved_output_schema"} {
if _, ok := row[field]; !ok {
t.Errorf("%s: filtering must not reshape rows; %q is missing", key, field)
}
}
}
if len(got) != len(want) {
t.Fatalf("filtered rows = %v, want the exact vc set %v", got, want)
}
for key := range want {
if !got[key] {
t.Errorf("vc key missing from the filtered list: %s", key)
}
}
}
func TestListDomain_TextFilter(t *testing.T) {
snap := compileCatalog()
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, snap, "im", false); err != nil {
t.Fatal(err)
}
out := stdout.String()
if !strings.Contains(out, "im.message.receive_v1") {
t.Error("im keys must be listed")
}
for _, foreign := range []string{"vc.", "minutes.", "board.", "approval."} {
if strings.Contains(out, foreign) {
t.Errorf("foreign domain %q leaked into the filtered text output", foreign)
}
}
}
func TestListDomain_UnknownDomainIsRejectedWithTheValidSet(t *testing.T) {
snap := compileCatalog()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
err := runList(f, snap, "definitely-bogus", true)
if err == nil {
t.Fatal("an unknown domain must be rejected")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("want invalid_argument, got %v", err)
}
if !strings.Contains(err.Error(), "unknown domain: definitely-bogus") {
t.Errorf("error must name the rejected value, got %v", err)
}
for _, domain := range []string{"application", "approval", "board", "card", "im", "minutes", "task", "vc"} {
if !strings.Contains(problem.Hint, domain) {
t.Errorf("hint must list valid domain %q, got %q", domain, problem.Hint)
}
}
}

View File

@@ -10,18 +10,18 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
snap := compileCatalog()
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q) should succeed", key)
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
}
}
}
@@ -29,15 +29,13 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, compileCatalog(), "", false); err != nil {
if err := runList(f, false); err != nil {
t.Fatalf("runList: %v", err)
}
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -53,7 +51,7 @@ func TestRunList_TextOutput(t *testing.T) {
func TestRunList_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, compileCatalog(), "", true); err != nil {
if err := runList(f, true); err != nil {
t.Fatalf("runList json: %v", err)
}
@@ -92,8 +90,6 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -1,65 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
)
func preconditionByName(list []appconsume.Precondition, name string) *appconsume.Precondition {
for i := range list {
if list[i].Name == name {
return &list[i]
}
}
return nil
}
// An unusable credential blocks the decision and carries the exact error a
// real run would have returned, so both paths refuse for the same reason.
func TestReadPreconditions_TokenErrorBlocksWithTheSameError(t *testing.T) {
tokenErr := errors.New("no tenant token available")
pf := &preflightCtx{
appID: "cli_test",
identity: core.AsBot,
keyDef: &eventlib.KeyDefinition{Key: "demo.thing.updated_v1"},
}
got := readPreconditions(context.Background(), pf, nil, tokenErr)
cred := preconditionByName(got, "credentials_available")
if cred == nil {
t.Fatal("credentials_available precondition missing")
}
if cred.Status != appconsume.PreconditionBlocked || !errors.Is(cred.BlockErr, tokenErr) {
t.Errorf("token failure must block with the original error, got %+v", cred)
}
}
// A scope ledger nobody could read is reported as unknown — never as ok.
func TestReadPreconditions_UnreadableScopesAreUnknown(t *testing.T) {
pf := &preflightCtx{
appID: "cli_test",
identity: core.AsBot,
keyDef: &eventlib.KeyDefinition{
Key: "demo.thing.updated_v1",
Scopes: []string{"demo:read"},
},
appVer: nil, // no published version: the bot scope ledger is unreadable
}
got := readPreconditions(context.Background(), pf, nil, nil)
scopes := preconditionByName(got, "scopes_granted")
if scopes == nil {
t.Fatal("scopes_granted precondition missing")
}
if scopes.Status != appconsume.PreconditionUnknown {
t.Errorf("an unreadable ledger must report unknown, got %q", scopes.Status)
}
}

View File

@@ -108,7 +108,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
_, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
if err != nil {
t.Fatalf("bot + nil appVer should skip, got: %v", err)
}
@@ -124,7 +124,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
"im:message.group_at_msg",
"contact:user:readonly",
}}
_, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err != nil {
t.Fatalf("all scopes granted, unexpected error: %v", err)
}
@@ -136,7 +136,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
_, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err == nil {
t.Fatal("expected error for missing scope")
}
@@ -169,7 +169,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{Key: "x"}
if _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package render turns consume decisions into user-facing output. It is the
// only place a decision becomes JSON; the application layer never formats
// anything itself.
package render
import (
"io"
"regexp"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
"github.com/larksuite/cli/internal/output"
)
// sensitiveParamName matches parameter names whose values must never be
// echoed back in a rendered decision. Names are matched, not values: a
// credential-bearing parameter is identifiable by its declaration, and
// guessing at value shapes would miss more than it catches.
var sensitiveParamName = regexp.MustCompile(`(?i)(token|secret|password|credential|cookie)`)
func redactParams(params map[string]string) map[string]string {
out := make(map[string]string, len(params))
for name, value := range params {
if sensitiveParamName.MatchString(name) {
out[name] = "[redacted]"
continue
}
out[name] = value
}
return out
}
// decisionPayload is the JSON shape under data.decision — snake_case, stable,
// documented in the event skill. Field additions must be additive.
type decisionPayload struct {
EventKey string `json:"event_key"`
Domain string `json:"domain"`
Identity string `json:"identity"`
Status string `json:"status"`
Params map[string]string `json:"params"`
Scope string `json:"scope"`
Preconditions []preconditionView `json:"preconditions"`
Preparation *preparationView `json:"preparation,omitempty"`
WouldRead []string `json:"would_read"`
WouldWrite []string `json:"would_write"`
}
type preconditionView struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
type preparationView struct {
Strategy string `json:"strategy"`
Condition string `json:"condition"`
Action string `json:"action"`
}
// WriteDecisionJSON emits the decision inside the standard success envelope
// with the envelope's own top-level dry_run marker set.
func WriteDecisionJSON(out, errOut io.Writer, identity string, v appconsume.DecisionView) error {
return output.WriteSuccessEnvelope(map[string]any{
"decision": toPayload(v),
}, output.SuccessEnvelopeOptions{
CommandPath: "event consume",
Identity: identity,
DryRun: true,
Out: out,
ErrOut: errOut,
})
}
func toPayload(v appconsume.DecisionView) decisionPayload {
p := decisionPayload{
EventKey: v.EventKey,
Domain: v.Domain,
Identity: v.Identity,
Status: v.Status,
Params: redactParams(v.Params),
Scope: v.Scope,
WouldRead: v.WouldRead,
WouldWrite: v.WouldWrite,
}
p.Preconditions = make([]preconditionView, 0, len(v.Preconditions))
for _, pc := range v.Preconditions {
p.Preconditions = append(p.Preconditions, preconditionView(pc))
}
if v.Preparation != nil {
pv := preparationView(*v.Preparation)
p.Preparation = &pv
}
return p
}

View File

@@ -1,114 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package render
import (
"bytes"
"encoding/json"
"strings"
"testing"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
)
func sampleView() appconsume.DecisionView {
return appconsume.DecisionView{
EventKey: "vc.note.generated_v1",
Domain: "vc",
Identity: "user",
Status: "ready",
Params: map[string]string{"whiteboard_id": "wb-1", "access_token": "sk-SENSITIVE-VALUE"},
Scope: "vc.note.generated_v1",
Preconditions: []appconsume.PreconditionView{
{Name: "console_event_published", Status: "ok"},
{Name: "scopes_granted", Status: "ok"},
},
Preparation: &appconsume.PreparationView{
Strategy: "legacy_preconsume", Condition: "first_consumer_for_scope", Action: "register_event_delivery",
},
WouldRead: []string{"local_bus_probe", "app_metadata_preflight"},
WouldWrite: []string{"start_or_reuse_local_bus", "register_consumer", "run_preparation_when_first", "open_event_stream"},
}
}
// The JSON contract: dry_run is the envelope's own top-level marker (never a
// data field), and the decision sits under data.decision with its documented
// members.
func TestWriteDecisionJSON_EnvelopeContract(t *testing.T) {
var out, errOut bytes.Buffer
if err := WriteDecisionJSON(&out, &errOut, "user", sampleView()); err != nil {
t.Fatal(err)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, out.String())
}
if string(envelope["ok"]) != "true" || string(envelope["dry_run"]) != "true" {
t.Errorf("envelope must carry top-level ok=true and dry_run=true, got %s", out.String())
}
if _, misplaced := envelope["decision"]; misplaced {
t.Error("decision must live under data, not at the envelope top level")
}
var data struct {
Decision struct {
EventKey string `json:"event_key"`
Domain string `json:"domain"`
Identity string `json:"identity"`
Status string `json:"status"`
Params map[string]string `json:"params"`
Scope string `json:"scope"`
Preparation *struct {
Strategy string `json:"strategy"`
Condition string `json:"condition"`
Action string `json:"action"`
} `json:"preparation"`
WouldRead []string `json:"would_read"`
WouldWrite []string `json:"would_write"`
DryRun *bool `json:"dry_run"`
} `json:"decision"`
}
if err := json.Unmarshal(envelope["data"], &data); err != nil {
t.Fatalf("data.decision does not match the documented shape: %v", err)
}
d := data.Decision
if d.EventKey != "vc.note.generated_v1" || d.Domain != "vc" || d.Identity != "user" || d.Status != "ready" {
t.Errorf("identity facts drifted: %+v", d)
}
if d.Preparation == nil || d.Preparation.Condition != "first_consumer_for_scope" {
t.Errorf("conditional preparation must be stated: %+v", d.Preparation)
}
if len(d.WouldRead) == 0 || len(d.WouldWrite) == 0 {
t.Error("would_read / would_write must be present")
}
if d.DryRun != nil {
t.Error("dry_run inside data.decision would duplicate the envelope marker")
}
}
// Sensitive parameter values never reach the rendered output. The control
// assertion first proves the sentinel would be visible if leaked.
func TestWriteDecision_RedactsSensitiveParams(t *testing.T) {
const sentinel = "sk-SENSITIVE-VALUE"
view := sampleView()
if !strings.Contains(view.Params["access_token"], sentinel) {
t.Fatal("control failed: the sentinel is not in the input, the test cannot prove redaction")
}
var jsonOut, jsonErr bytes.Buffer
if err := WriteDecisionJSON(&jsonOut, &jsonErr, "user", view); err != nil {
t.Fatal(err)
}
if strings.Contains(jsonOut.String(), sentinel) {
t.Errorf("JSON output leaks a sensitive param value: %s", jsonOut.String())
}
compact := strings.ReplaceAll(strings.ReplaceAll(jsonOut.String(), "\n", ""), " ", "")
if !strings.Contains(compact, `"access_token":"[redacted]"`) {
t.Errorf("sensitive param must render as redacted, got: %s", jsonOut.String())
}
if !strings.Contains(compact, `"whiteboard_id":"wb-1"`) {
t.Errorf("non-sensitive params must render verbatim, got: %s", jsonOut.String())
}
}

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package render
import (
"strings"
"testing"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// This file is a guard, not a contract: it does not pin what the redaction
// regex matches, it hunts for declared parameter names that smell like
// credentials yet would render verbatim. The detector wordlist is therefore
// deliberately wider than the production sensitiveParamName pattern — a hit
// here means either the parameter should be renamed or the production
// pattern must grow, decided by a human, never by loosening this list.
// credentialWords are matched against whole '_'/'-'/'.'-separated segments of
// a parameter name, so chat_key or tokenizer_mode cannot trip them. The bare
// word "key" is intentionally absent (identifier names like whiteboard_id or
// a hypothetical chat_key are not credentials); the api/key pairing is what
// carries credential semantics and is detected as a pair below.
var credentialWords = map[string]bool{
"token": true,
"secret": true,
"password": true,
"credential": true,
"credentials": true,
"cookie": true,
"auth": true,
"signature": true,
"bearer": true,
"apikey": true,
}
// smellsLikeCredential reports whether a parameter name carries credential
// semantics per the guard wordlist: any single segment in credentialWords,
// or the adjacent segment pair api+key.
func smellsLikeCredential(name string) bool {
segments := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool {
return r == '_' || r == '-' || r == '.'
})
for i, seg := range segments {
if credentialWords[seg] {
return true
}
if seg == "api" && i+1 < len(segments) && segments[i+1] == "key" {
return true
}
}
return false
}
// unredactedCredentialParams returns the names that smell like credentials
// but are NOT matched by the production redaction pattern — every such name
// would render its value verbatim in a dry-run decision.
func unredactedCredentialParams(names []string) []string {
var findings []string
for _, name := range names {
if smellsLikeCredential(name) && !sensitiveParamName.MatchString(name) {
findings = append(findings, name)
}
}
return findings
}
// The detector itself must bite before the live scan means anything: known
// credential-shaped names that the production pattern misses must be caught,
// and ordinary identifier names must pass.
func TestRedactionGuardDetector_SelfCheck(t *testing.T) {
// Credential-shaped and covered by the production pattern: no finding.
for _, name := range []string{"access_token", "client_secret", "user_password", "session_cookie", "sso_credential"} {
if got := unredactedCredentialParams([]string{name}); len(got) != 0 {
t.Errorf("%q is redacted by the production pattern, the guard must not flag it, got %v", name, got)
}
}
// Credential-shaped but NOT covered by the production pattern today: the
// guard must flag these, otherwise it can never catch a real gap.
for _, name := range []string{"api_key", "auth_code", "request_signature", "bearer_value"} {
if got := unredactedCredentialParams([]string{name}); len(got) != 1 {
t.Errorf("%q smells like a credential and is not redacted; the guard must flag it, got %v", name, got)
}
}
// Ordinary identifiers, including the wide-false-positive shapes the
// wordlist is segment-matched to avoid: no finding.
for _, name := range []string{"whiteboard_id", "chat_key", "tokenizer_mode", "author", "meeting_no"} {
if got := unredactedCredentialParams([]string{name}); len(got) != 0 {
t.Errorf("%q is an ordinary identifier, the guard must not flag it, got %v", name, got)
}
}
}
// Every declared parameter of every compiled EventKey either carries no
// credential semantics or is caught by the production redaction pattern.
func TestRedactionGuard_CatalogParamsHaveNoUnredactedCredentials(t *testing.T) {
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile catalog: %v", err)
}
var names []string
for _, entry := range snap.Entries() {
desc := entry.Descriptor()
for _, p := range desc.Params {
names = append(names, desc.Key+": "+p.Name)
if findings := unredactedCredentialParams([]string{p.Name}); len(findings) != 0 {
t.Errorf("EventKey %s declares param %q which smells like a credential but is not matched by the redaction pattern; rename the param or extend sensitiveParamName deliberately", desc.Key, p.Name)
}
}
}
// A scan that visited no parameters proves nothing.
if len(names) == 0 {
t.Fatal("the compiled catalog declares no parameters at all; the guard scanned nothing")
}
}

View File

@@ -1,163 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"reflect"
"strings"
"testing"
)
// renderedDeclarationFields lists every JSON field the list/schema commands
// are allowed to expose, each with the reason it belongs to the public
// contract. Golden files pin today's bytes; this gate protects tomorrow: a
// field added to the rendered structs (promoted through the embedded
// definition or nested anywhere under it) must either appear here
// deliberately or be tagged `json:"-"`. The set is flat — an entry admits its
// rendered name at any nesting level, which is the same latitude
// encoding/json gives a name.
var renderedDeclarationFields = map[string]string{
"key": "stable identifier agents subscribe by",
"domain": "declared domain override; empty for every shipped key (filtering reads the derived descriptor value), so legacy output is byte-identical",
"display_name": "human-readable name for pickers",
"description": "what the event means (KeyDefinition) / what the parameter does (ParamDef)",
"event_type": "upstream event type behind this key",
"subscription_type": "which console ledger the precheck reads",
"params": "declared consume parameters",
"schema": "declared schema source (native/custom markers)",
"scopes": "OAuth scopes required to consume",
"auth_types": "identities the key accepts",
"required_console_events": "console switches that must be enabled",
"buffer_size": "delivery buffer size after normalization",
"workers": "worker count after normalization",
"single_consumer": "whether a second consumer is rejected",
"resolved_output_schema": "fully resolved JSON schema of stdout events",
"jq_root_path": "schema command only: jq root for consuming stdout",
// Nested under params (ParamDef): everything an agent needs to pass the
// parameter correctly.
"name": "parameter name as passed via --param",
"type": "parameter value type (string/enum/multi/bool/int)",
"required": "whether the parameter must be provided",
"default": "value applied when the parameter is omitted",
"values": "allowed values for enum/multi parameters",
"subscription_key": "whether the parameter is part of the subscription identity",
// Nested under params.values (ParamValue).
"value": "one allowed parameter value",
"desc": "what choosing this value means",
// Nested under schema (SchemaDef / SchemaSpec): declaration markers only;
// the resolved schema is the sibling resolved_output_schema.
"native": "marker for keys delivering the raw V2 envelope",
"custom": "marker for keys delivering processed output",
"field_overrides": "per-field annotations overriding the reflected schema",
"raw": "raw declared schema bytes; empty for reflected types",
// Nested under schema.field_overrides (schemas.FieldMeta). The type has
// no json tags, so encoding/json renders the Go field names — pinned
// as-is because retagging them would change the public bytes.
"Description": "override for the field's schema description",
"Enum": "override for the field's allowed values",
"Kind": "override rendered as the field's schema format",
}
// TestRenderContract_NoRuntimeFieldLeaksIntoJSON walks both rendered shapes,
// following embedded struct promotion and recursing into every named type
// reachable through the rendered fields, and fails on any exported member
// that is neither allowlisted nor explicitly excluded from JSON.
func TestRenderContract_NoRuntimeFieldLeaksIntoJSON(t *testing.T) {
emitted := map[string]bool{}
for _, typ := range []reflect.Type{
reflect.TypeFor[listRow](),
reflect.TypeFor[schemaPayload](),
} {
walkRenderedFields(t, typ, emitted, map[reflect.Type]bool{})
}
if len(emitted) == 0 {
t.Fatal("no rendered fields were visited; the gate scanned nothing")
}
// The embedded definition is where leaks would hide: prove promotion was
// actually followed by requiring fields that only exist on it. The nested
// sentinels prove each recursion path is really taken: subscription_key
// (slice-of-struct: ParamDef), desc (slice inside a nested struct:
// ParamValue), raw (pointer-to-struct: SchemaSpec), Enum (map value:
// FieldMeta, rendered under its Go name because the type is untagged).
for _, sentinel := range []string{
"key", "event_type", "resolved_output_schema",
"subscription_key", "desc", "raw", "Enum",
} {
if !emitted[sentinel] {
t.Fatalf("field %q was not visited; the walker no longer reaches every rendered shape", sentinel)
}
}
for name := range renderedDeclarationFields {
if !emitted[name] {
t.Errorf("allowlist entry %q is stale: no rendered struct emits it", name)
}
}
}
// walkRenderedFields records every JSON field name typ can render: embedded
// structs promote into the parent object, and any struct reachable through a
// field's type — behind pointers, slice/array elements, or map values — is
// walked in turn, so a field added to a nested type like ParamDef cannot
// escape the gate. visited breaks cycles; a type already recorded in this
// walk contributes nothing new.
func walkRenderedFields(t *testing.T, typ reflect.Type, emitted map[string]bool, visited map[reflect.Type]bool) {
t.Helper()
typ = nestedStructType(typ)
if typ == nil || visited[typ] {
return
}
visited[typ] = true
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() {
continue
}
tag := field.Tag.Get("json")
if tag == "-" {
continue
}
if field.Anonymous && tag == "" {
if ft := nestedStructType(field.Type); ft != nil {
// Embedded struct without a tag: fields promote into the
// parent JSON object.
walkRenderedFields(t, ft, emitted, visited)
continue
}
}
name, _, _ := strings.Cut(tag, ",")
if name == "" {
// encoding/json renders an untagged exported field under its Go
// name (schemas.FieldMeta does this today); the rendered name is
// what the contract governs, so it is what must be declared.
name = field.Name
}
if _, ok := renderedDeclarationFields[name]; !ok {
t.Errorf("%s.%s renders JSON field %q that is not in the declared output contract; add it deliberately or exclude it with json:\"-\"", typ.Name(), field.Name, name)
}
emitted[name] = true
walkRenderedFields(t, field.Type, emitted, visited)
}
}
// nestedStructType unwraps pointers, slice/array elements, and map values
// until it reaches the struct that would render as a JSON object; nil means
// the type renders as a leaf (scalar, string, raw bytes) and holds no fields
// to govern.
func nestedStructType(typ reflect.Type) reflect.Type {
for {
switch typ.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
typ = typ.Elem()
case reflect.Struct:
return typ
default:
return nil
}
}
}

View File

@@ -11,13 +11,75 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/schemas"
"github.com/larksuite/cli/internal/output"
)
func NewCmdSchema(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
spec, isNative := pickSpec(def.Schema)
if spec == nil {
return nil, nil, nil
}
base, err := renderSpec(spec)
if err != nil {
return nil, nil, err
}
if base == nil {
return nil, nil, nil
}
if isNative {
base = schemas.WrapV2Envelope(base)
}
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"parse base schema for field overrides: %s", err).WithCause(err)
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"serialize schema with field overrides: %s", err).WithCause(err)
}
return out, orphans, nil
}
return base, nil, nil
}
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
if s.Native != nil {
return s.Native, true
}
if s.Custom != nil {
return s.Custom, false
}
return nil, false
}
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
if s.Type != nil {
return schemas.FromType(s.Type), nil
}
if len(s.Raw) > 0 {
buf := make(json.RawMessage, len(s.Raw))
copy(buf, s.Raw)
return buf, nil
}
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "schema <EventKey>",
@@ -25,7 +87,7 @@ func NewCmdSchema(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runSchema(f, snap, args[0], asJSON)
return runSchema(f, args[0], asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
@@ -33,15 +95,14 @@ func NewCmdSchema(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
return cmd
}
func runSchema(f *cmdutil.Factory, snap *catalog.Snapshot, key string, asJSON bool) error {
entry, ok := snap.Resolve(key)
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
def, ok := eventlib.Lookup(key)
if !ok {
return unknownEventKeyErr(snap, key)
return unknownEventKeyErr(key)
}
def := entry.Definition()
if asJSON {
return writeSchemaJSON(f, entry)
return writeSchemaJSON(f, def)
}
out := f.IOStreams.Out
@@ -109,7 +170,10 @@ func runSchema(f *cmdutil.Factory, snap *catalog.Snapshot, key string, asJSON bo
}
}
resolved := entry.Output().SchemaJSON
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")
printIndentedJSON(out, resolved)
@@ -138,22 +202,30 @@ func printIndentedJSON(out io.Writer, raw json.RawMessage) {
fmt.Fprintf(out, " %s\n", string(formatted))
}
// schemaPayload is the JSON shape of `event schema --json`. It is a named
// type (not a function-local literal) so the render contract test can walk
// its fields and reject accidental additions to the public output.
type schemaPayload struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
JQRootPath string `json:"jq_root_path,omitempty"`
}
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
func writeSchemaJSON(f *cmdutil.Factory, entry *catalog.Entry) error {
contract := entry.Output()
output.PrintJson(f.IOStreams.Out, schemaPayload{
KeyDefinition: entry.Definition(),
ResolvedSchema: contract.SchemaJSON,
JQRootPath: contract.JQRootPath,
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
type payload struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
JQRootPath string `json:"jq_root_path,omitempty"`
}
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
var jqRootPath string
if resolved != nil {
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
_, isNative := pickSpec(def.Schema)
jqRootPath = "."
if isNative {
jqRootPath = ".event"
}
}
output.PrintJson(f.IOStreams.Out, payload{
KeyDefinition: def,
ResolvedSchema: resolved,
JQRootPath: jqRootPath,
})
return nil
}

View File

@@ -10,54 +10,19 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/schemas"
_ "github.com/larksuite/cli/events"
)
// compileTestSnapshot compiles synthetic declarations into a snapshot using
// the same strategy set the production wiring provides.
func compileTestSnapshot(t *testing.T, defs ...eventlib.KeyDefinition) *catalog.Snapshot {
t.Helper()
snap, err := catalog.Compile(defs, catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile test catalog: %v", err)
}
return snap
}
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
Scopes []string `json:"scopes"`
Params []approvalSchemaJSONParam `json:"params"`
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
}
type approvalSchemaJSONParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
SubscriptionKey bool `json:"subscription_key"`
}
type approvalSchemaJSONResolvedSchema struct {
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
}
type approvalSchemaJSONProperty struct {
Format string `json:"format"`
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", false); err != nil {
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
@@ -77,7 +42,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) {
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, compileCatalog(), "im.message.message_read_v1", false); err != nil {
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
@@ -97,7 +62,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
err := runSchema(f, compileCatalog(), "im.message.recieve_v1", false)
err := runSchema(f, "im.message.recieve_v1", false)
if err == nil {
t.Fatal("expected error for unknown key")
}
@@ -113,7 +78,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
func TestRunSchema_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil {
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -131,44 +96,10 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
for _, field := range []string{
"root_id",
"thread_id",
"reply_to",
"sender_type",
"mentions",
} {
if _, ok := props[field]; !ok {
t.Errorf("receive schema missing field %q", field)
}
}
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
if !strings.Contains(msgDesc, "Recommended idempotency key") {
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
}
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
if strings.Contains(eventDesc, "safe for deduplication") {
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, compileCatalog(), "task.task.update_user_access_v2", true); err != nil {
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -193,60 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
tests := []struct {
key string
scope string
}{
{"approval.instance.status_changed_v4", "approval:instance:read"},
{"approval.task.status_changed_v4", "approval:task:read"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, compileCatalog(), tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload approvalSchemaJSONPayload
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload.JQRootPath != "." {
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
}
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
t.Errorf("auth_types = %#v, want user", got)
}
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
t.Errorf("scopes = %#v, want %s", got, tc.scope)
}
if len(payload.Params) != 1 {
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
}
param := payload.Params[0]
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
}
props := payload.ResolvedOutputSchema.Properties
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
if _, ok := props[field]; !ok {
t.Errorf("approval schema missing flat field %q: %+v", field, props)
}
}
if _, ok := props["event"]; ok {
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
}
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("operate_time format = %v, want timestamp_ms", got)
}
})
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
@@ -255,7 +132,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, compileCatalog(), key, true); err != nil {
if err := runSchema(f, key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -288,8 +165,9 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
@@ -300,7 +178,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, snap, syntheticKey, false); err != nil {
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
@@ -336,8 +214,9 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
@@ -345,7 +224,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, snap, syntheticKey, true); err != nil {
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -359,13 +238,12 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
type out struct {
SenderID string `json:"sender_id"`
}
// A compile that succeeds proves the overlay left no orphan pointers; the
// entry's output contract carries the resolved schema.
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Schema: eventlib.SchemaDef{
@@ -378,12 +256,13 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
return nil, nil
},
})
entry, ok := snap.Resolve(syntheticKey)
if !ok {
t.Fatalf("snap.Resolve(%q) should succeed", syntheticKey)
def, _ := eventlib.Lookup(syntheticKey)
resolved, orphans, err := resolveSchemaJSON(def)
if err != nil || len(orphans) != 0 {
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
}
var parsed map[string]interface{}
if err := json.Unmarshal(entry.Output().SchemaJSON, &parsed); err != nil {
if err := json.Unmarshal(resolved, &parsed); err != nil {
t.Fatal(err)
}
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
@@ -392,35 +271,37 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
}
}
func TestCompile_EmptySpecIsRejected(t *testing.T) {
_, err := catalog.Compile([]eventlib.KeyDefinition{{
Key: "synthetic.empty.spec",
EventType: "synthetic.empty.spec",
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{}},
}}, catalog.StrategyRefs{catalog.StrategyNone})
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
if !strings.Contains(err.Error(), "exactly one of Type or Raw") {
t.Errorf("error should reject the empty spec, got: %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestCompile_InvalidBaseWithOverridesIsRejected(t *testing.T) {
_, err := catalog.Compile([]eventlib.KeyDefinition{{
Key: "synthetic.invalid.base",
EventType: "synthetic.invalid.base",
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}}, catalog.StrategyRefs{catalog.StrategyNone})
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
// Garbage raw bytes are rejected by the spec check itself, before the
// overlay machinery would even try to parse them.
if !strings.Contains(err.Error(), "is not a JSON object") {
t.Errorf("error should reject the unparsable base schema, got: %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}

View File

@@ -1,85 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
eventlib "github.com/larksuite/cli/internal/event"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
"github.com/larksuite/cli/internal/event/catalog"
)
// consumeStrategies is the executable strategy set for this binary. The same
// registry is handed to catalog compilation, so a reference the compiler
// accepted is guaranteed to resolve here.
var consumeStrategies = appconsume.DefaultRegistry()
type identityResolverFunc func(ctx context.Context, entry *catalog.Entry) (string, error)
func (f identityResolverFunc) Resolve(ctx context.Context, entry *catalog.Entry) (string, error) {
return f(ctx, entry)
}
type preflightReaderFunc func(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error)
func (f preflightReaderFunc) Read(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error) {
return f(ctx, entry, identity)
}
type streamRunnerFunc func(ctx context.Context, prepare appconsume.PrepareFunc) error
func (f streamRunnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFunc) error {
return f(ctx, prepare)
}
// readPreconditions classifies the existing read-only preflight checks into
// named preconditions. Weak dependencies that could not answer stay visible
// as "unknown" instead of silently passing; a failed check carries the exact
// error a real run returns, so refusal is identical on both paths.
func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr, tokenErr error) []appconsume.Precondition {
credentials := appconsume.Precondition{Name: "credentials_available", Status: appconsume.PreconditionOK}
if tokenErr != nil {
credentials.Status = appconsume.PreconditionBlocked
credentials.Detail = tokenErr.Error()
credentials.BlockErr = tokenErr
}
console := appconsume.Precondition{Name: "console_event_published", Status: appconsume.PreconditionOK}
switch {
case len(pf.keyDef.RequiredConsoleEvents) == 0:
// nothing to verify
case pf.keyDef.SubscriptionType == eventlib.SubTypeCallback && pf.subscribedCallbacks == nil,
pf.keyDef.SubscriptionType != eventlib.SubTypeCallback && pf.appVer == nil:
console.Status = appconsume.PreconditionUnknown
if appVerErr != nil {
console.Detail = describeAppMetaErr(appVerErr)
} else {
console.Detail = "console ledger unavailable"
}
default:
if err := preflightEventTypes(pf); err != nil {
console.Status = appconsume.PreconditionBlocked
console.Detail = err.Error()
console.BlockErr = err
}
}
scopes := appconsume.Precondition{Name: "scopes_granted", Status: appconsume.PreconditionOK}
checked, err := preflightScopes(ctx, pf)
switch {
case err != nil:
scopes.Status = appconsume.PreconditionBlocked
scopes.Detail = err.Error()
scopes.BlockErr = err
case !checked:
// The scope ledger could not be read (no published version for bots,
// no resolvable token for users). Saying "ok" here would dress up
// "nobody looked" as "it was verified".
scopes.Status = appconsume.PreconditionUnknown
scopes.Detail = "granted scopes could not be read for this identity"
}
return []appconsume.Precondition{credentials, console, scopes}
}

View File

@@ -14,10 +14,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/adapter/localbus/busctl"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
)

View File

@@ -11,8 +11,8 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
)
type fakeScanner struct {

View File

@@ -13,9 +13,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/adapter/localbus/busctl"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
)

View File

@@ -9,7 +9,7 @@ import (
"sort"
"testing"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
"github.com/larksuite/cli/internal/event/busdiscover"
)
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {

View File

@@ -13,7 +13,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
"github.com/larksuite/cli/internal/event/protocol"
)
type mockTransport struct {

View File

@@ -9,14 +9,14 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event/catalog"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
func suggestEventKeys(snap *catalog.Snapshot, input string) []string {
func suggestEventKeys(input string) []string {
type match struct {
key string
dist int
@@ -24,13 +24,13 @@ func suggestEventKeys(snap *catalog.Snapshot, input string) []string {
var hits []match
threshold := max(2, len(input)/5)
for _, key := range snap.Keys() {
if strings.Contains(key, input) {
hits = append(hits, match{key, 0})
for _, def := range eventlib.ListAll() {
if strings.Contains(def.Key, input) {
hits = append(hits, match{def.Key, 0})
continue
}
if d := suggest.Levenshtein(input, key); d <= threshold {
hits = append(hits, match{key, d})
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
@@ -59,9 +59,9 @@ func formatSuggestions(keys []string) string {
}
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
func unknownEventKeyErr(snap *catalog.Snapshot, key string) error {
func unknownEventKeyErr(key string) error {
msg := fmt.Sprintf("unknown EventKey: %s", key)
if guesses := suggestEventKeys(snap, key); len(guesses) > 0 {
if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).

View File

@@ -6,10 +6,11 @@ package event
import (
"strings"
"testing"
_ "github.com/larksuite/cli/events"
)
func TestSuggestEventKeys(t *testing.T) {
snap := compileCatalog()
cases := []struct {
name string
input string
@@ -40,7 +41,7 @@ func TestSuggestEventKeys(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := suggestEventKeys(snap, tc.input)
got := suggestEventKeys(tc.input)
if tc.wantEmpty {
if len(got) != 0 {
t.Errorf("expected empty slice, got %v", got)
@@ -97,7 +98,7 @@ func TestFormatSuggestions(t *testing.T) {
}
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
err := unknownEventKeyErr(compileCatalog(), "im.message.recieve_v1")
err := unknownEventKeyErr("im.message.recieve_v1")
if err == nil {
t.Fatal("expected error")
}
@@ -114,7 +115,7 @@ func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
}
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
err := unknownEventKeyErr(compileCatalog(), "xyzzy_no_such_event_key_at_all")
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
if err == nil {
t.Fatal("expected error")
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +0,0 @@
KEY AUTH PARAMS DESCRIPTION
── application ──
application.bot.menu_v6 bot 0 Triggered when a user clicks a custom bot menu item whose action is configured as a push event.
── approval ──
approval.instance.status_changed_v4 user 1 Triggered after an approval instance status becomes visible to the requester or approval participants
approval.task.status_changed_v4 user 1 Triggered after an approval task status becomes visible to the requester or task approver
── board ──
board.whiteboard.updated_v1 user|bot 1 Pushed when the whiteboard content is updated.
── card ──
card.action.trigger bot 0 Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).
── im ──
im.chat.disbanded_v1 bot 0 Triggered after a chat is disbanded
im.chat.member.bot.added_v1 bot 0 Triggered when the bot is added to a chat
im.chat.member.bot.deleted_v1 bot 0 Triggered after the bot is removed from a chat
im.chat.member.user.added_v1 bot 0 Triggered when a new user joins a chat (including topic chats)
im.chat.member.user.deleted_v1 bot 0 Triggered when a user leaves or is removed from a chat
im.chat.member.user.withdrawn_v1 bot 0 Triggered after a pending user invite is withdrawn
im.chat.updated_v1 bot 0 Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated
im.message.message_read_v1 bot 0 Triggered after a user reads a P2P message sent by the bot
im.message.reaction.created_v1 bot 0 Triggered when a reaction is added to a message
im.message.reaction.deleted_v1 bot 0 Triggered when a reaction is removed from a message
im.message.receive_v1 bot 0 Receive IM messages
── minutes ──
minutes.minute.generated_v1 user 0 Triggered when a minute has been generated
── task ──
task.task.update_user_access_v2 user|bot 0 Triggered when tasks visible to the current user or app are created, deleted, or updated
── vc ──
vc.meeting.participant_meeting_ended_v1 user 0 Triggered when a meeting the current user participates in has ended
vc.meeting.participant_meeting_joined_v1 user 0 Triggered when the current user joins a meeting
vc.meeting.participant_meeting_started_v1 user 0 Triggered when a meeting the current user participates in has started
vc.note.generated_v1 user 0 Triggered when a note has been generated
vc.recording.recording_ended_v1 user 0 Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.
vc.recording.recording_started_v1 user 0 Triggered when a recording_bean recording starts; only generated when connected to Feishu software.
vc.recording.recording_transcript_generated_v1 user 0 Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.

View File

@@ -1,127 +0,0 @@
{
"key": "board.whiteboard.updated_v1",
"display_name": "Whiteboard updated",
"description": "Pushed when the whiteboard content is updated.",
"event_type": "board.whiteboard.updated_v1",
"subscription_type": "event",
"params": [
{
"name": "whiteboard_id",
"type": "string",
"required": true,
"description": "Whiteboard id to subscribe; subscription is per-whiteboard.",
"subscription_key": true
}
],
"schema": {
"native": {},
"field_overrides": {
"/event/operator_ids/*/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/operator_ids/*/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/operator_ids/*/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/whiteboard_id": {
"Description": "whiteboard id to subscribe",
"Enum": null,
"Kind": "whiteboard_id"
}
}
},
"scopes": [
"board:whiteboard:node:read"
],
"auth_types": [
"user",
"bot"
],
"required_console_events": [
"board.whiteboard.updated_v1"
],
"buffer_size": 100,
"workers": 1,
"resolved_output_schema": {
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"operator_ids": {
"items": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"whiteboard_id": {
"description": "whiteboard id to subscribe",
"format": "whiteboard_id",
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
},
"jq_root_path": ".event"
}

View File

@@ -1,89 +0,0 @@
Key: board.whiteboard.updated_v1
Description: Pushed when the whiteboard content is updated.
Event: board.whiteboard.updated_v1
Pre-consume: yes
Required Scopes:
- board:whiteboard:node:read
Required Console Events (must be enabled in developer console):
- board.whiteboard.updated_v1
Parameters:
NAME TYPE REQUIRED SUB-KEY DEFAULT DESCRIPTION
whiteboard_id string yes yes - Whiteboard id to subscribe; subscription is per-whiteboard.
Output Schema:
{
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"operator_ids": {
"items": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"whiteboard_id": {
"description": "whiteboard id to subscribe",
"format": "whiteboard_id",
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
}

View File

@@ -1,104 +0,0 @@
{
"key": "card.action.trigger",
"display_name": "Card action",
"description": "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
"event_type": "card.action.trigger",
"subscription_type": "callback",
"schema": {
"custom": {}
},
"scopes": [
"im:message:readonly"
],
"auth_types": [
"bot"
],
"required_console_events": [
"card.action.trigger"
],
"buffer_size": 100,
"workers": 1,
"single_consumer": true,
"resolved_output_schema": {
"type": "object",
"properties": {
"action_name": {
"type": "string",
"description": "Element name attribute"
},
"action_tag": {
"type": "string",
"description": "Triggered element type: button/select_static/input/checker/etc"
},
"action_value": {
"type": "string",
"description": "Developer-defined action value as JSON string"
},
"card_content": {
"type": "string",
"description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"
},
"chat_id": {
"type": "string",
"description": "Chat ID",
"format": "chat_id"
},
"checked": {
"type": "boolean",
"description": "Checkbox state (for checkbox elements)"
},
"event_id": {
"type": "string",
"description": "Globally unique event ID"
},
"form_value": {
"type": "string",
"description": "Form submission values as JSON string (only on form submit)"
},
"host": {
"type": "string",
"description": "Host type: im_message / im_top_notice"
},
"input_value": {
"type": "string",
"description": "Input field value (only for input elements)"
},
"message_id": {
"type": "string",
"description": "Message ID of the card",
"format": "message_id"
},
"operator_id": {
"type": "string",
"description": "Operator open_id",
"format": "open_id"
},
"option": {
"type": "string",
"description": "Selected option value (for single-select dropdown)"
},
"options": {
"type": "string",
"description": "Selected options, comma-separated (for multi-select)"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string)",
"format": "timestamp_ms"
},
"timezone": {
"type": "string",
"description": "User timezone for date/time picker interactions"
},
"token": {
"type": "string",
"description": "Token for delay card update (valid 30 min, max 2 updates)"
},
"type": {
"type": "string",
"description": "Event type; always card.action.trigger"
}
}
},
"jq_root_path": "."
}

View File

@@ -1,92 +0,0 @@
Key: card.action.trigger
Description: Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).
Event: card.action.trigger
Required Scopes:
- im:message:readonly
Required Console Events (must be enabled in developer console):
- card.action.trigger
Output Schema:
{
"type": "object",
"properties": {
"action_name": {
"type": "string",
"description": "Element name attribute"
},
"action_tag": {
"type": "string",
"description": "Triggered element type: button/select_static/input/checker/etc"
},
"action_value": {
"type": "string",
"description": "Developer-defined action value as JSON string"
},
"card_content": {
"type": "string",
"description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"
},
"chat_id": {
"type": "string",
"description": "Chat ID",
"format": "chat_id"
},
"checked": {
"type": "boolean",
"description": "Checkbox state (for checkbox elements)"
},
"event_id": {
"type": "string",
"description": "Globally unique event ID"
},
"form_value": {
"type": "string",
"description": "Form submission values as JSON string (only on form submit)"
},
"host": {
"type": "string",
"description": "Host type: im_message / im_top_notice"
},
"input_value": {
"type": "string",
"description": "Input field value (only for input elements)"
},
"message_id": {
"type": "string",
"description": "Message ID of the card",
"format": "message_id"
},
"operator_id": {
"type": "string",
"description": "Operator open_id",
"format": "open_id"
},
"option": {
"type": "string",
"description": "Selected option value (for single-select dropdown)"
},
"options": {
"type": "string",
"description": "Selected options, comma-separated (for multi-select)"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string)",
"format": "timestamp_ms"
},
"timezone": {
"type": "string",
"description": "User timezone for date/time picker interactions"
},
"token": {
"type": "string",
"description": "Token for delay card update (valid 30 min, max 2 updates)"
},
"type": {
"type": "string",
"description": "Event type; always card.action.trigger"
}
}
}

View File

@@ -1,430 +0,0 @@
{
"key": "im.chat.updated_v1",
"display_name": "Chat updated",
"description": "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
"event_type": "im.chat.updated_v1",
"subscription_type": "event",
"schema": {
"native": {},
"field_overrides": {
"/event/after_change/owner_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/after_change/owner_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/after_change/owner_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/before_change/owner_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/before_change/owner_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/before_change/owner_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/chat_id": {
"Description": "",
"Enum": null,
"Kind": "chat_id"
},
"/event/moderator_list/added_member_list/*/user_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/moderator_list/added_member_list/*/user_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/moderator_list/added_member_list/*/user_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/moderator_list/removed_member_list/*/user_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/moderator_list/removed_member_list/*/user_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/moderator_list/removed_member_list/*/user_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/operator_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/operator_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/operator_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
}
}
},
"scopes": [
"im:chat:read"
],
"auth_types": [
"bot"
],
"required_console_events": [
"im.chat.updated_v1"
],
"buffer_size": 100,
"workers": 1,
"resolved_output_schema": {
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"after_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"before_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"chat_id": {
"format": "chat_id",
"type": "string"
},
"external": {
"type": "boolean"
},
"moderator_list": {
"properties": {
"added_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
},
"removed_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
},
"operator_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"operator_tenant_key": {
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
},
"jq_root_path": ".event"
}

View File

@@ -1,337 +0,0 @@
Key: im.chat.updated_v1
Description: Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated
Event: im.chat.updated_v1
Required Scopes:
- im:chat:read
Required Console Events (must be enabled in developer console):
- im.chat.updated_v1
Output Schema:
{
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"after_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"before_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"chat_id": {
"format": "chat_id",
"type": "string"
},
"external": {
"type": "boolean"
},
"moderator_list": {
"properties": {
"added_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
},
"removed_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
},
"operator_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"operator_tenant_key": {
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
}

View File

@@ -1,130 +0,0 @@
{
"key": "im.message.receive_v1",
"display_name": "Receive message",
"description": "Receive IM messages",
"event_type": "im.message.receive_v1",
"subscription_type": "event",
"schema": {
"custom": {}
},
"scopes": [
"im:message.p2p_msg:readonly"
],
"auth_types": [
"bot"
],
"required_console_events": [
"im.message.receive_v1"
],
"buffer_size": 100,
"workers": 1,
"resolved_output_schema": {
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"description": "Chat/conversation ID; prefixed with oc_",
"format": "chat_id"
},
"chat_type": {
"type": "string",
"description": "Conversation type",
"enum": [
"p2p",
"group"
]
},
"content": {
"type": "string",
"description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."
},
"create_time": {
"type": "string",
"description": "Message creation time (ms timestamp string)",
"format": "timestamp_ms"
},
"event_id": {
"type": "string",
"description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead."
},
"id": {
"type": "string",
"description": "Message ID (legacy alias of message_id, kept for compatibility)",
"format": "message_id"
},
"mentions": {
"type": "array",
"description": "Compact mentions aligned with im +messages-mget",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Mentioned user open_id; prefixed with ou_",
"format": "open_id"
},
"key": {
"type": "string",
"description": "Mention placeholder key, for example @_user_1"
},
"name": {
"type": "string",
"description": "Mentioned display name"
}
}
}
},
"message_id": {
"type": "string",
"description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.",
"format": "message_id"
},
"message_type": {
"type": "string",
"description": "Message type"
},
"reply_to": {
"type": "string",
"description": "Parent message ID of the direct reply context, when present",
"format": "message_id"
},
"root_id": {
"type": "string",
"description": "Root message ID of the reply/thread context, when present",
"format": "message_id"
},
"sender_id": {
"type": "string",
"description": "Sender open_id; prefixed with ou_",
"format": "open_id"
},
"sender_type": {
"type": "string",
"description": "Sender type",
"enum": [
"user",
"bot"
]
},
"thread_id": {
"type": "string",
"description": "Thread ID, when present"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string); prefers header.create_time",
"format": "timestamp_ms"
},
"type": {
"type": "string",
"description": "Event type; always im.message.receive_v1"
},
"update_time": {
"type": "string",
"description": "Message update time (ms timestamp string); emitted only when different from create_time",
"format": "timestamp_ms"
}
}
},
"jq_root_path": "."
}

View File

@@ -1,119 +0,0 @@
Key: im.message.receive_v1
Description: Receive IM messages
Event: im.message.receive_v1
Required Scopes:
- im:message.p2p_msg:readonly
Required Console Events (must be enabled in developer console):
- im.message.receive_v1
Output Schema:
{
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"description": "Chat/conversation ID; prefixed with oc_",
"format": "chat_id"
},
"chat_type": {
"type": "string",
"description": "Conversation type",
"enum": [
"p2p",
"group"
]
},
"content": {
"type": "string",
"description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."
},
"create_time": {
"type": "string",
"description": "Message creation time (ms timestamp string)",
"format": "timestamp_ms"
},
"event_id": {
"type": "string",
"description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead."
},
"id": {
"type": "string",
"description": "Message ID (legacy alias of message_id, kept for compatibility)",
"format": "message_id"
},
"mentions": {
"type": "array",
"description": "Compact mentions aligned with im +messages-mget",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Mentioned user open_id; prefixed with ou_",
"format": "open_id"
},
"key": {
"type": "string",
"description": "Mention placeholder key, for example @_user_1"
},
"name": {
"type": "string",
"description": "Mentioned display name"
}
}
}
},
"message_id": {
"type": "string",
"description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.",
"format": "message_id"
},
"message_type": {
"type": "string",
"description": "Message type"
},
"reply_to": {
"type": "string",
"description": "Parent message ID of the direct reply context, when present",
"format": "message_id"
},
"root_id": {
"type": "string",
"description": "Root message ID of the reply/thread context, when present",
"format": "message_id"
},
"sender_id": {
"type": "string",
"description": "Sender open_id; prefixed with ou_",
"format": "open_id"
},
"sender_type": {
"type": "string",
"description": "Sender type",
"enum": [
"user",
"bot"
]
},
"thread_id": {
"type": "string",
"description": "Thread ID, when present"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string); prefers header.create_time",
"format": "timestamp_ms"
},
"type": {
"type": "string",
"description": "Event type; always im.message.receive_v1"
},
"update_time": {
"type": "string",
"description": "Message update time (ms timestamp string); emitted only when different from create_time",
"format": "timestamp_ms"
}
}
}

View File

@@ -1,25 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// compileCatalog is the event command tree's single assembly point: it turns
// the aggregated domain declarations into the immutable snapshot every
// subcommand reads. A compile failure is a defect in declarations built into
// this binary — there is nothing to recover at runtime, so it panics.
func compileCatalog() *catalog.Snapshot {
// The strategy registry that validates references is the same one that
// executes them, so "compiled" implies "resolvable at run time".
snap, err := catalog.Compile(events.All(), consumeStrategies)
if err != nil {
panic(fmt.Sprintf("event catalog failed to compile: %v", err))
}
return snap
}

View File

@@ -371,11 +371,10 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -65,17 +65,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,17 +128,6 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}

View File

@@ -403,9 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return serviceDryRun(f, request, config, opts)
return serviceDryRun(f, request, config, opts.Format)
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -667,19 +667,8 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
}
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
@@ -707,18 +696,20 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err

View File

@@ -1,400 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type serviceFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func servicePaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newServicePaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
if !errors.Is(err, sentinel) {
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newServicePaginateTestHarness(t)
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -224,39 +224,13 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data := got["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
if call["url"] != tt.wantInURL {
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
if !strings.Contains(stdout.String(), tt.wantInURL) {
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
}
})
}
}
func TestServiceMethod_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
cmd.SetArgs([]string{
"--params", `{"file_token":"boxcn123abc"}`,
"--dry-run",
"--jq", ".data.api[0].url",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
t.Fatalf("jq output = %q, want %q", got, want)
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
@@ -344,12 +318,8 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
if err != nil {
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
if !strings.Contains(stdout.String(), "Dry Run") {
t.Error("expected dry-run output")
}
}
@@ -1111,23 +1081,11 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates service command tests from the host machine: config (and
// the registry cache under it) is redirected to a temp dir, then the registry
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
if err != nil {
println("cmd/service test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd/service test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -5,7 +5,6 @@ package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
@@ -13,34 +12,11 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
func isStartupBrandHelper() bool {
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
}
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
return envNonce != "" && envNonce == argNonce
}
func startupBrandHelperNonce(args []string) string {
const prefix = "-startup-brand-helper="
for _, arg := range args {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
return ""
}
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
@@ -78,7 +54,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
// sync.Once, so the brand must be injected before the first catalog access.
// It runs in a subprocess because the registry is process-global.
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
if isStartupBrandHelper() {
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
@@ -95,11 +71,9 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Fatal(err)
}
nonce := uuid.NewString()
t.Setenv(startupBrandHelperEnv, nonce)
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
cmd.Env = append(os.Environ(),
"GO_TEST_STARTUP_BRAND_HELPER=1",
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
)
@@ -111,33 +85,3 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
for _, tt := range []struct {
name string
envNonce string
argNonce string
want bool
}{
{name: "neither set"},
{name: "ambient environment only", envNonce: "ambient"},
{name: "command argument only", argNonce: "command"},
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
} {
t.Run(tt.name, func(t *testing.T) {
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestStartupBrandHelperNonce(t *testing.T) {
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
}
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates command-tree tests from the host machine: config (and the
// registry cache under it) is redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
if isStartupBrandHelper() {
// Re-exec helper subprocess (startup_brand_test.go): the parent test
// already provides an isolated config dir and disables remote metadata,
// and the helper must own the first registry Init to prove the startup
// order — do not seed or eagerly initialize here.
os.Exit(m.Run())
}
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
if err != nil {
println("cmd test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -24,8 +24,6 @@ import (
"github.com/larksuite/cli/internal/skillscheck"
)
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -33,17 +31,13 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
// and fully mocked skills operations. Tests that only care about install-method
// detection must never fall through to the real npx skills CLI.
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
@@ -110,18 +104,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func mockSkillsSync(t *testing.T) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -246,9 +228,6 @@ func TestNormalizeVersion(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -277,9 +256,6 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -305,7 +281,6 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
}
func TestUpdateManual_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -337,7 +312,6 @@ func TestUpdateManual_JSON(t *testing.T) {
}
func TestUpdateManual_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -1187,7 +1161,6 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
}
called := false
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
@@ -1204,10 +1177,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: successfulSkillsCommand(),
}
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
@@ -1227,7 +1197,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
@@ -1544,133 +1513,28 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
}
}
// liveSkillsIsolationEnv is the single source of truth for the user-state
// directories a live skills test must redirect under the temporary home. It
// covers the CLI's own config, the agent homes the skills CLI installs into,
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
// .skill-lock.json), and the npm/npx overrides that take precedence over
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
func liveSkillsIsolationEnv(home string) map[string]string {
return map[string]string{
"HOME": home,
"USERPROFILE": home,
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
"CODEX_HOME": filepath.Join(home, ".codex"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
"npm_config_cache": filepath.Join(home, ".npm-cache"),
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
"npm_config_prefix": filepath.Join(home, ".npm-global"),
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
}
}
func prepareLiveSkillsIntegration(t *testing.T) string {
t.Helper()
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
}
home := t.TempDir()
for key, value := range liveSkillsIsolationEnv(home) {
t.Setenv(key, value)
}
return home
}
func TestPrepareLiveSkillsIntegration(t *testing.T) {
reachedAfterGate := false
t.Run("requires explicit opt-in", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "")
prepareLiveSkillsIntegration(t)
reachedAfterGate = true
})
if reachedAfterGate {
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
}
t.Run("isolates user directories", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "1")
home := prepareLiveSkillsIntegration(t)
// Pin the isolation contract by key: removing a variable from
// liveSkillsIsolationEnv must fail this list, and every redirected
// value must live under the temporary home.
required := []string{
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
"npm_config_cache", "NPM_CONFIG_CACHE",
"npm_config_prefix", "NPM_CONFIG_PREFIX",
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
}
env := liveSkillsIsolationEnv(home)
for _, key := range required {
expected, ok := env[key]
if !ok {
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
continue
}
if !strings.HasPrefix(expected, home) {
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
}
if got := os.Getenv(key); got != expected {
t.Errorf("%s = %q, want %q", key, got, expected)
}
}
})
}
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
// lark-calendar into the isolated global skills dir, and returns the parsed
// global skills list. The caller opted in explicitly, so every missing
// precondition is a hard failure — skipping would report "nothing verified"
// as a green run.
func seedLiveSkillsGlobal(t *testing.T) []string {
t.Helper()
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI, so the test is skipped when
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
t.Skipf("npx not found in PATH: %v", err)
}
// Three sequential npx runs against a cold cache (the isolated home starts
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
// the generous side.
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
}
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
t.Fatalf("failed to seed isolated global skills: %v", err)
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Fatalf("real global skills CLI unavailable: %v", err)
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if len(localSkills) == 0 {
t.Fatal("seeded lark-calendar but global skills list is empty")
}
if err := ctx.Err(); err != nil {
t.Fatalf("real skills CLI availability check timed out: %v", err)
t.Skipf("real skills CLI availability check timed out: %v", err)
}
return localSkills
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI and only runs with explicit
// opt-in. All user directories are redirected to a temporary home.
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed the
// isolated global skills install.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
@@ -1766,17 +1630,26 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
// This is a live integration test that calls the real npx skills CLI and only
// runs with explicit opt-in. All user directories are redirected to a temporary
// home.
// This is a live integration test that calls the real npx skills CLI; it is
// skipped when npx or the skills registry is unavailable.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed one known
// official skill into the isolated global install. Cold start means no
// skills-state.json — locally installed skills may still exist, and seeding
// one keeps the Phase 4 per-skill assertions from running zero times.
localSkills := seedLiveSkillsGlobal(t)
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package events aggregates the domain EventKey declarations. All returns
// them explicitly — whoever needs a catalog compiles one; nothing registers
// itself through import side effects.
package events
import (
"github.com/larksuite/cli/events/application"
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event/catalog"
)
// All returns every domain's declarations, ready for catalog.Compile.
// Mail is intentionally omitted in this phase.
func All() []catalog.KeyDefinition {
var all []catalog.KeyDefinition
for _, keys := range [][]catalog.KeyDefinition{
application.Keys(),
approval.Keys(),
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
} {
all = append(all, keys...)
}
return all
}

View File

@@ -1,101 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
type BotMenuOutput struct {
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
}
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
Operator struct {
OperatorID struct {
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
UserID string `json:"user_id"`
} `json:"operator_id"`
OperatorName string `json:"operator_name"`
} `json:"operator"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, processing.DropMalformed(raw.EventType)
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := raw.SourceTime
if timestamp == "" {
timestamp = menuTimestamp
}
operatorID := envelope.Event.Operator.OperatorID.OpenID
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: raw.EventID,
Timestamp: timestamp,
AppID: raw.AppID,
TenantKey: raw.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,
OperatorOpenID: operatorID,
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
OperatorName: envelope.Event.Operator.OperatorName,
}
return json.Marshal(out)
}
func rawScalarString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
return s
}
func timestampMillisString(raw json.RawMessage) string {
s := rawScalarString(raw)
if len(s) == 10 && allDigits(s) {
return s + "000"
}
return s
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}

View File

@@ -1,259 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/processing"
)
func TestKeysBotMenuMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeBotMenuV6 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
}
if def.EventType != eventTypeBotMenuV6 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
}
if def.SubscriptionType != "" {
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
}
if def.Schema.Custom == nil {
t.Fatal("Schema.Custom is nil")
}
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
}
if def.Schema.Native != nil {
t.Fatal("Schema.Native must be nil for processed output")
}
if def.Process == nil {
t.Fatal("Process is nil")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key)
}
}
func TestProcessBotMenu(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_001",
"event_type": "application.bot.menu_v6",
"create_time": "1776409469273",
"app_id": "cli_test",
"tenant_key": "tenant_test"
},
"event": {
"event_key": "start_eval",
"timestamp": 1776409469000,
"operator": {
"operator_id": {
"open_id": "ou_operator",
"union_id": "on_operator",
"user_id": "user_operator"
},
"operator_name": "Test User"
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
if out.EventID != "ev_menu_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.EventKey != "start_eval" {
t.Errorf("EventKey = %q", out.EventKey)
}
if out.MenuTimestamp != "1776409469000" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
}
if out.OperatorUnionID != "on_operator" {
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
}
if out.OperatorUserID != "user_operator" {
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
}
if out.OperatorName != "Test User" {
t.Errorf("OperatorName = %q", out.OperatorName)
}
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
}
}
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_002",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": "1776409469001",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1776409469001" {
t.Errorf("Timestamp fallback = %q", out.Timestamp)
}
if out.MenuTimestamp != "1776409469001" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
}
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_seconds",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": 1694592375,
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1694592375000" {
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
}
if out.MenuTimestamp != "1694592375000" {
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
}
}
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_003",
"event_type": "unexpected.event_type",
"create_time": "1776409469275"
},
"event": {
"event_key": "start_eval",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
}
func TestProcessBotMenuMalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
}
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id, create_time,
// app_id, and tenant_key from the RawEvent, which the consume pipeline fills
// from the envelope header before dispatch; tests that hand-build a RawEvent
// must mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
raw.AppID = envelope.Header.AppID
raw.TenantKey = envelope.Header.TenantKey
}
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)
}
var out BotMenuOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, got)
}
return out
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application registers Application-domain EventKeys.
package application
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeBotMenuV6 = "application.bot.menu_v6"
// Keys returns all Application-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeBotMenuV6,
DisplayName: "Bot menu",
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
EventType: eventTypeBotMenuV6,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
},
Process: processBotMenu,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
},
}
}

View File

@@ -1,145 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
func approvalSubscriptionPreConsume(eventType, subscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err
}
registered := make([]string, 0, len(subscriptionTypes))
for _, subscriptionType := range subscriptionTypes {
body := map[string]string{"subscription_type": subscriptionType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
}
registered = append(registered, subscriptionType)
}
// Approval subscriptions are durable user-auth relations. Consuming events
// should not cancel that relation when this local process exits.
return nil, nil
}
}
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
raw := strings.TrimSpace(params["subscription_type"])
if raw == "" {
return append([]string(nil), approvalAllSubscriptionTypes...), nil
}
values, err := parseApprovalSubscriptionTypeValues(raw)
if err != nil {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
selected := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
switch value {
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
selected[value] = true
default:
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
}
}
result := make([]string, 0, len(selected))
for _, value := range approvalAllSubscriptionTypes {
if selected[value] {
result = append(result, value)
}
}
if len(result) == 0 {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
return result, nil
}
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
return values, nil
}
return strings.Split(raw, ","), nil
}
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
eventType,
failed,
)
hint := fmt.Sprintf(
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
eventType,
)
if len(registered) > 0 {
msg = fmt.Sprintf(
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
eventType,
strings.Join(registered, ", "),
failed,
)
hint = fmt.Sprintf(
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
eventType,
strings.Join(registered, ", "),
failed,
)
}
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = msg + ": " + upstream
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
WithHint("%s", hint).
WithCause(err)
}
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid subscription_type for EventKey %s: %q", eventType, value).
WithParam("--param").
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
eventType)
}

View File

@@ -1,158 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package approval registers Approval-domain EventKeys.
package approval
import (
"context"
"encoding/json"
"reflect"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
const (
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
)
var approvalAllSubscriptionTypes = []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
}
// Keys returns all Approval-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeApprovalInstanceStatusChangedV4,
DisplayName: "Approval instance status changed",
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
EventType: eventTypeApprovalInstanceStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, pathApprovalInstancesSubscription),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
},
{
Key: eventTypeApprovalTaskStatusChangedV4,
DisplayName: "Approval task status changed",
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
EventType: eventTypeApprovalTaskStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
},
}
}
func approvalSubscriptionParams() []event.ParamDef {
return []event.ParamDef{
{
Name: "subscription_type",
Type: event.ParamMulti,
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
Values: []event.ParamValue{
{
Value: approvalSubscriptionTypeInvolved,
Desc: "Receive events where the current user is the approval requester or approver.",
},
{
Value: approvalSubscriptionTypeManaged,
Desc: "Receive events under approval definitions managed by the current user.",
},
},
},
}
}
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
ExternalID string `json:"external_id"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
StartUser *ApprovalUserID `json:"start_user"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, processing.DropMalformed(raw.EventType)
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
return json.Marshal(out)
}
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
TaskID string `json:"task_id"`
ExternalID string `json:"external_id"`
TaskExternalID string `json:"task_external_id"`
AssignedUser *ApprovalUserID `json:"assigned_user"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, processing.DropMalformed(raw.EventType)
}
out := &ApprovalTaskStatusChangedV4Output{
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
ExternalID: envelope.Event.ExternalID,
TaskExternalID: envelope.Event.TaskExternalID,
AssignedUser: envelope.Event.AssignedUser,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
return json.Marshal(out)
}

View File

@@ -1,671 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/event/schemas"
)
type recordedCall struct {
method string
path string
body interface{}
}
type fakeAPIClient struct {
calls []recordedCall
err error
errOnCall int
}
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
return nil, f.err
}
return json.RawMessage(`{}`), nil
}
func TestKeysApprovalMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 2 {
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
}
tests := []struct {
key string
scope string
schemaType reflect.Type
subscribe string
}{
{
key: eventTypeApprovalInstanceStatusChangedV4,
scope: "approval:instance:read",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
subscribe: pathApprovalInstancesSubscription,
},
{
key: eventTypeApprovalTaskStatusChangedV4,
scope: "approval:task:read",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
subscribe: pathApprovalTasksSubscription,
},
}
byKey := make(map[string]event.KeyDefinition, len(keys))
for _, def := range keys {
byKey[def.Key] = def
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
def, ok := byKey[tc.key]
if !ok {
t.Fatalf("missing key %s", tc.key)
}
if def.EventType != tc.key {
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
}
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
}
if def.Schema.Native != nil {
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
}
if def.Process == nil {
t.Fatal("Process must flatten raw V2 envelopes")
}
if def.PreConsume == nil {
t.Fatal("PreConsume must subscribe approval user-auth events")
}
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
}
assertSubscriptionParam(t, def.Params)
})
}
}
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
t.Helper()
if len(params) != 1 {
t.Fatalf("len(params) = %d, want 1", len(params))
}
p := params[0]
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
}
got := map[string]string{}
for _, v := range p.Values {
got[v.Value] = v.Desc
}
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
if got[want] == "" {
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
}
}
}
type reflectedApprovalSchema struct {
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
type reflectedApprovalSchemaProperty struct {
Format string `json:"format"`
Enum []string `json:"enum"`
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
func TestApprovalSchemasAnnotations(t *testing.T) {
tests := []struct {
name string
schemaType reflect.Type
eventType string
statusValues []string
userField string
}{
{
name: "instance",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
eventType: eventTypeApprovalInstanceStatusChangedV4,
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "start_user",
},
{
name: "task",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
eventType: eventTypeApprovalTaskStatusChangedV4,
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "assigned_user",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var schema reflectedApprovalSchema
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
props := schema.Properties
eventTypeEnum := props["type"].Enum
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
}
if got := props["timestamp"].Format; got != "timestamp_ms" {
t.Errorf("timestamp format = %v, want timestamp_ms", got)
}
assertEnumContains(t, props["status"].Enum, tc.statusValues)
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
}
userProps := props[tc.userField].Properties
if got := userProps["open_id"].Format; got != "open_id" {
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
}
if got := userProps["union_id"].Format; got != "union_id" {
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
}
if got := userProps["user_id"].Format; got != "user_id" {
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
}
})
}
}
func assertEnumContains(t *testing.T, raw []string, wants []string) {
t.Helper()
got := make(map[string]bool, len(raw))
for _, v := range raw {
got[v] = true
}
for _, want := range wants {
if !got[want] {
t.Errorf("enum missing %q; enum=%v", want, raw)
}
}
}
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
tests := []struct {
name string
eventType string
subscribePath string
params map[string]string
wantTypes []string
}{
{
name: "instance omitted subscription_type registers both",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "task explicit single managed",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
wantTypes: []string{approvalSubscriptionTypeManaged},
},
{
name: "task comma separated multi canonicalizes and deduplicates",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "instance json array multi",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
params: map[string]string{
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(tc.eventType, tc.subscribePath)
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
t.Fatalf("PreConsume returned error: %v", err)
}
if cleanup != nil {
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
}
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
})
}
}
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
t.Helper()
if len(got) != len(wantTypes) {
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
}
for i, wantType := range wantTypes {
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
}
}
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
t.Helper()
if got.method != wantMethod {
t.Errorf("method = %q, want %q", got.method, wantMethod)
}
if got.path != wantPath {
t.Errorf("path = %q, want %q", got.path, wantPath)
}
if !reflect.DeepEqual(got.body, wantBody) {
t.Errorf("body = %#v, want %#v", got.body, wantBody)
}
}
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "")
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal {
t.Fatalf("err = %T/%v, want typed internal error", err, err)
}
})
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "")
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
}
if ve.Hint == "" {
t.Error("invalid subscription_type should carry a hint")
}
})
}
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription)
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
t.Fatal("expected partial registration error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on registration error")
}
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
for _, want := range []string{
"registered subscription_type(s) [INVOLVED_APPROVAL]",
"failed subscription_type MANAGED_APPROVAL",
} {
if !strings.Contains(p.Message, want) {
t.Errorf("partial error message missing %q: %q", want, p.Message)
}
}
for _, want := range []string{
"already registered",
"--param subscription_type=MANAGED_APPROVAL",
} {
if !strings.Contains(p.Hint, want) {
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
}
}
})
}
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
t.Fatalf("nil cause returned error: %v", err)
}
})
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
upstream,
)
if err != upstream {
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
t.Errorf("message missing failed relation: %q", p.Message)
}
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint missing %q: %q", want, p.Hint)
}
}
})
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
cause := errors.New("transport closed")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
cause,
)
if !errors.Is(err, cause) {
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
t.Errorf("hint missing no-registration context: %q", p.Hint)
}
})
}
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
out := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_001",
"event_type": "approval.instance.status_changed_v4",
"create_time": "1710000000000"
},
"event": {
"approval_code": "approval_code_001",
"instance_code": "instance_code_001",
"external_id": "external_001",
"status": "PENDING",
"operate_time": "1666079207003",
"start_user": {
"open_id": "ou_start",
"union_id": "on_start",
"user_id": "user_start"
}
}
}`)
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
}
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
}
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
}
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
}
}
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
out := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_001",
"event_type": "approval.task.status_changed_v4",
"create_time": "1710000000001"
},
"event": {
"approval_code": "approval_code_002",
"instance_code": "instance_code_002",
"task_id": "task_001",
"external_id": "external_002",
"task_external_id": "task_external_001",
"status": "APPROVED",
"operate_time": "1666079207004",
"assigned_user": {
"open_id": "ou_assignee",
"union_id": "on_assignee",
"user_id": "user_assignee"
}
}
}`)
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
}
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
}
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
}
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
}
}
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
instance := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_fallback",
"create_time": "1710000000002"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"status": "APPROVED",
"operate_time": "1666079207005"
}
}`)
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
}
task := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_fallback",
"create_time": "1710000000003"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"task_id": "task_fallback",
"status": "DONE",
"operate_time": "1666079207006"
}
}`)
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
}
}
func TestProcessApprovalStatusChangedMalformedPayloadDrop(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
})
}
}
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
for _, tc := range []struct {
name string
process event.ProcessFunc
}{
{"instance", processApprovalInstanceStatusChanged},
{"task", processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.process(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("Process nil raw returned error: %v", err)
}
if got != nil {
t.Fatalf("Process nil raw output = %s, want nil", string(got))
}
})
}
}
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id and
// create_time from the RawEvent, which the consume pipeline fills from the
// envelope header before dispatch; tests that hand-build a RawEvent must
// mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalInstanceStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalInstanceStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalTaskStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalTaskStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
}
return out
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key)
}
}
}
var _ event.APIClient = (*fakeAPIClient)(nil)

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
// ApprovalUserID identifies a user in the three Lark ID formats included by
// approval status-change events.
type ApprovalUserID struct {
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
}
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
// approval.instance.status_changed_v4.
type ApprovalInstanceStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
}
// ApprovalTaskStatusChangedV4Output is the flattened shape for
// approval.task.status_changed_v4.
type ApprovalTaskStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
}

View File

@@ -1,356 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Architecture gates for the events declaration layer.
//
// events/<domain> packages are declarations: EventKeys, payload shapes, and
// processing hooks. Two kinds of rot would quietly destroy that role:
//
// 1. Importing command wiring, a transport host, or a concrete adapter turns
// declarations into another place where process and transport concerns
// accumulate, and drags the whole adapter tree into every binary that
// only wanted the catalog.
// 2. Re-parsing the envelope header inside a domain duplicates the kernel's
// single header decode; the copies then drift apart the day the envelope
// evolves.
//
// These tests turn both into build breaks.
package events_test
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"testing"
)
const (
archModulePath = "github.com/larksuite/cli"
archAdapterImportPrefix = archModulePath + "/internal/event/adapter"
)
// archProductionGoFiles returns every non-test .go file under root,
// skipping testdata directories. Paths are relative to root.
func archProductionGoFiles(t *testing.T, root string) []string {
t.Helper()
var files []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "testdata" {
return fs.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
files = append(files, path)
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
sort.Strings(files)
return files
}
// archForbiddenDomainImport reports why importPath is banned in events/, if
// it is. Domains may use the kernel (internal/event, model, catalog,
// processing, ...); they must never see the layers that host or transport
// them.
func archForbiddenDomainImport(importPath string) (reason string, banned bool) {
switch importPath {
case "github.com/spf13/cobra":
return "CLI framework; command wiring lives in cmd, a declaration that needs cobra has stopped being a declaration", true
case archModulePath + "/internal/event/bus":
return "bus is a host process; a domain importing its host inverts the dependency direction", true
case archModulePath + "/internal/event/consume":
return "consume is a host process; a domain importing its host inverts the dependency direction", true
}
if importPath == archAdapterImportPrefix || strings.HasPrefix(importPath, archAdapterImportPrefix+"/") {
return "concrete adapter; domains must stay transport-agnostic so any host can serve them", true
}
return "", false
}
// TestArchEventsImportRedline fails when any production file under events/
// imports command wiring, an event host, or a concrete adapter. It keeps the
// declaration layer linkable everywhere without pulling in transports.
func TestArchEventsImportRedline(t *testing.T) {
files := archProductionGoFiles(t, ".")
if len(files) == 0 {
t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run")
}
fset := token.NewFileSet()
for _, file := range files {
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
for _, imp := range f.Imports {
path, err := strconv.Unquote(imp.Path.Value)
if err != nil {
t.Fatalf("unquote import in %s: %v", file, err)
}
if reason, banned := archForbiddenDomainImport(path); banned {
t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason)
}
}
}
}
// envelopeHeaderTags are the metadata fields the kernel decodes exactly once
// from the envelope header. A domain that re-declares any of them inside a
// json:"header" block is re-parsing the envelope instead of consuming the
// kernel's decode — the duplicate drifts silently when the envelope changes.
var envelopeHeaderTags = map[string]bool{
"event_id": true,
"event_type": true,
"create_time": true,
"app_id": true,
"tenant_key": true,
}
// headerReparseBaseline is the ratchet of pinned pre-existing residue, keyed
// by file (relative to events/) with the header metadata tags it re-parses.
// It is empty: every domain consumes the kernel-decoded header, so the gate
// runs at zero tolerance. Never add an entry — new code must read the
// kernel-decoded header instead of unmarshalling the envelope again.
var headerReparseBaseline = map[string][]string{}
type archHeaderReparse struct {
file string // slash path relative to events/
line int
field string // Go field name inside the header block
tag string // offending json tag
}
// archJSONTagName extracts the json name (first comma segment) from a struct
// field tag, or "" when absent.
func archJSONTagName(field *ast.Field) string {
if field.Tag == nil {
return ""
}
raw, err := strconv.Unquote(field.Tag.Value)
if err != nil {
return ""
}
name, _, _ := strings.Cut(reflect.StructTag(raw).Get("json"), ",")
return name
}
// archNamedStructIndex maps type names declared in the given files (one
// package) to their struct bodies, so a json:"header" field with a named
// type still resolves.
func archNamedStructIndex(files []*ast.File) map[string]*ast.StructType {
index := make(map[string]*ast.StructType)
for _, f := range files {
for _, decl := range f.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if st, ok := ts.Type.(*ast.StructType); ok {
index[ts.Name.Name] = st
}
}
}
}
return index
}
// archStructBody resolves expr to a struct body: inline struct types,
// pointers to them, and named types declared in the same package.
func archStructBody(expr ast.Expr, named map[string]*ast.StructType) *ast.StructType {
switch v := expr.(type) {
case *ast.StructType:
return v
case *ast.StarExpr:
return archStructBody(v.X, named)
case *ast.Ident:
return named[v.Name]
}
return nil
}
// archFindHeaderReparses flags every field inside a json:"header" struct
// block whose json tag re-declares envelope header metadata. Fields outside
// header blocks are never flagged: a domain body owning its own create_time
// (e.g. a message's own timestamps) is legitimate.
func archFindHeaderReparses(fset *token.FileSet, file *ast.File, relPath string, named map[string]*ast.StructType) []archHeaderReparse {
var found []archHeaderReparse
ast.Inspect(file, func(n ast.Node) bool {
st, ok := n.(*ast.StructType)
if !ok {
return true
}
for _, field := range st.Fields.List {
if archJSONTagName(field) != "header" {
continue
}
body := archStructBody(field.Type, named)
if body == nil {
continue
}
for _, hf := range body.Fields.List {
tag := archJSONTagName(hf)
if !envelopeHeaderTags[tag] {
continue
}
name := "(embedded)"
if len(hf.Names) > 0 {
parts := make([]string, len(hf.Names))
for i, ident := range hf.Names {
parts[i] = ident.Name
}
name = strings.Join(parts, ",")
}
found = append(found, archHeaderReparse{
file: relPath,
line: fset.Position(hf.Pos()).Line,
field: name,
tag: tag,
})
}
}
return true
})
return found
}
// TestArchEventsNoHeaderMetadataReparse fails when a production file under
// events/ declares a json:"header" struct block that re-parses envelope
// header metadata, except for the pinned pre-existing residue in
// headerReparseBaseline (which may only shrink).
func TestArchEventsNoHeaderMetadataReparse(t *testing.T) {
files := archProductionGoFiles(t, ".")
if len(files) == 0 {
t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run")
}
// Parse per directory so named header types declared in a sibling file
// of the same package still resolve.
byDir := make(map[string][]string)
for _, file := range files {
dir := filepath.Dir(file)
byDir[dir] = append(byDir[dir], file)
}
dirs := make([]string, 0, len(byDir))
for dir := range byDir {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
fset := token.NewFileSet()
var violations []archHeaderReparse
for _, dir := range dirs {
astFiles := make([]*ast.File, 0, len(byDir[dir]))
for _, file := range byDir[dir] {
f, err := parser.ParseFile(fset, file, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
astFiles = append(astFiles, f)
}
named := archNamedStructIndex(astFiles)
for i, f := range astFiles {
rel := filepath.ToSlash(byDir[dir][i])
violations = append(violations, archFindHeaderReparses(fset, f, rel, named)...)
}
}
seen := make(map[string]bool)
for _, v := range violations {
seen[v.file+"\x00"+v.tag] = true
if slices.Contains(headerReparseBaseline[v.file], v.tag) {
continue
}
t.Errorf("%s:%d field %s re-parses envelope header metadata %q inside a json:\"header\" block — consume the kernel-decoded header instead of unmarshalling the envelope again", v.file, v.line, v.field, v.tag)
}
// Stale baseline entries: once a file stops re-parsing a tag, its entry
// must go, otherwise the ratchet is wider than reality and the cleanup
// can silently regress.
for file, tags := range headerReparseBaseline {
for _, tag := range tags {
if !seen[file+"\x00"+tag] {
t.Errorf("stale baseline entry %s / %q: no code matches it anymore — delete the entry so the cleanup is locked in", file, tag)
}
}
}
}
// TestArchEventsHeaderReparseDetectorSelfCheck runs the header-reparse
// detector on synthetic sources with a known violation count. If the
// detector rots (tag parsing, named-type resolution, header matching), the
// main gate would report green on a violating tree; this test makes that
// failure mode loud.
func TestArchEventsHeaderReparseDetectorSelfCheck(t *testing.T) {
parse := func(src string) []archHeaderReparse {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "synthetic.go", src, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("parse synthetic source: %v", err)
}
files := []*ast.File{f}
return archFindHeaderReparses(fset, f, "synthetic.go", archNamedStructIndex(files))
}
const violating = `package synth
type namedHeader struct {
AppID string ` + "`json:\"app_id\"`" + `
}
type envelope struct {
Header struct {
EventID string ` + "`json:\"event_id\"`" + `
TenantKey string ` + "`json:\"tenant_key,omitempty\"`" + `
Custom string ` + "`json:\"custom\"`" + `
} ` + "`json:\"header,omitempty\"`" + `
Named *namedHeader ` + "`json:\"header\"`" + `
Body struct {
CreateTime string ` + "`json:\"create_time\"`" + `
} ` + "`json:\"body\"`" + `
}
`
got := parse(violating)
gotIDs := make([]string, len(got))
for i, v := range got {
gotIDs[i] = v.field + ":" + v.tag
}
sort.Strings(gotIDs)
wantIDs := []string{"AppID:app_id", "EventID:event_id", "TenantKey:tenant_key"}
if !slices.Equal(gotIDs, wantIDs) {
t.Fatalf("detector self-check: flagged %v, want exactly %v — the detector has drifted and the main gate cannot be trusted", gotIDs, wantIDs)
}
const clean = `package synth
type output struct {
EventID string ` + "`json:\"event_id\"`" + `
Header struct {
Custom string ` + "`json:\"custom\"`" + `
} ` + "`json:\"header\"`" + `
}
`
if got := parse(clean); len(got) != 0 {
t.Fatalf("detector self-check: clean synthetic source flagged %+v — the detector over-triggers and will produce false reds", got)
}
}

View File

@@ -1,26 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"testing"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// compileRealCatalog compiles the full shipped declaration set exactly as the
// runtime does. Tests that used to walk the global registry iterate this
// snapshot instead.
func compileRealCatalog(t *testing.T) *catalog.Snapshot {
t.Helper()
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile catalog: %v", err)
}
return snap
}

View File

@@ -1,93 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"encoding/json"
"testing"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// This gate lives in the events package because the catalog package cannot
// import the declarations it compiles (that would be an import cycle). It is
// the acceptance half of the compiler's own rejection tests: the real catalog
// must compile — a compiler that rejects everything would also pass those.
func TestCompile_RealCatalogCompilesCleanly(t *testing.T) {
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("the shipped declarations must compile: %v", err)
}
if snap.Len() == 0 {
t.Fatal("the compiled catalog is empty; the gate proved nothing")
}
if snap.Len() != len(expectedKeys) {
t.Fatalf("compiled %d keys, frozen baseline has %d", snap.Len(), len(expectedKeys))
}
for _, want := range expectedKeys {
if _, ok := snap.Resolve(want); !ok {
t.Errorf("baseline key missing from the compiled catalog: %s", want)
}
}
}
// Every shipped key must satisfy its compiled output contract: a resolvable
// non-empty schema, a jq root that matches the output mode, and normalized
// delivery values. Golden files pin a few representative keys byte-for-byte;
// this covers the whole catalog structurally.
func TestOutputContract_HoldsForEveryKey(t *testing.T) {
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatal(err)
}
checked := 0
for _, entry := range snap.Entries() {
checked++
d := entry.Descriptor()
out := entry.Output()
var parsed map[string]json.RawMessage
if err := json.Unmarshal(out.SchemaJSON, &parsed); err != nil || len(parsed) == 0 {
t.Errorf("%s: resolved schema must be a non-empty JSON object (err=%v)", d.Key, err)
}
switch out.Mode {
case catalog.OutputNative:
if out.JQRootPath != ".event" {
t.Errorf("%s: native keys deliver the V2 envelope; jq root must be .event, got %q", d.Key, out.JQRootPath)
}
if entry.Binding().Process != nil {
t.Errorf("%s: native keys must not carry a processor", d.Key)
}
case catalog.OutputProcessed:
if out.JQRootPath != "." {
t.Errorf("%s: processed keys deliver a flat shape; jq root must be ., got %q", d.Key, out.JQRootPath)
}
if entry.Binding().Process == nil {
t.Errorf("%s: processed keys must carry a processor", d.Key)
}
default:
t.Errorf("%s: unknown output mode %q", d.Key, out.Mode)
}
cap := entry.Capability()
if cap.BufferSize <= 0 || cap.BufferSize > catalog.MaxBufferSize || cap.Workers <= 0 {
t.Errorf("%s: delivery values must be normalized, got buffer=%d workers=%d", d.Key, cap.BufferSize, cap.Workers)
}
if d.Domain == "" {
t.Errorf("%s: descriptor domain must always be resolved", d.Key)
}
}
if checked == 0 {
t.Fatal("no entries were checked; the gate proved nothing")
}
}

View File

@@ -1,59 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"testing"
)
// expectedKeys is the frozen catalog baseline. Adding, removing, or renaming
// an EventKey is a deliberate contract change: update this list in the same
// commit and call the change out in the changelog.
var expectedKeys = []string{
"application.bot.menu_v6",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"board.whiteboard.updated_v1",
"card.action.trigger",
"im.chat.disbanded_v1",
"im.chat.member.bot.added_v1",
"im.chat.member.bot.deleted_v1",
"im.chat.member.user.added_v1",
"im.chat.member.user.deleted_v1",
"im.chat.member.user.withdrawn_v1",
"im.chat.updated_v1",
"im.message.message_read_v1",
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.message.receive_v1",
"minutes.minute.generated_v1",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_ended_v1",
"vc.meeting.participant_meeting_joined_v1",
"vc.meeting.participant_meeting_started_v1",
"vc.note.generated_v1",
"vc.recording.recording_ended_v1",
"vc.recording.recording_started_v1",
"vc.recording.recording_transcript_generated_v1",
}
func TestRegisteredKeys_MatchFrozenBaseline(t *testing.T) {
all := compileRealCatalog(t).Definitions()
if len(all) == 0 {
t.Fatal("no EventKeys registered; the gate scanned nothing")
}
got := make(map[string]bool, len(all))
for _, def := range all {
got[def.Key] = true
}
for _, want := range expectedKeys {
if !got[want] {
t.Errorf("expected EventKey missing from registry: %s", want)
}
delete(got, want)
}
for extra := range got {
t.Errorf("EventKey not in frozen baseline (update expectedKeys deliberately): %s", extra)
}
}

View File

@@ -9,7 +9,6 @@ import (
"strings"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
@@ -36,6 +35,11 @@ type CardActionTriggerOutput struct {
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Operator struct {
OpenID string `json:"open_id"`
@@ -60,7 +64,7 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, processing.DropMalformed(raw.EventType)
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
}
actionValue := marshalToString(envelope.Event.Action.Value)
@@ -68,9 +72,9 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,

View File

@@ -10,11 +10,10 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := lookupCompiledDef(t, "card.action.trigger")
def, ok := event.Lookup("card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
@@ -244,11 +243,11 @@ func TestProcessCardAction_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), nil, raw, nil)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
@@ -416,7 +415,6 @@ func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionT
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
)
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id and
// create_time from the RawEvent, which the consume pipeline fills from the
// envelope header before dispatch; tests that hand-build a RawEvent must
// mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
}
// lookupCompiledDef compiles this domain's declarations and resolves one key,
// exactly as the runtime catalog would for a consumer.
func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) {
t.Helper()
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
entry, ok := snap.Resolve(key)
if !ok {
return nil, false
}
return entry.Definition(), true
}

View File

@@ -8,63 +8,50 @@ import (
"encoding/json"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
)
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
type ImMessageReceiveOutput struct {
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
}
type MentionOutput struct {
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
Name string `json:"name,omitempty" desc:"Mentioned display name"`
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Message struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
ThreadID string `json:"thread_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Mentions []interface{} `json:"mentions"`
} `json:"message"`
Sender struct {
SenderType string `json:"sender_type"`
SenderID struct {
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, processing.DropMalformed(raw.EventType)
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
msg := envelope.Event.Message
@@ -78,14 +65,14 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
})
}
timestamp := raw.SourceTime
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = msg.CreateTime
}
out := &ImMessageReceiveOutput{
Type: raw.EventType,
EventID: raw.EventID,
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
ID: msg.MessageID,
MessageID: msg.MessageID,
@@ -94,54 +81,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
ChatType: msg.ChatType,
MessageType: msg.MessageType,
SenderID: envelope.Event.Sender.SenderID.OpenID,
SenderType: envelope.Event.Sender.SenderType,
RootID: msg.RootID,
ThreadID: msg.ThreadID,
ReplyTo: msg.ParentID,
Content: content,
Mentions: compactMentions(msg.Mentions),
}
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
out.UpdateTime = msg.UpdateTime
}
return json.Marshal(out)
}
func compactMentions(mentions []interface{}) []MentionOutput {
if len(mentions) == 0 {
return nil
}
out := make([]MentionOutput, 0, len(mentions))
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
mention := MentionOutput{
Key: stringField(item, "key"),
ID: mentionOpenID(item["id"]),
Name: stringField(item, "name"),
}
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
out = append(out, mention)
}
}
if len(out) == 0 {
return nil
}
return out
}
func stringField(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
func mentionOpenID(raw interface{}) string {
switch v := raw.(type) {
case map[string]interface{}:
openID, _ := v["open_id"].(string)
return openID
case string:
return v
default:
return ""
}
}

View File

@@ -6,15 +6,22 @@ package im
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) {
def, ok := lookupCompiledDef(t, "im.message.receive_v1")
def, ok := event.Lookup("im.message.receive_v1")
if !ok {
t.Fatal("im.message.receive_v1 should be registered via Keys()")
}
@@ -46,7 +53,7 @@ func TestIMKeys_NativeEventsRegistered(t *testing.T) {
"im.chat.disbanded_v1",
}
for _, k := range want {
def, ok := lookupCompiledDef(t, k)
def, ok := event.Lookup(k)
if !ok {
t.Errorf("%s should be registered via Keys()", k)
continue
@@ -77,32 +84,19 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"root_id": "om_root_001",
"parent_id": "om_parent_001",
"thread_id": "omt_thread_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"update_time": "1776409469999",
"content": "{\"text\":\"hello @_user_1\"}",
"mentions": [
{
"key": "@_user_1",
"id": {"open_id": "ou_mentioned"},
"name": "Alice"
}
]
"content": "{\"text\":\"hello there\"}"
}
}
}`
out := runReceive(t, payload)
outMap := runReceiveMap(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
@@ -116,69 +110,12 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
if out.SenderID != "ou_sender" {
t.Errorf("SenderID = %q", out.SenderID)
}
if out.Content != "hello @Alice" {
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
if out.Content != "hello there" {
t.Errorf("Content = %q, want \"hello there\"", out.Content)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
for field, want := range map[string]string{
"sender_type": "user",
"root_id": "om_root_001",
"thread_id": "omt_thread_001",
"reply_to": "om_parent_001",
"update_time": "1776409469999",
} {
if got, _ := outMap[field].(string); got != want {
t.Errorf("%s = %q, want %q", field, got, want)
}
}
mentions, _ := outMap["mentions"].([]interface{})
if len(mentions) != 1 {
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
}
mention, _ := mentions[0].(map[string]interface{})
for field, want := range map[string]string{
"key": "@_user_1",
"id": "ou_mentioned",
"name": "Alice",
} {
if got, _ := mention[field].(string); got != want {
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
}
}
}
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_text",
"event_type": "im.message.receive_v1",
"create_time": "1776409469273",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"update_time": "1776409468987",
"content": "{\"text\":\"hello there\"}"
}
}
}`
outMap := runReceiveMap(t, payload)
if _, ok := outMap["update_time"]; ok {
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
}
}
func TestProcessImMessageReceive_Interactive(t *testing.T) {
@@ -225,11 +162,11 @@ func TestProcessImMessageReceive_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
@@ -241,7 +178,6 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
@@ -252,23 +188,3 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
}
return out
}
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package subscribeprep provides the shared PreConsume hook for EventKeys
// whose server-side subscription is a plain event_type register/unregister
// pair against fixed OAPI paths.
package subscribeprep
import (
"context"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event/processing"
)
// CleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const CleanupTimeout = 5 * time.Second
// Hook returns a PreConsume that subscribes eventType via subscribePath and
// hands back a cleanup that unsubscribes it via unsubscribePath.
func Hook(eventType, subscribePath, unsubscribePath string) func(context.Context, processing.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt processing.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
return SubscribeWithCleanup(ctx, rt, eventType, subscribePath, unsubscribePath)
}
}
// SubscribeWithCleanup calls the subscribe OAPI for eventType and returns a
// cleanup that invokes the matching unsubscribe, bounded by CleanupTimeout.
// rt must be non-nil; callers that validate their own params (e.g. to build
// per-resource paths) run those checks first and then delegate here.
func SubscribeWithCleanup(ctx context.Context, rt processing.APIClient, eventType, subscribePath, unsubscribePath string) (func() error, error) {
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), CleanupTimeout)
defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}

View File

@@ -9,19 +9,11 @@ import (
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestAllKeys_FieldOverridePointersResolve(t *testing.T) {
snap, err := catalog.Compile(All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile catalog: %v", err)
}
for _, def := range snap.Definitions() {
for _, def := range event.ListAll() {
if len(def.Schema.FieldOverrides) == 0 {
continue
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
)
// lookupCompiledDef compiles this domain's declarations and resolves one key,
// exactly as the runtime catalog would for a consumer.
func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) {
t.Helper()
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
entry, ok := snap.Resolve(key)
if !ok {
return nil, false
}
return entry.Definition(), true
}

View File

@@ -10,7 +10,6 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/validate"
)
@@ -37,6 +36,11 @@ type MinutesMinuteGeneratedOutput struct {
func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
MinuteToken string `json:"minute_token"`
MinuteSource struct {
@@ -46,15 +50,18 @@ func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, processing.DropMalformed(raw.EventType)
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &MinutesMinuteGeneratedOutput{
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MinuteToken: envelope.Event.MinuteToken,
}
if out.Type == "" {
out.Type = raw.EventType
}
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
out.MinuteSource = &MinutesMinuteSourceOutput{
SourceType: src.SourceType,

View File

@@ -7,12 +7,12 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/validate"
)
@@ -35,10 +35,17 @@ func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string)
}
}
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated)
def, ok := event.Lookup(eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
@@ -267,7 +274,7 @@ func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated)
def, ok := event.Lookup(eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
@@ -319,38 +326,14 @@ func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id and
// create_time from the RawEvent, which the consume pipeline fills from the
// envelope header before dispatch; tests that hand-build a RawEvent must
// mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
}
func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
@@ -358,7 +341,6 @@ func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) Minute
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -7,7 +7,6 @@ package minutes
import (
"reflect"
"github.com/larksuite/cli/events/internal/subscribeprep"
"github.com/larksuite/cli/internal/event"
)
@@ -32,7 +31,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
},
Process: processMinutesMinuteGenerated,
PreConsume: subscribeprep.Hook(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
Scopes: []string{"minutes:minutes.basic:read"},
AuthTypes: []string{
"user",

View File

@@ -1,461 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"bytes"
"context"
"encoding/json"
"flag"
"maps"
"os"
"path/filepath"
"sort"
"testing"
"time"
event "github.com/larksuite/cli/internal/event"
)
var updateBaseline = flag.Bool("update-baseline", false,
"rewrite testdata/output_baseline.json with the current Processed EventKey outputs")
// TestMain pins the process timezone to UTC before any test runs. Several
// Process handlers format timestamps in the machine's local timezone
// (e.g. meeting start/end times, recording event times), so without the pin
// the snapshot would drift between machines in different timezones.
func TestMain(m *testing.M) {
time.Local = time.UTC
os.Exit(m.Run())
}
const baselineSnapshotPath = "testdata/output_baseline.json"
// wantProcessedKeys freezes how many registered EventKeys define Process
// (im 2, vc 7, minutes 1, application 1, approval 2). The count assertion
// keeps this test honest: if a Processed key is added or removed, the covered
// output surface changed and the baseline would silently widen or narrow
// without it. Update the count, the fixtures, and the snapshot together,
// deliberately.
const wantProcessedKeys = 13
const (
baselineEventID = "evt-baseline-001"
baselineCreateTime = "1700000000000" // 2023-11-14T22:13:20Z in milliseconds
)
// baselineFixture holds the minimal well-formed inputs for one Processed
// EventKey: the business body placed under "event" in the V2 envelope, plus
// any extra header fields the handler reads beyond event_id / event_type /
// create_time. Every fixture must drive Process down its success path — no
// drop, no malformed-payload passthrough.
type baselineFixture struct {
extraHeader map[string]string
eventBody string
}
// baselineFixtures maps every Processed EventKey to its synthetic input.
// Field values are fixed constants so the resulting output is byte-stable.
var baselineFixtures = map[string]baselineFixture{
"application.bot.menu_v6": {
extraHeader: map[string]string{
"app_id": "cli-baseline-app",
"tenant_key": "tenant-baseline",
},
// 10-digit seconds timestamp: the handler normalizes it to milliseconds.
eventBody: `{
"event_key": "baseline_menu_key",
"timestamp": 1700000000,
"operator": {
"operator_id": {
"open_id": "ou-baseline-operator",
"union_id": "on-baseline-operator",
"user_id": "user-baseline-operator"
},
"operator_name": "Baseline Operator"
}
}`,
},
"approval.instance.status_changed_v4": {
eventBody: `{
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"external_id": "external-id-baseline",
"status": "APPROVED",
"operate_time": "1700000000000",
"start_user": {
"open_id": "ou-baseline-starter",
"union_id": "on-baseline-starter",
"user_id": "user-baseline-starter"
}
}`,
},
"approval.task.status_changed_v4": {
eventBody: `{
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"task_id": "task-id-baseline",
"external_id": "external-id-baseline",
"task_external_id": "task-external-id-baseline",
"status": "APPROVED",
"operate_time": "1700000000000",
"assigned_user": {
"open_id": "ou-baseline-assignee",
"union_id": "on-baseline-assignee",
"user_id": "user-baseline-assignee"
}
}`,
},
// The card handler fetches the card content through the API client using
// context.open_message_id; the fake client below serves that request.
"card.action.trigger": {
eventBody: `{
"operator": {"open_id": "ou-baseline-operator"},
"token": "card-token-baseline",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "baseline"},
"name": "baseline_button",
"form_value": {"field": "value"},
"input_value": "baseline input",
"option": "opt-1",
"options": ["opt-1", "opt-2"],
"checked": true,
"timezone": "Asia/Shanghai"
},
"context": {
"open_message_id": "om-baseline-card",
"open_chat_id": "oc-baseline-chat"
}
}`,
},
// update_time differs from create_time so the handler emits both; the
// mention placeholder in content exercises mention rendering.
"im.message.receive_v1": {
eventBody: `{
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou-baseline-sender"}
},
"message": {
"message_id": "om-baseline-msg",
"root_id": "om-baseline-root",
"parent_id": "om-baseline-parent",
"thread_id": "omt-baseline-thread",
"chat_id": "oc-baseline-chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1699999999000",
"update_time": "1700000000500",
"content": "{\"text\":\"hello @_user_1\"}",
"mentions": [
{
"key": "@_user_1",
"id": {"open_id": "ou-baseline-mention"},
"name": "Baseline User"
}
]
}
}`,
},
// The minutes handler enriches the output with the minute title via the
// API client; the fake client answers with a non-empty title on the first
// call so no retry attempt is made.
"minutes.minute.generated_v1": {
eventBody: `{
"minute_token": "minute-token-baseline",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
}`,
},
"vc.meeting.participant_meeting_started_v1": {
eventBody: `{
"meeting": {
"id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "1700000000",
"calendar_event_id": "calendar-event-baseline"
}
}`,
},
"vc.meeting.participant_meeting_joined_v1": {
eventBody: `{
"meeting": {
"id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "1700000000",
"calendar_event_id": "calendar-event-baseline"
}
}`,
},
"vc.meeting.participant_meeting_ended_v1": {
eventBody: `{
"meeting": {
"id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "1700000000",
"end_time": "1700000600",
"calendar_event_id": "calendar-event-baseline"
}
}`,
},
// The note handler enriches the output with document tokens via the API
// client; the fake client answers with both artifacts on the first call
// so no retry attempt is made.
"vc.note.generated_v1": {
eventBody: `{"note_id": "note-id-baseline"}`,
},
// Recording handlers only emit events whose source is recording_bean;
// anything else is dropped, which would break the success-path contract.
"vc.recording.recording_started_v1": {
eventBody: `{
"unique_key": "recording-key-baseline",
"source": "recording_bean"
}`,
},
"vc.recording.recording_transcript_generated_v1": {
eventBody: `{
"unique_key": "recording-key-baseline",
"source": "recording_bean",
"transcript_items": [
{
"speaker": {"user_name": "Baseline Speaker"},
"text": "baseline transcript text",
"start_time_ms": "1700000000000",
"end_time_ms": "1700000001000",
"sentence_id": "sentence-baseline-1"
}
]
}`,
},
"vc.recording.recording_ended_v1": {
eventBody: `{
"unique_key": "recording-key-baseline",
"source": "recording_bean"
}`,
},
}
// baselineAPIResponses maps request paths to canned success responses for the
// handlers that call the API during Process. Every response satisfies the
// handler on the first call, so retry loops never engage and no real network
// or credentials are involved.
var baselineAPIResponses = map[string]string{
"/open-apis/im/v1/messages/om-baseline-card?card_msg_content_type=user_card_content": `{
"code": 0,
"msg": "success",
"data": {
"items": [
{"body": {"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}"}}
]
}
}`,
"/open-apis/vc/v1/notes/note-id-baseline": `{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "note-doc-token-baseline"},
{"artifact_type": 2, "doc_token": "verbatim-doc-token-baseline"}
],
"note_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
}
}
}`,
"/open-apis/minutes/v1/minutes/minute-token-baseline": `{
"code": 0,
"msg": "success",
"data": {
"minute": {"title": "Baseline minute title"}
}
}`,
}
// baselineAPIClient serves the canned responses above. An unexpected request
// path fails the test immediately instead of returning an error, because
// several handlers swallow API errors (or retry with delays) and would
// silently produce a degraded output that gets frozen into the baseline.
type baselineAPIClient struct {
t *testing.T
}
func (c *baselineAPIClient) CallAPI(_ context.Context, method, path string, _ any) (json.RawMessage, error) {
c.t.Helper()
resp, ok := baselineAPIResponses[path]
if !ok {
c.t.Fatalf("unexpected API call during Process: %s %s — add a canned response to baselineAPIResponses", method, path)
}
return json.RawMessage(resp), nil
}
// TestProcessedOutputBaseline runs every Processed EventKey against a fixed
// well-formed synthetic payload and compares the outputs with the frozen
// snapshot in testdata/output_baseline.json. Any change to what a Processed
// key writes to stdout for a well-formed event shows up here as a named,
// per-key diff. Run with -update-baseline to accept an intentional change.
func TestProcessedOutputBaseline(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
rt := &baselineAPIClient{t: t}
got := map[string]json.RawMessage{}
seenFixtures := map[string]bool{}
for _, def := range compileRealCatalog(t).Definitions() {
if def.Process == nil {
continue
}
fx, ok := baselineFixtures[def.Key]
if !ok {
t.Fatalf("Processed EventKey %q has no baseline fixture; add one to baselineFixtures, bump wantProcessedKeys, and regenerate with -update-baseline", def.Key)
}
seenFixtures[def.Key] = true
payload := buildBaselineEnvelope(t, def.EventType, fx)
// The canonical fields mirror the synthetic envelope header exactly,
// including any extra header fields, just as the consume pipeline
// guarantees for real events before Process runs.
raw := &event.RawEvent{
EventID: baselineEventID,
EventType: def.EventType,
SourceTime: baselineCreateTime,
AppID: fx.extraHeader["app_id"],
TenantKey: fx.extraHeader["tenant_key"],
Payload: payload,
Timestamp: time.Unix(1700000000, 0).UTC(),
}
out, err := def.Process(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("%s: Process returned error on well-formed payload: %v", def.Key, err)
}
if out == nil {
t.Fatalf("%s: Process dropped a well-formed payload; the fixture must exercise the success path", def.Key)
}
if bytes.Equal(compactJSON(t, def.Key, out), compactJSON(t, def.Key, payload)) {
t.Fatalf("%s: Process returned the input unchanged; the fixture must exercise the success path, not the malformed-payload passthrough", def.Key)
}
got[def.Key] = out
}
if len(got) != wantProcessedKeys {
t.Fatalf("processed %d EventKeys, want exactly %d; a Processed key was added or removed — update baselineFixtures, wantProcessedKeys, and the snapshot together (keys run: %v)",
len(got), wantProcessedKeys, sortedKeys(got))
}
for key := range baselineFixtures {
if !seenFixtures[key] {
t.Fatalf("baseline fixture %q matches no registered Processed EventKey; remove it or fix the key name", key)
}
}
if *updateBaseline {
writeBaselineSnapshot(t, got)
return
}
compareBaselineSnapshot(t, got)
}
// buildBaselineEnvelope wraps a fixture body in the standard V2 event
// envelope with fixed header values.
func buildBaselineEnvelope(t *testing.T, eventType string, fx baselineFixture) json.RawMessage {
t.Helper()
header := map[string]string{
"event_id": baselineEventID,
"event_type": eventType,
"create_time": baselineCreateTime,
}
maps.Copy(header, fx.extraHeader)
headerJSON, err := json.Marshal(header)
if err != nil {
t.Fatalf("marshal envelope header: %v", err)
}
envelope := map[string]json.RawMessage{
"schema": json.RawMessage(`"2.0"`),
"header": headerJSON,
"event": json.RawMessage(fx.eventBody),
}
payload, err := json.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope for %s: %v", eventType, err)
}
return payload
}
func writeBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) {
t.Helper()
// MarshalIndent sorts map keys, so the snapshot is deterministic.
data, err := json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(baselineSnapshotPath), 0o755); err != nil {
t.Fatalf("create testdata dir: %v", err)
}
if err := os.WriteFile(baselineSnapshotPath, data, 0o644); err != nil {
t.Fatalf("write snapshot: %v", err)
}
t.Logf("baseline snapshot rewritten: %s (%d keys)", baselineSnapshotPath, len(got))
}
func compareBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) {
t.Helper()
data, err := os.ReadFile(baselineSnapshotPath)
if os.IsNotExist(err) {
t.Fatalf("baseline snapshot %s not found; generate it with: go test ./events/ -run TestProcessedOutput -update-baseline", baselineSnapshotPath)
}
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
var want map[string]json.RawMessage
if err := json.Unmarshal(data, &want); err != nil {
t.Fatalf("snapshot %s is not valid JSON: %v", baselineSnapshotPath, err)
}
for _, key := range sortedKeys(want) {
if _, ok := got[key]; !ok {
t.Errorf("%s: present in snapshot but produced no output this run; if the key was removed on purpose, regenerate with -update-baseline", key)
}
}
for _, key := range sortedKeys(got) {
wantOut, ok := want[key]
if !ok {
t.Errorf("%s: produced output but missing from snapshot; regenerate with -update-baseline", key)
continue
}
gotC := compactJSON(t, key, got[key])
wantC := compactJSON(t, key, wantOut)
if !bytes.Equal(gotC, wantC) {
t.Errorf("%s: Processed output drifted from baseline\n got: %s\n want: %s\nIf this change is intentional, regenerate with -update-baseline", key, gotC, wantC)
}
}
}
// compactJSON canonicalizes whitespace so comparisons are content-only.
func compactJSON(t *testing.T, key string, raw json.RawMessage) []byte {
t.Helper()
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
t.Fatalf("%s: output is not valid JSON: %v\nraw=%s", key, err, string(raw))
}
return buf.Bytes()
}
func sortedKeys(m map[string]json.RawMessage) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

30
events/register.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package events wires domain EventKey definitions into the global registry. Blank-import to populate.
package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
)
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}
for _, keys := range all {
for _, k := range keys {
event.RegisterKey(k)
}
}
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
event "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// closureAPIClient answers any API call with a benign error: a handler facing
// a malformed payload must decide to drop before it ever needs the API.
type closureAPIClient struct{}
func (closureAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) {
return nil, errors.New("no API access for malformed input")
}
// Every Processed EventKey declares an output schema; its stdout must stay
// inside that schema. A payload that cannot be decoded therefore has exactly
// one legal outcome: a malformed drop. Passing the raw envelope through would
// hand consumers a shape the schema never described.
//
// Native keys (Process == nil) are exempt by contract: their declared output
// is the raw envelope itself.
func TestAllKeys_MalformedPayloadStaysSchemaClosed(t *testing.T) {
const wantProcessedKeys = 13
processed := 0
for _, def := range compileRealCatalog(t).Definitions() {
if def.Process == nil {
continue
}
processed++
out, err := safeProcess(t, def, json.RawMessage(`this is definitely not valid json {{{`))
if out != nil {
t.Errorf("%s: malformed payload produced stdout output; it must be dropped", def.Key)
}
if !processing.IsDropMalformed(err) {
t.Errorf("%s: malformed payload must be dropped with a malformed marker, got err=%v", def.Key, err)
}
}
if processed == 0 {
t.Fatal("no processed keys were exercised; the gate scanned nothing")
}
if processed != wantProcessedKeys {
t.Fatalf("exercised %d processed keys, want exactly %d; update the count when keys are deliberately added or removed", processed, wantProcessedKeys)
}
}
// safeProcess isolates a panicking handler to a per-key finding instead of
// aborting the whole gate: a handler that dereferences before decoding is a
// bug in that key, not a reason to stop scanning the rest.
func safeProcess(t *testing.T, def *event.KeyDefinition, payload json.RawMessage) (out json.RawMessage, err error) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Errorf("%s: Process panicked on malformed payload: %v", def.Key, r)
out, err = nil, nil
}
}()
raw := &event.RawEvent{
EventID: "evt-closure-1",
EventType: def.EventType,
Payload: payload,
Timestamp: time.Unix(0, 0),
}
return def.Process(context.Background(), closureAPIClient{}, raw, map[string]string{})
}

View File

@@ -1,259 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"bytes"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"testing"
"github.com/larksuite/cli/internal/event/catalog"
)
// The output baseline freezes what every Processed key writes to stdout; the
// compiled catalog promises a schema for the same bytes. This test closes the
// loop between the two: every frozen output must be an instance of its key's
// resolved schema, so a schema and its real output can never drift apart with
// both sides individually green.
//
// The repository deliberately carries no JSON Schema validation dependency,
// so validation is done by a minimal in-repo checker that covers exactly the
// subset the catalog compiler emits (see validateValue). Any schema construct
// outside that subset is a loud failure, never a silent pass.
func TestProcessedBaselineOutputs_ConformToDeclaredSchemas(t *testing.T) {
snap := compileRealCatalog(t)
baseline := readBaselineSnapshot(t)
validated := 0
for _, entry := range snap.Entries() {
out := entry.Output()
if out.Mode != catalog.OutputProcessed {
continue
}
key := entry.Descriptor().Key
frozen, ok := baseline[key]
if !ok {
t.Errorf("%s: Processed key has no entry in %s; regenerate the baseline first", key, baselineSnapshotPath)
continue
}
schema := decodeSchemaNode(t, key, out.SchemaJSON)
instance := decodeInstance(t, key, frozen)
for _, problem := range validateValue("$", schema, instance) {
t.Errorf("%s: frozen output violates the declared schema: %s", key, problem)
}
validated++
}
// Idle detection, both directions: every Processed key was checked
// against a baseline entry, and no baseline entry escaped the check.
if validated == 0 {
t.Fatal("no Processed key was validated; the gate scanned nothing")
}
if validated != len(baseline) {
t.Fatalf("validated %d Processed keys but the baseline holds %d entries — a baseline entry matches no compiled Processed key (keys: %v)",
validated, len(baseline), sortedKeys(baseline))
}
}
// The validator itself must bite: an output tampered with in memory — an
// undeclared field, a primitive type flip — has to produce findings,
// otherwise a green conformance run proves nothing. The baseline file is
// never modified.
func TestSchemaInstanceValidator_BitesOnTamperedOutput(t *testing.T) {
const key = "im.message.receive_v1"
snap := compileRealCatalog(t)
entry, ok := snap.Resolve(key)
if !ok {
t.Fatalf("key %s is gone from the catalog; pick another Processed key for this self-check", key)
}
baseline := readBaselineSnapshot(t)
frozen, ok := baseline[key]
if !ok {
t.Fatalf("key %s has no baseline entry; the self-check needs a real frozen output", key)
}
schema := decodeSchemaNode(t, key, entry.Output().SchemaJSON)
// Control: the untampered output is conformant, so any finding below is
// caused by the tampering alone.
if problems := validateValue("$", schema, decodeInstance(t, key, frozen)); len(problems) != 0 {
t.Fatalf("control failed: the untampered output already has findings: %v", problems)
}
tampered, ok := decodeInstance(t, key, frozen).(map[string]any)
if !ok {
t.Fatalf("baseline output for %s is not a JSON object", key)
}
tampered["field_the_schema_never_declared"] = "smuggled"
if problems := validateValue("$", schema, tampered); len(problems) != 1 {
t.Errorf("an undeclared field must produce exactly one finding, got: %v", problems)
}
flipped, _ := decodeInstance(t, key, frozen).(map[string]any)
flipped["message_id"] = true // declared as a string
if problems := validateValue("$", schema, flipped); len(problems) != 1 {
t.Errorf("a primitive type flip must produce exactly one finding, got: %v", problems)
}
}
func readBaselineSnapshot(t *testing.T) map[string]json.RawMessage {
t.Helper()
data, err := os.ReadFile(baselineSnapshotPath)
if err != nil {
t.Fatalf("read %s: %v", baselineSnapshotPath, err)
}
var out map[string]json.RawMessage
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("%s is not valid JSON: %v", baselineSnapshotPath, err)
}
return out
}
func decodeSchemaNode(t *testing.T, key string, raw json.RawMessage) map[string]any {
t.Helper()
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("%s: resolved schema is not a JSON object: %v", key, err)
}
return schema
}
// decodeInstance parses a frozen output with UseNumber so integer/number
// checks see the literal digits instead of a lossy float64.
func decodeInstance(t *testing.T, key string, raw json.RawMessage) any {
t.Helper()
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
t.Fatalf("%s: baseline output is not valid JSON: %v", key, err)
}
return v
}
// validateValue checks one instance value against one schema node and returns
// the problems found. It implements only the subset the catalog compiler can
// emit (schemas.FromType plus raw declarations shaped the same way):
//
// - type object with properties: every instance field must be declared in
// properties and conform to its node; undeclared fields are errors.
// Absent declared fields are legal (handlers omit empty members).
// - type string / integer / number / boolean: the JSON value kind must
// match.
// - type array with items: every element must conform to items.
//
// description/format/enum annotations are metadata, not instance constraints
// here. Any construct outside the subset — a missing or unknown type, an
// object without properties, additionalProperties, an array without items —
// is reported as a problem so the validator can only be extended
// deliberately, never bypassed by a schema it does not understand.
func validateValue(path string, schema map[string]any, value any) []string {
typ, ok := schema["type"].(string)
if !ok {
return []string{fmt.Sprintf("%s: schema node has no \"type\"; outside the minimal validator subset, extend the validator deliberately", path)}
}
switch typ {
case "object":
obj, ok := value.(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: schema declares object, output has %s", path, jsonKind(value))}
}
if _, has := schema["additionalProperties"]; has {
return []string{fmt.Sprintf("%s: schema uses additionalProperties; outside the minimal validator subset, extend the validator deliberately", path)}
}
props, ok := schema["properties"].(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: object schema without properties; outside the minimal validator subset, extend the validator deliberately", path)}
}
var problems []string
for _, field := range sortedFieldNames(obj) {
fieldPath := path + "." + field
node, declared := props[field]
if !declared {
problems = append(problems, fmt.Sprintf("%s: field is not declared in the schema properties", fieldPath))
continue
}
nodeObj, ok := node.(map[string]any)
if !ok {
problems = append(problems, fmt.Sprintf("%s: schema property is not an object", fieldPath))
continue
}
problems = append(problems, validateValue(fieldPath, nodeObj, obj[field])...)
}
return problems
case "string":
if _, ok := value.(string); !ok {
return []string{fmt.Sprintf("%s: schema declares string, output has %s", path, jsonKind(value))}
}
case "boolean":
if _, ok := value.(bool); !ok {
return []string{fmt.Sprintf("%s: schema declares boolean, output has %s", path, jsonKind(value))}
}
case "integer":
num, ok := value.(json.Number)
if !ok {
return []string{fmt.Sprintf("%s: schema declares integer, output has %s", path, jsonKind(value))}
}
if _, err := strconv.ParseInt(num.String(), 10, 64); err != nil {
return []string{fmt.Sprintf("%s: schema declares integer, output has non-integer number %s", path, num)}
}
case "number":
if _, ok := value.(json.Number); !ok {
return []string{fmt.Sprintf("%s: schema declares number, output has %s", path, jsonKind(value))}
}
case "array":
arr, ok := value.([]any)
if !ok {
return []string{fmt.Sprintf("%s: schema declares array, output has %s", path, jsonKind(value))}
}
items, ok := schema["items"].(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: array schema without items; outside the minimal validator subset, extend the validator deliberately", path)}
}
var problems []string
for i, elem := range arr {
problems = append(problems, validateValue(fmt.Sprintf("%s[%d]", path, i), items, elem)...)
}
return problems
default:
return []string{fmt.Sprintf("%s: schema type %q; outside the minimal validator subset, extend the validator deliberately", path, typ)}
}
return nil
}
// jsonKind names a decoded JSON value's kind for problem messages.
func jsonKind(v any) string {
switch v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case string:
return "string"
case json.Number:
return "number"
case []any:
return "array"
case map[string]any:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}
func sortedFieldNames(obj map[string]any) []string {
names := make([]string, 0, len(obj))
for name := range obj {
names = append(names, name)
}
sort.Strings(names)
return names
}

View File

@@ -8,7 +8,7 @@ import (
"reflect"
"testing"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
@@ -83,14 +83,13 @@ func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key)
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

View File

@@ -1,177 +0,0 @@
{
"application.bot.menu_v6": {
"type": "application.bot.menu_v6",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"app_id": "cli-baseline-app",
"tenant_key": "tenant-baseline",
"event_key": "baseline_menu_key",
"menu_timestamp": "1700000000000",
"operator_id": "ou-baseline-operator",
"operator_open_id": "ou-baseline-operator",
"operator_union_id": "on-baseline-operator",
"operator_user_id": "user-baseline-operator",
"operator_name": "Baseline Operator"
},
"approval.instance.status_changed_v4": {
"type": "approval.instance.status_changed_v4",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"external_id": "external-id-baseline",
"status": "APPROVED",
"operate_time": "1700000000000",
"start_user": {
"open_id": "ou-baseline-starter",
"union_id": "on-baseline-starter",
"user_id": "user-baseline-starter"
}
},
"approval.task.status_changed_v4": {
"type": "approval.task.status_changed_v4",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"task_id": "task-id-baseline",
"external_id": "external-id-baseline",
"task_external_id": "task-external-id-baseline",
"assigned_user": {
"open_id": "ou-baseline-assignee",
"union_id": "on-baseline-assignee",
"user_id": "user-baseline-assignee"
},
"status": "APPROVED",
"operate_time": "1700000000000"
},
"card.action.trigger": {
"type": "card.action.trigger",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"operator_id": "ou-baseline-operator",
"message_id": "om-baseline-card",
"chat_id": "oc-baseline-chat",
"host": "im_message",
"token": "card-token-baseline",
"action_tag": "button",
"action_value": "{\"key\":\"baseline\"}",
"action_name": "baseline_button",
"form_value": "{\"field\":\"value\"}",
"input_value": "baseline input",
"option": "opt-1",
"options": "opt-1,opt-2",
"checked": true,
"timezone": "Asia/Shanghai",
"card_content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}"
},
"im.message.receive_v1": {
"type": "im.message.receive_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"id": "om-baseline-msg",
"message_id": "om-baseline-msg",
"create_time": "1699999999000",
"update_time": "1700000000500",
"chat_id": "oc-baseline-chat",
"chat_type": "p2p",
"message_type": "text",
"sender_id": "ou-baseline-sender",
"sender_type": "user",
"root_id": "om-baseline-root",
"thread_id": "omt-baseline-thread",
"reply_to": "om-baseline-parent",
"content": "hello @Baseline User",
"mentions": [
{
"key": "@_user_1",
"id": "ou-baseline-mention",
"name": "Baseline User"
}
]
},
"minutes.minute.generated_v1": {
"type": "minutes.minute.generated_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"minute_token": "minute-token-baseline",
"title": "Baseline minute title",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
},
"vc.meeting.participant_meeting_ended_v1": {
"type": "vc.meeting.participant_meeting_ended_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"meeting_id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "2023-11-14T22:13:20Z",
"end_time": "2023-11-14T22:23:20Z",
"calendar_event_id": "calendar-event-baseline"
},
"vc.meeting.participant_meeting_joined_v1": {
"type": "vc.meeting.participant_meeting_joined_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"meeting_id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "2023-11-14T22:13:20Z",
"calendar_event_id": "calendar-event-baseline"
},
"vc.meeting.participant_meeting_started_v1": {
"type": "vc.meeting.participant_meeting_started_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"meeting_id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "2023-11-14T22:13:20Z",
"calendar_event_id": "calendar-event-baseline"
},
"vc.note.generated_v1": {
"type": "vc.note.generated_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"note_id": "note-id-baseline",
"note_token": "note-doc-token-baseline",
"verbatim_token": "verbatim-doc-token-baseline",
"note_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
},
"vc.recording.recording_ended_v1": {
"type": "vc.recording.recording_ended_v1",
"event_id": "evt-baseline-001",
"event_time": "2023-11-14T22:13:20Z",
"unique_key": "recording-key-baseline",
"source": "recording_bean"
},
"vc.recording.recording_started_v1": {
"type": "vc.recording.recording_started_v1",
"event_id": "evt-baseline-001",
"event_time": "2023-11-14T22:13:20Z",
"unique_key": "recording-key-baseline",
"source": "recording_bean"
},
"vc.recording.recording_transcript_generated_v1": {
"type": "vc.recording.recording_transcript_generated_v1",
"event_id": "evt-baseline-001",
"event_time": "2023-11-14T22:13:20Z",
"unique_key": "recording-key-baseline",
"source": "recording_bean",
"transcript_items": [
{
"speaker_name": "Baseline Speaker",
"text": "baseline transcript text",
"start_time": "2023-11-14T22:13:20Z",
"end_time": "2023-11-14T22:13:21Z",
"sentence_id": "sentence-baseline-1"
}
]
}
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
)
// lookupCompiledDef compiles this domain's declarations and resolves one key,
// exactly as the runtime catalog would for a consumer.
func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) {
t.Helper()
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
entry, ok := snap.Resolve(key)
if !ok {
return nil, false
}
return entry.Definition(), true
}

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// recordingBeanSource is the only recording source the vc.recording.* keys
// emit; events carrying any other source are silently filtered out.
const recordingBeanSource = "recording_bean"
// recordingBeanEventBody is the shared {"event": ...} body for
// recording_started and recording_ended, whose payloads carry identical fields.
type recordingBeanEventBody struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
// decodeEventBody unmarshals the {"event": ...} envelope of raw and returns
// the decoded body; ok is false when the payload does not decode.
func decodeEventBody[T any](raw *event.RawEvent) (T, bool) {
var envelope struct {
Event T `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
var zero T
return zero, false
}
return envelope.Event, true
}
// millisToLocalRFC3339 converts a unix-millisecond timestamp string to
// RFC3339 in the local timezone; empty or non-numeric input yields "".
func millisToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}
// unixSecondsToLocalRFC3339 converts a unix-second timestamp string to
// RFC3339 in the local timezone; empty or non-numeric input yields "".
func unixSecondsToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
secs, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.Unix(secs, 0).Local().Format(time.RFC3339)
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/validate"
)
@@ -43,20 +42,28 @@ type VCNoteGeneratedOutput struct {
func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
NoteID string `json:"note_id"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, processing.DropMalformed(raw.EventType)
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &VCNoteGeneratedOutput{
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
NoteID: envelope.Event.NoteID,
}
if out.Type == "" {
out.Type = raw.EventType
}
if rt != nil && out.NoteID != "" {
fillVCNoteGeneratedDetails(ctx, rt, out)

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