Compare commits

...

27 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: prefer path syntax for whiteboard file inputs

* docs: keep lark-doc skill metadata unchanged

* fix: aggregate whiteboard path errors across markdown
2026-07-08 11:18:34 +08:00
bubbmon233
f495cbb166 feat(mail): add message modify and trash shortcuts (#1567) 2026-07-07 21:44:40 +08:00
Yuxuan Zhao
6f95c5eb22 e2e: harden CLI E2E retry, cleanup, and domain selection (#1709) 2026-07-07 19:41:11 +08:00
liangshuo-1
4e2cbea94e chore: release v1.0.66 (#1782) 2026-07-07 19:33:39 +08:00
fangshuyu-768
f98dbfe247 Improve agent-facing error guidance for drive, markdown, and wiki (#1779) 2026-07-07 18:10:23 +08:00
hugang-lark
40ea4d60ef feat: minute wait (#1768)
feat: remove reference lark-sharded skill

docs: clarify calendar write feedback

docs: split calendar scheduling workflow

docs: tighten calendar skill references

docs: annotate +freebusy scope to avoid unnecessary reads in scheduling flow

When users express "find free time + create event" intent, AI would
previously read freebusy.md before entering the scheduling workflow.
Adding a scope note to the shortcut table directs AI to use +suggestion
instead, reducing token consumption.

feat: support calendar +get

fix: optimize calendar skill

fix: optimize shortcuts and meta api

fix: optimize skill

Co-authored-by: calendar-assistant <tangfengyuan@bytedance.com>
2026-07-07 17:12:28 +08:00
liangshuo-1
f0b6f35fee fix: resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
* fix(schema): fall back to runtime catalog when no embedded metadata

Binaries built from the bare Go module (plugin builds) embed only the
empty meta_data_default.json stub because meta_data.json is gitignored
and fetched at build time. The schema command, its completion, and the
affordance command-form resolver read the embedded-only catalog, so
every schema lookup failed with "Unknown service" even though the
runtime registry had already sync-fetched full metadata.

Add registry.SchemaCatalog(): embedded when compiled in (official
builds unchanged, still deterministic), otherwise the merged runtime
catalog seeded from cache or remote fetch. When neither source has
data (offline plugin build with a cold cache), schema now returns a
failed_precondition error with an actionable hint instead of
"Unknown service" with an empty candidate list.

* fix(registry): gate cached meta overlay on version newer than embedded

The cached remote meta was overlaid onto the embedded meta_data.json
unconditionally, so after a CLI upgrade an equal- or older-version
cache kept shadowing the freshly shipped embedded definitions until a
later refresh happened to rewrite it.

Only overlay when the cache version is strictly newer than the
embedded baseline. The bare-module stub baseline is "0.0.0", so plugin
builds without compiled metadata still take any real cached version
(TestOverlayGate_StubEmbedded_OverlaysRealCache) and the schema
runtime fallback keeps working offline from a warm cache.

Ports #1376 onto the typed meta model.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:24:24 +08:00
liangshuo-1
91d785f92f refactor(envvars): consolidate agent env value access (#1757)
* feat(envvars): add LARKSUITE_CLI_AGENT_NAME accessor

envvars.AgentName() reads LARKSUITE_CLI_AGENT_NAME so business code can identify the agent driving the CLI. Value is sanitised (trim, reject control chars, 128-byte cap) and returns "" when unset or invalid. Local-only; not sent to the server.

* refactor(envvars): consolidate agent env accessors, dedupe sanitize

Move agent-trace reading from cmdutil.AgentTraceValue into envvars.AgentTrace(), symmetric with AgentName and sharing one sanitizeSingleLine. secheader.go now only assembles headers (BaseSecurityHeaders calls envvars.AgentTrace()); the duplicated sanitize and its os/unicode imports are gone. The X-Agent-Trace header and its behaviour are unchanged.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:17:51 +08:00
leave330
e621c6e50f fix: support pnpm global installs in self-update (#1705) 2026-07-06 21:11:01 +08:00
Zee Zheng
869a259d4e docs: clarify success envelope contract — judge success by ok, not code (#1730)
The stdout success envelope is {ok, identity, data, meta?} and carries no
top-level code/msg field, but wrappers following the raw OpenAPI convention
test code == 0 and misclassify every successful call as a failure. Around
write commands this defeats caller-side idempotency and causes duplicate
creates (see #1689).

Document the contract in ERROR_CONTRACT.md (success-envelope counterpart to
the error wire format), both READMEs (JSON Output Contract section), the
lark-shared skill (shared prerequisite for all skills), and add a success
response example to lark-task-create.md.

Closes #1689
2026-07-06 20:03:05 +08:00
fangshuyu-768
ee46e22abd docs: tighten doc creation validation workflow (#1759) 2026-07-06 17:07:10 +08:00
hhang
b76dc18c2f fix(calendar): guide approval room booking fallback (#1637) 2026-07-06 15:04:50 +08:00
calendar-assistant
85679d4258 feat: support semantic recurring calendar operations (#1723) 2026-07-06 14:10:57 +08:00
wangweiming-01
1ba4f3973c fix: guide drive import concurrency conflicts (#1751) 2026-07-06 10:46:46 +08:00
liangshuo-1
c45ff569c4 chore: release v1.0.65 (#1742) 2026-07-03 20:25:22 +08:00
caojie0621
a1506cdffb feat: add docs history shortcuts (#1612)
Add docs +history-list, +history-revert, and +history-revert-status backed by docs_ai history OpenAPI endpoints.

Document the safe history workflow and extend dry-run/live E2E coverage for the new shortcuts.
2026-07-03 16:21:18 +08:00
liuxin-0319
3595356ea1 chore: sync lark-doc skill from online-doc (#1701) 2026-07-03 15:55:46 +08:00
zhangjun-bytedance
73be1d06ec bugfix 0702 about speaker replace (#1731) 2026-07-03 14:05:48 +08:00
liujinkun2025
cccf025599 docs(drive): document 30-char query limit for +search (#1560)
The Search v2 API rejects queries longer than 30 characters (counted by
Unicode code point, CJK 1 each) with 99992402 field validation failed —
it is a hard error, not truncation. Surface this in the --query -h help
text and the lark-drive search skill so callers compress long queries
before searching instead of hitting the error.

Change-Id: Ieb30a66edae7a573690c49719627ec8fb2500a1a
2026-07-03 11:39:07 +08:00
zhanglei-1103
7db899db01 docs: add mindnote guidance to lark-doc (#1581) 2026-07-03 10:50:25 +08:00
liangshuo-1
c2d6038aae chore: release v1.0.64 (#1725) 2026-07-02 21:58:37 +08:00
Lekko
efa3439e01 fix(update): increase npm registry fetch timeout from 5s to 15s (#1724)
The update checker fetches https://registry.npmjs.org/@larksuite/cli/latest
with a 5-second HTTP client timeout. Under high-latency network conditions
(TUN-mode proxies, VPNs, transcontinental routes), TLS handshake alone can
take 4-6 seconds, causing the check to fail with:

  context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Measured example behind a Clash TUN proxy (US node from China):
  DNS resolve:    ~0ms (fake-ip)
  TCP connect:    ~0ms (local TUN)
  TLS handshake:  4.3-5.9s  <-- bottleneck
  Total:          4.7-6.3s

curl succeeds because it has no default connect timeout, but the Go HTTP
client with Timeout=5s is too tight. The registry endpoint returns a tiny
JSON payload (<1KB), so 15s is more than enough headroom while still
failing fast on genuinely unreachable networks.

Co-authored-by: 王伟达 <weida.wang@m.com>
2026-07-02 21:36:44 +08:00
169 changed files with 8832 additions and 1733 deletions

View File

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

3
.gitignore vendored
View File

@@ -27,6 +27,9 @@ Thumbs.db
# Go # Go
docs/ref docs/ref
docs/ docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/ vendor/

View File

@@ -2,6 +2,71 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.0.66] - 2026-07-07
### Features
- support semantic recurring calendar operations (#1723)
- minute wait (#1768)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
### Misc
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01 ## [v1.0.62] - 2026-07-01
### Features ### Features
@@ -1333,6 +1398,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62 [v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61 [v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60 [v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60

View File

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

View File

@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values --format csv # Comma-separated values
``` ```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination ### Pagination
```bash ```bash

View File

@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值 --format csv # 逗号分隔值
``` ```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页 ### 分页
```bash ```bash

View File

@@ -20,13 +20,28 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) { func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err) t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}}, Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
fn := cmd.ValidArgsFunction fn := cmd.ValidArgsFunction
tests := []struct { tests := []struct {
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
flag := cmd.Flags().Lookup("as") flag := cmd.Flags().Lookup("as")
if flag == nil { if flag == nil {
t.Fatal("expected --as flag to be registered") t.Fatal("expected --as flag to be registered")
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return apiRun(opts) return apiRun(opts)
}) })
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream", ContentType: "application/octet-stream",
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"}) cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute() err := cmd.Execute()
// Should return an error // Should return an error
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute() err := root.Execute()
if err == nil { if err == nil {
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"}) cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"}) cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })

View File

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

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd return cmd
} }
// completeSchemaPath is a thin adapter over the embedded catalog's Complete. // completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the embedded source so completion candidates match what `schema` // It uses the same source as schema execution so completion candidates match
// execution can resolve (both overlay-free). // what `schema` can resolve.
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context()) mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode)) completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp directive := cobra.ShellCompDirectiveNoFileComp
if noSpace { if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode) return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
} }
// runSchema resolves the path through the embedded catalog and renders the // runSchema resolves the path through the schema catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and // matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the // schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object, // output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints. // anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error { func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog() catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
target, err := catalog.Resolve(parts) target, err := catalog.Resolve(parts)
if err != nil { if err != nil {
return resolveError(err) return resolveError(err)

View File

@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version. Long: `Update lark-cli to the latest version.
Detects the installation method automatically: Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version> - npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL - manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts). Use --json for structured output (for AI agents and scripts).
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() { if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater) return doManualUpdate(opts, io, cur, latest, detect, updater)
} }
return doNpmUpdate(opts, io, cur, latest, updater) return doAutoUpdate(opts, io, cur, latest, detect, updater)
} }
// --- Output helpers --- // --- Output helpers ---
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n") fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest)) fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest) if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
emitSkillsTextHints(io, skillsResult) emitSkillsTextHints(io, skillsResult)
return nil return nil
} }
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error { func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
restore, err := updater.PrepareSelfReplace() restore, err := updater.PrepareSelfReplace()
if err != nil { if err != nil {
return reportError(opts, io, "update_error", return reportError(opts, io, "update_error",
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
} }
if !opts.JSON { if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest) fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
} }
npmResult := updater.RunNpmInstall(latest) npmResult := install(latest)
if npmResult.Err != nil { if npmResult.Err != nil {
restore() restore()
combined := npmResult.CombinedOutput() combined := npmResult.CombinedOutput()
if opts.JSON { if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{ output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{ "ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err), "type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput), "detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined), "hint": permissionHint(combined, pm),
}, },
}) })
return output.ErrBare(output.ExitAPI) return output.ErrBare(output.ExitAPI)
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
fmt.Fprint(io.ErrOut, npmResult.Stderr.String()) fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
} }
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err) fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined); hint != "" { if hint := permissionHint(combined, pm); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint) fmt.Fprintf(io.ErrOut, " %s\n", hint)
} }
return output.ErrBare(output.ExitAPI) return output.ErrBare(output.ExitAPI)
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
if err := updater.VerifyBinary(latest); err != nil { if err := updater.VerifyBinary(latest); err != nil {
restore() restore()
msg := fmt.Sprintf("new binary verification failed: %s", err) msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest) hint := verificationFailureHint(updater, latest, pm)
if opts.JSON { if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{ output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "ok": false,
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest) fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if skillsResult != nil { if skillsResult != nil {
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n") skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
} }
emitSkillsTextHints(io, skillsResult) emitSkillsTextHints(io, skillsResult)
return nil return nil
} }
func permissionHint(npmOutput string) string { func permissionHint(pmOutput, pm string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() { if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors" return ""
} }
return "" if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
} }
func verificationFailureHint(updater *selfupdate.Updater, latest string) string { func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
if updater.CanRestorePreviousVersion() { if updater.CanRestorePreviousVersion() {
return "the previous version has been restored" return "the previous version has been restored"
} }
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
} }

View File

@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
} }
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult { func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult { return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
} }
} }
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) { func TestNormalizeVersion(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") { if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out) t.Errorf("expected success message in stderr, got: %s", out)
} }
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
} }
func TestUpdateForce_JSON(t *testing.T) { func TestUpdateForce_JSON(t *testing.T) {
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS origOS := currentOS
defer func() { currentOS = origOS }() defer func() { currentOS = origOS }()
// Linux: EACCES should produce a hint with npm prefix guidance. // Linux + npm: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux" currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'") hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
if !strings.Contains(hint, "npm global prefix") { if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint) t.Errorf("expected npm prefix hint on linux, got: %s", hint)
} }
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint) t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
} }
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows). // Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows" currentOS = "windows"
hint = permissionHint("EACCES: permission denied") hint = permissionHint("EACCES: permission denied", "npm")
if hint != "" { if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint) t.Errorf("expected empty hint on Windows, got: %s", hint)
} }
// Non-EACCES error: always empty. // Non-EACCES error: always empty.
currentOS = "linux" currentOS = "linux"
if got := permissionHint("some other error"); got != "" { if got := permissionHint("some other error", "npm"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got) t.Errorf("expected empty hint for non-EACCES, got: %s", got)
} }
} }

View File

@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via `challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`. `CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories ## Categories
| Category | When | Exit | Typed struct | | Category | When | Exit | Typed struct |

View File

@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide. // space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string { func commandFormResolver(service string) func(string) string {
byForm := map[string]string{} byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() { if svc, ok := registry.SchemaCatalog().Service(service); ok {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) { for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
} }
break
} }
return func(h string) string { return func(h string) string {
h = strings.TrimSpace(h) h = strings.TrimSpace(h)

View File

@@ -6,12 +6,10 @@ package cmdutil
import ( import (
"context" "context"
"net/http" "net/http"
"os"
"reflect" "reflect"
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync" "sync"
"unicode"
"github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
@@ -40,8 +38,6 @@ const (
BuildKindUnknown = "unknown" BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli" officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
) )
// UserAgentValue returns the User-Agent value: "lark-cli/{version}". // UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -49,25 +45,6 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version return SourceValue + "/" + build.Version
} }
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry. // BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header { func BaseSecurityHeaders() http.Header {
h := make(http.Header) h := make(http.Header)
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version) h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue()) h.Set(HeaderUserAgent, UserAgentValue())
if v := AgentTraceValue(); v != "" { if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v) h.Set(HeaderAgentTrace, v)
} }
return h return h

View File

@@ -6,7 +6,6 @@ package cmdutil
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/credential"
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace // HeaderAgentTrace injection (via BaseSecurityHeaders)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) { func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "") t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders() h := BaseSecurityHeaders()

View File

@@ -19,6 +19,7 @@ const (
// Content safety scanning mode // Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE" CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

36
internal/envvars/read.go Normal file
View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,7 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog" import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free) // EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for `lark-cli schema`, golden tests // metadata — deterministic across machines, for golden tests and schema lint.
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog { func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped()) return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
} }
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog { func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped()) return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
} }
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
) )
//go:embed scope_priorities.json scope_overrides.json //go:embed scope_priorities.json scope_overrides.json
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand) brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged { if !brandChanged {
if cached, err := loadCachedMerged(); err == nil { // After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
overlayMergedServices(cached) overlayMergedServices(cached)
} }
} }

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
} }
// testRegistry returns a minimal MergedRegistry with one service. // testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry { func testRegistry(name string) MergedRegistry {
return MergedRegistry{ return MergedRegistry{
Version: "test-1.0", Version: "1.0.0",
Services: []meta.Service{ Services: []meta.Service{
{ {
Name: name, Name: name,
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
} }
func TestCacheHit_WithinTTL(t *testing.T) { func TestCacheHit_WithinTTL(t *testing.T) {
resetInit() swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir() tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
} }
func TestNetworkError_SilentDegradation(t *testing.T) { func TestNetworkError_SilentDegradation(t *testing.T) {
resetInit() swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir() tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil { if data == nil {
t.Fatal("expected non-nil data") t.Fatal("expected non-nil data")
} }
if reg.Version != "test-1.0" { if reg.Version != "1.0.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version) t.Errorf("expected version 1.0.0, got %s", reg.Version)
} }
} }

View File

@@ -32,6 +32,7 @@ type InstallMethod int
const ( const (
InstallNpm InstallMethod = iota InstallNpm InstallMethod = iota
InstallPnpm
InstallManual InstallManual
) )
@@ -53,22 +54,32 @@ var (
// DetectResult holds installation detection results. // DetectResult holds installation detection results.
type DetectResult struct { type DetectResult struct {
Method InstallMethod Method InstallMethod
ResolvedPath string ResolvedPath string
NpmAvailable bool NpmAvailable bool
PnpmAvailable bool
} }
// CanAutoUpdate returns true if the CLI can update itself automatically. // CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool { func (d DetectResult) CanAutoUpdate() bool {
return d.Method == InstallNpm && d.NpmAvailable switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
} }
// ManualReason returns a human-readable explanation of why auto-update is unavailable. // ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string { func (d DetectResult) ManualReason() string {
if d.Method == InstallNpm && !d.NpmAvailable { switch {
case d.Method == InstallNpm && !d.NpmAvailable:
return "installed via npm, but npm is not available in PATH" return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
} }
return "not installed via npm" return "not installed via npm or pnpm"
} }
// NpmResult holds the result of an npm install or skills update execution. // NpmResult holds the result of an npm install or skills update execution.
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct { type Updater struct {
DetectOverride func() DetectResult DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error VerifyOverride func(expectedVersion string) error
@@ -101,17 +113,38 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by // running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible. // CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
} }
// New creates an Updater with default (real) behavior. // New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} } func New() *Updater { return &Updater{} }
// DetectInstallMethod determines how the CLI was installed and whether // DetectInstallMethod determines how the CLI was installed and whether the
// npm is available for auto-update. // owning package manager is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult { func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil { if u.DetectOverride != nil {
return u.DetectOverride() return u.DetectOverride()
} }
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable() exe, err := vfs.Executable()
if err != nil { if err != nil {
return DetectResult{Method: InstallManual} return DetectResult{Method: InstallManual}
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
if err != nil { if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe} return DetectResult{Method: InstallManual, ResolvedPath: exe}
} }
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual method := InstallManual
if strings.Contains(resolved, "node_modules") { if strings.Contains(resolved, "node_modules") {
method = InstallNpm if containsPnpmMarker(resolved) {
} method = InstallPnpm
} else {
npmAvailable := false method = InstallNpm
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
} }
} }
d := DetectResult{Method: method, ResolvedPath: resolved}
return DetectResult{ switch method {
Method: method, case InstallNpm:
ResolvedPath: resolved, d.NpmAvailable = npmOnPath
NpmAvailable: npmAvailable, case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
} }
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
}
}
return false
} }
// RunNpmInstall executes npm install -g @larksuite/cli@<version>. // RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r return r
} }
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult { func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil { if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride() return u.SkillsIndexFetchOverride()
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...) return u.runSkillsCommand(args...)
} }
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult { func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil { if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...) return u.SkillsCommandOverride(args...)
} }
r := &NpmResult{} r := &NpmResult{}
npxPath, err := exec.LookPath("npx") det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
if err != nil { if err != nil {
r.Err = fmt.Errorf("npx not found in PATH: %w", err) r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
return r return r
} }
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout) ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, npxPath, args...) cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd.Stdout = &r.Stdout cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr cmd.Stderr = &r.Stderr
r.Err = cmd.Run() r.Err = cmd.Run()

