Compare commits

...

38 Commits

Author SHA1 Message Date
sunyihong.cpdsss
b709824aae fix: add expression to avoid misunderstanding
Change-Id: Ib3a6c8a327b95c3f837d4bb565365235d0f0dfb8
2026-05-15 16:40:41 +08:00
河伯
f03138b9f0 feat(wiki): add +space-list / +node-list / +node-copy shortcuts (#392)
Introduce three new wiki shortcuts that wrap the corresponding raw APIs
with structured flags, formatted output, my_library alias handling, and
unified envelope shape, replacing the bare `lark-cli wiki spaces list`
/ `wiki nodes list` / `wiki nodes copy` flows for the common cases.

Shortcuts
- wiki +space-list (read, scopes: wiki:space:retrieve):
  lists wiki spaces. Default fetches a single page; --page-all walks
  every page capped by --page-limit (default 10, 0 = unlimited).
  Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson.
  Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode
  distinguishes "no spaces" from "empty page with has_more" and hints
  the caller to resume.

- wiki +node-list (read, scopes: wiki:node:retrieve):
  lists nodes in a space or under a parent. Same pagination + format
  story as +space-list. Accepts the my_library alias for --space-id
  with --as user (resolved via a shared resolveMyLibrarySpaceID helper
  extracted from +node-create); rejects my_library upfront for --as bot.

- wiki +node-copy (high-risk-write, scopes: wiki:node:copy):
  copies a node into a target space or parent. --target-space-id and
  --target-parent-node-token are mutually exclusive. Risk is marked
  high-risk-write to match the upstream API's danger: true flag, so the
  framework requires --yes. Source is preserved; subtree is copied.

Both list shortcuts pick the narrowest scope the upstream API accepts.
The framework's preflight (internal/auth/scope.go MissingScopes) does
exact-string scope matching, so declaring the broader wiki:wiki:readonly
form would wrongly reject tokens that carry only the per-API scope —
which the API itself accepts — and emit a misleading missing-scope hint.

Shared changes
- shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID
  so +node-list and +node-create share one my_library resolution path.
- shortcuts/wiki/shortcuts.go: register the three new shortcuts.
- skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list,
  node-copy}.md: documentation for the new shortcuts.

Tooling
- scripts/check-doc-tokens.sh + Makefile gitleaks target:
  pre-commit check that scans skill reference docs for realistic-looking
  Lark token values without the _EXAMPLE_TOKEN placeholder convention,
  preventing gitleaks false positives.
- .gitleaks.toml: allowlist tuning.
- .gitignore: ignore .tmp/.

Tests
- shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry
  membership, declared-narrow-scope pinning, flag validation (page-size
  range, page-limit >= 0, target flag exclusivity, my_library + bot
  rejection), auto-pagination merging, --page-limit truncation
  surfacing next cursor, --page-token single-page mode, empty-slice
  serialisation, has_more hint pretty rendering, my_library user-path
  resolution, +node-copy copy-to-space / copy-to-parent + body shape,
  pretty rendering, and the high-risk-write --yes gate.
- tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end
  workflow exercising the shortcut layer against a real tenant.
  Reuses an existing my_library node as a host so the test never adds
  to the top-layer quota; the copy is placed under the same host node.
- tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added.

Minor cleanups
- skills/lark-doc/references/lark-doc-search.md and
  skills/lark-minutes/references/lark-minutes-search.md: replace
  realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so
  scripts/check-doc-tokens.sh passes.

Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253

Co-authored-by: liujinkun <liujinkun@bytedance.com>
2026-05-15 14:38:18 +08:00
Cato
ed9eecf94f fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836) (#886)
* fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836)

VerifyBinary was using vfs.Executable() to find the binary to run --version against.
On Linux with global npm install, this returns the inode of the running binary (old version),
not the newly installed one that sits behind npm's bin symlink.

Switch to exec.LookPath("lark-cli") which resolves the PATH entry and follows npm's
bin symlink to the correct newly installed version, matching what the user actually runs.

* test(selfupdate): add LookPath-based tests for VerifyBinary

Add TestVerifyBinaryLookPath, TestVerifyBinaryLookPathNotFound, and
TestVerifyBinaryEmptyOutput. Expose execLookPath variable so tests can
inject a mock LookPath and cover the full VerifyBinary execution path
including version parsing and error branches.

* test(selfupdate): add os/exec import and isolate config dir in VerifyBinary tests

CodeRabbit feedback:
- Add missing os/exec import for execLookPath variable
- Add t.Setenv(LARKSUITE_CLI_CONFIG_DIR, ...) to each new test for config isolation

* test(selfupdate): extract execLookPath to separate lookpath.go

Move the execLookPath variable declaration to its own file so it is
accessible to updater.go without the test-only import cycle.

* fix(selfupdate): remove unused os/exec import from test file

* fix(selfupdate): gofmt + fold lookpath hook and restore version fences

- Move execLookPath into updater.go (drops redundant lookpath.go)
- Document package-level mock: no t.Parallel()
- Extend TestVerifyBinaryLookPath with exact-match regressions (0.0, 12.1.0 vs 2.1.0)

Co-authored-by: CatfishGG <catfishgg@users.noreply.github.com>
2026-05-14 23:30:30 +08:00
liangshuo-1
f49a2f7e14 fix(registry): wait for background meta refresh before test reset (#894)
* fix(registry): wait for background meta refresh before test reset

TestComputeMinimumScopeSet can start doBackgroundRefresh via Init() while
the next test's resetInit() mutates package-level globals the goroutine
still reads (e.g. remoteMetaURL / configuredBrand), causing data races under
-race in the coverage job.

Track the refresh goroutine with a WaitGroup and drain it at the start of
resetInit() in tests.
2026-05-14 22:33:21 +08:00
caojie0621
a93fb2d6b3 docs: add drive permission public patch error guidance (#863) 2026-05-14 21:57:55 +08:00
SunPeiYang996
7acf64c3ef docs: add v2 api version to docs fetch examples (#891)
Change-Id: I130e6e02c0b7594a05bdda6c9bf552fb15572791
2026-05-14 20:50:55 +08:00
fangshuyu-768
52e0129078 feat(drive): add quick mode to status diff (#870) 2026-05-14 20:37:39 +08:00
liangshuo-1
8a8dff47ce chore(release): v1.0.31 (#889)
Change-Id: I1609f900c4b5dc219e1e58aecb642928d418c5b3
2026-05-14 20:19:31 +08:00
SunPeiYang996
1c2d3d7679 docs: update lark-doc skill description (#890)
Change-Id: I77e2ae690b8976e37f69ae5d581fccc13917ec5e
2026-05-14 20:17:48 +08:00
wangweiming-01
0d20f88453 feat: support file-token overwrite and version output for drive +upload (#885)
Change-Id: I76c334578fc2fa5cfd2eedb4525b0d9d735f610e
2026-05-14 19:50:51 +08:00
MaxHuang22
b0bd9b0258 feat(install): skip interactive prompts in non-TTY environments (#888)
* feat(install): skip interactive prompts in non-TTY environments

Change-Id: Ieb6ffef54d3118088f16728933c55d1b21a8abfb

* docs: simplify install instructions to use npx install wizard

Change-Id: Ic970d2c879fd649c2dbd6ddf9a259bc64eb1a384
2026-05-14 19:40:14 +08:00
MaxHuang22
ba6edb84e4 feat: recommend lark-cli update over npm install for AI agents (#884)
* docs: rewrite lark-shared update section to recommend lark-cli update

Change-Id: Ie043b1a32675dcd041f9123503fcccb791cccd07

* feat: add command field to _notice JSON for AI agents

Change-Id: I04b069880f7dca8db384ba8a6919e5682c0382be

* feat: demote npm install to fallback with skills-not-synced warning

Change-Id: If21c3ef6cd1818b28f5578078a04c3627128c6d0

* fix: address CodeRabbit review — guard type assertions, remove npm fallback from SKILL.md

- Add t.Fatalf guards before type-asserting notice sub-maps in
  TestSetupNotices_BothUpdateAndSkills to prevent nil-panic on
  unexpected shapes.
- Remove the npm fallback section from SKILL.md entirely so AI agents
  only see `lark-cli update` as the update path.
- Strip remaining npm mentions from the "重要" note.

Change-Id: Ieb124763b918093e1dcae06f5ea7428dbc248d5f

* fix: add npx skills add hint alongside npm fallback in update paths

When npm is shown as a fallback (manual update path and rollback hint),
append the npx skills add command so users know how to sync skills
separately.

Change-Id: I454172be51073d35def635613a23ad35ba68b5fb
2026-05-14 19:09:10 +08:00
shifengjuan-dev
a54a879330 feat(im): add --exclude-muted to +chat-search and new +chat-list (#820)
Add im +chat-list shortcut wrapping GET /open-apis/im/v1/chats (previously not exposed via lark-cli).
Add --exclude-muted to both +chat-search and +chat-list: client-side filter that calls POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status after each page and drops is_muted=true chats.
Introduce shortcuts/im/mute_filter.go with pure helpers and an orchestrator (MaybeApplyMuteFilter) shared by both shortcuts.

Change-Id: I22221ac5835667f58cbd40b34de75825d2445d1c
2026-05-14 17:47:34 +08:00
Paulazaaza-dev
a27c636131 add addsign and rollback method (#867)
Change-Id: I0a50796cf33fd59e4222f26003efd43aa7c5896a
2026-05-14 15:13:30 +08:00
JackZhao10086
37459b60ec feat(auth): support --exclude flag and combine --scope with --domain/… (#844)
* fix(auth/login): 增加exclude参数使用校验逻辑

当使用--exclude参数时,必须同时指定--scope、--domain或--recommend中的至少一个,避免非法参数调用

* feat(auth/login): add --exclude flag and support combining scope options

1. 新增--exclude命令行标志用于排除指定的授权范围
2. 移除--scope与--domain/--recommend的互斥限制,改为叠加使用
3. 重构范围合并与排除逻辑,增加校验和辅助工具函数
4. 更新--scope参数的帮助文档说明叠加行为

* fix(auth/login): 修复登录命令scope参数描述重复的问题

移除了重复的参数说明文本,整理冗余的注释内容,让帮助文档更清晰易读

* fix(auth/login): 修复exclude参数校验逻辑

添加--exclude参数必须配合其他可选参数使用的校验,避免无效的exclude参数调用

---------

Co-authored-by: cqc-a11y <chengqingchun@bytedance.com>
2026-05-14 14:12:29 +08:00
fangshuyu-768
f1aa7d8f42 feat(drive): add modified-time smart sync mode (#859) 2026-05-14 14:10:35 +08:00
liangshuo-1
a18504b1f9 chore(release): v1.0.30 (#871)
Change-Id: Iaa769f2ddc98ece7bf36efe821d4eb192f7fc727
2026-05-13 20:11:06 +08:00
shifengjuan-dev
5e0ac02f08 feat(im): add --chat-mode topic to +chat-create (#790)
Adds --chat-mode group|topic to lark-cli im +chat-create so users and AI agents can create 话题群 (topic chats) directly via the CLI. Without this, requests to create a topic chat silently fall back to a normal conversation group. Default remains group; chat_mode is now always emitted in the POST /open-apis/im/v1/chats request body.

Change-Id: I79385e2e8606f84e3f27de240d1b41037bf51261
2026-05-13 18:03:58 +08:00
aj
b0c9a4d74e fix(auth): support comma-separated --scope in auth login (#764)
`lark-cli auth login --scope "a,b"` previously sent the raw comma-joined
string to the device authorization endpoint, which treats it as a single
malformed scope and fails with:

  device authorization failed: The provided scope list contains invalid
  or malformed scopes.

OAuth 2.0 (RFC 6749 §3.3) requires space-delimited scopes on the wire,
but commas are the more natural separator for users typing on a shell
(quoting whitespace is awkward, especially for AI-agent generated
commands). Accept both: split on commas/whitespace, trim, dedupe, then
re-join with single spaces.

Also adds unit tests covering single, comma, space, mixed, dedupe, and
trailing-separator inputs.

Co-authored-by: aj <2072584+meijing0114@users.noreply.github.com>
2026-05-13 14:27:55 +08:00
JackZhao10086
ddc24fec90 fix(auth): clarify URL handling in auth messages and docs (#856) 2026-05-13 14:09:53 +08:00
liangshuo-1
25454f498b test(update): isolate stamp writes from real ~/.lark-cli/skills.stamp (#858)
Five tests in cmd/update mocked SkillsUpdateOverride to return success
and let runSkillsAndStamp call WriteStamp, but did not isolate
LARKSUITE_CLI_CONFIG_DIR. Each run clobbered the real
~/.lark-cli/skills.stamp with the mock version ("2.0.0" or "1.0.0"),
causing skillscheck to fire a misleading drift notice on every
subsequent lark-cli invocation.

Add t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) at the top of:
  - TestUpdateNpm_JSON
  - TestUpdateNpm_Human
  - TestUpdateForce_JSON
  - TestUpdateDevVersion_JSON
  - TestUpdateWindows_NpmSuccess_JSON

Scope is limited to tests that mock SkillsUpdateOverride to success;
tests that invoke real npx are pre-existing and out of scope here.

Change-Id: I7a78a6c70f276b51333253acc115e0109c01a851
2026-05-13 13:52:22 +08:00
evandance
62ff3d66a6 fix(bind): accept ~/ paths in OpenClaw secret references (#839)
OpenClaw stores secret file paths in user-authored ~/-relative form so
the configuration stays portable across machines. lark-cli config bind
previously rejected these as non-absolute, blocking users until they
rewrote the OpenClaw config with literal absolute paths.

bind now resolves ~ to the OpenClaw home directory (OPENCLAW_HOME if
set, otherwise the OS home) before the path audit runs, mirroring how
OpenClaw itself reads the same field. Cwd-relative paths and other
unsafe locations are still rejected as before.
2026-05-13 12:34:43 +08:00
liangshuo-1
ce0b68dc0e chore(release): v1.0.29 (#852) 2026-05-12 20:44:16 +08:00
zkh-bytedance
cc16c4d2d7 feat(whiteboard): pin whiteboard-cli to v0.2.11 in lark-whiteboard skill (#850) 2026-05-12 19:43:02 +08:00
zgz2048
1ee7f22ee5 docs: refine base analysis SOP wording (#849) 2026-05-12 17:18:05 +08:00
calendar-assistant
b612dde19e docs: update README capability descriptions (#793)
Change-Id: Ife2670e790da48b676e8f1d81db47f4b4a9e7430
2026-05-12 16:19:26 +08:00
zgz2048
4181174352 docs: refine lark-base data analysis SOP (#784)
* docs: refine lark-base data analysis SOP

* docs: clarify data-query record lookup paths

* docs: generalize data-query lookup example

* docs: clarify cloud-side query execution
2026-05-12 15:03:03 +08:00
xzcong0820
1180baac61 feat(mail): add unknown-flag fuzzy-match for lark-cli mail domain (#806)
Adds shortcuts/mail/flag_suggest.go (~120 LOC) implementing a cobra
FlagErrorFunc hook for the mail subcommand tree. On 'unknown flag: --X'
or 'unknown shorthand flag: "X" in -X', it collects flags from the
current command via cmd.Flags().VisitAll, runs bidirectional prefix
match + Levenshtein DP (threshold=max(1,len/3+1), cap 4), and returns
top-5 candidates inside the existing ErrorEnvelope JSON:

  error.type = "unknown_flag"
  error.detail.{unknown, command_path, candidates}
  error.detail.candidates[*] = {flag, shorthand, distance, reason}

Exit code stays 1 (ExitAPI), not ExitValidation - no breaking change for
CI/agent scripts that check non-zero exit. stderr switches from plain
'Error: unknown flag: --X' to JSON envelope, aligning with the existing
'errors = JSON envelope on stderr' convention; mail unknown-flag was the
last gap.

Scope is strictly the mail subcommand tree: shortcuts/register.go gains
a single 'if service == "mail" { mail.InstallOnMail(svc) }' branch
after the existing Mount loop. Other domains (calendar / im / api /
auth / ...) keep cobra's default FlagErrorFunc and unchanged plain-text
stderr behavior.

Covers:
- shortcuts/mail/flag_suggest.go      (new, ~120 LOC)
- shortcuts/mail/flag_suggest_test.go (new, 12 table-driven tests)
- shortcuts/register.go               (+3 lines after mail Mount loop)

No changes to cmd/root.go or internal/output/* - ErrDetail.Detail is
already interface{}, handleRootError already routes *ExitError via
WriteErrorEnvelope.
2026-05-12 14:28:09 +08:00
zhicong666-bytedance
db1a3fc0a6 feat(vc): add agent meeting join, leave, and events shortcuts (#824)
* feat(vc): agent join meeting basic shortcuts structure

Change-Id: Ic5d64067eb48670fa6636841cd00cbfa9b0bf3e7

* docs: add skill references for vc +meeting-join and +meeting-leave

* feat(vc): add meeting events shortcut

Add vc +meeting-events for bot meeting activity queries with page-all pagination support and tested pretty/json output.

* feat(vc): refine meeting events pagination and output

* test: add unit tests for vc +meeting-join and +meeting-leave shortcuts

* feat(vc): improve meeting events pretty timeline

* feat(vc): refine meeting events pretty output

* docs(skill): add vc meeting events shortcut guide

* docs(skill): clarify vc meeting events output guidance

* docs: clarify participant-snapshot vs meeting-events routing

* refactor: split lark-vc-agent from lark-vc

* docs: drop nonexistent workflow skill reference and fix identity

* docs: fix cross-links in lark-vc-agent references after split

* fix(vc): send meeting join password at top level

* docs: rewrite lark-vc-agent description in user-facing language

* docs: tighten lark-vc-agent description to descriptive neutral tone

* fix: use Chinese quotes in vc/vc-agent description YAML frontmatter

* docs: downgrade dry-run from mandatory to optional for vc-agent writes

* docs: clarify pretty vs json format choice by processing depth

* docs: systematic review of lark-vc-agent SKILL for clarity and precision

* feat(vc): print meeting event page token in pretty output

* docs(skill): refine vc agent meeting guidance

* revert: restore CRITICAL banner in lark-vc-agent to match repo convention

* docs: replace inaccurate no-replay warning with real social-cost risk

* docs: tighten meeting-join risk warning to single sentence

* docs: tighten vc-agent references - remove redundancy and fix vague wording

* Revert "docs: tighten vc-agent references - remove redundancy and fix vague wording"

This reverts commit 9845fc40622c65b0811da1c9ae4902434377f33e.

* docs(skill): refine vc meeting events paging guidance

* fix(vc): keep meeting event count aligned with events list

* docs(skill): tighten vc agent meeting events workflow

* refactor(vc): simplify meeting events pagination

* docs(skill): tighten vc agent meeting guidance

* docs(skill): require reading shared docs for meeting summaries

* chore(env): switch default feishu endpoints to pre

* fix(env): use feishu accounts host

* docs(vc): use explicit date in recording example

* revert(env): remove default ppe request header

* chore(env): switch default feishu endpoints to pre

* docs(skill): guide users to early-bird group on agent meeting gray miss

Teach the lark-vc-agent skill to recognize OAPI's new gray-miss signal for
the three agent meeting commands (`+meeting-join`, `+meeting-leave`,
`+meeting-events`) and route the user to the early-bird group instead of
treating it as a permission error.

When CLI stderr JSON returns `error.code=20017 / ErrNotInGray`, the agent
renders the fixed early-bird invite link
`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`.
The user manual is intentionally not surfaced yet.

Scope-related errors still follow the existing `auth login --scope` flow
with no early-bird copy mixed in. lark-shared and other skills are not
touched, so the guidance stays scoped to the agent meeting commands only.

* chore(env): switch endpoints to boe for agent meeting gray testing

* chore(vc-agent): update gray guide and boe endpoints

* docs(vc-agent): refine gray guidance flow

* docs(vc-agent): centralize gray guidance

* fix(ci): stabilize vc output and skill frontmatter

* fix(vc): address review feedback

---------

Co-authored-by: zhaolei.vc <zhaolei.vc@bytedance.com>
Co-authored-by: renaocheng <renaocheng@bytedance.com>
2026-05-11 21:32:06 +08:00
niuchong
7c6abb3834 fix: silence misleading "skills not installed" startup notice (#801)
Remove the cold-start _notice.skills that fires whenever
~/.lark-cli/skills.stamp is missing. The stamp is written
exclusively by `lark-cli update`, so users who installed skills via
`npx skills add larksuite/cli -g` (the documented path) saw the
notice on every run despite a fully populated ~/.agents/skills/.

The version-drift notice (stamp != binary) is preserved unchanged
for users who opted into tracking by running `lark-cli update`.

- internal/skillscheck/check.go: Init returns silently on empty stamp
- internal/skillscheck/notice.go: drop dead cold-start branch in Message;
  Current field is now guaranteed non-empty
- tests updated in skillscheck package + cmd/root_integration_test.go
  to assert the new contract

No new files, no env vars, no JSON schema change. The _notice.skills
shape stays {current, target, message} — only the cold-start message
string is no longer possible.
2026-05-11 21:02:55 +08:00
liangshuo-1
4c63198237 chore(release): v1.0.28 (#830)
Change-Id: If8e5170a3abb8ef846fcb7473977e6bf8bc91767
2026-05-11 20:40:32 +08:00
chenxingtong-bytedance
c0fbe54ef6 feat(lark-im): support UAT for forward and add threads.forward (#689)
- Update messages.forward identity to support `user` and `bot`
  - Add threads.forward entry under threads API resources
  - Add forward APIs -> `im:message`, `im:message.send_as_user` scope mapping

Change-Id: I2e33b0d78d72fd067ba3916095479f9b336e7eb9
2026-05-11 19:35:38 +08:00
fangshuyu-768
4ba39ef392 fix(drive): handle duplicate remote sync paths (#803) 2026-05-11 17:51:23 +08:00
shifengjuan-dev
25c72ced6f docs(im): name --query/--member-ids in +chat-search shortcut row (#812)
The +chat-search row in lark-im SKILL.md described the search as
"by keyword and/or member open_ids", which doesn't match the real
flag names (--query, --member-ids). Naming them inline avoids
agents guessing --keyword from the prose, matching the style
already used by +chat-messages-list.

Change-Id: Ife8668d9b13ee66711bc4e81a7b2bcc7f05d9586
2026-05-11 16:22:12 +08:00
SunPeiYang996
0ed63b02e4 chore(doc): inject docs scene into v2 requests (#808)
Change-Id: I4f23880e24164c8b229a5403942bfa1b7ddb0ce6
2026-05-11 14:35:00 +08:00
Yuxuan Zhao
5352e6a90a test: drop stale yes flags from e2e (#815) 2026-05-11 13:49:43 +08:00
seemslike
16f1a0f320 feat: add flag shortcuts for im (#770)
Add IM flag shortcut commands to lark-cli, enabling users to create, list, and cancel bookmarks on messages and threads via +flag-create, +flag-list, and +flag-cancel.

Change-Id: I8f87f0eadf83fb59b024a3b9fe67b23d363abe0a
2026-05-11 11:32:06 +08:00
Yuxuan Zhao
4d625420b0 test: drop stale e2e yes flags (#794) 2026-05-11 10:48:46 +08:00
159 changed files with 17027 additions and 559 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo
.tmp/

View File

@@ -14,3 +14,4 @@ id = "lark-session-token"
description = "Detect Lark session tokens"
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
keywords = ["XN0YXJ0-", "-WVuZA"]

View File

@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file.
## [v1.0.31] - 2026-05-14
### Features
- **install**: Skip interactive prompts in non-TTY environments (#888)
- **update**: Recommend `lark-cli update` over `npm install` for AI agents (#884)
- **im**: Add `--exclude-muted` to `+chat-search` and new `+chat-list` shortcut (#820)
- **auth**: Add `--exclude` flag and allow combining `--scope` with `--domain`/`--recommend` (#844)
- **drive**: Add modified-time smart sync mode (#859)
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)
## [v1.0.30] - 2026-05-13
### Features
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
### Bug Fixes
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
- **auth**: Clarify URL handling in auth messages and docs (#856)
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
### Tests
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
## [v1.0.29] - 2026-05-12
### Features
- **vc**: Add agent meeting join, leave, and events shortcuts (#824)
- **mail**: Add unknown-flag fuzzy match for `lark-cli mail` commands (#806)
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.11` in `lark-whiteboard` skill (#850)
### Bug Fixes
- Silence misleading "skills not installed" startup notice (#801)
### Documentation
- **base**: Refine data analysis SOP wording (#784, #849)
- Update README capability descriptions (#793)
## [v1.0.28] - 2026-05-11
### Features
- **im**: Support UAT for `messages.forward` and add `threads.forward` (#689)
- **im**: Add flag shortcuts `+flag-create` / `+flag-list` / `+flag-cancel` for message bookmarks (#770)
### Bug Fixes
- **drive**: Handle duplicate remote sync paths (#803)
### Documentation
- **im**: Name `--query` / `--member-ids` in `+chat-search` shortcut row (#812)
## [v1.0.27] - 2026-05-09
### Features
@@ -644,6 +703,10 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25

View File

@@ -8,7 +8,9 @@ DATE := $(shell date +%Y-%m-%d)
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks
all: test
fetch_meta:
python3 scripts/fetch_meta.py
@@ -37,3 +39,13 @@ uninstall:
clean:
rm -f $(BINARY)
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
# Step 2: gitleaks scans the full repo for real leaked secrets.
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
gitleaks:
@bash scripts/check-doc-tokens.sh
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
gitleaks detect --redact -v --exit-code=2

View File

@@ -24,7 +24,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| Category | Capabilities |
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
| 📅 Calendar | View, create and update events, invite attendees, find meeting rooms, RSVP to invitations, check free/busy & time suggestions |
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
@@ -36,7 +36,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| 🎥 Meetings | Search meeting records, query meeting minutes artifacts and recordings |
| 🕐 Attendance | Query personal attendance check-in records |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
@@ -62,11 +62,7 @@ Choose **one** of the following methods:
**Option 1 — From npm (recommended):**
```bash
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**Option 2 — From source:**
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
**Step 1 — Install**
```bash
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**Step 2 — Configure app credentials**
@@ -136,7 +128,7 @@ lark-cli auth status
| Skill | Description |
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
| `lark-calendar` | Calendar events (create/update), agenda view, free/busy queries, time suggestions, room finding, RSVP replies |
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
@@ -151,7 +143,7 @@ lark-cli auth status
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters); upload audio/video to create minutes, download media |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-attendance` | Query personal attendance check-in records |

View File

@@ -24,7 +24,7 @@
| 类别 | 能力 |
| ------------- |--------------------------------------------|
| 📅 日历 | 查看日程、创建日程邀请参会人、查询忙闲状态、时间建议 |
| 📅 日历 | 查看、创建和更新日程邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
@@ -36,7 +36,7 @@
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
@@ -62,11 +62,7 @@
**方式一 — 从 npm 安装(推荐):**
```bash
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**方式二 — 从源码安装:**
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
**第 1 步 — 安装**
```bash
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**第 2 步 — 配置应用凭证**
@@ -137,7 +129,7 @@ lark-cli auth status
| Skill | 说明 |
| --------------------------------- |-------------------------------------------|
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
@@ -152,7 +144,7 @@ lark-cli auth status
| `lark-event` | 实时事件订阅WebSocket支持正则路由与 Agent 友好格式 |
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节),上传音视频生成妙记,下载音视频文件 |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-attendance` | 查询个人考勤打卡记录 |

View File

@@ -30,6 +30,7 @@ type LoginOptions struct {
Scope string
Recommend bool
Domains []string
Exclude []string
NoWait bool
DeviceCode string
}
@@ -62,11 +63,13 @@ browser. Run it in the background and retrieve the verification URL from its out
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
available := sortedKnownDomains()
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
@@ -158,6 +161,10 @@ func authLoginRun(opts *LoginOptions) error {
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
if len(opts.Exclude) > 0 && !hasAnyOption {
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
}
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
@@ -185,14 +192,17 @@ func authLoginRun(opts *LoginOptions) error {
}
}
finalScope := opts.Scope
// Normalize --scope so users can pass either OAuth-standard space-separated
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
// space-delimited scopes in the wire request, so the device authorization
// endpoint rejects raw "a,b" strings as a single malformed scope.
finalScope := normalizeScopeInput(opts.Scope)
// Resolve scopes from domain/permission filters
// Resolve scopes from domain/permission filters and merge with --scope.
// --scope, --domain, and --recommend combine additively so callers can,
// for example, request all `docs` scopes plus a few specific `drive`
// scopes in a single command.
if len(selectedDomains) > 0 || opts.Recommend {
if opts.Scope != "" {
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
}
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user")
@@ -206,11 +216,35 @@ func authLoginRun(opts *LoginOptions) error {
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
}
if len(candidateScopes) == 0 {
if len(candidateScopes) == 0 && opts.Scope == "" {
return output.ErrValidation("no matching scopes found, check domain/scope options")
}
finalScope = strings.Join(candidateScopes, " ")
// Merge --scope additively with the resolved domain scopes.
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
for _, s := range candidateScopes {
merged[s] = true
}
for _, s := range strings.Fields(finalScope) {
merged[s] = true
}
finalScope = joinSortedScopeSet(merged)
}
// Apply --exclude on top of the resolved scope set. We honour exclude
// regardless of whether scopes came from --scope, --domain, --recommend,
// or any combination thereof.
if len(opts.Exclude) > 0 {
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
if len(unknown) > 0 {
return output.ErrValidation(
"these --exclude scopes are not present in the requested set: %s",
strings.Join(unknown, ", "))
}
finalScope = excluded
if strings.TrimSpace(finalScope) == "" {
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
}
}
// Step 1: Request device authorization
@@ -232,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. Then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -473,7 +507,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.ScopesForIdentity(identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
}
}
@@ -532,6 +566,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
return false
}
// normalizeScopeInput accepts a user-supplied --scope value that may use
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
// canonical OAuth 2.0 wire form: a single space-joined string with empties
// trimmed and duplicates removed (first occurrence wins; order preserved).
//
// Examples:
//
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
// "a, b , c" -> "a b c"
// "a b a" -> "a b"
// "" -> ""
func normalizeScopeInput(raw string) string {
if raw == "" {
return ""
}
// Treat both commas and any whitespace as separators.
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(fields) == 0 {
return ""
}
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, f := range fields {
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
out = append(out, f)
}
return strings.Join(out, " ")
}
// suggestDomain finds the best "did you mean" match for an unknown domain.
func suggestDomain(input string, known map[string]bool) string {
// Check common cases: prefix match or input is a substring
@@ -542,3 +610,58 @@ func suggestDomain(input string, known map[string]bool) string {
}
return ""
}
// joinSortedScopeSet returns a deterministic, space-separated scope string
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
func joinSortedScopeSet(set map[string]bool) string {
out := make([]string, 0, len(set))
for s := range set {
if strings.TrimSpace(s) == "" {
continue
}
out = append(out, s)
}
sort.Strings(out)
return strings.Join(out, " ")
}
// applyExcludeScopes removes the provided exclude entries from the requested
// scope string. Each --exclude flag value may itself contain comma- or
// whitespace-separated scopes. Returns the filtered scope string and any
// exclude entries that were not present in the requested set (callers can
// surface those as a validation error to catch typos like
// `--exclude drive:file:downlod`).
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
requestedSet := make(map[string]bool)
for _, s := range strings.Fields(requested) {
requestedSet[s] = true
}
excludeSet := make(map[string]bool)
for _, raw := range excludes {
// --exclude already splits on commas (StringSliceVar), but also
// tolerate whitespace-separated entries inside a single value.
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
excludeSet[s] = true
}
}
var unknown []string
for s := range excludeSet {
if !requestedSet[s] {
unknown = append(unknown, s)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return requested, unknown
}
kept := make(map[string]bool, len(requestedSet))
for s := range requestedSet {
if !excludeSet[s] {
kept[s] = true
}
}
return joinSortedScopeSet(kept), nil
}

View File

@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code导致用户授权链接失效。",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询**不要短 timeout 反复重试**每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout**; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",

View File

@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
}
}
func TestNormalizeScopeInput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"single", "vc:note:read", "vc:note:read"},
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
{"trim_and_dedup", " a , b , a ", "a b"},
{"trailing_separators", "a,b,,", "a b"},
{"only_separators", " , , ", ""},
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeScopeInput(tc.in); got != tc.want {
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
@@ -879,6 +905,57 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
}
}
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
})
if err != nil {
t.Fatalf("authLoginRun() error = %v", err)
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"exactly as returned by the CLI",
"opaque string",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)
}
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
@@ -917,6 +994,60 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
}
}
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: ctx,
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error from cancelled context")
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["agent_hint"].(string)
for _, want := range []string{
"timeout >= 600s",
"逐字原样转发 CLI 返回的 URL",
"opaque string",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
}
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {

View File

@@ -97,7 +97,7 @@ func diagBuild(domains []string) diagOutput {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
continue
}
for _, scope := range sc.ScopesForIdentity(identity) {
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.Command, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
@@ -169,6 +169,25 @@ func appendUniq(ss []string, s string) []string {
return append(ss, s)
}
func TestDiagBuild_ShortcutIncludesConditionalScopes(t *testing.T) {
out := diagBuild([]string{"drive"})
var sawMetadata, sawDownload bool
for _, method := range out.Methods {
if method.Domain != "drive" || method.Type != "shortcut" || method.Method != "+status" {
continue
}
if method.Scope == "drive:drive.metadata:readonly" {
sawMetadata = true
}
if method.Scope == "drive:file:download" {
sawDownload = true
}
}
if !sawMetadata || !sawDownload {
t.Fatalf("drive +status should advertise both metadata and conditional download scopes, saw metadata=%v download=%v", sawMetadata, sawDownload)
}
}
// ── Snapshot generation ───────────────────────────────────────────────
//
// Generates a JSON snapshot of all API methods and shortcuts with their

View File

@@ -252,7 +252,7 @@ func checkCLIUpdate() []checkResult {
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
"run: lark-cli update")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}

View File

@@ -75,7 +75,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
continue
}
scopes := sc.ScopesForIdentity(identity)
scopes := sc.DeclaredScopesForIdentity(identity)
if len(scopes) == 0 {
return nil
}

View File

@@ -140,6 +140,7 @@ func setupNotices() {
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
@@ -147,6 +148,7 @@ func setupNotices() {
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {

View File

@@ -504,10 +504,12 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
})
}
// TestSetupNotices_ColdStart verifies that when no skills stamp exists,
// the composed PendingNotice provider includes a "skills" key with an
// empty Current and the cold-start message.
func TestSetupNotices_ColdStart(t *testing.T) {
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
// produces no skills key in the composed notice. Users who installed
// skills via `npx skills add` (no stamp) must not see the misleading
// "not installed" notice — only `lark-cli update` users opt into the
// drift tracker.
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
@@ -530,17 +532,10 @@ func TestSetupNotices_ColdStart(t *testing.T) {
notice := output.GetNotice()
if notice == nil {
t.Fatal("GetNotice() = nil, want non-nil for cold start")
return // expected — no pending notices at all
}
skills, ok := notice["skills"].(map[string]interface{})
if !ok {
t.Fatalf("notice.skills missing, got %+v", notice)
}
if skills["current"] != "" || skills["target"] != "1.0.21" {
t.Errorf("notice.skills = %+v, want {current:\"\", target:\"1.0.21\"}", skills)
}
if msg, _ := skills["message"].(string); msg != "lark-cli skills not installed, run: lark-cli update" {
t.Errorf("notice.skills.message = %q, want cold-start message", msg)
if _, ok := notice["skills"]; ok {
t.Errorf("notice.skills present in cold-start state, want absent: %+v", notice)
}
}
@@ -617,6 +612,9 @@ func TestSetupNotices_Drift(t *testing.T) {
if msg, _ := skills["message"].(string); msg != want {
t.Errorf("notice.skills.message = %q, want %q", msg, want)
}
if cmd, _ := skills["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
}
}
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
@@ -663,6 +661,20 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
if _, ok := notice["skills"].(map[string]interface{}); !ok {
t.Errorf("missing 'skills' key: %+v", notice)
}
upd, ok := notice["update"].(map[string]interface{})
if !ok {
t.Fatalf("notice.update missing or wrong type: %+v", notice)
}
if cmd, _ := upd["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update")
}
sk, ok := notice["skills"].(map[string]interface{})
if !ok {
t.Fatalf("notice.skills missing or wrong type: %+v", notice)
}
if cmd, _ := sk["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
}
}
// clearNoticeEnv unsets the env vars that affect either notice. We

View File

@@ -284,6 +284,32 @@ func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.
}
}
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "drive"}
shortcutCmd := &cobra.Command{Use: "+status"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if exitErr.Detail == nil {
t.Fatal("expected error detail")
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
}
}
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -227,7 +227,7 @@ 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, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
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)
return nil
}
@@ -324,7 +324,7 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, 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))
}
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the

View File

@@ -168,6 +168,11 @@ func TestUpdateManual_Human(t *testing.T) {
}
func TestUpdateNpm_JSON(t *testing.T) {
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -195,6 +200,9 @@ func TestUpdateNpm_JSON(t *testing.T) {
}
func TestUpdateNpm_Human(t *testing.T) {
// Same isolation as TestUpdateNpm_JSON — see comment there.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -222,6 +230,9 @@ func TestUpdateNpm_Human(t *testing.T) {
}
func TestUpdateForce_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--force", "--json"})
@@ -312,6 +323,9 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
}
func TestUpdateDevVersion_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -467,6 +481,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
t.Errorf("expected manual reinstall command in hint, got: %s", out)
}
if !strings.Contains(out, "skills will not be synced") {
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
}
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
}
}
func TestUpdateCheck_JSON_Npm(t *testing.T) {
@@ -629,6 +649,9 @@ func TestPermissionHint(t *testing.T) {
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})

View File

@@ -65,7 +65,11 @@ func AssertSecurePath(params AuditParams) (string, error) {
}
// requireAbsolutePath rejects relative paths; relative paths would depend on
// the process cwd and defeat the point of a static audit.
// the process cwd and defeat the point of a static audit. Shell-style
// shortcuts like `~` are home-relative, not cwd-relative — they are an
// orthogonal concern and the audit is intentionally Go-stdlib strict here.
// Callers that accept user-authored config (e.g. resolveFileRef) must
// pre-resolve any such shortcuts before passing the path in.
func requireAbsolutePath(target, label string) error {
if !filepath.IsAbs(target) {
return fmt.Errorf("%s: path must be absolute, got %q", label, target)

View File

@@ -23,9 +23,19 @@ func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) {
return "", fmt.Errorf("file provider path is empty")
}
// OpenClaw preserves user-authored `~/...` paths verbatim on disk for
// portability and resolves them at read time. lark-cli reads the file
// raw, so we mirror that resolution here before the audit — otherwise
// an unambiguous home-relative path would be rejected by
// requireAbsolutePath, which is meant to guard against cwd-relative
// paths (a different concern). expandTildePath honours OPENCLAW_HOME so
// a tilde inside an OPENCLAW_HOME-overridden config resolves to the
// same absolute path OpenClaw itself would have used.
targetPath := expandTildePath(pc.Path)
// Security audit on file path
securePath, err := AssertSecurePath(AuditParams{
TargetPath: pc.Path,
TargetPath: targetPath,
Label: "secrets.providers file path",
TrustedDirs: pc.TrustedDirs,
AllowInsecurePath: pc.AllowInsecurePath,

View File

@@ -6,6 +6,7 @@ package binding
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -230,3 +231,88 @@ func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
// TestResolveFileRef_TildePath_SingleValue is the end-to-end smoke test
// for the fix: a singleValue file provider with a ~/-relative path
// resolves correctly through resolveFileRef. Before this PR the audit
// would reject the path as "must be absolute".
func TestResolveFileRef_TildePath_SingleValue(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(p, []byte("tilde_secret\n"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "~/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "tilde_secret" {
t.Errorf("got %q, want %q", got, "tilde_secret")
}
}
// TestResolveFileRef_RelativePath_StillRejected guards the absolute-path
// audit: cwd-relative input must still be rejected even though tilde was
// loosened. Catches regressions if expandTildePath is ever widened to
// also expand "./..." (which would weaken the audit's invariant).
func TestResolveFileRef_RelativePath_StillRejected(t *testing.T) {
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "relative/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for relative path, got nil")
}
wantSub := "path must be absolute"
if !strings.Contains(err.Error(), wantSub) {
t.Errorf("error = %q, want substring %q", err.Error(), wantSub)
}
}
// TestResolveFileRef_TildePath_JSONMode verifies the tilde-expansion
// path works for json mode (where ref id is a JSON pointer) as well as
// singleValue mode — the mechanism is mode-agnostic.
func TestResolveFileRef_TildePath_JSONMode(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secrets.json")
content := `{"providers":{"feishu":{"key":"json_via_tilde"}}}`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
pc := &ProviderConfig{
Source: "file",
Path: "~/secrets.json",
Mode: "json",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "json_via_tilde" {
t.Errorf("got %q, want %q", got, "json_via_tilde")
}
}

180
internal/binding/tilde.go Normal file
View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
// hasTildePrefix reports whether s begins with `~` followed by end-of-string,
// `/`, or `\` — the form OpenClaw treats as home-relative.
func hasTildePrefix(s string) bool {
if s == "" || s[0] != '~' {
return false
}
if len(s) == 1 {
return true
}
return s[1] == '/' || s[1] == '\\'
}
// joinTildeSuffix expands a tilde-prefixed string against a resolved home
// directory. Replaces only the leading `~` so the original separator
// (forward or back slash) and suffix bytes are kept verbatim, matching
// OpenClaw's `input.replace(/^~(?=$|[\\/])/, home)` semantics rather than
// going through filepath.Join (which would silently drop a literal `\` on
// POSIX). filepath.Clean is applied so `..` and duplicate separators are
// collapsed in the same way Node's path.resolve does on each platform.
//
// Caller must ensure hasTildePrefix(s) is true and home is non-empty.
func joinTildeSuffix(s, home string) string {
if len(s) == 1 {
return home
}
return filepath.Clean(home + s[1:])
}
// normalizeSentinel applies OpenClaw's normalize() helper to a single
// string: trims whitespace and treats the JS-flavoured literals
// "undefined" / "null" (along with empty/whitespace-only) as unset.
func normalizeSentinel(v string) string {
v = strings.TrimSpace(v)
if v == "undefined" || v == "null" {
return ""
}
return v
}
// osHome returns the OS-level home directory by walking OpenClaw's
// resolution chain: HOME → USERPROFILE → OS user database (getpwuid on
// Unix / user32 on Windows, via os/user.Current). Each candidate is
// passed through normalizeSentinel so sentinel literals and blank
// strings fall through.
//
// Matches OpenClaw's resolveRawOsHomeDir env chain so the same tilde
// resolves against the same home under mixed shell environments and
// accidentally-stringified env values. Go's stdlib os.UserHomeDir on
// Unix only re-reads HOME and gives up; Node's os.homedir() still
// returns the account home via the user database, so the explicit
// user.Current() step is what keeps OpenClaw-authored `~/...` working
// in HOME-unset shells.
//
// Deliberate hybrid contract — neither a strict mirror of OpenClaw
// nor a strict reject-on-missing:
//
// - OpenClaw's final fallback is cwd (via resolveRequiredHomeDir →
// process.cwd()). We don't do that because requireAbsolutePath
// exists precisely to reject cwd-dependent paths; routing
// `~/secret` through cwd would defeat the audit invariant.
//
// - We still go through user.Current() before giving up, even when
// HOME is a sentinel literal ("undefined" / "null") and
// USERPROFILE is unset. At that point OpenClaw would land on cwd,
// and a strict implementation would reject; user.Current() lands
// on the account home instead — cwd-independent and user-bound,
// so it satisfies the audit's safety goal while still letting
// ~/-authored configs resolve in a malformed-env shell.
//
// - Only returns "" when the env chain AND user.Current() are all
// unresolvable, at which point the caller surfaces a clean
// "path must be absolute" error from the audit.
func osHome() string {
if v := normalizeSentinel(os.Getenv("HOME")); v != "" {
return v
}
if v := normalizeSentinel(os.Getenv("USERPROFILE")); v != "" {
return v
}
if u, err := user.Current(); err == nil {
return normalizeSentinel(u.HomeDir)
}
return ""
}
// explicitOpenClawHome reads OPENCLAW_HOME with OpenClaw's normalize()
// semantics applied.
func explicitOpenClawHome() string {
return normalizeSentinel(os.Getenv("OPENCLAW_HOME"))
}
// absolutize returns p as an absolute path, resolving against the process
// cwd when p is relative. Returns "" when the cwd cannot be resolved.
// Wraps filepath.Abs semantics via vfs.Getwd because forbidigo bans
// filepath.Abs inside internal/ packages.
func absolutize(p string) string {
if p == "" {
return ""
}
if filepath.IsAbs(p) {
return filepath.Clean(p)
}
wd, err := vfs.Getwd()
if err != nil {
return ""
}
return filepath.Join(wd, p)
}
// openClawHome returns the home directory used to resolve `~`-relative paths
// authored against OpenClaw's config. Closely mirrors OpenClaw's
// home-resolution semantics so the same tilde resolves to the same
// absolute path here as inside OpenClaw runtime under all normal
// conditions.
//
// Resolution order:
// 1. OPENCLAW_HOME env var, when set (sentinel-normalised).
// 2. If OPENCLAW_HOME itself has a tilde prefix, expand it against the OS
// home (see osHome); the result is empty when the OS home is
// unresolvable.
// 3. Otherwise fall back to the OS home.
//
// The returned path is absolute (relative OPENCLAW_HOME values are
// absolutised against the process cwd, matching Node path.resolve in
// OpenClaw's pipeline).
//
// Returns "" when no home can be resolved. This is a deliberate
// divergence from OpenClaw, whose read pipeline would fall back to
// cwd via resolveRequiredHomeDir — see osHome for the rationale.
func openClawHome() string {
raw := explicitOpenClawHome()
switch {
case raw == "":
raw = osHome()
case hasTildePrefix(raw):
h := osHome()
if h == "" {
return ""
}
raw = joinTildeSuffix(raw, h)
}
return absolutize(raw)
}
// expandTildePath resolves a leading `~` or `~/...` prefix to OpenClaw's
// effective home directory (see openClawHome).
//
// Returns the input unchanged when it lacks a tilde prefix or when
// openClawHome cannot resolve a home directory. The latter case is a
// deliberate divergence from OpenClaw, whose read pipeline falls back
// to cwd — see osHome. Surfacing a "path must be absolute" error from
// the audit is preferable to silently routing a user-authored
// `~/secret` through cwd resolution.
//
// `~user` shell-style expansion is intentionally not supported (OpenClaw
// does not support it either).
func expandTildePath(p string) string {
if !hasTildePrefix(p) {
return p
}
home := openClawHome()
if home == "" {
return p
}
return joinTildeSuffix(p, home)
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setFakeOSHome controls osHome's env-chain inputs (HOME and USERPROFILE)
// in one call so tests stay deterministic across platforms. osHome reads
// HOME first, then USERPROFILE, then user.Current(); setting only one of
// the two leaves the test sensitive to whichever the runner happens to
// have populated. Passing dir == "" disables both env entries so tests
// can exercise the user.Current() fallback or no-home edge cases.
func setFakeOSHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
}
// isolateRuntimeWrites parks the process cwd in a fresh TempDir for the
// test's duration. Tests that set HOME to a sentinel literal trigger Go
// runtime side effects — most visibly the telemetry subsystem, which
// calls os.UserConfigDir() (= "$HOME/Library/Application Support" on
// darwin) and happily writes through a relative result like
// "undefined/Library/...". Without isolation those files land in the
// package or repo dir and get accidentally staged. Chdir'ing into a
// TempDir routes the noise into a path testing.T auto-cleans.
func isolateRuntimeWrites(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(orig)
})
}
// TestOpenClawHome covers the openClawHome resolution table: empty /
// sentinel OPENCLAW_HOME falls back to the OS home, explicit absolute
// values are used verbatim (with whitespace trimmed), and tilde-prefixed
// values recurse through the OS home.
func TestOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
explicit := t.TempDir()
setFakeOSHome(t, homeDir)
tests := []struct {
name string
openclawEnv string
want string
}{
{"unset falls back to OS home", "", homeDir},
{"undefined literal treated as unset", "undefined", homeDir},
{"null literal treated as unset", "null", homeDir},
{"whitespace-only treated as unset", " ", homeDir},
{"explicit absolute path used verbatim", explicit, explicit},
{"explicit absolute path is trimmed", " " + explicit + " ", explicit},
{"bare tilde resolves to OS home", "~", homeDir},
{"tilde-prefixed value recurses through OS home", "~/custom", filepath.Join(homeDir, "custom")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("OPENCLAW_HOME", tc.openclawEnv)
got := openClawHome()
if got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_RelativeIsAbsolutized confirms a relative
// OPENCLAW_HOME is resolved against the process cwd, mirroring Node's
// path.resolve behaviour in OpenClaw.
func TestOpenClawHome_RelativeIsAbsolutized(t *testing.T) {
t.Setenv("OPENCLAW_HOME", filepath.FromSlash("relative/dir"))
got := openClawHome()
if !filepath.IsAbs(got) {
t.Fatalf("openClawHome() = %q, want absolute path", got)
}
wantSuffix := filepath.FromSlash("relative/dir")
if !strings.HasSuffix(got, wantSuffix) {
t.Errorf("openClawHome() = %q, want suffix %q", got, wantSuffix)
}
}
// TestOpenClawHome_FallsBackToUserDatabase pins osHome's final fallback
// to the OS user database when HOME and USERPROFILE are both unset,
// matching Node's os.homedir() (which uses getpwuid). Cwd-independent
// and user-bound, so it does not conflict with the "no cwd fallback"
// rule documented on osHome.
func TestOpenClawHome_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home from user.Current)", got, u.HomeDir)
}
}
// TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback pins that
// a tilde-form OPENCLAW_HOME ("~/custom") expands against the
// user-database fallback when HOME and USERPROFILE are both unset.
// Without the user.Current() step in osHome this would have failed
// (returning "") and dropped the bind back to the audit's
// "path must be absolute" error.
func TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "~/custom")
got := openClawHome()
want := filepath.Join(u.HomeDir, "custom")
if got != want {
t.Errorf("openClawHome() = %q, want %q", got, want)
}
}
// TestExpandTildePath covers the full input grid for expandTildePath:
// bare tilde, tilde-slash, tilde + suffix, nested suffix, plain absolute
// and relative literals, and the intentionally-unchanged forms (~user,
// ~foo) that OpenClaw does not expand either.
func TestExpandTildePath(t *testing.T) {
fakeHome := t.TempDir()
absFixture := filepath.Join(fakeHome, "abs.json")
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"bare tilde", "~", fakeHome},
{"tilde slash", "~/", fakeHome},
{"tilde with file", "~/secret.json", filepath.Join(fakeHome, "secret.json")},
{"tilde with nested path", "~/.openclaw/secret.json", filepath.Join(fakeHome, ".openclaw/secret.json")},
{"absolute unchanged", absFixture, absFixture},
{"relative unchanged", "foo/bar", "foo/bar"},
{"dot relative unchanged", "../foo", "../foo"},
{"tilde user form unchanged", "~root/foo", "~root/foo"},
{"tilde without separator unchanged", "~foo", "~foo"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := expandTildePath(tc.in)
if got != tc.want {
t.Errorf("expandTildePath(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestExpandTildePath_RespectsOpenClawHome verifies that with
// OPENCLAW_HOME set, tilde expansion uses that custom home rather than
// the OS home — the integration-level invariant that closes the
// internal inconsistency CodeX's first review flagged.
func TestExpandTildePath_RespectsOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
clawHome := t.TempDir()
setFakeOSHome(t, homeDir)
t.Setenv("OPENCLAW_HOME", clawHome)
got := expandTildePath("~/secret.json")
want := filepath.Join(clawHome, "secret.json")
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (should use OPENCLAW_HOME)", "~/secret.json", got, want)
}
if got == filepath.Join(homeDir, "secret.json") {
t.Errorf("expandTildePath unexpectedly used OS home %q instead of OPENCLAW_HOME %q", homeDir, clawHome)
}
}
// TestExpandTildePath_FallsBackToUserDatabase is the end-to-end
// equivalent of TestOpenClawHome_FallsBackToUserDatabase: with HOME and
// USERPROFILE both unset, expandTildePath still resolves `~/foo` via
// osHome's user.Current() step. Matches Node os.homedir() and keeps
// OpenClaw-authored configs working in minimal-env shells.
func TestExpandTildePath_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath("~/foo")
want := filepath.Join(u.HomeDir, "foo")
if got != want {
t.Errorf("expandTildePath(~/foo) = %q, want %q", got, want)
}
}
// TestOpenClawHome_OSHomeNormalization pins OpenClaw's sentinel
// normalisation on the env chain: the literals "undefined" / "null" /
// blank-or-whitespace are all treated as unset, so a JS-flavoured
// accidentally-stringified env value (e.g. `HOME=undefined` from a
// shell wrapper) doesn't end up as a literal directory component when
// the user authored `~/secret`. Combined with the user.Current()
// fallback further down (see TestOpenClawHome_FallsBackToUserDatabase),
// the contract is: a malformed HOME falls through to USERPROFILE first,
// and only if that's also unset/sentinel do we go to the user database.
func TestOpenClawHome_OSHomeNormalization(t *testing.T) {
isolateRuntimeWrites(t)
userProfileDir := t.TempDir()
homeWinsDir := t.TempDir()
tests := []struct {
name string
home string
userProfile string
want string
}{
{"HOME=undefined falls through to USERPROFILE", "undefined", userProfileDir, userProfileDir},
{"HOME=null falls through to USERPROFILE", "null", userProfileDir, userProfileDir},
{"HOME=whitespace falls through to USERPROFILE", " ", userProfileDir, userProfileDir},
{"HOME wins over USERPROFILE when both are valid", homeWinsDir, userProfileDir, homeWinsDir},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", tc.home)
t.Setenv("USERPROFILE", tc.userProfile)
t.Setenv("OPENCLAW_HOME", "")
if got := openClawHome(); got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd pins the
// deliberate hybrid documented on osHome: with HOME a sentinel literal
// and USERPROFILE unset, OpenClaw would fall back to process.cwd();
// this implementation falls to the OS user database instead. The
// account home is both safer (cwd-independent) and more useful (it is
// where the user originally authored `~/...` against), so we prefer it
// over either OpenClaw's cwd fallback or a strict reject.
func TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd(t *testing.T) {
isolateRuntimeWrites(t)
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
t.Setenv("HOME", "undefined")
t.Setenv("USERPROFILE", "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home, not cwd)", got, u.HomeDir)
}
}
// TestExpandTildePath_BackslashPreservedOnPOSIX pins that `~\secret.json`
// expands by replacing only the `~` byte, leaving the backslash literally
// as part of the filename — matching OpenClaw's regex-replace semantics
// (`/^~(?=$|[\\/])/`) rather than going through filepath.Join (which would
// drop the backslash on POSIX). On Windows backslash is a real separator,
// so the literal-byte invariant doesn't apply.
func TestExpandTildePath_BackslashPreservedOnPOSIX(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("backslash is a path separator on Windows; invariant only applies on POSIX")
}
fakeHome := t.TempDir()
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath(`~\secret.json`)
want := fakeHome + `\secret.json`
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (backslash should be preserved as filename byte)", `~\secret.json`, got, want)
}
}

View File

@@ -255,11 +255,18 @@ func doSyncFetch() {
// --- background refresh ---
var refreshOnce sync.Once
var (
refreshOnce sync.Once
bgRefreshInFlight sync.WaitGroup // tracks doBackgroundRefresh goroutines for test teardown (resetInit)
)
func triggerBackgroundRefresh() {
refreshOnce.Do(func() {
go doBackgroundRefresh()
bgRefreshInFlight.Add(1)
go func() {
defer bgRefreshInFlight.Done()
doBackgroundRefresh()
}()
})
}

View File

@@ -17,8 +17,18 @@ import (
"github.com/larksuite/cli/internal/core"
)
// waitBackgroundRefresh blocks until any in-flight background refresh started by
// triggerBackgroundRefresh has finished. Lives in this _test file so production
// binaries cannot call it and accidentally block on test teardown state.
func waitBackgroundRefresh() {
bgRefreshInFlight.Wait()
}
// resetInit resets the package-level state so each test starts fresh.
func resetInit() {
// Must wait: a prior test's Init() may have started doBackgroundRefresh which
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
waitBackgroundRefresh()
initOnce = sync.Once{}
mergedServices = make(map[string]map[string]interface{})
mergedProjectList = nil

View File

@@ -17,6 +17,13 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
// execLookPath is the LookPath implementation used by VerifyBinary.
// It defaults to the standard library exec.LookPath but is swapped in tests
// via lookPathMock to provide controlled binary resolution.
//
// Tests that mutate execLookPath must not call t.Parallel().
var execLookPath = exec.LookPath
// InstallMethod describes how the CLI was installed.
type InstallMethod int
@@ -186,13 +193,13 @@ func (u *Updater) VerifyBinary(expectedVersion string) error {
if u.VerifyOverride != nil {
return u.VerifyOverride(expectedVersion)
}
// Prefer the current executable path (what the user actually launched).
// Use Executable() directly without EvalSymlinks — after npm install the
// symlink target may have changed, but the path itself is still valid for
// execution. Fall back to LookPath only if Executable() fails entirely.
exe, err := vfs.Executable()
// Prefer PATH resolution so npm global bin symlinks pick up the newly
// installed binary (#836). If `lark-cli` is not on PATH (e.g. the user
// invoked this process by absolute path), fall back to the running
// executable — same as the pre-#836 secondary resolution path.
exe, err := execLookPath("lark-cli")
if err != nil {
exe, err = exec.LookPath("lark-cli")
exe, err = vfs.Executable()
if err != nil {
return fmt.Errorf("cannot locate binary: %w", err)
}

View File

@@ -4,6 +4,7 @@
package selfupdate
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
// executableTestFS mocks vfs for tests that still need vfs.Executable.
type executableTestFS struct {
vfs.OsFs
exe string
@@ -19,6 +21,28 @@ type executableTestFS struct {
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
// lookPathMock patches execLookPath within VerifyBinary for controlled testing.
// Do not use t.Parallel() in tests that install this mock — it mutates a package-level var.
type lookPathMock struct {
oldLookPath func(string) (string, error)
result string
resultErr error
}
func (m *lookPathMock) install(bin string) {
m.oldLookPath = execLookPath
execLookPath = func(name string) (string, error) {
if name == bin {
return m.result, m.resultErr
}
return m.oldLookPath(name)
}
}
func (m *lookPathMock) restore() {
execLookPath = m.oldLookPath
}
func TestResolveExe(t *testing.T) {
u := New()
p, err := u.resolveExe()
@@ -44,46 +68,101 @@ func TestCleanupStaleFiles_NoPanic(t *testing.T) {
u.CleanupStaleFiles()
}
func TestVerifyBinaryChecksVersion(t *testing.T) {
func TestVerifyBinaryLookPath(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
exe := filepath.Join(dir, "lark-cli")
// Script prints version string matching real CLI format when --version is passed.
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(exe, []byte(script), 0755); err != nil {
bin := filepath.Join(dir, "lark-cli")
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
t.Fatalf("write test binary: %v", err)
}
// Mock vfs.Executable to return our test script, matching VerifyBinary's
// primary lookup path. Also prepend to PATH for the LookPath fallback.
origFS := vfs.DefaultFS
vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe}
t.Cleanup(func() { vfs.DefaultFS = origFS })
mock := &lookPathMock{result: bin}
mock.install("lark-cli")
t.Cleanup(mock.restore)
origPath := os.Getenv("PATH")
t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
// Matching version → success.
if err := New().VerifyBinary("2.0.0"); err != nil {
t.Fatalf("VerifyBinary(matching) error = %v, want nil", err)
if err := New().VerifyBinary("2.1.0"); err != nil {
t.Fatalf("VerifyBinary(2.1.0) error = %v, want nil", err)
}
// Mismatched version → error.
if err := New().VerifyBinary("3.0.0"); err == nil {
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
}
// Substring of actual version must not match (e.g. "0.0" is in "2.0.0").
// Regression: version must match exactly (not substring / prefix).
if err := New().VerifyBinary("0.0"); err == nil {
t.Fatal("VerifyBinary(substring) expected error, got nil")
t.Fatal("VerifyBinary(substring-style mismatch) expected error, got nil")
}
// Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0").
// Binary reports "2.0.0", asking for "12.0.0" must fail.
if err := New().VerifyBinary("12.0.0"); err == nil {
t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil")
if err := New().VerifyBinary("12.1.0"); err == nil {
t.Fatal("VerifyBinary(prefix-style mismatch) expected error, got nil")
}
}
func TestVerifyBinaryLookPathNotFound(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not found")}
mock.install("lark-cli")
t.Cleanup(mock.restore)
oldFS := vfs.DefaultFS
t.Cleanup(func() { vfs.DefaultFS = oldFS })
// Without this, VerifyBinary would fall back to the real test binary, which
// is not a lark-cli --version implementation.
vfs.DefaultFS = executableTestFS{exe: filepath.Join(t.TempDir(), "missing-lark-cli")}
if err := New().VerifyBinary("2.0.0"); err == nil {
t.Fatal("VerifyBinary(not-found) expected error, got nil")
}
}
func TestVerifyBinaryFallbackExecutableWhenNotOnPath(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
bin := filepath.Join(dir, "lark-cli-abs")
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write test binary: %v", err)
}
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not on PATH")}
mock.install("lark-cli")
t.Cleanup(mock.restore)
oldFS := vfs.DefaultFS
t.Cleanup(func() { vfs.DefaultFS = oldFS })
vfs.DefaultFS = executableTestFS{exe: bin}
if err := New().VerifyBinary("2.1.0"); err != nil {
t.Fatalf("VerifyBinary(fallback executable) error = %v, want nil", err)
}
}
func TestVerifyBinaryEmptyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
bin := filepath.Join(dir, "lark-cli")
script := "#!/bin/sh\necho\nexit 0\n"
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
t.Fatalf("write test binary: %v", err)
}
mock := &lookPathMock{result: bin}
mock.install("lark-cli")
t.Cleanup(mock.restore)
if err := New().VerifyBinary("2.0.0"); err == nil {
t.Fatal("VerifyBinary(empty output) expected error, got nil")
}
}

View File

@@ -4,9 +4,9 @@
package skillscheck
// Init runs the synchronous skills version check. Stores a StaleNotice
// when the local stamp does not match currentVersion. Safe to call
// from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local stamp file read.
// when the local stamp records a version that does not match
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
// zero network, zero subprocess — only a local stamp file read.
//
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
@@ -15,10 +15,12 @@ package skillscheck
// - shouldSkip rule met
// - ReadStamp returns an I/O error other than ENOENT
// - Stamp matches currentVersion (in-sync)
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
// opt into drift tracking; npx-only installs are intentionally silent.
func Init(currentVersion string) {
// Clear any stale notice from a prior call so early returns below
// (skip rules / read errors / in-sync) leave pending == nil instead
// of preserving a stale value from a previous Init invocation.
// (skip rules / read errors / cold start / in-sync) leave pending == nil
// instead of preserving a stale value from a previous Init invocation.
SetPending(nil)
if shouldSkip(currentVersion) {
return
@@ -28,11 +30,19 @@ func Init(currentVersion string) {
// Fail closed — don't nag for a transient FS problem.
return
}
if stamp == "" {
// Cold start: the stamp is written exclusively by `lark-cli update`
// (runSkillsAndStamp). Users who installed skills via
// `npx skills add larksuite/cli -g` have no stamp yet — they must
// not be nagged with "skills not installed", since the on-disk
// skills directory may already be fully populated.
return
}
if stamp == currentVersion {
return
}
SetPending(&StaleNotice{
Current: stamp, // "" when never synced
Current: stamp, // guaranteed non-empty under the new contract
Target: currentVersion,
})
}

View File

@@ -29,17 +29,13 @@ func TestInit_InSync_NoNotice(t *testing.T) {
}
}
func TestInit_ColdStart_NoticeWithEmptyCurrent(t *testing.T) {
func TestInit_ColdStart_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
Init("1.0.21")
got := GetPending()
if got == nil {
t.Fatal("GetPending() = nil, want non-nil for cold start")
}
if got.Current != "" || got.Target != "1.0.21" {
t.Errorf("notice = %+v, want {Current:\"\", Target:\"1.0.21\"}", got)
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (cold start is silent)", got)
}
}

View File

@@ -15,20 +15,20 @@ import (
// StaleNotice signals that the locally synced skills version does not
// match the running binary. Current is the last successfully synced
// version (or "" when never synced); Target is the running binary
// version. Mirrors internal/update.UpdateInfo's pending-notice pattern.
// version (always non-empty — Init no longer emits a notice on cold
// start). Target is the running binary version. Mirrors
// internal/update.UpdateInfo's pending-notice pattern.
type StaleNotice struct {
Current string `json:"current"`
Target string `json:"target"`
}
// Message returns a single-line, AI-agent-parseable description of the
// gap plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix).
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
// non-empty because Init only emits a StaleNotice for the drift case
// (stamp present and != binary version).
func (s *StaleNotice) Message() string {
if s.Current == "" {
return "lark-cli skills not installed, run: lark-cli update"
}
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
s.Current, s.Target,

View File

@@ -14,11 +14,6 @@ func TestStaleNotice_Message(t *testing.T) {
n StaleNotice
want string
}{
{
"cold_start",
StaleNotice{Current: "", Target: "1.0.21"},
"lark-cli skills not installed, run: lark-cli update",
},
{
"drift",
StaleNotice{Current: "1.0.20", Target: "1.0.21"},

View File

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

66
scripts/check-doc-tokens.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
#
# check-doc-tokens.sh
#
# Scans skill reference docs for token-like values that look realistic but
# are not using the required placeholder format (*_EXAMPLE_TOKEN or similar).
#
# Real token patterns (Lark API) often look like:
# wikcnXXXXXXXXX doccnXXXXXXX shtcnXXX fldcnXXX ou_XXXX cli_XXXX
#
# Docs MUST use clearly fake placeholders, e.g.:
# wikcn_EXAMPLE_TOKEN doccn_EXAMPLE_TOKEN <space_id> your_token_here
#
# If this check fails, replace the realistic-looking value with a placeholder
# like `wikcn_EXAMPLE_TOKEN` so gitleaks CI won't flag it as a real secret.
set -euo pipefail
SKILLS_DIR="${1:-skills}"
ERRORS=0
# Patterns that indicate a realistic-looking Lark token value.
# Three forms are detected:
# 1. JSON-style quoted strings: "field": "token_value"
# 2. Markdown backtick spans: `token_value`
# 3. Bare tokens: --flag wikcnABC123 (e.g. inside fenced code blocks)
#
# Token prefixes used by Lark Open Platform:
# wikcn doccn docx shtcn bascn fldcn vewcn tbln ou_ cli_ obcn flec
#
# Excluded (clearly fake, matched by PLACEHOLDER_RE below):
# - Values containing EXAMPLE / _TOKEN / XXXX / your_ / _here
# - Angle-bracket placeholders <your_token>
# Require at least one digit in the suffix — real API tokens are always alphanumeric
# with digits. Pure-letter suffixes (e.g. ou_manager, ou_director) are clearly fake names.
PREFIXES='(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)'
TOKEN_BODY="${PREFIXES}"'[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}'
REALISTIC_TOKEN_RE="\"${TOKEN_BODY}\"|\`${TOKEN_BODY}\`|\\b${TOKEN_BODY}\\b"
PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)'
while IFS= read -r -d '' file; do
# grep returns exit 1 when no match — use || true to avoid set -e killing us
# Then filter out values that are clearly placeholders (EXAMPLE, XXXX, etc.)
matches=$(grep -nEo "$REALISTIC_TOKEN_RE" "$file" 2>/dev/null | grep -vE "$PLACEHOLDER_RE" || true)
if [[ -n "$matches" ]]; then
echo ""
echo "$file"
echo " Contains realistic-looking token values that may trigger gitleaks:"
while IFS= read -r line; do
echo " $line"
done <<< "$matches"
echo " → Replace with a placeholder, e.g.: wikcn_EXAMPLE_TOKEN, doccn_EXAMPLE_TOKEN"
ERRORS=$((ERRORS + 1))
fi
done < <(find "$SKILLS_DIR" -path "*/references/*.md" -print0)
if [[ $ERRORS -gt 0 ]]; then
echo ""
echo "❌ check-doc-tokens: $ERRORS file(s) contain realistic token values in reference docs."
echo " Use _EXAMPLE_TOKEN placeholders to avoid false positives in gitleaks CI."
exit 1
else
echo "✅ check-doc-tokens: all reference docs use safe placeholder tokens."
fi

View File

@@ -44,6 +44,7 @@ const messages = {
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
nonTtyHint: "要完成配置,请在终端中运行:\n lark-cli config init --new\n lark-cli auth login",
},
en: {
setup: "Setting up Feishu/Lark CLI...",
@@ -72,6 +73,7 @@ const messages = {
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
cancelled: "Installation cancelled",
nonTtyHint: "To complete setup, run interactively:\n lark-cli config init --new\n lark-cli auth login",
},
};
@@ -353,17 +355,23 @@ async function stepAuthLogin(msg) {
// ---------------------------------------------------------------------------
async function main() {
const lang = await stepSelectLang();
const isInteractive = !!process.stdin.isTTY;
const lang = isInteractive ? await stepSelectLang() : (parseLangArg() || "en");
const msg = messages[lang];
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
if (isInteractive) {
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
} else {
console.log(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
console.log(msg.nonTtyHint);
}
}
main().catch((err) => {

View File

@@ -33,9 +33,18 @@ type Shortcut struct {
Command string
Description string
Risk string // "read" | "write" | "high-risk-write" (empty defaults to "read")
Scopes []string // default scopes (fallback when UserScopes/BotScopes are empty)
UserScopes []string // optional: user-identity scopes (overrides Scopes when non-empty)
BotScopes []string // optional: bot-identity scopes (overrides Scopes when non-empty)
Scopes []string // unconditional pre-flight scopes (fallback when UserScopes/BotScopes are empty)
UserScopes []string // optional: user-identity unconditional scopes (overrides Scopes when non-empty)
BotScopes []string // optional: bot-identity unconditional scopes (overrides Scopes when non-empty)
// ConditionalScopes are additional scopes that only some execution paths
// need (for example a default mode vs. a lighter --quick mode, or a
// destructive flag like --delete-remote). They are surfaced in metadata,
// auth hints, and scope-diagnosis output via DeclaredScopesForIdentity, but
// they are NOT enforced by the framework's unconditional pre-flight check.
ConditionalScopes []string // fallback when ConditionalUserScopes/BotScopes are empty
ConditionalUserScopes []string // optional: user-identity conditional scopes
ConditionalBotScopes []string // optional: bot-identity conditional scopes
// Declarative fields (new framework).
AuthTypes []string // supported identities: "user", "bot" (default: ["user"])
@@ -72,3 +81,47 @@ func (s *Shortcut) ScopesForIdentity(identity string) []string {
}
return s.Scopes
}
// ConditionalScopesForIdentity returns additional flag/path-dependent scopes
// for the given identity. Identity-specific conditional scopes override the
// default ConditionalScopes when present.
func (s *Shortcut) ConditionalScopesForIdentity(identity string) []string {
switch identity {
case "user":
if len(s.ConditionalUserScopes) > 0 {
return s.ConditionalUserScopes
}
case "bot":
if len(s.ConditionalBotScopes) > 0 {
return s.ConditionalBotScopes
}
}
return s.ConditionalScopes
}
// DeclaredScopesForIdentity returns the full scope set agents/help/diagnostics
// should know about for this shortcut: unconditional pre-flight scopes plus
// any conditional scopes that some execution paths may require.
func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
base := s.ScopesForIdentity(identity)
extra := s.ConditionalScopesForIdentity(identity)
if len(base) == 0 && len(extra) == 0 {
return nil
}
out := make([]string, 0, len(base)+len(extra))
seen := make(map[string]struct{}, len(base)+len(extra))
for _, scope := range append(base, extra...) {
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
out = append(out, scope)
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -71,3 +71,37 @@ func TestScopesForIdentity_NilScopes(t *testing.T) {
t.Errorf("expected nil, got %v", got)
}
}
func TestConditionalScopesForIdentity_FallbackAndOverrides(t *testing.T) {
s := Shortcut{
ConditionalScopes: []string{"c-default"},
ConditionalUserScopes: []string{"c-user"},
ConditionalBotScopes: []string{"c-bot"},
}
if got := s.ConditionalScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"c-user"}) {
t.Errorf("expected user conditional scopes, got %v", got)
}
if got := s.ConditionalScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"c-bot"}) {
t.Errorf("expected bot conditional scopes, got %v", got)
}
if got := s.ConditionalScopesForIdentity("tenant"); !reflect.DeepEqual(got, []string{"c-default"}) {
t.Errorf("expected default conditional scopes for unknown identity, got %v", got)
}
}
func TestDeclaredScopesForIdentity_MergesAndDeduplicates(t *testing.T) {
s := Shortcut{
Scopes: []string{"base-a", "shared"},
ConditionalScopes: []string{"shared", "cond-b"},
}
if got := s.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"base-a", "shared", "cond-b"}) {
t.Errorf("expected merged declared scopes, got %v", got)
}
}
func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
s := Shortcut{ConditionalScopes: []string{"cond-only"}}
if got := s.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"cond-only"}) {
t.Errorf("expected conditional-only declared scopes, got %v", got)
}
}

View File

@@ -67,6 +67,7 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
if v := runtime.Str("parent-position"); v != "" {
body["parent_position"] = v
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -109,6 +109,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
if ro := buildReadOption(runtime); ro != nil {
body["read_option"] = ro
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
runtime := newFetchBodyTestRuntime(ctx)
body := buildFetchBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newCreateBodyTestRuntime(ctx)
body := buildCreateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newUpdateBodyTestRuntime(ctx)
body := buildUpdateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
body := buildFetchBody(runtime)
if _, ok := body["scene"]; ok {
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
}
}
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+create"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("content", "<title>hello</title>", "")
cmd.Flags().String("parent-token", "", "")
cmd.Flags().String("parent-position", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+update"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", 0, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}

View File

@@ -162,5 +162,6 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
if v := runtime.Str("src-block-ids"); v != "" {
body["src_block_ids"] = v
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -4,6 +4,7 @@
package doc
import (
"context"
"encoding/json"
"strings"
@@ -11,6 +12,10 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
// scene without exposing it as a user-controlled CLI flag.
const docsSceneContextKey = "lark_cli_docs_scene"
type documentRef struct {
Kind string
Token string
@@ -65,6 +70,20 @@ func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body inter
return runtime.DoAPIJSONWithLogID(method, apiPath, nil, body)
}
func docsSceneFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
scene, _ := ctx.Value(docsSceneContextKey).(string)
return strings.TrimSpace(scene)
}
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
body["scene"] = scene
}
}
func buildDriveRouteExtra(docID string) (string, error) {
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
if err != nil {

View File

@@ -18,8 +18,19 @@ const docsServiceHelpDefault = `Document and content operations.`
const docsServiceHelpV2 = `Document and content operations (v2).`
var docsVersionSelectionTips = []string{
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
"Docs v1 is deprecated and will be removed soon. Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
"After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.",
}
var docsV2VersionSelectionTips = []string{
"Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
}
func docsTipsForVersion(apiVersion string) []string {
if apiVersion == "v2" {
return docsV2VersionSelectionTips
}
return docsVersionSelectionTips
}
// Shortcuts returns all docs shortcuts.
@@ -38,8 +49,7 @@ func Shortcuts() []common.Shortcut {
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
// The shortcut-level help remains compatible with legacy v1 skills; this parent
// help gives agents enough context to choose v2 only when their installed skill
// explicitly asks for `--api-version v2`.
// help switches docs guidance to match the selected API version.
func ConfigureServiceHelp(cmd *cobra.Command) {
if cmd == nil {
return
@@ -75,7 +85,7 @@ func ConfigureServiceHelp(cmd *cobra.Command) {
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range docsVersionSelectionTips {
for _, tip := range docsTipsForVersion(apiVersion) {
fmt.Fprintf(out, " • %s\n", tip)
}
})

View File

@@ -6,6 +6,7 @@ package doc
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -29,6 +30,7 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
f.Hidden = fv != ver
}
})
cmdutil.SetTips(cmd, docsTipsForVersion(ver))
origHelp(cmd, args)
})
}
@@ -37,6 +39,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
// path is used.
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
fmt.Fprintf(runtime.IO().ErrOut,
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n",
shortcut)
"[deprecated] docs %s is using the v1 API. %s\n",
shortcut, docsV2VersionSelectionTips[0])
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
func TestWarnDeprecatedV1SuggestsSkillUpdate(t *testing.T) {
for _, shortcut := range []string{"+create", "+fetch", "+update"} {
t.Run(shortcut, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
warnDeprecatedV1(&common.RuntimeContext{Factory: f}, shortcut)
got := stderr.String()
for _, want := range []string{
"[deprecated] docs " + shortcut + " is using the v1 API.",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
} {
if !strings.Contains(got, want) {
t.Fatalf("warning missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "will be removed in a future release") {
t.Fatalf("warning should not include removal-only guidance:\n%s", got)
}
})
}
}

View File

@@ -0,0 +1,994 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
const (
duplicateRemoteFileIDFirst = "example-file-token-first"
duplicateRemoteFileIDSecond = "example-file-token-second"
duplicateRemoteFileIDThird = "example-file-token-third"
duplicateRemoteFolderID = "example-folder-token"
)
func TestDriveStatusFailsOnDuplicateRemoteFiles(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)
}
registerDuplicateRemoteFiles(reg)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePullFailsOnDuplicateRemoteFilesBeforeWriting(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)
}
registerDuplicateRemoteFiles(reg)
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
if _, statErr := os.Stat(filepath.Join("local", "dup.txt")); !os.IsNotExist(statErr) {
t.Fatalf("duplicate default failure must not write local dup.txt; stat err=%v", statErr)
}
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePullRenameDownloadsDuplicateRemoteFilesWithStableHashSuffix(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)
}
registerDuplicateRemoteFiles(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst + "/download",
Status: 200,
Body: []byte("FIRST"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond + "/download",
Status: 200,
Body: []byte("SECOND"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
if strings.Contains(renamedRelPath, duplicateRemoteFileIDSecond) {
t.Fatalf("renamed rel_path should not expose raw file token: %s", renamedRelPath)
}
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 2 {
t.Fatalf("summary.downloaded = %d, want 2", got)
}
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
}
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
reg.Verify(t)
}
func TestDrivePullRenameStrengthensSuffixWhenShortHashTargetAlreadyExists(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)
}
shortHashRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
{"token": duplicateRemoteFileIDThird, "name": shortHashRelPath, "type": "file", "size": 7, "created_time": "3", "modified_time": "3"},
})
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
registerDownload(reg, duplicateRemoteFileIDThird, "THIRD")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
occupied := occupiedRemotePaths([]driveRemoteEntry{
{RelPath: "dup.txt"},
{RelPath: "dup.txt"},
{RelPath: shortHashRelPath},
})
strongerRelPath, err := relPathWithUniqueFileTokenSuffix("dup.txt", duplicateRemoteFileIDSecond, occupied)
if err != nil {
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
}
if strongerRelPath == shortHashRelPath {
t.Fatalf("expected stronger unique suffix when %q is already occupied", shortHashRelPath)
}
mustReadFile(t, filepath.Join("local", shortHashRelPath), "THIRD")
mustReadFile(t, filepath.Join("local", strongerRelPath), "SECOND")
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 3 {
t.Fatalf("summary.downloaded = %d, want 3", got)
}
if item := findPullItem(payload.Data.Items, strongerRelPath); item.SourceID == "" || item.FileToken != "" {
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
}
assertPullItemAction(t, stdout.Bytes(), strongerRelPath, "downloaded")
reg.Verify(t)
}
func TestDrivePullRenameAppendsSequenceWhenAllHashSuffixTargetsAreOccupied(t *testing.T) {
fileToken := duplicateRemoteFileIDSecond
tokenHash := stableTokenHash(fileToken)
occupied := map[string]struct{}{
"dup.txt": {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:12]): {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:24]): {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash): {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_2"): {},
}
got, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
if err != nil {
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
}
want := relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_3")
if got != want {
t.Fatalf("unique rel_path = %q, want %q", got, want)
}
}
func TestRelPathWithUniqueFileTokenSuffixReturnsErrorAfterMaxAttempts(t *testing.T) {
fileToken := duplicateRemoteFileIDSecond
tokenHash := stableTokenHash(fileToken)
occupied := map[string]struct{}{
"dup.txt": {},
}
for _, suffix := range []string{
"__lark_" + tokenHash[:12],
"__lark_" + tokenHash[:24],
"__lark_" + tokenHash,
} {
occupied[relPathWithSuffix("dup.txt", suffix)] = struct{}{}
}
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
occupied[relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))] = struct{}{}
}
_, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
if err == nil {
t.Fatal("expected relPathWithUniqueFileTokenSuffix to fail after exhausting all suffix attempts")
}
}
func TestDrivePullNewestChoosesMostRecentDuplicateRemoteFile(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)
}
registerDuplicateRemoteFiles(reg)
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "newest",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
mustReadFile(t, filepath.Join("local", "dup.txt"), "SECOND")
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 1 {
t.Fatalf("summary.downloaded = %d, want 1", got)
}
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDSecond {
t.Fatalf("stdout should surface the chosen newest file token, got: %#v", item)
}
reg.Verify(t)
}
func TestDrivePullOldestChoosesOldestDuplicateRemoteFile(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)
}
registerDuplicateRemoteFiles(reg)
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "oldest",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 1 {
t.Fatalf("summary.downloaded = %d, want 1", got)
}
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDFirst {
t.Fatalf("stdout should surface the chosen oldest file token, got: %#v", item)
}
reg.Verify(t)
}
func TestDrivePullRenameHandlesNestedDuplicateRemoteFilesEndToEnd(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)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
})
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
renamedRelPath := expectedRenamedRelPath("sub/dup.txt", duplicateRemoteFileIDSecond, 12, 0)
mustReadFile(t, filepath.Join("local", "sub", "dup.txt"), "FIRST")
mustReadFile(t, filepath.Join("local", filepath.FromSlash(renamedRelPath)), "SECOND")
assertPullItemAction(t, stdout.Bytes(), "sub/dup.txt", "downloaded")
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
reg.Verify(t)
}
func TestDrivePushFailsOnDuplicateRemoteFilesBeforeUpload(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", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePullFailsOnRemoteFileFolderConflictEvenWithRename(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)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePushFailsOnRemoteFileFolderConflictEvenWithNewest(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", "dup"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "newest",
"--if-exists", "skip",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePushDeleteRemoteDeletesUnchosenDuplicateSibling(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", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "skip",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
reg.Verify(t)
}
func TestDrivePushOldestOverwritesChosenDuplicateAndDeletesSibling(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", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "dup-oldest-new-token",
"version": "v11",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "oldest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDFirst {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDFirst)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for the newer duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestDrivePushNewestResolvesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "sub", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "nested-dup-new-token",
"version": "v7",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
}
assertPushItemAction(t, stdout.Bytes(), "sub/dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for nested duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) {
files := []driveRemoteEntry{
{FileToken: "token_b", CreatedTime: "9", ModifiedTime: "9"},
{FileToken: "token_a", CreatedTime: "10", ModifiedTime: "10"},
}
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
if err != nil {
t.Fatalf("chooseRemoteFile newest: %v", err)
}
if gotNewest.FileToken != "token_a" {
t.Fatalf("newest token = %q, want token_a", gotNewest.FileToken)
}
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
if err != nil {
t.Fatalf("chooseRemoteFile oldest: %v", err)
}
if gotOldest.FileToken != "token_b" {
t.Fatalf("oldest token = %q, want token_b", gotOldest.FileToken)
}
}
// TestChooseRemoteFileSortsMixedUnitEpochsByActualTime verifies duplicate
// resolution compares actual timestamps rather than raw integer magnitudes when
// Drive mixes second- and millisecond-resolution epoch strings.
func TestChooseRemoteFileSortsMixedUnitEpochsByActualTime(t *testing.T) {
files := []driveRemoteEntry{
{FileToken: "token_seconds", CreatedTime: "1715594881", ModifiedTime: "1715594881"},
{FileToken: "token_millis", CreatedTime: "1715594880123", ModifiedTime: "1715594880123"},
}
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
if err != nil {
t.Fatalf("chooseRemoteFile newest: %v", err)
}
if gotNewest.FileToken != "token_seconds" {
t.Fatalf("newest token = %q, want token_seconds", gotNewest.FileToken)
}
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
if err != nil {
t.Fatalf("chooseRemoteFile oldest: %v", err)
}
if gotOldest.FileToken != "token_millis" {
t.Fatalf("oldest token = %q, want token_millis", gotOldest.FileToken)
}
}
// TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits
// proves the duplicate selector and delete pass agree on the true newest file
// even when remote timestamps use mixed epoch units.
func TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits(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", "dup.txt"), []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1715594880123", "modified_time": "1715594880123"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "1715594881", "modified_time": "1715594881"},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "dup-new-token",
"version": "v7",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for older mixed-unit duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) {
files := []driveRemoteEntry{
{FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"},
{FileToken: "token_b", CreatedTime: "10", ModifiedTime: "10"},
}
got, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
if err != nil {
t.Fatalf("chooseRemoteFile: %v", err)
}
if got.FileToken != "token_a" {
t.Fatalf("fallback token = %q, want token_a", got.FileToken)
}
}
func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) {
_, err := chooseRemoteFile(nil, driveDuplicateRemoteNewest)
if err == nil {
t.Fatal("expected chooseRemoteFile to reject empty candidates")
}
}
func TestCompareDriveRemoteModifiedToLocalSupportsSecondAndMillisecondEpochs(t *testing.T) {
t.Run("second resolution truncates local mtime", func(t *testing.T) {
cmp, ok := compareDriveRemoteModifiedToLocal("100", time.Unix(100, 900*int64(time.Millisecond)))
if !ok {
t.Fatal("expected second-resolution timestamp to parse")
}
if cmp != 0 {
t.Fatalf("cmp = %d, want 0 when local only differs below second resolution", cmp)
}
})
t.Run("millisecond resolution stays precise", func(t *testing.T) {
const remoteMillis = int64(1715594880123)
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMillis, 10), time.UnixMilli(remoteMillis))
if !ok {
t.Fatal("expected millisecond-resolution timestamp to parse")
}
if cmp != 0 {
t.Fatalf("cmp = %d, want 0 for equal millisecond timestamps", cmp)
}
})
t.Run("microsecond resolution stays precise", func(t *testing.T) {
const remoteMicros = int64(1715594880123456)
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMicros, 10), time.UnixMicro(remoteMicros))
if !ok {
t.Fatal("expected microsecond-resolution timestamp to parse")
}
if cmp != 0 {
t.Fatalf("cmp = %d, want 0 for equal microsecond timestamps", cmp)
}
})
t.Run("invalid timestamp is rejected", func(t *testing.T) {
if _, ok := compareDriveRemoteModifiedToLocal("not-a-time", time.Now()); ok {
t.Fatal("expected invalid remote timestamp to be rejected")
}
})
}
func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) {
_, _, err := drivePullRemoteViews([]driveRemoteEntry{
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst},
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDSecond},
}, "mystery")
if err == nil {
t.Fatal("expected drivePullRemoteViews to reject an unknown duplicate strategy")
}
}
func registerDuplicateRemoteFiles(reg *httpmock.Registry) {
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
})
}
func registerRemoteListing(reg *httpmock.Registry, folderToken string, files []map[string]interface{}) {
items := make([]interface{}, 0, len(files))
for _, file := range files {
items = append(items, file)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=" + folderToken,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": items,
"has_more": false,
},
},
})
}
func registerDownload(reg *httpmock.Registry, fileToken, body string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/" + fileToken + "/download",
Status: 200,
Body: []byte(body),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
}
func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) {
t.Helper()
if err == nil {
t.Fatal("expected duplicate_remote_path error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" {
t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail)
}
detailMap, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail)
}
duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath)
if !ok {
t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"])
}
if len(duplicates) == 0 {
t.Fatal("duplicate detail should include at least one rel_path group")
}
if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey {
t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap)
}
var matched bool
for _, duplicate := range duplicates {
if duplicate.RelPath != relPath {
continue
}
matched = true
if len(duplicate.Entries) != len(tokens) {
t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath)
}
for i, token := range tokens {
if duplicate.Entries[i].FileToken != token {
t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token)
}
if duplicate.Entries[i].Type == "" {
t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath)
}
}
}
if !matched {
t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates)
}
raw, marshalErr := json.Marshal(exitErr.Detail.Detail)
if marshalErr != nil {
t.Fatalf("marshal detail: %v", marshalErr)
}
text := string(raw)
if !strings.Contains(text, relPath) {
t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text)
}
for _, token := range tokens {
if !strings.Contains(text, token) {
t.Fatalf("duplicate detail missing token %q: %s", token, text)
}
}
}
type drivePullStdoutPayload struct {
Data struct {
Summary struct {
Downloaded int `json:"downloaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
} `json:"summary"`
Items []struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
} `json:"items"`
} `json:"data"`
}
func decodeDrivePullStdout(t *testing.T, raw []byte) drivePullStdoutPayload {
t.Helper()
var payload drivePullStdoutPayload
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
}
return payload
}
func findPullItem(items []struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
}, relPath string) struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
} {
for _, item := range items {
if item.RelPath == relPath {
return item
}
}
return struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
}{}
}
func expectedRenamedRelPath(relPath, fileToken string, hashLen, attempt int) string {
sum := sha256.Sum256([]byte(fileToken))
hash := hex.EncodeToString(sum[:])
suffix := "__lark_" + hash[:hashLen]
if attempt > 0 {
suffix = "__lark_" + hash + "_" + strconv.Itoa(attempt)
}
dir, base := path.Split(relPath)
ext := path.Ext(base)
if ext == base {
return dir + base + suffix
}
stem := base[:len(base)-len(ext)]
return dir + stem + suffix + ext
}
func assertPullItemAction(t *testing.T, raw []byte, relPath, action string) {
t.Helper()
var payload struct {
Data struct {
Items []struct {
RelPath string `json:"rel_path"`
Action string `json:"action"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
}
for _, item := range payload.Data.Items {
if item.RelPath == relPath && item.Action == action {
return
}
}
t.Fatalf("missing pull item %q/%q in stdout: %s", relPath, action, string(raw))
}
func assertPushItemAction(t *testing.T, raw []byte, relPath, action, fileToken string) {
t.Helper()
var payload struct {
Data struct {
Items []struct {
RelPath string `json:"rel_path"`
Action string `json:"action"`
FileToken string `json:"file_token"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode push stdout: %v\n%s", err, string(raw))
}
for _, item := range payload.Data.Items {
if item.RelPath == relPath && item.Action == action && item.FileToken == fileToken {
return
}
}
t.Fatalf("missing push item %q/%q/%q in stdout: %s", relPath, action, fileToken, string(raw))
}

View File

@@ -228,6 +228,206 @@ func TestDriveUploadLargeFileToWikiUsesMultipart(t *testing.T) {
}
}
func TestDriveUploadLargeFileOverwriteUsesMultipart(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-large-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(2),
},
},
}
reg.Register(prepareStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_multipart_overwrite_token",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "large.bin",
"--file-token", "box_existing_large_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
}
body := decodeCapturedJSONBody(t, prepareStub)
if got := body["file_token"]; got != "box_existing_large_upload" {
t.Fatalf("file_token = %#v, want %q", got, "box_existing_large_upload")
}
}
func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinish(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-large-overwrite-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(1),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_multipart_overwrite_version_token",
"version": "v44",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "large.bin",
"--file-token", "box_existing_large_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v44" {
t.Fatalf("data.version = %#v, want %q", got, "v44")
}
}
func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinishAlias(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-large-overwrite-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(1),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_multipart_overwrite_alias_token",
"data_version": "v45",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "large.bin",
"--file-token", "box_existing_large_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v45" {
t.Fatalf("data.version = %#v, want %q", got, "v45")
}
}
func TestDriveUploadSmallFile(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -267,6 +467,93 @@ func TestDriveUploadSmallFile(t *testing.T) {
}
}
func TestDriveUploadSmallFileOverwriteUsesFileToken(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_small_overwrite_token",
"version": "v42",
},
},
}
reg.Register(stub)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "small.bin",
"--file-token", "box_existing_small_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected small overwrite upload to succeed, got error: %v", err)
}
body := decodeDriveMultipartBody(t, stub)
if got := body.Fields["file_token"]; got != "box_existing_small_upload" {
t.Fatalf("file_token = %q, want %q", got, "box_existing_small_upload")
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v42" {
t.Fatalf("data.version = %#v, want %q", got, "v42")
}
}
func TestDriveUploadReturnsVersionFromDataVersionAlias(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_small_alias_token",
"data_version": "v43",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "small.bin",
"--file-token", "box_existing_alias_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected overwrite upload to succeed, got error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v43" {
t.Fatalf("data.version = %#v, want %q", got, "v43")
}
}
func TestDriveUploadSmallFileToWiki(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-wiki-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -767,6 +1054,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -812,6 +1100,7 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -821,6 +1110,9 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
if err := cmd.Flags().Set("folder-token", " fld_upload_target "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
if err := cmd.Flags().Set("file-token", " box_upload_target "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("wiki-token", " wikcn_upload_target "); err != nil {
t.Fatalf("set --wiki-token: %v", err)
}
@@ -839,11 +1131,108 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
if got.FolderToken != "fld_upload_target" {
t.Fatalf("FolderToken = %q, want trimmed token", got.FolderToken)
}
if got.FileToken != "box_upload_target" {
t.Fatalf("FileToken = %q, want trimmed token", got.FileToken)
}
if got.WikiToken != "wikcn_upload_target" {
t.Fatalf("WikiToken = %q, want trimmed token", got.WikiToken)
}
}
func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "./report.pdf"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveUpload.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
}
func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("as", "", "")
if err := cmd.Flags().Set("file", "./report.pdf"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("as", "bot"); err != nil {
t.Fatalf("set --as: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveUpload.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Desc string `json:"desc"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
if strings.Contains(got.API[0].Desc, "grant the current CLI user full_access") {
t.Fatalf("dry-run desc should skip permission-grant hint for overwrite, got %q", got.API[0].Desc)
}
}
func TestDriveUploadTargetLabel(t *testing.T) {
t.Parallel()
@@ -901,6 +1290,7 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -923,6 +1313,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -940,11 +1331,35 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
}
}
func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "report.pdf"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("file-token", " "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty file-token error", err)
}
}
func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -983,6 +1398,12 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) {
value: "wikcn_bad#fragment",
wantErr: "--wiki-token contains invalid characters",
},
{
name: "file token",
flag: "file-token",
value: "box_bad?query=true",
wantErr: "--file-token contains invalid characters",
},
}
for _, tt := range tests {
@@ -991,6 +1412,7 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")

View File

@@ -75,6 +75,48 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
}
}
func TestDriveUploadBotOverwriteSkipsPermissionGrant(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_uploaded",
"version": "v2",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--file-token", "file_uploaded",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant for overwrite output: %#v", data)
}
if got := data["version"]; got != "v2" {
t.Fatalf("version = %#v, want %q", got, "v2")
}
}
func TestDriveImportBotAutoGrantSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)

View File

@@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -20,18 +21,36 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var drivePullChtimes = drivePullApplyChtimes
// drivePullApplyChtimes is a tiny indirection that keeps the production path on
// os.Chtimes while still letting tests inject mtime failures without requiring a
// custom filesystem implementation.
func drivePullApplyChtimes(path string, atime, mtime time.Time) error {
return os.Chtimes(path, atime, mtime) //nolint:forbidigo // FileIO exposes no mtime mutation API yet; callers resolve and bound the path first.
}
const (
drivePullIfExistsOverwrite = "overwrite"
drivePullIfExistsSmart = "smart"
drivePullIfExistsSkip = "skip"
)
type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
type drivePullTarget struct {
DownloadToken string
ItemFileToken string
ItemSourceID string
ModifiedTime string
}
// DrivePull performs a one-way file-level mirror from a Drive folder onto
// a local directory: recursively lists --folder-token, downloads each
// type=file entry under --local-dir, and optionally deletes local files
@@ -53,13 +72,16 @@ var DrivePull = common.Shortcut{
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
{Name: "if-exists", Desc: "policy when a local file already exists (skip = never touch existing files; smart = skip when local mtime is already up to date; overwrite = always replace)", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSmart, drivePullIfExistsSkip}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
},
Tips: []string{
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
"For repeat syncs, --if-exists=smart is the recommended best-effort incremental mode: it compares local mtime with Drive modified_time and skips downloads when the local copy is already up to date.",
"Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.",
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -102,6 +124,10 @@ var DrivePull = common.Shortcut{
if ifExists == "" {
ifExists = drivePullIfExistsOverwrite
}
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
if duplicateRemote == "" {
duplicateRemote = driveDuplicateRemoteFail
}
deleteLocal := runtime.Bool("delete-local")
// Resolve --local-dir to its canonical absolute path before we
@@ -132,10 +158,13 @@ var DrivePull = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// Two views over the same listing:
// - remoteFiles drives the download/skip loop (only type=file
// has hashable bytes the local mirror can write back).
@@ -143,13 +172,9 @@ var DrivePull = common.Shortcut{
// rel_path Drive owns regardless of type, so a local file
// shadowed by a remote folder / online doc / shortcut is NOT
// treated as orphaned.
remoteFiles := make(map[string]string, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
for rel, entry := range entries {
remotePaths[rel] = struct{}{}
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
var downloaded, skipped, failed, deletedLocal int
@@ -164,7 +189,10 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
token := remoteFiles[rel]
targetFile := remoteFiles[rel]
downloadToken := targetFile.DownloadToken
itemFileToken := targetFile.ItemFileToken
itemSourceID := targetFile.ItemSourceID
target := filepath.Join(rootRelToCwd, rel)
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
@@ -178,7 +206,8 @@ var DrivePull = common.Shortcut{
if info.IsDir() {
items = append(items, drivePullItem{
RelPath: rel,
FileToken: token,
FileToken: itemFileToken,
SourceID: itemSourceID,
Action: "failed",
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
})
@@ -186,20 +215,20 @@ var DrivePull = common.Shortcut{
downloadFailed++
continue
}
if ifExists == drivePullIfExistsSkip {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
if ifExists == drivePullIfExistsSkip || drivePullShouldSkipSmart(target, targetFile, ifExists, runtime) {
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"})
skipped++
continue
}
}
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
failed++
downloadFailed++
continue
}
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
downloaded++
}
@@ -289,7 +318,9 @@ var DrivePull = common.Shortcut{
},
}
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
// drivePullDownload streams one Drive file into the local mirror target and
// then best-effort aligns the local mtime to Drive's modified_time.
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
@@ -304,9 +335,114 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err)
}
return nil
}
// drivePullApplyRemoteModifiedTime preserves Drive's modified_time on a local
// file when the remote timestamp is parseable and the target path is safe.
func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime *common.RuntimeContext) error {
remoteTime, _, ok := parseDriveEpoch(remoteModifiedTime)
if !ok {
return nil
}
resolved, err := runtime.FileIO().ResolvePath(target)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil {
return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err)
}
return nil
}
func drivePullShouldSkipSmart(target string, remoteFile drivePullTarget, ifExists string, runtime *common.RuntimeContext) bool {
if ifExists != drivePullIfExistsSmart {
return false
}
if remoteFile.ModifiedTime == "" {
return false
}
resolved, err := runtime.FileIO().ResolvePath(target)
if err != nil {
return false
}
info, err := os.Stat(resolved) //nolint:forbidigo // FileIO exposes no ModTime-capable Stat; ResolvePath already bounded the path.
if err != nil {
return false
}
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, info.ModTime())
if !ok {
return false
}
// Local is already at least as new as the remote file, so another
// download would be redundant.
return cmp <= 0
}
func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]drivePullTarget, map[string]struct{}, error) {
remoteFiles := make(map[string]drivePullTarget, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
fileGroups := make(map[string][]driveRemoteEntry)
occupied := occupiedRemotePaths(entries)
for _, entry := range entries {
if entry.Type == driveTypeFile {
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
continue
}
remotePaths[entry.RelPath] = struct{}{}
}
relPaths := make([]string, 0, len(fileGroups))
for rel := range fileGroups {
relPaths = append(relPaths, rel)
}
sort.Strings(relPaths)
for _, rel := range relPaths {
files := fileGroups[rel]
if len(files) == 1 {
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken, ModifiedTime: files[0].ModifiedTime}
remotePaths[rel] = struct{}{}
continue
}
switch duplicateRemote {
case driveDuplicateRemoteRename:
candidates := append([]driveRemoteEntry(nil), files...)
sortRemoteFiles(candidates, driveDuplicateRemoteOldest)
for idx, file := range candidates {
targetRel := rel
if idx > 0 {
var err error
targetRel, err = relPathWithUniqueFileTokenSuffix(rel, file.FileToken, occupied)
if err != nil {
return nil, nil, err
}
}
remoteFiles[targetRel] = drivePullTarget{
DownloadToken: file.FileToken,
ItemSourceID: stableTokenIdentifier(file.FileToken),
ModifiedTime: file.ModifiedTime,
}
remotePaths[targetRel] = struct{}{}
}
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
chosen, err := chooseRemoteFile(files, duplicateRemote)
if err != nil {
return nil, nil, err
}
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime}
remotePaths[rel] = struct{}{}
default:
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remotePaths, nil
}
// drivePullWalkLocal walks the canonical absolute root and returns the
// absolute paths of every regular file underneath it. The caller deletes
// some of these paths, so it is critical that they are produced by

View File

@@ -4,17 +4,23 @@
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// TestDrivePullDownloadsAndCreatesParents verifies the happy path: a remote
@@ -151,6 +157,322 @@ func TestDrivePullSkipsExistingWhenSkipPolicy(t *testing.T) {
mustReadFile(t, filepath.Join("local", "keep.txt"), "local-original")
}
// TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate verifies the
// smart fast path for Drive → local mirrors: when the local copy is already
// at least as new as the remote file, +pull skips the download.
func TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate(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)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(200, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %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_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
},
"has_more": false,
},
},
})
// Intentionally NO download stub: smart mode should skip the transfer.
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"downloaded": 0`) {
t.Errorf("expected downloaded=0, got: %s", out)
}
mustReadFile(t, localPath, "hello")
}
// TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer verifies the smart
// policy still downloads when the remote file is newer than the local copy.
func TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer(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)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %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_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_keep/download",
Status: 200,
Body: []byte("WORLD"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"downloaded": 1`) {
t.Errorf("expected downloaded=1, got: %s", out)
}
mustReadFile(t, localPath, "WORLD")
info, err := os.Stat(localPath)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got, want := info.ModTime(), time.Unix(200, 0); !got.Equal(want) {
t.Fatalf("local mtime = %v, want %v", got, want)
}
}
// TestDrivePullTreatsModifiedTimePreservationFailureAsNotice verifies a local
// write that succeeds but cannot preserve remote modified_time still reports a
// successful download and only emits an operator-facing notice on stderr.
func TestDrivePullTreatsModifiedTimePreservationFailureAsNotice(t *testing.T) {
f, stdout, stderrBuf, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
prevChtimes := drivePullChtimes
drivePullChtimes = func(string, time.Time, time.Time) error {
return fmt.Errorf("mtime mutation unsupported")
}
t.Cleanup(func() {
drivePullChtimes = prevChtimes
})
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_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_keep/download",
Status: 200,
Body: []byte("WORLD"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--delete-local",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderrBuf.String())
}
out := stdout.String()
if !strings.Contains(out, `"downloaded": 1`) {
t.Errorf("expected downloaded=1, got: %s", out)
}
if !strings.Contains(out, `"failed": 0`) {
t.Errorf("expected failed=0, got: %s", out)
}
mustReadFile(t, filepath.Join("local", "keep.txt"), "WORLD")
if !strings.Contains(stderrBuf.String(), "could not preserve remote modified_time") {
t.Errorf("expected stderr notice about modified_time preservation failure, got: %s", stderrBuf.String())
}
reg.Verify(t)
}
func TestDrivePullShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
for _, tt := range []struct {
name string
ifExists string
remoteFile drivePullTarget
}{
{
name: "non-smart policy",
ifExists: drivePullIfExistsOverwrite,
remoteFile: drivePullTarget{ModifiedTime: "100"},
},
{
name: "missing remote timestamp",
ifExists: drivePullIfExistsSmart,
remoteFile: drivePullTarget{ModifiedTime: ""},
},
{
name: "invalid remote timestamp",
ifExists: drivePullIfExistsSmart,
remoteFile: drivePullTarget{ModifiedTime: "not-a-time"},
},
} {
t.Run(tt.name, func(t *testing.T) {
if got := drivePullShouldSkipSmart(localPath, tt.remoteFile, tt.ifExists, runtime); got {
t.Fatalf("drivePullShouldSkipSmart() = true, want false for %s", tt.name)
}
})
}
}
func TestDrivePullShouldSkipSmartFallsBackWhenPathCannotBeResolved(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
if got := drivePullShouldSkipSmart("../escape.txt", drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
t.Fatal("drivePullShouldSkipSmart() = true, want false when ResolvePath rejects the target")
}
}
func TestDrivePullShouldSkipSmartFallsBackWhenLocalFileDisappeared(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
if got := drivePullShouldSkipSmart(filepath.Join("local", "missing.txt"), drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
t.Fatal("drivePullShouldSkipSmart() = true, want false when os.Stat cannot find the local file")
}
}
func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(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)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(200, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %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_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "100"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"downloaded": 0`) {
t.Errorf("expected downloaded=0, got: %s", out)
}
mustReadFile(t, localPath, "hello")
}
// TestDrivePullSurfacesDirectoryFileMirrorConflict pins the contract
// for the case where Drive ships a regular file at a rel_path that is
// already a directory locally. SafeOutputPath would refuse to overwrite
@@ -293,6 +615,49 @@ func TestDrivePullPaginationHandlesPageTokenField(t *testing.T) {
reg.Verify(t)
}
func TestDrivePullRenameSummarizesDuplicateDownloadsAndAvoidsRawTokenInRelPath(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)
}
registerDuplicateRemoteFiles(reg)
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 2 {
t.Fatalf("summary.downloaded = %d, want 2", got)
}
if out := stdout.String(); strings.Contains(out, duplicateRemoteFileIDSecond) {
t.Fatalf("stdout should not expose the raw duplicate file token in rename mode, got: %s", out)
}
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
}
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
reg.Verify(t)
}
// TestDrivePullDeleteLocalRequiresYes verifies the upfront safety guard:
// --delete-local without --yes must be rejected before any API call.
func TestDrivePullDeleteLocalRequiresYes(t *testing.T) {

View File

@@ -15,6 +15,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -25,6 +26,7 @@ import (
const (
drivePushIfExistsOverwrite = "overwrite"
drivePushIfExistsSmart = "smart"
drivePushIfExistsSkip = "skip"
)
@@ -91,14 +93,17 @@ var DrivePush = common.Shortcut{
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (skip = never touch existing remote files; smart = skip when remote modified_time already matches or is newer, otherwise fall through to overwrite semantics; overwrite = always replace)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSmart, drivePushIfExistsSkip}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
},
Tips: []string{
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
"For repeat syncs, --if-exists=smart is a best-effort incremental mode: it compares local mtime with Drive modified_time and skips uploads when the remote copy is already up to date; otherwise it falls through to the same overwrite path as --if-exists=overwrite.",
"Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.",
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero. The same caveat applies when --if-exists=smart decides the remote file is older and falls through to overwrite.",
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
@@ -149,7 +154,7 @@ var DrivePush = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, skip existing, skip up-to-date files when --if-exists=smart, overwrite when --if-exists=overwrite, and (when --delete-remote --yes is set) delete Drive files absent locally.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
@@ -164,6 +169,10 @@ var DrivePush = common.Shortcut{
// rolling-out upload_all `file_token`/`version` protocol field.
ifExists = drivePushIfExistsSkip
}
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
if duplicateRemote == "" {
duplicateRemote = driveDuplicateRemoteFail
}
deleteRemote := runtime.Bool("delete-remote")
// Resolve --local-dir to its canonical absolute path before walking.
@@ -190,10 +199,13 @@ var DrivePush = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// Two views over the same listing:
// - remoteFiles drives upload / overwrite / orphan-delete
// decisions (only type=file entries are upload candidates;
@@ -203,15 +215,9 @@ var DrivePush = common.Shortcut{
// path skip create_folder when an intermediate folder already
// exists, and keeps directory recreation idempotent across
// reruns.
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
for rel, entry := range entries {
switch entry.Type {
case driveTypeFile:
remoteFiles[rel] = entry
case driveTypeFolder:
remoteFolders[rel] = entry
}
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
var uploaded, skipped, failed, deletedRemote int
@@ -264,7 +270,7 @@ var DrivePush = common.Shortcut{
localFile := localFiles[rel]
if entry, ok := remoteFiles[rel]; ok {
if ifExists == drivePushIfExistsSkip {
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
skipped++
continue
@@ -333,24 +339,31 @@ var DrivePush = common.Shortcut{
}
if deleteRemote && !uploadFailed {
// Stable iteration order so failures (and tests) are deterministic.
remoteRelPaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
remoteRelPaths := make([]string, 0, len(remoteFileGroups))
for p := range remoteFileGroups {
remoteRelPaths = append(remoteRelPaths, p)
}
sort.Strings(remoteRelPaths)
for _, rel := range remoteRelPaths {
keepToken := ""
if _, ok := localFiles[rel]; ok {
continue
if chosen, ok := remoteFiles[rel]; ok {
keepToken = chosen.FileToken
}
}
entry := remoteFiles[rel]
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
failed++
continue
for _, entry := range remoteFileGroups[rel] {
if entry.FileToken == keepToken {
continue
}
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
deletedRemote++
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
deletedRemote++
}
}
@@ -384,6 +397,7 @@ type drivePushLocalFile struct {
OpenPath string
FileName string
Size int64
ModTime time.Time
}
// drivePushWalkLocal walks the canonical absolute root produced by
@@ -440,6 +454,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
OpenPath: relToCwd,
FileName: filepath.Base(rel),
Size: info.Size(),
ModTime: info.ModTime(),
}
return nil
})
@@ -463,6 +478,70 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
return files, dirs, nil
}
func drivePushShouldSkipExisting(localFile drivePushLocalFile, remoteFile driveRemoteEntry, ifExists string) bool {
switch ifExists {
case drivePushIfExistsSkip:
return true
case drivePushIfExistsSmart:
return drivePushShouldSkipSmart(localFile, remoteFile)
default:
return false
}
}
func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemoteEntry) bool {
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, localFile.ModTime)
if !ok {
// Smart mode is an optimization. If the timestamp is missing or
// malformed, fall back to the safe transfer path instead of silently
// skipping an update we could not compare.
return false
}
// Remote is already at least as new as the local file, so another
// upload would be redundant.
return cmp >= 0
}
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
fileGroups := make(map[string][]driveRemoteEntry)
for _, entry := range entries {
switch entry.Type {
case driveTypeFile:
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
case driveTypeFolder:
remoteFolders[entry.RelPath] = entry
}
}
relPaths := make([]string, 0, len(fileGroups))
for rel := range fileGroups {
relPaths = append(relPaths, rel)
}
sort.Strings(relPaths)
for _, rel := range relPaths {
files := fileGroups[rel]
if len(files) == 1 {
remoteFiles[rel] = files[0]
continue
}
switch duplicateRemote {
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
chosen, err := chooseRemoteFile(files, duplicateRemote)
if err != nil {
return nil, nil, nil, err
}
remoteFiles[rel] = chosen
default:
return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remoteFolders, fileGroups, nil
}
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
// folder identified by rootFolderToken) exists on Drive, creating any
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the

View File

@@ -12,6 +12,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
@@ -324,6 +325,203 @@ func TestDrivePushSkipsWhenIfExistsSkip(t *testing.T) {
// would 404 against the registry and the run would have errored above.
}
// TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate verifies the smart
// fast path for local → Drive mirrors: when the remote copy is already at
// least as new as the local file, +push skips the upload.
func TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate(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)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %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_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
},
"has_more": false,
},
},
})
// Intentionally NO upload_all stub: smart mode should skip the transfer.
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"uploaded": 0`) {
t.Errorf("expected uploaded=0, got: %s", out)
}
}
// TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer verifies the smart
// path still uploads when the local file is newer than the remote one.
func TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer(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)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(200, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %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_keep_old", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
},
"has_more": false,
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "tok_keep_new", "version": "v43"},
},
}
reg.Register(uploadStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"uploaded": 1`) {
t.Errorf("expected uploaded=1, got: %s", out)
}
if !strings.Contains(out, `"action": "overwritten"`) {
t.Errorf("expected overwritten action, got: %s", out)
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != "tok_keep_old" {
t.Fatalf("upload_all form file_token = %q, want tok_keep_old", got)
}
}
func TestDrivePushShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
t.Parallel()
localFile := drivePushLocalFile{
Size: 5,
ModTime: time.Unix(100, 500*int64(time.Millisecond)),
}
for _, tt := range []struct {
name string
remoteFile driveRemoteEntry
}{
{
name: "invalid remote timestamp",
remoteFile: driveRemoteEntry{ModifiedTime: "not-a-time"},
},
} {
t.Run(tt.name, func(t *testing.T) {
if got := drivePushShouldSkipSmart(localFile, tt.remoteFile); got {
t.Fatalf("drivePushShouldSkipSmart() = true, want false for %s", tt.name)
}
})
}
}
func TestDrivePushSkipsWhenSmartIgnoresRemoteSize(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)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %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_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "200"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"uploaded": 0`) {
t.Errorf("expected uploaded=0, got: %s", out)
}
}
// TestDrivePushDeleteRemoteRequiresYes locks in the upfront safety guard:
// --delete-remote without --yes must be refused before any list / upload
// happens, so a stray flag never silently deletes anything.
@@ -454,6 +652,124 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
}
}
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(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", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "dup-new-token",
"version": "v99",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
}
out := stdout.String()
if !strings.Contains(out, `"uploaded": 1`) {
t.Fatalf("expected uploaded=1, got: %s", out)
}
if !strings.Contains(out, `"deleted_remote": 1`) {
t.Fatalf("expected deleted_remote=1, got: %s", out)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for the unchosen duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestDrivePushDeleteRemoteDeletesEntireDuplicateGroupWithoutLocalCounterpart(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)
}
registerDuplicateRemoteFiles(reg)
deleteFirst := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
deleteSecond := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteFirst)
reg.Register(deleteSecond)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "skip",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"uploaded": 0`) {
t.Fatalf("expected uploaded=0, got: %s", out)
}
if !strings.Contains(out, `"deleted_remote": 2`) {
t.Fatalf("expected deleted_remote=2, got: %s", out)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
if deleteFirst.CapturedHeaders == nil || deleteSecond.CapturedHeaders == nil {
t.Fatal("expected both duplicate remote DELETE requests to be issued")
}
reg.Verify(t)
}
// TestDrivePushRejectsAbsoluteLocalDir confirms SafeLocalFlagPath surfaces
// the proper flag name in the error message.
func TestDrivePushRejectsAbsoluteLocalDir(t *testing.T) {

View File

@@ -13,6 +13,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -26,8 +27,24 @@ type driveStatusEntry struct {
FileToken string `json:"file_token,omitempty"`
}
type driveStatusLocalFile struct {
PathToCwd string
ModTime time.Time
}
type driveStatusRemoteFile struct {
FileToken string
ModifiedTime string
}
const (
driveStatusDetectionExact = "exact"
driveStatusDetectionQuick = "quick"
)
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
// four buckets (new_local, new_remote, modified, unchanged) either by exact
// SHA-256 hash (default) or by a quick modified_time comparison (--quick).
//
// Only Drive entries with type=file are compared; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
@@ -37,19 +54,22 @@ type driveStatusEntry struct {
// path that resolves outside cwd, which keeps the local side bounded to the
// caller's working directory.
var DriveStatus = common.Shortcut{
Service: "drive",
Command: "+status",
Description: "Compare a local directory with a Drive folder by content hash",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Service: "drive",
Command: "+status",
Description: "Compare a local directory with a Drive folder by exact hash or quick modified_time",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly"},
ConditionalScopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
{Name: "quick", Type: "bool", Desc: "compare modified_time only and skip remote downloads for files present on both sides"},
},
Tips: []string{
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
"Default detection=exact downloads files present on both sides and SHA-256 hashes them in memory; expect noticeable I/O on large folders.",
"Pass --quick for the recommended fast preflight mode: it compares local mtime with Drive modified_time, skips remote downloads, and reports detection=quick as a best-effort diff.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
@@ -77,17 +97,37 @@ var DriveStatus = common.Shortcut{
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
// Conditional scope pre-check: quick mode only compares local mtime with
// Drive modified_time, so it must not be blocked on the download grant.
// Exact mode hashes remote bytes, which requires drive:file:download. Do
// the stricter check here once we know which execution path the flags
// selected. EnsureScopes is a silent no-op when scope metadata is
// unavailable, so environments without token scope introspection still
// proceed and rely on the API-level missing_scope error if needed.
if !runtime.Bool("quick") {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
desc := "Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."
if runtime.Bool("quick") {
desc = "Walk --local-dir, recursively list --folder-token, and compare local mtime with Drive modified_time for files present on both sides without downloading remote bytes."
}
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
Desc(desc).
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
detection := driveStatusDetectionExact
if runtime.Bool("quick") {
detection = driveStatusDetectionQuick
}
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
@@ -112,45 +152,60 @@ var DriveStatus = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := duplicateRemoteFilePaths(entries); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// +status only diffs binary content, so collapse the unified
// listing to type=file. Online docs / shortcuts have no
// hashable bytes and are intentionally absent from the diff
// view (a docx living next to a same-named local file is a
// known no-op).
remoteFiles := make(map[string]string, len(entries))
for rel, entry := range entries {
remoteFiles := make(map[string]driveStatusRemoteFile, len(entries))
for _, entry := range entries {
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
remoteFiles[entry.RelPath] = driveStatusRemoteFile{FileToken: entry.FileToken, ModifiedTime: entry.ModifiedTime}
}
}
paths := mergeStatusPaths(localHashes, remoteFiles)
paths := mergeStatusPaths(localFiles, remoteFiles)
var newLocal, newRemote, modified, unchanged []driveStatusEntry
for _, relPath := range paths {
localHash, hasLocal := localHashes[relPath]
remoteToken, hasRemote := remoteFiles[relPath]
localFile, hasLocal := localFiles[relPath]
remoteFile, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
default:
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
if detection == driveStatusDetectionQuick {
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
continue
}
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
if err != nil {
return err
}
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
if err != nil {
return err
}
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
@@ -160,6 +215,7 @@ var DriveStatus = common.Shortcut{
}
runtime.Out(map[string]interface{}{
"detection": detection,
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
@@ -177,8 +233,8 @@ var DriveStatus = common.Shortcut{
// hit, we report rel_path relative to root for the JSON output, and
// convert the absolute path to a cwd-relative form so FileIO.Open's
// SafeInputPath check (which rejects absolute paths) still applies.
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
files := make(map[string]string)
func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalFile, error) {
files := make(map[string]driveStatusLocalFile)
// FileIO has no walker today and shortcuts can't import internal/vfs.
// The walk root is the canonical absolute path returned by
// validate.SafeInputPath, so it is no longer a symlink itself, and
@@ -199,11 +255,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
if err != nil {
return err
}
sum, err := hashLocalForStatus(runtime, relToCwd)
info, err := d.Info()
if err != nil {
return err
}
files[filepath.ToSlash(rel)] = sum
files[filepath.ToSlash(rel)] = driveStatusLocalFile{PathToCwd: relToCwd, ModTime: info.ModTime()}
return nil
})
if err != nil {
@@ -212,6 +268,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
return files, nil
}
func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Time) bool {
cmp, ok := compareDriveRemoteModifiedToLocal(remoteModified, local)
return ok && cmp == 0
}
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
@@ -241,7 +302,7 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
return hex.EncodeToString(h.Sum(nil)), nil
}
func mergeStatusPaths(local, remote map[string]string) []string {
func mergeStatusPaths(local map[string]driveStatusLocalFile, remote map[string]driveStatusRemoteFile) []string {
seen := make(map[string]struct{}, len(local)+len(remote))
for p := range local {
seen[p] = struct{}{}

View File

@@ -4,16 +4,32 @@
package drive
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// driveStatusScopedTokenResolver returns a token with caller-controlled scopes
// so tests can deterministically exercise the shortcut scope preflight.
type driveStatusScopedTokenResolver struct {
scopes string
}
// ResolveToken satisfies credential.TokenProvider for scope-preflight tests.
func (r *driveStatusScopedTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
}
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
// against a real walk of the temp dir and a mocked Drive listing.
func TestDriveStatusCategorizesByHash(t *testing.T) {
@@ -105,6 +121,9 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, `"detection": "exact"`) {
t.Fatalf("output missing detection=exact\noutput: %s", out)
}
checks := []struct {
bucket string
path string
@@ -134,6 +153,264 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
reg.Verify(t)
}
func TestDriveStatusQuickCategorizesByModifiedTimeWithoutDownloads(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local/sub", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil {
t.Fatalf("WriteFile b.txt: %v", err)
}
if err := os.WriteFile("local/sub/c.txt", []byte("local-c"), 0o644); err != nil {
t.Fatalf("WriteFile sub/c.txt: %v", err)
}
matchTime := time.Unix(1715594880, 0)
changedTime := time.Unix(1715594940, 0)
if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil {
t.Fatalf("Chtimes a.txt: %v", err)
}
if err := os.Chtimes("local/sub/c.txt", changedTime, changedTime); err != nil {
t.Fatalf("Chtimes sub/c.txt: %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_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"},
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=tok_sub",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file", "modified_time": "1715594880"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", out)
}
checks := []struct {
bucket string
path string
token string
}{
{"new_local", "b.txt", ""},
{"new_remote", "d.txt", "tok_d"},
{"modified", "sub/c.txt", "tok_c"},
{"unchanged", "a.txt", "tok_a"},
}
for _, c := range checks {
if !strings.Contains(out, `"`+c.bucket+`":`) {
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
}
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
}
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
}
}
reg.Verify(t)
}
// TestDriveStatusQuickMarksUntrustedTimestampAsModified locks in the
// conservative fallback for malformed remote modified_time values.
func TestDriveStatusQuickMarksUntrustedTimestampAsModified(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("local/a.txt", []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %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_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", out)
}
if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"rel_path": "a.txt"`) {
t.Fatalf("invalid remote modified_time must fall back to modified\noutput: %s", out)
}
reg.Verify(t)
}
// TestDriveStatusExactRejectsMissingDownloadScope proves that exact mode keeps
// requiring drive:file:download even after quick mode made download optional.
func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected missing_scope error for exact mode without drive:file:download")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured exit error, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" {
t.Fatalf("expected missing_scope detail, got %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "missing required scope(s): drive:file:download") {
t.Fatalf("unexpected error: %v", err)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Hint, "auth login --scope") {
t.Fatalf("missing scope hint not found in detail: %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "drive:file:download") {
t.Fatalf("error should mention drive:file:download: %v", err)
}
}
// TestDriveStatusQuickAcceptsMissingDownloadScope ensures quick mode is not
// blocked on the exact-mode download scope precheck.
func TestDriveStatusQuickAcceptsMissingDownloadScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %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_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("quick mode should not require drive:file:download: %v\nstdout: %s", err, stdout.String())
}
if !strings.Contains(stdout.String(), `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", stdout.String())
}
reg.Verify(t)
}
// TestDriveStatusShouldTreatAsUnchangedQuick exercises the tiny quick helper
// directly so Codecov also sees coverage on the helper body itself.
func TestDriveStatusShouldTreatAsUnchangedQuick(t *testing.T) {
t.Run("matching timestamp returns true", func(t *testing.T) {
if !driveStatusShouldTreatAsUnchangedQuick("1715594880", time.Unix(1715594880, 500)) {
t.Fatal("expected matching second-resolution timestamps to be unchanged")
}
})
t.Run("different timestamp returns false", func(t *testing.T) {
if driveStatusShouldTreatAsUnchangedQuick("1715594881", time.Unix(1715594880, 0)) {
t.Fatal("expected different timestamps to be treated as modified")
}
})
t.Run("invalid timestamp returns false", func(t *testing.T) {
if driveStatusShouldTreatAsUnchangedQuick("not-a-timestamp", time.Unix(1715594880, 0)) {
t.Fatal("expected invalid timestamp to be treated as modified")
}
})
}
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
@@ -213,6 +490,37 @@ func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
reg.Verify(t)
}
func TestDriveStatusFailsOnRemoteFileFolderConflict(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)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
{"token": "nested-file-token", "name": "child.txt", "type": "file", "size": 1, "created_time": "3", "modified_time": "3"},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -27,6 +27,7 @@ const (
type driveUploadSpec struct {
FilePath string
FileToken string
FolderToken string
WikiToken string
Name string
@@ -37,9 +38,15 @@ type driveUploadTarget struct {
ParentNode string
}
type driveUploadResult struct {
FileToken string
Version string
}
func newDriveUploadSpec(runtime *common.RuntimeContext) driveUploadSpec {
return driveUploadSpec{
FilePath: runtime.Str("file"),
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
Name: runtime.Str("name"),
@@ -89,6 +96,7 @@ var DriveUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "file-token", Desc: "existing file token to overwrite in place"},
{Name: "folder-token", Desc: "target folder token (default: root folder; mutually exclusive with --wiki-token)"},
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
{Name: "name", Desc: "uploaded file name (default: local file name)"},
@@ -96,6 +104,8 @@ var DriveUpload = common.Shortcut{
Tips: []string{
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
"In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
@@ -103,22 +113,28 @@ var DriveUpload = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveUploadSpec(runtime)
target := spec.Target()
isOverwrite := spec.FileToken != ""
body := map[string]interface{}{
"file_name": spec.FileName(),
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"file": "@" + spec.FilePath,
}
if spec.FileToken != "" {
body["file_token"] = spec.FileToken
}
d := common.NewDryRunAPI().
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
POST("/open-apis/drive/v1/files/upload_all").
Body(map[string]interface{}{
"file_name": spec.FileName(),
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"file": "@" + spec.FilePath,
})
if runtime.IsBot() {
Body(body)
if runtime.IsBot() && !isOverwrite {
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveUploadSpec(runtime)
isOverwrite := spec.FileToken != ""
fileName := spec.FileName()
target := spec.Target()
@@ -130,32 +146,37 @@ var DriveUpload = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> %s\n", fileName, common.FormatSize(fileSize), target.Label())
var fileToken string
var uploadResult driveUploadResult
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
fileToken, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize)
uploadResult, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken)
} else {
fileToken, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize)
uploadResult, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken)
}
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": fileToken,
"file_token": uploadResult.FileToken,
"file_name": fileName,
"size": fileSize,
}
if uploadResult.Version != "" {
out["version"] = uploadResult.Version
}
// wiki-hosted files have no standalone /file/<token> URL — only the
// wiki node URL, which the upload response doesn't carry. Skip the
// fallback for parent_type=wiki rather than emit a link that 404s.
if target.ParentType == driveUploadParentTypeExplorer {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" {
out["url"] = u
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil {
out["permission_grant"] = grant
if !isOverwrite {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
@@ -164,6 +185,9 @@ var DriveUpload = common.Shortcut{
}
func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error {
if driveUploadFlagExplicitlyEmpty(runtime, "file-token") {
return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite")
}
if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token")
}
@@ -191,6 +215,11 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe
return output.ErrValidation("%s", err)
}
}
if spec.FileToken != "" {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
}
@@ -200,10 +229,10 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str
strings.TrimSpace(runtime.Str(flagName)) == ""
}
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) {
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return driveUploadResult{}, common.WrapInputStatError(err)
}
defer f.Close()
@@ -213,6 +242,9 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if existingFileToken != "" {
fd.AddField("file_token", existingFileToken)
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
@@ -223,34 +255,37 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", err
return driveUploadResult{}, err
}
return "", output.ErrNetwork("upload failed: %v", err)
return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
}
return fileToken, nil
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(data),
}, nil
}
// uploadFileMultipart uploads a large file using the three-step multipart API:
// 1. upload_prepare — get upload_id, block_size, block_num
// 2. upload_part — upload each block sequentially
// 3. upload_finish — finalize and get file_token
func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) {
// 3. upload_finish — finalize and get file_token/version
func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
// Step 1: Prepare
prepareBody := map[string]interface{}{
"file_name": fileName,
@@ -258,9 +293,12 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
"parent_node": target.ParentNode,
"size": fileSize,
}
if existingFileToken != "" {
prepareBody["file_token"] = existingFileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
return driveUploadResult{}, err
}
uploadID := common.GetString(prepareResult, "upload_id")
@@ -270,7 +308,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
blockNum := int(blockNumF)
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return "", output.Errorf(output.ExitAPI, "api_error",
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error",
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
@@ -288,7 +326,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partFile, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return driveUploadResult{}, common.WrapInputStatError(err)
}
fd := larkcore.NewFormdata()
@@ -306,18 +344,18 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", err
return driveUploadResult{}, err
}
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
@@ -330,13 +368,24 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
if err != nil {
return "", err
return driveUploadResult{}, err
}
fileToken := common.GetString(finishResult, "file_token")
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
}
return fileToken, nil
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(finishResult),
}, nil
}
func driveUploadVersionFromData(data map[string]interface{}) string {
version := common.GetString(data, "version")
if version == "" {
version = common.GetString(data, "data_version")
}
return version
}

View File

@@ -5,8 +5,15 @@ package drive
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"sort"
"strconv"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -14,52 +21,63 @@ const (
driveListRemotePageSize = 200
driveTypeFile = "file"
driveTypeFolder = "folder"
driveUniqueSuffixMaxSeq = 1024
)
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
// driveRemoteEntry is one Drive entry returned by listRemoteFolderEntries. It
// carries enough metadata for every shortcut that consumes the listing
// to build its own per-shortcut view by filtering on Type.
type driveRemoteEntry struct {
// FileToken is the Drive token for this entry. For type=folder this
// is the folder_token; for everything else it is the file_token.
FileToken string
Name string
Size int64
// Type is the Drive entry kind verbatim from the API:
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
// "mindnote" | "slides" | "shortcut" | …
Type string
Type string
CreatedTime string
ModifiedTime string
// RelPath is the entry's path relative to the listing root. Encoded
// with "/" separators on every platform so it matches the rel_paths
// produced by the shortcuts' local walkers.
RelPath string
}
// listRemoteFolder recursively lists folderToken under relBase and
// returns one entry per Drive item, keyed by rel_path. Subfolders are
// descended into and the folder's own entry is also recorded — callers
// can reason about "this rel_path is occupied by a folder" without
// re-listing.
type driveDuplicateRemoteEntry struct {
FileToken string `json:"file_token"`
Name string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size,omitempty"`
CreatedTime string `json:"created_time,omitempty"`
ModifiedTime string `json:"modified_time,omitempty"`
}
type driveDuplicateRemotePath struct {
RelPath string `json:"rel_path"`
Entries []driveDuplicateRemoteEntry `json:"entries"`
}
// listRemoteFolderEntries recursively lists folderToken under relBase and
// returns one entry per Drive item. Subfolders are descended into and the
// folder's own entry is also recorded, allowing callers to detect multiple
// remote files that map to the same rel_path.
//
// This is the shared backbone for the three sync-disk shortcuts. None
// of them need every field at every call site, so each one filters
// on Type:
// The helper deliberately stores every Drive object kind. Online docs and
// shortcuts are skipped by sync shortcuts later, but preserving their rel_path
// here prevents destructive mirror modes from treating a local same-named
// regular file as an orphan when Drive already owns that path.
//
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
// to drive content-hash diffs against the local tree.
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
// download set, and the full key set (every rel_path) as the
// guard for --delete-local.
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
// for the create_folder cache.
//
// Pagination uses common.PaginationMeta, which accepts both
// page_token and next_page_token — the Drive list endpoint has
// historically returned the latter, but the helper future-proofs
// against a backend rename.
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
out := make(map[string]driveRemoteEntry)
// Pagination uses common.PaginationMeta, which accepts both page_token and
// next_page_token.
func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) ([]driveRemoteEntry, error) {
var out []driveRemoteEntry
pageToken := ""
for {
if err := ctx.Err(); err != nil {
return nil, err
}
params := map[string]interface{}{
"folder_token": folderToken,
"page_size": fmt.Sprint(driveListRemotePageSize),
@@ -84,15 +102,24 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
continue
}
rel := joinRelDrive(relBase, fName)
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
out = append(out, driveRemoteEntry{
FileToken: fToken,
Name: fName,
Size: int64(common.GetFloat(f, "size")),
Type: fType,
CreatedTime: common.GetString(f, "created_time"),
ModifiedTime: common.GetString(f, "modified_time"),
RelPath: rel,
})
if fType == driveTypeFolder {
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
if err := ctx.Err(); err != nil {
return nil, err
}
sub, err := listRemoteFolderEntries(ctx, runtime, fToken, rel)
if err != nil {
return nil, err
}
for k, v := range sub {
out[k] = v
}
out = append(out, sub...)
}
}
hasMore, nextToken := common.PaginationMeta(result)
@@ -104,6 +131,256 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
return out, nil
}
func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemotePath {
groups := make(map[string][]driveRemoteEntry)
for _, entry := range entries {
groups[entry.RelPath] = append(groups[entry.RelPath], entry)
}
relPaths := make([]string, 0, len(groups))
for relPath, grouped := range groups {
if len(grouped) > 1 {
relPaths = append(relPaths, relPath)
}
}
sort.Strings(relPaths)
duplicates := make([]driveDuplicateRemotePath, 0, len(relPaths))
for _, relPath := range relPaths {
grouped := append([]driveRemoteEntry(nil), groups[relPath]...)
sort.SliceStable(grouped, func(i, j int) bool {
if grouped[i].Type != grouped[j].Type {
return grouped[i].Type < grouped[j].Type
}
if cmp, ok := compareDriveTimes(grouped[i].CreatedTime, grouped[j].CreatedTime); ok && cmp != 0 {
return cmp < 0
}
if cmp, ok := compareDriveTimes(grouped[i].ModifiedTime, grouped[j].ModifiedTime); ok && cmp != 0 {
return cmp < 0
}
return grouped[i].FileToken < grouped[j].FileToken
})
dupEntries := make([]driveDuplicateRemoteEntry, 0, len(grouped))
for _, entry := range grouped {
dupEntries = append(dupEntries, driveDuplicateRemoteEntry{
FileToken: entry.FileToken,
Name: entry.Name,
Type: entry.Type,
Size: entry.Size,
CreatedTime: entry.CreatedTime,
ModifiedTime: entry.ModifiedTime,
})
}
duplicates = append(duplicates, driveDuplicateRemotePath{RelPath: relPath, Entries: dupEntries})
}
return duplicates
}
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "duplicate_remote_path",
Message: "multiple Drive entries map to the same rel_path",
Detail: map[string]interface{}{
"duplicates_remote": duplicates,
},
},
}
}
const (
driveDuplicateRemoteFail = "fail"
driveDuplicateRemoteRename = "rename"
driveDuplicateRemoteNewest = "newest"
driveDuplicateRemoteOldest = "oldest"
)
// sortRemoteFiles orders duplicate Drive files according to the conflict
// strategy, using parsed Drive timestamps so mixed second/millisecond/
// microsecond epochs compare by actual time rather than raw integer width.
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
sort.SliceStable(files, func(i, j int) bool {
a, b := files[i], files[j]
switch strategy {
case driveDuplicateRemoteNewest:
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
return cmp > 0
} else if !ok {
return a.FileToken < b.FileToken
}
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
return cmp > 0
} else if !ok {
return a.FileToken < b.FileToken
}
default:
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
return cmp < 0
} else if !ok {
return a.FileToken < b.FileToken
}
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
return cmp < 0
} else if !ok {
return a.FileToken < b.FileToken
}
}
return a.FileToken < b.FileToken
})
}
// compareDriveTimes compares two Drive epoch strings after normalizing their
// unit (seconds, milliseconds, or microseconds) into time.Time values.
func compareDriveTimes(a, b string) (int, bool) {
at, _, aOK := parseDriveEpoch(a)
bt, _, bOK := parseDriveEpoch(b)
if !aOK || !bOK {
return 0, false
}
switch {
case at.Before(bt):
return -1, true
case at.After(bt):
return 1, true
default:
return 0, true
}
}
func parseDriveEpoch(raw string) (time.Time, time.Duration, bool) {
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return time.Time{}, 0, false
}
// Drive timestamps are epoch strings. The API currently returns
// milliseconds, but tests and older payloads may still use seconds.
// Infer the unit conservatively from magnitude and compare local mtimes
// at the same resolution so sub-second filesystem noise does not force
// a transfer in smart mode.
switch {
case v > 1e14 || v < -1e14:
return time.UnixMicro(v), time.Microsecond, true
case v > 1e11 || v < -1e11:
return time.UnixMilli(v), time.Millisecond, true
default:
return time.Unix(v, 0), time.Second, true
}
}
// compareDriveRemoteModifiedToLocal compares one Drive modified_time string to a
// local file mtime.
// - returns -1 when remote < local
// - returns 0 when remote == local at the remote timestamp resolution
// - returns 1 when remote > local
//
// The bool reports whether the remote timestamp was parseable.
func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (int, bool) {
remoteTime, resolution, ok := parseDriveEpoch(remoteModified)
if !ok {
return 0, false
}
localAtRemoteResolution := local.Truncate(resolution)
switch {
case remoteTime.Before(localAtRemoteResolution):
return -1, true
case remoteTime.After(localAtRemoteResolution):
return 1, true
default:
return 0, true
}
}
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
if len(files) == 0 {
return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy)
}
candidates := append([]driveRemoteEntry(nil), files...)
sortRemoteFiles(candidates, strategy)
return candidates[0], nil
}
func isFileOnlyDuplicatePath(duplicate driveDuplicateRemotePath) bool {
if len(duplicate.Entries) < 2 {
return false
}
for _, entry := range duplicate.Entries {
if entry.Type != driveTypeFile {
return false
}
}
return true
}
func blockingRemotePathConflicts(entries []driveRemoteEntry, duplicateRemote string) []driveDuplicateRemotePath {
duplicates := duplicateRemoteFilePaths(entries)
if duplicateRemote == driveDuplicateRemoteFail {
return duplicates
}
blocking := make([]driveDuplicateRemotePath, 0, len(duplicates))
for _, duplicate := range duplicates {
if !isFileOnlyDuplicatePath(duplicate) {
blocking = append(blocking, duplicate)
}
}
return blocking
}
func occupiedRemotePaths(entries []driveRemoteEntry) map[string]struct{} {
occupied := make(map[string]struct{}, len(entries))
for _, entry := range entries {
occupied[entry.RelPath] = struct{}{}
}
return occupied
}
func stableTokenHash(fileToken string) string {
sum := sha256.Sum256([]byte(fileToken))
return hex.EncodeToString(sum[:])
}
func stableTokenIdentifier(fileToken string) string {
hash := stableTokenHash(fileToken)
if len(hash) > 12 {
hash = hash[:12]
}
return "hash_" + hash
}
func relPathWithSuffix(relPath, suffix string) string {
dir, base := path.Split(relPath)
ext := path.Ext(base)
if ext == base {
return dir + base + suffix
}
stem := base[:len(base)-len(ext)]
return dir + stem + suffix + ext
}
func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[string]struct{}) (string, error) {
tokenHash := stableTokenHash(fileToken)
suffixes := []string{
"__lark_" + tokenHash[:12],
"__lark_" + tokenHash[:24],
"__lark_" + tokenHash,
}
for _, suffix := range suffixes {
candidate := relPathWithSuffix(relPath, suffix)
if _, exists := occupied[candidate]; !exists {
occupied[candidate] = struct{}{}
return candidate, nil
}
}
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
candidate := relPathWithSuffix(relPath, "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))
if _, exists := occupied[candidate]; !exists {
occupied[candidate] = struct{}{}
return candidate, nil
}
}
return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
}
// joinRelDrive joins a rel_path base with an entry name using "/".
// Empty base means the entry sits at the listing root. Mirrors the
// behavior the per-shortcut helpers used to ship and keeps rel_paths

View File

@@ -15,6 +15,7 @@ import (
"github.com/spf13/cobra"
)
// mustMarshalDryRun marshals v to a JSON string, calling t.Fatalf on error.
func mustMarshalDryRun(t *testing.T, v interface{}) string {
t.Helper()
@@ -25,6 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
return string(b)
}
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
// command whose flags are populated from the provided string and bool maps,
// for unit-testing shortcut bodies, validators, and dry-run shapes.
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -55,6 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext is the messages-search variant of
// newTestRuntimeContext: registers the search-specific --page-size flag
// before applying caller-provided values.
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -86,6 +93,8 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st
return &common.RuntimeContext{Cmd: cmd}
}
// TestBuildCreateChatBody verifies the request body assembled when every
// flag is populated, including the default chat_mode="group".
func TestBuildCreateChatBody(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
@@ -94,11 +103,13 @@ func TestBuildCreateChatBody(t *testing.T) {
"users": "ou_1, ou_2",
"bots": "cli_1, cli_2",
"owner": "ou_owner",
"chat-mode": "group",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "group",
"name": "Team Chat",
"description": "daily sync",
"user_id_list": []string{
@@ -116,6 +127,43 @@ func TestBuildCreateChatBody(t *testing.T) {
}
}
// TestBuildCreateChatBody_TopicMode verifies that --chat-mode topic produces
// chat_mode="topic" in the request body, the topic-chat creation path.
func TestBuildCreateChatBody_TopicMode(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Topic Group",
"chat-mode": "topic",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "topic",
"name": "Topic Group",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want)
}
}
// TestBuildCreateChatBody_EmptyChatModeFallsBack pins the defensive fallback:
// explicit `--chat-mode ""` slips past validateEnumFlags (which skips empty
// values), but buildCreateChatBody must still emit chat_mode="group" rather
// than an empty string with unspecified server semantics.
func TestBuildCreateChatBody_EmptyChatModeFallsBack(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Fallback Test",
"chat-mode": "",
}, nil)
got := buildCreateChatBody(runtime)
if got["chat_mode"] != "group" {
t.Fatalf("buildCreateChatBody() chat_mode = %#v, want \"group\"", got["chat_mode"])
}
}
// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17]
func TestSplitMembers(t *testing.T) {
got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ")
@@ -591,10 +639,12 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
})
}
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
// produces the expected API path, query parameters, and request body.
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"type", "name", "users", "owner"} {
for _, name := range []string{"type", "name", "users", "owner", "chat-mode"} {
cmd.Flags().String(name, "", "")
}
cmd.Flags().Bool("set-bot-manager", false, "")
@@ -604,9 +654,10 @@ func TestShortcutDryRunShapes(t *testing.T) {
_ = cmd.Flags().Set("users", "ou_1,ou_2")
_ = cmd.Flags().Set("owner", "ou_owner")
_ = cmd.Flags().Set("set-bot-manager", "true")
_ = cmd.Flags().Set("chat-mode", "group")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, "bot")
got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) {
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) || !strings.Contains(got, `"chat_mode":"group"`) {
t.Fatalf("ImChatCreate.DryRun() = %s", got)
}
})
@@ -623,6 +674,25 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
}, map[string]bool{
"exclude-muted": true,
})
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
// Filter is client-side; --exclude-muted must NOT mutate request body or auto-inject search_types.
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) {
t.Fatalf("ImChatSearch.DryRun() missing endpoint: %s", got)
}
if strings.Contains(got, `"exclude_muted"`) || strings.Contains(got, `"exclude-muted"`) {
t.Fatalf("--exclude-muted leaked into request: %s", got)
}
if strings.Contains(got, `"search_types"`) {
t.Fatalf("search_types must not be auto-injected by --exclude-muted: %s", got)
}
})
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
@@ -758,6 +828,20 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
})
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
t.Fatalf("ImChatList.DryRun() = %s", got)
}
if !strings.Contains(got, `"sort_type":"ByCreateTimeAsc"`) {
t.Fatalf("ImChatList.DryRun() missing sort_type: %s", got)
}
})
}
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
@@ -772,3 +856,26 @@ func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
}
func TestDetectAllNonMemberPreSkip(t *testing.T) {
cases := []struct {
name string
searchTypes string
want string
}{
{"empty", "", ""},
{"only public_not_joined", "public_not_joined", SkipReasonAllNonMember},
{"public_not_joined with whitespace", " public_not_joined ", SkipReasonAllNonMember},
{"private only", "private", ""},
{"mixed includes public_not_joined", "public_not_joined,private", ""},
{"all four types", "private,public_joined,external,public_not_joined", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := detectAllNonMemberPreSkip(c.searchTypes)
if got != c.want {
t.Fatalf("detectAllNonMemberPreSkip(%q) = %q, want %q", c.searchTypes, got, c.want)
}
})
}
}

View File

@@ -20,6 +20,8 @@ import (
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -32,6 +34,18 @@ var mentionFixRe = regexp.MustCompile(`<at\s+(id|open_id|user_id)=("?)([^"\s/>]+
var threadIDRe = regexp.MustCompile(`^omt_`)
var messageIDRe = regexp.MustCompile(`^om_`)
func flagMessageID(rt *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(rt.Str("message-id"))
if id == "" {
return "", output.ErrValidation("--message-id is required")
}
if strings.HasPrefix(id, "omt_") {
return "", output.ErrValidation(
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id)
}
return validateMessageID(id)
}
func normalizeAtMentions(content string) string {
return mentionFixRe.ReplaceAllString(content, `<at user_id="$3">`)
}
@@ -1432,3 +1446,222 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
}
return fileKey, nil
}
// FlagType enumerates the kind of bookmark.
// Aligned with server-side constants: Unknown=0, Feed=1, Message=2.
type FlagType int
const (
FlagTypeUnknown FlagType = 0
FlagTypeFeed FlagType = 1
FlagTypeMessage FlagType = 2
)
// ItemType enumerates the kind of thing being bookmarked.
// Server-side constants (only the types used by IM flags):
//
// default=0, thread=4, msg_thread=11.
//
// Note on the two thread-shaped item types:
// - ItemTypeThread (4) — thread inside a topic-style chat
// - ItemTypeMsgThread (11) — thread inside a regular chat
type ItemType int
const (
ItemTypeDefault ItemType = 0
ItemTypeThread ItemType = 4 // thread in a topic-style chat
ItemTypeMsgThread ItemType = 11 // thread in a regular chat
)
const (
flagWriteScope = "im:feed.flag:write"
flagReadScope = "im:feed.flag:read"
)
var (
flagWriteLookupScopes = append([]string{flagWriteScope}, flagLookupScopes...)
flagMessageReadScopes = []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
}
flagLookupScopes = []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
"im:chat:read",
}
)
func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, required []string) error {
if len(required) == 0 {
return nil
}
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
if err != nil {
return output.ErrWithHint(output.ExitAuth, "auth",
fmt.Sprintf("cannot verify required scope(s): %v", err),
flagScopeLoginHint(required))
}
if result == nil || result.Scopes == "" {
fmt.Fprintf(rt.IO().ErrOut,
"warning: cannot verify required scope(s) because token scope metadata is unavailable; API may fail if missing: %s\n",
strings.Join(required, " "))
return nil
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
flagScopeLoginHint(missing))
}
return nil
}
func flagScopeLoginHint(scopes []string) string {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(scopes, " "))
}
// flagItem is one entry in the flags API body. The server expects numeric
// enums serialized as strings.
type flagItem struct {
ItemID string `json:"item_id"`
ItemType string `json:"item_type"`
FlagType string `json:"flag_type"`
}
// parseItemID inspects an om_ prefix and returns a best-guess
// (itemType, flagType) pair. Used when the user omits the explicit enums.
// - om_xxx → (default, message)
func parseItemID(id string) (ItemType, FlagType, error) {
id = strings.TrimSpace(id)
switch {
case strings.HasPrefix(id, "om_"):
return ItemTypeDefault, FlagTypeMessage, nil
case id == "":
return 0, 0, output.ErrValidation("--message-id cannot be empty")
default:
return 0, 0, output.ErrValidation(
"cannot infer item type from id %q: expected om_ (message) prefix; "+
"pass --item-type and --flag-type explicitly if you are using a different id format", id)
}
}
// parseItemType converts a user-facing string to the server enum.
func parseItemType(s string) (ItemType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "default":
return ItemTypeDefault, nil
case "thread":
return ItemTypeThread, nil
case "msg_thread":
return ItemTypeMsgThread, nil
}
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
}
// parseFlagType converts a user-facing string to the server enum.
func parseFlagType(s string) (FlagType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "message":
return FlagTypeMessage, nil
case "feed":
return FlagTypeFeed, nil
}
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
}
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
// Note: (ItemType, FlagType) is shorthand for (item_type, flag_type) — the two
// enum fields that determine which layer the flag operates on.
//
// Valid combinations are:
// - (default, message) — regular chat message (message-layer flag)
// - (thread, feed) — thread as feed-layer flag (topic-style chat)
// - (msg_thread, feed) — message-thread as feed-layer flag (regular chat)
func isValidCombo(it ItemType, ft FlagType) bool {
return (it == ItemTypeDefault && ft == FlagTypeMessage) ||
(it == ItemTypeThread && ft == FlagTypeFeed) ||
(it == ItemTypeMsgThread && ft == FlagTypeFeed)
}
// parseItemTypeFromRaw parses a stringified numeric item_type back to ItemType.
// Used when re-parsing the serialized enum for combo-validity checks.
// Note: Unknown values return ItemTypeDefault (0). This is safe because:
// 1. This function only parses values we serialized ourselves via newFlagItem
// 2. Unknown server values would fail combo validation or be rejected by the server
func parseItemTypeFromRaw(s string) ItemType {
switch s {
case "0":
return ItemTypeDefault
case "4":
return ItemTypeThread
case "11":
return ItemTypeMsgThread
}
return ItemTypeDefault
}
// parseFlagTypeFromRaw parses a stringified numeric flag_type back to FlagType.
// Used when re-parsing the serialized enum for combo-validity checks.
func parseFlagTypeFromRaw(s string) FlagType {
switch s {
case "1":
return FlagTypeFeed
case "2":
return FlagTypeMessage
}
return FlagTypeUnknown
}
// newFlagItem builds a payload entry with numeric-stringified enums.
func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem {
return flagItem{
ItemID: itemID,
ItemType: fmt.Sprintf("%d", int(it)),
FlagType: fmt.Sprintf("%d", int(ft)),
}
}
// getMessageChatID queries the message API to get the chat_id.
// Used by flag-create to determine the chat type for feed-layer flags.
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
if err != nil {
return "", err
}
items, ok := data["items"].([]any)
if !ok || len(items) == 0 {
return "", output.ErrValidation("message not found or unexpected API response format")
}
msg, ok := items[0].(map[string]any)
if !ok {
return "", output.ErrValidation("unexpected message format in API response")
}
chatID, ok := msg["chat_id"].(string)
if !ok {
return "", output.ErrValidation("message response missing chat_id field")
}
return chatID, nil
}
// resolveThreadFeedItemType determines the correct feed-layer ItemType for a thread
// by querying the chat API for chat_mode.
// - topic-style chat → ItemTypeThread
// - regular chat → ItemTypeMsgThread
//
// Returns an error if the chat query fails, since guessing the wrong item_type
// can cause silent failures in flag operations.
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
if err != nil {
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
}
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
chatMode, _ := data["chat_mode"].(string)
if chatMode == "topic" {
return ItemTypeThread, nil
}
return ItemTypeMsgThread, nil
}

View File

@@ -859,6 +859,7 @@ func TestShortcuts(t *testing.T) {
want := []string{
"+chat-create",
"+chat-list",
"+chat-messages-list",
"+chat-search",
"+chat-update",
@@ -868,6 +869,9 @@ func TestShortcuts(t *testing.T) {
"+messages-search",
"+messages-send",
"+threads-messages-list",
"+flag-create",
"+flag-cancel",
"+flag-list",
}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)

View File

@@ -16,10 +16,14 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImChatCreate is the +chat-create shortcut: creates a group chat or topic
// chat via POST /open-apis/im/v1/chats. Supports user and bot identities;
// --chat-mode selects group (default) or topic; --type selects private
// (default) or public; --users/--bots invite members at creation.
var ImChatCreate = common.Shortcut{
Service: "im",
Command: "+chat-create",
Description: "Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager",
Description: "Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager",
Risk: "write",
UserScopes: []string{"im:chat:create_by_user"},
BotScopes: []string{"im:chat:create"},
@@ -32,6 +36,7 @@ var ImChatCreate = common.Shortcut{
{Name: "bots", Desc: "comma-separated bot app IDs (cli_xxx) to invite, max 5"},
{Name: "owner", Desc: "owner open_id (ou_xxx); defaults to bot (--as bot) or authorized user (--as user)"},
{Name: "type", Default: "private", Desc: "chat type", Enum: []string{"private", "public"}},
{Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}},
{Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -141,9 +146,18 @@ var ImChatCreate = common.Shortcut{
},
}
// buildCreateChatBody assembles the POST /open-apis/im/v1/chats request
// body. chat_mode is always emitted; an empty value (which can slip past
// validateEnumFlags, since that helper skips empty strings) is pinned to
// "group" so the wire never carries an unspecified chat_mode value.
func buildCreateChatBody(runtime *common.RuntimeContext) map[string]interface{} {
chatMode := runtime.Str("chat-mode")
if chatMode == "" {
chatMode = "group"
}
body := map[string]interface{}{
"chat_type": runtime.Str("type"),
"chat_mode": chatMode,
}
if name := runtime.Str("name"); name != "" {
body["name"] = name

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"
// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to
// list groups the current user/bot is a member of. Supports sort order,
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
var ImChatList = common.Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(imChatListPath).
Params(buildChatListParams(runtime))
},
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return output.ErrValidation("--page-size must be an integer between 1 and 100")
}
return nil
},
// Execute fetches one page of chats, optionally applies --exclude-muted
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
// populated only when --exclude-muted is set (backward compatible).
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildChatListParams(runtime)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
if err != nil {
return err
}
rawItems, _ := resData["items"].([]interface{})
hasMore, pageToken := common.PaginationMeta(resData)
var items []map[string]interface{}
for _, raw := range rawItems {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
items = append(items, item)
}
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
ExcludeMuted: runtime.Bool("exclude-muted"),
IsBot: runtime.IsBot(),
Chats: items,
ChatIDKey: "chat_id",
HasMore: hasMore,
})
if err != nil {
return err
}
items = mfOut.Chats
outData := map[string]interface{}{
"chats": items,
"has_more": hasMore,
"page_token": pageToken,
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
fmt.Fprintln(w, "No chats found.")
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
return
}
rows := make([]map[string]interface{}, 0, len(items))
for _, m := range items {
row := map[string]interface{}{
"chat_id": m["chat_id"],
"name": m["name"],
}
if desc, _ := m["description"].(string); desc != "" {
row["description"] = desc
}
if ownerID, _ := m["owner_id"].(string); ownerID != "" {
row["owner_id"] = ownerID
}
if external, ok := m["external"].(bool); ok {
row["external"] = external
}
if status, _ := m["chat_status"].(string); status != "" {
row["chat_status"] = status
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d chat(s) listed", len(rows))
if hasMore {
fmt.Fprint(w, " (more available, use --page-token to fetch next page")
if pageToken != "" {
fmt.Fprintf(w, ", page_token: %s", pageToken)
}
fmt.Fprint(w, ")")
}
fmt.Fprintln(w)
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
})
return nil
},
}
// buildChatListParams builds the query parameters for the GET /im/v1/chats
// call from the runtime flag values. user_id_type and sort_type are always
// present (their flag defaults are non-empty); page_token is omitted when
// empty; page_size falls back to the API default of 20 when not provided.
func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
}
if n := runtime.Int("page-size"); n > 0 {
params["page_size"] = n
} else {
params["page_size"] = 20
}
if pt := runtime.Str("page-token"); pt != "" {
params["page_token"] = pt
}
return params
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext —
// it registers page-size as Int (the existing newTestRuntimeContext registers
// it as String, which would short-circuit our buildChatListParams logic).
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestBuildChatListParams_Defaults(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt)
if got["user_id_type"] != "open_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
if got["sort_type"] != "ByCreateTimeAsc" {
t.Fatalf("sort_type = %v", got["sort_type"])
}
if got["page_size"] != 20 {
t.Fatalf("page_size = %v, want 20", got["page_size"])
}
if _, present := got["page_token"]; present {
t.Fatalf("page_token should be omitted when empty")
}
}
func TestBuildChatListParams_Overrides(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "user_id",
"sort-type": "ByActiveTimeDesc",
"page-size": "50",
"page-token": "tok_xyz",
}, nil)
got := buildChatListParams(rt)
if got["user_id_type"] != "user_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
if got["sort_type"] != "ByActiveTimeDesc" {
t.Fatalf("sort_type = %v", got["sort_type"])
}
if got["page_size"] != 50 {
t.Fatalf("page_size = %v, want 50", got["page_size"])
}
if got["page_token"] != "tok_xyz" {
t.Fatalf("page_token = %v", got["page_token"])
}
}
func TestImChatList_Validate_PageSizeBounds(t *testing.T) {
cases := []struct {
name string
pageSize string
wantErr bool
}{
{"zero rejected", "0", true},
{"negative rejected", "-1", true},
{"one ok", "1", false},
{"hundred ok", "100", false},
{"oneoone rejected", "101", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{"page-size": c.pageSize}, nil)
err := ImChatList.Validate(context.Background(), rt)
if (err != nil) != c.wantErr {
t.Fatalf("Validate() err = %v, wantErr=%v", err, c.wantErr)
}
})
}
}
func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByActiveTimeDesc",
"page-size": "30",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
t.Fatalf("DryRun missing endpoint: %s", got)
}
if !strings.Contains(got, `"sort_type":"ByActiveTimeDesc"`) {
t.Fatalf("DryRun missing sort_type: %s", got)
}
if !strings.Contains(got, `"page_size":30`) {
t.Fatalf("DryRun missing page_size: %s", got)
}
}

View File

@@ -15,10 +15,14 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// ImChatSearch is the +chat-search shortcut: wraps POST /open-apis/im/v2/chats/search
// to find visible group chats by keyword and/or member open_ids. Supports
// member/type filters, sort order, pagination, and (user identity only) the
// --exclude-muted client-side mute filter.
var ImChatSearch = common.Shortcut{
Service: "im",
Command: "+chat-search",
Description: "Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination",
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -32,7 +36,9 @@ var ImChatSearch = common.Shortcut{
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the POST /open-apis/im/v2/chats/search request without executing.
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildSearchChatBody(runtime)
params := buildSearchChatParams(runtime)
@@ -41,6 +47,8 @@ var ImChatSearch = common.Shortcut{
Params(params).
Body(body)
},
// Validate enforces query/member-ids presence, --query rune cap, search-types
// enum, --member-ids count and format, and --page-size bounds.
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
query := runtime.Str("query")
memberIDs := runtime.Str("member-ids")
@@ -79,6 +87,10 @@ var ImChatSearch = common.Shortcut{
}
return nil
},
// Execute fetches one page, extracts per-item meta_data, optionally applies
// the --exclude-muted client-side filter (with a PreSkipReason when
// --search-types is exactly public_not_joined), and renders the result.
// outData["filter"] is populated only when --exclude-muted is set.
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildSearchChatBody(runtime)
params := buildSearchChatParams(runtime)
@@ -106,16 +118,39 @@ var ImChatSearch = common.Shortcut{
items = append(items, meta)
}
preSkipReason := ""
if runtime.Bool("exclude-muted") {
preSkipReason = detectAllNonMemberPreSkip(runtime.Str("search-types"))
}
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
ExcludeMuted: runtime.Bool("exclude-muted"),
IsBot: runtime.IsBot(),
PreSkipReason: preSkipReason,
Chats: items,
ChatIDKey: "chat_id",
HasMore: hasMore,
})
if err != nil {
return err
}
items = mfOut.Chats
outData := map[string]interface{}{
"chats": items,
"total": int(total),
"has_more": hasMore,
"page_token": pageToken,
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
fmt.Fprintln(w, "No matching group chats found.")
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
return
}
var rows []map[string]interface{}
@@ -154,11 +189,19 @@ var ImChatSearch = common.Shortcut{
moreHint += ")"
}
fmt.Fprintf(w, "\n%d chat(s) found%s\n", int(total), moreHint)
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
})
return nil
},
}
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
// from the runtime flag values. The query string is normalized via
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
// is empty.
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
@@ -194,6 +237,9 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
return body
}
// buildSearchChatParams builds the query parameters for the POST
// /im/v2/chats/search call. page_size defaults to the API default of 20 when
// not provided; page_token is omitted when empty.
func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if n := runtime.Int("page-size"); n > 0 {
@@ -207,10 +253,11 @@ func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{
return params
}
// normalizeChatSearchQuery wraps hyphenated search queries in double quotes
// because the search API treats hyphenated keywords specially and expects the
// whole query to be quoted. Already-quoted input is unwrapped before requoting
// so we don't emit nested quotes. Inputs without "-" pass through unchanged.
func normalizeChatSearchQuery(query string) string {
// The search API treats hyphenated keywords specially and expects the whole
// query to be quoted. Normalize already-quoted input before requoting so we
// don't emit nested quotes.
if !strings.Contains(query, "-") {
return query
}
@@ -219,3 +266,15 @@ func normalizeChatSearchQuery(query string) string {
}
return strconv.Quote(query)
}
// detectAllNonMemberPreSkip returns SkipReasonAllNonMember when --search-types
// is exactly "public_not_joined" — the one combination guaranteeing no member
// chats, making the mute filter a no-op. Any other value (including empty or
// mixed) returns "".
func detectAllNonMemberPreSkip(searchTypesCSV string) string {
types := common.SplitCSV(searchTypesCSV)
if len(types) == 1 && types[0] == "public_not_joined" {
return SkipReasonAllNonMember
}
return ""
}

View File

@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark.
// When no --flag-type is given, it performs double-cancel: removes both message and feed layers.
var ImFlagCancel = common.Shortcut{
Service: "im",
Command: "+flag-cancel",
Description: "Cancel (remove) a bookmark. When no --flag-type is given, " +
"performs double-cancel: removes both message and feed layers",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "message ID (om_xxx)"},
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
{Name: "flag-type", Desc: "flag type override: message|feed; omit to double-cancel both layers"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := buildCancelItemsForPreview(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
items, _, err := buildCancelItemsForPreview(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
d := common.NewDryRunAPI().
POST("/open-apis/im/v1/flags/cancel").
Body(map[string]any{"flag_items": items})
if len(items) > 1 {
d.Desc("double-cancel: tries both message and feed layers (best-effort); feed-layer skipped if chat_type undeterminable")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
items, err := buildCancelItems(runtime)
if err != nil {
return err
}
// Make separate API calls for each item so they are independent.
// If one fails, the other can still succeed.
results := make([]map[string]any, 0, len(items))
var lastErr error
for _, item := range items {
itemType := itemTypeString(parseItemTypeFromRaw(item.ItemType))
flagType := flagTypeString(parseFlagTypeFromRaw(item.FlagType))
result := map[string]any{
"item_id": item.ItemID,
"item_type": itemType,
"flag_type": flagType,
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil,
map[string]any{"flag_items": []flagItem{item}})
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n",
itemType, flagType, err)
result["status"] = "failed"
result["error"] = err.Error()
lastErr = err
} else {
result["status"] = "ok"
result["response"] = data
}
results = append(results, result)
}
runtime.Out(map[string]any{"results": results}, nil)
return lastErr
},
}
// buildCancelItemsForPreview builds cancel items without API calls.
// It shows double-cancel when no explicit flags are provided.
// DryRun cannot query chat_mode, so feed-layer item_type is represented with
// the same auto-detect placeholder used by +flag-create.
func buildCancelItemsForPreview(rt *common.RuntimeContext) ([]any, bool, error) {
id, err := flagMessageID(rt)
if err != nil {
return nil, false, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
// Explicit override provided → single targeted delete
if itOverride != "" || ftOverride != "" {
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
if err != nil {
return nil, false, err
}
return []any{item}, false, nil
}
// No override: show double-cancel (message + feed layers)
// Dry-run shows both layers; actual execution is best-effort.
return []any{
newFlagItem(id, ItemTypeDefault, FlagTypeMessage),
map[string]string{
"item_id": id,
"item_type": "<auto:thread|msg_thread>",
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
},
}, true, nil
}
// buildCancelItems picks the (item_type, flag_type) pairs to cancel.
//
// Logic:
// 1. If --flag-type is explicitly provided, do a single targeted delete.
// 2. Otherwise, perform double-cancel: remove both message layer and feed layer.
// - Message layer is always included (uses known message_id with ItemTypeDefault)
// - Feed layer is best-effort: if chat_type cannot be determined, skip with warning
// - Each layer is independent; failure to cancel one doesn't block the other
func buildCancelItems(rt *common.RuntimeContext) ([]flagItem, error) {
id, err := flagMessageID(rt)
if err != nil {
return nil, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
// Explicit override provided → single targeted delete
if itOverride != "" || ftOverride != "" {
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
if err != nil {
return nil, err
}
return []flagItem{item}, nil
}
// Double-cancel: message layer + feed layer (best effort)
// Message layer is always included - we have the message_id and know the combo is valid.
items := []flagItem{newFlagItem(id, ItemTypeDefault, FlagTypeMessage)}
// Feed layer: try to determine chat_type, but don't fail if we can't.
// Most messages only have one layer flagged, so this is best-effort cleanup.
chatID, err := getMessageChatID(rt, id)
if err != nil {
// Can't get chat_id, warn and skip feed layer
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
return items, nil
}
feedIT, err := resolveThreadFeedItemType(rt, chatID)
if err != nil {
// Can't determine chat_type, warn and skip feed layer
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
return items, nil
}
// Include feed layer
items = append(items, newFlagItem(id, feedIT, FlagTypeFeed))
return items, nil
}
// buildSingleCancelItem builds a single cancel item when user provides explicit flags.
func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error) {
var itemType ItemType
var flagType FlagType
if itOverride != "" {
it, err := parseItemType(itOverride)
if err != nil {
return flagItem{}, err
}
itemType = it
}
if ftOverride != "" {
ft, err := parseFlagType(ftOverride)
if err != nil {
return flagItem{}, err
}
flagType = ft
}
if itOverride == "" || ftOverride == "" {
inferIT, inferFT, err := parseItemID(id)
if err != nil {
return flagItem{}, err
}
if itOverride == "" {
itemType = inferIT
}
if ftOverride == "" {
flagType = inferFT
}
}
if !isValidCombo(itemType, flagType) {
// Provide more specific hints for common mistakes
if itOverride != "" && ftOverride == "" {
if itemType == ItemTypeThread || itemType == ItemTypeMsgThread {
return flagItem{}, output.ErrValidation(
"invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)",
itOverride)
}
return flagItem{}, output.ErrValidation(
"invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override",
itOverride, flagTypeString(flagType))
}
if itOverride == "" && ftOverride != "" {
return flagItem{}, output.ErrValidation(
"invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override",
ftOverride, itemTypeString(itemType))
}
return flagItem{}, output.ErrValidation(
"invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed")
}
return newFlagItem(id, itemType, flagType), nil
}
// itemTypeString converts ItemType to a user-facing string.
func itemTypeString(it ItemType) string {
switch it {
case ItemTypeDefault:
return "default"
case ItemTypeThread:
return "thread"
case ItemTypeMsgThread:
return "msg_thread"
}
return "unknown"
}
// flagTypeString converts FlagType to a user-facing string.
func flagTypeString(ft FlagType) string {
switch ft {
case FlagTypeFeed:
return "feed"
case FlagTypeMessage:
return "message"
}
return "unknown"
}

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFlagCreate provides the +flag-create shortcut for creating a bookmark on a message.
var ImFlagCreate = common.Shortcut{
Service: "im",
Command: "+flag-create",
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "message ID (om_xxx)"},
{Name: "item-type", Desc: "item type override: default|thread|msg_thread (rarely needed)"},
{Name: "flag-type", Desc: "flag type: message (default) or feed"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildCreateItemForPreview(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
item, err := buildCreateItemForPreview(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
d := common.NewDryRunAPI().
POST("/open-apis/im/v1/flags").
Body(map[string]any{"flag_items": []any{item}})
if m, ok := item.(map[string]string); ok && m["item_type"] == "<auto:thread|msg_thread>" {
d.Desc("feed-layer item_type is auto-detected at execution time by reading the message chat and chat_mode")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
item, err := buildCreateItem(runtime)
if err != nil {
return err
}
// Combo validation already done in Validate, but double-check as a safety net.
if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
return output.ErrValidation(
"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
"(default, message), (thread, feed), or (msg_thread, feed)",
item.ItemType, item.FlagType)
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil,
map[string]any{"flag_items": []flagItem{item}})
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// buildCreateItemForPreview derives a preview payload without making network calls.
// Feed-layer execution auto-detects item_type from chat_mode, but dry-run must
// not query the message or chat APIs, so it uses an explicit placeholder.
func buildCreateItemForPreview(rt *common.RuntimeContext) (any, error) {
id, err := flagMessageID(rt)
if err != nil {
return nil, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
if err != nil {
return nil, err
}
flagType := FlagTypeMessage
if combo.FlagTypeSet {
flagType = combo.FlagType
}
if flagType == FlagTypeMessage {
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
}
if combo.ItemTypeSet {
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
}
return map[string]string{
"item_id": id,
"item_type": "<auto:thread|msg_thread>",
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
}, nil
}
// buildCreateItem derives a flagItem for the create path.
//
// Resolution logic:
// 1. No --flag-type or --flag-type=message → (default, message)
// 2. --flag-type=feed (no --item-type) → query message to get chat_id,
// then query chat_mode to determine: topic-style → (thread, feed), regular → (msg_thread, feed)
// 3. Both --item-type and --flag-type provided → honor verbatim (for edge cases)
func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) {
id, err := flagMessageID(rt)
if err != nil {
return flagItem{}, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
if err != nil {
return flagItem{}, err
}
flagType := FlagTypeMessage
if combo.FlagTypeSet {
flagType = combo.FlagType
}
// Message-layer flag: always (default, message)
if flagType == FlagTypeMessage {
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
}
// Feed-layer flag: need to determine item_type from chat_mode
if combo.ItemTypeSet {
// User explicitly specified item-type, honor it
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
}
chatID, err := getMessageChatID(rt, id)
if err != nil {
return flagItem{}, output.ErrValidation(
"failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err)
}
if chatID == "" {
return flagItem{}, output.ErrValidation(
"message does not belong to a chat; feed-layer flags are only for messages in chats")
}
feedIT, err := resolveThreadFeedItemType(rt, chatID)
if err != nil {
return flagItem{}, output.ErrValidation(
"failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err)
}
return newFlagItem(id, feedIT, FlagTypeFeed), nil
}
type explicitFlagCombo struct {
ItemType ItemType
FlagType FlagType
ItemTypeSet bool
FlagTypeSet bool
}
func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, error) {
itOverride = strings.TrimSpace(itOverride)
ftOverride = strings.TrimSpace(ftOverride)
var combo explicitFlagCombo
if itOverride != "" {
it, err := parseItemType(itOverride)
if err != nil {
return explicitFlagCombo{}, err
}
combo.ItemType = it
combo.ItemTypeSet = true
}
if ftOverride != "" {
ft, err := parseFlagType(ftOverride)
if err != nil {
return explicitFlagCombo{}, err
}
combo.FlagType = ft
combo.FlagTypeSet = true
}
if combo.ItemTypeSet && !combo.FlagTypeSet {
switch combo.ItemType {
case ItemTypeThread, ItemTypeMsgThread:
return explicitFlagCombo{}, output.ErrValidation(
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride)
case ItemTypeDefault:
return explicitFlagCombo{}, output.ErrValidation(
"--item-type=default requires --flag-type=message; or omit both to use default behavior")
}
}
if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) {
return explicitFlagCombo{}, output.ErrValidation(
"invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed",
itOverride, ftOverride)
}
return combo, nil
}
// validateExplicitCombo validates the (item_type, flag_type) combination when
// the user explicitly provides flags. It does not make API calls - it only
// validates the logic for what the user explicitly specified.
func validateExplicitCombo(itOverride, ftOverride string) error {
_, err := parseExplicitFlagCombo(itOverride, ftOverride)
return err
}

View File

@@ -0,0 +1,300 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImFlagList provides the +flag-list shortcut for listing bookmarks.
// Feed-type thread entries are auto-enriched with message content.
var ImFlagList = common.Shortcut{
Service: "im",
Command: "+flag-list",
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
Risk: "read",
UserScopes: []string{flagReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
{Name: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateListOptions(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if err := validateListOptions(runtime); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
d := common.NewDryRunAPI().
GET("/open-apis/im/v1/flags").
Params(map[string]any{
"page_size": strconv.Itoa(runtime.Int("page-size")),
"page_token": runtime.Str("page-token"),
})
if runtime.Bool("enrich-feed-thread") {
d.Desc("conditional enrichment: if feed/thread flag items are missing message content, execution may also call GET /open-apis/im/v1/messages/mget and requires scopes im:message.group_msg:get_as_user im:message.p2p_msg:get_as_user; pass --enrich-feed-thread=false to skip this extra call and extra scopes")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// When --page-token is explicitly provided, the user wants a specific page —
// no auto-pagination regardless of --page-all.
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
return executeListAllPages(runtime)
}
data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
if err != nil {
return err
}
if runtime.Bool("enrich-feed-thread") {
if err := enrichFeedThreadItems(runtime, data); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
}
}
runtime.Out(data, nil)
return nil
},
}
func validateListOptions(rt *common.RuntimeContext) error {
if n := rt.Int("page-size"); n < 1 || n > 50 {
return output.ErrValidation("--page-size must be an integer between 1 and 50")
}
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
return output.ErrValidation("--page-limit must be an integer between 1 and 1000")
}
return nil
}
// listQuery builds the query parameters for the flag list API call.
// page_token is required by the server even on the first page — pass empty
// string when the user hasn't supplied one.
func listQuery(rt *common.RuntimeContext) larkcore.QueryParams {
return larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
"page_token": []string{rt.Str("page-token")},
}
}
// enrichFeedThreadItems attaches message body to feed-shape thread entries
// by calling messages/mget. The list API returns only IDs for feed-shape entries,
// so this enrichment is needed to provide full message content.
//
// NOTE: This function modifies data["flag_items"] in place by adding a "message" key
// to each feed-thread entry.
func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error {
// Only enrich active flags (flag_items), not canceled flags (delete_flag_items).
// Canceled message-type flags don't show message content, so thread-type flags don't need it either.
items, _ := data["flag_items"].([]any)
if len(items) == 0 {
return nil
}
// Index any messages the server already returned — saves a mget round-trip
// (ItemType=default+FlagType=Message responses already carry the message body).
byID := make(map[string]map[string]any)
if inline, ok := data["messages"].([]any); ok {
for _, m := range inline {
mm, _ := m.(map[string]any)
if mm == nil {
continue
}
if id := asString(mm["message_id"]); id != "" {
byID[id] = mm
}
}
}
// Collect feed-thread ids whose message body wasn't inlined — dedup to cut mget calls.
need := map[string]bool{}
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
ft := asString(m["flag_type"])
itStr := asString(m["item_type"])
if ft != strconv.Itoa(int(FlagTypeFeed)) {
continue
}
if itStr != strconv.Itoa(int(ItemTypeThread)) && itStr != strconv.Itoa(int(ItemTypeMsgThread)) {
continue
}
id := asString(m["item_id"])
if id == "" {
continue
}
if _, inlined := byID[id]; !inlined {
need[id] = true
}
}
if len(need) > 0 {
if err := checkFlagRequiredScopes(rt.Ctx(), rt, flagMessageReadScopes); err != nil {
return err
}
ids := make([]string, 0, len(need))
for id := range need {
ids = append(ids, id)
}
// /messages/mget accepts max 50 IDs per request — batch if needed.
const mgetBatchSize = 50
for i := 0; i < len(ids); i += mgetBatchSize {
end := i + mgetBatchSize
if end > len(ids) {
end = len(ids)
}
batch := ids[i:end]
got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget",
larkcore.QueryParams{"message_ids": batch}, nil)
if err != nil {
return err
}
fetched, _ := got["items"].([]any)
for _, m := range fetched {
mm, _ := m.(map[string]any)
if mm == nil {
continue
}
if id := asString(mm["message_id"]); id != "" {
byID[id] = mm
}
}
}
}
if len(byID) == 0 {
return nil
}
// Attach message payload to the matching list entries.
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
ft := asString(m["flag_type"])
itType := asString(m["item_type"])
if ft != strconv.Itoa(int(FlagTypeFeed)) {
continue
}
if itType != strconv.Itoa(int(ItemTypeThread)) && itType != strconv.Itoa(int(ItemTypeMsgThread)) {
continue
}
if msg, ok := byID[asString(m["item_id"])]; ok {
m["message"] = msg
}
}
return nil
}
// asString converts an arbitrary value to its string representation.
// Handles string, float64, int, int64, and json.Number types; returns empty string for other types.
func asString(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case int:
return strconv.Itoa(x)
case int64:
return strconv.FormatInt(x, 10)
case json.Number:
return x.String()
}
return ""
}
// executeListAllPages fetches all pages and merges the results into a single response.
// The flag list API returns items sorted by update_time ascending, so the last page
// contains the newest items.
func executeListAllPages(rt *common.RuntimeContext) error {
maxPages := rt.Int("page-limit")
if maxPages < 1 {
maxPages = 20
}
if maxPages > 1000 {
maxPages = 1000
}
// Use make([]any, 0) to ensure empty arrays serialize as [] not null
allFlagItems := make([]any, 0)
allDeleteFlagItems := make([]any, 0)
allMessages := make([]any, 0)
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__" // Sentinel to detect unchanged token
for page := 0; page < maxPages; page++ {
token := ""
if page > 0 {
token = lastPageToken
}
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags",
larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
"page_token": []string{token},
}, nil)
if err != nil {
return err
}
if v, ok := data["flag_items"].([]any); ok {
allFlagItems = append(allFlagItems, v...)
}
if v, ok := data["delete_flag_items"].([]any); ok {
allDeleteFlagItems = append(allDeleteFlagItems, v...)
}
if v, ok := data["messages"].([]any); ok {
allMessages = append(allMessages, v...)
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
// Progress output to stderr
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d flags, %d deleted\n",
page+1, len(allFlagItems), len(allDeleteFlagItems))
if !lastHasMore || lastPageToken == "" {
break
}
// Detect server anomaly: same token returned twice means infinite loop
if lastPageToken == prevPageToken {
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
break
}
prevPageToken = lastPageToken
}
merged := map[string]any{
"flag_items": allFlagItems,
"delete_flag_items": allDeleteFlagItems,
"messages": allMessages,
"has_more": lastHasMore,
"page_token": lastPageToken,
}
if rt.Bool("enrich-feed-thread") {
if err := enrichFeedThreadItems(rt, merged); err != nil {
fmt.Fprintf(rt.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
}
}
rt.Out(merged, nil)
return nil
}

1812
shortcuts/im/im_flag_test.go Normal file

File diff suppressed because it is too large Load Diff

320
shortcuts/im/mute_filter.go Normal file
View File

@@ -0,0 +1,320 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package-level helper: client-side filter that drops muted chats from
// search/list results by calling /open-apis/im/v1/chat_user_setting/batch_get_mute_status.
//
// The native chat search/list APIs do not return mute status; we fetch it as
// a separate batch lookup, then drop is_muted=true items. Non-member /
// invalid-format chat_ids come back via invalid_id_list and are silently
// retained (we don't know their mute state). Bot identity is unsupported by
// the upstream API (UAT-only), so we skip the filter and emit a machine-readable
// skipped indicator instead of erroring.
package im
import (
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// MuteFilterMeta describes the outcome of a single page's mute filter run.
// UnknownCount is internal — used to compose the hint, not exposed in JSON.
type MuteFilterMeta struct {
Applied string
Skipped bool
SkipReason string
FetchedCount int
ReturnedCount int
FilteredCount int
UnknownCount int
Hint string
}
// MaxMuteStatusBatchSize is the upstream cap for chat_ids per
// batch_get_mute_status call (after dedupe).
const MaxMuteStatusBatchSize = 100
// BatchGetMuteStatusPath is the upstream HTTP path.
const BatchGetMuteStatusPath = "/open-apis/im/v1/chat_user_setting/batch_get_mute_status"
// SkipReason constants — written to filter.skip_reason when Skipped=true.
const (
SkipReasonBotIdentity = "bot_identity_no_mute_data"
SkipReasonAllNonMember = "all_non_member_search_types"
)
// BuildMuteFilterHint composes the user/AI-facing English hint for a finished
// filter run. hasMore is the underlying API's has_more (so we can suggest paging).
// Returns "" when the filter ran but had no effect (FilteredCount==0 and not skipped).
func BuildMuteFilterHint(meta MuteFilterMeta, hasMore bool) string {
if meta.Skipped {
switch meta.SkipReason {
case SkipReasonBotIdentity:
return "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter."
case SkipReasonAllNonMember:
if hasMore {
return "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
}
return "All results on this page are non-member public groups; mute filter does not apply. No more pages."
}
return ""
}
if meta.FilteredCount == 0 {
return ""
}
tail := "no more pages."
if hasMore {
tail = "use --page-token to fetch more."
}
if meta.UnknownCount > 0 {
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining, including %d non-member public group(s)); %s",
meta.FilteredCount, meta.ReturnedCount, meta.UnknownCount, tail)
}
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining); %s",
meta.FilteredCount, meta.ReturnedCount, tail)
}
// BuildBatchGetMuteStatusBody constructs the request body for
// POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status.
func BuildBatchGetMuteStatusBody(chatIDs []string) map[string]interface{} {
return map[string]interface{}{"chat_ids": chatIDs}
}
// ParseBatchGetMuteStatusResponse maps the API response to:
// - muted: chat_id -> is_muted, only for ids returned in items
// - unknown: chat_ids that came back in invalid_id_list (any msg) OR
// were in input but missing from both lists.
//
// unknown preserves input order for stable hint output.
func ParseBatchGetMuteStatusResponse(input []string, resp map[string]interface{}) (map[string]bool, []string) {
muted := make(map[string]bool, len(input))
if rawItems, ok := resp["items"].([]interface{}); ok {
for _, raw := range rawItems {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
cid, _ := item["chat_id"].(string)
if cid == "" {
continue
}
isMuted, _ := item["is_muted"].(bool)
muted[cid] = isMuted
}
}
unknownSet := make(map[string]struct{})
if rawInvalid, ok := resp["invalid_id_list"].([]interface{}); ok {
for _, raw := range rawInvalid {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id, _ := item["id"].(string)
if id != "" {
unknownSet[id] = struct{}{}
}
}
}
for _, id := range input {
if _, hasMute := muted[id]; hasMute {
continue
}
unknownSet[id] = struct{}{}
}
unknown := make([]string, 0, len(unknownSet))
for _, id := range input {
if _, ok := unknownSet[id]; ok {
unknown = append(unknown, id)
delete(unknownSet, id) // dedupe while preserving input order
}
}
return muted, unknown
}
// ApplyMuteFilter drops chats whose mute map entry is true. Chats whose id
// is in the unknown set, or which have no chatIDKey value, are retained
// (we have no basis to filter them) and counted as UnknownCount.
//
// Pure function; no API calls. The caller is responsible for fetching the
// mute map via FetchMuteStatus.
//
// Invariant: meta.FetchedCount == meta.ReturnedCount + meta.FilteredCount.
func ApplyMuteFilter(
chats []map[string]interface{},
chatIDKey string,
muted map[string]bool,
unknown []string,
) ([]map[string]interface{}, MuteFilterMeta) {
unknownSet := make(map[string]struct{}, len(unknown))
for _, id := range unknown {
unknownSet[id] = struct{}{}
}
out := make([]map[string]interface{}, 0, len(chats))
meta := MuteFilterMeta{Applied: "exclude_muted", FetchedCount: len(chats)}
for _, row := range chats {
cid, _ := row[chatIDKey].(string)
if cid == "" {
out = append(out, row)
meta.UnknownCount++
continue
}
if _, isUnknown := unknownSet[cid]; isUnknown {
out = append(out, row)
meta.UnknownCount++
continue
}
if isMuted, ok := muted[cid]; ok {
if isMuted {
meta.FilteredCount++
continue
}
out = append(out, row)
continue
}
// Defensive: id not in muted, not in unknown — treat as unknown, retain.
out = append(out, row)
meta.UnknownCount++
}
meta.ReturnedCount = len(out)
return out, meta
}
// ExtractChatIDs collects unique chat_ids (in input order) from a page of rows.
// Rows missing the key or with an empty value are skipped.
func ExtractChatIDs(chats []map[string]interface{}, chatIDKey string) []string {
if len(chats) == 0 {
return nil
}
seen := make(map[string]struct{}, len(chats))
out := make([]string, 0, len(chats))
for _, row := range chats {
cid, _ := row[chatIDKey].(string)
if cid == "" {
continue
}
if _, dup := seen[cid]; dup {
continue
}
seen[cid] = struct{}{}
out = append(out, cid)
}
return out
}
// MuteFilterMetaToMap renders the meta as the "filter" sub-object the
// command writes into outData. The schema is fixed-shape: exactly 5 fields,
// regardless of skip state.
//
// Skip context (bot identity / all-non-member search-types) is encoded
// entirely in the Hint string — consumers read the natural-language hint
// to understand why the filter did or did not apply. UnknownCount and the
// Skipped / SkipReason struct fields are internal-only (used to compose
// Hint) and are not exposed in JSON.
func MuteFilterMetaToMap(meta MuteFilterMeta) map[string]interface{} {
return map[string]interface{}{
"applied": meta.Applied,
"fetched_count": meta.FetchedCount,
"returned_count": meta.ReturnedCount,
"filtered_count": meta.FilteredCount,
"hint": meta.Hint,
}
}
// FetchMuteStatus calls batch_get_mute_status for the given chat_ids and
// parses the result. Caller MUST ensure len(chatIDs) <= MaxMuteStatusBatchSize
// (the shortcuts already cap --page-size at 100, so a single page is safe).
//
// Empty input is a no-op (avoids triggering the upstream "chat_ids is empty"
// InvalidParam).
func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[string]bool, []string, error) {
if len(chatIDs) == 0 {
return map[string]bool{}, nil, nil
}
if len(chatIDs) > MaxMuteStatusBatchSize {
return nil, nil, output.ErrValidation(
"batch_get_mute_status accepts at most %d chat_ids per call (got %d)",
MaxMuteStatusBatchSize, len(chatIDs))
}
body := BuildBatchGetMuteStatusBody(chatIDs)
resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body)
if err != nil {
return nil, nil, fmt.Errorf("fetch mute status: %w", err)
}
muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp)
return muted, unknown, nil
}
// MuteFilterInput captures everything the orchestrator needs from the calling shortcut.
type MuteFilterInput struct {
ExcludeMuted bool // value of --exclude-muted
IsBot bool // current identity
PreSkipReason string // optional caller-supplied skip reason (e.g. SkipReasonAllNonMember); leave empty under bot — IsBot is handled separately
Chats []map[string]interface{} // page of result rows
ChatIDKey string // key in row holding the chat_id ("chat_id" for both v1 list and v2 search meta_data)
HasMore bool // for hint composition
}
// MuteFilterOutput is what the shortcut writes back into outData.
type MuteFilterOutput struct {
Chats []map[string]interface{} // filtered (or unchanged when not applied)
Meta MuteFilterMeta // zero-valued when ExcludeMuted=false; callers detect via Meta.Applied != ""
}
// MaybeApplyMuteFilter is the single entry point shortcuts call.
//
// Behavior:
// - ExcludeMuted=false: returns chats unchanged, Meta is zero-valued (Applied=="")
// - ExcludeMuted=true && IsBot: skip the API call, mark Skipped with SkipReasonBotIdentity
// - ExcludeMuted=true && PreSkipReason!="" (not bot): skip the API call, mark Skipped with that reason
// - ExcludeMuted=true && len(chats)==0: skip the API call (avoids upstream
// InvalidParam on empty chat_ids); meta has zero counts, Skipped=false
// - ExcludeMuted=true && otherwise: fetch + apply; populate counts and Hint
//
// Callers detect whether the filter ran via out.Meta.Applied != "".
// Callers compose the JSON map via MuteFilterMetaToMap(out.Meta) at the use site.
func MaybeApplyMuteFilter(runtime *common.RuntimeContext, in MuteFilterInput) (MuteFilterOutput, error) {
if !in.ExcludeMuted {
return MuteFilterOutput{Chats: in.Chats}, nil
}
meta := MuteFilterMeta{
Applied: "exclude_muted",
FetchedCount: len(in.Chats),
ReturnedCount: len(in.Chats),
}
switch {
case in.IsBot:
meta.Skipped = true
meta.SkipReason = SkipReasonBotIdentity
case in.PreSkipReason != "":
meta.Skipped = true
meta.SkipReason = in.PreSkipReason
case len(in.Chats) == 0:
// counts already zero; Skipped stays false
default:
ids := ExtractChatIDs(in.Chats, in.ChatIDKey)
muted, unknown, err := FetchMuteStatus(runtime, ids)
if err != nil {
return MuteFilterOutput{}, err
}
var filtered []map[string]interface{}
filtered, meta = ApplyMuteFilter(in.Chats, in.ChatIDKey, muted, unknown)
in.Chats = filtered
}
meta.Hint = BuildMuteFilterHint(meta, in.HasMore)
return MuteFilterOutput{
Chats: in.Chats,
Meta: meta,
}, nil
}

View File

@@ -0,0 +1,445 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"fmt"
"reflect"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildMuteFilterHint(t *testing.T) {
cases := []struct {
name string
meta MuteFilterMeta
hasMore bool
want string
}{
{
name: "1 skipped bot identity",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity},
hasMore: false,
want: "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter.",
},
{
name: "2 skipped all non-member, has_more",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
hasMore: true,
want: "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more.",
},
{
name: "3 skipped all non-member, no more",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
hasMore: false,
want: "All results on this page are non-member public groups; mute filter does not apply. No more pages.",
},
{
name: "4 filtered>0 unknown=0 has_more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
hasMore: true,
want: "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more.",
},
{
name: "5 filtered>0 unknown=0 no more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
hasMore: false,
want: "Filtered out 3 muted chat(s) on this page (17 remaining); no more pages.",
},
{
name: "6 filtered>0 unknown>0 has_more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
hasMore: true,
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more.",
},
{
name: "7 filtered>0 unknown>0 no more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
hasMore: false,
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); no more pages.",
},
{
name: "8 filtered=0 returns empty regardless of unknown/hasMore",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 5, ReturnedCount: 5, UnknownCount: 2},
hasMore: true,
want: "",
},
{
name: "9 skipped with unrecognized reason returns empty",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: "unknown_reason"},
hasMore: false,
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := BuildMuteFilterHint(c.meta, c.hasMore)
if got != c.want {
t.Fatalf("BuildMuteFilterHint() = %q, want %q", got, c.want)
}
})
}
}
func TestBuildBatchGetMuteStatusBody(t *testing.T) {
got := BuildBatchGetMuteStatusBody([]string{"oc_a", "oc_b"})
want := map[string]interface{}{"chat_ids": []string{"oc_a", "oc_b"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("BuildBatchGetMuteStatusBody() = %v, want %v", got, want)
}
}
func TestParseBatchGetMuteStatusResponse(t *testing.T) {
t.Run("happy path with mixed muted/non-muted/invalid", func(t *testing.T) {
input := []string{"oc_a", "oc_b", "oc_c", "bad"}
resp := map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
map[string]interface{}{"chat_id": "oc_b", "is_muted": false},
},
"invalid_id_list": []interface{}{
map[string]interface{}{"id": "oc_c", "msg": "not_a_member"},
map[string]interface{}{"id": "bad", "msg": "invalid_format"},
},
}
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
wantMuted := map[string]bool{"oc_a": true, "oc_b": false}
wantUnknown := []string{"oc_c", "bad"}
if !reflect.DeepEqual(muted, wantMuted) {
t.Fatalf("muted = %v, want %v", muted, wantMuted)
}
if !reflect.DeepEqual(unknown, wantUnknown) {
t.Fatalf("unknown = %v, want %v", unknown, wantUnknown)
}
})
t.Run("missing chat_ids fall through to unknown", func(t *testing.T) {
input := []string{"oc_a", "oc_b"}
resp := map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
},
}
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) {
t.Fatalf("muted = %v", muted)
}
if !reflect.DeepEqual(unknown, []string{"oc_b"}) {
t.Fatalf("unknown = %v", unknown)
}
})
t.Run("empty response yields all unknown", func(t *testing.T) {
input := []string{"oc_a"}
muted, unknown := ParseBatchGetMuteStatusResponse(input, map[string]interface{}{})
if len(muted) != 0 {
t.Fatalf("muted = %v, want empty", muted)
}
if !reflect.DeepEqual(unknown, []string{"oc_a"}) {
t.Fatalf("unknown = %v", unknown)
}
})
t.Run("skips nil entries and empty chat_id in items/invalid_id_list", func(t *testing.T) {
input := []string{"oc_a", "oc_b"}
resp := map[string]interface{}{
"items": []interface{}{
nil,
map[string]interface{}{"chat_id": "", "is_muted": false},
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
},
"invalid_id_list": []interface{}{
nil,
map[string]interface{}{"id": "oc_b", "msg": "not_a_member"},
},
}
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) {
t.Fatalf("muted = %v", muted)
}
if !reflect.DeepEqual(unknown, []string{"oc_b"}) {
t.Fatalf("unknown = %v", unknown)
}
})
}
func TestApplyMuteFilter(t *testing.T) {
chats := []map[string]interface{}{
{"chat_id": "oc_a", "name": "alpha"},
{"chat_id": "oc_b", "name": "beta"},
{"chat_id": "oc_c", "name": "gamma"},
{"chat_id": "oc_d", "name": "delta"},
}
t.Run("drops only is_muted=true", func(t *testing.T) {
muted := map[string]bool{"oc_a": true, "oc_b": false, "oc_c": true, "oc_d": false}
got, meta := ApplyMuteFilter(chats, "chat_id", muted, nil)
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0]["chat_id"] != "oc_b" || got[1]["chat_id"] != "oc_d" {
t.Fatalf("got = %v, want [oc_b, oc_d]", got)
}
want := MuteFilterMeta{
Applied: "exclude_muted", FetchedCount: 4, ReturnedCount: 2, FilteredCount: 2, UnknownCount: 0,
}
if meta != want {
t.Fatalf("meta = %+v, want %+v", meta, want)
}
})
t.Run("retains unknown chats and counts them", func(t *testing.T) {
muted := map[string]bool{"oc_a": true, "oc_b": false}
unknown := []string{"oc_c", "oc_d"}
got, meta := ApplyMuteFilter(chats, "chat_id", muted, unknown)
if len(got) != 3 {
t.Fatalf("len(got) = %d, want 3 (oc_b + oc_c + oc_d)", len(got))
}
if meta.FilteredCount != 1 || meta.UnknownCount != 2 || meta.ReturnedCount != 3 {
t.Fatalf("meta = %+v, want filtered=1 unknown=2 returned=3", meta)
}
})
t.Run("preserves original order", func(t *testing.T) {
muted := map[string]bool{"oc_b": true}
got, _ := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"})
gotIDs := []string{}
for _, r := range got {
gotIDs = append(gotIDs, r["chat_id"].(string))
}
want := []string{"oc_a", "oc_c", "oc_d"}
if !reflect.DeepEqual(gotIDs, want) {
t.Fatalf("ordering = %v, want %v", gotIDs, want)
}
})
t.Run("missing chatIDKey treated as unknown but kept", func(t *testing.T) {
bad := []map[string]interface{}{{"name": "no_id"}}
got, meta := ApplyMuteFilter(bad, "chat_id", map[string]bool{}, nil)
if len(got) != 1 {
t.Fatalf("missing-id row should be retained, got len = %d", len(got))
}
if meta.UnknownCount != 1 || meta.FilteredCount != 0 || meta.ReturnedCount != 1 {
t.Fatalf("meta = %+v, want unknown=1 filtered=0 returned=1", meta)
}
})
t.Run("invariant fetched == returned + filtered", func(t *testing.T) {
muted := map[string]bool{"oc_a": true, "oc_b": false}
_, meta := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"})
if meta.FetchedCount != meta.ReturnedCount+meta.FilteredCount {
t.Fatalf("invariant broken: fetched=%d, returned=%d, filtered=%d",
meta.FetchedCount, meta.ReturnedCount, meta.FilteredCount)
}
})
}
func TestExtractChatIDs(t *testing.T) {
t.Run("dedupes and preserves order", func(t *testing.T) {
chats := []map[string]interface{}{
{"chat_id": "oc_a"},
{"chat_id": "oc_b"},
{"chat_id": "oc_a"},
{"chat_id": ""},
{"name": "no_id"},
{"chat_id": "oc_c"},
}
got := ExtractChatIDs(chats, "chat_id")
want := []string{"oc_a", "oc_b", "oc_c"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExtractChatIDs() = %v, want %v", got, want)
}
})
t.Run("empty input yields empty slice", func(t *testing.T) {
got := ExtractChatIDs(nil, "chat_id")
if len(got) != 0 {
t.Fatalf("ExtractChatIDs(nil) = %v, want empty", got)
}
})
}
func TestMuteFilterMetaToMap(t *testing.T) {
wantKeys := []string{"applied", "fetched_count", "returned_count", "filtered_count", "hint"}
t.Run("active filter exposes exactly 5 fields", func(t *testing.T) {
meta := MuteFilterMeta{
Applied: "exclude_muted",
FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2,
Hint: "test hint",
}
got := MuteFilterMetaToMap(meta)
if got["applied"] != "exclude_muted" ||
got["fetched_count"] != 20 || got["returned_count"] != 19 ||
got["filtered_count"] != 1 || got["hint"] != "test hint" {
t.Fatalf("MuteFilterMetaToMap() = %v", got)
}
assertExactKeys(t, got, wantKeys)
})
t.Run("skipped path: hint carries the skip explanation, no extra fields", func(t *testing.T) {
meta := MuteFilterMeta{
Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity,
FetchedCount: 5, ReturnedCount: 5, Hint: "skipped hint",
}
got := MuteFilterMetaToMap(meta)
if got["hint"] != "skipped hint" {
t.Fatalf("hint = %v, want \"skipped hint\"", got["hint"])
}
assertExactKeys(t, got, wantKeys)
})
}
// assertExactKeys fails the test if got has any keys outside want, or is missing any.
func assertExactKeys(t *testing.T, got map[string]interface{}, want []string) {
t.Helper()
wantSet := make(map[string]struct{}, len(want))
for _, k := range want {
wantSet[k] = struct{}{}
if _, ok := got[k]; !ok {
t.Errorf("missing required key %q", k)
}
}
for k := range got {
if _, ok := wantSet[k]; !ok {
t.Errorf("unexpected key %q in MuteFilterMetaToMap output (got %v)", k, got)
}
}
}
// runtimeForOrchestrator builds a minimal RuntimeContext for testing the
// branches of MaybeApplyMuteFilter that do NOT call the underlying API.
func runtimeForOrchestrator(t *testing.T) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestMaybeApplyMuteFilter_NotEnabled(t *testing.T) {
rt := runtimeForOrchestrator(t)
chats := []map[string]interface{}{{"chat_id": "oc_a"}}
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: false,
Chats: chats,
ChatIDKey: "chat_id",
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 1 || out.Meta.Applied != "" {
t.Fatalf("expected pass-through, got chats=%v meta.applied=%q", out.Chats, out.Meta.Applied)
}
}
func TestMaybeApplyMuteFilter_BotIdentity(t *testing.T) {
rt := runtimeForOrchestrator(t)
chats := []map[string]interface{}{
{"chat_id": "oc_a"},
{"chat_id": "oc_b"},
}
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: true,
IsBot: true,
Chats: chats,
ChatIDKey: "chat_id",
HasMore: false,
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 2 {
t.Fatalf("bot skip should retain all chats, got %d", len(out.Chats))
}
if !out.Meta.Skipped {
t.Fatalf("skipped should be true, got meta=%+v", out.Meta)
}
if out.Meta.SkipReason != SkipReasonBotIdentity {
t.Fatalf("skip_reason = %v", out.Meta.SkipReason)
}
wantHint := "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter."
if out.Meta.Hint != wantHint {
t.Fatalf("hint = %q", out.Meta.Hint)
}
}
func TestMaybeApplyMuteFilter_PreSkipAllNonMember(t *testing.T) {
rt := runtimeForOrchestrator(t)
chats := []map[string]interface{}{
{"chat_id": "oc_a"},
{"chat_id": "oc_b"},
}
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: true,
IsBot: false,
PreSkipReason: SkipReasonAllNonMember,
Chats: chats,
ChatIDKey: "chat_id",
HasMore: true,
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 2 {
t.Fatalf("pre-skip should retain all chats, got %d", len(out.Chats))
}
if !out.Meta.Skipped || out.Meta.SkipReason != SkipReasonAllNonMember {
t.Fatalf("meta = %+v", out.Meta)
}
wantHint := "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
if out.Meta.Hint != wantHint {
t.Fatalf("hint = %q", out.Meta.Hint)
}
}
func TestMaybeApplyMuteFilter_EmptyPage(t *testing.T) {
rt := runtimeForOrchestrator(t)
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: true,
Chats: nil,
ChatIDKey: "chat_id",
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 0 {
t.Fatalf("expected empty out, got %v", out.Chats)
}
if out.Meta.Applied != "exclude_muted" {
t.Fatalf("meta.applied = %q, want exclude_muted", out.Meta.Applied)
}
if out.Meta.FetchedCount != 0 || out.Meta.ReturnedCount != 0 || out.Meta.FilteredCount != 0 {
t.Fatalf("counts should all be zero, got meta=%+v", out.Meta)
}
if out.Meta.Skipped {
t.Fatalf("empty page is not 'skipped', got meta.skipped=%v", out.Meta.Skipped)
}
}
func TestFetchMuteStatus_OverLimit(t *testing.T) {
rt := runtimeForOrchestrator(t)
ids := make([]string, MaxMuteStatusBatchSize+1)
for i := range ids {
ids[i] = fmt.Sprintf("oc_%d", i)
}
_, _, err := FetchMuteStatus(rt, ids)
if err == nil {
t.Fatalf("expected error on over-limit batch")
}
}
func TestFetchMuteStatus_Empty(t *testing.T) {
rt := runtimeForOrchestrator(t)
muted, unknown, err := FetchMuteStatus(rt, nil)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(muted) != 0 || len(unknown) != 0 {
t.Fatalf("expected empty results, got muted=%v unknown=%v", muted, unknown)
}
}

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatList,
ImChatMessageList,
ImChatSearch,
ImChatUpdate,
@@ -18,5 +19,8 @@ func Shortcuts() []common.Shortcut {
ImMessagesSearch,
ImMessagesSend,
ImThreadsMessagesList,
ImFlagCreate,
ImFlagCancel,
ImFlagList,
}
}

View File

@@ -0,0 +1,288 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/internal/output"
)
// flagName is a package-private snapshot of a pflag.Flag's identity.
type flagName struct {
long, short string
hidden bool
}
// Candidate is a single suggested flag returned to the user when an
// unknown flag is detected. It is serialised into the ErrorEnvelope's
// error.detail.candidates[] array.
type Candidate struct {
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
Flag string `json:"flag"`
// Shorthand is the single-character shorthand (without the leading
// dash) when the suggested flag has one; empty otherwise.
Shorthand string `json:"shorthand,omitempty"`
// Distance is the Levenshtein edit distance to the unknown token.
// Zero indicates a bidirectional prefix hit (Reason == "prefix").
Distance int `json:"distance"`
// Reason explains how the candidate was matched: "prefix" for
// bidirectional prefix hits, "edit_distance" for fuzzy matches.
Reason string `json:"reason"`
}
// maxCandidates caps the number of suggestions returned per error so
// the JSON envelope stays compact and the user-visible hint remains
// scannable.
const maxCandidates = 5
// InstallOnMail attaches the unknown-flag fuzzy-match hook on the mail
// service cobra parent command. It is invoked exactly once from
// shortcuts/register.go inside the `service == "mail"` branch.
//
// Cobra's FlagErrorFunc walks up the parent chain looking for the nearest
// non-nil hook, so every mail subcommand inherits this behaviour without
// any per-shortcut wiring.
func InstallOnMail(svc *cobra.Command) {
if svc == nil {
return
}
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
}
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a
// structured *output.ExitError carrying candidate suggestions. Any other
// error is passed through unchanged so cobra's existing handling kicks in.
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
if err == nil {
return nil
}
token, isShorthand, ok := parseUnknownToken(err.Error())
if !ok {
// Non unknown-flag errors (e.g. "required flag(s) ... not set")
// pass through to cmd/root.go::handleRootError's fallback path.
return err
}
names := collectFlags(c)
var matches []Candidate
if isShorthand {
matches = suggestShorthand(token, names)
} else {
matches = suggest(token, names)
}
// Normalise to a non-nil slice so the JSON envelope always emits
// `candidates: []` instead of `null`, keeping the wire shape stable
// for downstream parsers regardless of command-state.
if matches == nil {
matches = []Candidate{}
}
hint := buildHint(c, matches)
detail := map[string]any{
"unknown": rawUnknownToken(token, isShorthand),
"command_path": c.CommandPath(),
"candidates": matches,
}
// Code is ExitAPI (=1), matching cobra's default unknown-flag exit
// code. The structured type discrimination lives in error.type.
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: err.Error(),
Hint: hint,
Detail: detail,
},
}
}
// parseUnknownToken extracts the offending flag name from a pflag error
// string. Recognised forms:
//
// - "unknown flag: --tos"
// - "unknown flag: --bogus=val"
// - "unknown shorthand flag: 'X' in -Xyz"
//
// Anything else returns (_, _, false) so the caller can pass the error
// through unchanged.
func parseUnknownToken(errMsg string) (token string, isShorthand bool, ok bool) {
const longPrefix = "unknown flag: --"
const shortPrefix = "unknown shorthand flag: '"
switch {
case strings.HasPrefix(errMsg, longPrefix):
rest := errMsg[len(longPrefix):]
if eq := strings.IndexAny(rest, "= \t"); eq >= 0 {
rest = rest[:eq]
}
return rest, false, rest != ""
case strings.HasPrefix(errMsg, shortPrefix):
rest := errMsg[len(shortPrefix):]
end := strings.IndexByte(rest, '\'')
if end <= 0 {
return "", false, false
}
return rest[:end], true, true
}
return "", false, false
}
// rawUnknownToken re-attaches the leading dash(es) to a bare token so the
// JSON envelope echoes the user-visible spelling.
func rawUnknownToken(token string, isShorthand bool) string {
if isShorthand {
return "-" + token
}
return "--" + token
}
// collectFlags snapshots the merged local + persistent + inherited flag
// set of cmd. The hidden bit is preserved on each entry; the suggest
// helpers apply the actual filter so the slice stays reusable.
func collectFlags(cmd *cobra.Command) []flagName {
if cmd == nil {
return nil
}
var out []flagName
cmd.Flags().VisitAll(func(f *pflag.Flag) {
out = append(out, flagName{long: f.Name, short: f.Shorthand, hidden: f.Hidden})
})
return out
}
// suggest produces top-N long-flag candidates for an unknown token, using
// bidirectional prefix matching first and Levenshtein distance for the
// remainder. Hidden flags and empty long names are skipped. Results are
// stably sorted by (Distance asc, Flag asc) and capped at maxCandidates.
func suggest(unknown string, names []flagName) []Candidate {
if unknown == "" || len(names) == 0 {
return nil
}
threshold := levThreshold(unknown)
out := make([]Candidate, 0, len(names))
seen := make(map[string]struct{}, len(names))
// Priority 1: bidirectional prefix match.
for _, n := range names {
if n.hidden || n.long == "" {
continue
}
if strings.HasPrefix(n.long, unknown) || strings.HasPrefix(unknown, n.long) {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
seen[n.long] = struct{}{}
}
}
// Priority 2: Levenshtein distance, skipping already-matched names.
for _, n := range names {
if n.hidden || n.long == "" {
continue
}
if _, ok := seen[n.long]; ok {
continue
}
if d := levenshtein(unknown, n.long); d <= threshold {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: d, Reason: "edit_distance"})
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Distance != out[j].Distance {
return out[i].Distance < out[j].Distance
}
return out[i].Flag < out[j].Flag
})
if len(out) > maxCandidates {
out = out[:maxCandidates]
}
return out
}
// suggestShorthand produces candidates for an unknown single-character
// shorthand. It first looks for exact f.Shorthand matches; if there are
// none, it falls back to long names that begin with the same character.
// Levenshtein is deliberately not used here since single-char edit
// distance would match almost every flag.
func suggestShorthand(c string, names []flagName) []Candidate {
if c == "" || len(names) == 0 {
return nil
}
out := make([]Candidate, 0)
for _, n := range names {
if n.hidden {
continue
}
if n.short == c {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
}
}
if len(out) == 0 {
for _, n := range names {
if n.hidden || n.long == "" {
continue
}
if strings.HasPrefix(n.long, c) {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Flag < out[j].Flag })
if len(out) > maxCandidates {
out = out[:maxCandidates]
}
return out
}
// buildHint returns a one-line hint suitable for the ErrorEnvelope.
// When at least one candidate exists, the top hit is named; otherwise
// the user is directed to --help.
func buildHint(c *cobra.Command, matches []Candidate) string {
if len(matches) == 0 {
return fmt.Sprintf("Run `%s --help` to view available flags", c.CommandPath())
}
return fmt.Sprintf("Did you mean: %s ?", matches[0].Flag)
}
// levThreshold returns the maximum acceptable Levenshtein distance for a
// token of the given length, clamped to [1, 4].
func levThreshold(s string) int {
t := len(s)/3 + 1
if t < 1 {
return 1
}
if t > 4 {
return 4
}
return t
}
// levenshtein computes the standard Levenshtein edit distance between
// two ASCII strings using a 2-row dynamic-programming table.
func levenshtein(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[lb]
}

View File

@@ -0,0 +1,352 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/larksuite/cli/internal/output"
)
// --- suggest (long-flag) ---
func TestSuggest_Prefix(t *testing.T) {
names := []flagName{
{long: "to", short: "t"},
{long: "cc"},
{long: "subject", short: "s"},
}
got := suggest("tos", names)
require.NotEmpty(t, got)
// "tos" has --to as a prefix → bidirectional prefix hit, Distance=0.
assert.Equal(t, "--to", got[0].Flag)
assert.Equal(t, 0, got[0].Distance)
assert.Equal(t, "prefix", got[0].Reason)
}
func TestSuggest_Levenshtein(t *testing.T) {
names := []flagName{
{long: "subject"},
{long: "body"},
{long: "to"},
}
// Distance 1 from "subject".
got := suggest("subjec", names)
require.NotEmpty(t, got)
// "subjec" is prefix of "subject" → bidirectional prefix.
assert.Equal(t, "--subject", got[0].Flag)
assert.Equal(t, "prefix", got[0].Reason)
// True edit-distance: "subjeect" is not a prefix either way of "subject".
got = suggest("subjeect", names)
require.NotEmpty(t, got)
assert.Equal(t, "--subject", got[0].Flag)
assert.Equal(t, "edit_distance", got[0].Reason)
assert.GreaterOrEqual(t, got[0].Distance, 1)
}
func TestSuggest_HiddenSkipped(t *testing.T) {
names := []flagName{
{long: "internal-debug", hidden: true},
{long: "interactive"},
}
got := suggest("internal", names)
for _, c := range got {
assert.NotEqual(t, "--internal-debug", c.Flag, "hidden flag must not appear in suggestions")
}
}
func TestSuggest_TopNAndStableSort(t *testing.T) {
// 6 names all within threshold and at the same distance (1) from the
// unknown token so that the lexicographic tiebreak and maxCandidates
// cap are both exercised. (Earlier the names were 3-distance from
// "zzz" which is above the threshold of 2 — suggest returned empty
// and the assertions trivially passed.)
names := []flagName{
{long: "aaab"},
{long: "aaac"},
{long: "aaad"},
{long: "aaae"},
{long: "aaaf"},
{long: "aaag"},
}
got := suggest("aaaa", names)
require.Len(t, got, maxCandidates, "must cap at maxCandidates")
// All distances equal → lex ordering by Flag asc, top 5 alphabetically.
wantFlags := []string{"--aaab", "--aaac", "--aaad", "--aaae", "--aaaf"}
gotFlags := []string{got[0].Flag, got[1].Flag, got[2].Flag, got[3].Flag, got[4].Flag}
assert.Equal(t, wantFlags, gotFlags, "tiebreak must order by Flag asc")
}
// --- suggestShorthand ---
func TestSuggestShorthand_Exact(t *testing.T) {
names := []flagName{
{long: "to", short: "t"},
{long: "cc", short: "c"},
{long: "subject", short: "s"},
}
got := suggestShorthand("t", names)
require.NotEmpty(t, got)
assert.Equal(t, "--to", got[0].Flag)
assert.Equal(t, "t", got[0].Shorthand)
assert.Equal(t, "prefix", got[0].Reason)
}
func TestSuggestShorthand_PrefixFallback(t *testing.T) {
// No short matches "x"; fall back to long names starting with "x".
names := []flagName{
{long: "xargs"},
{long: "xterm"},
{long: "yargs"},
}
got := suggestShorthand("x", names)
require.NotEmpty(t, got)
flags := make([]string, 0, len(got))
for _, c := range got {
flags = append(flags, c.Flag)
}
assert.Contains(t, flags, "--xargs")
assert.Contains(t, flags, "--xterm")
assert.NotContains(t, flags, "--yargs")
}
// --- parseUnknownToken ---
func TestParseUnknownToken_Long(t *testing.T) {
tok, isShort, ok := parseUnknownToken("unknown flag: --tos")
assert.True(t, ok)
assert.False(t, isShort)
assert.Equal(t, "tos", tok)
tok, isShort, ok = parseUnknownToken("unknown flag: --bogus=val")
assert.True(t, ok)
assert.False(t, isShort)
assert.Equal(t, "bogus", tok, "must strip =value tail")
tok, _, ok = parseUnknownToken("unknown flag: --bogus value")
assert.True(t, ok)
assert.Equal(t, "bogus", tok, "must strip whitespace tail")
}
func TestParseUnknownToken_Shorthand(t *testing.T) {
tok, isShort, ok := parseUnknownToken("unknown shorthand flag: 'X' in -X")
assert.True(t, ok)
assert.True(t, isShort)
assert.Equal(t, "X", tok)
tok, isShort, ok = parseUnknownToken("unknown shorthand flag: 'q' in -qrs")
assert.True(t, ok)
assert.True(t, isShort)
assert.Equal(t, "q", tok)
}
func TestParseUnknownToken_NotMatch(t *testing.T) {
cases := []string{
`required flag(s) "to" not set`,
"some unrelated error",
"",
"unknown command \"foo\" for \"mail\"",
}
for _, in := range cases {
tok, isShort, ok := parseUnknownToken(in)
assert.False(t, ok, "input %q must not match", in)
assert.False(t, isShort)
assert.Equal(t, "", tok)
}
}
// --- flagSuggestErrorFunc ---
// newFakeMailCmd builds a cobra command tree resembling the mail parent
// with a handful of flags exercised by the hook tests.
func newFakeMailCmd() *cobra.Command {
c := &cobra.Command{Use: "mail"}
c.Flags().String("to", "", "recipients")
c.Flags().String("cc", "", "cc recipients")
c.Flags().String("subject", "", "subject")
c.Flags().StringP("body", "b", "", "body")
return c
}
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsExitError(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr), "expected *output.ExitError, got %T", got)
require.NotNil(t, exitErr.Detail)
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
assert.Equal(t, "unknown flag: --tos", exitErr.Detail.Message)
assert.Contains(t, exitErr.Detail.Hint, "--to")
detail, ok := exitErr.Detail.Detail.(map[string]any)
require.True(t, ok, "Detail.Detail should be map[string]any")
assert.Equal(t, "--tos", detail["unknown"])
assert.Equal(t, cmd.CommandPath(), detail["command_path"])
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok, "candidates should be []Candidate")
require.NotEmpty(t, cands)
var foundTo bool
for _, c := range cands {
if c.Flag == "--to" {
foundTo = true
assert.Equal(t, "prefix", c.Reason)
break
}
}
assert.True(t, foundTo, "expected --to in candidates")
}
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
cmd := newFakeMailCmd()
in := errors.New(`required flag(s) "to" not set`)
got := flagSuggestErrorFunc(cmd, in)
// Identity passthrough: same error pointer.
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
}
func TestFlagSuggestErrorFunc_ExitCodeIsOne(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
// Hard contract — both compile-time and runtime guards:
assert.Equal(t, output.ExitAPI, exitErr.Code, "unknown_flag must use ExitAPI, not ExitValidation")
assert.Equal(t, 1, output.ExitAPI, "ExitAPI constant must remain 1")
}
// --- edge-case coverage ---
func TestInstallOnMail_NilIsNoop(t *testing.T) {
// Must not panic; the nil-guard is the contract.
InstallOnMail(nil)
}
func TestInstallOnMail_InstallsHook(t *testing.T) {
c := newFakeMailCmd()
InstallOnMail(c)
require.NotNil(t, c.FlagErrorFunc())
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr), "installed hook must produce *output.ExitError")
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
}
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
cmd := newFakeMailCmd()
assert.NoError(t, flagSuggestErrorFunc(cmd, nil))
}
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
assert.Equal(t, "--tos", detail["unknown"], "value tail must be stripped before echoing")
}
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
assert.Equal(t, "-b", detail["unknown"])
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok)
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
require.NotEmpty(t, cands)
assert.Equal(t, "--body", cands[0].Flag)
assert.Equal(t, "b", cands[0].Shorthand)
}
func TestFlagSuggestErrorFunc_CandidatesAlwaysArray(t *testing.T) {
// A cobra command with no flags forces collectFlags → empty names →
// suggest → nil. The envelope must still expose candidates as a
// non-nil []Candidate so the JSON wire shape is "candidates: []"
// rather than "candidates: null".
bare := &cobra.Command{Use: "mail"}
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok, "candidates must be []Candidate even when empty")
assert.NotNil(t, cands, "candidates must be non-nil empty slice, not nil")
assert.Empty(t, cands)
}
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
cmd := newFakeMailCmd()
// Token with no plausible neighbor in {to, cc, subject, body}.
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
assert.Contains(t, exitErr.Detail.Hint, "--help")
}
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {
// Long form with empty token after the prefix.
_, _, ok := parseUnknownToken("unknown flag: --")
assert.False(t, ok, "empty long token must not match")
// Shorthand with no closing quote.
_, _, ok = parseUnknownToken("unknown shorthand flag: 'q")
assert.False(t, ok, "shorthand without closing quote must not match")
// Shorthand with empty char between quotes.
_, _, ok = parseUnknownToken("unknown shorthand flag: '' in -")
assert.False(t, ok, "empty shorthand token must not match")
}
func TestSuggest_EmptyInputs(t *testing.T) {
assert.Nil(t, suggest("", []flagName{{long: "to"}}))
assert.Nil(t, suggest("foo", nil))
}
func TestSuggestShorthand_EmptyInputs(t *testing.T) {
assert.Nil(t, suggestShorthand("", []flagName{{long: "to", short: "t"}}))
assert.Nil(t, suggestShorthand("x", nil))
}
func TestSuggestShorthand_HiddenSkipped(t *testing.T) {
names := []flagName{
{long: "secret", short: "s", hidden: true},
{long: "subject", short: "s"},
}
got := suggestShorthand("s", names)
for _, c := range got {
assert.NotEqual(t, "--secret", c.Flag, "hidden shorthand must not be suggested")
}
}
func TestCollectFlags_NilSafe(t *testing.T) {
assert.Nil(t, collectFlags(nil))
}
func TestLevThreshold_Clamp(t *testing.T) {
// len 0 → 0/3+1 = 1
assert.Equal(t, 1, levThreshold(""))
// len 3 → 2
assert.Equal(t, 2, levThreshold("abc"))
// Long token caps at 4.
assert.Equal(t, 4, levThreshold("aaaaaaaaaaaaaaaaaaaa"))
}
func TestLevenshtein_EmptyAndIdentical(t *testing.T) {
assert.Equal(t, 0, levenshtein("", ""))
assert.Equal(t, 3, levenshtein("", "abc"))
assert.Equal(t, 3, levenshtein("abc", ""))
assert.Equal(t, 0, levenshtein("abc", "abc"))
assert.Equal(t, 1, levenshtein("abc", "abd"))
}

View File

@@ -99,5 +99,8 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
for _, shortcut := range shortcuts {
shortcut.MountWithContext(ctx, svc, f)
}
if service == "mail" {
mail.InstallOnMail(svc)
}
}
}

View File

@@ -6,6 +6,7 @@ package shortcuts
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -14,6 +15,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -109,7 +111,7 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
@@ -135,11 +137,11 @@ func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T)
}
for _, want := range []string{
"Tips:",
"Agent version rule",
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
"Docs v1 is deprecated and will be removed soon",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
} {
if !strings.Contains(defaultHelp.String(), want) {
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
@@ -168,15 +170,22 @@ func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
for _, want := range []string{
"Document and content operations (v2).",
"Tips:",
"Agent version rule",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
}
}
for _, unwanted := range []string{
"Docs v1 is deprecated and will be removed soon",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
} {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs v2 help should not include %q:\n%s", unwanted, out.String())
}
}
}
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
@@ -253,24 +262,47 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T)
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
}
wantTips := []string{
"Tips:",
"Docs v1 is deprecated and will be removed soon",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
}
unwantedTips := []string{
"[NOTE]",
"Use --api-version v2 for the latest API",
"otherwise use the default v1 flags",
"legacy v1 examples and flags",
}
if tt.apiVersion == "v2" {
wantTips = []string{
"Tips:",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
}
unwantedTips = append(unwantedTips,
"Docs v1 is deprecated and will be removed soon",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
)
}
for _, want := range []string{
tt.shortcutHelp,
tt.versionedFlag,
"Tips:",
"Agent version rule",
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
}
}
for _, unwanted := range []string{
"[NOTE]",
"Use --api-version v2 for the latest API",
} {
for _, want := range wantTips {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
}
}
for _, unwanted := range unwantedTips {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
}
@@ -305,6 +337,65 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
}
}
// TestRegisterShortcutsInstallsMailFlagSuggestHook is the end-to-end
// wiring guard for the mail unknown-flag fuzzy-match feature: it ensures
// the `if service == "mail" { mail.InstallOnMail(svc) }` branch in
// RegisterShortcutsWithContext is actually exercised, so a future refactor
// that drops the branch (or breaks the import) will fail this test rather
// than silently regressing the structured-error contract.
func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
mailCmd, _, err := program.Find([]string{"mail"})
if err != nil {
t.Fatalf("find mail command: %v", err)
}
if mailCmd == nil || mailCmd.Name() != "mail" {
t.Fatalf("mail command not mounted: %#v", mailCmd)
}
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
// invoking it on the mail parent (or any of its children) must yield
// a structured *output.ExitError with type "unknown_flag".
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T (%v)", got, got)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "unknown_flag" {
t.Fatalf("expected Detail.Type=unknown_flag, got %#v", exitErr.Detail)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected Code=ExitAPI(%d), got %d", output.ExitAPI, exitErr.Code)
}
}
// TestRegisterShortcutsLeavesNonMailFlagErrorUntouched confirms the
// install is scoped: a non-mail service must keep the default cobra
// pass-through behaviour, otherwise an accidental fall-through in
// register.go would silently change every domain's error envelope.
func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
baseCmd, _, err := program.Find([]string{"base"})
if err != nil {
t.Fatalf("find base command: %v", err)
}
in := errors.New("unknown flag: --bogus")
got := baseCmd.FlagErrorFunc()(baseCmd, in)
// Default cobra hook is identity — anything else means the mail hook
// leaked across domains.
var exitErr *output.ExitError
if errors.As(got, &exitErr) {
t.Fatalf("base service unexpectedly produced *output.ExitError: %#v", exitErr)
}
if got != in {
t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got)
}
}
func TestGenerateShortcutsJSON(t *testing.T) {
output := os.Getenv("SHORTCUTS_OUTPUT")
if output == "" {
@@ -324,7 +415,7 @@ func TestGenerateShortcutsJSON(t *testing.T) {
grouped[s.Service] = append(grouped[s.Service], entry{
Verb: verb,
Description: s.Description,
Scopes: s.ScopesForIdentity("user"),
Scopes: s.DeclaredScopesForIdentity("user"),
})
}

View File

@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
VCSearch,
VCNotes,
VCRecording,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingEvents,
}
}

View File

@@ -0,0 +1,984 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"unicode"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
vcMeetingEventsAPIPath = "/open-apis/vc/v1/bots/events"
defaultVCMeetingEventsSize = 20
minVCMeetingEventsPageSize = 20
maxVCMeetingEventsPageSize = 100
maxVCMeetingEventsPages = 200
)
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
// toUnixSeconds converts a supported CLI time input into a Unix seconds string.
func toUnixSeconds(input string, hint ...string) (string, error) {
ts, err := common.ParseTime(input, hint...)
if err != nil {
return "", err
}
if _, err := strconv.ParseInt(ts, 10, 64); err != nil {
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
}
return ts, nil
}
// VCMeetingEvents lists bot meeting events for a meeting.
var VCMeetingEvents = common.Shortcut{
Service: "vc",
Command: "+meeting-events",
Description: "List bot meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
{Name: "start", Desc: "time lower bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
{Name: "end", Desc: "time upper bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
{Name: "page-token", Desc: "page token for the next page"},
{Name: "page-size", Default: "20", Desc: "page size, 20-100 (default 20)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all available pages"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
return err
}
if _, err := meetingEventsPageSize(runtime); err != nil {
return err
}
if _, _, err := parseMeetingEventsTimeRange(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dryRun := common.NewDryRunAPI()
if runtime.Bool("page-all") {
dryRun = dryRun.Desc("Auto-paginates through all available pages")
}
dryRun = dryRun.GET(vcMeetingEventsAPIPath)
if flat := flattenQueryParams(params); len(flat) > 0 {
dryRun.Params(flat)
}
return dryRun
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
if err != nil {
return err
}
data, events, hasMore, pageToken, err := fetchMeetingEvents(ctx, runtime, startTime, endTime)
if err != nil {
return err
}
events = compactMeetingEvents(events)
outData := map[string]interface{}{
"events": events,
"has_more": data["has_more"],
"page_token": data["page_token"],
}
timeline := buildMeetingEventTimeline(events)
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
if len(timeline.entries) == 0 {
fmt.Fprintln(w, "No meeting events.")
return
}
io.WriteString(w, renderMeetingEventsPretty(timeline))
})
if runtime.Format == "pretty" && pageToken != "" {
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
if hasMore {
fmt.Fprintln(runtime.IO().Out, "more available")
}
}
return nil
},
}
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
if runtime.Bool("page-all") {
return maxVCMeetingEventsPageSize, nil
}
pageSizeStr := strings.TrimSpace(runtime.Str("page-size"))
if pageSizeStr == "" {
return defaultVCMeetingEventsSize, nil
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil {
return 0, common.FlagErrorf("invalid --page-size %q: must be an integer", pageSizeStr)
}
if pageSize < minVCMeetingEventsPageSize {
return minVCMeetingEventsPageSize, nil
}
if pageSize > maxVCMeetingEventsPageSize {
return maxVCMeetingEventsPageSize, nil
}
return pageSize, nil
}
func meetingEventsPaginationConfig(runtime *common.RuntimeContext) (bool, int) {
if !runtime.Bool("page-all") {
return false, 0
}
return true, maxVCMeetingEventsPages
}
func validateMeetingEventsMeetingID(meetingID string) error {
meetingID = strings.TrimSpace(meetingID)
if meetingID == "" {
return common.FlagErrorf("--meeting-id is required")
}
value, err := strconv.ParseInt(meetingID, 10, 64)
if err != nil || value <= 0 {
return common.FlagErrorf("--meeting-id must be a positive integer, got %q", meetingID)
}
return nil
}
// parseMeetingEventsTimeRange validates --start/--end and returns Unix seconds strings.
func parseMeetingEventsTimeRange(runtime *common.RuntimeContext) (string, string, error) {
start := strings.TrimSpace(runtime.Str("start"))
end := strings.TrimSpace(runtime.Str("end"))
if start == "" && end == "" {
return "", "", nil
}
var startTime, endTime string
if start != "" {
parsed, err := toUnixSeconds(start)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
}
startTime = parsed
}
if end != "" {
parsed, err := toUnixSeconds(end, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
}
endTime = parsed
}
if startTime != "" && endTime != "" {
startValue, _ := strconv.ParseInt(startTime, 10, 64)
endValue, _ := strconv.ParseInt(endTime, 10, 64)
if startValue > endValue {
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
}
}
return startTime, endTime, nil
}
func buildMeetingEventsParams(runtime *common.RuntimeContext, startTime, endTime string) (larkcore.QueryParams, error) {
pageSize, err := meetingEventsPageSize(runtime)
if err != nil {
return nil, err
}
params := make(larkcore.QueryParams)
params.Set("meeting_id", strings.TrimSpace(runtime.Str("meeting-id")))
params.Set("page_size", strconv.Itoa(pageSize))
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
params.Set("page_token", pageToken)
}
if startTime != "" {
params.Set("start_time", startTime)
}
if endTime != "" {
params.Set("end_time", endTime)
}
return params, nil
}
func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, []interface{}, bool, string, error) {
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
if err != nil {
return nil, nil, false, "", err
}
autoPaginate, pageLimit := meetingEventsPaginationConfig(runtime)
if !autoPaginate {
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
if err != nil {
return nil, nil, false, "", err
}
if data == nil {
data = map[string]interface{}{}
}
events := common.GetSlice(data, "events")
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
return data, events, hasMore, pageToken, nil
}
var (
allEvents []interface{}
lastData map[string]interface{}
lastPageToken string
lastHasMore bool
)
for page := 0; page < pageLimit; page++ {
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
if err != nil {
return nil, nil, false, "", err
}
if data == nil {
data = map[string]interface{}{}
}
lastData = data
events := common.GetSlice(data, "events")
allEvents = append(allEvents, events...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
params.Set("page_token", lastPageToken)
}
if lastData == nil {
lastData = map[string]interface{}{}
}
lastData["events"] = allEvents
lastData["has_more"] = lastHasMore
lastData["page_token"] = lastPageToken
return lastData, allEvents, lastHasMore, lastPageToken, nil
}
func flattenQueryParams(params larkcore.QueryParams) map[string]interface{} {
if len(params) == 0 {
return nil
}
flat := make(map[string]interface{}, len(params))
for key, values := range params {
switch len(values) {
case 0:
continue
case 1:
flat[key] = values[0]
default:
copied := make([]string, len(values))
copy(copied, values)
flat[key] = copied
}
}
return flat
}
func compactMeetingEvents(events []interface{}) []interface{} {
compacted := make([]interface{}, 0, len(events))
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
continue
}
if payload := common.GetMap(event, "payload"); payload != nil {
event["payload"] = compactMeetingPayload(payload)
}
compacted = append(compacted, event)
}
return compacted
}
func compactMeetingPayload(payload map[string]interface{}) map[string]interface{} {
if payload == nil {
return nil
}
compacted := make(map[string]interface{}, len(payload))
for key, value := range payload {
if items, ok := value.([]interface{}); ok && len(items) == 0 {
continue
}
compacted[key] = value
}
return compacted
}
type meetingTimeline struct {
topic string
startTime time.Time
hasStart bool
endTime time.Time
hasEnd bool
entries []meetingTimelineEntry
}
type meetingTimelineEntry struct {
when time.Time
hasWhen bool
sequence int
group int
subject string
description string
details []string
}
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
timeline := meetingTimeline{}
var sequence int
var group int
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
continue
}
payload := common.GetMap(event, "payload")
if payload == nil {
continue
}
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
}
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
timeline.entries = append(timeline.entries, entry)
}
group++
}
sort.SliceStable(timeline.entries, func(i, j int) bool {
left := timeline.entries[i]
right := timeline.entries[j]
switch {
case left.hasWhen && right.hasWhen:
if left.when.Equal(right.when) {
return left.sequence < right.sequence
}
return left.when.Before(right.when)
case left.hasWhen:
return true
case right.hasWhen:
return false
default:
return left.sequence < right.sequence
}
})
return timeline
}
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
if timeline == nil || meeting == nil {
return
}
if timeline.topic == "" {
timeline.topic = common.GetString(meeting, "topic")
}
if !timeline.hasStart {
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "start_time")); ok {
timeline.startTime = parsed
timeline.hasStart = true
}
}
if !timeline.hasEnd {
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "end_time")); ok {
timeline.endTime = parsed
timeline.hasEnd = true
}
}
}
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
payload := common.GetMap(event, "payload")
if payload == nil {
return nil
}
eventType := meetingEventType(event)
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
switch eventType {
case "participant_joined":
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
case "participant_left":
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
case "transcript_received":
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
case "chat_received":
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
case "magic_share_started":
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
case "magic_share_ended":
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
default:
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
}
}
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_joined_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "join_time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
}
return entries
}
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_left_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
}
return entries
}
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "transcript_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "start_time_ms"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "speaker"))
if subject == "" {
subject = "未知发言人"
}
text := strings.TrimSpace(common.GetString(item, "text"))
description := "产生了转写"
if text != "" {
description = text
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
}
return entries
}
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "chat_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "send_time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
if subject == "" {
subject = "未知发送者"
}
typeLabel := chatMessageTypeLabel(item)
description := strings.TrimSpace(common.GetString(item, "content"))
if description == "" {
description = fmt.Sprintf("[%s] 发送了消息", typeLabel)
} else {
description = fmt.Sprintf("[%s] %s", typeLabel, description)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
}
return entries
}
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_started_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
if subject == "" {
subject = "未知用户"
}
title := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "title"))
url := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "url"))
description := "开始共享内容"
if title != "" {
description = fmt.Sprintf("开始共享「%s」", title)
}
var details []string
if url != "" {
details = append(details, "URL: "+url)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
}
return entries
}
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_ended_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
if subject == "" {
subject = "未知用户"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
}
return entries
}
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
entry := meetingTimelineEntry{
when: when,
hasWhen: hasWhen,
sequence: *sequence,
group: group,
subject: subject,
description: description,
details: details,
}
*sequence = *sequence + 1
return entry
}
func parseFlexibleTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
if ts, err := strconv.ParseInt(raw, 10, 64); err == nil {
switch {
case ts > 1_000_000_000_000:
return time.UnixMilli(ts), true
case ts > 0:
return time.Unix(ts, 0), true
}
}
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed, true
}
return time.Time{}, false
}
func renderMeetingEventsPretty(timeline meetingTimeline) string {
var b strings.Builder
if timeline.topic != "" {
fmt.Fprintf(&b, "会议主题:%s\n", escapePrettyText(timeline.topic))
}
if timeline.hasStart || timeline.hasEnd {
fmt.Fprintf(&b, "会议时间:%s\n", escapePrettyText(formatMeetingWindow(timeline.startTime, timeline.hasStart, timeline.endTime, timeline.hasEnd)))
}
if b.Len() > 0 {
b.WriteString("\n")
}
for _, entry := range timeline.entries {
fmt.Fprintf(&b, "[%s] ", formatTimelineOffset(entry.when, entry.hasWhen, timeline.startTime, timeline.hasStart))
if entry.subject != "" {
if entry.description == "" {
fmt.Fprintln(&b, escapePrettyText(entry.subject))
for _, detail := range entry.details {
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
}
continue
}
if needsColon(entry.description) {
fmt.Fprintf(&b, "%s: %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
} else {
fmt.Fprintf(&b, "%s %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
}
for _, detail := range entry.details {
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
}
continue
}
fmt.Fprintln(&b, escapePrettyText(entry.description))
for _, detail := range entry.details {
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
}
}
if b.Len() == 0 {
return ""
}
return b.String()
}
func escapePrettyText(s string) string {
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch r {
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
if unicode.IsControl(r) {
fmt.Fprintf(&b, "\\u%04X", r)
continue
}
b.WriteRune(r)
}
}
return b.String()
}
func formatMeetingWindow(start time.Time, hasStart bool, end time.Time, hasEnd bool) string {
switch {
case hasStart && hasEnd:
if !end.After(start) {
return fmt.Sprintf("%s进行中", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
}
return fmt.Sprintf("%s - %s", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"), end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
case hasStart:
return start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
case hasEnd:
return end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
default:
return ""
}
}
func formatTimelineOffset(when time.Time, hasWhen bool, meetingStart time.Time, hasMeetingStart bool) string {
if hasWhen && hasMeetingStart {
diff := when.Sub(meetingStart)
if diff < 0 {
diff = 0
}
totalSeconds := int(diff.Seconds())
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
if hasWhen {
return when.In(meetingDisplayLocation).Format("15:04:05")
}
return "??:??:??"
}
func needsColon(description string) bool {
switch description {
case "发送了消息", "产生了转写":
return false
default:
return !strings.HasPrefix(description, "加入了") &&
!strings.HasPrefix(description, "离开了") &&
!strings.HasPrefix(description, "被移出") &&
!strings.HasPrefix(description, "会议结束") &&
!strings.HasPrefix(description, "开始共享") &&
!strings.HasPrefix(description, "结束共享")
}
}
func leaveAction(item map[string]interface{}) string {
switch int(common.GetFloat(item, "leave_reason")) {
case 2:
return "因会议结束离开了会议"
case 3:
return "被移出了会议"
default:
return "离开了会议"
}
}
func meetingEventUserWithID(user map[string]interface{}) string {
if user == nil {
return ""
}
userID := common.GetString(user, "id")
userName := common.GetString(user, "user_name")
switch {
case userName != "" && userID != "":
return fmt.Sprintf("%s(%s)", userName, userID)
case userName != "":
return userName
case userID != "":
return userID
default:
return ""
}
}
func meetingEventType(event map[string]interface{}) string {
if eventType := common.GetString(event, "event_type"); eventType != "" {
return eventType
}
return common.GetString(common.GetMap(event, "payload"), "activity_event_type")
}
func meetingEventSummary(event map[string]interface{}) string {
payload := common.GetMap(event, "payload")
eventType := meetingEventType(event)
switch eventType {
case "participant_joined":
return participantJoinedSummary(payload)
case "participant_left":
return participantLeftSummary(payload)
case "transcript_received":
return transcriptReceivedSummary(payload)
case "chat_received":
return chatReceivedSummary(payload)
case "magic_share_started":
return magicShareStartedSummary(payload)
case "magic_share_ended":
return magicShareEndedSummary(payload)
default:
return fallbackMeetingEventSummary(payload, eventType)
}
}
func participantJoinedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "participant_joined_items")
switch len(items) {
case 0:
return "participant joined"
case 1:
user := common.GetMap(firstSliceMap(payload, "participant_joined_items"), "participant")
if label := meetingEventUserLabel(user); label != "" {
return fmt.Sprintf("participant %s joined", label)
}
return "participant joined"
default:
return fmt.Sprintf("%d participants joined", len(items))
}
}
func participantLeftSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "participant_left_items")
switch len(items) {
case 0:
return "participant left"
case 1:
user := common.GetMap(firstSliceMap(payload, "participant_left_items"), "participant")
if label := meetingEventUserLabel(user); label != "" {
return fmt.Sprintf("participant %s left", label)
}
return "participant left"
default:
return fmt.Sprintf("%d participants left", len(items))
}
}
func transcriptReceivedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "transcript_received_items")
if len(items) > 1 {
return fmt.Sprintf("%d transcript items", len(items))
}
item := firstSliceMap(payload, "transcript_received_items")
text := common.GetString(item, "text")
speaker := meetingEventUserLabel(common.GetMap(item, "speaker"))
switch {
case speaker != "" && text != "":
return fmt.Sprintf("speaker %s: %s", speaker, text)
case speaker != "":
return fmt.Sprintf("speaker %s transcript received", speaker)
case text != "":
return fmt.Sprintf("transcript: %s", text)
default:
return "transcript received"
}
}
func chatReceivedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "chat_received_items")
switch len(items) {
case 0:
return "chat received"
case 1:
item := firstSliceMap(payload, "chat_received_items")
content := common.GetString(item, "content")
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
switch {
case operator != "" && content != "":
return fmt.Sprintf("%s: %s", operator, content)
case operator != "":
return fmt.Sprintf("message by %s", operator)
case content != "":
return fmt.Sprintf("message: %s", content)
default:
return "chat received"
}
default:
count, operator := summarizeChatOperators(items)
switch {
case count == 1 && operator != "":
return fmt.Sprintf("%d messages by %s", len(items), operator)
case count > 1:
return fmt.Sprintf("%d messages by %d users", len(items), count)
default:
return fmt.Sprintf("%d messages", len(items))
}
}
}
func magicShareStartedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "magic_share_started_items")
if len(items) > 1 {
return fmt.Sprintf("%d share start events", len(items))
}
item := firstSliceMap(payload, "magic_share_started_items")
shareID := common.GetString(item, "share_id")
title := common.GetString(common.GetMap(item, "share_doc"), "title")
switch {
case shareID != "" && title != "":
return fmt.Sprintf("share %s started: %s", shareID, title)
case shareID != "":
return fmt.Sprintf("share %s started", shareID)
case title != "":
return fmt.Sprintf("share started: %s", title)
default:
return "share started"
}
}
func magicShareEndedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "magic_share_ended_items")
if len(items) > 1 {
return fmt.Sprintf("%d share end events", len(items))
}
item := firstSliceMap(payload, "magic_share_ended_items")
if shareID := common.GetString(item, "share_id"); shareID != "" {
return fmt.Sprintf("share %s ended", shareID)
}
return "share ended"
}
func fallbackMeetingEventSummary(payload map[string]interface{}, eventType string) string {
meeting := common.GetMap(payload, "meeting")
if topic := common.GetString(meeting, "topic"); topic != "" {
if eventType != "" {
return fmt.Sprintf("%s: %s", eventType, topic)
}
return topic
}
if eventType != "" {
return eventType
}
return "meeting event"
}
func firstSliceMap(payload map[string]interface{}, key string) map[string]interface{} {
items := common.GetSlice(payload, key)
if len(items) == 0 {
return nil
}
first, _ := items[0].(map[string]interface{})
return first
}
func meetingEventUserLabel(user map[string]interface{}) string {
if user == nil {
return ""
}
userID := common.GetString(user, "id")
userName := common.GetString(user, "user_name")
switch {
case userID != "" && userName != "":
return fmt.Sprintf("%s (%s)", userID, userName)
case userID != "":
return userID
case userName != "":
return userName
default:
return ""
}
}
func meetingEventUserDisplayName(user map[string]interface{}) string {
if user == nil {
return ""
}
if userName := common.GetString(user, "user_name"); userName != "" {
return userName
}
return common.GetString(user, "id")
}
func chatMessageTypeLabel(item map[string]interface{}) string {
code := int(common.GetFloat(item, "message_type"))
switch code {
case 1:
return "text"
case 2:
return "system"
case 3:
return "reaction"
case 4:
return "encrypted"
default:
return "unknown"
}
}
func summarizeChatOperators(items []interface{}) (int, string) {
seen := make(map[string]struct{}, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
if operator == "" {
continue
}
seen[operator] = struct{}{}
}
if len(seen) != 1 {
return len(seen), ""
}
for operator := range seen {
return 1, operator
}
return 0, ""
}

View File

@@ -0,0 +1,931 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"reflect"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
func newMeetingEventsRuntime() *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().String("page-size", "", "")
cmd.Flags().Bool("page-all", false, "")
return common.TestNewRuntimeContext(cmd, defaultConfig())
}
func mustSetMeetingEventsFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
}
}
func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: vcMeetingEventsAPIPath,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"total": len(events),
"has_more": hasMore,
"page_token": pageToken,
"events": events,
},
},
}
}
func participantJoinedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-1",
"event_type": "participant_joined",
"event_time": "2026-04-17T08:00:00Z",
"payload": map[string]interface{}{
"activity_event_type": "participant_joined",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"participant_joined_items": []interface{}{
map[string]interface{}{
"participant": map[string]interface{}{
"id": "bot_001",
"user_name": "Demo Bot",
},
"join_time": "2026-04-17T08:00:00Z",
},
},
},
}
}
func participantJoinedEventOngoing() map[string]interface{} {
event := participantJoinedEvent()
payload := common.GetMap(event, "payload")
meeting := common.GetMap(payload, "meeting")
meeting["start_time"] = "1776410100"
meeting["end_time"] = "1776410100"
return event
}
func chatReceivedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-2",
"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",
},
"participant_joined_items": []interface{}{},
"participant_left_items": []interface{}{},
"transcript_received_items": []interface{}{},
"magic_share_started_items": []interface{}{},
"magic_share_ended_items": []interface{}{},
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 3,
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
},
},
}
}
func multiChatReceivedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-3",
"event_type": "chat_received",
"event_time": "2026-04-17T08:06: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": "第一条\n第二行",
"message_type": 3,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
map[string]interface{}{
"content": "第二条",
"message_type": 3,
"send_time": "1776408062000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
},
},
}
}
func magicShareStartedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-4",
"event_type": "magic_share_started",
"event_time": "2026-04-17T08:07:00Z",
"payload": map[string]interface{}{
"activity_event_type": "magic_share_started",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"magic_share_started_items": []interface{}{
map[string]interface{}{
"time": "1776408123000",
"operator": map[string]interface{}{
"id": "u2",
"user_name": "Bob",
},
"share_doc": map[string]interface{}{
"title": "共享文档",
"url": "https://example.com/doc",
},
},
},
},
}
}
func TestChatReceivedSummary_MultipleItems(t *testing.T) {
payload := map[string]interface{}{
"chat_received_items": []interface{}{
map[string]interface{}{"content": "hello"},
map[string]interface{}{"content": "world"},
},
}
got := chatReceivedSummary(payload)
if got != "2 messages" {
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages")
}
}
func TestChatReceivedSummary_MultipleItemsSameOperator(t *testing.T) {
payload := map[string]interface{}{
"chat_received_items": []interface{}{
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
},
}
got := chatReceivedSummary(payload)
if got != "2 messages by Alice" {
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages by Alice")
}
}
func TestChatReceivedSummary_MultipleItemsMultipleOperators(t *testing.T) {
payload := map[string]interface{}{
"chat_received_items": []interface{}{
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u2", "user_name": "Bob"}},
map[string]interface{}{"content": "again", "operator": map[string]interface{}{"id": "u3", "user_name": "Carol"}},
},
}
got := chatReceivedSummary(payload)
if got != "3 messages by 3 users" {
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "3 messages by 3 users")
}
}
func TestParticipantJoinedSummary_MultipleItems(t *testing.T) {
payload := map[string]interface{}{
"participant_joined_items": []interface{}{
map[string]interface{}{"participant": map[string]interface{}{"id": "u1", "user_name": "User 1"}},
map[string]interface{}{"participant": map[string]interface{}{"id": "u2", "user_name": "User 2"}},
},
}
got := participantJoinedSummary(payload)
if got != "2 participants joined" {
t.Fatalf("participantJoinedSummary() = %q, want %q", got, "2 participants joined")
}
}
func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "not-a-number")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for invalid meeting ID")
}
if !strings.Contains(err.Error(), "positive integer") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "start", "200")
mustSetMeetingEventsFlag(t, runtime, "end", "100")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for invalid time range")
}
if !strings.Contains(err.Error(), "after --end") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestMeetingEvents_Validation_PageSizeBelowMinDoesNotError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no validation error for page-size clamp, got: %v", err)
}
}
func TestMeetingEvents_Validation_PageAllIgnoresInvalidPageSize(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no validation error when page-all ignores page-size, got: %v", err)
}
}
func TestMeetingEvents_Validation_InvalidPageSizeReturnsFlagError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "foo")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for non-integer page-size")
}
if !strings.Contains(err.Error(), "invalid --page-size") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBuildMeetingEventsParams(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "40")
mustSetMeetingEventsFlag(t, runtime, "page-token", "1710000000000000000")
params, err := buildMeetingEventsParams(runtime, "1710000000", "1710003600")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["meeting_id"][0]; got != "7628568141510692381" {
t.Fatalf("meeting_id = %q, want %q", got, "7628568141510692381")
}
if got := params["page_size"][0]; got != "40" {
t.Fatalf("page_size = %q, want %q", got, "40")
}
if got := params["page_token"][0]; got != "1710000000000000000" {
t.Fatalf("page_token = %q, want %q", got, "1710000000000000000")
}
if got := params["start_time"][0]; got != "1710000000" {
t.Fatalf("start_time = %q, want %q", got, "1710000000")
}
if got := params["end_time"][0]; got != "1710003600" {
t.Fatalf("end_time = %q, want %q", got, "1710003600")
}
}
func TestBuildMeetingEventsParams_PageSizeBelowMinClampsToMin(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
params, err := buildMeetingEventsParams(runtime, "", "")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "20" {
t.Fatalf("page_size = %q, want %q when below min", got, "20")
}
}
func TestBuildMeetingEventsParams_PageSizeAboveMaxClampsToMax(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "999")
params, err := buildMeetingEventsParams(runtime, "", "")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "100" {
t.Fatalf("page_size = %q, want %q when above max", got, "100")
}
}
func TestBuildMeetingEventsParams_PageAllUsesMaxPageSize(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
mustSetMeetingEventsFlag(t, runtime, "page-size", "50")
params, err := buildMeetingEventsParams(runtime, "", "")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "100" {
t.Fatalf("page_size = %q, want %q when page-all is set", got, "100")
}
}
func TestMeetingEvents_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--page-token", "1710000000000000000",
"--page-size", "40",
"--start", "1710000000",
"--end", "1710003600",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
vcMeetingEventsAPIPath,
`"meeting_id": "7628568141510692381"`,
`"page_token": "1710000000000000000"`,
`"page_size": "40"`,
`"start_time": "1710000000"`,
`"end_time": "1710003600"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
}
func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--page-all",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "Auto-paginates through all available pages") {
t.Fatalf("dry-run output missing auto-pagination description: %s", stdout.String())
}
}
func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--page-all",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
}
if !strings.Contains(out, `"has_more":false`) {
t.Fatalf("expected final has_more=false: %s", stdout.String())
}
}
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "user",
}, 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"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"events":[`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
for _, unwanted := range []string{
`"participant_joined_items": []`,
`"participant_left_items": []`,
`"transcript_received_items": []`,
`"magic_share_started_items": []`,
`"magic_share_ended_items": []`,
} {
if strings.Contains(out, unwanted) {
t.Fatalf("json output should not contain %q: %s", unwanted, out)
}
}
if !strings.Contains(out, `"message_type": 3`) {
t.Fatalf("json output should keep numeric fields: %s", out)
}
}
func TestMeetingEvents_ExecutePretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
for _, want := range []string{
"会议主题:项目例会",
"会议时间2026-04-17 15:15:00进行中",
"Demo Bot(bot_001) 加入了会议",
"Alice(u1): [reaction] 第一条\\n第二行",
"Alice(u1): [reaction] 第二条",
"Bob(u2) 开始共享「共享文档」",
"URL: https://example.com/doc",
"page_token: 1710000000000000000",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
if strings.Contains(out, "第二条\n\n[") {
t.Fatalf("pretty output should not insert blank lines between event entries: %s", out)
}
if !strings.Contains(out, "第二条\n[") {
t.Fatalf("pretty output should keep event entries contiguous: %s", out)
}
}
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
if !strings.Contains(out, "page_token: pt_last") {
t.Fatalf("pretty output should print page_token even when has_more is false: %s", out)
}
if strings.Contains(out, "more available") {
t.Fatalf("pretty output should not print more-available hint when has_more is false: %s", out)
}
}
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub(nil, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), "No meeting events.") {
t.Fatalf("unexpected output: %s", stdout.String())
}
}
func TestParseFlexibleTime(t *testing.T) {
t.Run("unix seconds", func(t *testing.T) {
got, ok := parseFlexibleTime("1776410100")
if !ok {
t.Fatal("parseFlexibleTime() ok = false, want true")
}
if want := time.Unix(1776410100, 0); !got.Equal(want) {
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
}
})
t.Run("unix millis", func(t *testing.T) {
got, ok := parseFlexibleTime("1776408061000")
if !ok {
t.Fatal("parseFlexibleTime() ok = false, want true")
}
if want := time.UnixMilli(1776408061000); !got.Equal(want) {
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
}
})
t.Run("rfc3339", func(t *testing.T) {
got, ok := parseFlexibleTime("2026-04-17T08:00:00Z")
if !ok {
t.Fatal("parseFlexibleTime() ok = false, want true")
}
if want, _ := time.Parse(time.RFC3339, "2026-04-17T08:00:00Z"); !got.Equal(want) {
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
}
})
t.Run("invalid", func(t *testing.T) {
if _, ok := parseFlexibleTime("not-a-time"); ok {
t.Fatal("parseFlexibleTime() ok = true, want false")
}
})
}
func TestFormatMeetingWindow(t *testing.T) {
start := time.Unix(1776410100, 0)
end := time.Unix(1776413700, 0)
tests := []struct {
name string
start time.Time
hasStart bool
end time.Time
hasEnd bool
want string
}{
{
name: "ongoing",
start: start,
hasStart: true,
end: start,
hasEnd: true,
want: "2026-04-17 15:15:00进行中",
},
{
name: "finished range",
start: start,
hasStart: true,
end: end,
hasEnd: true,
want: "2026-04-17 15:15:00 - 2026-04-17 16:15:00",
},
{
name: "only start",
start: start,
hasStart: true,
want: "2026-04-17 15:15:00",
},
{
name: "only end",
end: end,
hasEnd: true,
want: "2026-04-17 16:15:00",
},
{
name: "empty",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatMeetingWindow(tt.start, tt.hasStart, tt.end, tt.hasEnd); got != tt.want {
t.Fatalf("formatMeetingWindow() = %q, want %q", got, tt.want)
}
})
}
}
func TestFormatTimelineOffset(t *testing.T) {
start := time.Unix(1776410100, 0)
later := start.Add(90 * time.Second)
earlier := start.Add(-5 * time.Minute)
tests := []struct {
name string
when time.Time
hasWhen bool
meetingStart time.Time
hasMeetingStart bool
want string
}{
{
name: "with meeting start",
when: later,
hasWhen: true,
meetingStart: start,
hasMeetingStart: true,
want: "00:01:30",
},
{
name: "negative diff clamps to zero",
when: earlier,
hasWhen: true,
meetingStart: start,
hasMeetingStart: true,
want: "00:00:00",
},
{
name: "without meeting start uses wall clock",
when: later,
hasWhen: true,
want: "15:16:30",
},
{
name: "missing when",
want: "??:??:??",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatTimelineOffset(tt.when, tt.hasWhen, tt.meetingStart, tt.hasMeetingStart); got != tt.want {
t.Fatalf("formatTimelineOffset() = %q, want %q", got, tt.want)
}
})
}
}
func TestFlattenQueryParams(t *testing.T) {
params := larkcore.QueryParams{
"one": []string{"1"},
"many": []string{"2", "3"},
"empty": []string{},
}
got := flattenQueryParams(params)
want := map[string]interface{}{
"one": "1",
"many": []string{"2", "3"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("flattenQueryParams() = %#v, want %#v", got, want)
}
}
func TestCompactMeetingPayload_DropsOnlyEmptySlices(t *testing.T) {
got := compactMeetingPayload(map[string]interface{}{
"empty_items": []interface{}{},
"items": []interface{}{"x"},
"zero": 0,
"text": "ok",
})
if _, ok := got["empty_items"]; ok {
t.Fatalf("compactMeetingPayload() should drop empty_items: %#v", got)
}
if !reflect.DeepEqual(got["items"], []interface{}{"x"}) {
t.Fatalf("compactMeetingPayload() items = %#v, want %#v", got["items"], []interface{}{"x"})
}
if got["zero"] != 0 || got["text"] != "ok" {
t.Fatalf("compactMeetingPayload() preserved fields mismatch: %#v", got)
}
}
func TestCompactMeetingEvents_IgnoresNonMapsAndCompactsPayload(t *testing.T) {
got := compactMeetingEvents([]interface{}{
"skip-me",
map[string]interface{}{
"event_type": "chat_received",
"payload": map[string]interface{}{
"chat_received_items": []interface{}{"x"},
"empty_items": []interface{}{},
},
},
})
if len(got) != 1 {
t.Fatalf("len(compactMeetingEvents()) = %d, want 1", len(got))
}
event, _ := got[0].(map[string]interface{})
payload := common.GetMap(event, "payload")
if _, ok := payload["empty_items"]; ok {
t.Fatalf("compactMeetingEvents() should prune empty payload slices: %#v", payload)
}
}
func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
got := Shortcuts()
var commands []string
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}
}
func TestLeaveAction(t *testing.T) {
tests := []struct {
name string
item map[string]interface{}
want string
}{
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := leaveAction(tt.item); got != tt.want {
t.Fatalf("leaveAction() = %q, want %q", got, tt.want)
}
})
}
}
func TestMeetingEventUserWithID(t *testing.T) {
tests := []struct {
name string
user map[string]interface{}
want string
}{
{name: "nil", want: ""},
{name: "name and id", user: map[string]interface{}{"user_name": "Alice", "id": "u1"}, want: "Alice(u1)"},
{name: "name only", user: map[string]interface{}{"user_name": "Alice"}, want: "Alice"},
{name: "id only", user: map[string]interface{}{"id": "u1"}, want: "u1"},
{name: "empty", user: map[string]interface{}{}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meetingEventUserWithID(tt.user); got != tt.want {
t.Fatalf("meetingEventUserWithID() = %q, want %q", got, tt.want)
}
})
}
}
func TestMeetingEventSummary(t *testing.T) {
tests := []struct {
name string
event map[string]interface{}
want string
}{
{
name: "participant joined count",
event: map[string]interface{}{
"event_type": "participant_joined",
"payload": map[string]interface{}{
"participant_joined_items": []interface{}{
map[string]interface{}{},
map[string]interface{}{},
},
},
},
want: "2 participants joined",
},
{
name: "participant left with label",
event: map[string]interface{}{
"event_type": "participant_left",
"payload": map[string]interface{}{
"participant_left_items": []interface{}{
map[string]interface{}{"participant": map[string]interface{}{"user_name": "Bob", "id": "u2"}},
},
},
},
want: "participant u2 (Bob) left",
},
{
name: "fallback unknown event",
event: map[string]interface{}{
"event_type": "mystery_event",
"payload": map[string]interface{}{},
},
want: "mystery_event",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meetingEventSummary(tt.event); got != tt.want {
t.Fatalf("meetingEventSummary() = %q, want %q", got, tt.want)
}
})
}
}
func TestEscapePrettyText(t *testing.T) {
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
want := `line1\nline2\t\r\u0007`
if got != want {
t.Fatalf("escapePrettyText() = %q, want %q", got, want)
}
}
func TestNeedsColon(t *testing.T) {
tests := []struct {
description string
want bool
}{
{description: "发送了消息", want: false},
{description: "加入了会议", want: false},
{description: "离开了会议", want: false},
{description: "开始共享「文档」", want: false},
{description: "[text] hello", want: true},
}
for _, tt := range tests {
if got := needsColon(tt.description); got != tt.want {
t.Fatalf("needsColon(%q) = %v, want %v", tt.description, got, tt.want)
}
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var meetingNumberRe = regexp.MustCompile(`^\d{9}$`)
// validMeetingNumber checks whether s is a valid 9-digit meeting number.
func validMeetingNumber(s string) bool {
return meetingNumberRe.MatchString(s)
}
// VCMeetingJoin joins a meeting by meeting number via /vc/v1/bots/join.
var VCMeetingJoin = common.Shortcut{
Service: "vc",
Command: "+meeting-join",
Description: "Join a meeting by meeting number (bot join)",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},
{Name: "password", Desc: "meeting password (if required)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
mn := strings.TrimSpace(runtime.Str("meeting-number"))
if !validMeetingNumber(mn) {
return common.FlagErrorf("--meeting-number must be exactly 9 digits, got %q", mn)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildMeetingJoinBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/vc/v1/bots/join").
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildMeetingJoinBody(runtime)
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/join", nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
meeting, _ := data["meeting"].(map[string]interface{})
if meeting == nil {
fmt.Fprintln(w, "Joined meeting (no meeting info returned).")
return
}
fmt.Fprintf(w, "Joined meeting successfully.\n")
if id := common.GetString(meeting, "id"); id != "" {
fmt.Fprintf(w, " Meeting ID: %s\n", id)
}
if no := common.GetString(meeting, "meeting_no"); no != "" {
fmt.Fprintf(w, " Meeting No: %s\n", no)
}
if topic := common.GetString(meeting, "topic"); topic != "" {
fmt.Fprintf(w, " Topic: %s\n", topic)
}
if startTime := common.GetString(meeting, "start_time"); startTime != "" {
fmt.Fprintf(w, " Start Time: %s\n", startTime)
}
})
return nil
},
}
func buildMeetingJoinBody(runtime *common.RuntimeContext) map[string]interface{} {
meetingNo := strings.TrimSpace(runtime.Str("meeting-number"))
body := map[string]interface{}{
"join_type": 1,
"join_identify": map[string]interface{}{
"meeting_no": meetingNo,
},
}
if pw := strings.TrimSpace(runtime.Str("password")); pw != "" {
body["password"] = pw
}
return body
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// VCMeetingLeave leaves a meeting via /vc/v1/bots/leave.
var VCMeetingLeave = common.Shortcut{
Service: "vc",
Command: "+meeting-leave",
Description: "Leave a meeting by meeting ID",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("meeting-id")) == "" {
return common.FlagErrorf("--meeting-id is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/vc/v1/bots/leave").
Body(map[string]interface{}{
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
meetingID := strings.TrimSpace(runtime.Str("meeting-id"))
body := map[string]interface{}{
"meeting_id": meetingID,
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/leave", nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "Left meeting %s successfully.\n", meetingID)
})
return nil
},
}

View File

@@ -0,0 +1,536 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// Unit tests: pure functions
// ---------------------------------------------------------------------------
func TestValidMeetingNumber(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{"9 digits", "123456789", true},
{"9 digits leading zero", "012345678", true},
{"empty", "", false},
{"8 digits", "12345678", false},
{"10 digits", "1234567890", false},
{"with space", "12345 678", false},
{"letters mixed", "12345678a", false},
{"pure letters", "abcdefghi", false},
{"with dash", "123-456-789", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validMeetingNumber(tt.in); got != tt.want {
t.Errorf("validMeetingNumber(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestBuildMeetingJoinBody_WithoutPassword(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["join_type"] != 1 {
t.Errorf("join_type = %v, want 1", body["join_type"])
}
ji, ok := body["join_identify"].(map[string]interface{})
if !ok {
t.Fatalf("join_identify missing or wrong type: %v", body["join_identify"])
}
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
}
if _, exists := body["password"]; exists {
t.Errorf("password should be omitted when empty, got %v", body["password"])
}
}
func TestBuildMeetingJoinBody_WithPassword(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
_ = cmd.Flags().Set("password", "secret")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["password"] != "secret" {
t.Errorf("password = %v, want secret", body["password"])
}
}
func TestBuildMeetingJoinBody_TrimsWhitespace(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", " 123456789 ")
_ = cmd.Flags().Set("password", " pw ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
ji, _ := body["join_identify"].(map[string]interface{})
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no should be trimmed, got %q", ji["meeting_no"])
}
if body["password"] != "pw" {
t.Errorf("password should be trimmed, got %q", body["password"])
}
}
// ---------------------------------------------------------------------------
// Validate tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_Validate_MissingNumber(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
// cobra MarkFlagRequired should reject missing --meeting-number
err := mountAndRun(t, VCMeetingJoin, []string{"+meeting-join", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected error when --meeting-number is missing")
}
if !strings.Contains(err.Error(), "meeting-number") {
t.Errorf("error should mention meeting-number, got: %v", err)
}
}
func TestMeetingJoin_Validate_InvalidFormat(t *testing.T) {
tests := []struct {
name string
num string
}{
{"too short", "12345678"},
{"too long", "1234567890"},
{"with letters", "12345abcd"},
{"empty after trim", " "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", tt.num)
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingJoin.Validate(context.Background(), runtime)
if err == nil {
t.Fatalf("expected validation error for %q", tt.num)
}
if !strings.Contains(err.Error(), "9 digits") {
t.Errorf("error should mention '9 digits', got: %v", err)
}
})
}
}
func TestMeetingJoin_Validate_Valid(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
if err := VCMeetingJoin.Validate(context.Background(), runtime); err != nil {
t.Errorf("unexpected validation error: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789", "--password", "pw123",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/join") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if !strings.Contains(out, "123456789") {
t.Errorf("dry-run should include meeting number, got: %s", out)
}
if !strings.Contains(out, "pw123") {
t.Errorf("dry-run should include password, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Execute tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meeting": map[string]interface{}{
"id": "69999999",
"meeting_no": "123456789",
"topic": "Weekly Sync",
"start_time": "1700000000",
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// verify captured request body
if len(stub.CapturedBody) == 0 {
t.Fatal("expected request body to be captured")
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["join_type"].(float64) != 1 {
t.Errorf("join_type = %v, want 1", req["join_type"])
}
ji, _ := req["join_identify"].(map[string]interface{})
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
}
if _, exists := ji["password"]; exists {
t.Errorf("password should be omitted when not provided, got %v", ji["password"])
}
// verify response envelope carries meeting info under data.meeting
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stdout: %v", err)
}
data, _ := resp["data"].(map[string]any)
meeting, _ := data["meeting"].(map[string]any)
if meeting["id"] != "69999999" {
t.Errorf("meeting.id = %v, want 69999999 (envelope: %s)", meeting["id"], stdout.String())
}
}
func TestMeetingJoin_Execute_WithPassword_CapturesBody(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "987654321", "--password", "s3cret",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
ji, _ := req["join_identify"].(map[string]interface{})
if req["password"] != "s3cret" {
t.Errorf("password = %v, want s3cret", req["password"])
}
if ji["meeting_no"] != "987654321" {
t.Errorf("meeting_no = %v, want 987654321", ji["meeting_no"])
}
}
func TestMeetingJoin_Execute_PrettyOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meeting": map[string]interface{}{
"id": "69999999",
"meeting_no": "123456789",
"topic": "Weekly Sync",
"start_time": "1700000000",
},
},
},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Joined meeting successfully", "69999999", "123456789", "Weekly Sync", "1700000000"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing %q, got: %s", want, out)
}
}
}
func TestMeetingJoin_Execute_PrettyOutput_NoMeetingInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "no meeting info returned") {
t.Errorf("pretty output should fall back to 'no meeting info' notice, got: %s", stdout.String())
}
}
func TestMeetingLeave_Execute_PrettyOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Left meeting 69999999 successfully") {
t.Errorf("pretty output should confirm leave, got: %s", out)
}
}
func TestMeetingJoin_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{"code": 190001, "msg": "invalid meeting number"},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--as", "user",
}, f, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for API failure")
}
if !strings.Contains(err.Error(), "invalid meeting number") {
t.Errorf("error should surface API message, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// Validate tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_Validate_MissingID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingLeave, []string{"+meeting-leave", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected error when --meeting-id is missing")
}
if !strings.Contains(err.Error(), "meeting-id") {
t.Errorf("error should mention meeting-id, got: %v", err)
}
}
func TestMeetingLeave_Validate_WhitespaceOnly(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", " ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingLeave.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for whitespace-only meeting-id")
}
if !strings.Contains(err.Error(), "meeting-id") {
t.Errorf("error should mention meeting-id, got: %v", err)
}
}
func TestMeetingLeave_Validate_Valid(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", "69999999")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
if err := VCMeetingLeave.Validate(context.Background(), runtime); err != nil {
t.Errorf("unexpected validation error: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/leave") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if !strings.Contains(out, "69999999") {
t.Errorf("dry-run should include meeting-id, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Execute tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// verify captured request body
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["meeting_id"] != "69999999" {
t.Errorf("meeting_id = %v, want 69999999", req["meeting_id"])
}
}
func TestMeetingLeave_Execute_TrimsMeetingID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", " 69999999 ",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["meeting_id"] != "69999999" {
t.Errorf("meeting_id should be trimmed, got %q", req["meeting_id"])
}
}
func TestMeetingLeave_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999", "--as", "user",
}, f, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for API failure")
}
if !strings.Contains(err.Error(), "no permission") {
t.Errorf("error should surface API message, got: %v", err)
}
}

View File

@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
WikiMove,
WikiNodeCreate,
WikiDeleteSpace,
WikiSpaceList,
WikiNodeList,
WikiNodeCopy,
}
}

View File

@@ -0,0 +1,973 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ── +space-list ──────────────────────────────────────────────────────────────
func TestWikiShortcutsIncludesSpaceListNodeListNodeCopy(t *testing.T) {
t.Parallel()
commands := map[string]bool{}
for _, s := range Shortcuts() {
commands[s.Command] = true
}
for _, want := range []string{"+space-list", "+node-list", "+node-copy"} {
if !commands[want] {
t.Errorf("Shortcuts() missing %q", want)
}
}
}
// TestWikiListShortcutsDeclareNarrowScopes pins the per-endpoint scope
// choice. The framework's preflight does exact string matching, so a broad
// scope (e.g. wiki:wiki:readonly) would wrongly reject tokens carrying only
// the narrow per-API scope that the API actually accepts.
func TestWikiListShortcutsDeclareNarrowScopes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
shortcut common.Shortcut
want []string
}{
{"+space-list", WikiSpaceList, []string{"wiki:space:retrieve"}},
{"+node-list", WikiNodeList, []string{"wiki:node:retrieve"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !reflect.DeepEqual(tc.shortcut.Scopes, tc.want) {
t.Fatalf("%s scopes = %v, want %v", tc.name, tc.shortcut.Scopes, tc.want)
}
})
}
}
func TestWikiSpaceListReturnsPaginatedSpaces(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_1",
"name": "Engineering Wiki",
"space_type": "team",
},
map[string]interface{}{
"space_id": "space_2",
"name": "Personal Library",
"space_type": "my_library",
},
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.HasMore {
t.Fatalf("has_more = true, want false on natural end")
}
if envelope.Data.Spaces[0]["name"] != "Engineering Wiki" {
t.Fatalf("spaces[0].name = %v, want %q", envelope.Data.Spaces[0]["name"], "Engineering Wiki")
}
}
// ── +node-list ───────────────────────────────────────────────────────────────
func TestWikiNodeListRequiresSpaceID(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{"+node-list", "--as", "user"}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "required") {
t.Fatalf("expected required flag error, got %v", err)
}
}
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_node_1",
"obj_token": "docx_1",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Getting Started",
"has_child": true,
},
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_node_2",
"obj_token": "docx_2",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Architecture",
"has_child": false,
},
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.Nodes[0]["title"] != "Getting Started" {
t.Fatalf("nodes[0].title = %v, want %q", envelope.Data.Nodes[0]["title"], "Getting Started")
}
if envelope.Data.Nodes[0]["has_child"] != true {
t.Fatalf("nodes[0].has_child = %v, want true", envelope.Data.Nodes[0]["has_child"])
}
}
func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_child",
"obj_token": "docx_child",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Child Doc",
"has_child": false,
},
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
// Verify the correct node was returned (parent_node_token was passed correctly).
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
}
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
}
}
func TestWikiNodeListRejectsMyLibraryForBot(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "my_library", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") {
t.Fatalf("expected my_library bot rejection, got %v", err)
}
}
func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Step 1: resolve my_library to the real space_id.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/my_library",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{
"space_id": "space_personal_42",
"name": "My Library",
"space_type": "my_library",
},
},
},
})
// Step 2: list nodes in the resolved space.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_personal_42",
"node_token": "wik_personal_1",
"title": "Personal Note",
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "my_library", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
}
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
}
}
// ── +node-copy ───────────────────────────────────────────────────────────────
func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy", "--space-id", "space_123", "--node-token", "wik_src", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") {
t.Fatalf("expected target validation error, got %v", err)
}
}
func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy", "--space-id", "space_123", "--node-token", "wik_src",
"--target-space-id", "space_dst", "--target-parent-node-token", "wik_parent",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutually exclusive error, got %v", err)
}
}
// TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write
// contract: invocation without --yes must fail with a confirmation_required
// error and must NOT issue the underlying API call. The aligned upstream
// schema flags this API as `danger: true`, and the shortcut now matches that
// risk classification.
func TestWikiNodeCopyDeclaredHighRiskWrite(t *testing.T) {
t.Parallel()
if WikiNodeCopy.Risk != "high-risk-write" {
t.Fatalf("WikiNodeCopy.Risk = %q, want %q", WikiNodeCopy.Risk, "high-risk-write")
}
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
// No HTTP stub registered — if the gate leaks, the request fires and
// httpmock errors with "no stub for POST ..." instead of the expected
// confirmation_required error, making the regression obvious.
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation_required error, got %v", err)
}
}
func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_copied",
"obj_token": "docx_copied",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Architecture (Copy)",
"has_child": false,
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--title", "Architecture (Copy)",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Data["node_token"] != "wik_copied" {
t.Fatalf("node_token = %v, want %q", envelope.Data["node_token"], "wik_copied")
}
if envelope.Data["space_id"] != "space_dst" {
t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst")
}
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["target_space_id"] != "space_dst" {
t.Fatalf("captured target_space_id = %v, want %q", captured["target_space_id"], "space_dst")
}
if captured["title"] != "Architecture (Copy)" {
t.Fatalf("captured title = %v, want %q", captured["title"], "Architecture (Copy)")
}
if got := stderr.String(); !strings.Contains(got, "Copying wiki node") {
t.Fatalf("stderr = %q, want copy message", got)
}
}
func TestWikiNodeCopyCopiesNodeToTargetParent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_src",
"node_token": "wik_copied2",
"obj_token": "docx_copied2",
"obj_type": "docx",
"parent_node_token": "wik_parent_dst",
"node_type": "origin",
"title": "Architecture",
"has_child": false,
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-parent-node-token", "wik_parent_dst",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["target_parent_token"] != "wik_parent_dst" {
t.Fatalf("captured target_parent_token = %v, want %q", captured["target_parent_token"], "wik_parent_dst")
}
if _, hasTitle := captured["title"]; hasTitle {
t.Fatalf("title should not be in body when --title not provided, got %v", captured)
}
}
// ── +space-list / +node-list pagination & format ─────────────────────────────
func TestWikiSpaceListRejectsInvalidPageSize(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-size", "0", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--page-size must be between 1 and 50") {
t.Fatalf("expected page-size validation error, got %v", err)
}
}
func TestWikiSpaceListRejectsNegativePageLimit(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-limit", "-1", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be a non-negative integer") {
t.Fatalf("expected page-limit validation error, got %v", err)
}
}
func TestWikiSpaceListAutoPaginatesAcrossPages(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Page 1: has_more=true, page_token set. Loop must continue.
page1 := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_page2",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_1", "name": "First"},
},
},
},
}
// Page 2: must receive page_token=tok_page2 in query. Captured to verify.
var page2Query string
page2 := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
OnMatch: func(req *http.Request) { page2Query = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_2", "name": "Second"},
},
},
},
}
reg.Register(page1)
reg.Register(page2)
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--page-all", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 2 || len(envelope.Data.Spaces) != 2 {
t.Fatalf("merged spaces = %d / count=%v, want 2 / 2", len(envelope.Data.Spaces), envelope.Meta.Count)
}
if envelope.Data.HasMore || envelope.Data.PageToken != "" {
t.Fatalf("natural end should clear has_more/page_token, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken)
}
q, _ := url.ParseQuery(page2Query)
if q.Get("page_token") != "tok_page2" {
t.Fatalf("page2 page_token = %q, want tok_page2", q.Get("page_token"))
}
}
func TestWikiSpaceListPageLimitTruncatesAndExposesNextCursor(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only stub page 1; with --page-limit=1, the loop must stop BEFORE
// requesting page 2 — and surface has_more/page_token so the caller can resume.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_only", "name": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-all", "--page-limit", "1", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Spaces) != 1 {
t.Fatalf("spaces = %d, want 1 (capped)", len(envelope.Data.Spaces))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("truncated state = has_more=%v page_token=%q, want true / tok_next", envelope.Data.HasMore, envelope.Data.PageToken)
}
}
func TestWikiSpaceListExplicitPageTokenStopsAfterOnePage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Stub a page where has_more=true; auto-pagination should NOT trigger
// because the caller supplied an explicit --page-token cursor.
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
OnMatch: func(req *http.Request) { capturedQuery = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{map[string]interface{}{"space_id": "sp_x"}},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-token", "tok_input", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
q, _ := url.ParseQuery(capturedQuery)
if q.Get("page_token") != "tok_input" {
t.Fatalf("captured page_token = %q, want tok_input", q.Get("page_token"))
}
}
func TestWikiSpaceListPrettyFormatRendersFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "sp_1",
"name": "Engineering",
"description": "team docs",
"space_type": "team",
"visibility": "public",
"open_sharing": "open",
},
},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--format", "pretty", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Engineering",
"space_id: sp_1",
"space_type: team",
"visibility: public",
"description: team docs",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only one stub registered; if the default tried to auto-paginate, the
// loop would attempt a 2nd request and httpmock would error. So this
// test pins down the "default = single page" contract.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("nodes = %d, want 1 (single page default)", len(envelope.Data.Nodes))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("single-page default should surface upstream cursor, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken)
}
}
func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_1",
"obj_type": "docx",
"obj_token": "docx_1",
"title": "Getting Started",
"has_child": true,
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Getting Started",
"node_token: wik_1",
"obj_type: docx",
"has_child: true",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
// ── QA-driven fixes: empty slice + has_more hint + node-copy format ──
func TestWikiSpaceListEmptyResultReturnsEmptySliceNotNull(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
// Substring assertion is the only reliable way to distinguish [] from null
// in serialised JSON — unmarshalling both back into a Go slice would
// collapse the distinction.
if !strings.Contains(stdout.String(), `"spaces": []`) {
t.Fatalf("expected spaces to be empty array [], got:\n%s", stdout.String())
}
if strings.Contains(stdout.String(), `"spaces": null`) {
t.Fatalf("spaces serialised as null — JSON consumers expect []:\n%s", stdout.String())
}
var envelope struct {
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 0 {
t.Fatalf("meta.count = %v, want 0", envelope.Meta.Count)
}
}
func TestWikiSpaceListPrettyHintsWhenEmptyButHasMore(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_more",
"items": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--format", "pretty", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
// When the bot's first page is filtered out by upstream permissions, the
// blanket "No wiki spaces found." used to mislead users into thinking they
// had no access at all. Pretty mode must now distinguish that case.
if strings.Contains(out, "No wiki spaces found.") {
t.Fatalf("pretty output should not flatly claim 'No wiki spaces found.' when has_more=true; got:\n%s", out)
}
for _, want := range []string{
"Current page is empty but the server reports more pages.",
"tok_more",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
func TestWikiNodeCopyHasFormatPrettyRendersNode(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_copied",
"obj_token": "docx_copied",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Architecture (Copy)",
},
},
},
})
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--title", "Architecture (Copy)",
"--format", "pretty",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Copied node:",
"title: Architecture (Copy)",
"node_token: wik_copied",
"space_id: space_dst",
"parent_node_token: wik_parent",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// WikiNodeCopy copies a wiki node into a target space or under a target parent node.
var WikiNodeCopy = common.Shortcut{
Service: "wiki",
Command: "+node-copy",
Description: "Copy a wiki node to a target space or parent node",
Risk: "high-risk-write",
Scopes: []string{"wiki:node:copy"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "space-id", Desc: "source wiki space ID", Required: true},
{Name: "node-token", Desc: "source node token to copy", Required: true},
{Name: "target-space-id", Desc: "target wiki space ID; required if --target-parent-node-token is not set"},
{Name: "target-parent-node-token", Desc: "target parent node token; required if --target-space-id is not set"},
{Name: "title", Desc: "new title for the copied node; leave empty to keep the original title"},
},
Tips: []string{
"At least one of --target-space-id or --target-parent-node-token must be provided.",
"Omit --title to keep the original node title in the copy.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("space-id")), "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("node-token")), "--node-token"); err != nil {
return err
}
targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id"))
targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token"))
if targetSpaceID == "" && targetParent == "" {
return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required")
}
if targetSpaceID != "" && targetParent != "" {
return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one")
}
if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil {
return err
}
return validateOptionalResourceName(targetParent, "--target-parent-node-token")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
nodeToken := strings.TrimSpace(runtime.Str("node-token"))
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken))).
Body(buildNodeCopyBody(runtime)).
Set("space_id", spaceID).
Set("node_token", nodeToken)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
nodeToken := strings.TrimSpace(runtime.Str("node-token"))
fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n",
common.MaskToken(nodeToken), common.MaskToken(spaceID))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken)),
nil, buildNodeCopyBody(runtime))
if err != nil {
return err
}
node, err := parseWikiNodeRecord(common.GetMap(data, "node"))
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n",
common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID))
out := wikiNodeCopyOutput(node)
runtime.OutFormat(out, nil, func(w io.Writer) {
renderWikiNodeCopyPretty(w, out)
})
return nil
},
}
func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) {
fmt.Fprintf(w, "Copied node:\n")
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"]))
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
if parent, _ := out["parent_node_token"].(string); parent != "" {
fmt.Fprintf(w, " parent_node_token: %s\n", parent)
}
}
func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
// Validate has already rejected the case where both --target-space-id and
// --target-parent-node-token are set (mutually exclusive). It is safe to
// inline both flags here; do not loosen that check without revisiting this
// body builder, or the upstream API will see an ambiguous request shape.
body := map[string]interface{}{}
if v := strings.TrimSpace(runtime.Str("target-space-id")); v != "" {
body["target_space_id"] = v
}
if v := strings.TrimSpace(runtime.Str("target-parent-node-token")); v != "" {
body["target_parent_token"] = v
}
if v := strings.TrimSpace(runtime.Str("title")); v != "" {
body["title"] = v
}
return body
}
func wikiNodeCopyOutput(node *wikiNodeRecord) map[string]interface{} {
return map[string]interface{}{
"space_id": node.SpaceID,
"node_token": node.NodeToken,
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
"node_type": node.NodeType,
"title": node.Title,
"parent_node_token": node.ParentNodeToken,
"has_child": node.HasChild,
}
}

View File

@@ -413,6 +413,25 @@ func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
}
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
// the per-user real space_id. Shared by shortcuts that accept the my_library
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
nil, nil,
)
if err != nil {
return "", err
}
space, err := parseWikiSpaceRecord(common.GetMap(data, "space"))
if err != nil {
return "", err
}
return requireWikiSpaceID(space)
}
func validateOptionalResourceName(value, flagName string) error {
if value == "" {
return nil

View File

@@ -111,8 +111,8 @@ func TestWikiShortcutsIncludeAllCommands(t *testing.T) {
t.Parallel()
shortcuts := Shortcuts()
if len(shortcuts) != 3 {
t.Fatalf("len(Shortcuts()) = %d, want 3", len(shortcuts))
if len(shortcuts) != 6 {
t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts))
}
if shortcuts[0].Command != "+move" {
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiNodeListDefaultPageSize = 50
wikiNodeListMaxPageSize = 50
)
// WikiNodeList lists child nodes in a wiki space or under a parent node.
var WikiNodeList = common.Shortcut{
Service: "wiki",
Command: "+node-list",
Description: "List wiki nodes in a space or under a parent node",
Risk: "read",
// Same exact-match-scope reasoning as +space-list: declare the
// narrowest scope the upstream API accepts so we don't false-reject
// tokens that only carry wiki:node:retrieve.
Scopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library, or +space-list to discover other space IDs", Required: true},
{Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"},
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiNodeListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiNodeListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page (large knowledge bases can be huge — keep an eye on --page-limit).",
"Use --parent-node-token to drill into a sub-directory.",
"Run +space-list first to discover your space IDs, including the personal document library.",
"--space-id my_library is a per-user alias and is only valid with --as user.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// my_library is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --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 validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
params["parent_node_token"] = pt
}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
d := common.NewDryRunAPI()
if wikiListShouldAutoPaginate(runtime) {
d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
// When the caller passes my_library, +node-list must first resolve it
// to the real per-user space_id before listing nodes, mirroring the
// two-step orchestration used by +node-create.
if spaceID == wikiMyLibrarySpaceID {
return d.
Desc("2-step orchestration: resolve my_library -> list nodes").
GET("/open-apis/wiki/v2/spaces/my_library").
Desc("[1] Resolve my_library space ID").
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", "<resolved_space_id>")).
Desc("[2] List nodes").
Params(params).
Set("space_id", "<resolved_space_id>")
}
return d.
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
Params(params).
Set("space_id", spaceID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// Resolve the my_library alias to the per-user real space_id before
// listing, so the subsequent request hits a concrete space endpoint.
if spaceID == wikiMyLibrarySpaceID {
resolved, err := resolveMyLibrarySpaceID(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved))
spaceID = resolved
}
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d node(s)\n", len(nodes))
outData := map[string]interface{}{
"nodes": nodes,
"has_more": hasMore,
"page_token": nextToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(nodes)}, func(w io.Writer) {
renderWikiNodesPretty(w, nodes, hasMore, nextToken)
})
return nil
},
}
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
apiPath := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))
// Non-nil empty slice keeps json output stable as `[]` instead of `null`.
var (
nodes = make([]map[string]interface{}, 0)
pageToken = startToken
lastHasMore bool
lastPageToken string
)
for page := 0; ; page++ {
params := map[string]interface{}{"page_size": pageSize}
if parentNodeToken != "" {
params["parent_node_token"] = parentNodeToken
}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}
items, _ := data["items"].([]interface{})
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
nodes = append(nodes, wikiNodeListItem(m))
}
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !auto {
break
}
if !lastHasMore || lastPageToken == "" {
break
}
if pageLimit > 0 && page+1 >= pageLimit {
break
}
pageToken = lastPageToken
}
return nodes, lastHasMore, lastPageToken, nil
}
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(m, "space_id"),
"node_token": common.GetString(m, "node_token"),
"obj_token": common.GetString(m, "obj_token"),
"obj_type": common.GetString(m, "obj_type"),
"parent_node_token": common.GetString(m, "parent_node_token"),
"node_type": common.GetString(m, "node_type"),
"title": common.GetString(m, "title"),
"has_child": common.GetBool(m, "has_child"),
}
}
func renderWikiNodesPretty(w io.Writer, nodes []map[string]interface{}, hasMore bool, pageToken string) {
if len(nodes) == 0 {
if hasMore && pageToken != "" {
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
return
}
fmt.Fprintln(w, "No wiki nodes found.")
return
}
for i, n := range nodes {
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(n["title"]))
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(n["node_token"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(n["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(n["obj_token"]))
hasChild, _ := n["has_child"].(bool)
fmt.Fprintf(w, " has_child: %t\n", hasChild)
if parent, _ := n["parent_node_token"].(string); parent != "" {
fmt.Fprintf(w, " parent: %s\n", parent)
}
fmt.Fprintln(w)
}
if hasMore && pageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
}
}

View File

@@ -0,0 +1,211 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces"
wikiSpaceListDefaultPageSize = 50
wikiSpaceListMaxPageSize = 50
)
// WikiSpaceList lists all wiki spaces the caller has access to.
var WikiSpaceList = common.Shortcut{
Service: "wiki",
Command: "+space-list",
Description: "List wiki spaces accessible to the caller",
Risk: "read",
// Declare the narrowest valid scope: the upstream API accepts any of
// wiki:wiki / wiki:wiki:readonly / wiki:space:retrieve, but the
// framework's preflight does exact-string scope matching (see
// internal/auth/scope.go), so picking the broad readonly form would
// wrongly reject tokens that only carry the narrow retrieve scope and
// hand them a misleading missing-scope hint.
Scopes: []string{"wiki:space:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiSpaceListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiSpaceListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
},
Tips: []string{
"Default fetches a single page (matches other list shortcuts in this CLI); pass --page-all to pull every page.",
"The underlying API never returns the my_library personal library; resolve it via `wiki spaces get --params '{\"space_id\":\"my_library\"}'`.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateWikiListPagination(runtime, wikiSpaceListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
dry := common.NewDryRunAPI()
// Auto-pagination is the default — make it explicit in the dry-run so
// callers can see whether the loop will fire.
if wikiListShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
return dry.GET(wikiSpaceListAPIPath).Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaces, hasMore, nextToken, err := fetchWikiSpaces(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d wiki space(s)\n", len(spaces))
outData := map[string]interface{}{
"spaces": spaces,
"has_more": hasMore,
"page_token": nextToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(spaces)}, func(w io.Writer) {
renderWikiSpacesPretty(w, spaces, hasMore, nextToken)
})
return nil
},
}
// fetchWikiSpaces honours the four pagination flags:
// - default (no --page-all, no --page-token): fetch a single page from the start
// - --page-token X: fetch a single page starting at X (auto-pagination disabled)
// - --page-all: pull subsequent pages, capped by --page-limit (default 10; 0 = unlimited)
//
// The returned slice is always non-nil so json output stays as `[]` instead of `null`.
func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
var (
spaces = make([]map[string]interface{}, 0)
pageToken = startToken
lastHasMore bool
lastPageToken string
)
for page := 0; ; page++ {
params := map[string]interface{}{"page_size": pageSize}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil)
if err != nil {
return nil, false, "", err
}
items, _ := data["items"].([]interface{})
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
spaces = append(spaces, parseWikiSpaceItem(m))
}
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !auto {
break
}
if !lastHasMore || lastPageToken == "" {
break
}
if pageLimit > 0 && page+1 >= pageLimit {
break
}
pageToken = lastPageToken
}
return spaces, lastHasMore, lastPageToken, nil
}
func parseWikiSpaceItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(m, "space_id"),
"name": common.GetString(m, "name"),
"description": common.GetString(m, "description"),
"space_type": common.GetString(m, "space_type"),
"visibility": common.GetString(m, "visibility"),
"open_sharing": common.GetString(m, "open_sharing"),
}
}
func renderWikiSpacesPretty(w io.Writer, spaces []map[string]interface{}, hasMore bool, pageToken string) {
if len(spaces) == 0 {
// Distinguish "nothing here" from "current page empty but server says
// more pages follow" — the latter is a hint to keep paginating instead
// of giving up.
if hasMore && pageToken != "" {
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
return
}
fmt.Fprintln(w, "No wiki spaces found.")
return
}
for i, s := range spaces {
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(s["name"]))
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(s["space_id"]))
fmt.Fprintf(w, " space_type: %s\n", valueOrDash(s["space_type"]))
fmt.Fprintf(w, " visibility: %s\n", valueOrDash(s["visibility"]))
fmt.Fprintf(w, " open_sharing: %s\n", valueOrDash(s["open_sharing"]))
if desc, _ := s["description"].(string); desc != "" {
fmt.Fprintf(w, " description: %s\n", desc)
}
fmt.Fprintln(w)
}
if hasMore && pageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
}
}
func valueOrDash(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}
// validateWikiListPagination performs flag-level validation shared by
// +space-list and +node-list.
func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error {
if n := runtime.Int("page-size"); n < 1 || n > maxPageSize {
return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize)
}
if n := runtime.Int("page-limit"); n < 0 {
return common.FlagErrorf("--page-limit must be a non-negative integer")
}
return nil
}
// wikiListShouldAutoPaginate reports whether the fetch loop should keep
// requesting additional pages. An explicit --page-token disables auto loop
// because the caller has supplied a specific cursor.
func wikiListShouldAutoPaginate(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("page-token")) != "" {
return false
}
return runtime.Bool("page-all")
}
// warnIfConflictingPagingFlags logs a notice when --page-token and --page-all
// are both set. --page-token wins (single-page fetch from the supplied cursor)
// and --page-all is silently ignored, which would otherwise look like a bug to
// callers expecting subsequent pages to be drained.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
fmt.Fprintln(runtime.IO().ErrOut,
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
}
}

View File

@@ -86,8 +86,8 @@ Drive Folder (云空间文件夹)
## 重要说明:画板编辑
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
### 场景 1已通过 docs +fetch 获取到文档内容和画板 token
如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
### 场景 1已通过 docs +fetch --api-version v2 获取到文档内容和画板 token
如果用户已经通过 `docs +fetch --api-version v2` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
1. 记录画板的 token
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
### 场景 2刚创建画板需要编辑
@@ -115,4 +115,4 @@ Drive Folder (云空间文件夹)
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
## 补充说明
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。

View File

@@ -101,7 +101,7 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |

View File

@@ -4,6 +4,7 @@
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
- **Reaction**: An emoji reaction on a message.
- **Flag**: A bookmark on a message or thread.
## Resource Relationships
@@ -35,3 +36,14 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
### Flag Types
Flags support two layers:
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
Item types for feed-layer flags:
- **ItemTypeThread** (4) = thread in a topic-style chat
- **ItemTypeMsgThread** (11) = thread in a regular chat

View File

@@ -35,6 +35,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
- `reject` — 拒绝审批任务
- `transfer` — 转交审批任务
- `query` — 查询用户的任务列表
- `add_sign` — 审批任务加签
- `rollback` — 退回审批任务
## 权限表
@@ -49,4 +51,6 @@ lark-cli approval <resource> <method> [flags] # 调用 API
| `tasks.reject` | `approval:task:write` |
| `tasks.transfer` | `approval:task:write` |
| `tasks.query` | `approval:task:read` |
| `tasks.add_sign` | `approval:task:write` |
| `tasks.rollback` | `approval:task:write` |

View File

@@ -12,6 +12,7 @@ metadata:
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;如需先解析 Wiki 链接,可先调用 `lark-cli wiki ...`。
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
@@ -104,7 +105,7 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id``+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query``+record-get` 支持重复 `--record-id``--json` 读取多条记录 |
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP:已知 `record_id``+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query``+record-get` 支持重复 `--record-id``--json` 读取多条记录 |
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token``+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403 |
@@ -120,7 +121,7 @@ metadata:
|------|------------------|----------------|----------|
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
@@ -228,14 +229,24 @@ metadata:
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create``+table-create``+record-upsert` |
### 3.3 表名、字段名与表达式引用
### 3.3 查询执行契约
涉及查询、统计或判断结论时,先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),并遵守以下高优先级规则:
1. `+record-list` 默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询服务中执行;不要先拉明细到本地上下文再手工筛选排序。
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
4. 多表查询必须先确认关系字段和连接键link 单元格里的 `record_id` 是关系键,不是用户可读答案。
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
### 3.4 表名、字段名与表达式引用
1. 表名、字段名必须精确匹配真实返回,来源应是 `+table-list / +table-get / +field-list`
2. 不要凭自然语言猜名称,不要自行改写用户口述中的表名、字段名。
3. `formula / lookup / data-query / workflow` 中出现的名称同样必须精确匹配表达式引用、where 条件、DSL 字段名、workflow 配置都遵守同一规则。
4. 跨表场景必须额外读取目标表结构,不能只看当前表。
### 3.4 Token 与链接
### 3.5 Token 与链接
这是高优先级章节。只要用户输入里出现链接、token或报错涉及 `baseToken` / `wiki_token` / `obj_token`,都应优先回到这里检查。
@@ -254,7 +265,7 @@ metadata:
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
### 3.5 身份选择与权限降级策略
### 3.6 身份选择与权限降级策略
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。
@@ -282,10 +293,11 @@ lark-cli auth login --domain base
1. 先判断任务属于哪个模块,选对命令族。
2. 如果用户给了链接,先解析 token不要把 wiki token、完整 URL 或其他对象 ID 误当成 `base_token`
3. 先拿结构,再写命令,避免猜表名、字段名、表达式引用
4. 定位到命令后,先读对应 reference再执行命令
5. 执行命令,并按返回结果判断下一步
6. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
3. 如果是查询类任务,先判断问题范围,阅读 data analysis SOP再决定使用 `record / view / data-query`
4. 先拿结构,再写命令,避免猜表名、字段名、表达式引用
5. 定位到命令后,先读对应 reference执行命令。
6. 执行命令,并按返回结果判断下一步。
7. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
### 4.2 不可违反规则
@@ -297,11 +309,12 @@ lark-cli auth login --domain base
6. 只写可写字段;系统字段、附件字段、`formula``lookup` 默认不作为普通记录写入目标。
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
9. Base 场景不要改走裸 API不要切去 `lark-cli api /open-apis/bitable/v1/...`
10. 统一使用 `--base-token`
11. workflow 场景先读 schema不要凭自然语言猜 `type`
12. dashboard 场景先读 guide提到图表、看板、block 就先进入 dashboard 模块
13. formula / lookup 场景先读 guide没读 guide 前不要直接创建或更新
9. 全局查询不得基于默认分页、小 `--limit` 或未证明全量的本地 `jq` 结果下结论
10. Base 场景不要改走裸 API不要切去 `lark-cli api /open-apis/bitable/v1/...`
11. 统一使用 `--base-token`
12. workflow 场景先读 schema不要凭自然语言猜 `type`
13. dashboard 场景先读 guide提到图表、看板、block 就先进入 dashboard 模块
14. formula / lookup 场景先读 guide没读 guide 前不要直接创建或更新。
### 4.3 并发、分页与批量限制

View File

@@ -0,0 +1,88 @@
# Base data analysis SOP
Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、排序、Top/Bottom N、聚合统计、分组聚合、多表关联、临时分析和查询后写入前的目标定位。
具体命令参数不要在本文猜;需要时跳到对应 reference
- `+data-query`: [lark-base-data-query.md](lark-base-data-query.md)
- 视图筛选/排序/投影: [lark-base-view-set-filter.md](lark-base-view-set-filter.md), [lark-base-view-set-sort.md](lark-base-view-set-sort.md), [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
- 记录读取: [lark-base-record.md](lark-base-record.md)
## 0. Hard Rules
- 全局问题不能用默认 `+record-list --limit N` 片面地回答。
- `jq` / shell / 本地代码是在个人电脑或当前运行环境中处理已返回数据,只适合小范围结果;超过 200 行默认不推荐本地统计、排序或求极值,应改用 Base 云端查询服务的 filter/sort/aggregate。
- “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义必须在 Base 云端查询服务中完成筛选、排序或聚合。
- `+record-search` 用于关键词检索字段的展示文本;可搜多类字段,但匹配的是文本表示(如人员命中 name不要用它替代金额、状态、日期、空值等结构化条件。
- 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。
- 交付输出必须使用用户可读的真实字段值;内部 ID、`record_id`、关联记录 ID、open_id、编码字段只可作为连接键或定位键不能替代最终输出除非用户明确要求输出这些键值。
- 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。
## 1. Intent -> Tool Path
| 用户意图 | 首选路径 | 关键规则 |
| --- | --- | --- |
| 看几条、预览、示例 | `+record-list --limit N` | 保持局部语义;不要推广为全局结论 |
| 已知 `record_id` | `+record-get` | 直接读取;不要 search/list 反查 |
| 明确关键词 | `+record-search` | 按字段展示文本命中;使用 `search_fields` 限定匹配范围、`select_fields` 投影降低返回内容 token 量;不要把文本检索当作结构化关联解析 |
| 按条件找明细记录 | 先创建临时视图设置筛选和可见字段,再用 `+record-list --view-id` 读取 | 条件字段来自 `+field-list`;不要先读全表再本地过滤 |
| 排序 / TopN 原始记录 | 临时视图 filter/sort/projection -> `+record-list --view-id --limit N` | 最高/最新降序,最低/最早升序 |
| 聚合 / 分组 / 分组排序 | `+data-query` | 使用 filters/dimensions/measures/sort/limit |
| 聚合后输出实体字段 | `+data-query` 得到业务 key -> record 路径回查明细 | `+data-query` 不返回原始记录或 link 明细;聚合结果中的 key 需要再解析成用户要求字段 |
| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段,并在回答用结果中合并展示 |
| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询优先沉淀为持久视图 |
## 2. Execution Patterns
### 2.1 结构化明细与 TopN
使用视图路径:
1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。
2. `+view-create` 创建 grid 视图。
3. 设置 filter/sort/visible fields。
4. `+record-list --view-id <view_id> --limit <N>` 读取结果。
不要从未筛选、未排序的全表输出中手动挑选。一次性查询可用临时视图;如果这个筛选/排序结果对用户后续查看有价值,应保留为持久视图,不要删除,并告知用户视图名称和用途。视图参数细节见 view-set references。
### 2.2 聚合分析与 TopN
使用 `+data-query`
- 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。
- `pagination.limit` 是 Base 云端查询服务中的聚合结果限制,不是本地分页扫描。
- 需要输出明细或用户可读字段时,先拿业务 key再用 record 路径精确回查。
- 字段类型、日期 value、DSL shape 以 [lark-base-data-query.md](lark-base-data-query.md) 为准。
### 2.3 关系查询与回查
- link 单元格通常是关联表 `record_id` 数组,不是用户可读内容,只是连接键。
- 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。
- 从驱动表拿到候选记录后,用关联 `record_id` 到关联表 `+record-get` 批量读取记录内容。
- 多跳关系逐跳建立 `record_id/key -> 用户可读字段` 映射;最终用户可读的信息。
禁止:
- 把 link `record_id` 当最终输出。
-`+record-search` 搜 link `record_id`
- 基于 ID、自增编号、link 值做语义猜测;禁止依赖字段先验、样本记忆补全交付输出。
## 3. Range & Pagination Contract
- `+record-list` 默认页、固定 `--limit`、本地 `jq`、shell 管道、手工浏览输出,都只覆盖已读取范围;超过 200 行不要把本地处理当作推荐路径。
- `has_more=true`、存在下一页 offset/page token、或返回行数等于 page size都表示可能还有未读取数据。
- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。
- 必须全量导出时,按 CLI 分页语义串行翻页;不要并发调用 `+record-list`
## 4. Final Answer Check
形成交付输出前必须能确认:
- 问题范围是局部样例、单点定位、全局明细、聚合分析、多表关联,还是查询后写入。
- 筛选、排序、聚合是否发生在 Base 云端查询服务中,而不是本地 `jq` / shell 中。
- 如果使用 `jq` / shell本地输入是否是 200 行以内的小范围结果;超过 200 行是否已改用 Base 云端查询服务查询。
- 如果使用 `+record-list`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。
- 如果涉及关系查询,是否按 `record_id` 或业务 key 精确回查,交付输出是否来自关联表真实字段。
- 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。
任一项无法确认时,继续查询或明确说明只能得到局部结论。

View File

@@ -5,6 +5,8 @@
对多维表格数据进行聚合查询(分组、过滤、排序、聚合计算),基于以下语法的 JSON DSL
查询类任务还必须先遵守 [`lark-base-data-analysis-sop.md`](lark-base-data-analysis-sop.md)。`+data-query` 适合让筛选、分组、聚合、排序和 TopN 在 Base 云端查询服务中执行;不要用默认分页的 `+record-list` 或本地 `jq` 替代聚合查询。
## 限制
- **权限要求**(按文档类型分流):
@@ -51,6 +53,23 @@ lark-cli base +data-query \
"measures": [{"field_name": "金额", "aggregation": "sum", "alias": "total"}],
"shaper": {"format": "flat"}
}'
# 聚合后如需读取明细,先让 data-query 返回可回查的业务 key
lark-cli base +data-query \
--base-token MAGObxxxxx \
--dsl '{
"datasource": {"type": "table", "table": {"tableId": "tblxxxxxxxx"}},
"dimensions": [{"field_name": "业务编号", "alias": "biz_key"}],
"measures": [{"field_name": "指标值", "aggregation": "max", "alias": "max_value"}],
"filters": {
"type": 1,
"conjunction": "and",
"conditions": [{"field_name": "状态", "operator": "is", "value": ["有效"]}]
},
"sort": [{"field_name": "max_value", "order": "desc"}],
"pagination": {"limit": 10},
"shaper": {"format": "flat"}
}'
```
## 参数
@@ -397,6 +416,19 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
- 每个 value 是 CellValue 对象,实际值在 `value` 字段中,如 `{"value": "北京"}``{"value": 12345.00}`
- 失败时结果在 `data.error` 中,包含具体错误码和信息
## 与记录读取组合
`+data-query` 不返回原始记录或 link 字段明细。需要输出聚合结果对应的原始记录字段、展示值或关联表字段时,按以下方式组合:
1.`+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN得到业务 key、分组值或候选范围。
2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取明细字段。
3. 如果拿到的是结构化业务 key例如编号、状态、日期、金额等优先创建临时视图做精确过滤后再 `+record-list --view-id` 读取;不要用 `+record-search` 代替结构化条件。
4. 只有候选条件本身是文本展示值关键词时,才使用 `+record-search`,并用 `search_fields` 限定范围、`select_fields` 做投影。
5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。
6. 最终回答业务字段,不要把内部 `record_id` 当作用户可读答案。
不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量明细导出时回到 data analysis SOP 的 record 分页规则。
## 坑点
- ⚠️ **必须先查表结构**DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `lark-cli base +field-list --base-token <base_token> --table-id <table_id>` 获取真实字段名
@@ -408,10 +440,12 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
- ⚠️ **数据表标识 `tableId` vs `tableName`**datasource 中可以用 `tableId`(如 `tblXXX`)或 `tableName`(数据表的用户自定义显示名称),二选一,不要混用
- ⚠️ **`pagination.limit` 最大 5000**:超过会报错,且不支持 offset只支持 limit
- ⚠️ **所有 alias 必须全局唯一**dimensions 和 measures 之间的 alias 也不能重名
- ⚠️ **不要用本地分页结果替代 data-query**:凡是全局计数、分组、聚合、排序 TopN优先让 `+data-query` 在 Base 云端查询服务中执行;默认页 `+record-list` 后本地统计只能得到已读取范围内的结果
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、record 明细回查和关系查询 SOP
- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范
- [lark-base-shortcut-field-properties.md](lark-base-shortcut-field-properties.md) — shortcut 字段类型与 JSON 结构

View File

@@ -1,86 +0,0 @@
# base record read SOP
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和全局参数。
记录读取由 6 个功能组合完成选路、字段投影、视图预处理、分页与范围、返回结构解释、link 关联读取。
## 1. 读取选路
| 场景 | 使用方式 | 规则 |
|------|------|------|
| 已知 `record_id` | `+record-get` | 只读单条记录,不要用 search/list 反查。 |
| 明确关键词检索 | `+record-search` | 只用于文本关键词检索;金额、状态、日期等结构化条件不要用 search。 |
| 普通明细读取 / 导出 / 查看前 N 条 | `+record-list` | 优先加 `--view-id` 时只读该视图可见记录与可见字段;或者加 `--field-id` 手动裁剪字段;不传 `--view-id` 时会读取全表。 |
| 明确筛选 / 排序 / Top N / Bottom N 且需要原始记录或 `record_id` | 创建带 filter + sort 的临时视图 + `+record-list --view-id` | 让视图完成 filter/sort projectionLLM 不擅长手工筛选排序,建议用视图完成。 |
| 统计 / 聚合结果且不需要 `record_id` | 转到 [`lark-base-data-query.md`](lark-base-data-query.md) | `data-query` 是特殊分析 DSL不是记录读取工具。 |
## 2. 字段投影
- `FieldListFirst`: 不清楚字段结构时先 `+field-list`,确认筛选字段、排序字段、展示字段、关联字段、业务唯一键字段。
- `UseRealField`: 字段名和字段 ID 必须来自 `+field-list` 返回,不要凭自然语言猜字段名。
- `MinimalProjection`: 每次读取只返回本次任务需要的字段;`+record-list` 用重复 `--field-id`,视图读取用 `+view-set-visible-fields`
- `FieldScopePriority`: 返回字段优先级为显式投影字段(`+record-list --field-id` / `record-search select_fields` > 视图可见字段 > 全表字段;需要稳定列范围时必须显式投影。
- `LongFieldAvoidance`: 默认不要读取 `trace``raw`、长文本、附件等高噪声字段,除非任务明确需要。
- `BusinessKey`: 后续要定位、更新或解释记录时投影中必须包含可识别业务字段例如订单号、日报ID、姓名、编号。
## 3. 视图预处理
适用于结构化筛选、排序、最高/最低、倒数、Top/Bottom N、按条件找记录等场景。
1. `+field-list` 获取字段 ID、字段名和字段类型。
2. `+view-create` 创建临时 `grid` 视图,名称带任务语义,例如 `tmp_query_销售额升序`
3. `+view-set-filter` 设置筛选条件;空值是否参与必须按用户语义判断。
4. `+view-set-sort` 设置排序条件;最高/最新用降序,最低/最早/倒数用升序。
5. `+view-set-visible-fields` 设置投影字段,只保留业务键、排序字段、筛选解释字段、需要展示或二跳的字段。
6. `+record-list --view-id <view_id> --limit <N>` 读取结果;不要再从未排序全表输出中手动挑选。
## 4. 分页与范围
- `ViewScope`: URL 带 `view_id` 时先判断用户是否要求“该视图下”;全表问题不要误用 URL 视图范围,应该根据需求创建合适的临时视图完成查询任务。
- `ViewIdScope`: `+record-list --view-id` 是作用域参数;仅用于用户指定的视图,或本次任务主动创建的临时筛选 / 排序 / 投影视图。
- `NeedAllPages`: 用户要求全部、导出、统计、最高/最低且未用视图/limit 限定时,必须检查 `has_more` 并串行翻页。
- `LimitWhenScoped`: 用户只要示例、前 N 条、Top/Bottom N使用 `--limit` 控制结果规模。
- `NoConcurrentList`: `+record-list` 禁止并发调用;分页和多表读取必须串行。
- `DataQueryScope`: `data-query` 的筛选 DSL 与视图筛选不是同一套语法;不要混用。
## 5. 返回结构解释
- `ColumnMapping`: `fields` / `field_id_list` 定义 `data` 每列含义;解释记录前先建立列到字段名的映射。
- `RowMapping`: `record_id_list[i]``data[i]` 是同一行;需要后续定位、更新或关联时,按下标整理成 `record_id + 字段名:值` 的小表。
- `BusinessMatch`: 后续引用目标记录时按业务字段匹配,不靠肉眼数行号。
- `FieldType`: 按字段类型解释值数字、货币、日期、人员、formula、lookup、attachment、link 不要当普通文本处理。
- `EmptyValue`: 空值参与筛选或排序前必须明确语义;不要默认把空值当 `0`、空字符串或有效状态。
- `AnswerCheck`: 最终回答前复核答案记录来自读取结果、筛选排序已应用、字段含义和 record_id 映射无误。
## 6. link 关联字段读取
link 字段是关联单元格;读取结果通常是关联表的 `record_id` 数组,不是用户可读名称。
| 步骤 | 做法 |
|------|------|
| 识别 link 字段 | 用 `+field-list` 查看字段类型为 `link`,并读取 `link_table` 确认关联目标表。 |
| 读取当前表 | 在当前表 `+record-list` / `+record-get` 中保留 link 字段和业务键字段。 |
| 解析单元格值 | link 单元格通常形如 `[{"id":"rec..."}]`;提取其中每个 `id` 作为关联表 `record_id`。 |
| 读取关联表 | 到 `link_table` 使用 `+record-get --record-id <rec...>` 或裁剪后的 `+record-list` 读取显示字段。 |
| 建立映射 | 形成 `关联record_id -> 显示字段值` 映射,再回填当前表结果。 |
| 多值处理 | 多个关联值保持原顺序;可去重批量读取,但回答时按原单元格顺序输出。 |
禁止事项:
- 不要把 link 单元格里的 `record_id` 当作最终答案。
- 不要用 `+record-search` 搜索 link `record_id` 来查关联记录。
- 不要凭关联 `record_id` 猜名称、负责人、门店等显示值。
- 不要只看当前表字段名推断关联表结构;跨表读取前必须拿关联表字段结构。
## 7. 命令 help
- `HelpFirst`: 参数、示例、JSON shape 和取值约束以 `lark-cli base +record-get --help``+record-search --help``+record-list --help` 为准。
- `RecordSearchJson`: 构造 `+record-search --json` 前先看 `+record-search --help`,确认 `keyword/search_fields/select_fields/view_id/offset/limit` 的结构和约束。
- `RecordListProjection`: 构造 `+record-list` 前先看 `+record-list --help`,确认 `--field-id``--view-id``--offset``--limit` 的语义。
## 参考
- [lark-base-view-set-filter.md](lark-base-view-set-filter.md)
- [lark-base-view-set-sort.md](lark-base-view-set-sort.md)
- [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
- [lark-base-data-query.md](lark-base-data-query.md)

View File

@@ -8,7 +8,7 @@ record 相关命令索引。
| 文档 | 命令 | 说明 |
|------|------|------|
| [lark-base-record-read-sop.md](lark-base-record-read-sop.md) | `+record-get` / `+record-search` / `+record-list` | 记录读取统一选路、筛选排序投影 SOP |
| [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) | `+record-get` / `+record-search` / `+record-list` / `+data-query` / 视图筛选排序 | 数据查询与分析统一选路、筛选排序投影、聚合后回查明细 SOP |
| [lark-base-record-upsert.md](lark-base-record-upsert.md) | `+record-upsert` | 创建或更新记录 |
| [lark-base-record-batch-create.md](lark-base-record-batch-create.md) | `+record-batch-create` | 按 `fields/rows` 批量创建记录 |
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
@@ -19,7 +19,7 @@ record 相关命令索引。
## 说明
- 读取记录前优先阅读 [lark-base-record-read-sop.md](lark-base-record-read-sop.md),它合并了 `+record-get` / `+record-search` / `+record-list` 的选路和 SOP
- 读取记录前优先阅读 [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md),它合并了 `record / view / data-query` 的选路、分页、投影、聚合后回查明细和 link 关联读取
- 聚合页只保留目录职责;写入、删除、历史等命令的详细说明请进入对应单命令文档。
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。

View File

@@ -1,7 +1,7 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档v2创建和编辑飞书文档。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文、更新文档八种指令str_replace/block_insert_after/block_copy_insert_after/block_replace/block_delete/block_move_after/overwrite/append、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用如果用户是想按名称或关键词先定位电子表格、报表等云空间对象也优先使用本 skill 的 docs +search 做资源发现。"
description: "飞书云文档 / Docx / 知识库 Wiki 文档v2创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、画板、引用或同步块时,也先用本 skill 读取和提取 token再切到对应 skill 下钻。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML也支持 Markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -10,7 +10,7 @@ metadata:
# docs (v2)
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create`、`docs +fetch`、`docs +update` 命令必须携带 `--api-version v2`。**
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create --api-version v2`、`docs +fetch --api-version v2`、`docs +update --api-version v2` 命令必须携带 `--api-version v2`。**
```bash
# 常用示例
@@ -23,7 +23,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
2. **读取文档(`docs +fetch --api-version v2`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误、格式错误或样式不达标。**
@@ -49,7 +49,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
**补充:** 云空间资源发现统一走 [`drive +search`](../lark-drive/references/lark-drive-search.md);当用户口头说"表格/报表/最近我编辑过的 xxx"时,也优先从 `drive +search` 开始。老的 `docs +search` 只在沿用 `--filter` JSON 的存量脚本里保留,后续会下线。

View File

@@ -130,7 +130,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
## 嵌入电子表格 / 多维表格
返回中可能含 `<sheet>``<bitable>``<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
返回中可能含 `<sheet>``<bitable>``<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch --api-version v2` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
## 参考

View File

@@ -1,6 +1,6 @@
# Markdown 格式参考
`docs +fetch / +create / +update` 使用 `--doc-format markdown` 时适用。
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
## 转义规则
@@ -34,14 +34,14 @@
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
**导出已转义,不要反转义:**
`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[``\|``\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
`docs +fetch --api-version v2 --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[``\|``\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
**写入时必须转义:**
使用 `docs +create``docs +update``--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
**导出 → 更新 工作流示例:**
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
1. `docs +fetch --api-version v2` 导出得到 `C:\\Users\\test\[1\]`
2.`str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`

View File

@@ -57,7 +57,7 @@ lark-cli docs +search \
# 按文档所有者过滤creator_ids 传文档所有者 open_id不是邮箱 / user_id
lark-cli docs +search \
--query "季度总结" \
--filter '{"creator_ids":["ou_7890123456abcdef"]}'
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"]}'
# 只搜索指定类型
lark-cli docs +search \
@@ -87,7 +87,7 @@ lark-cli docs +search \
# 只搜索指定分享者分享过的文档sharer_ids 传分享者 open_id最多 20 个)
lark-cli docs +search \
--query "复盘" \
--filter '{"sharer_ids":["ou_7890123456abcdef"]}'
--filter '{"sharer_ids":["ou_EXAMPLE_USER_ID"]}'
# 按创建时间过滤并指定排序方式
lark-cli docs +search \
@@ -97,7 +97,7 @@ lark-cli docs +search \
# 组合多个筛选条件
lark-cli docs +search \
--query "项目复盘" \
--filter '{"creator_ids":["ou_7890123456abcdef"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
# 只在指定知识空间下搜 Wiki
lark-cli docs +search \
@@ -179,10 +179,10 @@ lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
### 常见 `--filter` JSON 片段
```json
{"creator_ids":["ou_7890123456abcdef"]}
{"creator_ids":["ou_EXAMPLE_USER_ID"]}
{"doc_types":["SHEET","DOCX"]}
{"chat_ids":["oc_1234567890abcdef"]}
{"sharer_ids":["ou_7890123456abcdef"]}
{"sharer_ids":["ou_EXAMPLE_USER_ID"]}
{"folder_tokens":["fld_123456"]}
{"only_title":true}
{"only_comment":true}

View File

@@ -15,7 +15,7 @@
> - **局部精修**`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML默认。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
> - **整段写入**`append` / `overwrite`XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown否则默认 XML。
>
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID也无样式颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope``outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown只是写入内容不带样式。
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID也无样式颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --api-version v2 --detail with-ids` **配合 `--scope``outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown只是写入内容不带样式。
## 参数
@@ -221,7 +221,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
## 画板处理
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。

View File

@@ -16,7 +16,7 @@
| 场景 | 入口 |
|------|------|
| 文档中需要插入新画板 | 继续步骤 2 |
| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3 |
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3 |
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
### 步骤 2在文档中创建空白画板

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