mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
14 Commits
pr-870
...
fix/okr_sk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b709824aae | ||
|
|
f03138b9f0 | ||
|
|
ed9eecf94f | ||
|
|
f49a2f7e14 | ||
|
|
a93fb2d6b3 | ||
|
|
7acf64c3ef | ||
|
|
52e0129078 | ||
|
|
8a8dff47ce | ||
|
|
1c2d3d7679 | ||
|
|
0d20f88453 | ||
|
|
b0bd9b0258 | ||
|
|
ba6edb84e4 | ||
|
|
a54a879330 | ||
|
|
a27c636131 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
|
||||
@@ -14,3 +14,4 @@ id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,17 @@
|
||||
|
||||
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
|
||||
@@ -692,6 +703,7 @@ 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
|
||||
|
||||
14
Makefile
14
Makefile
@@ -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
|
||||
|
||||
12
README.md
12
README.md
@@ -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**
|
||||
|
||||
12
README.zh.md
12
README.zh.md
@@ -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 步 — 配置应用凭证**
|
||||
|
||||
@@ -507,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -612,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
|
||||
@@ -658,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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -481,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) {
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.30",
|
||||
"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
66
scripts/check-doc-tokens.sh
Executable 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
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
36
shortcuts/doc/versioned_help_test.go
Normal file
36
shortcuts/doc/versioned_help_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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", "", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +152,7 @@ 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
|
||||
}
|
||||
@@ -130,30 +170,42 @@ var DriveStatus = common.Shortcut{
|
||||
// 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))
|
||||
remoteFiles := make(map[string]driveStatusRemoteFile, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[entry.RelPath] = 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 {
|
||||
@@ -163,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),
|
||||
@@ -180,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
|
||||
@@ -202,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 {
|
||||
@@ -215,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 {
|
||||
@@ -244,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{}{}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -674,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",
|
||||
@@ -809,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) {
|
||||
@@ -823,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,6 +859,7 @@ func TestShortcuts(t *testing.T) {
|
||||
|
||||
want := []string{
|
||||
"+chat-create",
|
||||
"+chat-list",
|
||||
"+chat-messages-list",
|
||||
"+chat-search",
|
||||
"+chat-update",
|
||||
|
||||
156
shortcuts/im/im_chat_list.go
Normal file
156
shortcuts/im/im_chat_list.go
Normal 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
|
||||
}
|
||||
128
shortcuts/im/im_chat_list_test.go
Normal file
128
shortcuts/im/im_chat_list_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
320
shortcuts/im/mute_filter.go
Normal file
320
shortcuts/im/mute_filter.go
Normal 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
|
||||
}
|
||||
445
shortcuts/im/mute_filter_test.go
Normal file
445
shortcuts/im/mute_filter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -111,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))
|
||||
|
||||
@@ -137,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())
|
||||
@@ -170,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) {
|
||||
@@ -255,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())
|
||||
}
|
||||
@@ -385,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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
WikiMove,
|
||||
WikiNodeCreate,
|
||||
WikiDeleteSpace,
|
||||
WikiSpaceList,
|
||||
WikiNodeList,
|
||||
WikiNodeCopy,
|
||||
}
|
||||
}
|
||||
|
||||
973
shortcuts/wiki/wiki_list_copy_test.go
Normal file
973
shortcuts/wiki/wiki_list_copy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
140
shortcuts/wiki/wiki_node_copy.go
Normal file
140
shortcuts/wiki/wiki_node_copy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
218
shortcuts/wiki/wiki_node_list.go
Normal file
218
shortcuts/wiki/wiki_node_list.go
Normal 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)
|
||||
}
|
||||
}
|
||||
211
shortcuts/wiki/wiki_space_list.go
Normal file
211
shortcuts/wiki/wiki_space_list.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
@@ -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 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
|
||||
@@ -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 中直接提取 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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 的存量脚本里保留,后续会下线。
|
||||
|
||||
|
||||
@@ -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) 路由表。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -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\]`
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) 的「画板语法与插入」章节。
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| 场景 | 入口 |
|
||||
|------|------|
|
||||
| 文档中需要插入新画板 | 继续步骤 2 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3 |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
### 步骤 2:在文档中创建空白画板
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
### 第三波 — 整合审查 + 画板意图识别(串行)
|
||||
|
||||
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
|
||||
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节及推荐的画板类型
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch` 支持直接传入 URL |
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`slides` 仅支持 block_id,且都支持最终解析到对应类型的 wiki URL |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
@@ -200,6 +200,19 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides) |
|
||||
|
||||
#### `permission.public.patch` 错误码引导
|
||||
|
||||
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope;它们通常表示租户、对外分享或文档密级策略拦截。
|
||||
|
||||
| 错误码 | 含义 | 给用户的引导 |
|
||||
|--------|------------------------|--------------|
|
||||
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过 API 或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
|
||||
| `91010` | 文档对外分享未打开 | 提示用户:当前文档尚未打开对外分享,请先在文档权限设置中打开对外分享,再重试 `permission.public.patch`。 |
|
||||
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
|
||||
当用户最初提供的是文档 URL,遇到 `91011` 或 `91012` 时直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token,需要先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL,再给出可点击的文档 URL。
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
当需要将文档权限授予**当前应用(bot)自身**时,先通过 bot info 接口获取应用的 open_id,再调用权限接口授权:
|
||||
@@ -229,7 +242,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
|
||||
@@ -302,27 +315,29 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
| `file.comments.patch` | `docs:document.comment:update` |
|
||||
| `file.comment.replys.create` | `docs:document.comment:create` |
|
||||
| `file.comment.replys.delete` | `docs:document.comment:delete` |
|
||||
| `file.comment.replys.list` | `docs:document.comment:read` |
|
||||
| `file.comment.replys.update` | `docs:document.comment:update` |
|
||||
| `permission.members.auth` | `docs:permission.member:auth` |
|
||||
| `permission.members.create` | `docs:permission.member:create` |
|
||||
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
|
||||
| `metas.batch_query` | `drive:drive.metadata:readonly` |
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
|
||||
| 方法 | 所需 scope |
|
||||
|------------------------------------------------|-----------------------------------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
| `file.comments.patch` | `docs:document.comment:update` |
|
||||
| `file.comment.replys.create` | `docs:document.comment:create` |
|
||||
| `file.comment.replys.delete` | `docs:document.comment:delete` |
|
||||
| `file.comment.replys.list` | `docs:document.comment:read` |
|
||||
| `file.comment.replys.update` | `docs:document.comment:update` |
|
||||
| `permission.members.auth` | `docs:permission.member:auth` |
|
||||
| `permission.members.create` | `docs:permission.member:create` |
|
||||
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
|
||||
| `permission.public.get` | `docs:permission.setting:read` |
|
||||
| `permission.public.patch` | `docs:permission.setting:write_only` |
|
||||
| `metas.batch_query` | `drive:drive.metadata:readonly` |
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||
按 **精确 SHA-256**(默认)或 **快速 modified_time**(`--quick`)比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `new_local` | 仅本地存在 |
|
||||
| `new_remote` | 仅云端存在 |
|
||||
| `modified` | 双端都存在但 hash 不一致 |
|
||||
| `unchanged` | 双端都存在且 hash 一致 |
|
||||
| `modified` | 双端都存在且本次检测判定为已变更:`detection=exact` 时表示 hash 不一致;`detection=quick` 时表示本地 mtime 与远端 `modified_time` 不一致,或远端时间戳不可可信 |
|
||||
| `unchanged` | 双端都存在且本次检测判定为未变更:`detection=exact` 时表示 hash 一致;`detection=quick` 时表示本地 mtime 与远端 `modified_time` 相等 |
|
||||
|
||||
只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。
|
||||
只读命令:
|
||||
|
||||
- 默认 `detection=exact`:双端都有的文件会从云端拉一份字节流过来在内存里算 hash,不下载落盘,但大目录 / 大文件会有可观的网络流量。
|
||||
- 传 `--quick` 后 `detection=quick`:只比较本地 mtime 与远端 `modified_time`,**不下载远端文件内容**,适合先做快速预检查;它是 best-effort,不等同于严格内容一致性判断。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
@@ -26,7 +29,13 @@ lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 只看 hash 不一致的项(结合 --jq 过滤)
|
||||
# 快速模式 —— 只比较 modified_time,不下载远端文件内容
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx \
|
||||
--quick
|
||||
|
||||
# 只看判定为 modified 的项(exact=hash 不一致;quick=mtime 不一致)(结合 --jq 过滤)
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx \
|
||||
@@ -39,6 +48,7 @@ lark-cli drive +status \
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | Drive 文件夹 token |
|
||||
| `--quick` | 否 | bool | 快速模式:只比较本地 mtime 与远端 `modified_time`,跳过远端下载和 SHA-256 计算;输出里的 `detection` 会变成 `quick` |
|
||||
|
||||
## 输出 schema
|
||||
|
||||
@@ -46,6 +56,7 @@ lark-cli drive +status \
|
||||
|
||||
```json
|
||||
{
|
||||
"detection": "exact",
|
||||
"new_local": [{"rel_path": "..."}],
|
||||
"new_remote": [{"rel_path": "...", "file_token": "..."}],
|
||||
"modified": [{"rel_path": "...", "file_token": "..."}],
|
||||
@@ -53,6 +64,11 @@ lark-cli drive +status \
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `detection=exact`:默认模式,双端都有的文件会下载远端字节流并做 SHA-256 比较。
|
||||
- `detection=quick`:`--quick` 模式,只按本地 mtime 与远端 `modified_time` 做 best-effort 判断。
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
|
||||
|
||||
远端同名文件冲突时:
|
||||
@@ -84,6 +100,7 @@ lark-cli drive +status \
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
||||
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
|
||||
- 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。
|
||||
- `--quick` 模式下,双端都有的文件只在 **远端时间精度** 下比较 `modified_time` / 本地 mtime:相等才记为 `unchanged`,否则记为 `modified`;远端时间戳缺失或非法时,走保守路径记为 `modified`,不会盲判 `unchanged`。
|
||||
|
||||
## 范围限制
|
||||
|
||||
@@ -99,9 +116,10 @@ lark-cli drive +status \
|
||||
|
||||
## 性能注意
|
||||
|
||||
- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
|
||||
- 默认 `detection=exact` 下,`unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
|
||||
- `--quick` / `detection=quick` 下,不会下载双端共有文件的远端内容,执行时间更接近 `O(文件数量)`,而不是 `O(总文件大小)`。
|
||||
- 仅一侧存在的文件不会被下载。
|
||||
- Hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。
|
||||
- 默认模式的 hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
@@ -110,7 +128,7 @@ lark-cli drive +status \
|
||||
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` |
|
||||
| 下载并 hash 文件 | `drive:file:download` |
|
||||
|
||||
如果当前 token 缺这些 scope,命令会直接报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只声明上面这两个细粒度 scope。
|
||||
默认会先要求 `drive:drive.metadata:readonly`。在 `detection=exact` 路径(默认,不传 `--quick`)下,CLI 还会额外要求 `drive:file:download`;传 `--quick` 时不会要求下载 scope。如果当前 token 缺本次执行路径需要的 scope,命令会报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只依赖上面这些细粒度 scope。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -23,12 +23,16 @@ lark-cli drive +upload --file ./report.pdf
|
||||
# 自定义上传后的文件名
|
||||
lark-cli drive +upload --file ./report.pdf --name "季度总结.pdf"
|
||||
|
||||
# 覆盖已存在文件(原地覆盖,保留 file_token)
|
||||
lark-cli drive +upload --file ./report.pdf --file-token boxcn_existing_file
|
||||
|
||||
# 原生命令(高级/分片上传):预上传 + 完成上传
|
||||
lark-cli drive files upload_prepare --data '{
|
||||
"file_name": "report.pdf",
|
||||
"parent_type": "explorer",
|
||||
"parent_node": "fldbc_xxx",
|
||||
"size": 1048576
|
||||
"size": 1048576,
|
||||
"file_token": "boxcn_existing_file"
|
||||
}'
|
||||
lark-cli drive files upload_finish --data '{
|
||||
"upload_id": "<UPLOAD_ID>",
|
||||
@@ -40,7 +44,9 @@ lark-cli schema drive.files.upload_prepare
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果文件是**以应用身份(bot)上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
|
||||
> 如果文件是**以应用身份(bot)新建上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
|
||||
>
|
||||
> 如果这次调用传了 `--file-token`,表示是在**覆盖已有文件**,CLI **不会**额外修改该文件权限。
|
||||
>
|
||||
> 以应用身份上传时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
|
||||
> - `status = granted`:当前 CLI 用户已获得该文件的可管理权限
|
||||
@@ -51,12 +57,18 @@ lark-cli schema drive.files.upload_prepare
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
> [!TIP]
|
||||
> 当底层上传接口返回版本号时,shortcut 会在结果里额外透出 `version`。
|
||||
|
||||
## 目标位置选择(关键)
|
||||
|
||||
- 上传到 Drive 文件夹:传 `--folder-token <folder_token>`,shortcut 会发送 `parent_type=explorer`
|
||||
- 上传到 wiki 节点:传 `--wiki-token <wiki_token>`,shortcut 会发送 `parent_type=wiki`
|
||||
- 上传到 Drive 根目录:`--folder-token` 和 `--wiki-token` 都不传
|
||||
- 覆盖已有文件:额外传 `--file-token <existing_file_token>`;shortcut 会把它原样透传到底层 `upload_all` / `upload_prepare`,让后端按覆盖语义写入
|
||||
- bot 模式下,`--file-token` 覆盖只改文件内容;不会额外给当前 CLI 用户补 `full_access`
|
||||
- 不要传空目标值:`--folder-token ""` / `--wiki-token ""` 会被视为参数错误;如需上传到 Drive 根目录,应直接省略这两个参数
|
||||
- 不要传空 `--file-token`:如需新建上传,直接省略该参数;显式传空字符串会报错
|
||||
- `--folder-token` 和 `--wiki-token` 互斥,不要同时传
|
||||
- `--wiki-token` 传的是 **wiki node token**,不是 `space_id`
|
||||
|
||||
@@ -65,6 +77,7 @@ Shortcut 参数:
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地文件路径 |
|
||||
| `--file-token` | 否 | 已存在文件的 token;传入后按“覆盖已有文件”语义上传 |
|
||||
| `--folder-token` | 否 | 目标文件夹 token;与 `--wiki-token` 互斥;省略时默认为 Drive 根目录;显式传空字符串会报错 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;会映射为 `parent_type=wiki`、`parent_node=<wiki_token>`;显式传空字符串会报错 |
|
||||
| `--name` | 否 | 上传后的文件名;默认使用本地文件名 |
|
||||
@@ -77,6 +90,7 @@ Shortcut 参数:
|
||||
| `parent_type` | 是 | 父节点类型;上传到文件夹 / 根目录时用 `"explorer"`,上传到 wiki 节点时用 `"wiki"` |
|
||||
| `parent_node` | 是 | 父节点 token;`explorer` 时传文件夹 token(根目录可为空字符串),`wiki` 时传 wiki node token |
|
||||
| `size` | 是 | 文件大小(字节) |
|
||||
| `file_token` | 否 | 已存在文件 token;传入后覆盖该文件内容 |
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
@@ -69,8 +69,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | 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, and pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | 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) |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
@@ -96,7 +97,6 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
- `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
|
||||
- `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
|
||||
- `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
|
||||
- `list` — 获取用户或机器人所在的群列表。Identity: supports `user` and `bot`.
|
||||
- `update` — 更新群信息。Identity: supports `user` and `bot`.
|
||||
|
||||
### chat.members
|
||||
@@ -141,7 +141,6 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `chats.create` | `im:chat:create` |
|
||||
| `chats.get` | `im:chat:read` |
|
||||
| `chats.link` | `im:chat:read` |
|
||||
| `chats.list` | `im:chat:read` |
|
||||
| `chats.update` | `im:chat:update` |
|
||||
| `chat.members.bots` | `im:chat.members:read` |
|
||||
| `chat.members.create` | `im:chat.members:write_only` |
|
||||
|
||||
113
skills/lark-im/references/lark-im-chat-list.md
Normal file
113
skills/lark-im/references/lark-im-chat-list.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# im +chat-list
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
List groups the current user (or bot, with `--as bot`) is a member of. Useful for enumerating "my chats" without a search keyword, or for bulk operations against the caller's chats. Supports pagination, sort order, and (user identity only) muted-chat filtering.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# List the user's chats (default sort: ByCreateTimeAsc)
|
||||
lark-cli im +chat-list
|
||||
|
||||
# Sort by recent activity (most recently active first)
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc
|
||||
|
||||
# Limit page size
|
||||
lark-cli im +chat-list --page-size 50
|
||||
|
||||
# Pagination
|
||||
lark-cli im +chat-list --page-token "xxx"
|
||||
|
||||
# Drop muted chats (user identity only)
|
||||
lark-cli im +chat-list --exclude-muted
|
||||
|
||||
# JSON output
|
||||
lark-cli im +chat-list --format json
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +chat-list --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Limits | Description |
|
||||
|------|------|------|------|
|
||||
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
|
||||
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
|
||||
|
||||
## Output Fields
|
||||
|
||||
| Field | Description |
|
||||
|------|------|
|
||||
| `chat_id` | Chat ID (`oc_xxx` format) |
|
||||
| `name` | Chat name |
|
||||
| `description` | Chat description |
|
||||
| `owner_id` | Owner ID (type controlled by `--user-id-type`) |
|
||||
| `external` | Whether the chat is external |
|
||||
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
|
||||
|
||||
## Filtering muted chats
|
||||
|
||||
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the list call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
|
||||
|
||||
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
|
||||
|
||||
```json
|
||||
{
|
||||
"chats": [...],
|
||||
"filter": {
|
||||
"applied": "exclude_muted",
|
||||
"fetched_count": 20,
|
||||
"returned_count": 17,
|
||||
"filtered_count": 3,
|
||||
"hint": "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: List my recent chats
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --page-size 10
|
||||
```
|
||||
|
||||
### Scenario 2: List my non-muted chats sorted by activity
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --exclude-muted
|
||||
```
|
||||
|
||||
### Scenario 3: Iterate all my chats programmatically
|
||||
|
||||
```bash
|
||||
TOKEN=""
|
||||
while :; do
|
||||
RESP=$(lark-cli im +chat-list --page-size 100 --page-token "$TOKEN" --format json)
|
||||
echo "$RESP" | jq -r '.data.chats[].chat_id'
|
||||
HAS_MORE=$(echo "$RESP" | jq -r '.data.has_more')
|
||||
[ "$HAS_MORE" = "true" ] || break
|
||||
TOKEN=$(echo "$RESP" | jq -r '.data.page_token')
|
||||
done
|
||||
```
|
||||
|
||||
## Common Errors and Troubleshooting
|
||||
|
||||
| Symptom | Root Cause | Solution |
|
||||
|---------|---------|---------|
|
||||
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
|
||||
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
|
||||
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
|
||||
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
|
||||
| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering |
|
||||
@@ -129,7 +129,7 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
lark-cli im +chat-search --query "<chat name keyword>" --format json
|
||||
lark-cli im +chat-messages-list --chat-id <chat_id>
|
||||
```
|
||||
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
|
||||
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
|
||||
2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls.
|
||||
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`.
|
||||
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.
|
||||
|
||||
@@ -49,6 +49,7 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `--sort-by <field>` | No | `create_time_desc`, `update_time_desc`, `member_count_desc` | Sort field in descending order |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
@@ -65,6 +66,27 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `external` | Whether the chat is external |
|
||||
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
|
||||
|
||||
## Filtering muted chats
|
||||
|
||||
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the search call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
|
||||
|
||||
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
|
||||
|
||||
```json
|
||||
{
|
||||
"chats": [...],
|
||||
"filter": {
|
||||
"applied": "exclude_muted",
|
||||
"fetched_count": 20,
|
||||
"returned_count": 19,
|
||||
"filtered_count": 1,
|
||||
"hint": "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: only confirmed-muted chats count toward `filtered_count`; non-member public groups are retained and surfaced in `hint`. For strict member-only results, combine with `--search-types "private,public_joined,external"`.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: Search chats that contain a keyword
|
||||
@@ -106,7 +128,7 @@ When the user asks to search chats, follow these rules:
|
||||
2. **Search scope is limited:** only chats visible to the current user or bot can be found (joined chats plus public chats). This is not a global search over all chats.
|
||||
3. **Control result volume:** the result set may be large. Use `--page-size` deliberately.
|
||||
4. **Suggest follow-up actions:** after finding a chat, common next steps include listing recent messages (`im +chat-messages-list`) or sending a message (`im +messages-send`).
|
||||
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `im chats list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
|
||||
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `+chat-list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ lark-cli im +chat-search --query "<chat name keyword>" --format json
|
||||
lark-cli im +messages-search --query "keyword" --chat-id <chat_id>
|
||||
```
|
||||
|
||||
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
|
||||
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
|
||||
|
||||
## Work Summary / Report Generation
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA
|
||||
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
|
||||
|
||||
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
|
||||
lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
|
||||
lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
@@ -17,6 +17,22 @@ Alignment (对齐关系): Objective ↔ Objective
|
||||
Category (分类): Objective 的分组标签
|
||||
```
|
||||
|
||||
## 常用用户表述
|
||||
|
||||
以下是部分用户可能会使用的指令表述,以及它们对应的实体与字段。
|
||||
|
||||
- "进度 / 完成度 / 进展值",当用户提到量化的进度或进展这样的概念时
|
||||
- 对应实体: Indicator, 通常主要关注进度的当前值(Indicator.current_value)
|
||||
- "进展 / 更新 / Check-in",当用户提到泛化的进度更新,尤其是有明确的文本化内容时
|
||||
- 对应实体: Progress
|
||||
- "Objective 或 KeyResult 的状态"
|
||||
- 对应字段: Indicator.indicator_status
|
||||
- 注意,虽然 Objective/KeyResult 下的 Progress 中也有 ProgressRate 字段,但这一字段仅代表这条 Progress 的进度状态(创建 Progress 时的状态),而非 Objective/KeyResult 的当前进度
|
||||
- "打分 / 评分 / 分数"
|
||||
- 对应字段: Objective.score, KeyResult.score, Cycle.score
|
||||
- "对齐 / 挂靠"
|
||||
- 对应实体: Alignment
|
||||
|
||||
---
|
||||
|
||||
## Owner (所有者)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-shared
|
||||
version: 1.0.0
|
||||
description: "飞书/Lark CLI 共享基础:应用配置初始化、认证登录(auth login)、身份切换(--as user/bot)、权限与 scope 管理、Permission denied 错误处理、安全规则。当用户需要第一次配置(`lark-cli config init`)、使用登录授权(`lark-cli auth login`)、遇到权限不足、切换 user/bot 身份、配置 scope、或首次使用 lark-cli 时触发。"
|
||||
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
@@ -82,11 +82,13 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(CLI 和 Skills 需要同时更新):
|
||||
2. 提议执行更新(同时更新 CLI 和 Skills):
|
||||
```bash
|
||||
npm update -g @larksuite/cli && npx skills add larksuite/cli -g -y
|
||||
lark-cli update
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent**以加载最新 Skills
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
|
||||
|
||||
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ lark-cli schema drive.metas.batch_query
|
||||
# 批量获取文档基本信息: 一次最多查询 10 个文档
|
||||
lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", "doc_token": "<doc_token>"}], "with_url": true}'
|
||||
```
|
||||
3. 需要获取文档内容时,使用 `lark-cli docs +fetch`。
|
||||
3. 需要获取文档内容时,使用 `lark-cli docs +fetch --api-version v2`。
|
||||
```bash
|
||||
# 获取文档内容
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
@@ -79,7 +79,7 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant
|
||||
| 用户意图 | 推荐命令 | 所在 skill |
|
||||
|---------|---------|--------|
|
||||
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
|
||||
| 已结束会议的发言内容 | `vc +notes` 取 `verbatim_doc_token` 再 `docs +fetch` | 本 skill |
|
||||
| 已结束会议的发言内容 | `vc +notes` 取 `verbatim_doc_token` 再 `docs +fetch --api-version v2` | 本 skill |
|
||||
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ metadata:
|
||||
| 用户给了什么 | 怎么获取 |
|
||||
|---|---|
|
||||
| 直接给了 whiteboard token(`wbcnXXX`)| 直接使用 |
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --api-version v2 --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
|
||||
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md)|
|
||||
|
||||
**Step 2:渲染 & 写入**
|
||||
|
||||
@@ -57,6 +57,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)
|
||||
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |
|
||||
| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution |
|
||||
| [`+delete-space`](references/lark-wiki-delete-space.md) | Delete a wiki space, polling the async delete task when needed |
|
||||
| [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller |
|
||||
| [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) |
|
||||
| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -98,6 +101,7 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
| `members.delete` | `wiki:member:update` |
|
||||
| `members.list` | `wiki:member:retrieve` |
|
||||
| `nodes.copy` | `wiki:node:copy` |
|
||||
| `nodes.move` | `wiki:node:move` |
|
||||
| `nodes.create` | `wiki:node:create` |
|
||||
| `nodes.list` | `wiki:node:retrieve` |
|
||||
|
||||
|
||||
72
skills/lark-wiki/references/lark-wiki-node-copy.md
Normal file
72
skills/lark-wiki/references/lark-wiki-node-copy.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# lark-wiki +node-copy
|
||||
|
||||
Copy a wiki node (including its content) to a target space or under a target parent node. Used for cross-space migration.
|
||||
|
||||
> ⚠️ **High-risk write** — the upstream API is flagged `danger: true`, so this shortcut requires explicit `--yes` confirmation before issuing the request. Forgetting `--yes` returns a `confirmation_required` error and the copy is **not** performed.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-copy \
|
||||
--space-id <source_space_id> \
|
||||
--node-token <source_node_token> \
|
||||
(--target-space-id <target_space_id> | --target-parent-node-token <token>) \
|
||||
[--title <new_title>] \
|
||||
--yes \
|
||||
[--as user|bot]
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `--space-id` | **Yes** | Source wiki space ID |
|
||||
| `--node-token` | **Yes** | Source node token to copy |
|
||||
| `--target-space-id` | Conditional | Target space ID. Required if `--target-parent-node-token` is not set |
|
||||
| `--target-parent-node-token` | Conditional | Target parent node token. Required if `--target-space-id` is not set |
|
||||
| `--title` | No | New title for the copied node. Omit to keep the original title |
|
||||
| `--yes` | **Yes** | Confirm the high-risk operation. Without this flag the shortcut refuses to send the API request |
|
||||
| `--format` | No | Output format: `json` (default) / `pretty` / `table` / `csv` / `ndjson` |
|
||||
| `--as` | No | Identity: `user` or `bot` (default: `user`) |
|
||||
|
||||
> At least one of `--target-space-id` or `--target-parent-node-token` must be provided.
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"space_id": "target_space_id",
|
||||
"node_token": "wikcn_EXAMPLE_TOKEN",
|
||||
"obj_token": "doccn_EXAMPLE_TOKEN",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Getting Started (Copy)",
|
||||
"parent_node_token": "",
|
||||
"has_child": false
|
||||
}
|
||||
```
|
||||
|
||||
## Migration workflow
|
||||
|
||||
To migrate a subtree from one space to another:
|
||||
|
||||
```bash
|
||||
# 1. List nodes in the source space
|
||||
lark-cli wiki +node-list --space-id source_space_id
|
||||
|
||||
# 2. Copy each node to the target space
|
||||
lark-cli wiki +node-copy \
|
||||
--space-id <source_space_id> \
|
||||
--node-token wikcn_EXAMPLE_TOKEN \
|
||||
--target-space-id <target_space_id> \
|
||||
--yes
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Copying is recursive — the subtree under the node is also copied.
|
||||
- There is no native move API; migration = copy to target + (manually delete source if needed).
|
||||
|
||||
## Required Scope
|
||||
|
||||
`wiki:node:copy`
|
||||
88
skills/lark-wiki/references/lark-wiki-node-list.md
Normal file
88
skills/lark-wiki/references/lark-wiki-node-list.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# lark-wiki +node-list
|
||||
|
||||
List wiki nodes in a space or under a specific parent node. **Default fetches a single page** (large knowledge bases can have thousands of nodes — opt into `--page-all` explicitly with an eye on `--page-limit`).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Default: single page of root nodes
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID>
|
||||
|
||||
# Drill into a sub-directory (still single page by default)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token <NODE_TOKEN>
|
||||
|
||||
# Personal document library (user identity only)
|
||||
lark-cli wiki +node-list --space-id my_library --as user
|
||||
|
||||
# Walk every page (capped by --page-limit, default 10)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --page-all
|
||||
|
||||
# Walk every page with a higher cap
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --page-all --page-limit 30
|
||||
|
||||
# Resume from a cursor
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --page-token <TOKEN>
|
||||
|
||||
# Pretty / table output
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) |
|
||||
| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root |
|
||||
| `--page-size` | int | No | 50 | Page size, 1-50 |
|
||||
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
|
||||
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
|
||||
| `--page-limit` | int | No | 10 | Max pages with `--page-all` (0 = unlimited) |
|
||||
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
|
||||
| `--as` | enum | No | `user` | Identity: `user` or `bot` |
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"nodes": [
|
||||
{
|
||||
"space_id": "6946843325487912356",
|
||||
"node_token": "wikcn_EXAMPLE_TOKEN",
|
||||
"obj_token": "doccn_EXAMPLE_TOKEN",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "",
|
||||
"node_type": "origin",
|
||||
"title": "Getting Started",
|
||||
"has_child": true
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"page_token": ""
|
||||
},
|
||||
"meta": { "count": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=<cursor>` so the caller can resume via `--page-token` or by increasing `--page-limit`.
|
||||
|
||||
## Traverse the wiki tree
|
||||
|
||||
To list all content recursively, call `+node-list` again with each node's `node_token` as `--parent-node-token` when `has_child` is `true`.
|
||||
|
||||
```bash
|
||||
# Step 1: list root nodes
|
||||
lark-cli wiki +node-list --space-id 6946843325487912356
|
||||
|
||||
# Step 2: drill into a node that has children
|
||||
lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikcn_EXAMPLE_TOKEN
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront.
|
||||
|
||||
## Required Scope
|
||||
|
||||
`wiki:node:retrieve`
|
||||
68
skills/lark-wiki/references/lark-wiki-space-list.md
Normal file
68
skills/lark-wiki/references/lark-wiki-space-list.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# lark-wiki +space-list
|
||||
|
||||
List wiki spaces accessible to the caller. **Default fetches a single page** (matches the rest of the CLI's list shortcuts); pass `--page-all` to walk every page.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Default: single page (first up to --page-size items)
|
||||
lark-cli wiki +space-list
|
||||
|
||||
# Walk every page (capped by --page-limit, default 10)
|
||||
lark-cli wiki +space-list --page-all
|
||||
|
||||
# Walk every page, no cap (use with care if you have many spaces)
|
||||
lark-cli wiki +space-list --page-all --page-limit 0
|
||||
|
||||
# Resume from a specific cursor (single-page fetch regardless of --page-all)
|
||||
lark-cli wiki +space-list --page-token <TOKEN>
|
||||
|
||||
# Pretty / table / csv / ndjson output
|
||||
lark-cli wiki +space-list --format pretty
|
||||
lark-cli wiki +space-list --format table
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--page-size` | int | 50 | Page size, 1-50 |
|
||||
| `--page-token` | string | — | Page cursor; implies single-page fetch (no auto-pagination) |
|
||||
| `--page-all` | bool | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
|
||||
| `--page-limit` | int | 10 | Max pages with `--page-all` (0 = unlimited) |
|
||||
| `--format` | enum | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
|
||||
| `--as` | enum | `user` | Identity: `user` or `bot` |
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"spaces": [
|
||||
{
|
||||
"space_id": "6946843325487912356",
|
||||
"name": "Engineering Wiki",
|
||||
"description": "...",
|
||||
"space_type": "team",
|
||||
"visibility": "private",
|
||||
"open_sharing": "closed"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"page_token": ""
|
||||
},
|
||||
"meta": { "count": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=<cursor>` so the caller can resume via `--page-token` or by increasing `--page-limit`.
|
||||
|
||||
## Notes
|
||||
|
||||
- **The underlying API never returns the my_library personal library**; resolve it via `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`.
|
||||
- Use `space_id` from the output as `--space-id` for `+node-list` or `+node-copy`.
|
||||
|
||||
## Required Scope
|
||||
|
||||
`wiki:space:retrieve`
|
||||
@@ -8,6 +8,7 @@
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
|
||||
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
|
||||
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
|
||||
@@ -15,7 +16,7 @@
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`; live duplicate/status workflows also use real `+upload` to seed remote fixtures.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` and `TestDriveUploadDryRun_WithFileToken` cover the wiki-target and overwrite request shapes for `drive +upload`; live upload/status/duplicate workflows also use real `+upload` against the backend.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -33,7 +34,7 @@
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
|
||||
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
|
||||
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders | dry-run covers wiki-target shape; live workflows assert returned file tokens and consume the uploaded fixtures |
|
||||
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_upload_dryrun_test.go::TestDriveUploadDryRun_WithFileToken + drive_upload_workflow_test.go::TestDrive_UploadWorkflow + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `--file-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders; in-place overwrite uploads | dry-run covers wiki-target and overwrite request shapes; live workflows assert returned file tokens, token-stable overwrite behavior, and that uploaded fixtures are consumable by downstream commands |
|
||||
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys list | api | | none | no reply workflow yet |
|
||||
|
||||
@@ -70,6 +70,7 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
|
||||
if statusResult.ExitCode == 0 {
|
||||
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
|
||||
}
|
||||
@@ -191,6 +192,7 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
|
||||
|
||||
@@ -70,6 +70,50 @@ func TestDrive_StatusDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_StatusDryRunQuick(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--quick",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "modified_time") || strings.Contains(desc, "SHA-256") {
|
||||
t.Fatalf("quick description must mention modified_time and skip SHA-256 wording, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
|
||||
// --local-dir path validator runs in the real binary's Validate stage and
|
||||
// surfaces a structured error referencing --local-dir (not the framework
|
||||
|
||||
@@ -5,8 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -123,6 +126,7 @@ func TestDrive_StatusWorkflow(t *testing.T) {
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
@@ -183,4 +187,278 @@ func TestDrive_StatusWorkflow(t *testing.T) {
|
||||
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
|
||||
}
|
||||
}
|
||||
|
||||
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--quick",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
quickResult.AssertExitCode(t, 0)
|
||||
quickResult.AssertStdoutStatus(t, true)
|
||||
|
||||
quickOut := quickResult.Stdout
|
||||
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
|
||||
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 {
|
||||
t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 {
|
||||
t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" {
|
||||
t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" {
|
||||
t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int())
|
||||
if sharedCount != 2 {
|
||||
t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut)
|
||||
}
|
||||
for _, path := range []string{"unchanged.txt", "modified.txt"} {
|
||||
if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() {
|
||||
t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time
|
||||
// semantics on the live backend instead of silently behaving like the default
|
||||
// exact hash mode.
|
||||
//
|
||||
// The fixture intentionally makes the two shared files diverge in opposite ways:
|
||||
// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified
|
||||
// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged
|
||||
//
|
||||
// This locks in the best-effort nature of quick mode with real Drive
|
||||
// modified_time values fetched from the list API, plus the expected new_local /
|
||||
// new_remote buckets.
|
||||
func TestDrive_StatusQuickWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-status-quick-" + suffix
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
writeLocal := func(rel, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(workDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent of %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
uploadDriveFile := func(name, content string) string {
|
||||
t.Helper()
|
||||
stage := "_upload_" + name
|
||||
writeLocal(stage, content)
|
||||
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stage,
|
||||
"--folder-token", folderToken,
|
||||
"--name", name,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
|
||||
})
|
||||
return fileToken
|
||||
}
|
||||
|
||||
tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A")
|
||||
tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B")
|
||||
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only")
|
||||
|
||||
remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken)
|
||||
sameMtimeRemote := remoteFiles["same-mtime.txt"]
|
||||
remoteNewer := remoteFiles["remote-newer.txt"]
|
||||
if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" {
|
||||
t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles)
|
||||
}
|
||||
|
||||
writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote
|
||||
writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote
|
||||
writeLocal("local/local-only.txt", "local only") // local-only bucket
|
||||
|
||||
sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt")
|
||||
remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt")
|
||||
sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime)
|
||||
remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime)
|
||||
if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil {
|
||||
t.Fatalf("chtimes same-mtime.txt: %v", err)
|
||||
}
|
||||
localOlder := remoteNewerAt.Add(-2 * time.Second)
|
||||
if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil {
|
||||
t.Fatalf("chtimes remote-newer.txt: %v", err)
|
||||
}
|
||||
|
||||
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--quick",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
quickResult.AssertExitCode(t, 0)
|
||||
quickResult.AssertStdoutStatus(t, true)
|
||||
|
||||
quickOut := quickResult.Stdout
|
||||
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
|
||||
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime)
|
||||
assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer)
|
||||
assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "")
|
||||
assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly)
|
||||
assertStatusBucketLen(t, quickOut, "unchanged", 1)
|
||||
assertStatusBucketLen(t, quickOut, "modified", 1)
|
||||
assertStatusBucketLen(t, quickOut, "new_local", 1)
|
||||
assertStatusBucketLen(t, quickOut, "new_remote", 1)
|
||||
|
||||
exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, exactResult)
|
||||
exactResult.AssertExitCode(t, 0)
|
||||
exactResult.AssertStdoutStatus(t, true)
|
||||
|
||||
exactOut := exactResult.Stdout
|
||||
if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" {
|
||||
t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut)
|
||||
}
|
||||
assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime)
|
||||
assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer)
|
||||
assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "")
|
||||
assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly)
|
||||
assertStatusBucketLen(t, exactOut, "unchanged", 1)
|
||||
assertStatusBucketLen(t, exactOut, "modified", 1)
|
||||
assertStatusBucketLen(t, exactOut, "new_local", 1)
|
||||
assertStatusBucketLen(t, exactOut, "new_remote", 1)
|
||||
}
|
||||
|
||||
type driveStatusListedFile struct {
|
||||
Token string
|
||||
ModifiedTime string
|
||||
}
|
||||
|
||||
func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile {
|
||||
t.Helper()
|
||||
params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "files", "list", "--params", params},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
files := make(map[string]driveStatusListedFile)
|
||||
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
|
||||
name := entry.Get("name").String()
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
files[name] = driveStatusListedFile{
|
||||
Token: entry.Get("token").String(),
|
||||
ModifiedTime: entry.Get("modified_time").String(),
|
||||
}
|
||||
return true
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time {
|
||||
t.Helper()
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Drive epoch %q: %v", raw, err)
|
||||
}
|
||||
switch {
|
||||
case v > 1e14 || v < -1e14:
|
||||
return time.UnixMicro(v)
|
||||
case v > 1e11 || v < -1e11:
|
||||
return time.UnixMilli(v)
|
||||
default:
|
||||
return time.Unix(v, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) {
|
||||
t.Helper()
|
||||
entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`)
|
||||
if !entry.Exists() {
|
||||
t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout)
|
||||
}
|
||||
if fileToken == "" {
|
||||
if got := entry.Get("file_token").String(); got != "" {
|
||||
t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := entry.Get("file_token").String(); got != fileToken {
|
||||
t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) {
|
||||
t.Helper()
|
||||
if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want {
|
||||
t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func skipDriveStatusExactIfMissingDownloadScope(t *testing.T, result *clie2e.Result) {
|
||||
t.Helper()
|
||||
if result == nil || result.ExitCode == 0 {
|
||||
return
|
||||
}
|
||||
combinedLower := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combinedLower, "missing_scope") && strings.Contains(combinedLower, "drive:file:download") {
|
||||
t.Skipf("skip drive +status exact live workflow due to missing drive:file:download scope: stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,31 @@ func TestDriveUploadDryRun_WikiTarget(t *testing.T) {
|
||||
assert.Contains(t, output, `"parent_type": "wiki"`)
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRun_WithFileToken(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", "./report.pdf",
|
||||
"--folder-token", "fldDryRunUploadTarget",
|
||||
"--file-token", "boxcnDryRunOverwriteTarget",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
|
||||
assert.Contains(t, output, `"parent_node": "fldDryRunUploadTarget"`)
|
||||
assert.Contains(t, output, `"file_token": "boxcnDryRunOverwriteTarget"`)
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRunRejectsEmptyWikiToken(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
|
||||
119
tests/cli_e2e/drive/drive_upload_workflow_test.go
Normal file
119
tests/cli_e2e/drive/drive_upload_workflow_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_UploadWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-upload-"+suffix, "")
|
||||
workDir := t.TempDir()
|
||||
|
||||
cleanupTokens := map[string]struct{}{}
|
||||
scheduleDelete := func(fileToken string) {
|
||||
t.Helper()
|
||||
if fileToken == "" {
|
||||
return
|
||||
}
|
||||
if _, seen := cleanupTokens[fileToken]; seen {
|
||||
return
|
||||
}
|
||||
cleanupTokens[fileToken] = struct{}{}
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
|
||||
})
|
||||
}
|
||||
|
||||
uploadFile := func(stageName, remoteName, content, fileToken string) string {
|
||||
t.Helper()
|
||||
stagePath := filepath.Join(workDir, stageName)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write stage file %s: %v", stageName, err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"drive", "+upload",
|
||||
"--file", stageName,
|
||||
"--folder-token", folderToken,
|
||||
"--name", remoteName,
|
||||
}
|
||||
if fileToken != "" {
|
||||
args = append(args, "--file-token", fileToken)
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
gotToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, gotToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
if got := gjson.Get(result.Stdout, "data.file_name").String(); got != remoteName {
|
||||
t.Fatalf("data.file_name=%q want %q\nstdout:\n%s", got, remoteName, result.Stdout)
|
||||
}
|
||||
if got := gjson.Get(result.Stdout, "data.size").Int(); got != int64(len(content)) {
|
||||
t.Fatalf("data.size=%d want %d\nstdout:\n%s", got, len(content), result.Stdout)
|
||||
}
|
||||
return gotToken
|
||||
}
|
||||
|
||||
initialContent := "drive upload e2e: initial content\n"
|
||||
initialToken := uploadFile("_upload_initial.txt", "overwrite.txt", initialContent, "")
|
||||
scheduleDelete(initialToken)
|
||||
|
||||
updatedContent := "drive upload e2e: overwritten via file-token\n"
|
||||
overwriteToken := uploadFile("_upload_overwrite.txt", "overwrite.txt", updatedContent, initialToken)
|
||||
scheduleDelete(overwriteToken)
|
||||
|
||||
if overwriteToken != initialToken {
|
||||
t.Fatalf("overwrite token=%q want original token=%q", overwriteToken, initialToken)
|
||||
}
|
||||
|
||||
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+download",
|
||||
"--file-token", overwriteToken,
|
||||
"--output", "downloaded.txt",
|
||||
"--overwrite",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadResult.AssertExitCode(t, 0)
|
||||
downloadResult.AssertStdoutStatus(t, true)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workDir, "downloaded.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read downloaded file: %v", err)
|
||||
}
|
||||
if string(data) != updatedContent {
|
||||
t.Fatalf("downloaded content=%q want %q", string(data), updatedContent)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
# Wiki CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 6 leaf commands
|
||||
- Covered: 6
|
||||
- Denominator: 6 leaf commands + 3 shortcut commands
|
||||
- Covered: 9
|
||||
- Coverage: 100.0%
|
||||
|
||||
## Summary
|
||||
- TestWiki_NodeWorkflow: proves the full currently-tested wiki domain surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`.
|
||||
- The workflow covers both node creation/copy/listing and space lookup/listing with persisted token assertions.
|
||||
- TestWiki_NodeWorkflow: proves the full currently-tested bare-API surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`.
|
||||
- TestWiki_ShortcutWorkflow: covers the shortcut layer for `wiki +space-list`, `wiki +node-list`, and `wiki +node-copy` — flag→body mapping, envelope shape (`{spaces|nodes, has_more, page_token}` + `meta.count`), `--page-all` / `--page-limit` truncation, my_library alias resolution (user positive + bot validation rejection), and copy-source-survival.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -19,3 +19,6 @@
|
||||
| ✓ | wiki spaces get | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get space as bot | `space_id` in `--params` | |
|
||||
| ✓ | wiki spaces get_node | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get created node as bot | `token`; `obj_type` in `--params` | |
|
||||
| ✓ | wiki spaces list | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/list spaces as bot | `page_size` in `--params` | |
|
||||
| ✓ | wiki +space-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+space-list: stable envelope shape | `--page-size`; `--format json`; bot identity | |
|
||||
| ✓ | wiki +node-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-list: finds child under parent; +node-list: --page-limit caps the loop and exposes cursor; +node-list --space-id my_library --as bot: validation rejection; +node-list --space-id my_library --as user: resolves and lists | `--space-id`; `--parent-node-token`; `--page-all`; `--page-size`; `--page-limit`; my_library alias | |
|
||||
| ✓ | wiki +node-copy | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-copy: copies child + verifies source survives + cleanup | `--space-id`; `--node-token`; `--target-space-id`; `--title` | |
|
||||
|
||||
269
tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go
Normal file
269
tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestWiki_ShortcutWorkflow exercises the shortcut layer (wiki +space-list,
|
||||
// +node-list, +node-copy) end-to-end against a real Lark tenant. The existing
|
||||
// TestWiki_NodeWorkflow only hits the bare `api` command, so it does not
|
||||
// protect against regressions in shortcut-specific behavior — flag → body
|
||||
// mapping, envelope shape ({spaces|nodes, has_more, page_token} + meta.count),
|
||||
// auto-pagination, my_library alias resolution, or required-flag validation.
|
||||
func TestWiki_ShortcutWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
parentTitle := "lark-cli-e2e-wiki-sc-parent-" + suffix
|
||||
childTitle := "lark-cli-e2e-wiki-sc-child-" + suffix
|
||||
copyTitle := "lark-cli-e2e-wiki-sc-copy-" + suffix
|
||||
|
||||
var spaceID, parentNodeToken, childNodeToken, childObjType string
|
||||
|
||||
// Setup: reuse an existing first-layer node in my_library as the host so
|
||||
// we never bump the top-layer node count (the bot's my_library top layer
|
||||
// has hit the API's "single-layer nodes ... upper limit" — code 131003 —
|
||||
// in earlier CI runs because of leftover nodes). Then create a FRESH
|
||||
// intermediate parent under that host, and put the test child under the
|
||||
// fresh parent. We can't put the child directly under the host because
|
||||
// leftover nodes from prior runs accumulate as the host's children, so
|
||||
// `+node-list --parent-node-token=<host>` returns hundreds of unrelated
|
||||
// nodes and the just-created child gets paged out (regardless of
|
||||
// --page-limit) before the test can find it. An isolated intermediate
|
||||
// parent always has exactly the children this test creates, so the
|
||||
// pagination scan never has to dig through historical cruft.
|
||||
t.Run("setup: locate my_library host node + create isolated parent + create test child", func(t *testing.T) {
|
||||
listResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/my_library/nodes"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"page_size": 50},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, 0)
|
||||
|
||||
items := gjson.Get(listResult.Stdout, "data.items").Array()
|
||||
if len(items) == 0 {
|
||||
t.Skip("skipped: my_library has no existing top-level nodes to host the test structure")
|
||||
}
|
||||
host := items[0]
|
||||
spaceID = host.Get("space_id").String()
|
||||
hostNodeToken := host.Get("node_token").String()
|
||||
require.NotEmpty(t, spaceID, "host space_id must be present in listing")
|
||||
require.NotEmpty(t, hostNodeToken, "host node_token must be present in listing")
|
||||
|
||||
// Create a fresh intermediate parent under the host. The helper
|
||||
// auto-registers a t.Cleanup callback that deletes this parent
|
||||
// (and, by API cascade, anything still under it) after the test.
|
||||
isolatedParent := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": parentTitle,
|
||||
"parent_node_token": hostNodeToken,
|
||||
})
|
||||
parentNodeToken = isolatedParent.Get("node_token").String()
|
||||
require.NotEmpty(t, parentNodeToken, "isolated parent node_token must be present after create")
|
||||
|
||||
// Create the test child UNDER the freshly-isolated parent.
|
||||
child := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": childTitle,
|
||||
"parent_node_token": parentNodeToken,
|
||||
})
|
||||
childNodeToken = child.Get("node_token").String()
|
||||
childObjType = child.Get("obj_type").String()
|
||||
require.NotEmpty(t, childNodeToken)
|
||||
})
|
||||
|
||||
// QA-P1: +space-list envelope shape is stable for JSON consumers.
|
||||
// `spaces` must always be an array (never null), and pagination metadata
|
||||
// fields must always exist so downstream agents can introspect.
|
||||
t.Run("+space-list: stable envelope shape", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "+space-list", "--page-size", "1"},
|
||||
DefaultAs: "bot",
|
||||
Format: "json",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, out.Get("data.spaces").Exists(), "data.spaces must exist")
|
||||
assert.True(t, out.Get("data.spaces").IsArray(), "data.spaces must be an array, even when empty")
|
||||
assert.True(t, out.Get("data.has_more").Exists(), "data.has_more must always be present")
|
||||
assert.True(t, out.Get("data.page_token").Exists(), "data.page_token must always be present")
|
||||
// meta.count uses `json:",omitempty"` in the envelope framework, so the
|
||||
// field is dropped when the count is zero. Comparing values (gjson
|
||||
// returns 0 for missing keys) keeps the assertion correct in both the
|
||||
// "no spaces visible" and "some spaces" cases without requiring a
|
||||
// framework-level change.
|
||||
spacesLen := len(out.Get("data.spaces").Array())
|
||||
assert.Equal(t, float64(spacesLen), out.Get("meta.count").Float(),
|
||||
"meta.count must equal len(data.spaces) (or be omitted when zero); stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
// QA-P1: +node-list correctly maps flags onto the underlying request body
|
||||
// and surfaces the child we just created under the parent.
|
||||
t.Run("+node-list: finds child under parent", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID)
|
||||
require.NotEmpty(t, parentNodeToken)
|
||||
require.NotEmpty(t, childNodeToken)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-list",
|
||||
"--space-id", spaceID,
|
||||
"--parent-node-token", parentNodeToken,
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
match := out.Get(`data.nodes.#(node_token=="` + childNodeToken + `")`)
|
||||
require.True(t, match.Exists(), "+node-list did not return the child we created:\n%s", result.Stdout)
|
||||
assert.Equal(t, childTitle, match.Get("title").String())
|
||||
assert.Equal(t, parentNodeToken, match.Get("parent_node_token").String())
|
||||
})
|
||||
|
||||
// QA-P2: --page-size 1 --page-all --page-limit 1 must aggregate exactly
|
||||
// one page and surface the next cursor when has_more=true. This catches
|
||||
// regressions where the pagination loop overruns the cap or fails to
|
||||
// surface has_more / page_token.
|
||||
t.Run("+node-list: --page-limit caps the loop and exposes cursor", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-list",
|
||||
"--space-id", spaceID,
|
||||
"--page-size", "1",
|
||||
"--page-all",
|
||||
"--page-limit", "1",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
nodes := out.Get("data.nodes").Array()
|
||||
assert.LessOrEqual(t, len(nodes), 1, "--page-limit=1 + --page-size=1 should yield ≤1 node, got %d", len(nodes))
|
||||
// has_more / page_token must still exist — never elided — so
|
||||
// callers can resume regardless of whether the cap actually fired.
|
||||
assert.True(t, out.Get("data.has_more").Exists())
|
||||
assert.True(t, out.Get("data.page_token").Exists())
|
||||
})
|
||||
|
||||
// QA-P1: +node-copy creates a copy under the same space and the source
|
||||
// stays put (copy ≠ move). Cleanup deletes the copy. The copy is placed
|
||||
// under the same host parent we use for the test child, so it doesn't
|
||||
// add another top-layer node and trip the per-space limit.
|
||||
t.Run("+node-copy: copies child + verifies source survives + cleanup", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID)
|
||||
require.NotEmpty(t, parentNodeToken)
|
||||
require.NotEmpty(t, childNodeToken)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-copy",
|
||||
"--space-id", spaceID,
|
||||
"--node-token", childNodeToken,
|
||||
"--target-parent-node-token", parentNodeToken,
|
||||
"--title", copyTitle,
|
||||
},
|
||||
// +node-copy is now declared high-risk-write to align with the
|
||||
// upstream API's `danger: true` flag, so the framework requires
|
||||
// explicit confirmation before issuing the request.
|
||||
Yes: true,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
copiedNodeToken := out.Get("data.node_token").String()
|
||||
copiedSpaceID := out.Get("data.space_id").String()
|
||||
copiedObjType := out.Get("data.obj_type").String()
|
||||
require.NotEmpty(t, copiedNodeToken, "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, copiedSpaceID)
|
||||
assert.Equal(t, copyTitle, out.Get("data.title").String())
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
deleteResult, deleteErr := deleteWikiNode(cleanupCtx, copiedSpaceID, copiedNodeToken, copiedObjType)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
// Copy must be retrievable; source must still exist (copy ≠ move).
|
||||
copied := getWikiNode(t, ctx, copiedNodeToken)
|
||||
assert.Equal(t, copyTitle, copied.Get("title").String())
|
||||
original := getWikiNode(t, ctx, childNodeToken)
|
||||
assert.Equal(t, childTitle, original.Get("title").String(),
|
||||
"source node must remain after +node-copy (copy is non-destructive)")
|
||||
_ = childObjType // reserved for future +node-list filter checks
|
||||
})
|
||||
|
||||
// QA-P2: bot identity must be rejected upfront when --space-id=my_library
|
||||
// because the personal-library alias is per-user and meaningless for a
|
||||
// tenant_access_token. The shortcut layer should fail before sending any
|
||||
// HTTP request, with a validation error mentioning my_library.
|
||||
t.Run("+node-list --space-id my_library --as bot: validation rejection", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "+node-list", "--space-id", "my_library"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.ExitCode, "bot + my_library must fail")
|
||||
|
||||
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
assert.Contains(t, combined, "my_library",
|
||||
"error must mention my_library to disambiguate from generic auth failures; got stdout=%s stderr=%s",
|
||||
result.Stdout, result.Stderr)
|
||||
})
|
||||
|
||||
// QA-P2: user identity must positively resolve --space-id=my_library to a
|
||||
// real per-user space_id and proceed to list nodes. Skipped when no user
|
||||
// token is available (matches the rest of the suite's user-flow gating).
|
||||
t.Run("+node-list --space-id my_library --as user: resolves and lists", func(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "+node-list", "--space-id", "my_library", "--page-size", "1"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, out.Get("data.nodes").Exists(), "data.nodes must exist after my_library resolution")
|
||||
assert.True(t, out.Get("data.nodes").IsArray(), "data.nodes must be an array")
|
||||
// stderr must record the my_library resolution so users/agents can
|
||||
// see what space_id the alias mapped to.
|
||||
assert.Contains(t, result.Stderr, "Resolved my_library",
|
||||
"expected my_library resolution log on stderr; got: %s", result.Stderr)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user