View File

@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1]) t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
} }
} }
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}

View File

@@ -25,7 +25,7 @@ import (
const ( const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest" registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
fetchTimeout = 5 * time.Second fetchTimeout = 15 * time.Second
stateFile = "update-state.json" stateFile = "update-state.json"
maxBody = 256 << 10 // 256 KB maxBody = 256 << 10 // 256 KB

View File

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

View File

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

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

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

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

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

224
scripts/e2e_domains.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil return attendees, nil
} }
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
return true
}
}
return false
}
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
if err == nil || !attendeesIncludeRoom(attendees) {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
return err
}
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
}
var CalendarCreate = common.Shortcut{ var CalendarCreate = common.Shortcut{
Service: "calendar", Service: "calendar",
Command: "+create", Command: "+create",
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
"need_notification": true, "need_notification": true,
}) })
if err != nil { if err != nil {
err = guideApprovalRoomReasonError(err, attendees)
// Rollback: delete the event // Rollback: delete the event
_, rollbackErr := runtime.CallAPITyped("DELETE", _, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)), fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
"start": startStr, "start": startStr,
"end": endStr, "end": endStr,
} }
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
resultData["recurrence"] = recurrence
}
runtime.OutFormat(resultData, nil, func(w io.Writer) { runtime.OutFormat(resultData, nil, func(w io.Writer) {
var rows []map[string]interface{} var rows []map[string]interface{}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +get — get a single calendar event detail by calendar_id and event_id
package calendar
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// calendarEventTime mirrors start_time / end_time in the API response.
type calendarEventTime struct {
Date string `json:"date,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// calendarEventVChat mirrors the vchat block in the API response.
type calendarEventVChat struct {
VCType string `json:"vc_type,omitempty"`
IconType string `json:"icon_type,omitempty"`
Description string `json:"description,omitempty"`
MeetingURL string `json:"meeting_url,omitempty"`
}
// calendarEventLocation mirrors the location block in the API response.
type calendarEventLocation struct {
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
}
// calendarEventReminder mirrors a reminder entry.
type calendarEventReminder struct {
Minutes int `json:"minutes"`
}
// calendarEventOrganizer mirrors event_organizer.
type calendarEventOrganizer struct {
UserID string `json:"user_id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
// calendarEventAttachment mirrors a single attachment entry.
type calendarEventAttachment struct {
FileToken string `json:"file_token,omitempty"`
FileSize string `json:"file_size,omitempty"`
Name string `json:"name,omitempty"`
}
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
type calendarEventCheckInTime struct {
TimeType string `json:"time_type,omitempty"`
Duration int `json:"duration"`
}
// calendarEventCheckIn mirrors event_check_in.
type calendarEventCheckIn struct {
EnableCheckIn bool `json:"enable_check_in"`
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
NeedNotifyAttendees bool `json:"need_notify_attendees"`
}
// calendarEvent mirrors the event object inside the API response.
type calendarEvent struct {
EventID string `json:"event_id,omitempty"`
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
Visibility string `json:"visibility,omitempty"`
AttendeeAbility string `json:"attendee_ability,omitempty"`
FreeBusyStatus string `json:"free_busy_status,omitempty"`
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
Location *calendarEventLocation `json:"location,omitempty"`
Color int `json:"color,omitempty"`
Reminders []calendarEventReminder `json:"reminders,omitempty"`
Recurrence string `json:"recurrence,omitempty"`
Status string `json:"status,omitempty"`
IsException bool `json:"is_exception,omitempty"`
RecurringEventID string `json:"recurring_event_id,omitempty"`
CreateTime string `json:"create_time,omitempty"`
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
AppLink string `json:"app_link,omitempty"`
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
}
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
rawEvent, ok := data["event"]
if !ok || rawEvent == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
}
raw, err := json.Marshal(rawEvent)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
}
var event calendarEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
}
return &event, nil
}
// buildCalendarEventOutput converts the typed event into the output map and
// applies the four transformation rules:
// 1. create_time -> RFC3339
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
// 3. flatten event into the top-level result
// 4. when status != "cancelled", drop status (and adjust all-day end date)
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
raw, err := json.Marshal(event)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
}
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
}
}
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
delete(startMap, "timestamp")
}
}
}
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
delete(endMap, "timestamp")
}
}
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
if dt, _ := endMap["datetime"].(string); dt == "" {
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
}
}
}
}
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
return out, nil
}
// CalendarGet gets a single calendar event detail.
var CalendarGet = common.Shortcut{
Service: "calendar",
Command: "+get",
Description: "Get a single calendar event detail by calendar-id and event-id",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "event-id", Desc: "event ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
for _, flag := range []string{"calendar-id", "event-id"} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
if eventId == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
d := common.NewDryRunAPI()
switch calendarId {
case "":
d.Desc("(calendar-id omitted) Will use primary calendar")
calendarId = "<primary>"
case "primary":
calendarId = "<primary>"
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
return d.
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
Set("calendar_id", calendarId).
Set("event_id", eventId)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarId == "" {
calendarId = PrimaryCalendarIDStr
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
validate.EncodePathSegment(calendarId),
validate.EncodePathSegment(eventId)),
nil, nil)
if err != nil {
return err
}
event, err := parseCalendarEvent(data)
if err != nil {
return err
}
out, err := buildCalendarEventOutput(event)
if err != nil {
return err
}
runtime.OutFormat(out, nil, func(w io.Writer) {
summary, _ := out["summary"].(string)
if summary == "" {
summary = "(untitled)"
}
startMap, _ := out["start_time"].(map[string]interface{})
endMap, _ := out["end_time"].(map[string]interface{})
startStr, _ := startMap["datetime"].(string)
if startStr == "" {
startStr, _ = startMap["date"].(string)
}
endStr, _ := endMap["datetime"].(string)
if endStr == "" {
endStr, _ = endMap["date"].(string)
}
eventIdOut, _ := out["event_id"].(string)
freeBusyStatus, _ := out["free_busy_status"].(string)
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
row := map[string]interface{}{
"event_id": eventIdOut,
"summary": summary,
"start": startStr,
"end": endStr,
"free_busy_status": freeBusyStatus,
"self_rsvp_status": selfRsvpStatus,
}
output.PrintTable(w, []map[string]interface{}{row})
fmt.Fprintln(w)
})
return nil
},
}

