mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
31 Commits
feat/suppo
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c396b404 | ||
|
|
a01279011e | ||
|
|
c205954000 | ||
|
|
48b7ec70b2 | ||
|
|
adf1ac4326 | ||
|
|
dd84702b40 | ||
|
|
e4db9630f3 | ||
|
|
ac4637f8ce | ||
|
|
266ec5455b | ||
|
|
3074cdb2b8 | ||
|
|
8f916dd561 | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e | ||
|
|
af8507ea8e | ||
|
|
02c2ebcf7c | ||
|
|
abf6f99d7e | ||
|
|
8ba910eb9f | ||
|
|
78bf126bb0 | ||
|
|
4eefe32c1a | ||
|
|
8f6f8eb0fc | ||
|
|
80323bb464 | ||
|
|
0a33bd7c57 | ||
|
|
aafaed06a7 |
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -9,7 +9,40 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
git fetch origin main
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-release:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,35 +59,79 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Include release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Collect release asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir npm-publish-asset
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
needs: build-release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download release asset
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset
|
||||
|
||||
- name: Verify npm publish asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
(cd npm-publish-asset && sha256sum --check checksums.txt)
|
||||
cp npm-publish-asset/checksums.txt checksums.txt
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
rm "$PACK_FILE"
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -2,6 +2,36 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- add okr single create shortcut & skill text opti (#1941)
|
||||
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve table shortcut behavior & guidance (#1803)
|
||||
- issue#1935 & whiteboard shortcut reformat (#1980)
|
||||
- remove legacy shortcut (#1997)
|
||||
- **e2e**: inject shared credentials by identity (#1995)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: describe html5 block xml usage (#1380)
|
||||
- clarify fetch metadata and user cites (#1981)
|
||||
- add topic move collector workflow (#1473)
|
||||
- update lark doc HTML size limit (#2001)
|
||||
- **base**: align record write schema guidance (#2000)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: declare request identities explicitly (#2004)
|
||||
|
||||
### Misc
|
||||
|
||||
- harden npm release publishing (#1918)
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
@@ -1608,6 +1638,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
36
internal/cmdutil/localfile.go
Normal file
36
internal/cmdutil/localfile.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// StatLocalFile returns metadata for a path in the process filesystem namespace.
|
||||
// It is intended for advisory validation; callers must validate the opened file
|
||||
// again before using its contents.
|
||||
func StatLocalFile(path string) (fs.FileInfo, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Stat(localPath)
|
||||
}
|
||||
|
||||
// OpenLocalFile opens a path in the process filesystem namespace.
|
||||
// Absolute and relative paths are accepted. It is the shared replacement for
|
||||
// direct os.Open/os.ReadFile use in commands that intentionally read local
|
||||
// paths outside the workspace sandbox. Callers inspect the returned descriptor
|
||||
// before reading so validation and use apply to the same opened file.
|
||||
func OpenLocalFile(path string) (fs.File, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Open(localPath)
|
||||
}
|
||||
96
internal/cmdutil/localfile_test.go
Normal file
96
internal/cmdutil/localfile_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
TestChdir(t, workDir)
|
||||
|
||||
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
|
||||
f, err := OpenLocalFile(input)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
|
||||
}
|
||||
got, readErr := io.ReadAll(f)
|
||||
closeErr := f.Close()
|
||||
if readErr != nil || closeErr != nil || string(got) != "content" {
|
||||
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
|
||||
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
|
||||
info, err := StatLocalFile(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("StatLocalFile() error = %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previous := vfs.DefaultFS
|
||||
counting := &countingLocalFileFS{FS: previous}
|
||||
vfs.DefaultFS = counting
|
||||
t.Cleanup(func() { vfs.DefaultFS = previous })
|
||||
|
||||
f, err := OpenLocalFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile() error = %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counting.openCalls != 1 || counting.statCalls != 0 {
|
||||
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type countingLocalFileFS struct {
|
||||
vfs.FS
|
||||
openCalls int
|
||||
statCalls int
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
|
||||
f.openCalls++
|
||||
return f.FS.Open(name)
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
|
||||
f.statCalls++
|
||||
return f.FS.Stat(name)
|
||||
}
|
||||
@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// LocalInputPath validates a local input path without restricting it to the
|
||||
// current working directory. It delegates to localfileio.LocalInputPath so
|
||||
// command validation and shared local-file readers use one policy.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
return localfileio.LocalInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
|
||||
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
|
||||
got, err := LocalInputPath(path)
|
||||
if err != nil || got != path {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalInputPath("report\n.pdf"); err == nil {
|
||||
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// LocalInputPath validates an input path in the process local filesystem
|
||||
// namespace. It intentionally does not impose cwd containment or canonicalize
|
||||
// the path: absolute paths, parent-relative paths, and symlink traversal retain
|
||||
// their normal OS semantics. Character validation remains mandatory because
|
||||
// paths are user-controlled and may appear in errors or progress output.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("local input path must not be empty")
|
||||
}
|
||||
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
|
||||
return "", fmt.Errorf("local input path must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalInputPlatform(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isWindowsNonLocalNamespace(path string) bool {
|
||||
normalized := strings.ReplaceAll(path, "/", `\`)
|
||||
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
return "", fmt.Errorf("%s: %w", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
8
internal/vfs/localfileio/path_local_other.go
Normal file
8
internal/vfs/localfileio/path_local_other.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package localfileio
|
||||
|
||||
func validateLocalInputPlatform(string) error { return nil }
|
||||
33
internal/vfs/localfileio/path_local_windows.go
Normal file
33
internal/vfs/localfileio/path_local_windows.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateLocalInputPlatform(path string) error {
|
||||
if isWindowsNonLocalNamespace(path) {
|
||||
return fmt.Errorf("local input path must not use a Windows network or device namespace")
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(path)
|
||||
volume := filepath.VolumeName(cleaned)
|
||||
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
|
||||
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
|
||||
return r == '\\' || r == '/'
|
||||
}) {
|
||||
if component == "." || component == ".." {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsLocal(component) {
|
||||
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
`C:\Users\agent\NUL.txt`,
|
||||
`CON`,
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/tmp/report.pdf",
|
||||
"../outside/report.pdf",
|
||||
"./report.pdf",
|
||||
"nested/../report.pdf",
|
||||
`C:\Users\agent\report.pdf`,
|
||||
"报告.pdf",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
got, err := LocalInputPath(input)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
|
||||
}
|
||||
if got != input {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsNonLocalNamespace(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
} {
|
||||
if !isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
|
||||
}
|
||||
}
|
||||
|
||||
for _, input := range []string{
|
||||
`C:\Users\agent\report.pdf`,
|
||||
`C:/Users/agent/report.pdf`,
|
||||
`..\outside\report.pdf`,
|
||||
`.\report.pdf`,
|
||||
} {
|
||||
if isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
" ",
|
||||
"file\x00.txt",
|
||||
"file\tname.txt",
|
||||
"file\nname.txt",
|
||||
"file\rname.txt",
|
||||
"file\u202Ename.txt",
|
||||
"file\u200Bname.txt",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a clean temp directory as CWD
|
||||
dir := t.TempDir()
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.76",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.76",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
"arm64",
|
||||
"riscv64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.74",
|
||||
"version": "1.0.76",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
"postinstall": "node scripts/install.js",
|
||||
"release:check": "node scripts/release-preflight.js"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
|
||||
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error("[SECURITY] Expected checksum is missing or invalid");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
|
||||
throw new Error(
|
||||
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
{ message: /^\[SECURITY\] checksums\.txt not found/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
it("accepts a valid uppercase 64-character hex hash", () => {
|
||||
const content = "case test";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content).toUpperCase();
|
||||
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
for (const [name, expectedHash] of [
|
||||
["null", null],
|
||||
["empty", ""],
|
||||
["non-string", 123],
|
||||
]) {
|
||||
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /Expected checksum is missing or invalid/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "abc123"),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY] format Error for a non-hex hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "g".repeat(64)),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
|
||||
108
scripts/release-preflight.js
Normal file
108
scripts/release-preflight.js
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
||||
|
||||
function isStableVersion(value) {
|
||||
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function releaseError(message, observed, hint) {
|
||||
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
|
||||
}
|
||||
|
||||
function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
const packageVersion = packageJson?.version;
|
||||
const lockVersion = packageLockJson?.version;
|
||||
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
|
||||
const observed = {
|
||||
packageVersion: packageVersion ?? null,
|
||||
lockVersion: lockVersion ?? null,
|
||||
lockRootVersion: lockRootVersion ?? null,
|
||||
tagVersion: null,
|
||||
};
|
||||
|
||||
for (const [field, value] of [
|
||||
["package.json.version", packageVersion],
|
||||
["package-lock.json.version", lockVersion],
|
||||
['package-lock.json.packages[""].version', lockRootVersion],
|
||||
]) {
|
||||
if (!isStableVersion(value)) {
|
||||
return releaseError(
|
||||
`${field} must be a stable release version in X.Y.Z form`,
|
||||
observed,
|
||||
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
|
||||
return releaseError(
|
||||
"Package version fields do not match",
|
||||
observed,
|
||||
"Synchronize package.json.version and both package-lock.json version fields.",
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === undefined) {
|
||||
return { ok: true, data: observed };
|
||||
}
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
|
||||
return releaseError(
|
||||
"--tag must use the stable release form vX.Y.Z",
|
||||
{ ...observed, tag },
|
||||
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tagVersion = tag.slice(1);
|
||||
if (tagVersion !== packageVersion) {
|
||||
return releaseError(
|
||||
"Tag version does not match the package version",
|
||||
{ ...observed, tagVersion, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, data: { ...observed, tagVersion } };
|
||||
}
|
||||
|
||||
function writeResult(result) {
|
||||
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let tag;
|
||||
if (args.length === 2 && args[0] === "--tag") {
|
||||
tag = args[1];
|
||||
} else if (args.length !== 0) {
|
||||
writeResult(releaseError(
|
||||
"Expected no arguments or --tag vX.Y.Z",
|
||||
{ arguments: args },
|
||||
"Run release:check without arguments or pass exactly one --tag value.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
|
||||
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
} catch (error) {
|
||||
writeResult(releaseError(
|
||||
"Could not read release package metadata",
|
||||
{ reason: error.message },
|
||||
"Ensure package.json and package-lock.json exist and contain valid JSON.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateReleasePreflight };
|
||||
|
||||
if (require.main === module) main();
|
||||
66
scripts/release-preflight.test.js
Normal file
66
scripts/release-preflight.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const { validateReleasePreflight } = require("./release-preflight");
|
||||
|
||||
function metadata(version = "1.2.3") {
|
||||
return {
|
||||
packageJson: { version },
|
||||
packageLockJson: {
|
||||
version,
|
||||
packages: { "": { version } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertRejected(result) {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.type, "release_preflight");
|
||||
assert.equal(typeof result.error.message, "string");
|
||||
}
|
||||
|
||||
describe("validateReleasePreflight", () => {
|
||||
it("accepts matching stable package, lock, and tag versions", () => {
|
||||
const { packageJson, packageLockJson } = metadata();
|
||||
|
||||
assert.deepEqual(
|
||||
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.3",
|
||||
lockRootVersion: "1.2.3",
|
||||
tagVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-stable or inconsistent package metadata", () => {
|
||||
const prerelease = metadata("1.2.3-beta.1");
|
||||
const topLevelMismatch = metadata();
|
||||
topLevelMismatch.packageLockJson.version = "1.2.4";
|
||||
const rootMismatch = metadata();
|
||||
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
|
||||
|
||||
for (const { packageJson, packageLockJson } of [
|
||||
prerelease,
|
||||
topLevelMismatch,
|
||||
rootMismatch,
|
||||
]) {
|
||||
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid or mismatched release tag", () => {
|
||||
const { packageJson, packageLockJson } = metadata();
|
||||
|
||||
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
|
||||
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,49 +3,48 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Read version from package.json
|
||||
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not read version from package.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
TAG="v${VERSION}"
|
||||
|
||||
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
|
||||
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Tag: ${TAG}"
|
||||
|
||||
# Check if tag already exists locally
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} already exists locally, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if tag already exists on remote
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag ${TAG} already exists on remote, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure package.json changes are committed before tagging
|
||||
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
|
||||
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "${CURRENT_BRANCH}" != "main" ]; then
|
||||
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure current branch is pushed to remote before tagging
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
LOCAL_SHA=$(git rev-parse HEAD)
|
||||
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
||||
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
|
||||
if ! git diff --quiet HEAD -- package.json package-lock.json; then
|
||||
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create and push tag
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
git fetch origin main
|
||||
|
||||
echo "Successfully created and pushed tag ${TAG}"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
|
||||
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
|
||||
echo "Error: HEAD must exactly match origin/main before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Error: local tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
|
||||
if [ -n "${REMOTE_TAG}" ]; then
|
||||
echo "Error: remote tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${HEAD_SHA}"
|
||||
git push origin "refs/tags/${TAG}"
|
||||
|
||||
echo "Successfully pushed tag ${TAG}"
|
||||
|
||||
@@ -12,10 +12,23 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
|
||||
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
|
||||
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
|
||||
const maxFileListPageSize = 200
|
||||
|
||||
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
|
||||
func validateFileListPageSize(n int) error {
|
||||
if n < 1 || n > maxFileListPageSize {
|
||||
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size(1..200)/--page-token。
|
||||
// file 域不分 dev/online,无 --env。
|
||||
//
|
||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||||
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
|
||||
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
|
||||
@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
|
||||
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
|
||||
for _, ps := range []string{"0", "201", "500"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
|
||||
}
|
||||
if ve.Param != "--page-size" {
|
||||
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验(dry-run 不报错并把 page_size 下发)。
|
||||
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
|
||||
for _, ps := range []string{"1", "200"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
st, err := rctx.FileIO().Stat(f)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
|
||||
}
|
||||
if st.Size() > fileUploadMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
|
||||
}
|
||||
return nil
|
||||
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
||||
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(localPath)
|
||||
contentType := mimeByExt(fileName)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
|
||||
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
|
||||
// file and previews the pre-upload request without reading or uploading it.
|
||||
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
absolutePath := filepath.Join(t.TempDir(), "logo.png")
|
||||
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
|
||||
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
|
||||
// absolute path outside the current working directory.
|
||||
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-abs"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Keep the process cwd unchanged so the temporary file is outside it.
|
||||
dir := t.TempDir()
|
||||
absFile := filepath.Join(dir, "report.pdf")
|
||||
if !filepath.IsAbs(absFile) {
|
||||
t.Fatalf("test setup: %q is not absolute", absFile)
|
||||
}
|
||||
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with absolute path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PDFBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-parent"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(workDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with parent-relative path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PARENT" {
|
||||
t.Fatalf("PUT body = %q, want PARENT", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "too-large.bin")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err = runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "limit") {
|
||||
t.Fatalf("error = %v, want size limit context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("/dev/zero is unavailable on Windows")
|
||||
}
|
||||
if _, err := os.Stat("/dev/zero"); err != nil {
|
||||
t.Skipf("/dev/zero unavailable: %v", err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "regular file") {
|
||||
t.Fatalf("error = %v, want non-regular-file context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
|
||||
@@ -2435,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-submit",
|
||||
Description: "Submit a form (fill and submit form data)",
|
||||
Risk: "write",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
|
||||
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
||||
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
||||
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFormSubmit(runtime)
|
||||
|
||||
@@ -801,7 +801,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record batch create json",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantHelp: []string{
|
||||
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
|
||||
"create_records contains one field map per record",
|
||||
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -850,8 +851,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
||||
"do not look for parent_record_id or a separate child-record API",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
"select (multiple=false) -> \"Todo\"",
|
||||
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
|
||||
"datetime -> \"2026-03-24 10:00:00\"",
|
||||
"checkbox -> true/false",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
@@ -865,11 +866,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantTips: []string{
|
||||
"Happy path fields: fields is the column order",
|
||||
"rows is an array of row arrays",
|
||||
"may use null for empty cells",
|
||||
"Happy path field: create_records",
|
||||
"create_records is an array of independent record field maps",
|
||||
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch create supports max 200 rows per call",
|
||||
"Batch create supports max 200 records per call",
|
||||
"do not immediately +record-list the same table",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
@@ -2055,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
|
||||
if s.Service != "base" {
|
||||
t.Fatalf("Service=%q want base", s.Service)
|
||||
}
|
||||
if s.Risk != "write" {
|
||||
t.Fatalf("Risk=%q want write", s.Risk)
|
||||
if s.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
|
||||
}
|
||||
if !s.HasFormat {
|
||||
t.Fatal("HasFormat should be true")
|
||||
@@ -2356,6 +2357,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"+form-submit",
|
||||
"--share-token", "shr_exec1",
|
||||
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2424,6 +2426,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_exec6",
|
||||
"--base-token", "bas_exec6",
|
||||
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
@@ -2472,6 +2475,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_dedup",
|
||||
"--base-token", "bas_dedup",
|
||||
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2483,6 +2487,33 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
|
||||
// without --yes the runner's confirmation gate must fire before Execute runs,
|
||||
// returning a typed confirmation_required error and touching no API.
|
||||
func TestFormSubmitRequiresConfirmation(t *testing.T) {
|
||||
if BaseFormSubmit.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{
|
||||
"+form-submit",
|
||||
"--share-token", "shr_confirm",
|
||||
"--json", `{"fields":{"Rating":5}}`,
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required error without --yes")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
t.Run("single file upload via execute path", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
@@ -2519,6 +2550,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_para1",
|
||||
"--base-token", "bas_para1",
|
||||
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2553,6 +2585,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_err",
|
||||
"--base-token", "bas_err",
|
||||
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
||||
"Use only for fields with options, such as select or multi-select fields.",
|
||||
"Use only for select fields, whether multiple is false or true.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
|
||||
@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
|
||||
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
|
||||
"Happy path field: create_records is an array of independent record field maps.",
|
||||
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch create supports max 200 rows per call.",
|
||||
"Batch create supports max 200 records per call.",
|
||||
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
|
||||
"Use the record-batch-create guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
|
||||
@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
|
||||
const maxRecordSearchSelectFieldCount = 50
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
|
||||
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
|
||||
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
|
||||
|
||||
146
shortcuts/common/localfile.go
Normal file
146
shortcuts/common/localfile.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// ValidateLocalFileFlag validates that a local input path exists, is a regular
|
||||
// file, and does not exceed maxBytes. Absolute and relative paths use
|
||||
// the process filesystem namespace.
|
||||
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := cmdutil.StatLocalFile(path)
|
||||
if err != nil {
|
||||
return localFileReadError(param, path, "inspect", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return localFileSizeError(param, path, info.Size(), maxBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
|
||||
// shortcuts. It accepts absolute and relative paths, enforces a hard size
|
||||
// limit, and returns command-facing typed errors.
|
||||
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := cmdutil.OpenLocalFile(path)
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "open", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil && retErr == nil {
|
||||
data = nil
|
||||
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
|
||||
}
|
||||
}()
|
||||
|
||||
openedInfo, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "inspect opened", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if openedInfo.Size() > maxBytes {
|
||||
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
|
||||
}
|
||||
|
||||
readLimit := maxBytes + 1
|
||||
if maxBytes == math.MaxInt64 {
|
||||
readLimit = maxBytes
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(f, readLimit))
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "read", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
|
||||
WithParam(param)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
|
||||
name, param, err := localFileFlagNames(flagName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ctx == nil || ctx.Cmd == nil {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
|
||||
}
|
||||
|
||||
path = strings.TrimSpace(ctx.Str(name))
|
||||
if path == "" {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
|
||||
}
|
||||
if _, err := validate.LocalInputPath(path); err != nil {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
if maxBytes < 0 {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
|
||||
}
|
||||
return path, param, nil
|
||||
}
|
||||
|
||||
func localFileRegularError(param, path string, mode fs.FileMode) error {
|
||||
if mode.IsRegular() {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is not a regular file", param, path).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileReadError(param, path, op string, cause error) error {
|
||||
if errors.Is(cause, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
if errors.Is(cause, fs.ErrNotExist) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
|
||||
}
|
||||
|
||||
func localFileSizeError(param, path string, size, limit int64) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileFlagNames(flagName string) (name, param string, err error) {
|
||||
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
|
||||
if name == "" {
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
|
||||
}
|
||||
return name, "--" + name, nil
|
||||
}
|
||||
95
shortcuts/common/localfile_test.go
Normal file
95
shortcuts/common/localfile_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx := localFileTestRuntime(t, path)
|
||||
|
||||
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
|
||||
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
|
||||
}
|
||||
got, err := rctx.ReadLocalFileFlag("file", 7)
|
||||
if err != nil || string(got) != "content" {
|
||||
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
if err := cmd.Flags().Set("file", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Tests pinning bot-identity support for `minutes +detail` (minute metadata,
|
||||
// artifacts, and transcript all flow under a tenant access token).
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestMinutesDetailSupportsUserAndBotIdentity(t *testing.T) {
|
||||
want := []string{"user", "bot"}
|
||||
if !reflect.DeepEqual(MinutesDetail.AuthTypes, want) {
|
||||
t.Fatalf("MinutesDetail.AuthTypes = %v, want %v", MinutesDetail.AuthTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
|
||||
t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_DryRun_BotIdentity_Transcript(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--transcript", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "artifacts") {
|
||||
t.Errorf("dry-run should show artifacts API path when --transcript is set, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermissionSupportsUserAndBotIdentity(t *testing.T) {
|
||||
want := []string{"user", "bot"}
|
||||
if !reflect.DeepEqual(MinutesApplyPermission.AuthTypes, want) {
|
||||
t.Fatalf("MinutesApplyPermission.AuthTypes = %v, want %v", MinutesApplyPermission.AuthTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPermission_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesApplyPermission, []string{
|
||||
"+apply-permission", "--minute-token", "obcnexampleminute", "--perm", "view", "--dry-run", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/obcnexampleminute/permissions/apply") {
|
||||
t.Errorf("dry-run should show apply-permission API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"perm": "view"`) && !strings.Contains(out, `"perm":"view"`) {
|
||||
t.Errorf("dry-run should show perm body, got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ var MinutesApplyPermission = common.Shortcut{
|
||||
Description: "Apply for view or edit permission on a minute",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:permission:apply"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-token", Desc: "minute token", Required: true},
|
||||
{Name: "perm", Desc: "permission to apply for", Required: true, Enum: []string{"view", "edit"}},
|
||||
|
||||
@@ -285,7 +285,7 @@ var MinutesDetail = common.Shortcut{
|
||||
Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
|
||||
package slides
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var presentationFlagAliases = []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
|
||||
// Shortcuts returns all slides shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
all := []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
@@ -18,4 +31,39 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
for i := range all {
|
||||
if hasPresentationFlag(all[i].Flags) {
|
||||
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func hasPresentationFlag(flags []common.Flag) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "presentation" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withPresentationFlagAliases accepts common agent-generated spellings for
|
||||
// --presentation without registering extra flags. The aliases therefore stay
|
||||
// out of help and completion while resolving to the canonical flag at parse
|
||||
// time, matching the zero-round-trip compatibility used by Sheets.
|
||||
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
if name == alias {
|
||||
return pflag.NormalizedName("presentation")
|
||||
}
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
68
shortcuts/slides/shortcuts_alias_test.go
Normal file
68
shortcuts/slides/shortcuts_alias_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWithPresentationFlagAliases(t *testing.T) {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
withPresentationFlagAliases(nil)(cmd)
|
||||
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
|
||||
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Fatalf("read --presentation: %v", err)
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
|
||||
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
|
||||
count := 0
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if !hasPresentationFlag(shortcut.Flags) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if shortcut.PostMount == nil {
|
||||
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
shortcut.PostMount(cmd)
|
||||
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
|
||||
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one slides shortcut with --presentation")
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,23 +18,19 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
base := []string{"slides:presentation:screenshot"}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -100,6 +101,40 @@ func extractTaskGuid(input string) string {
|
||||
return extractTasklistGuid(input)
|
||||
}
|
||||
|
||||
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
|
||||
|
||||
func parseTaskGUID(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
invalid := func(format string, args ...interface{}) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
|
||||
WithParam("--task-id").
|
||||
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
return "", invalid("task ID is empty")
|
||||
}
|
||||
|
||||
lowerInput := strings.ToLower(input)
|
||||
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", invalid("invalid task applink: %v", err).WithCause(err)
|
||||
}
|
||||
guid := strings.TrimSpace(u.Query().Get("guid"))
|
||||
if guid == "" {
|
||||
return "", invalid("task applink is missing a non-empty guid query parameter")
|
||||
}
|
||||
return guid, nil
|
||||
}
|
||||
|
||||
if taskDisplayNumberPattern.MatchString(input) {
|
||||
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := make(map[string]interface{})
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -15,3 +18,80 @@ func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTaskGUID(t *testing.T) {
|
||||
t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
|
||||
{name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
|
||||
{
|
||||
name: "task applink",
|
||||
input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
|
||||
want: "task-guid-123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseTaskGUID(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unusable task identifiers", func(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
"https://applink.larksuite.com/client/todo/detail",
|
||||
"https://%",
|
||||
"t12345",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseTaskGUID(input)
|
||||
if err == nil {
|
||||
t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatal("problem hint is empty")
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--task-id" {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves applink parse cause", func(t *testing.T) {
|
||||
_, err := parseTaskGUID("https://%")
|
||||
if err == nil {
|
||||
t.Fatal("parseTaskGUID() error = nil, want URL parse error")
|
||||
}
|
||||
|
||||
var urlErr *url.Error
|
||||
if !errors.As(err, &urlErr) {
|
||||
t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,45 +25,59 @@ var CompleteTask = common.Shortcut{
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
return err
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCompleteBody()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
taskID := url.PathEscape(taskGUID)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/" + taskId).
|
||||
GET("/open-apis/task/v2/tasks/" + taskID).
|
||||
Desc("get current task status").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskID).
|
||||
Desc("complete task if not completed").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID := url.PathEscape(taskGUID)
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var data map[string]interface{}
|
||||
|
||||
// 1. Get current task status
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskData, _ := getData["task"].(map[string]interface{})
|
||||
completedAtStr, _ := taskData["completed_at"].(string)
|
||||
alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
|
||||
|
||||
// 2. If already completed, directly return success
|
||||
if completedAtStr != "" && completedAtStr != "0" {
|
||||
if alreadyCompleted {
|
||||
data = getData
|
||||
} else {
|
||||
// 3. Complete the task
|
||||
body := buildCompleteBody()
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,11 +87,19 @@ var CompleteTask = common.Shortcut{
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
completedAt, _ := task["completed_at"].(string)
|
||||
status := "todo"
|
||||
if completedAt != "" && completedAt != "0" {
|
||||
status = "done"
|
||||
}
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"status": status,
|
||||
"completed_at": completedAt,
|
||||
"already_completed": alreadyCompleted,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
@@ -45,6 +48,9 @@ func TestCompleteTask(t *testing.T) {
|
||||
formatFlag: "json",
|
||||
expectedOutput: []string{
|
||||
`"guid": "task-789"`,
|
||||
`"status": "done"`,
|
||||
`"completed_at": "1775174400000"`,
|
||||
`"already_completed": false`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -109,3 +115,98 @@ func TestCompleteTask(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
for _, method := range []string{"GET", "PATCH"} {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: method,
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-applink",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-applink",
|
||||
"summary": "Applink task",
|
||||
"completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
|
||||
"url": "https://example.com/task-guid-applink",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete",
|
||||
"--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
|
||||
t.Fatalf("output = %s, want normalized task GUID", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-done",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-done",
|
||||
"summary": "Already done",
|
||||
"completed_at": "1775174400000",
|
||||
"url": "https://example.com/task-guid-done",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
|
||||
t.Fatalf("completion state = %#v, want done/already_completed server state", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("CompleteTask error = nil, want invalid task ID error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
|
||||
t.Fatalf("error param = %#v, want --task-id", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,27 +27,42 @@ var UpdateTask = common.Shortcut{
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
|
||||
{Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
|
||||
{Name: "summary", Desc: "task title"},
|
||||
{Name: "description", Desc: "task description"},
|
||||
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
|
||||
{Name: "data", Desc: "JSON payload for task object"},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
return err
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
preview := common.NewDryRunAPI()
|
||||
for _, taskID := range taskIDs {
|
||||
preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
return preview
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
// buildTaskUpdateBody already returns a typed validation error;
|
||||
@@ -55,17 +70,11 @@ var UpdateTask = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
var updatedTasks []map[string]interface{}
|
||||
|
||||
for _, taskId := range taskIds {
|
||||
taskId = strings.TrimSpace(taskId)
|
||||
if taskId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, taskID := range taskIDs {
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,19 +85,28 @@ var UpdateTask = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
updateFields, _ := body["update_fields"].([]string)
|
||||
var tasks []map[string]interface{}
|
||||
for _, task := range updatedTasks {
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
confirmed := make(map[string]interface{})
|
||||
for _, field := range updateFields {
|
||||
if value, ok := task[field]; ok {
|
||||
confirmed[field] = value
|
||||
}
|
||||
}
|
||||
tasks = append(tasks, map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"confirmed": confirmed,
|
||||
})
|
||||
}
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"tasks": tasks,
|
||||
"updated_fields": updateFields,
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
|
||||
@@ -112,6 +130,26 @@ var UpdateTask = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func parseTaskGUIDs(input string) ([]string, error) {
|
||||
parts := strings.Split(input, ",")
|
||||
taskGUIDs := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
continue
|
||||
}
|
||||
guid, err := parseTaskGUID(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskGUIDs = append(taskGUIDs, guid)
|
||||
}
|
||||
if len(taskGUIDs) == 0 {
|
||||
_, err := parseTaskGUID("")
|
||||
return nil, err
|
||||
}
|
||||
return taskGUIDs, nil
|
||||
}
|
||||
|
||||
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
taskObj := make(map[string]interface{})
|
||||
var updateFields []string
|
||||
|
||||
201
shortcuts/task/task_update_test.go
Normal file
201
shortcuts/task/task_update_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParseTaskGUIDs(t *testing.T) {
|
||||
got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskGUIDs() error = %v", err)
|
||||
}
|
||||
want := []string{"task-guid-1", "task-guid-2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
_, err = parseTaskGUIDs("task-guid-1,t12345")
|
||||
if err == nil {
|
||||
t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
|
||||
cmd.Flags().String("summary", "updated", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("data", "", "")
|
||||
|
||||
preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
|
||||
payload, err := json.Marshal(preview)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run preview: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &got); err != nil {
|
||||
t.Fatalf("decode dry-run preview: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
|
||||
}
|
||||
|
||||
wantURLs := []string{
|
||||
"/open-apis/task/v2/tasks/task-guid-1",
|
||||
"/open-apis/task/v2/tasks/task-guid-2",
|
||||
}
|
||||
for i, call := range got.API {
|
||||
if call.Method != "PATCH" {
|
||||
t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
|
||||
}
|
||||
if call.URL != wantURLs[i] {
|
||||
t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
|
||||
}
|
||||
if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
|
||||
t.Errorf("api[%d].params = %#v", i, call.Params)
|
||||
}
|
||||
if !reflect.DeepEqual(call.Body, got.API[0].Body) {
|
||||
t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
first := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-1",
|
||||
"url": "https://example.com/task-guid-1",
|
||||
"summary": "server summary one",
|
||||
"description": "server description one",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
second := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-2",
|
||||
"url": "https://example.com/task-guid-2",
|
||||
"summary": "server summary two",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(first)
|
||||
reg.Register(second)
|
||||
|
||||
err := runMountedTaskShortcut(t, UpdateTask, []string{
|
||||
"+update",
|
||||
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
|
||||
"--summary", "requested summary",
|
||||
"--description", "requested description",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", envelope["data"])
|
||||
}
|
||||
if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
|
||||
t.Fatalf("updated_fields = %v, want [summary description]", got)
|
||||
}
|
||||
|
||||
tasks, ok := data["tasks"].([]interface{})
|
||||
if !ok || len(tasks) != 2 {
|
||||
t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
|
||||
}
|
||||
firstTask := tasks[0].(map[string]interface{})
|
||||
if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
|
||||
t.Fatalf("first task identifiers = %#v", firstTask)
|
||||
}
|
||||
if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
|
||||
"summary": "server summary one", "description": "server description one",
|
||||
}) {
|
||||
t.Fatalf("first confirmed = %#v", got)
|
||||
}
|
||||
|
||||
secondTask := tasks[1].(map[string]interface{})
|
||||
if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
|
||||
"summary": "server summary two",
|
||||
}) {
|
||||
t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, UpdateTask, []string{
|
||||
"+update",
|
||||
"--task-id", "task-guid-1,t12345",
|
||||
"--summary", "must not be written",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("UpdateTask error = nil, want invalid task ID error")
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
|
||||
t.Fatalf("error param = %#v, want --task-id", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func stringSlice(value interface{}) []string {
|
||||
items, _ := value.([]interface{})
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Tests pinning bot-identity support for the vc read shortcuts
|
||||
// (+detail / +notes / +recording).
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthTypes contracts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestVCReadShortcutsSupportUserAndBotIdentity(t *testing.T) {
|
||||
want := []string{"user", "bot"}
|
||||
cases := map[string][]string{
|
||||
"+detail": VCDetail.AuthTypes,
|
||||
"+notes": VCNotes.AuthTypes,
|
||||
"+recording": VCRecording.AuthTypes,
|
||||
}
|
||||
for cmd, got := range cases {
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s AuthTypes = %v, want %v", cmd, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot dry-run: the meeting/recording paths flow under bot identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/meetings/{meeting_id}") {
|
||||
t.Errorf("dry-run should show meeting.get API, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/notes/{note_id}") {
|
||||
t.Errorf("dry-run should show note.get API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar-event-ids also flows under bot: a bot has a primary calendar, so the
|
||||
// primary-calendar -> meeting_id -> recording/notes chain is expected to work.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecording_DryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", "evt001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show the primary-calendar resolution step, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_DryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show the primary-calendar resolution step, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity-aware preflight: bot resolves TAT (empty local scopes in this stub),
|
||||
// so an under-scoped UAT must not make --as bot fail. Reverting to
|
||||
// auth.GetStoredToken(user) would break this.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecording_BotIdentityAwareScopePreflight(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &recordingIdentityTokenResolver{
|
||||
uatScopes: "calendar:calendar:read", // deliberately missing vc:record:readonly
|
||||
tatScopes: "", // bot/tenant: no local scope metadata
|
||||
}, nil)
|
||||
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("bot preflight must resolve tenant token, not the under-scoped user token; got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingIdentityTokenResolver returns different scopes for UAT vs TAT so
|
||||
// bot identity-aware preflight can be pinned separately from user preflight.
|
||||
type recordingIdentityTokenResolver struct {
|
||||
uatScopes string
|
||||
tatScopes string
|
||||
}
|
||||
|
||||
func (r *recordingIdentityTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
scopes := r.uatScopes
|
||||
if req.Type == credential.TokenTypeTAT {
|
||||
scopes = r.tatScopes
|
||||
}
|
||||
return &credential.TokenResult{Token: "test-token", Scopes: scopes}, nil
|
||||
}
|
||||
@@ -164,7 +164,7 @@ var VCDetail = common.Shortcut{
|
||||
Description: "Get meeting details including note_id and minute_token by meeting IDs",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},
|
||||
|
||||
@@ -536,7 +536,7 @@ var VCNotes = common.Shortcut{
|
||||
Description: "Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
AuthTypes: []string{"user"},
|
||||
Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -92,13 +91,13 @@ var VCRecording = common.Shortcut{
|
||||
Description: "Query minute_token from meeting-ids or calendar-event-ids",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:record:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
|
||||
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.ExactlyOneTyped(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -117,14 +116,18 @@ var VCRecording = common.Shortcut{
|
||||
case runtime.Str("calendar-event-ids") != "":
|
||||
required = scopesRecordingCalendarEventIDs
|
||||
}
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
appID := runtime.Config.AppID
|
||||
userOpenID := runtime.UserOpenId()
|
||||
if appID != "" && userOpenID != "" {
|
||||
stored := auth.GetStoredToken(appID, userOpenID)
|
||||
if stored != nil {
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -139,15 +141,27 @@ func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecording_Validate_MissingScope(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
// TestFactory's default token resolver returns empty Scopes, which skips
|
||||
// identity-aware preflight. Inject a resolver that returns an under-scoped
|
||||
// user token so the MissingScopes path is exercised.
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &recordingScopedTokenResolver{
|
||||
scopes: "calendar:calendar:read",
|
||||
}, nil)
|
||||
keyring.MockInit() // use in-memory keyring to avoid macOS keychain popups
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
cfg := defaultConfig()
|
||||
// Store a token that intentionally lacks the vc:record:readonly scope.
|
||||
token := &auth.StoredUAToken{
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AppId: cfg.AppID,
|
||||
AccessToken: "test-user-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "calendar:calendar:read",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing_scope error, got nil")
|
||||
@@ -175,16 +189,6 @@ func TestRecording_Validate_MissingScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// recordingScopedTokenResolver returns a token with caller-controlled scopes
|
||||
// so tests can deterministically exercise the identity-aware scope preflight.
|
||||
type recordingScopedTokenResolver struct {
|
||||
scopes string
|
||||
}
|
||||
|
||||
func (r *recordingScopedTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
## 各命令
|
||||
|
||||
### +file-list
|
||||
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。
|
||||
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20,范围 1..200)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-list --app-id app_xxx
|
||||
|
||||
@@ -29,7 +29,7 @@ metadata:
|
||||
## 使用边界
|
||||
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL;简单命令由命令自身的参数、tips 和错误恢复承接。
|
||||
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置,首次请求必须基于可信的当前配置执行 read-modify-write:只修改用户明确指定的内容,保留其他仍适用的可写配置,并按命令要求的结构提交。若命令支持局部/delta update,按其契约提交最小合法 payload;不得以不完整请求试错补参。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
@@ -104,20 +104,18 @@ metadata:
|
||||
|
||||
## 写入前置规则
|
||||
|
||||
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
|
||||
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
|
||||
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
||||
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
||||
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
|
||||
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
|
||||
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。
|
||||
- 删除、角色更新、字段更新、表单提交(`+form-submit`)等高风险操作遵循 CLI 的 confirmation gate,必须带 `--yes`;目标不明确时先用 get/list 消歧。
|
||||
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
|
||||
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值。
|
||||
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。
|
||||
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。
|
||||
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`。
|
||||
- `+record-batch-create`:`rows` 是 `CellValue[][]`,列顺序由 `fields` 决定。
|
||||
- `+record-batch-create`:使用 `create_records`,其每个元素都是 `Map<FieldNameOrID, CellValue>`。
|
||||
- `+record-batch-update`:使用 `update_records`,其每个 value 都是 `Map<FieldNameOrID, CellValue>`。
|
||||
- 一次 payload 里同一字段只用一种 key(字段名或字段 ID),不要重复。
|
||||
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
|
||||
@@ -48,7 +48,7 @@ text 字段的 `style.type` 影响单元格检查逻辑:
|
||||
|
||||
### 2.3 select(单选/多选)
|
||||
|
||||
单选用选项名字符串;多选用选项名数组。选项名建议与字段配置一致;写入未知选项时平台可能自动新增选项,因此不要把自然语言近义词当成已有选项传入。
|
||||
`select` 字段用 `multiple` 区分单选和多选:`multiple=false` 时传选项名字符串,`multiple=true` 时传选项名数组。只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
通过表单分享链接填写并提交多维表格表单。仅支持分享模式(share_token),支持填写普通字段值和上传本地文件作为附件。
|
||||
|
||||
> **⚠️ 高风险写操作(high-risk-write):** 本命令会向表单写入并提交数据,属于高风险写操作,必须额外传递 `--yes` 进行确认,否则会返回 `confirmation_required` 错误并退出。当用户明确要求提交且目标表单无歧义时,直接附加 `--yes`,无需再次询问。
|
||||
|
||||
## 填写前必读:先获取表单详情
|
||||
|
||||
**在调用 `+form-submit` 之前,必须先使用 `+form-detail` 获取表单详情。** 原因如下:
|
||||
@@ -21,10 +23,11 @@ lark-cli base +form-detail --share-token <share_token>
|
||||
|
||||
# 2️⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
|
||||
|
||||
# 3️⃣ 再提交
|
||||
# 3️⃣ 再提交(高风险写操作,必须带 --yes)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}'
|
||||
--json '{"fields":{...}}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
`+form-detail` 的返回中要重点读取 `questions[].type`、`questions[].required`、题目 `filter` 和附件场景所需的 `data.base_token`。
|
||||
@@ -35,7 +38,8 @@ lark-cli base +form-submit \
|
||||
# 基本提交(填写普通字段)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
|
||||
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' \
|
||||
--yes
|
||||
|
||||
# 带附件提交(需要额外提供 --base-token)
|
||||
lark-cli base +form-submit \
|
||||
@@ -47,15 +51,17 @@ lark-cli base +form-submit \
|
||||
"附件字段名": ["./report.pdf", "./photo.png"],
|
||||
"另一个附件字段": ["./doc.docx"]
|
||||
}
|
||||
}'
|
||||
}' \
|
||||
--yes
|
||||
|
||||
# 使用应用身份(bot)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}' \
|
||||
--as bot
|
||||
--as bot \
|
||||
--yes
|
||||
|
||||
# 预览 API 调用(不实际执行)
|
||||
# 预览 API 调用(不实际执行,dry-run 无需 --yes)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}' \
|
||||
@@ -69,6 +75,7 @@ lark-cli base +form-submit \
|
||||
| `--share-token <token>` | 是 | 表单分享 Token(必填),从表单分享链接中提取 |
|
||||
| `--base-token <token>` | 条件必填 | Base token;**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
|
||||
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
|
||||
| `--yes` | 是 | 确认高风险写操作。本命令为 high-risk-write,不带 `--yes` 会返回 `confirmation_required` |
|
||||
| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv |
|
||||
| `--as` | 否 | 身份:user(默认)\| bot |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
@@ -138,7 +145,8 @@ https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
|
||||
```bash
|
||||
lark-cli base +form-submit \
|
||||
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
|
||||
--json '{"fields":{...}}'
|
||||
--json '{"fields":{...}}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
@@ -158,6 +166,7 @@ lark-cli base +form-submit \
|
||||
|
||||
## 提示
|
||||
|
||||
- **本命令为高风险写操作(high-risk-write),必须额外传递 `--yes` 确认**,否则返回 `confirmation_required` 并以非零码退出;`--dry-run` 预览除外
|
||||
- 本命令仅支持通过表单分享链接(share_token)提交,不支持通过 base_token + table_id + view_id 方式提交
|
||||
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
|
||||
- 附件字段只需在 `--json.attachments` 中提供本地路径即可,CLI 自动完成校验、并行上传、Token 获取和合并写入
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
## 适用场景(重点)
|
||||
|
||||
- 适合导入 CSV / Excel、外部系统一次性写入新数据。
|
||||
- 先把输入数据映射到合适的字段类型,再组装 `fields + rows`。
|
||||
- 先把每条输入数据映射为独立的字段对象,再组装到 `create_records`。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> \
|
||||
--json '{"fields":["标题","状态"],"rows":[["任务 A","Open"],["任务 B","Done"]]}'
|
||||
--json '{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}'
|
||||
|
||||
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> --json @batch-create.json
|
||||
```
|
||||
@@ -34,23 +34,25 @@ lark-cli base +record-batch-create --base-token <base_token> --table-id <table_i
|
||||
|
||||
本节只说明 `+record-batch-create` 的外层 JSON 形状;CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。
|
||||
|
||||
对象形态:`{"fields":[...],"rows":[...]}`。
|
||||
对象形态:
|
||||
|
||||
```json
|
||||
{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `fields` | `string[]` | 是 | 字段 ID 或字段名数组 |
|
||||
| `rows` | `CellValue[][]` | 是 | 二维数组,每一行按 `fields` 同序给 cell;单次最多 200 行 |
|
||||
| `create_records` | `Array<Map<FieldNameOrID, CellValue>>` | 是 | 记录字段对象数组;每条记录可以提交不同字段,单次最多 200 条 |
|
||||
|
||||
## 返回重点
|
||||
|
||||
返回 `fields`、`field_id_list`、`record_id_list`、`data`,其中 `data` 与 `fields` 列顺序对齐。
|
||||
返回 `record_id_list` 和可选的 `ignored_fields`。
|
||||
|
||||
## 坑点
|
||||
|
||||
- `fields` 与每行 `rows` 的列顺序必须一一对应。
|
||||
- 空单元格必须显式用 `null` 填充。
|
||||
- 单次最多 200 行,超出需分批写入。
|
||||
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先确认真实选项名。
|
||||
- 每个 `create_records` 元素都是独立的记录字段对象,只提交该记录需要写入的字段。
|
||||
- 单次最多 200 条,超出需分批写入。
|
||||
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ lark-cli base +record-upsert --base-token <base_token> --table-id <table_id> --r
|
||||
## 坑点
|
||||
|
||||
- 有 `--record-id` 就一定更新;不传就一定创建,不会自动查重或按业务键 upsert。
|
||||
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先用 `+field-list` / `+field-search-options` 确认真实选项名。
|
||||
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。
|
||||
- 这是写入操作,执行前必须确认目标表和字段。
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
### 内容限制
|
||||
|
||||
- HTML 总长度上限为 900000 字符。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
|
||||
## OKR block
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ metadata:
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
|
||||
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps`(`file_type=apps`),裸 token 调 shortcut 时传 `--type apps`。
|
||||
|
||||
@@ -96,6 +96,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
||||
- "某项目发布会重点" → 先搜项目名 + "发布会" + "重点/功能/一览",再按标题和摘要判断是否需要只搜标题或扩大到正文。
|
||||
|
||||
每轮扩展都要保留非污染、可解释的 evidence(URL/token/标题/摘要);不能因为某个扩展词搜到高相似标题就跳过证据核验。
|
||||
扩展 query 时,优先保留用户已经指定的空间、文件夹、群聊、人员、时间和类型等 filter;确需放宽检索范围时,先向用户说明原因并征得确认。
|
||||
|
||||
## 参数
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# 主题资料收集工作流:执行
|
||||
|
||||
由状态 `CONFIRM_EXECUTION`、`EXECUTE`、`VERIFY`、`RESTORE` 加载。
|
||||
|
||||
本文档负责最终写操作确认、目标创建、资源移动、验证、恢复行为、`RollbackSnapshotItem` 和执行日志。不得修改搜索、召回、分类规则或计划 schema。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理写操作确认、高风险操作、身份、认证和权限。
|
||||
2. 按 [`lark-drive-create-folder.md`](lark-drive-create-folder.md) 创建 Drive 文件夹。
|
||||
3. 按 [`lark-drive-move.md`](lark-drive-move.md) 执行 Drive 移动。
|
||||
4. 按 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) 创建 Wiki 节点。
|
||||
5. 按 [`../../lark-wiki/references/lark-wiki-move.md`](../../lark-wiki/references/lark-wiki-move.md) 执行 Wiki 移动和 Drive 文档移动到 Wiki。
|
||||
6. 按 [`../../lark-wiki/references/lark-wiki-move-to-drive.md`](../../lark-wiki/references/lark-wiki-move-to-drive.md) 将 Wiki 节点移出到 Drive 文件夹。
|
||||
7. 按 [`lark-drive-delete.md`](lark-drive-delete.md) 删除本次 workflow 新建的 Drive 文件夹。
|
||||
8. 按 [`../../lark-wiki/references/lark-wiki-node-delete.md`](../../lark-wiki/references/lark-wiki-node-delete.md) 删除本次 workflow 新建的 Wiki 节点。
|
||||
9. 需要轮询异步任务时,按 [`lark-drive-task-result.md`](lark-drive-task-result.md) 执行。
|
||||
10. `MovePlanItem` schema 由 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) 定义,本文件只消费已确认计划。
|
||||
|
||||
## 状态:`CONFIRM_EXECUTION`
|
||||
|
||||
进入条件:移动计划已准备,且用户要求执行。
|
||||
|
||||
必须:
|
||||
|
||||
1. 执行前展示所有写操作类别。
|
||||
2. 将目标创建和资源移动分开展示。
|
||||
3. 展示默认纳入的高相关资源。
|
||||
4. 如有用户选择的中相关资源,也要展示。
|
||||
5. 展示跳过分组和原因。
|
||||
6. 明确展示跨容器移动。
|
||||
7. 展示无移动权限和移动权限未知的资源数量。
|
||||
8. 请求用户明确确认。
|
||||
9. 确认前校验每个 `move_resource` 项都包含完整 `command_family`、`command_args`、权限快照和 `rollback_input`;缺失时必须返回 `PLAN_MOVE` 重新生成计划,不得在执行阶段补猜。
|
||||
10. 只有 `move_permission_state=movable` 且 `target_write_state=confirmed` 的计划项可以列入“将移动”。
|
||||
11. 对每个 `rollback_supported=false` 的计划项逐项展示标题、当前位置、目标位置、不可恢复原因和影响,不得只展示数量。
|
||||
|
||||
### 确认 UI
|
||||
|
||||
```text
|
||||
请确认是否执行以下写操作:
|
||||
|
||||
本次搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
|
||||
|
||||
将创建:
|
||||
- 目标名称|父级位置|目标类型
|
||||
|
||||
将移动:
|
||||
- 标题|类型|当前位置|目标位置|原因
|
||||
|
||||
不会移动:
|
||||
- 中相关未选择:N 项
|
||||
- 低相关:N 项
|
||||
- 无权限:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 无法验证:N 项
|
||||
- 不支持移动:N 项
|
||||
|
||||
风险提示:
|
||||
- 不可自动恢复:N 项
|
||||
- 标题|当前位置|目标位置|不可恢复原因|影响:移动成功后 workflow 无法自动搬回原位置,需要手动处理
|
||||
- 如果搜索范围是所有当前身份可见资源,移动权限未知项不会移动。
|
||||
|
||||
确认后才会创建目标和移动资源。
|
||||
|
||||
如果不存在不可自动恢复项,请回复“确认执行”开始写操作。
|
||||
如果存在不可自动恢复项,请回复“确认执行,包括不可自动恢复项”;普通“确认执行”不满足本次风险确认。
|
||||
也可以回复“调整计划”返回选择资源,或回复“取消”结束流程。
|
||||
```
|
||||
|
||||
如果用户修改选择或相关性分组,废弃当前 `move_plan_items` 并返回 `PLAN_MOVE` 重新生成计划;不得在 `CONFIRM_EXECUTION` 直接局部改写计划。
|
||||
|
||||
## 状态:`EXECUTE`
|
||||
|
||||
进入条件:用户明确确认写操作;存在 `rollback_supported=false` 的计划项时,用户已明确确认包括不可自动恢复项。
|
||||
|
||||
必须:
|
||||
|
||||
1. 只执行已确认 `MovePlanItem.command_family` 和 `command_args`;不得回查 `ResourceItem` 补齐或改写命令参数。
|
||||
2. 当存在 `action_type=create_target` 的 `MovePlanItem` 时,先创建目标。
|
||||
3. 目标创建后记录返回 token;只允许把 `created_by_plan:<create_target plan_id>` 引用解析为该 token,并把解析后的实际参数写入 `execution_journal`。不得重新搜索或猜测目标。
|
||||
4. 目标 token 引用解析成功后再移动依赖该目标的资源;解析失败时停止依赖该创建目标的移动并记录 blocker,不得替换为其他目标。
|
||||
5. 执行任何写操作前,基于每个已确认计划项的 `rollback_input` 生成 `rollback_snapshot`。`rollback_supported=false` 且已有明确 `rollback_blocker` 的快照视为完整风险快照,不阻塞其他项。
|
||||
6. 执行任何写操作前,初始化 `execution_journal`。
|
||||
7. 每次写操作尝试后记录 `execution_journal`。
|
||||
8. 单项失败后可继续执行相互独立的移动;目标创建失败时必须停止。
|
||||
9. 不得移动 `permission_denied`、`no_move_permission`、`move_permission_unknown`、`unverifiable`、`low` 或 `unsupported_move_target` 项。
|
||||
10. 不得移动 `move_permission_state!=movable` 或 `target_write_state!=confirmed` 的资源。
|
||||
11. 如果移动命令返回权限错误,记录失败原因,不自动申请权限,不自动重试同一移动。
|
||||
12. 如果 `rollback_supported=true` 但 `rollback_input` 缺少恢复所需字段,将该计划项标记为 `failed` / `plan_snapshot_incomplete` 并跳过;不得在未重新确认风险的情况下把它静默降级为不可恢复项,也不得阻塞其他独立项。
|
||||
|
||||
### 移动方式选择
|
||||
|
||||
| 来源 -> 目标 | 移动方式 |
|
||||
|------------------|-------------|
|
||||
| Drive resource -> Drive folder | `drive +move` |
|
||||
| Drive document-like resource -> Wiki target | `wiki +move` 的 docs-to-wiki 模式;默认不可自动恢复 |
|
||||
| Wiki node -> Wiki target | `wiki +move --node-token` |
|
||||
| Wiki node -> Drive folder | `wiki +move-to-drive` |
|
||||
|
||||
### 执行顺序
|
||||
|
||||
1. 如有 `create_target` 项,先执行。
|
||||
2. 按确认计划顺序执行 `move_resource` 项。
|
||||
3. 如果命令返回 task ID,执行异步任务轮询。
|
||||
4. 输出写操作执行摘要。
|
||||
|
||||
### 进度 UI
|
||||
|
||||
批量较大时,按计数汇报进度:
|
||||
|
||||
```text
|
||||
执行进度:已完成 <done_count>/<total_count>,成功 <success_count>,失败 <failed_count>。
|
||||
当前操作:<title>
|
||||
继续执行中,不需要你操作;如遇到需要确认的失败会单独提示。
|
||||
```
|
||||
|
||||
## 状态:`VERIFY`
|
||||
|
||||
进入条件:执行完成。
|
||||
|
||||
必须:
|
||||
|
||||
1. 如果创建了目标,验证目标存在。
|
||||
2. 能力支持时,验证已移动资源在目标位置可见。
|
||||
3. 对比实际位置和 `move_plan_items`。
|
||||
4. 为每一项标记验证状态。
|
||||
5. 只有当已有移动成功且存在严重不一致或失败时,才提供恢复选项。
|
||||
6. 输出验证结果时,必须说明用户下一步可以结束流程、查看失败项,或在可恢复时选择恢复。
|
||||
7. 如果出现 `async_pending`,先使用 `drive +task_result` 轮询确认;超过轮询限制后再报告 pending blocker。
|
||||
|
||||
### 验证结果
|
||||
|
||||
| 状态值 | 说明 |
|
||||
|--------|------|
|
||||
| `verified` | 资源已在目标位置可见。 |
|
||||
| `not_found` | 目标位置未找到资源。 |
|
||||
| `permission_unknown` | 当前身份无法确认结果。 |
|
||||
| `async_pending` | 异步任务尚未完成,需要继续轮询。 |
|
||||
| `failed` | 移动命令失败或结果不符合计划。 |
|
||||
|
||||
## 状态:`RESTORE`
|
||||
|
||||
进入条件:失败、不一致或用户明确要求恢复。
|
||||
|
||||
必须:
|
||||
|
||||
1. 只基于 `rollback_snapshot` 和 `execution_journal` 生成恢复计划。
|
||||
2. 展示可恢复项和不可恢复项。
|
||||
3. 执行恢复写操作前请求明确确认;确认内容必须包含反向移动和删除本次 workflow 新建目标。
|
||||
4. 只恢复本次 workflow 移动过的资源。
|
||||
5. 只恢复 `rollback_supported=true` 且 `rollback_eligible=true` 的移动项。
|
||||
6. Drive / Wiki 跨容器移动、原父级 token 缺失等 `rollback_supported=false` 的项不得反向移动,也不得删除迁入后的文档。
|
||||
7. 本次 workflow 成功创建的目标文件夹或 Wiki 节点必须纳入清理计划。
|
||||
8. 删除 workflow 新建的 Wiki 目标节点时,必须使用 `wiki +node-delete --include-children=false --yes`,让已迁入的直接子文档保留到该节点父级层级。
|
||||
9. 删除 workflow 新建的 Drive 文件夹前,必须先恢复或移出其中由本次 workflow 放入的资源;如果无法确认文件夹已安全可删,报告清理阻塞,不得用删除文件夹来删除用户资源。
|
||||
|
||||
### 恢复顺序
|
||||
|
||||
1. 先恢复 `rollback_supported=true` 且 `rollback_eligible=true` 的移动项。
|
||||
2. 对全部 `rollback_supported=false` 的项,只记录“保留在当前目标位置,不回迁、不删除”和对应 blocker。
|
||||
3. 再清理 `created_by_workflow=true` 的目标容器。
|
||||
4. Wiki 新建目标清理使用 `--include-children=false`;Drive 新建目标清理只在不会删除用户资源时执行。
|
||||
|
||||
### 恢复 UI
|
||||
|
||||
```text
|
||||
可以尝试恢复本次已移动的资源:
|
||||
|
||||
可恢复:
|
||||
- 标题|当前位置|原位置
|
||||
|
||||
不可自动恢复:
|
||||
- 标题|当前位置|原位置|原因|影响:需要手动恢复
|
||||
|
||||
将清理本次新建目标:
|
||||
- 名称|类型|清理方式
|
||||
|
||||
将保留在当前目标位置的跨容器迁入文档:
|
||||
- 标题|当前位置|保留结果
|
||||
|
||||
是否执行恢复?
|
||||
```
|
||||
|
||||
## RollbackSnapshotItem
|
||||
|
||||
```json
|
||||
{
|
||||
"snapshot_id": "稳定快照行 ID",
|
||||
"plan_id": "对应 MovePlanItem.plan_id",
|
||||
"resource_id": "对应 MovePlanItem.resource_id",
|
||||
"source_kind": "drive|wiki",
|
||||
"title": "资源标题",
|
||||
"resource_type": "Drive 恢复命令需要的资源类型",
|
||||
"original_token": "原始 Drive token",
|
||||
"original_node_token": "原始 Wiki node token",
|
||||
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
|
||||
"original_parent_token": "原始父级 token",
|
||||
"original_space_id": "原始 Wiki space_id",
|
||||
"original_path": "执行前路径",
|
||||
"planned_target_parent_token": "计划目标父级 token",
|
||||
"rollback_supported": "是否支持自动恢复",
|
||||
"rollback_blocker": "不可自动恢复原因"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `snapshot_id` | 稳定快照行 ID。 |
|
||||
| `plan_id` | 对应 `MovePlanItem.plan_id`,用于连接计划、快照和执行日志。 |
|
||||
| `resource_id` | 对应稳定资源 ID,用于审计计划来源。 |
|
||||
| `resource_type` | `drive +move` 恢复时必须传入的 `--type`;非 Drive 恢复也保留原始资源类型。 |
|
||||
| `original_token` / `original_node_token` | 执行前源资源身份。 |
|
||||
| `original_parent_kind` / `original_parent_token` | 执行前父级位置。 |
|
||||
| `rollback_supported` | 是否支持自动恢复。 |
|
||||
| `rollback_blocker` | 不可自动恢复原因。 |
|
||||
|
||||
## 执行日志
|
||||
|
||||
每次写操作尝试都必须追加一条内部日志:
|
||||
|
||||
```json
|
||||
{
|
||||
"journal_id": "稳定日志行 ID",
|
||||
"plan_id": "对应 MovePlanItem 的 plan_id",
|
||||
"time": "ISO-8601",
|
||||
"action_type": "create_target|move_resource|restore_resource|cleanup_target",
|
||||
"operation": "create_folder|create_node|move_drive|move_wiki_node|move_wiki_to_drive|restore_drive|restore_wiki_node|delete_folder|delete_wiki_node",
|
||||
"command_family": "drive +move|wiki +move|wiki +move-to-drive|drive +create-folder|wiki +node-create|drive +delete|wiki +node-delete",
|
||||
"resolved_command_args": {"<arg>": "实际发送的参数"},
|
||||
"title": "资源或目标名称",
|
||||
"resource_type": "资源类型",
|
||||
"input_token": "命令输入 token",
|
||||
"input_node_token": "命令输入 Wiki node token",
|
||||
"input_parent_token": "已知源父级 token",
|
||||
"target_parent_token": "目标父级 token",
|
||||
"returned_token": "命令返回 token",
|
||||
"returned_node_token": "命令返回 Wiki node token",
|
||||
"returned_parent_token": "返回父级 token",
|
||||
"task_id": "异步任务 ID",
|
||||
"next_command": "异步继续命令",
|
||||
"created_by_workflow": "是否由本次 workflow 创建",
|
||||
"rollback_eligible": "是否可进入自动恢复计划",
|
||||
"status": "success|failed|pending",
|
||||
"error": "失败原因"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `journal_id` | 稳定日志行 ID。 |
|
||||
| `plan_id` | 对应 `MovePlanItem`,用于把日志项匹配回原计划。 |
|
||||
| `operation` | 细分操作类型,用于区分创建、移动和恢复。 |
|
||||
| `resolved_command_args` | 从确认计划解析出的实际发送参数;用于审计 `created_by_plan:<plan_id>` 的唯一运行时替换。 |
|
||||
| `resource_type` | 实际移动 / 恢复使用的资源类型。 |
|
||||
| `input_token` / `input_node_token` | 命令实际输入的资源 token。 |
|
||||
| `input_parent_token` | 执行前已知源父级 token。 |
|
||||
| `target_parent_token` | 命令输入的目标父级 token。 |
|
||||
| `returned_token` / `returned_node_token` | 命令返回的资源 token,恢复时作为当前源。 |
|
||||
| `returned_parent_token` | 命令返回的当前父级 token。 |
|
||||
| `task_id` / `next_command` | 异步任务跟踪信息。 |
|
||||
| `created_by_workflow` | 是否由本次 workflow 创建,用于后续清理判断。 |
|
||||
| `rollback_eligible` | 是否可进入自动恢复计划。 |
|
||||
| `status` | 写操作状态,异步未完成时为 `pending`。 |
|
||||
|
||||
除非用户要求查看技术调试细节,否则不要展示完整原始命令输出。
|
||||
@@ -0,0 +1,202 @@
|
||||
# 主题资料收集工作流:召回
|
||||
|
||||
由状态 `SEARCH_RECALL`、`RECALL_ENHANCE` 加载。
|
||||
|
||||
本文档负责基础搜索召回、覆盖增强、query 证据、去重和 `CandidateItem`。不得解析目标移动 token、读取完整文档内容、判断相关性或执行写操作。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
|
||||
2. 按 [`lark-drive-search.md`](lark-drive-search.md) 处理 `drive +search` 语法、过滤条件、单批最多 5 页和身份语义;本 workflow 的全量续批规则见下文。
|
||||
|
||||
## 搜索原则
|
||||
|
||||
1. 默认使用 `drive +search --mine` 召回当前用户 owner / 负责的 Workspace 资源。
|
||||
2. 除非用户本来就要求限定范围,否则不要要求用户指定文件夹或 Wiki 范围。
|
||||
3. `SEARCH_RECALL` 和 `RECALL_ENHANCE` 必须保持为独立状态。
|
||||
4. `SEARCH_RECALL` 使用用户原始关键词、`owner_scope` 和显式限制。
|
||||
5. `RECALL_ENHANCE` 可以基于基础召回证据增加扩展 query,且必须继承同一个 `owner_scope`。
|
||||
6. 每个候选项必须保留 query 证据,方便后续解释来源。
|
||||
7. 单页或单个最多 5 页的 query 批次不代表完整覆盖;`has_more=true` 时必须保存 `next_page_token` 并自动开始下一批,直到 `has_more=false` 或出现阻塞。
|
||||
8. 召回和增强召回可能耗时较长,执行超过 60 秒时必须输出进度提示,之后约每 60 秒提示一次。
|
||||
9. 只有用户在 `CONFIRM_CONTEXT` 明确确认 `owner_scope=all_visible` 时,才允许移除 `--mine`。
|
||||
|
||||
### 分页优先级与完成语义
|
||||
|
||||
1. 用户确认进入 `topic_move_collector` 即表示同意为本次收集任务执行完整召回;无需再要求用户额外说“全部 / 全量 / 继续翻”。本规则覆盖 `lark-drive-search.md` 的默认首屏交互规则。
|
||||
2. 仍遵守 `lark-drive-search.md` 的单轮最多 5 页限制。每读取最多 5 页形成一个批次;批次结束且 `has_more=true` 时,保存 checkpoint,并使用原 query、原过滤条件和返回的 `next_page_token` 自动开始下一批。
|
||||
3. 自动续批不改变 workflow 状态,也不触发用户确认。执行超过约 60 秒时只输出进度。
|
||||
4. 一个 query 只有在 `has_more=false` 时才是 `complete`。单批结束、达到 5 页或已有部分候选都不代表完成。
|
||||
5. 当前状态的全部 query 都为 `complete` 后,才能进入下一状态。认证、权限、无效分页 token、连续重试失败或工具预算不足属于 blocker;必须保留 checkpoint、报告部分召回并停在当前状态,不得把部分结果当成完整召回继续分类。
|
||||
|
||||
### QueryRecallState
|
||||
|
||||
每个基础 / 增强 query 必须维护:
|
||||
|
||||
```json
|
||||
{
|
||||
"query_id": "稳定 query ID",
|
||||
"query": "完整 query",
|
||||
"recall_stage": "search_recall|recall_enhance",
|
||||
"page_count": 0,
|
||||
"batch_count": 0,
|
||||
"next_page_token": "下一批起点",
|
||||
"has_more": true,
|
||||
"status": "pending|running|complete|blocked",
|
||||
"blocker": "阻塞原因"
|
||||
}
|
||||
```
|
||||
|
||||
## 状态:`SEARCH_RECALL`
|
||||
|
||||
进入条件:用户已确认 `CONFIRM_CONTEXT`。
|
||||
|
||||
必须:
|
||||
|
||||
1. 基于已确认的 `topic` 构造基础 query。
|
||||
2. 应用默认 `owner_scope=mine` 和 `constraints` 中的显式限制。
|
||||
3. 不隐式添加 `--folder-tokens` 或 `--space-ids`。
|
||||
4. 当 `owner_scope=mine` 时,所有基础 query 必须带 `--mine`。
|
||||
5. 当 `owner_scope=all_visible` 时,不带 `--mine`,并记录扩展召回风险。
|
||||
6. 除非命令限制要求更低值,否则使用 `--page-size 20`。
|
||||
7. 每个基础 query 按每批最多 5 页执行;批次结束仍有更多结果时自动续批,并合并所有页面。
|
||||
8. 记录基础统计:query、搜索范围、页数、批次数、收集数量、重复数量、阻塞项。
|
||||
9. 只有全部基础 query 的 `status=complete` 且 `has_more=false` 时,才进入 `RECALL_ENHANCE`;出现阻塞时保持在 `SEARCH_RECALL`。
|
||||
|
||||
### 召回进度 UI
|
||||
|
||||
当 `SEARCH_RECALL` 或 `RECALL_ENHANCE` 持续超过约 60 秒时,输出当前进度:
|
||||
|
||||
```text
|
||||
搜索进度:当前阶段 <SEARCH_RECALL|RECALL_ENHANCE>,已执行 <query_count> 个 query,已读取 <page_count> 页,收集候选 <raw_count> 项,去重后 <unique_count> 项。继续搜索,不会创建或移动资源。
|
||||
```
|
||||
|
||||
如果正在执行具体 query,可补充:
|
||||
|
||||
```text
|
||||
当前 query:<query>
|
||||
```
|
||||
|
||||
### 基础 Query 规则
|
||||
|
||||
| 用户输入 | 基础 Query |
|
||||
|------------|----------------|
|
||||
| 单个关键词 | 直接作为 `--query`。 |
|
||||
| 多个关键词组成一个短语 | 优先按用户输入的短语执行。 |
|
||||
| 明确精确短语 | 保留引号。 |
|
||||
| 明确排除词 | 保留负向词。 |
|
||||
| 没有真实关键词,只有过滤条件 | 使用 `--query ""` 搭配过滤条件。 |
|
||||
|
||||
在 `SEARCH_RECALL` 中不得添加同义词、仅标题搜索、仅评论搜索或 OR 扩展。
|
||||
|
||||
### 基础召回输出
|
||||
|
||||
```text
|
||||
基础召回完成:
|
||||
- 使用 query:
|
||||
- 搜索范围:
|
||||
- 应用限制:
|
||||
- 收集候选:
|
||||
- 去重后候选:
|
||||
- 阻塞项:
|
||||
|
||||
下一步:继续执行覆盖增强,不需要你操作;不会创建或移动资源。
|
||||
```
|
||||
|
||||
## 状态:`RECALL_ENHANCE`
|
||||
|
||||
进入条件:基础召回完成。
|
||||
|
||||
必须:
|
||||
|
||||
1. 基于已确认主题和基础召回证据生成增强 query。
|
||||
2. 确保增强 query 可解释且不引入明显污染。
|
||||
3. 每个增强 query 都必须继承 `owner_scope`;`owner_scope=mine` 时必须带 `--mine`。
|
||||
4. 每个 query 都必须按每批最多 5 页处理分页,并自动续批直到 `has_more=false`。
|
||||
5. 有稳定去重键时,按稳定去重键合并候选项。
|
||||
6. 为每个候选项保留 `source_queries` 和命中证据。
|
||||
7. 当 query 不再产生新候选,或出现工具预算 / API 阻塞时,停止增强。
|
||||
|
||||
### 召回阶段退出门禁
|
||||
|
||||
`RECALL_ENHANCE` 完成后,必须:
|
||||
|
||||
1. 确认全部基础和增强 query 的 `status=complete` 且 `has_more=false`,再固化完整 `candidate_items`,包含去重结果、`source_queries`、`match_channels`、`snippets` 和 `dedupe_status`。
|
||||
2. 将 `current_state` 设置为 `RESOURCE_RESOLVE`。
|
||||
3. 加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md)。
|
||||
4. 把完整 `candidate_items` 交给 `RESOURCE_RESOLVE`。
|
||||
5. 不得直接进入 `RELEVANCE_CLASSIFY`、`PLAN_MOVE` 或展示相关性结果。
|
||||
6. 不得用搜索标题、摘要或 query 命中直接生成高 / 中 / 低相关分组。
|
||||
|
||||
### 增强策略
|
||||
|
||||
| 策略 | 说明 |
|
||||
|----------|------|
|
||||
| 精确短语 | 对明确短语使用 `"..."` 提高精确命中。 |
|
||||
| `intitle:` | 对项目名、客户名、制度名、报表名等标题特征强的主题执行标题召回。 |
|
||||
| `--only-title` | 当标题命中更可信时使用。 |
|
||||
| `--only-comment` | 当主题可能只出现在评论讨论中时使用。 |
|
||||
| 类型拆分 | 对 `docx`、`sheet`、`bitable`、`slides`、`file` 等分类型搜索,减少服务端排序偏差。 |
|
||||
| 同义词 / 别名 | 使用业务上明确的同义词、简称、英文名、中文名。 |
|
||||
| OR 扩展 | 对同一实体的别名做 OR 扩展。 |
|
||||
| 负向词 | 对明显噪声使用 `-term`,但不能排除可能相关的主题词。 |
|
||||
|
||||
### Query 证据
|
||||
|
||||
每个候选项都要记录:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `source_queries` | 命中过该资源的 query 列表。 |
|
||||
| `match_channels` | 命中位置,如 title、body、comment、metadata。 |
|
||||
| `snippets` | 搜索返回的摘要或片段。 |
|
||||
| `query_rank` | 资源在各 query 中的相对位置。 |
|
||||
| `recall_stage` | `search_recall` 或 `recall_enhance`。 |
|
||||
|
||||
## 去重规则
|
||||
|
||||
必须:
|
||||
|
||||
1. 搜索响应提供 canonical token 时,优先使用 canonical token。
|
||||
2. 对 Wiki 结果,不得只按 object token 去重;同一对象可能出现在多个 Wiki 节点中。
|
||||
3. token 缺失时,使用 URL 作为 fallback。
|
||||
4. 合并重复项时保留所有 query 证据。
|
||||
5. 如果无法确定去重是否稳定,保留该项并设置 `dedupe_status=uncertain`。
|
||||
|
||||
## CandidateItem
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "资源标题",
|
||||
"url": "资源链接",
|
||||
"raw_type": "搜索返回类型",
|
||||
"source_queries": ["query"],
|
||||
"match_channels": ["title|body|comment|metadata"],
|
||||
"snippets": ["命中片段"],
|
||||
"page_rank": 1,
|
||||
"dedupe_key": "候选去重键",
|
||||
"dedupe_status": "stable|fallback|uncertain",
|
||||
"recall_stage": "search_recall|recall_enhance"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `title` | 搜索结果标题。 |
|
||||
| `url` | 资源访问链接。 |
|
||||
| `raw_type` | 搜索返回的原始类型。 |
|
||||
| `source_queries` | 命中过该资源的搜索 query。 |
|
||||
| `match_channels` | 命中位置。 |
|
||||
| `snippets` | 摘要或命中片段。 |
|
||||
| `page_rank` | 当前 query 下的排序位置。 |
|
||||
| `dedupe_key` | 候选去重键。 |
|
||||
| `dedupe_status` | 去重可信度。 |
|
||||
| `recall_stage` | 资源首次进入候选集的召回阶段。 |
|
||||
|
||||
## 阻塞项
|
||||
|
||||
缺少认证 / scope、`drive +search` 返回权限或策略阻塞、分页 token 无效、分页重试后仍无法继续,或工具预算不足以完成全部页面时,必须把对应 `QueryRecallState.status` 设置为 `blocked`,保留累计候选、页数和 `next_page_token`,停止并报告。阻塞解除后从 checkpoint 续跑;在全部 query 完成前不得进入资源解析或分类阶段。
|
||||
@@ -0,0 +1,231 @@
|
||||
# 主题资料收集工作流:资源解析与内容验证
|
||||
|
||||
由状态 `RESOURCE_RESOLVE`、`CONTENT_VERIFY` 加载。
|
||||
|
||||
本文档负责资源解析、结构化父级、移动资格、内容验证和 `ResourceItem`。不得判断相关性、生成移动计划、创建目标、移动资源或执行恢复操作。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
|
||||
2. 按 [`lark-drive-inspect.md`](lark-drive-inspect.md) 处理 URL / token 解析。
|
||||
3. 使用 `drive metas batch_query` 补齐 Drive 资源 owner、标题和 URL。
|
||||
4. 必要时使用 `drive permission.members auth` 读取权限信号;该接口不提供 `full_access` / 移动权限的直接判定,不能把 `manage_public` 等同为可移动。
|
||||
5. 按 [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 处理 Wiki 节点解析。
|
||||
6. 按 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) 读取文档内容。
|
||||
7. 需要验证 Sheet 内容时,按 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md) 执行。
|
||||
|
||||
## 进入解析与验证阶段前校验
|
||||
|
||||
进入本文档后,如果 `resource_items` 还不存在,当前状态必须是 `RESOURCE_RESOLVE`。
|
||||
|
||||
禁止从 `candidate_items` 直接进入 `CONTENT_VERIFY` 或 `RELEVANCE_CLASSIFY`,也禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使候选项已有标题、URL、摘要或 token,也必须依次执行 `RESOURCE_RESOLVE` 和 `CONTENT_VERIFY`;两个状态不得合并。
|
||||
|
||||
## 状态:`RESOURCE_RESOLVE`
|
||||
|
||||
进入条件:候选列表已准备。
|
||||
|
||||
必须:
|
||||
|
||||
1. 为每个 `CandidateItem` 生成稳定 `resource_id`,并转换为标准化 `ResourceItem`。
|
||||
2. 解析 canonical token、资源类型、URL、结构化当前父级、Wiki 节点身份和读取权限状态。
|
||||
3. 对 Wiki 资源同时保留 `wiki_node_token` 和 `wiki_obj_token`。
|
||||
4. 按 `move_method` 补齐 `owner_id`、`is_owner`、`source_move_state`、`source_parent_write_state`、`target_write_state`、`move_permission_state` 和 `move_permission_basis`。
|
||||
5. 基于 `target_location` 检测不支持的移动方向。
|
||||
6. 未解析成功的资源仍保留在审核分组中,不得静默丢弃。
|
||||
7. 即使搜索结果已经包含标题、URL 或 token,也必须经过本状态生成 `ResourceItem`;不得从召回结果直接进入相关性分级。
|
||||
8. 只有确认 `move_permission_state=movable` 且 `target_write_state=confirmed` 的资源,才能进入后续默认移动链路。
|
||||
9. 解析耗时超过约 60 秒时,必须输出进度提示,之后约每 60 秒提示一次。
|
||||
|
||||
### 解析规则
|
||||
|
||||
| 候选类型 | agent 必须执行 |
|
||||
|----------------|---------------|
|
||||
| Drive URL / token | token 或类型不确定时,使用 `drive +inspect`。 |
|
||||
| Wiki URL / token | 使用 `drive +inspect` 或 `wiki +node-get`;保留节点身份和对象身份。 |
|
||||
| 文件夹候选 | 标记为容器;不要当作普通文档做内容验证。 |
|
||||
| 快捷方式候选 | 能解析源资源时解析源资源;同时保留快捷方式身份。 |
|
||||
| 无读取权限 | 保留可见元数据,并设置 `permission_state=denied`。 |
|
||||
| 无移动权限或移动权限未知 | 保留可见元数据和召回证据,并设置对应 `move_permission_state`。 |
|
||||
| 无法解析当前父级 | 设置 `current_parent_kind=unknown`,保留已知路径,后续计划项设置 `rollback_supported=false` 和明确 blocker;不得编造父级 token。 |
|
||||
|
||||
### 资源解析进度 UI
|
||||
|
||||
当 `RESOURCE_RESOLVE` 持续超过约 60 秒时,输出当前进度:
|
||||
|
||||
```text
|
||||
资源解析进度:已解析 <resolved_count>/<total_count> 项,已确认可移动 <movable_count> 项,无移动权限 <denied_count> 项,移动权限未知 <unknown_count> 项,解析失败 <failed_count> 项。
|
||||
当前资源:<title>
|
||||
继续解析中,不会创建或移动资源。
|
||||
```
|
||||
|
||||
如果正在处理权限或 owner 元数据,可补充:
|
||||
|
||||
```text
|
||||
当前步骤:解析 owner / 当前父级 / 移动资格。
|
||||
```
|
||||
|
||||
`RESOURCE_RESOLVE` 完成后,输出摘要:
|
||||
|
||||
```text
|
||||
资源解析完成:
|
||||
- 候选总数:N 项
|
||||
- 可进入内容验证:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 解析失败或无读取权限:N 项
|
||||
|
||||
下一步会对可移动资源做内容验证;不会创建或移动资源。
|
||||
```
|
||||
|
||||
### 资源解析出口门禁
|
||||
|
||||
`RESOURCE_RESOLVE` 完成后必须:
|
||||
|
||||
1. 将 `content_verify_completed` 重置为 `false`。
|
||||
2. 将下一状态设置为 `CONTENT_VERIFY`,不得设置为 `RELEVANCE_CLASSIFY` 或 `PLAN_MOVE`。
|
||||
3. 不得在本状态生成 `relevance`、`relevance_groups` 或移动计划。
|
||||
4. 即使可读取正文的资源数量为 0,也必须进入 `CONTENT_VERIFY`,为每项记录跳过验证原因并输出验证摘要。
|
||||
|
||||
### 移动资格判定
|
||||
|
||||
`owner` 只能作为部分权限证据,不得单独把资源判为 `movable`。`RESOURCE_RESOLVE` 必须先按 `move_method` 记录以下独立状态:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `source_move_state` | 当前身份是否确认可以对源资源执行对应移动;Drive owner 只可作为 Drive 源资源可管理的证据,Wiki 底层资源 owner 不能证明 Wiki 节点可移动。 |
|
||||
| `source_parent_write_state` | 当前身份是否确认可编辑源位置;仅 `drive_move` 必须确认,其他移动方式为 `not_required`。 |
|
||||
| `target_write_state` | 当前身份是否确认可写目标位置;待创建目标以父级位置的创建 / 写入权限为准。 |
|
||||
|
||||
#### 按移动方式的权限矩阵
|
||||
|
||||
| `move_method` | `source_move_state=confirmed` 的证据 | `source_parent_write_state` | `target_write_state` |
|
||||
|---------------|--------------------------------------|-----------------------------|----------------------|
|
||||
| `drive_move` | 当前用户是可靠解析出的 Drive 资源 owner,或有明确资源可管理证据 | 必须为 `confirmed` | 必须为 `confirmed` |
|
||||
| `wiki_move_docs_to_wiki` | 有明确的 Drive 文档直接迁入权限;仅 owner 元数据不足以证明可直接迁入 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
|
||||
| `wiki_move_node` | 有明确的 Wiki 节点 / 源空间移动权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
|
||||
| `wiki_move_to_drive` | 有明确的 Wiki 节点移出权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Drive 文件夹可写 |
|
||||
|
||||
#### 聚合顺序
|
||||
|
||||
1. 目标方向或资源类型不支持时,设置 `move_permission_state=denied`、`move_permission_basis=["unsupported_direction"]`。
|
||||
2. 任一必需状态为 `denied` 时,设置 `move_permission_state=denied`,并在 `move_permission_basis` 记录 `source_denied`、`source_parent_denied` 或 `target_denied`。
|
||||
3. 任一必需状态为 `unknown` 时,设置 `move_permission_state=unknown`,并记录对应的 `source_unknown`、`source_parent_unknown` 或 `target_unknown`。
|
||||
4. 只有权限矩阵中的全部必需状态都为 `confirmed` 时,才能设置 `move_permission_state=movable`、`move_permission_basis=["permission_matrix_confirmed"]`。
|
||||
|
||||
注意:
|
||||
|
||||
1. `drive permission.members auth` 不提供 `full_access` 或 `move` action;不能用 `view`、`edit`、`share` 或 `manage_public` 结果推断源位置或目标位置可写。
|
||||
2. `target_write_state=unknown|denied` 的资源不得进入高 / 中相关可执行分组或移动计划。
|
||||
3. `move_permission_state=unknown` 的资源默认不进入内容验证、相关性高 / 中分组或移动计划。
|
||||
4. 当 `owner_scope=mine` 但解析出的 owner 不是当前用户时,将该资源视为异常候选,设置 `source_move_state=unknown` 和 `move_permission_state=unknown`,不得加入移动计划。
|
||||
|
||||
## 状态:`CONTENT_VERIFY`
|
||||
|
||||
进入条件:资源列表已准备。
|
||||
|
||||
必须:
|
||||
|
||||
1. 本状态不可跳过,也不得与 `RESOURCE_RESOLVE` 或 `RELEVANCE_CLASSIFY` 合并;没有可读取正文的资源时仍须执行。
|
||||
2. 只在资源解析后读取支持的内容。
|
||||
3. 按数量、大小和类型能力限制读取范围。
|
||||
4. 结合搜索证据和内容证据;除非标题精确且足够强,否则不要仅凭标题判为高相关。
|
||||
5. 将不可读取资源标记为 `unverifiable` 或 `permission_denied`。
|
||||
6. 不得自动申请权限。
|
||||
7. 为每个资源写入验证状态:已读取内容证据、仅可使用搜索证据、无权限、无移动权限、移动权限未知、无法验证或不支持内容验证。
|
||||
8. 对 `move_permission_state=denied|unknown` 的资源,不再读取正文内容,写入跳过验证原因并保留召回证据;写入跳过原因属于执行本状态,不等于跳过本状态。
|
||||
9. 所有资源都有验证状态或跳过原因后,将 `content_verify_completed` 设置为 `true` 并输出验证摘要。
|
||||
10. `content_verify_completed=true` 前不得进入 `RELEVANCE_CLASSIFY`。
|
||||
|
||||
### 验证方式
|
||||
|
||||
| 资源类型 | 验证方式 |
|
||||
|---------------|---------------------|
|
||||
| `docx` / `doc` | 允许时使用 `docs +fetch --api-version v2`。 |
|
||||
| `sheet` | 使用 `sheets +find` 查关键词证据,或用 `sheets +read` 读取有界范围。 |
|
||||
| `bitable` | 只有必要且已加载 Base 能力时验证。 |
|
||||
| `slides` | 除非具备幻灯片读取能力,否则使用元数据 / 预览 / 标题证据。 |
|
||||
| `file` | 仅在支持时使用标题、元数据、预览或导出文本。 |
|
||||
| `wiki` 节点 | 按 `obj_type` 验证底层对象;节点本身不是内容 token。 |
|
||||
| `folder` | 除非用户明确要移动容器,否则通常不作为主题证据移动。 |
|
||||
|
||||
### 内容验证完成 UI
|
||||
|
||||
完成 `CONTENT_VERIFY` 后必须输出:
|
||||
|
||||
```text
|
||||
内容验证完成:
|
||||
- 已读取内容证据:N 项
|
||||
- 仅复用搜索证据:N 项
|
||||
- 因无权限或移动资格跳过:N 项
|
||||
- 无法验证或不支持验证:N 项
|
||||
|
||||
下一步会基于以上证据进行相关性分组;不会创建或移动资源。
|
||||
```
|
||||
|
||||
如果没有任何资源可以读取正文,仍须输出该摘要,并明确说明所有资源采用的搜索证据或跳过原因。
|
||||
|
||||
### 内容验证出口门禁
|
||||
|
||||
`CONTENT_VERIFY` 完成后必须:
|
||||
|
||||
1. 确认 `content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过原因。
|
||||
2. 将下一状态设置为 `RELEVANCE_CLASSIFY`。
|
||||
3. 加载 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md)。
|
||||
4. 不得直接进入 `PLAN_MOVE`。
|
||||
|
||||
## ResourceItem
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_id": "稳定资源 ID",
|
||||
"title": "资源标题",
|
||||
"resource_type": "doc|docx|sheet|bitable|file|folder|wiki|slides|shortcut",
|
||||
"url": "资源链接",
|
||||
"canonical_token": "标准资源 token",
|
||||
"wiki_node_token": "Wiki 节点 token",
|
||||
"wiki_obj_token": "Wiki 底层对象 token",
|
||||
"wiki_obj_type": "Wiki 底层对象类型",
|
||||
"space_id": "知识空间 ID",
|
||||
"current_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
|
||||
"current_parent_token": "当前父级 token",
|
||||
"current_parent_space_id": "当前父级 Wiki space_id",
|
||||
"current_path": "用于展示的当前位置",
|
||||
"owner_id": "资源 owner open_id",
|
||||
"is_owner": "true|false|unknown",
|
||||
"permission_state": "readable|denied|unknown",
|
||||
"source_move_state": "confirmed|unknown|denied",
|
||||
"source_parent_write_state": "confirmed|unknown|denied|not_required",
|
||||
"move_permission_state": "movable|denied|unknown",
|
||||
"move_permission_basis": ["权限矩阵证据或阻塞原因"],
|
||||
"target_write_state": "confirmed|unknown|denied",
|
||||
"item_resolve_status": "resolved|partial|failed",
|
||||
"content_verify_state": "verified|search_evidence_only|skipped_by_move_permission|permission_denied|unverifiable|unsupported",
|
||||
"content_evidence": ["证据"],
|
||||
"relevance": "high|medium|low|permission_denied|no_move_permission|move_permission_unknown|unverifiable|unsupported_move_target"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `canonical_token` | 内容读取、Drive 对象操作或底层对象操作使用的标准 token;Wiki 节点移动不得使用该字段。 |
|
||||
| `resource_id` | 资源解析时生成的稳定 ID,用于连接 `ResourceItem` 和 `MovePlanItem`。 |
|
||||
| `wiki_node_token` | Wiki 节点身份,用于 Wiki 节点移动。 |
|
||||
| `wiki_obj_token` | Wiki 节点背后的真实文档 token。 |
|
||||
| `current_parent_kind` / `current_parent_token` / `current_parent_space_id` | 结构化执行前父级,用于 `already_at_target` 判断和恢复;未知值不得猜测。 |
|
||||
| `current_path` | 仅用于用户展示的当前位置,不得代替父级 token。 |
|
||||
| `owner_id` | 资源 owner;Drive 资源优先来自 `drive metas batch_query`,Wiki 节点优先来自 `wiki +node-get`。 |
|
||||
| `is_owner` | 当前用户是否为资源 owner。 |
|
||||
| `permission_state` | 当前身份下的读取权限状态。 |
|
||||
| `source_move_state` | 当前身份是否确认能对源资源执行所选 `move_method`;必须按权限矩阵判断。 |
|
||||
| `source_parent_write_state` | Drive 内移动所需的源位置编辑状态;非 `drive_move` 为 `not_required`。 |
|
||||
| `move_permission_state` | 权限矩阵聚合结果;只有 `movable` 且目标写入状态为 `confirmed` 才可进入默认移动链路。 |
|
||||
| `move_permission_basis` | 移动资格判断依据,用于解释为什么纳入或排除。 |
|
||||
| `target_write_state` | 目标位置是否确认可写。 |
|
||||
| `item_resolve_status` | 资源项解析状态;不要和 `TargetLocation.target_resolve_status` 混用。 |
|
||||
| `content_verify_state` | 内容验证状态或跳过验证原因。 |
|
||||
| `content_evidence` | 支撑相关性判断的命中证据。 |
|
||||
| `relevance` | 相关性和可执行性分组。 |
|
||||
@@ -0,0 +1,248 @@
|
||||
# 主题资料收集工作流:审核与计划
|
||||
|
||||
由状态 `RELEVANCE_CLASSIFY`、`PLAN_MOVE` 加载。
|
||||
|
||||
本文档负责相关性分级、审核 UI、移动计划生成和 `MovePlanItem`。不得重新执行资源解析或内容验证,也不得创建目标、移动资源或执行恢复操作。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 输入契约
|
||||
|
||||
进入本文档前必须已有:
|
||||
|
||||
1. `resource_items`,且每个 `ResourceItem` 已包含稳定 `resource_id`、资源类型、移动所需 token、结构化当前父级、权限状态、内容验证状态和证据。
|
||||
2. `content_verify_completed=true`。
|
||||
3. 每个资源都有内容证据、搜索证据复用说明或明确跳过原因。
|
||||
|
||||
`ResourceItem` schema 和字段生成规则由 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 负责。只要上述输入契约完整,本状态不得为重复读取 schema 而重新加载或执行前一阶段文档。
|
||||
|
||||
如果输入字段缺失、资源需要重新解析或用户要求重新读取证据,废弃受影响的相关性和计划结果,返回 `RESOURCE_RESOLVE` 或 `CONTENT_VERIFY`,并加载资源解析与内容验证文档;不得在本状态补猜。
|
||||
|
||||
## 状态:`RELEVANCE_CLASSIFY`
|
||||
|
||||
进入条件:`CONTENT_VERIFY` 已完成,`content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过验证原因。
|
||||
|
||||
禁止条件:
|
||||
|
||||
1. 只有 `candidate_items`,没有 `resource_items`。
|
||||
2. 资源未经过 `RESOURCE_RESOLVE`。
|
||||
3. 资源没有 `RESOURCE_RESOLVE` 写入的移动资格状态。
|
||||
4. 资源没有 `CONTENT_VERIFY` 写入的验证状态或跳过验证原因。
|
||||
5. 上一完成状态是 `RESOURCE_RESOLVE`,或 `content_verify_completed` 不为 `true`。
|
||||
|
||||
必须将每个资源归入且只归入一个分组:
|
||||
|
||||
| 分组 | 说明 | 默认移动 |
|
||||
|-------|------|--------------|
|
||||
| `high` | 可移动资源,且主题或内容直接命中,有明确标题 / 正文 / 表格 / 评论证据。 | 是 |
|
||||
| `medium` | 可移动资源,可能相关,但证据不足或只命中弱相关片段。 | 否,需用户选择 |
|
||||
| `low` | 可移动资源,弱相关或噪声,保留展示但不建议移动。 | 否 |
|
||||
| `permission_denied` | 当前身份无权读取或解析,不能验证内容。 | 否 |
|
||||
| `no_move_permission` | 已确认当前身份不具备移动资格。 | 否 |
|
||||
| `move_permission_unknown` | 无法确认当前身份是否具备移动资格。 | 否 |
|
||||
| `unverifiable` | 类型或工具限制导致无法验证内容。 | 否 |
|
||||
| `unsupported_move_target` | 目标方向或资源类型不支持移动。 | 否 |
|
||||
|
||||
`high`、`medium` 和 `low` 只能包含 `move_permission_state=movable` 且 `target_write_state=confirmed` 的资源。
|
||||
|
||||
判为高相关至少需要一个强证据:
|
||||
|
||||
1. 标题或内容中出现精确主题短语。
|
||||
2. 多个主题词在相关上下文中同时出现。
|
||||
3. Sheet / 表格单元格明确匹配用户主题。
|
||||
4. 用户明确提供的文档名或项目别名命中。
|
||||
|
||||
中相关示例:
|
||||
|
||||
1. 标题包含一个主题词,但内容无法确认。
|
||||
2. 搜索摘要看起来相关,但无法完整读取。
|
||||
3. 别名命中合理但证据不够强。
|
||||
|
||||
## 审核 UI
|
||||
|
||||
必须展示每个分组中的资源名称。
|
||||
|
||||
默认展示规则:
|
||||
|
||||
1. 展开 `high` 和 `medium`。
|
||||
2. 折叠 `low`、`permission_denied`、`no_move_permission`、`move_permission_unknown`、`unverifiable` 和 `unsupported_move_target`,但展示数量并允许展开。
|
||||
3. 每个可见资源展示标题、类型、当前位置、证据和默认动作。
|
||||
4. 除非用户要求技术细节,否则不展示原始 token。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
筛选结果:
|
||||
|
||||
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
|
||||
|
||||
高相关(默认移动):
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
中相关(需你勾选后才移动):
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
未默认移动:
|
||||
- 低相关:N 项
|
||||
- 无权限:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 无法验证:N 项
|
||||
- 不支持移动:N 项
|
||||
|
||||
你可以选择:
|
||||
1. 确认按默认规则生成移动计划。
|
||||
2. 勾选要加入计划的中相关资源。
|
||||
3. 要求把某些资源移到其他分组或从计划中移除。
|
||||
4. 展开低相关 / 无权限 / 无移动权限 / 移动权限未知 / 无法验证 / 不支持移动分组查看名称。
|
||||
```
|
||||
|
||||
### 用户调整规则
|
||||
|
||||
如果用户不同意相关性结果,必须基于用户要求更新 `relevance_groups`,再重新展示分组结果并重新生成后续移动计划。
|
||||
|
||||
典型调整包括:
|
||||
|
||||
1. 从 `high` 中移除某个资源。
|
||||
2. 将 `medium` 中某个资源提升为 `high`。
|
||||
3. 将某个资源标为 `low` 或不移动。
|
||||
4. 要求重新读取证据或重新判断一批资源。
|
||||
5. 要求重新确认某些资源的移动权限。
|
||||
|
||||
用户调整后:
|
||||
|
||||
1. 旧的 `move_plan_items` 立即失效。
|
||||
2. 必须先输出“调整后相关性结果”,展示被调整项、各分组数量和高 / 中相关资源名称。
|
||||
3. 不得只回复“已调整”,也不得直接跳到 `CONFIRM_EXECUTION`。
|
||||
4. 必须基于新的 `relevance_groups` 重新执行 `PLAN_MOVE`。
|
||||
5. 不得把 `no_move_permission` 或 `move_permission_unknown` 资源直接提升到 `high` / `medium`;必须先回到 `RESOURCE_RESOLVE`,加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 取得可移动证据。
|
||||
|
||||
### 调整后结果 UI
|
||||
|
||||
```text
|
||||
已按你的要求调整相关性结果:
|
||||
- <标题>:<原分组> -> <新分组>
|
||||
|
||||
调整后分组:
|
||||
|
||||
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
|
||||
|
||||
高相关(默认移动):N 项
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
中相关(需你勾选后才移动):N 项
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
未默认移动:
|
||||
- 低相关:N 项
|
||||
- 无权限:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 无法验证:N 项
|
||||
- 不支持移动:N 项
|
||||
|
||||
接下来会基于这个调整后的结果重新生成移动计划;你也可以继续调整。
|
||||
```
|
||||
|
||||
## 状态:`PLAN_MOVE`
|
||||
|
||||
进入条件:相关性分组已准备。
|
||||
|
||||
必须:
|
||||
|
||||
1. 当 `target_location.create_required=true` 时,纳入目标创建计划。
|
||||
2. 生成移动计划前,比较规范化的当前父级与目标父级;已在目标位置的资源生成 `skip_resource`,设置 `skip_reason=already_at_target`,不得生成移动命令。
|
||||
3. 默认纳入全部 `high`、`move_permission_state=movable` 且 `target_write_state=confirmed` 的资源。
|
||||
4. 只有用户明确选择时,才纳入 `medium`、`move_permission_state=movable` 且 `target_write_state=confirmed` 的资源。
|
||||
5. 默认排除 `low`、`permission_denied`、`no_move_permission`、`move_permission_unknown`、`unverifiable` 和 `unsupported_move_target`。
|
||||
6. 为每个跳过项生成 `skip_reason`。
|
||||
7. 为每个计划项生成稳定 `plan_id`,并使用 `resource_id` 连接对应资源;不得按标题或临时 token 猜测关联。
|
||||
8. 按 `command_family` 保存完整、不可变的 `command_args`;不得把 Wiki 底层对象 token 当作 Wiki 节点移动 token。
|
||||
9. 为每个 `move_resource` 项复制执行前恢复所需的完整 `rollback_input`,使确认计划不依赖运行时回查 `ResourceItem`。
|
||||
10. 当前父级无法结构化解析或属于 Drive / Wiki 跨容器移动时,设置 `rollback_supported=false` 和明确 `rollback_blocker`;该单项仍可进入确认,但必须逐项展示不可恢复风险,不得阻塞其他独立项。
|
||||
11. 停止并等待用户选择或执行意图。
|
||||
12. 不得为 `move_permission_state!=movable` 或 `target_write_state!=confirmed` 的资源生成 `move_resource` 计划项。
|
||||
|
||||
### 已在目标位置判定
|
||||
|
||||
1. `drive_move` 比较 `current_parent_kind` 和目标 Drive 父级,并比较规范化后的 `current_parent_token` / root 标识。
|
||||
2. `wiki_move_node` 比较 `current_parent_space_id`、`current_parent_kind` 和 `current_parent_token`;Wiki 空间根节点使用明确的 root 标识,不得用空字符串和未知状态混淆。
|
||||
3. 只有父级类型、space ID(适用时)和 token 都已解析且相等时,才能设置 `skip_reason=already_at_target`;父级未知时不得猜测为相等。
|
||||
|
||||
### 移动 token 选择
|
||||
|
||||
| `command_family` | `command_args` 必须包含 |
|
||||
|------------------|---------------------------|
|
||||
| `drive +move` | `file_token`、`type`、`folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空且目标类型为 root。 |
|
||||
| `wiki +move`(node) | `node_token`,以及 `target_space_id` 或 `target_parent_token`;可选 `source_space_id`。不得使用 `wiki_obj_token` 代替 `node_token`。 |
|
||||
| `wiki +move`(docs-to-wiki) | `obj_type`、`obj_token`、`target_space_id`、可选 `target_parent_token`,并显式保存 `apply=false`。 |
|
||||
| `wiki +move-to-drive` | `node_token`、`folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空。 |
|
||||
| `drive +create-folder` | `name`、父级 `folder_token`;创建在 Drive root 时显式记录父级为空。 |
|
||||
| `wiki +node-create` | `space_id`、`title`、`obj_type`、可选 `parent_node_token`。 |
|
||||
| `none` | 不执行命令,保留 `skip_reason`。 |
|
||||
|
||||
目标由本次 workflow 创建时,对应目标参数保存 `created_by_plan:<create_target plan_id>` 引用。`EXECUTE` 只允许把该引用替换为对应创建计划返回的 token;不得重新搜索或猜测目标。
|
||||
|
||||
### 计划 UI
|
||||
|
||||
```text
|
||||
移动计划已生成:
|
||||
- 默认将移动高相关:N 项
|
||||
- 你已选择中相关:N 项
|
||||
- 其中不可自动恢复:N 项
|
||||
- 已在目标位置:N 项
|
||||
- 不会移动:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
|
||||
你可以回复“确认执行”,也可以继续调整分组、增减中相关资源,或取消本次移动。
|
||||
```
|
||||
|
||||
## MovePlanItem
|
||||
|
||||
```json
|
||||
{
|
||||
"plan_id": "稳定计划项 ID",
|
||||
"resource_id": "对应 ResourceItem.resource_id;create_target 为空",
|
||||
"action_type": "create_target|move_resource|skip_resource|unsupported",
|
||||
"title": "资源或目标名称",
|
||||
"resource_type": "源资源类型",
|
||||
"move_method": "drive_move|wiki_move_node|wiki_move_docs_to_wiki|wiki_move_to_drive|none",
|
||||
"command_family": "具体 shortcut 命令或 none",
|
||||
"command_args": {
|
||||
"<arg>": "按 command_family 参数表保存的完整、类型明确的参数"
|
||||
},
|
||||
"source_path": "用户确认时展示的源位置",
|
||||
"target_path": "用户确认时展示的目标位置",
|
||||
"move_permission_state": "movable|denied|unknown|not_required",
|
||||
"target_write_state": "confirmed|unknown|denied",
|
||||
"reason": "纳入或跳过原因",
|
||||
"skip_reason": "already_at_target 或其他跳过原因",
|
||||
"rollback_input": {
|
||||
"source_kind": "drive|wiki",
|
||||
"original_token": "原始 Drive / obj token",
|
||||
"original_node_token": "原始 Wiki node token",
|
||||
"resource_type": "恢复命令需要的资源类型",
|
||||
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
|
||||
"original_parent_token": "原始父级 token",
|
||||
"original_space_id": "原始 Wiki space_id",
|
||||
"original_path": "执行前路径"
|
||||
},
|
||||
"rollback_supported": "是否支持自动恢复",
|
||||
"rollback_blocker": "不可自动恢复原因",
|
||||
"execution_status": "pending|success|failed|skipped"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `plan_id` | 稳定计划项 ID,用于连接计划、快照和执行日志。 |
|
||||
| `resource_id` | 稳定资源 ID,用于连接确认计划和解析结果;`create_target` 为空。执行阶段不得依赖该关联回查可变参数。 |
|
||||
| `action_type` | 计划动作类型。 |
|
||||
| `move_method` | 实际使用的移动方式。 |
|
||||
| `command_family` / `command_args` | 用户确认的完整写命令及参数快照;确认后保持不可变。目标待创建时只允许使用 `created_by_plan:<plan_id>` 引用。 |
|
||||
| `move_permission_state` / `target_write_state` | 用户确认时的权限门禁快照;`move_resource` 必须分别为 `movable` / `confirmed`。`create_target` 的移动权限为 `not_required`,但父级写入权限仍必须为 `confirmed`。 |
|
||||
| `rollback_input` | 从 `ResourceItem` 复制出的完整恢复输入;仅 `move_resource` 必填,生成确认计划后不得再回查或猜测。 |
|
||||
| `rollback_supported` | 是否支持自动恢复。 |
|
||||
| `rollback_blocker` | 不可自动恢复原因;跨容器移动使用 `cross_container_permission_model_not_losslessly_restorable`,原父级 token 缺失使用 `original_parent_token_unavailable`。 |
|
||||
| `execution_status` | 执行状态。 |
|
||||
@@ -0,0 +1,174 @@
|
||||
# 主题资料收集工作流:输入与目标确认
|
||||
|
||||
由状态 `PARSE_INPUT`、`RESOLVE_TARGET`、`CONFIRM_CONTEXT` 加载。
|
||||
|
||||
本文档负责用户输入解析、目标位置解析、搜索前确认和 `TargetLocation`。不得执行搜索召回、资源分类、目标创建或资源移动。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档后必须确认 `workflow_id=topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
|
||||
2. 解析 Drive 目标时,遵循 [`lark-drive-inspect.md`](lark-drive-inspect.md)、[`lark-drive-create-folder.md`](lark-drive-create-folder.md) 和 [`lark-drive-search.md`](lark-drive-search.md)。
|
||||
3. 解析 Wiki 目标时,遵循 [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)、[`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 和 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md)。
|
||||
|
||||
## 状态:`PARSE_INPUT`
|
||||
|
||||
进入条件:workflow 被触发。
|
||||
|
||||
必须:
|
||||
|
||||
1. 提取 `topic`、`target`、`identity`、`owner_scope` 和 `constraints`。
|
||||
2. 将 `topic` 和 `target` 视为必填字段。
|
||||
3. 除非用户明确要求 bot / app 视角,否则 `identity` 默认使用用户身份。
|
||||
4. 默认 `allow_cross_container_move=true`,但必须在 `CONFIRM_CONTEXT` 展示。
|
||||
5. 默认 `owner_scope=mine`,表示只搜索当前用户 owner / 负责的资源。
|
||||
6. 只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时,才设置 `owner_scope=all_visible`。
|
||||
7. 除非用户明确提供限制,否则 `constraints` 保持为空。
|
||||
8. 如果缺少 `topic` 或 `target`,只提出最小澄清问题。
|
||||
|
||||
### 输入字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `topic` | 用户要查找的主题、关键词、内容线索、同义词、缩写、排除词。 |
|
||||
| `target` | 归档目标,可以是已有 Drive 文件夹、已有 Wiki 节点、待创建 Drive 文件夹或待创建 Wiki 节点。 |
|
||||
| `identity` | 执行身份,默认 `--as user`。 |
|
||||
| `owner_scope` | 搜索 owner 范围,默认 `mine`;`all_visible` 仅在用户明确要求扩展到所有可见资源时使用。 |
|
||||
| `constraints` | 用户显式给出的类型、时间、创建人、评论、标题、范围等限制。 |
|
||||
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须确认。 |
|
||||
|
||||
### 澄清模板
|
||||
|
||||
```text
|
||||
我还需要补齐两个信息后才能开始:
|
||||
|
||||
1. 要查找的主题 / 关键词 / 内容线索是什么?
|
||||
2. 找到后要移动到哪个 Drive 文件夹或 Wiki 节点?如果需要新建目标,也请说明父级位置和新名称。
|
||||
```
|
||||
|
||||
## 状态:`RESOLVE_TARGET`
|
||||
|
||||
进入条件:`topic` 和 `target` 已获得。
|
||||
|
||||
必须:
|
||||
|
||||
1. 将已有目标解析为具体 token。
|
||||
2. 如果目标需要创建,只解析父级位置和新目标名称。
|
||||
3. 在本状态中不得创建文件夹或 Wiki 节点。
|
||||
4. 分别保留 Drive 文件夹 token、Wiki 节点 token、Wiki 对象 token、space ID 和 parent token。
|
||||
5. 如果目标 URL / token 存在,但当前身份无法读取或解析目标位置,设置 `target_resolve_status=permission_denied`,保持在 `RESOLVE_TARGET` 并等待用户更换目标或结束;不得进入搜索。
|
||||
6. 如果已知移动方向不支持,尽早标记。
|
||||
|
||||
### 目标解析
|
||||
|
||||
| 条件 | agent 必须执行 | 设置 `target_type` |
|
||||
|-----------|---------------|-------------------|
|
||||
| 已有 Drive 文件夹 URL 或 token | 有 URL 时用 `drive +inspect` 解析;保留 `folder_token` | `drive_folder` |
|
||||
| 已有 Wiki 节点 URL 或 token | 用 `wiki +node-get` 或 `drive +inspect` 解析;保留 `wiki_node_token` 和 `space_id` | `wiki_node` |
|
||||
| 在已知父级下新建 Drive 文件夹 | 解析父文件夹;保存新文件夹名称;不创建 | `new_drive_folder` |
|
||||
| 在已知父级下新建 Wiki 节点 | 解析知识空间和可选父节点;保存新节点标题;不创建 | `new_wiki_node` |
|
||||
| 以 Wiki 空间根节点作为目标 | 解析 `space_id`;parent token 可以为空 | `wiki_space` |
|
||||
| 目标名称有歧义 | 仅在必要时搜索或列出候选;展示候选并等待用户选择 | `unknown` |
|
||||
|
||||
### 目标解析状态
|
||||
|
||||
| 条件 | `target_resolve_status` |
|
||||
|------|--------------------------|
|
||||
| 目标已解析,或待创建目标的父级位置已解析 | `resolved` |
|
||||
| 目标名称有歧义、候选不唯一,或 `target_type=unknown` 需要用户选择 | `ambiguous` |
|
||||
| 已知目标方向或目标类型不支持本 workflow | `unsupported` |
|
||||
| 目标 URL / token 存在,但当前身份无权读取、解析或确认目标位置 | `permission_denied` |
|
||||
|
||||
### 目标解析出口门禁
|
||||
|
||||
| `target_resolve_status` | 下一状态 | agent 必须执行 |
|
||||
|-------------------------|----------|----------------|
|
||||
| `resolved` | `CONFIRM_CONTEXT` | 展示已解析目标并进入搜索前确认。 |
|
||||
| `ambiguous` | 保持 `RESOLVE_TARGET` | 展示候选并等待用户选择;不得进入 `CONFIRM_CONTEXT`。 |
|
||||
| `unsupported` | 保持 `RESOLVE_TARGET` | 展示不支持原因,等待用户更换目标或结束;不得搜索。 |
|
||||
| `permission_denied` | 保持 `RESOLVE_TARGET` | 展示权限 blocker,等待用户更换目标或结束;不得搜索。 |
|
||||
|
||||
用户提供新目标后,重新执行 `RESOLVE_TARGET`。只有新的解析结果为 `resolved`,才能进入 `CONFIRM_CONTEXT`;用户选择结束时进入 `DONE`。
|
||||
|
||||
### 跨容器规则
|
||||
|
||||
| 来源 -> 目标 | 默认规则 |
|
||||
|------------------|---------|
|
||||
| Drive 资源 -> Drive 文件夹 | 支持,使用 `drive +move`。 |
|
||||
| Drive 文档类资源 -> Wiki 节点 / 空间 | 资源类型支持时,使用 `wiki +move`。 |
|
||||
| Wiki 节点 -> Wiki 节点 / 空间 | 支持,使用 `wiki +move --node-token`。 |
|
||||
| Wiki 节点 -> Drive 文件夹 | `wiki +move-to-drive`。 |
|
||||
|
||||
## 状态:`CONFIRM_CONTEXT`
|
||||
|
||||
进入条件:`target_resolve_status=resolved`。
|
||||
|
||||
必须:
|
||||
|
||||
1. 展示主题、目标、身份、搜索 owner 范围、限制和目标解析字段。
|
||||
2. 说明下一步只进行搜索 / 读取。
|
||||
3. 说明是否计划创建目标,但尚未执行。
|
||||
4. 展示是否允许跨容器移动。
|
||||
5. 在进入 `SEARCH_RECALL` 前停止并等待用户确认。
|
||||
6. 如果 `owner_scope=all_visible`,明确提示候选数量可能较多,且可能包含无法移动的资源。
|
||||
|
||||
### 确认 UI
|
||||
|
||||
```text
|
||||
我先确认本次收集任务。
|
||||
|
||||
查找主题:
|
||||
目标位置:
|
||||
目标解析:
|
||||
执行身份:
|
||||
搜索范围:
|
||||
可选限制:
|
||||
跨容器移动:
|
||||
下一步操作:只进行搜索和读取验证,不创建目标,不移动资源。
|
||||
|
||||
请确认是否按以上信息开始搜索?
|
||||
```
|
||||
|
||||
默认搜索范围文案:
|
||||
|
||||
```text
|
||||
搜索范围:当前用户 owner / 负责的资源
|
||||
```
|
||||
|
||||
扩展搜索范围文案:
|
||||
|
||||
```text
|
||||
搜索范围:所有当前身份可见资源
|
||||
风险提示:候选数量可能较多,且部分资源可能无法移动;后续仍会经过资源解析和内容验证。
|
||||
```
|
||||
|
||||
如果用户修改任一字段,更新 `topic`、`target_location`、`owner_scope` 或 `constraints`,然后只重新执行受影响的 setup 状态,再次展示确认信息。
|
||||
|
||||
## TargetLocation
|
||||
|
||||
```json
|
||||
{
|
||||
"target_type": "drive_folder|wiki_node|wiki_space|new_drive_folder|new_wiki_node|unknown",
|
||||
"target_token": "已有目标的 folder_token 或 wiki_node_token",
|
||||
"parent_token": "待创建目标的父级 folder_token 或 wiki_node_token",
|
||||
"space_id": "知识库空间 ID",
|
||||
"target_name": "待创建目标名称",
|
||||
"create_required": false,
|
||||
"allow_cross_container_move": true,
|
||||
"target_resolve_status": "resolved|ambiguous|unsupported|permission_denied"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `target_type` | 目标位置类型,用于决定后续创建和移动命令。 |
|
||||
| `target_token` | 已有目标的可执行 token。 |
|
||||
| `parent_token` | 待创建目标的父级位置 token。 |
|
||||
| `space_id` | Wiki 目标所属知识空间 ID。 |
|
||||
| `target_name` | 待创建目标的名称。 |
|
||||
| `create_required` | 是否需要在 `EXECUTE` 阶段创建目标。 |
|
||||
| `allow_cross_container_move` | 是否允许 Drive / Wiki 之间移动。 |
|
||||
| `target_resolve_status` | 目标位置解析状态;不要和 `ResourceItem.item_resolve_status` 混用。 |
|
||||
@@ -0,0 +1,202 @@
|
||||
# 主题资料收集工作流
|
||||
|
||||
Workflow id: `topic_move_collector`
|
||||
|
||||
Risk / Structure: `R2-R3` / `S3`
|
||||
|
||||
本文档实现已注册的主题资料收集 workflow。执行前必须先阅读 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
|
||||
|
||||
本文档负责定义本 workflow 的全局约束、状态机和渐进加载关系。具体阶段规则放在配套文档中,只有进入对应状态时才加载。
|
||||
|
||||
配套文档只是本 workflow 的引用文件,不是独立 skill。不要把用户请求直接路由到某个配套文档。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),用于处理身份、认证、权限和写操作确认规则。
|
||||
|
||||
按阶段渐进加载其他 skill / 引用文档:
|
||||
|
||||
- 目标是 Wiki 或个人文档库:[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)
|
||||
- 需要读取文档内容:[`../../lark-doc/SKILL.md`](../../lark-doc/SKILL.md) 和 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md)
|
||||
- 需要验证 Sheet 内容:[`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md)
|
||||
- 需要 Drive 搜索:[`lark-drive-search.md`](lark-drive-search.md)
|
||||
- 需要资源解析:[`lark-drive-inspect.md`](lark-drive-inspect.md)
|
||||
|
||||
## 适用范围
|
||||
|
||||
本 workflow 用于根据用户给出的主题、关键词或内容线索,在云空间 / 云盘 / Wiki / 电子表格等 Workspace 资源中查找相关资料,并在用户确认后统一移动到指定 Drive 文件夹或 Wiki 节点下。
|
||||
|
||||
适用触发语包括:
|
||||
|
||||
- "帮我找到和某主题相关的文档并放到这个文件夹"
|
||||
- "把所有关于某项目的资料收集到知识库节点下"
|
||||
- "找出包含某内容的资料,确认后移动到新建目录"
|
||||
- "按这个关键词搜索我负责的资料,把相关资料归档"
|
||||
|
||||
默认搜索范围是当前用户 owner / 负责的 Workspace 资源,即 `owner_scope=mine`。只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时,才使用 `owner_scope=all_visible` 进入扩展召回模式。
|
||||
|
||||
不要求用户先限定文件夹或知识库范围。只有用户明确指定范围时,才使用 `--folder-tokens`、`--space-ids` 或其他显式限制。
|
||||
|
||||
## 非目标
|
||||
|
||||
默认不生成:
|
||||
|
||||
- 长篇研究报告
|
||||
- 内容总结文档
|
||||
- Sheet 清单或统计看板
|
||||
- 自动权限治理报告
|
||||
|
||||
默认禁止执行:
|
||||
|
||||
- 未确认前创建文件夹或 Wiki 节点
|
||||
- 未确认前移动资源
|
||||
- 删除资源、重命名资源或修改公开权限
|
||||
- 自动批量申请权限
|
||||
- 把无权限或无法验证的资源加入移动计划
|
||||
- 把移动权限未知或不具备移动资格的资源加入移动计划
|
||||
|
||||
如果用户明确要求把结果写入 Sheet / Doc,切到对应专项能力;本 workflow 的默认产物是移动后的资源归档结果。
|
||||
|
||||
## Agent 执行约束
|
||||
|
||||
触发本 workflow 后,agent 必须:
|
||||
|
||||
1. 按“执行状态机”的顺序执行。
|
||||
2. 维护“运行时状态”中的字段。
|
||||
3. 执行某个状态前,先读取本文档 `## 渐进加载关系` 表格中该状态对应的文档。
|
||||
4. 用户可见说明、字段说明和 UI 文案使用中文。
|
||||
5. 状态名、字段名、枚举值、命令名保留英文稳定标识。
|
||||
6. 将 `CONFIRM_CONTEXT` 和 `CONFIRM_EXECUTION` 作为强用户确认门:前者确认主题、目标位置、身份、搜索范围、可选限制和目标解析结果后才能搜索;后者确认创建目标和移动资源后才能写入。
|
||||
7. 进入 `EXECUTE` 前,不得创建目标文件夹 / 节点,也不得移动资源。
|
||||
8. 必须展示每个相关性分组中的资源名称;低置信分组可以折叠,但必须可查看。
|
||||
9. 默认只移动 `high` 相关资源;`medium` 资源必须由用户显式选择。
|
||||
10. 即使用户可见列表分页展示,也必须维护完整内部状态。
|
||||
11. `RESOURCE_RESOLVE` 和 `CONTENT_VERIFY` 是两个独立的强制阶段,不得合并;不得用搜索结果、标题或摘要直接替代 `CONTENT_VERIFY`,也不得从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。
|
||||
12. 触发后锁定 `workflow_id=topic_move_collector`;执行期间不得自动切换到其他 workflow。
|
||||
13. 如果认为需要切换 workflow,必须停止并向用户说明原因,等待用户确认。
|
||||
14. `RESOURCE_RESOLVE` 是移动资格门禁;只有确认 `move_permission_state=movable` 且 `target_write_state=confirmed` 的资源才能进入默认移动链路。
|
||||
|
||||
## 用户展示 UI 规则
|
||||
|
||||
所有用户可见 UI 都必须包含:
|
||||
|
||||
1. 已经完成的关键结果。
|
||||
2. 下一步会做什么,以及是否会产生写操作。
|
||||
3. 如果 `wait_for_user=true`,明确告诉用户可以选择的动作。
|
||||
4. 如果无需用户操作,明确说明将继续执行,避免用户误以为流程停住。
|
||||
|
||||
典型动作包括:确认继续、修改主题 / 目标 / 限制、展开更多结果、调整相关性分组、选择中相关资源、确认执行、取消执行。
|
||||
|
||||
## 职责边界
|
||||
|
||||
| 文件 | 负责 | 不负责 |
|
||||
|------|------|--------------|
|
||||
| `lark-drive-workflow-topic-move-collector.md` | 触发规则、全局约束、状态机、渐进加载关系、命令族白名单 | 具体阶段规则、UI 模板、执行细节 |
|
||||
| `lark-drive-workflow-topic-move-collector-setup.md` | `PARSE_INPUT`、`RESOLVE_TARGET`、`CONFIRM_CONTEXT`、`TargetLocation` | 搜索执行、相关性分类、写操作 |
|
||||
| `lark-drive-workflow-topic-move-collector-recall.md` | `SEARCH_RECALL`、`RECALL_ENHANCE`、搜索 query 策略、去重、`CandidateItem` | 资源 token 解析、内容验证、写操作 |
|
||||
| `lark-drive-workflow-topic-move-collector-resolve-verify.md` | `RESOURCE_RESOLVE`、`CONTENT_VERIFY`、权限矩阵、`ResourceItem` | 相关性分类、移动计划、写操作 |
|
||||
| `lark-drive-workflow-topic-move-collector-review-plan.md` | `RELEVANCE_CLASSIFY`、`PLAN_MOVE`、`MovePlanItem`、展示分组 | 资源解析、内容验证、写操作执行、恢复 |
|
||||
| `lark-drive-workflow-topic-move-collector-execute.md` | `CONFIRM_EXECUTION`、`EXECUTE`、`VERIFY`、`RESTORE`、`RollbackSnapshotItem`、执行日志 | 搜索、分类和计划 schema |
|
||||
|
||||
## 运行时状态
|
||||
|
||||
本 workflow 扩展共享 Artifact Contract。agent 在一次 workflow 运行中必须维护以下专项内部字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `current_state` | 当前状态机节点。 |
|
||||
| `topic` | 用户确认后的主题、关键词、同义词和排除词。 |
|
||||
| `target_location` | 目标位置解析结果,见 setup 文件的 `TargetLocation`。 |
|
||||
| `identity` | 执行身份;默认优先 `--as user`。 |
|
||||
| `owner_scope` | 搜索 owner 范围;默认 `mine`,仅搜索当前用户 owner / 负责的资源;用户明确要求扩展时才为 `all_visible`。 |
|
||||
| `constraints` | 用户显式确认的类型、时间、创建人、范围等限制。 |
|
||||
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须展示给用户确认。 |
|
||||
| `recall_query_states` | 每个基础 / 增强 query 的分页状态、累计页数、`next_page_token`、`has_more`、完成或阻塞状态。 |
|
||||
| `candidate_items` | 搜索召回结果,包含 query 证据和去重信息。 |
|
||||
| `resource_items` | 解析后的标准资源列表。 |
|
||||
| `content_verify_completed` | 内容验证阶段完成标记;`resource_items` 新建或变化时重置为 `false`,只有全部资源都有验证状态或跳过原因后才设为 `true`。 |
|
||||
| `relevance_groups` | 高相关、中相关、低相关、无权限、无移动权限、移动权限未知、无法验证、不可移动分组。 |
|
||||
| `move_plan_items` | 经用户选择后生成的完整移动计划,包含稳定资源关联、不可变命令参数、权限快照和恢复输入。 |
|
||||
| `execution_journal` | 写操作日志,用于验证和恢复。 |
|
||||
| `rollback_snapshot` | 写操作前位置快照,仅用于失败恢复或用户要求恢复。 |
|
||||
| `display_page_state` | 用户可见列表的分页、筛选和展开状态。 |
|
||||
|
||||
## 执行状态机
|
||||
|
||||
| 状态 | Protocol Step | 进入条件 | agent 必须执行 | 用户可见输出 | `wait_for_user` | 下一状态 |
|
||||
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INPUT` | `route` / `scope` | workflow 被触发 | 加载 setup 文档;解析主题、目标、身份和限制 | 澄清问题或解析摘要 | 必填字段缺失时为 `true` | `RESOLVE_TARGET` |
|
||||
| `RESOLVE_TARGET` | `scope` | 主题和目标已获得 | 解析已有目标,或解析待创建目标;按解析状态分流 | 目标解析结果或 blocker | 非 `resolved` 时为 `true` | `resolved` 时进入 `CONFIRM_CONTEXT`;否则保持本状态 |
|
||||
| `CONFIRM_CONTEXT` | `scope` | `target_resolve_status=resolved` | 展示主题、目标、身份、限制和跨容器设置 | 搜索前确认 UI | `true` | `SEARCH_RECALL` |
|
||||
| `SEARCH_RECALL` | `read` | 用户确认上下文 | 用原始关键词、默认 owner 范围和显式限制执行基础召回;按每批最多 5 页自动续批 | 搜索进度 / 基础统计 | 阻塞时为 `true` | 所有基础 query 完成后进入 `RECALL_ENHANCE` |
|
||||
| `RECALL_ENHANCE` | `read` | 所有基础 query 已完成 | 执行覆盖增强 query,按每批最多 5 页自动续批并合并结果 | 增强召回摘要 | 阻塞时为 `true` | 所有增强 query 完成后进入 `RESOURCE_RESOLVE` |
|
||||
| `RESOURCE_RESOLVE` | `read` | 候选列表已准备 | 解析 token、类型、父级位置、owner 和移动资格 | 解析进度 / 阻塞摘要 | 阻塞时为 `true` | `CONTENT_VERIFY` |
|
||||
| `CONTENT_VERIFY` | `read` | 资源列表已准备 | 对支持的资源做有界内容读取,并为其余资源写入跳过原因 | 验证进度 / 验证摘要 | 阻塞时为 `true` | `RELEVANCE_CLASSIFY` |
|
||||
| `RELEVANCE_CLASSIFY` | `assess` | 证据已准备 | 按相关性和可执行性分组 | 分组结果列表 | `false` | `PLAN_MOVE` |
|
||||
| `PLAN_MOVE` | `assess` / `plan` | 分组完成 | 基于默认规则和用户可选项生成移动计划 | 草案计划和选择项 | `true` | `CONFIRM_EXECUTION` |
|
||||
| `CONFIRM_EXECUTION` | `confirm` | 用户要求执行 | 展示创建、移动、跳过项和风险 | 写操作确认 UI | `true` | `EXECUTE` 或 `PLAN_MOVE` 或 `DONE` |
|
||||
| `EXECUTE` | `execute` | 用户明确确认写操作 | 需要时先创建目标,再移动确认资源 | 执行进度 | 阻塞时为 `true` | `VERIFY` 或 `RESTORE` |
|
||||
| `VERIFY` | `verify` | 执行完成 | 验证目标位置下的移动结果 | 验证结果 | 提供恢复选项时为 `true` | `DONE` 或 `RESTORE` |
|
||||
| `RESTORE` | `recovery confirm` / `recovery execute` | 用户要求恢复 | 仅基于快照和日志恢复 | 恢复确认 / 结果 | 写操作前为 `true` | `VERIFY` 或 `DONE` |
|
||||
| `DONE` | `done` | 无后续操作 | 停止 | 最终回复 | `false` | 结束 |
|
||||
|
||||
### 状态跳转硬约束
|
||||
|
||||
1. `RESOLVE_TARGET` 只有在 `target_resolve_status=resolved` 时才能进入 `CONFIRM_CONTEXT`;`ambiguous`、`unsupported` 或 `permission_denied` 必须保持在 `RESOLVE_TARGET` 并等待用户选择、更换目标或结束。
|
||||
2. `SEARCH_RECALL` 只有在全部基础 query 的 `has_more=false` 时才能进入 `RECALL_ENHANCE`;单批达到 5 页但仍有更多结果时必须自动续批,不得提前跳转。
|
||||
3. `RECALL_ENHANCE` 只有在全部增强 query 的 `has_more=false` 时才能进入 `RESOURCE_RESOLVE`;不得直接进入 `RELEVANCE_CLASSIFY` 或 `PLAN_MOVE`。
|
||||
4. `RESOURCE_RESOLVE` 必须为每个 `CandidateItem` 生成对应的 `ResourceItem`,或生成明确的解析失败 / 权限受限状态。
|
||||
5. `RESOURCE_RESOLVE` 必须为每个 `ResourceItem` 写入 `move_permission_state` 和 `move_permission_basis`;完成后将 `content_verify_completed=false`,下一状态只能是 `CONTENT_VERIFY`。
|
||||
6. 禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使没有任何资源可以读取正文,也必须进入 `CONTENT_VERIFY`,为每项写入验证状态或跳过原因并输出验证摘要。
|
||||
7. `CONTENT_VERIFY` 必须为每个 `ResourceItem` 写入内容证据、搜索证据复用说明,或不可验证原因;移动权限未知或无移动权限的资源可以只写入跳过验证原因。
|
||||
8. 只有当 `resource_items` 已准备、每项都有验证状态或跳过原因,且 `content_verify_completed=true` 时,才能进入 `RELEVANCE_CLASSIFY`。
|
||||
9. 用户调整相关性分组后,必须回到 `RELEVANCE_CLASSIFY` 输出调整后的分组结果,再进入 `PLAN_MOVE` 重新生成计划。
|
||||
|
||||
### Workflow 切换门禁
|
||||
|
||||
只有以下情况允许考虑切换 workflow:
|
||||
|
||||
1. 用户明确说不再做主题资料收集,改为整理整个目录结构或生成盘点方案。
|
||||
2. 当前 workflow 明确无法覆盖用户的新目标。
|
||||
3. 用户要求的是目录结构治理,而不是查找主题相关资料并移动。
|
||||
|
||||
即使满足以上条件,也不得自动切换;必须先向用户说明原因并等待确认。
|
||||
|
||||
## 渐进加载关系
|
||||
|
||||
| 状态 | 必读文档 |
|
||||
|-------|---------------|
|
||||
| `PARSE_INPUT` / `RESOLVE_TARGET` / `CONFIRM_CONTEXT` | [`lark-drive-workflow-topic-move-collector-setup.md`](lark-drive-workflow-topic-move-collector-setup.md) |
|
||||
| `SEARCH_RECALL` / `RECALL_ENHANCE` | [`lark-drive-workflow-topic-move-collector-recall.md`](lark-drive-workflow-topic-move-collector-recall.md) |
|
||||
| `RESOURCE_RESOLVE` / `CONTENT_VERIFY` | [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) |
|
||||
| `RELEVANCE_CLASSIFY` / `PLAN_MOVE` | [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) |
|
||||
| `CONFIRM_EXECUTION` / `EXECUTE` / `VERIFY` / `RESTORE` | [`lark-drive-workflow-topic-move-collector-execute.md`](lark-drive-workflow-topic-move-collector-execute.md) |
|
||||
|
||||
## 命令映射
|
||||
|
||||
| 状态 | 允许的命令族 | 用途 |
|
||||
|-------|--------------------------|---------|
|
||||
| `RESOLVE_TARGET` | `drive +inspect`、`wiki +node-get`、`wiki +space-list`、仅用于查找文件夹候选的 `drive +search` | 解析目标位置 |
|
||||
| `SEARCH_RECALL` / `RECALL_ENHANCE` | `drive +search` | 搜索召回和覆盖增强 |
|
||||
| `RESOURCE_RESOLVE` | `drive +inspect`、`wiki +node-get`、`drive metas batch_query`、必要时 `drive permission.members auth` | 解析标准 token、owner、权限信号和移动资格 |
|
||||
| `CONTENT_VERIFY` | `docs +fetch`、`sheets +read`、`sheets +find`、必要时 `drive +preview` | 验证内容证据 |
|
||||
| `EXECUTE` | `drive +create-folder`、`wiki +node-create`、`drive +move`、`wiki +move`、`wiki +move-to-drive`、`drive +task_result` | 执行已确认写操作 |
|
||||
| `VERIFY` | `drive files list`、`wiki +node-list`、`wiki +node-get`、`drive +inspect`、`drive +task_result` | 验证执行结果 |
|
||||
| `RESTORE` | `drive +move`、`wiki +move`、`drive +delete`、`wiki +node-delete`、`drive +task_result` | 恢复已确认资源并清理本次新建目标 |
|
||||
|
||||
## 引用文档
|
||||
|
||||
- [输入与目标确认](lark-drive-workflow-topic-move-collector-setup.md)
|
||||
- [召回](lark-drive-workflow-topic-move-collector-recall.md)
|
||||
- [资源解析与内容验证](lark-drive-workflow-topic-move-collector-resolve-verify.md)
|
||||
- [审核与计划](lark-drive-workflow-topic-move-collector-review-plan.md)
|
||||
- [执行](lark-drive-workflow-topic-move-collector-execute.md)
|
||||
- [lark-drive-search](lark-drive-search.md)
|
||||
- [lark-drive-inspect](lark-drive-inspect.md)
|
||||
- [lark-drive-move](lark-drive-move.md)
|
||||
- [lark-drive-create-folder](lark-drive-create-folder.md)
|
||||
- [lark-drive-delete](lark-drive-delete.md)
|
||||
- [lark-wiki-move](../../lark-wiki/references/lark-wiki-move.md)
|
||||
- [lark-wiki-move-to-drive](../../lark-wiki/references/lark-wiki-move-to-drive.md)
|
||||
- [lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md)
|
||||
- [lark-wiki-node-delete](../../lark-wiki/references/lark-wiki-node-delete.md)
|
||||
@@ -97,7 +97,7 @@ Structure Level:
|
||||
2. Entry file 超过约 300 行时,优先拆 `commands`、`outputs` 或 `artifacts` reference。
|
||||
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
|
||||
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template,不默认新增独立 workflow。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;`knowledge_organize` 是 `R2-R3/S3`。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;`knowledge_organize` 和 `topic_move_collector` 是 `R2-R3/S3`。
|
||||
|
||||
## 加载与拆分边界
|
||||
|
||||
@@ -108,10 +108,11 @@ Structure Level:
|
||||
|
||||
## Workflow Registry
|
||||
|
||||
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|
||||
|----------|--------|------|-----------|------------|---------|
|
||||
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|
||||
|----------|--------|------|-----------|------------|-----------------------------------------------------------------|
|
||||
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
|
||||
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
|
||||
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
|
||||
| `topic_move_collector` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-topic-move-collector.md`](lark-drive-workflow-topic-move-collector.md) | 按主题、关键词或内容线索跨容器搜索资料,验证相关性和移动资格,并在用户确认后归档到 Drive 文件夹或 Wiki 节点 |
|
||||
|
||||
## Workflow Loading
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
|
||||
"icon": { "tag": "standard_icon", "token": "lark-logo_colorful" },
|
||||
"text_tag_list": [
|
||||
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
|
||||
]
|
||||
|
||||
@@ -105,12 +105,12 @@
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "mail_colorful" }
|
||||
"icon": { "tag": "standard_icon", "token": "calendar_colorful" }
|
||||
}
|
||||
```
|
||||
|
||||
- `token` 从 `resource/icons.md` 按场景选取;彩色图标用 `*_colorful` 后缀,单色用普通名称。
|
||||
- 常用速查:通知 `notice_colorful`、告警 `warning_colorful`、审批 `approve_colorful`、日历 `calendar_colorful`、数据 `chart_colorful`、任务 `todo_colorful`、AI `myai_colorful`。
|
||||
- `token` 必须从 `resource/icons.md` 的精确枚举中选择;禁止根据名称规律自行拼接 token。没有合适的 token 时省略 icon。
|
||||
- 场景速查:日历 `calendar_colorful`、待办 `todo_colorful`、投票 `vote_colorful`、妙记 `file-lark-minutes_colorful`、多维表格 `wiki-bitable_colorful`、表单 `file-form_colorful`、社区 `larkcommunity_colorful`、招聘 `hirelogo_colorful`、飞书品牌 `lark-logo_colorful`、Meego `meego_colorful`、AI `myai_colorful`、aPaaS `apaas_colorful`、审批 `approval_colorful`、通用 AI `ai-common_colorful`。
|
||||
|
||||
### 1. 配色纪律(服务 P6 语义一致)
|
||||
|
||||
@@ -212,7 +212,7 @@ header 有三层能力,**尽量用满**(至少用 `title` + `icon`;`subtit
|
||||
"title": { "tag": "plain_text", "content": "发版审批" },
|
||||
"subtitle": { "tag": "plain_text", "content": "2026-06-25 · 后端服务" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "approve_colorful" },
|
||||
"icon": { "tag": "standard_icon", "token": "approval_colorful" },
|
||||
"text_tag_list": [
|
||||
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "待审批" }, "color": "yellow" }
|
||||
]
|
||||
|
||||
@@ -34,5 +34,19 @@
|
||||
| 通知/铃铛 | `bell_outlined` | 定位 | `pin_outlined` |
|
||||
| 附件 | `attachment_outlined` | 审批 | `approval_outlined` |
|
||||
|
||||
## 彩色图标(精确 token)
|
||||
|
||||
彩色图标必须从下表按**完整字符串**选择,禁止根据名称规律自行拼接。彩色 token 自带颜色,不要再推导其他后缀或变体。
|
||||
|
||||
| 含义 | token | 含义 | token |
|
||||
|---|---|---|---|
|
||||
| 日历 | `calendar_colorful` | 待办 | `todo_colorful` |
|
||||
| 投票 | `vote_colorful` | 飞书妙记 | `file-lark-minutes_colorful` |
|
||||
| 多维表格 | `wiki-bitable_colorful` | 表单 | `file-form_colorful` |
|
||||
| 飞书社区 | `larkcommunity_colorful` | 招聘 | `hirelogo_colorful` |
|
||||
| 飞书品牌 | `lark-logo_colorful` | Meego | `meego_colorful` |
|
||||
| AI | `myai_colorful` | aPaaS | `apaas_colorful` |
|
||||
| 审批 | `approval_colorful` | 通用 AI | `ai-common_colorful` |
|
||||
|
||||
> token 必须与官方完全一致,否则图标不渲染。上表为常用项,全量(数百个,分系统/商务/沟通/用户/媒体/文档等类目)以官方图标库为准:
|
||||
> https://open.larkoffice.com/document/feishu-cards/enumerations-for-icons
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
所有 minutes 命令默认使用 `--as user`。`+detail` 和 `+download` 也支持 `--as bot`(bot 只能访问 bot 有权限的妙记)。
|
||||
所有 minutes 命令默认使用 `--as user`。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ metadata:
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口。**
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口,`summary.warning_count > 0` 时必须先做对应页面的截图复核。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素,并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
|
||||
截图失败则降级到 XML 读回、结构 lint等非截图检查路径。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
2. 用 `slides +xml-get` 回读,确认是否已有部分页面写入。
|
||||
3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`。
|
||||
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 `xml_text_overlap_lint.py`;先修复所有 `error`,再对 `warning` 指向的页面和元素做截图复核。
|
||||
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
|
||||
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
|
||||
|
||||
|
||||
@@ -25,19 +25,32 @@ lark-cli slides +xml-get --as user \
|
||||
--json
|
||||
```
|
||||
|
||||
## Automated XML Text Overlap Lint
|
||||
## Automated XML Layout Lint
|
||||
|
||||
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
|
||||
`slides +xml-get` 保存 XML 后,只运行统一版式准出入口。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
|
||||
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
|
||||
```
|
||||
|
||||
通过标准:
|
||||
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
|
||||
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
准出规则:
|
||||
|
||||
- `summary.error_count > 0` 或 `summary.release_ready == false`:阻断创建、替换或交付,必须先修复。
|
||||
- `summary.warning_count > 0`:静态检查不直接阻断,但 `summary.screenshot_review_required == true`,必须复核对应页面截图。
|
||||
- `slides[].status` 为 `blocked`、`needs_screenshot_review` 或 `passed`,可直接决定逐页后续动作。
|
||||
- CLI 在存在 `error` 时退出码为 1;只有 `warning` 时仍输出 JSON 并退出 0,供截图复核链路继续执行。
|
||||
|
||||
每条 `error` / `warning` 都包含:
|
||||
|
||||
- `element_ids`:相关 XML 元素 ID;
|
||||
- `rule`:规则 ID、名称、阈值和比较关系;
|
||||
- `measurement`:越界量、交叠面积、覆盖率等实测值;
|
||||
- `related_objects`:相关对象的类型与坐标框;
|
||||
- `target`、`message`、`hint`:页码、语义说明和处理建议。
|
||||
|
||||
当 `sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。
|
||||
|
||||
常见 code 的处理方向:
|
||||
|
||||
@@ -51,6 +64,11 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
|
||||
| `icon_transparent_fill_color` | `<icon>` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
|
||||
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
|
||||
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
|
||||
| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 |
|
||||
| `text_container_overlap_risk` | 垂直堆叠的文本容器边界相交,实际文字可能进入相邻文本容器 | 优先拉开文本容器边界;若要保留重叠,必须用服务端截图确认真实渲染结果 |
|
||||
|
||||
## Screenshot QA
|
||||
|
||||
|
||||
@@ -188,6 +188,13 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<shadow>`
|
||||
- `<content>`
|
||||
|
||||
`type` 常用取值:`text`(文本框)、`rect`、`round-rect`(圆角矩形)、`ellipse`(椭圆/圆)、`triangle`、`diamond`、`parallelogram`、`trapezoid`、`custom`(配合 `path` 属性写 SVG 路径串)。箭头、星形、标注气泡、`chevron`、`flow-chart-*` 等更多形状见 XSD `ShapeType` 枚举。
|
||||
|
||||
其它可选属性:
|
||||
|
||||
- `presetHandlers`:控制点,用于圆角等。例如 `<shape type="rect" presetHandlers="60">` = 圆角半径 60px 的圆角矩形;多个控制点用逗号分隔。
|
||||
- `path`:仅 `type="custom"` 时使用,SVG 路径串。
|
||||
|
||||
### line
|
||||
|
||||
```xml
|
||||
@@ -198,6 +205,16 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
|
||||
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`。
|
||||
|
||||
### polyline
|
||||
|
||||
折线 / 曲线连接线,用外接矩形定位(`topLeftX` / `topLeftY` / `width` / `height`),不是端点坐标;`<border>` 必填(无 border 不可见)。`type` 默认 `bent-connector2`(可选 `bent-connector2-5` 折线 / `curved-connector2-5` 曲线)。
|
||||
|
||||
```xml
|
||||
<polyline topLeftX="120" topLeftY="120" width="200" height="100">
|
||||
<border color="rgb(43, 47, 54)" width="2"/>
|
||||
</polyline>
|
||||
```
|
||||
|
||||
### img
|
||||
|
||||
```xml
|
||||
@@ -238,6 +255,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<colgroup>` 直接子元素只有 `<col width="...">`,width 定义列宽,默认 110。
|
||||
- `<tr height="...">` 直接子元素只有 `<td>`,height 定义行高,默认 37。
|
||||
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>`、`<img>`、`<icon>`。
|
||||
- 合并单元格:`<td>` 上用 `colspan`(跨列,默认 1)和 `rowspan`(跨行,默认 1);被合并覆盖的单元格不再写对应 `<td>`。
|
||||
|
||||
表头默认的白底白字视觉效果极差,必须设置背景和文字颜色,需在首行每个 `<td>` 上加 `<fill>`(配合 `bold` 与对比文字色)与正文行区分。
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,13 @@ metadata:
|
||||
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识(GUID),不是客户端展示的任务编号(例如 `t104121` / `suite_entity_num`)。
|
||||
> 对于 Feishu 的任务 applink(例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
|
||||
|
||||
> **从任务清单定位并修改任务的最短路径**:
|
||||
> 1. 已知任务清单 GUID 时直接使用,不要先搜索;已知任务清单 applink 时,取 URL query 中的 `guid` 作为 `tasklist_guid`。
|
||||
> 2. 只有清单名称或关键词、没有 GUID/applink 时,才调用一次 `+tasklist-search` 解析目标清单。
|
||||
> 3. 按原生 API 规则先执行 `lark-cli schema task.tasklists.tasks`,再执行 `lark-cli task tasklists tasks --params '{"tasklist_guid":"<tasklist_guid>"}' --as user`。
|
||||
> 4. 从清单任务结果中取任务的 `guid`,直接传给 `+update` 或 `+complete`;禁止传客户端展示编号(例如 `t104121`)。这两个 shortcut 也可直接接收包含 `guid=` 的任务 applink。
|
||||
> 5. `+update` 返回 `updated_fields` 和每个任务的服务端 `confirmed` 字段;`+complete` 返回 `status`、`completed_at`、`already_completed`。这些字段已确认目标状态时,不要例行追加 `tasks get`;仅在服务端未返回所需字段或用户明确要求完整复核时再查询详情。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-task-create.md) | create a task |
|
||||
|
||||
@@ -9,19 +9,23 @@ Mark a task as completed.
|
||||
```bash
|
||||
# Complete a task
|
||||
lark-cli task +complete --task-id "<task_guid>"
|
||||
|
||||
# A task applink is accepted directly; the CLI extracts its guid query value
|
||||
lark-cli task +complete --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--task-id <guid>` | Yes | The task GUID to complete. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
|
||||
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the task to complete.
|
||||
2. Execute the command.
|
||||
3. Report success.
|
||||
3. Read `data.status`, `data.completed_at`, and `data.already_completed` from the result. `already_completed: true` means the shortcut observed an already-completed task and skipped the PATCH.
|
||||
4. Do not routinely call `task tasks get` when the result already reports `status: done` and a non-zero `completed_at`. Query details only if confirmation fields are absent or the user explicitly asks for a full verification.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
|
||||
@@ -13,6 +13,9 @@ lark-cli task +update --task-id "<task_guid>" --summary "New Summary"
|
||||
# Update multiple tasks' due dates
|
||||
lark-cli task +update --task-id "<task_guid>,<another_task_guid>" --due "+2d"
|
||||
|
||||
# A task applink is accepted directly; the CLI extracts its guid query value
|
||||
lark-cli task +update --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>" --summary "New Summary"
|
||||
|
||||
# Update with JSON data
|
||||
lark-cli task +update --task-id "<task_guid>" --data '{"description": "New description"}'
|
||||
```
|
||||
@@ -21,7 +24,7 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--task-id <guid>` | Yes | The task GUID to update. Comma-separated task GUIDs are supported for multiple tasks. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
|
||||
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Comma-separated GUIDs/applinks are supported for multiple tasks. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
|
||||
| `--summary <text>` | No | New summary/title for the task. |
|
||||
| `--description <text>` | No | New description for the task. |
|
||||
| `--due <time>` | No | New due date (supports relative time). |
|
||||
@@ -31,7 +34,8 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
|
||||
|
||||
1. Confirm with the user the tasks to update and the fields.
|
||||
2. Execute `lark-cli task +update --task-id "..." ...`
|
||||
3. Report the successful updates.
|
||||
3. Read `data.updated_fields` and `data.tasks[].confirmed` from the result and report only the fields confirmed by the server.
|
||||
4. Do not routinely call `task tasks get` after the update when `confirmed` already contains the required state. Query details only if a required field is absent or the user explicitly asks for a full verification.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
所有 vc 命令默认使用 `--as user`。`meeting get`、`+detail`、`+recording`、`+notes` 也支持 `--as bot`(bot 只能访问 bot 有权限的会议、录制和纪要)。`+search` 仅支持 user。
|
||||
所有 vc 命令默认使用 `--as user`。`+search` 和 `meeting get` 也支持 `--as bot`。
|
||||
|
||||
```bash
|
||||
# BAD — 查昨天的会议用 calendar,会漏掉即时会议
|
||||
|
||||
@@ -40,9 +40,9 @@ lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
每次只能指定一种输入方式。同时传入会报错。
|
||||
|
||||
### 2. 身份支持
|
||||
### 2. 仅支持 user 身份
|
||||
|
||||
`--meeting-ids` 和 `--calendar-event-ids` 两种模式都支持 `--as user` 和 `--as bot`。user token 只能查自己有权限的录制;bot 使用 tenant_access_token,只能查 bot 有权限的录制。
|
||||
该命令仅支持 `user` 身份,使用前需完成 `lark-cli auth login`。user token 只能查自己有权限的录制。
|
||||
|
||||
### 3. 批量上限
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。
|
||||
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow;该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
|
||||
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move` 或 `drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
|
||||
62
tests/cli_e2e/apps/apps_file_upload_dryrun_test.go
Normal file
62
tests/cli_e2e/apps/apps_file_upload_dryrun_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
absolutePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||
require.NoError(t, os.WriteFile(absolutePath, []byte("dry-run-input"), 0o600))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+file-upload",
|
||||
"--app-id", "app_x",
|
||||
"--file", absolutePath,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "report.pdf", clie2e.DryRunGet(result.Stdout, "api.0.body.file_name").String())
|
||||
}
|
||||
|
||||
func TestAppsFileUploadDryRun_RejectsMissingHostPath(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "report.pdf")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+file-upload",
|
||||
"--app-id", "app_x",
|
||||
"--file", missingAbsolutePath,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--file", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
}
|
||||
96
tests/cli_e2e/apps/apps_file_upload_live_test.go
Normal file
96
tests/cli_e2e/apps/apps_file_upload_live_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestAppsFileUploadLiveWorkflow(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("LARKSUITE_CLI_CONFIG_DIR")) == "" {
|
||||
t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated live-test config")
|
||||
}
|
||||
appID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_FILE_APP_ID"))
|
||||
if appID == "" {
|
||||
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_FILE_APP_ID to a dedicated app for upload/delete testing")
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("lark-cli-host-path-e2e-%d.txt", time.Now().UnixNano())
|
||||
absolutePath := filepath.Join(t.TempDir(), fileName)
|
||||
content := []byte("host-path-live-e2e")
|
||||
require.NoError(t, os.WriteFile(absolutePath, content, 0o600))
|
||||
|
||||
remotePath := ""
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
if remotePath == "" {
|
||||
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-list", "--app-id", appID, "--name", fileName},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
if listErr != nil || listResult.ExitCode != 0 {
|
||||
clie2e.ReportCleanupFailure(t, "find uploaded file "+fileName, listResult, listErr)
|
||||
return
|
||||
}
|
||||
for _, item := range gjson.Get(listResult.Stdout, "data.items").Array() {
|
||||
if item.Get("file_name").String() == fileName {
|
||||
remotePath = item.Get("path").String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if remotePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-delete", "--app-id", appID, "--path", remotePath},
|
||||
DefaultAs: "user",
|
||||
Yes: true,
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, "delete uploaded file "+remotePath, deleteResult, deleteErr)
|
||||
if deleteErr == nil && deleteResult != nil && gjson.Get(deleteResult.Stdout, "data.results.0.status").String() != "ok" {
|
||||
t.Errorf("cleanup delete did not report success: %s", deleteResult.Stdout)
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-upload", "--app-id", appID, "--file", absolutePath},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
uploadResult.AssertExitCode(t, 0)
|
||||
uploadResult.AssertStdoutStatus(t, true)
|
||||
remotePath = gjson.Get(uploadResult.Stdout, "data.path").String()
|
||||
require.NotEmpty(t, remotePath, "stdout:\n%s", uploadResult.Stdout)
|
||||
assert.Equal(t, fileName, gjson.Get(uploadResult.Stdout, "data.file_name").String(), "stdout:\n%s", uploadResult.Stdout)
|
||||
|
||||
getResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-get", "--app-id", appID, "--path", remotePath},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
return result == nil || result.ExitCode != 0 || gjson.Get(result.Stdout, "data.path").String() != remotePath
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, int64(len(content)), gjson.Get(getResult.Stdout, "data.size_bytes").Int(), "stdout:\n%s", getResult.Stdout)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# Apps CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 18 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
|
||||
- Selected command coverage: 100% (18/18)
|
||||
- API dry-run coverage: 100% (16/16 API-backed commands)
|
||||
- Denominator: 19 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
|
||||
- Selected command coverage: 100% (19/19)
|
||||
- API dry-run coverage: 100% (17/17 API-backed commands)
|
||||
- Local E2E coverage: 100% (2/2 local-only commands)
|
||||
- Live coverage: tracked role workflows are intentionally fixture-gated and skipped by default CI. When run manually with dedicated fixtures, a transient-role lifecycle covers create/get/update, member add/list/`--all` clear, role-presence readback, delete, and target-ID absence readback; shared-fixture workflows separately cover explicit member removal and `+role-match-list`.
|
||||
- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup.
|
||||
|
||||
## Summary
|
||||
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
|
||||
@@ -14,6 +14,8 @@
|
||||
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
|
||||
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
|
||||
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
|
||||
- `TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath`: dry-run validates an absolute local file and derives its basename without uploading it; a missing source path is rejected before preview.
|
||||
- `TestAppsFileUploadLiveWorkflow`: fixture-gated absolute-path upload, `+file-get` readback, and `+file-delete` cleanup in a dedicated app.
|
||||
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
|
||||
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
|
||||
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
|
||||
@@ -23,7 +25,7 @@
|
||||
- `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers.
|
||||
- `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added.
|
||||
|
||||
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Selected role read/member/match live flows intentionally remain fixture-gated and skipped by default because they mutate app role members.
|
||||
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -35,6 +37,7 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps
|
||||
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
|
||||
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
|
||||
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
|
||||
| ✓ | apps +file-upload | shortcut | apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath; apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_RejectsMissingHostPath; apps_file_upload_live_test.go::TestAppsFileUploadLiveWorkflow | `--app-id`, `--file` (absolute or relative local path) | live workflow uses `LARK_CLI_E2E_APPS_FILE_APP_ID`, reads metadata back, and deletes the uploaded file |
|
||||
| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT |
|
||||
| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only |
|
||||
| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |
|
||||
|
||||
40
tests/cli_e2e/base/base_form_submit_dryrun_test.go
Normal file
40
tests/cli_e2e/base/base_form_submit_dryrun_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseFormSubmitDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-submit",
|
||||
"--share-token", "shrXXXX",
|
||||
"--json", `{"fields":{"Rating":5}}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/submit")
|
||||
assert.Contains(t, output, `"share_token"`)
|
||||
assert.Contains(t, output, "shrXXXX")
|
||||
assert.Contains(t, output, `"method": "POST"`)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
@@ -35,7 +36,7 @@ func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
|
||||
"base", "+record-batch-create",
|
||||
"--base-token", baseToken,
|
||||
"--table-id", tableID,
|
||||
"--json", `{"fields":["Name","Status","Score"],"rows":[["alpha","Open",10],["beta","Open",15]]}`,
|
||||
"--json", `{"create_records":[{"Name":"alpha","Status":"Open","Score":10},{"Name":"beta","Status":"Open","Score":15}]}`,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.fields`; `--json.rows` | seeds heterogeneous live workflow records |
|
||||
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
|
||||
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
|
||||
| ✕ | base +record-delete | shortcut | | none | record workflows not covered |
|
||||
| ✓ | base +record-get | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--record-id`; repeated `--field-id`; `--format json` | reads back select and number values after batch update |
|
||||
|
||||
@@ -88,12 +88,6 @@ func SkipWithoutTenantAccessToken(t *testing.T) {
|
||||
if token == "" || appID == "" {
|
||||
t.Skip("skipped: tenant test credentials not set")
|
||||
}
|
||||
|
||||
// Scope standard env credentials to tests that explicitly require a live
|
||||
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
|
||||
// config and dry-run CLI subprocesses from activating the env provider.
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
|
||||
}
|
||||
|
||||
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
|
||||
@@ -245,14 +239,36 @@ func buildCommandEnv(req Request) []string {
|
||||
for k, v := range req.Env {
|
||||
overrides[k] = v
|
||||
}
|
||||
// Keep user-token injection scoped to user-only test commands so bot
|
||||
// commands retain the process-level bot credentials.
|
||||
if req.DefaultAs == "user" {
|
||||
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
|
||||
// Shared TEST_* credentials are fallbacks for explicitly identified live
|
||||
// commands. Existing standard env (including dry-run fixtures) and
|
||||
// per-request overrides always take precedence.
|
||||
switch req.DefaultAs {
|
||||
case "bot":
|
||||
if !hasCredentialEnv(req.Env,
|
||||
"LARKSUITE_CLI_APP_ID",
|
||||
"LARKSUITE_CLI_APP_SECRET",
|
||||
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN",
|
||||
) {
|
||||
appID := os.Getenv("TEST_BOT1_APP_ID")
|
||||
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
|
||||
if appID != "" && token != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
overrides["LARKSUITE_CLI_TENANT_ACCESS_TOKEN"] = token
|
||||
}
|
||||
}
|
||||
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
case "user":
|
||||
if !hasCredentialEnv(req.Env,
|
||||
"LARKSUITE_CLI_APP_ID",
|
||||
"LARKSUITE_CLI_APP_SECRET",
|
||||
"LARKSUITE_CLI_USER_ACCESS_TOKEN",
|
||||
) {
|
||||
appID := os.Getenv("TEST_BOT1_APP_ID")
|
||||
token := os.Getenv("TEST_USER_ACCESS_TOKEN")
|
||||
if appID != "" && token != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range overrides {
|
||||
@@ -272,6 +288,18 @@ func buildCommandEnv(req Request) []string {
|
||||
return env
|
||||
}
|
||||
|
||||
func hasCredentialEnv(requestEnv map[string]string, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if _, ok := requestEnv[key]; ok {
|
||||
return true
|
||||
}
|
||||
if os.Getenv(key) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
|
||||
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
|
||||
if opts.Attempts <= 0 {
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
|
||||
t.Run("accepts shared tenant credentials without mutating standard env", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
@@ -198,8 +198,8 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
|
||||
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
@@ -274,25 +274,65 @@ func TestRunCmd(t *testing.T) {
|
||||
assert.Equal(t, "hello from stdin\n", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("injects user token env only for user commands", func(t *testing.T) {
|
||||
t.Run("injects shared credentials by requested identity", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "tat_test")
|
||||
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
|
||||
|
||||
env := buildCommandEnv(Request{DefaultAs: "user"})
|
||||
env := buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
|
||||
env = buildCommandEnv(Request{DefaultAs: "user"})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
|
||||
env = buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
|
||||
|
||||
env = buildCommandEnv(Request{})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
})
|
||||
|
||||
t.Run("preserves standard dry-run bot credentials", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "dry-run-app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "dry-run-secret")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
|
||||
env := buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=dry-run-app")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_SECRET=dry-run-secret")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
|
||||
})
|
||||
|
||||
t.Run("request env overrides shared bot credentials", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
|
||||
env := buildCommandEnv(Request{
|
||||
DefaultAs: "bot",
|
||||
Env: map[string]string{
|
||||
"LARKSUITE_CLI_APP_ID": "request-app",
|
||||
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN": "",
|
||||
},
|
||||
})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=request-app")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
|
||||
})
|
||||
|
||||
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
|
||||
@@ -44,6 +44,7 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
|
||||
"--doc", docToken,
|
||||
"--doc-format", "markdown",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -37,29 +37,6 @@ func TestMinutesApplyPermission_DryRun(t *testing.T) {
|
||||
assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output)
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_DryRun_BotIdentity(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"minutes", "+apply-permission",
|
||||
"--minute-token", "obcnexampleminute",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/permissions/apply"), "dry-run should contain API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output)
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_InvalidPerm(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMinutesDetailDryRun_BotIdentity pins that `minutes +detail` accepts
|
||||
// --as bot for both the metadata (GetMinuteArtifacts) and transcript
|
||||
// (GetMinuteTranscript) paths, which accept a tenant access token.
|
||||
func TestMinutesDetailDryRun_BotIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
setDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"minutes", "+detail",
|
||||
"--minute-tokens", "obcn1234567890",
|
||||
"--transcript",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Args, "--as")
|
||||
require.Contains(t, result.Args, "bot")
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/minutes/v1/minutes/{minute_token}", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/minutes/v1/minutes/{minute_token}/artifacts", clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
|
||||
|
||||
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"minutes", "+detail", "--help"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
helpResult.AssertExitCode(t, 0)
|
||||
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
|
||||
}
|
||||
@@ -366,6 +366,7 @@ func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suf
|
||||
"--cycle-id", cycleID,
|
||||
"--input", string(inputJSON),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err, "failed to create test objectives")
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -411,6 +412,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
|
||||
"--key-result-id", krID,
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
|
||||
select {
|
||||
@@ -426,6 +428,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
|
||||
"--objective-id", obj.ObjectiveID,
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
|
||||
if i > 0 {
|
||||
@@ -447,6 +450,7 @@ func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suff
|
||||
"--cycle-id", cycleID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err, "failed to create live objective")
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -466,6 +470,7 @@ func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string,
|
||||
"--objective-id", objectiveID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err, "failed to create live key result")
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -499,6 +504,7 @@ func TestOKR_BatchCreateLive(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -544,6 +550,7 @@ func TestOKR_CreateLive_Objective(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -581,6 +588,7 @@ func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -640,6 +648,7 @@ func TestOKR_ReorderLive(t *testing.T) {
|
||||
"--level", "objective",
|
||||
"--ops", string(opsJSON),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -650,6 +659,7 @@ func TestOKR_ReorderLive(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -702,6 +712,7 @@ func TestOKR_WeightLive(t *testing.T) {
|
||||
"--level", "objective",
|
||||
"--weights", string(weightsJSON),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -712,6 +723,7 @@ func TestOKR_WeightLive(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSlidesPresentationAliasesDryRunE2E(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
aliases := []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+xml-get",
|
||||
"--" + alias, "presAliasDryRun",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.0.method").String(), result.Stdout)
|
||||
require.Equal(t,
|
||||
"/open-apis/slides_ai/v1/xml_presentations/presAliasDryRun",
|
||||
gjson.Get(result.Stdout, "data.api.0.url").String(),
|
||||
result.Stdout,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
99
tests/cli_e2e/task/task_id_handling_dryrun_test.go
Normal file
99
tests/cli_e2e/task/task_id_handling_dryrun_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestTaskIDHandlingDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "task_id_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_id_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
run := func(t *testing.T, args []string) *clie2e.Result {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
t.Run("GUID and applink produce equivalent update requests", func(t *testing.T) {
|
||||
guidResult := run(t, []string{
|
||||
"task", "+update", "--task-id", "task-guid-123", "--summary", "updated", "--dry-run",
|
||||
})
|
||||
guidResult.AssertExitCode(t, 0)
|
||||
applinkResult := run(t, []string{
|
||||
"task", "+update", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-123", "--summary", "updated", "--dry-run",
|
||||
})
|
||||
applinkResult.AssertExitCode(t, 0)
|
||||
|
||||
wantURL := "/open-apis/task/v2/tasks/task-guid-123"
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(guidResult.Stdout, "api.0.url").String())
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(applinkResult.Stdout, "api.0.url").String())
|
||||
require.Equal(t, clie2e.DryRunGet(guidResult.Stdout, "api.0.body").Raw, clie2e.DryRunGet(applinkResult.Stdout, "api.0.body").Raw)
|
||||
})
|
||||
|
||||
t.Run("multi-ID update previews every mutation", func(t *testing.T) {
|
||||
result := run(t, []string{
|
||||
"task", "+update",
|
||||
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/task?guid=task-guid-2",
|
||||
"--summary", "updated",
|
||||
"--dry-run",
|
||||
})
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
|
||||
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
|
||||
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-1", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
|
||||
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.1.method").String())
|
||||
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-2", clie2e.DryRunGet(result.Stdout, "api.1.url").String())
|
||||
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.params").Raw, clie2e.DryRunGet(result.Stdout, "api.1.params").Raw)
|
||||
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.body").Raw, clie2e.DryRunGet(result.Stdout, "api.1.body").Raw)
|
||||
})
|
||||
|
||||
t.Run("GUID and applink produce equivalent completion requests", func(t *testing.T) {
|
||||
guidResult := run(t, []string{
|
||||
"task", "+complete", "--task-id", "task-guid-456", "--dry-run",
|
||||
})
|
||||
guidResult.AssertExitCode(t, 0)
|
||||
applinkResult := run(t, []string{
|
||||
"task", "+complete", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-456", "--dry-run",
|
||||
})
|
||||
applinkResult.AssertExitCode(t, 0)
|
||||
|
||||
wantURL := "/open-apis/task/v2/tasks/task-guid-456"
|
||||
for _, result := range []*clie2e.Result{guidResult, applinkResult} {
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.0.url").String())
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.1.url").String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, shortcut := range []string{"+update", "+complete"} {
|
||||
t.Run(shortcut+" rejects display numbers", func(t *testing.T) {
|
||||
args := []string{"task", shortcut, "--task-id", "t12345", "--dry-run"}
|
||||
if shortcut == "+update" {
|
||||
args = append(args, "--summary", "must not be written")
|
||||
}
|
||||
result := run(t, args)
|
||||
result.AssertExitCode(t, 2)
|
||||
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "guid=", "stderr:\n%s", result.Stderr)
|
||||
require.False(t, gjson.Get(result.Stdout, "data.api").Exists(), "invalid input must not emit a dry-run API request\nstdout:\n%s", result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
70
tests/cli_e2e/task/task_id_handling_workflow_test.go
Normal file
70
tests/cli_e2e/task/task_id_handling_workflow_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestTaskIDHandlingWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
originalSummary := "lark-cli-e2e-task-id-original-" + suffix
|
||||
updatedSummary := "lark-cli-e2e-task-id-updated-" + suffix
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"summary": originalSummary,
|
||||
"description": "created by task ID handling workflow",
|
||||
},
|
||||
})
|
||||
taskApplink := "https://applink.larksuite.com/client/todo/task?guid=" + url.QueryEscape(taskGUID)
|
||||
|
||||
t.Run("update accepts task applink", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"task", "+update", "--task-id", taskApplink, "--summary", updatedSummary},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.tasks.0.guid").String(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.tasks.0.confirmed.summary").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("display number is rejected without modifying task", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"task", "+update", "--task-id", "t12345", "--summary", "must-not-be-written-" + suffix},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
assert.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
assert.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
assert.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
|
||||
|
||||
getResult, getErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"task", "tasks", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"task_guid": taskGUID},
|
||||
})
|
||||
require.NoError(t, getErr)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, updatedSummary, gjson.Get(getResult.Stdout, "data.task.summary").String(), "stdout:\n%s", getResult.Stdout)
|
||||
})
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVCDetailDryRun_BotIdentity pins that `vc +detail` accepts --as bot and
|
||||
// previews the meeting.get + recording API round-trip (GetMeetingByID /
|
||||
// GetRecordingByMeetingID both accept a tenant access token).
|
||||
func TestVCDetailDryRun_BotIdentity(t *testing.T) {
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+detail",
|
||||
"--meeting-ids", "7628568141510692381",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Args, "--as")
|
||||
require.Contains(t, result.Args, "bot")
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(out, "api.#").Int(), "stdout:\n%s", out)
|
||||
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}/recording", clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "7628568141510692381", clie2e.DryRunGet(out, "meeting_ids.0").String(), "stdout:\n%s", out)
|
||||
|
||||
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"vc", "+detail", "--help"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
helpResult.AssertExitCode(t, 0)
|
||||
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVCRecordingDryRun_BotIdentity pins that `vc +recording --meeting-ids`
|
||||
// accepts --as bot (GetRecordingByMeetingID accepts a tenant access token).
|
||||
func TestVCRecordingDryRun_BotIdentity(t *testing.T) {
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+recording",
|
||||
"--meeting-ids", "7628568141510692381",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}/recording", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
|
||||
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"vc", "+recording", "--help"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
helpResult.AssertExitCode(t, 0)
|
||||
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
|
||||
}
|
||||
|
||||
// TestVCRecordingDryRun_BotIdentity_CalendarEventIDs pins that the
|
||||
// calendar-event-ids path also flows under --as bot: a bot has a primary
|
||||
// calendar, so the primary -> mget_instance_relation_info -> recording chain
|
||||
// is previewed without a validation error.
|
||||
func TestVCRecordingDryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+recording",
|
||||
"--calendar-event-ids", "evt_001",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Contains(t, out, "mget_instance_relation_info", "stdout:\n%s", out)
|
||||
require.Contains(t, out, "recording", "stdout:\n%s", out)
|
||||
}
|
||||
Reference in New Issue
Block a user