View File

@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
} }
} }
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_approval_room",
"summary": "Approval Room",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/events/evt_approval_room/attendees",
Body: map[string]interface{}{
"code": codeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/events/evt_approval_room",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Approval Room",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "omm_room1",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for approval room missing approval_reason, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for %T", err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeInvalidParameters {
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
}
if p.Code != codeInvalidParamsWithDetail {
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
}
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
}
}
}
// When the add-attendees call fails AND the rollback DELETE also fails, the // When the add-attendees call fails AND the rollback DELETE also fails, the
// primary error stays the add failure (classification preserved) and the Hint // primary error stays the add failure (classification preserved) and the Hint
// must surface BOTH the rollback failure reason and the orphan event_id so the // must surface BOTH the rollback failure reason and the orphan event_id so the
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test // Shortcuts() registration test
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestShortcuts_Returns9(t *testing.T) { func TestShortcuts_Returns10(t *testing.T) {
shortcuts := Shortcuts() shortcuts := Shortcuts()
if len(shortcuts) != 9 { if len(shortcuts) != 10 {
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts)) t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
} }
names := map[string]bool{} names := map[string]bool{}
for _, s := range shortcuts { for _, s := range shortcuts {
names[s.Command] = true names[s.Command] = true
} }
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} { for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
if !names[want] { if !names[want] {
t.Errorf("missing shortcut %s", want) t.Errorf("missing shortcut %s", want)
} }
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
t.Errorf("param=%q, want --timezone", ve.Param) t.Errorf("param=%q, want --timezone", ve.Param)
} }
} }
// ---------------------------------------------------------------------------
// CalendarGet tests
// ---------------------------------------------------------------------------
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Daily Sync",
"create_time": "1602504000",
"start_time": map[string]interface{}{
"timestamp": "1742515200",
"timezone": "Asia/Shanghai",
},
"end_time": map[string]interface{}{
"timestamp": "1742518800",
"timezone": "Asia/Shanghai",
},
"status": "confirmed",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_001",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Expect flattened — fields appear directly under "data", not under "data.event"
if strings.Contains(out, "\"event\": {") {
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
}
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
t.Errorf("expected event_id in output, got: %s", out)
}
// status=confirmed should be dropped
if strings.Contains(out, "\"status\": \"confirmed\"") {
t.Errorf("status should be dropped when not cancelled, got: %s", out)
}
// timestamp must be replaced with datetime
if strings.Contains(out, "\"timestamp\":") {
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
}
if !strings.Contains(out, "\"datetime\":") {
t.Errorf("expected datetime in output, got: %s", out)
}
// create_time must be RFC3339 (contain 'T' and timezone)
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
t.Errorf("expected RFC3339 create_time, got: %s", out)
}
}
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_002",
"summary": "Cancelled Meeting",
"create_time": "1602504000",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
"status": "cancelled",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_002",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"status\": \"cancelled\"") {
t.Errorf("status should be preserved when cancelled, got: %s", out)
}
}
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_003",
"summary": "All-day",
"start_time": map[string]interface{}{"date": "2025-03-21"},
"end_time": map[string]interface{}{"date": "2025-03-22"},
"status": "confirmed",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_003",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
}
}
func TestGet_EmptyEventID_Typed(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--event-id", " ",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("want error for empty event-id")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Param != "--event-id" {
t.Errorf("param=%q, want --event-id", ve.Param)
}
}
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_404",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("want error when event field is missing")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("want *errs.InternalError, got %T", err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
}
}

View File

@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
CalendarSuggestion, CalendarSuggestion,
CalendarMeeting, CalendarMeeting,
CalendarSearchEvent, CalendarSearchEvent,
CalendarGet,
} }
} }

View File

@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true}, {Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true}, {Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"}, {Name: "doc-id", Desc: "document ID (for drive_route_token)"},
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type docsHistoryListSpec struct {
Doc documentRef
PageSize int
PageToken string
}
type docsHistoryRevertSpec struct {
Doc documentRef
HistoryVersionID string
WaitTimeoutMs int
}
type docsHistoryRevertStatusSpec struct {
Doc documentRef
TaskID string
}
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
ref, err := parseDocumentRef(raw)
if err != nil {
return documentRef{}, err
}
if ref.Kind == "doc" {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
}
return ref, nil
}
func validateDocsHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateDocsHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
}
if version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
}
return nil
}
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func docsHistoryAPIPath(docToken, suffix string) string {
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
}
var DocsHistoryList = common.Shortcut{
Service: "docs",
Command: "+history-list",
Description: "List Lark document history versions",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-list"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
return err
}
return validateDocsHistoryPageSize(runtime.Int("page-size"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: list document history versions").
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
Set("document_id", spec.Doc.Token).
Params(docsHistoryListParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "histories"),
docsHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevert = common.Shortcut{
Service: "docs",
Command: "+history-revert",
Description: "Revert a Lark document to a historical version",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
return err
}
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
return common.NewDryRunAPI().
Desc("OpenAPI: revert document history").
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
Set("document_id", spec.Doc.Token).
Body(docsHistoryRevertBody(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
nil,
docsHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevertStatus = common.Shortcut{
Service: "docs",
Command: "+history-revert-status",
Description: "Get Lark document history revert task status",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert-status"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: get document history revert status").
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
Set("document_id", spec.Doc.Token).
Params(docsHistoryStatusParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
docsHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestDocsHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
category errs.Category
subtype errs.Subtype
wantCause bool
}{
{
name: "list rejects legacy doc URL",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
param: "--doc",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "list rejects invalid page size",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects non-numeric history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
wantCause: true,
},
{
name: "revert rejects non-positive history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects invalid wait timeout",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "status rejects empty task id",
shortcut: DocsHistoryRevertStatus,
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != tt.category {
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
}
if problem.Subtype != tt.subtype {
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
}
})
}
}
func TestDocsHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
"doc": "doxcnHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
"doc": "doxcnHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
"doc": "doxcnHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryList, []string{
"+history-list",
"--doc", "doxcnHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestDocsHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
"+history-revert",
"--doc", "doxcnHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": []interface{}{"blk_1"},
},
},
})
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
"+history-revert-status",
"--doc", "doxcnHistory",
"--task-id", "task_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["status"]; got != "partial_failed" {
t.Fatalf("status = %#v, want partial_failed", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
failed, _ := data["failed_block_tokens"].([]interface{})
if len(failed) != 1 || failed[0] != "blk_1" {
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
}
}
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}
func TestDocsHistoryURLValidationMessage(t *testing.T) {
t.Parallel()
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

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

View File

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

View File

@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-fetch.md" return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update": case "update":
return docsSkillReadCommand + " references/lark-doc-update.md" return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
default: default:
return docsSkillReadCommand return docsSkillReadCommand
} }
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +fetch --help" return "lark-cli docs +fetch --help"
case "update": case "update":
return "lark-cli docs +update --help" return "lark-cli docs +update --help"
case "history-list":
return "lark-cli docs +history-list --help"
case "history-revert":
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
default: default:
return "lark-cli docs --help" return "lark-cli docs --help"
} }
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
DocsCreate, DocsCreate,
DocsFetch, DocsFetch,
DocsUpdate, DocsUpdate,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
DocMediaInsert, DocMediaInsert,
DocMediaUpload, DocMediaUpload,
DocMediaPreview, DocMediaPreview,

View File

@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -28,6 +29,8 @@ const (
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024 driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024 driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024 driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
) )
// driveImportExtToDocTypes defines which source file extensions can be imported // driveImportExtToDocTypes defines which source file extensions can be imported
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
"pptx": {"slides"}, "pptx": {"slides"},
} }
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
// driveImportSpec contains the user-facing import inputs after normalization. // driveImportSpec contains the user-facing import inputs after normalization.
type driveImportSpec struct { type driveImportSpec struct {
FilePath string FilePath string
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return status, true, nil return status, true, nil
} }
if status.Failed() { if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg) return status, false, driveImportFailureError(status)
if msg == "" {
msg = status.StatusLabel()
}
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
} }
} }
if !hadSuccessfulPoll && lastErr != nil { if !hadSuccessfulPoll && lastErr != nil {
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return lastStatus, false, nil return lastStatus, false, nil
} }
func driveImportFailureError(status driveImportStatus) *errs.APIError {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
if code, ok := driveImportConcurrentOperationCode(msg); ok {
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
}
return apiErr
}
func driveImportConcurrentOperationCode(msg string) (int, bool) {
for _, code := range driveImportConcurrentOperationCodes {
codeText := strconv.Itoa(code)
for idx := strings.Index(msg, codeText); idx >= 0; {
end := idx + len(codeText)
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
return code, true
}
nextStart := idx + 1
next := strings.Index(msg[nextStart:], codeText)
if next < 0 {
break
}
idx = nextStart + next
}
}
return 0, false
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}

View File

@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
} }
} }
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
t.Parallel()
for _, code := range driveImportConcurrentOperationCodes {
t.Run(strconv.Itoa(code), func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype != errs.SubtypeServerError {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
}
if problem.Code != code {
t.Fatalf("code = %d, want %d", problem.Code, code)
}
if !problem.Retryable {
t.Fatal("expected retryable error")
}
if problem.Hint != driveImportConcurrentOperationHint {
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
}
})
}
}
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg string
}{
{
name: "ordinary failure",
msg: "unsupported conversion",
},
{
name: "longer numeric code containing known code",
msg: "call CreateObjNode return error code, code: 12321401012, message:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: tt.msg,
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Code != 0 {
t.Fatalf("code = %d, want 0", problem.Code)
}
if problem.Retryable {
t.Fatal("expected non-concurrency failure to remain non-retryable")
}
if problem.Hint != "" {
t.Fatalf("hint = %q, want empty", problem.Hint)
}
})
}
}
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) { func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{

View File

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

View File

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

View File

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

View File

@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"}, {Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"}, {Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"}, {Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,12 @@ import (
const minutesDetailLogPrefix = "[minutes +detail]" const minutesDetailLogPrefix = "[minutes +detail]"
// Error codes from the minutes API. // Error codes from the minutes API.
const minutesDetailNoReadPermissionCode = 2091005 const (
minutesDetailProcessingCode = 2091003
minutesDetailNoReadPermissionCode = 2091005
minutesDetailWaitTimeoutDefault = 300
minutesDetailWaitIntervalDefault = 15
)
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`) var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
// minuteDetailItem represents a single minute detail result. // minuteDetailItem represents a single minute detail result.
type minuteDetailItem struct { type minuteDetailItem struct {
MinuteToken string `json:"minute_token"` MinuteToken string `json:"minute_token"`
Status string `json:"status,omitempty"`
Title string `json:"title"` Title string `json:"title"`
NoteID string `json:"note_id"` NoteID string `json:"note_id"`
Artifacts map[string]any `json:"artifacts,omitempty"` Artifacts map[string]any `json:"artifacts,omitempty"`
Retryable bool `json:"retryable,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
NextCommand string `json:"next_command,omitempty"`
} }
// fetchMinuteDetail queries a single minute's metadata and selected artifacts. // fetchMinuteDetail queries a single minute's metadata and selected artifacts.
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem { func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
data, err := runtime.CallAPITyped(http.MethodGet, artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil) waitReady := runtime.Bool("wait-ready")
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
return runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
})
if err != nil { if err != nil {
result := &minuteDetailItem{MinuteToken: minuteToken} result := &minuteDetailItem{MinuteToken: minuteToken}
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode { if isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken) result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
} else { } else {
result.Error = fmt.Sprintf("failed to query minute: %v", err) result.Error = fmt.Sprintf("failed to query minute: %v", err)
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
needKeyword := runtime.Bool("keyword") needKeyword := runtime.Bool("keyword")
if needSummary || needTodo || needChapter || needTranscript || needKeyword { if needSummary || needTodo || needChapter || needTranscript || needKeyword {
artData, err := runtime.CallAPITyped(http.MethodGet, artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil) return runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
})
if err != nil { if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err) if isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
} else {
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
}
} else { } else {
artifacts := make(map[string]any) artifacts := make(map[string]any)
if needSummary { if needSummary {
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
return result return result
} }
func isMinutesDetailProcessingError(err error) bool {
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
return true
}
return false
}
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
}
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
if timeoutSeconds <= 0 {
timeoutSeconds = minutesDetailWaitTimeoutDefault
}
if intervalSeconds <= 0 {
intervalSeconds = minutesDetailWaitIntervalDefault
}
return timeoutSeconds, intervalSeconds
}
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
deadline := time.Now().Add(timeout)
for {
data, err := call()
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
return data, err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
remaining := time.Until(deadline)
if remaining <= 0 || interval > remaining {
return nil, err
}
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
var flags []string
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
if runtime.Bool(flag) {
flags = append(flags, "--"+flag)
}
}
return flags
}
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
result.Status = "processing"
result.Retryable = true
result.Error = reason
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
}
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
parts = append(parts, artifactFlags...)
parts = append(parts, "--wait-ready")
return strings.Join(parts, " ")
}
// saveDetailTranscript persists transcript bytes to the canonical artifact path. // saveDetailTranscript persists transcript bytes to the canonical artifact path.
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/ // With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default // to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
{Name: "keyword", Type: "bool", Desc: "include keywords"}, {Name: "keyword", Type: "bool", Desc: "include keywords"},
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"}, {Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
tokens := common.SplitCSV(runtime.Str("minute-tokens")) tokens := common.SplitCSV(runtime.Str("minute-tokens"))
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
for _, r := range results { for _, r := range results {
row := map[string]interface{}{"minute_token": r.MinuteToken} row := map[string]interface{}{"minute_token": r.MinuteToken}
if r.Error != "" { if r.Error != "" {
row["status"] = "FAIL" if r.Status == "processing" {
row["status"] = "PROCESSING"
} else {
row["status"] = "FAIL"
}
row["error"] = r.Error row["error"] = r.Error
if r.NextCommand != "" {
row["next_command"] = r.NextCommand
}
} else { } else {
row["status"] = "OK" row["status"] = "OK"
row["title"] = r.Title row["title"] = r.Title

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
} }
} }
func detailProcessingStub(path string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: path,
Body: map[string]interface{}{
"code": 2091003,
"msg": "minute is processing",
},
}
}
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) { func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil) err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
} }
} }
func TestDetail_HiddenWaitFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
parent := &cobra.Command{Use: "minutes"}
MinutesDetail.Mount(parent, f)
parent.SetOut(stdout)
parent.SetArgs([]string{"+detail", "--help"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("help failed: %v", err)
}
help := stdout.String()
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
if strings.Contains(help, hidden) {
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
}
}
stdout.Reset()
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("hidden wait flags should parse: %v", err)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Execute tests with mocked HTTP // Execute tests with mocked HTTP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
} }
} }
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" {
t.Fatalf("status = %v, want processing", m["status"])
}
if m["retryable"] != true {
t.Fatalf("retryable = %v, want true", m["retryable"])
}
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
t.Fatalf("next_command = %v", m["next_command"])
}
}
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" {
t.Fatalf("status = %v, want processing", m["status"])
}
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
}
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
t.Fatalf("next_command = %v", m["next_command"])
}
}
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := firstDetailMinute(t, stdout.Bytes())
if m["title"] != "Ready Metadata" {
t.Fatalf("title = %v, want Ready Metadata", m["title"])
}
if _, ok := m["artifacts"]; ok {
t.Fatal("artifacts should not be fetched without artifact flags")
}
}
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
reg.Register(detailArtifactsStub("tokwaitart", ""))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := firstDetailMinute(t, stdout.Bytes())
arts, _ := m["artifacts"].(map[string]any)
if arts == nil {
t.Fatal("expected artifacts")
}
if arts["summary"] != "Test summary content" {
t.Fatalf("summary = %v", arts["summary"])
}
}
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
}
}
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var callCount int
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/tokmissing",
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
Reusable: true,
OnMatch: func(req *http.Request) { callCount++ },
})
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
if callCount != 1 {
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pure function tests // Pure function tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
} }
} }
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
}
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
}
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
if timeout != 9 || interval != 3 {
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
}
}
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
t.Helper()
var resp map[string]any
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
}
m, _ := minutes[0].(map[string]any)
return m
}
// chdirForDetailTest switches cwd to a temp dir for the test. // chdirForDetailTest switches cwd to a temp dir for the test.
func chdirForDetailTest(t *testing.T) string { func chdirForDetailTest(t *testing.T) string {
t.Helper() t.Helper()

View File

@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token")) minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
dr := common.NewDryRunAPI() return common.NewDryRunAPI().
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" { PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
}
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(buildSpeakerReplaceRequestBody(runtime)) Body(buildSpeakerReplaceRequestBody(runtime))
}, },
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token")) minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id")) fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id")) toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken) _, err := runtime.CallAPITyped(http.MethodPut,
if err != nil {
return err
}
_, err = runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID)) map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
if err != nil { if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID)) return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
} }
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil) runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil return nil
}, },
} }
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
return body return body
} }
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
out := map[string]interface{}{ out := map[string]interface{}{
"minute_token": minuteToken, "minute_token": minuteToken,
"to_user_id": toUserID, "to_user_id": toUserID,
} }
if fromSpeakerID != "" { if fromSpeakerID != "" {
out["from_speaker_id"] = fromSpeakerID out["from_speaker_id"] = fromSpeakerID
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
out["from_speaker_input"] = fromSpeakerInput
}
} else { } else {
out["from_user_id"] = fromUserID out["from_user_id"] = fromUserID
} }
return out return out
} }
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string { func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
if fromSpeakerInput != "" {
return fromSpeakerInput
}
if fromSpeakerID != "" { if fromSpeakerID != "" {
return fromSpeakerID return fromSpeakerID
} }

View File

@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
} }
} }
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) { func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected speakerlist path, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t) warmTokenCache(t)
reg.Register(&httpmock.Stub{ // Only the PUT is registered on purpose: an opaque speaker_id must be passed
Method: http.MethodGet, // straight through without a second speakerlist call. If the code still
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist", // prefetched speakerlist, the unregistered GET would fail the request.
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"speakers": []interface{}{
map[string]interface{}{
"speaker_id": "ENCRYPTED_TOKEN_ABC",
"name": "说话人1",
},
},
},
},
})
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: http.MethodPut, Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
err := mountAndRun(t, MinutesSpeakerReplace, []string{ err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace", "+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken, "--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1", "--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
"--to-user-id", "ou_new_speaker", "--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user", "--format", "json", "--as", "user",
}, f, stdout) }, f, stdout)
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
var envelope struct { var envelope struct {
Data struct { Data struct {
MinuteToken string `json:"minute_token"` FromSpeakerID string `json:"from_speaker_id"`
FromSpeakerInput string `json:"from_speaker_input"` ToUserID string `json:"to_user_id"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err) t.Fatalf("unmarshal stdout: %v", err)
} }
if envelope.Data.FromSpeakerInput != "说话人1" {
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
}
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" { if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID) t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
} }
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
} }
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
} }
out := stdout.String() out := stdout.String()
if !strings.Contains(out, "GET") { if strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out) t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
} }
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") { if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
t.Errorf("expected from_speaker_id in body, got:\n%s", out) t.Errorf("expected from_speaker_id in body, got:\n%s", out)

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type minuteSpeaker struct {
SpeakerID string
Name string
}
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
}
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
items := common.GetSlice(data, "speakers")
speakers := make([]minuteSpeaker, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
name := strings.TrimSpace(common.GetString(item, "name"))
if id == "" {
continue
}
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
}
return speakers, nil
}
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
name = strings.TrimSpace(name)
var matches []minuteSpeaker
for _, s := range speakers {
if s.Name == name {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return "", errs.NewValidationError(errs.SubtypeNotFound,
"no speaker named %q in minute transcript", name).
WithParam("--from-speaker-id").
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
case 1:
return matches[0].SpeakerID, nil
default:
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.SpeakerID
}
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
WithParam("--from-speaker-id").
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
}
}
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
// The input may already be an opaque speaker_id, or a display name that requires
// an internal speaker-list fetch.
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
input = strings.TrimSpace(input)
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
if err != nil {
return "", err
}
for _, s := range speakers {
if s.SpeakerID == input {
return input, nil
}
}
return resolveSpeakerIDByName(speakers, input)
}
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID != "" {
return "", fromUserID, nil
}
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
return fromSpeakerID, "", err
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestResolveSpeakerIDByName(t *testing.T) {
speakers := []minuteSpeaker{
{SpeakerID: "id_a", Name: "Alice"},
{SpeakerID: "id_b", Name: "Bob"},
{SpeakerID: "id_c", Name: "Alice"},
}
id, err := resolveSpeakerIDByName(speakers, "Bob")
if err != nil || id != "id_b" {
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
}
_, err = resolveSpeakerIDByName(speakers, "Carol")
if err == nil {
t.Fatal("expected not found error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
t.Fatalf("want not-found validation error, got %T: %v", err, err)
}
_, err = resolveSpeakerIDByName(speakers, "Alice")
if err == nil {
t.Fatal("expected duplicate name error")
}
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
}
}

View File

@@ -5,6 +5,8 @@ package minutes
import ( import (
"context" "context"
"net/url"
"strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
outData := map[string]interface{}{ outData := map[string]interface{}{
"minute_url": minuteURL, "minute_url": minuteURL,
} }
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
outData["minute_token"] = minuteToken
}
runtime.OutFormat(outData, nil, nil) runtime.OutFormat(outData, nil, nil)
return nil return nil
}, },
} }
func extractUploadedMinuteToken(minuteURL string) string {
u, err := url.Parse(minuteURL)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
for i, part := range parts {
if part == "minutes" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}

View File

@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" { if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"]) t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
} }
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
}
}
func TestExtractUploadedMinuteToken(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
{name: "invalid", url: "://bad", want: ""},
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
}
})
}
} }

View File

@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
} }
} }
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
cmd, _, err := program.Find([]string{"docs", name})
if err != nil {
t.Fatalf("find docs %s shortcut: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
}
if cmd.Flags().Lookup("api-version") != nil {
t.Fatalf("docs %s should not expose --api-version", name)
}
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
}
}
}
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) { func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"} program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t)) RegisterShortcuts(program, newRegisterTestFactory(t))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable` - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx` - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet` - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder` - 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base` - `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
@@ -194,6 +195,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` | | `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable | | `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable |
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg` | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
### 授权当前应用访问文档 ### 授权当前应用访问文档

View File

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

View File

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

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